feat: catalog browser polish + design system refresh + translation pipeline

- catalog: P5 restriction selector flow (mainGroupsPath), grid/tree/columns view modes for brands and models with persisted user settings
- translations: bulk translateMany() path with 1d cache-miss TTL, expanded automotive dictionary; categories.service now drives EN→TR via TranslationsService instead of mapper-side strings
- pcat: migrate auth from v1 JWT to v3 widget tokens (TWS- api-key + supporting X-* headers, IP-bound via DataImpulse proxy)
- pl24: new fetchP5Restrictions() for restriction-level navigation
- subscriptions: trial extended 7 → 30 days
- design: oklch color tokens, brand semantic color, Geist + Instrument Serif fonts, tinted shadows, button "brand" variant with hover-lift, accessible focus rings, skip link, 404 NotFound page, auth layout polish
- nginx: dynamic resolver for Faro upstream
- config: OPENROUTER_API_KEY env (used by emex translate bootstrap script)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 15:54:30 +00:00
parent 14e43bc808
commit f35d64f2be
46 changed files with 2499 additions and 855 deletions

View File

@@ -0,0 +1,368 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Car, ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@sase/ui";
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
/* ── Types ── */
interface CatalogVehicle {
id: string;
brandName: string;
model: string;
year: string | null;
engine: string | null;
bodyType: string | null;
transmission: string | null;
architecture: string | null;
catalogPath: string | null;
}
type ColumnItem =
| { kind: "model"; id: string; label: string; sublabel?: string; vehicle: CatalogVehicle }
| { kind: "restriction"; code: string; name: string; path: string; isFinal: boolean }
| { kind: "category"; id: string; name: string; isLeaf: boolean; children?: any[] };
interface Column {
type: "models" | "restrictions" | "categories";
items: ColumnItem[];
selectedId?: string;
}
/* ── Component ── */
export function ModelListColumns({
models,
brandName,
}: {
models: CatalogVehicle[];
brandName: string;
}) {
const navigate = useNavigate();
const scrollRef = useRef<HTMLDivElement>(null);
const [loadingCol, setLoadingCol] = useState<number | null>(null);
// Track selected vehicle for category navigation and back button
const selectedVehicleRef = useRef<string | null>(null);
const selectedModelLabelRef = useRef<string | null>(null);
const [inCategoryMode, setInCategoryMode] = useState(false);
const modelItems: ColumnItem[] = models.map((m) => ({
kind: "model" as const,
id: m.id,
label: m.model,
sublabel: [m.year, m.engine].filter(Boolean).join(" · ") || undefined,
vehicle: m,
}));
const [columns, setColumns] = useState<Column[]>([
{ type: "models", items: modelItems },
]);
// Reset when models change
useEffect(() => {
setColumns([{ type: "models", items: modelItems }]);
selectedVehicleRef.current = null;
selectedModelLabelRef.current = null;
setInCategoryMode(false);
}, [models]);
const handleBackToModels = useCallback(() => {
setColumns([{ type: "models", items: modelItems }]);
setInCategoryMode(false);
}, [modelItems]);
// Auto-scroll right when new column added
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
}
}, [columns.length]);
const truncateColumns = useCallback((fromIndex: number) => {
setColumns((prev) => prev.slice(0, fromIndex + 1));
}, []);
const setSelectedInColumn = useCallback((colIdx: number, itemId: string) => {
setColumns((prev) =>
prev.map((col, i) => (i === colIdx ? { ...col, selectedId: itemId } : col)),
);
}, []);
/* ── Handlers ── */
const handleModelSelect = useCallback(
async (item: ColumnItem & { kind: "model" }, colIdx: number) => {
const vehicle = item.vehicle;
truncateColumns(colIdx);
setSelectedInColumn(colIdx, item.id);
selectedVehicleRef.current = vehicle.id;
selectedModelLabelRef.current = item.label;
const arch = vehicle.architecture;
const isLegacyVariant = ["LEGACY_PSA", "LEGACY_FORD", "LEGACY_VOLVO"].includes(arch ?? "");
if (isLegacyVariant) {
// Navigate away — legacy brands need full-page variant selectors
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId: vehicle.id },
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
return;
}
const needsP5Restrictions =
arch === "P5_MODERN" &&
!!vehicle.catalogPath &&
!vehicle.catalogPath.includes("/mainGroup");
setLoadingCol(colIdx);
try {
if (needsP5Restrictions) {
// Fetch first restriction level
const data = await api.get<{ options: any[]; isFinal: boolean }>(
`/catalog/vehicles/${vehicle.id}/p5-restrictions`,
);
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
kind: "restriction" as const,
code: o.code,
name: o.name,
path: o.path,
isFinal: data.isFinal,
}));
setColumns((prev) => [
...prev.slice(0, colIdx + 1),
{ type: "restrictions", items },
]);
} else {
// Fetch categories directly
await fetchCategories(vehicle.id, colIdx, undefined);
}
} catch {
// On error, do nothing
} finally {
setLoadingCol(null);
}
},
[brandName, navigate, truncateColumns, setSelectedInColumn],
);
const handleRestrictionSelect = useCallback(
async (item: ColumnItem & { kind: "restriction" }, colIdx: number) => {
truncateColumns(colIdx);
setSelectedInColumn(colIdx, item.code);
const vehicleId = selectedVehicleRef.current;
if (!vehicleId) return;
setLoadingCol(colIdx);
try {
if (item.isFinal) {
// Restrictions complete — fetch categories with mgp
await fetchCategories(vehicleId, colIdx, item.path);
} else {
// More restriction levels
const data = await api.get<{ options: any[]; isFinal: boolean }>(
`/catalog/vehicles/${vehicleId}/p5-restrictions?path=${encodeURIComponent(item.path)}`,
);
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
kind: "restriction" as const,
code: o.code,
name: o.name,
path: o.path,
isFinal: data.isFinal,
}));
setColumns((prev) => [
...prev.slice(0, colIdx + 1),
{ type: "restrictions", items },
]);
}
} catch {
// On error, do nothing
} finally {
setLoadingCol(null);
}
},
[truncateColumns, setSelectedInColumn],
);
const fetchCategories = useCallback(
async (vehicleId: string, _afterColIdx: number, mgp: string | undefined) => {
const mgpParam = mgp ? `?mgp=${encodeURIComponent(mgp)}` : "";
const categories = await api.get<any[]>(
`/catalog/vehicles/${vehicleId}/categories${mgpParam}`,
);
const items: ColumnItem[] = (categories ?? []).map((c: any) => ({
kind: "category" as const,
id: c.id,
name: c.name,
isLeaf: c.children !== undefined && c.children.length === 0,
children: c.children,
}));
// Reset columns — categories start fresh from leftmost column
setColumns([{ type: "categories", items }]);
setInCategoryMode(true);
},
[],
);
const handleCategorySelect = useCallback(
async (item: ColumnItem & { kind: "category" }, colIdx: number) => {
truncateColumns(colIdx);
setSelectedInColumn(colIdx, item.id);
const vehicleId = selectedVehicleRef.current;
if (!vehicleId) return;
if (item.isLeaf) {
// Navigate to schema page
navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId: vehicleId, categoryId: item.id },
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
return;
}
// If children already known from initial data
if (item.children && item.children.length > 0) {
const items: ColumnItem[] = item.children.map((c: any) => ({
kind: "category" as const,
id: c.id,
name: c.name,
isLeaf: c.children !== undefined && c.children.length === 0,
children: c.children,
}));
setColumns((prev) => [
...prev.slice(0, colIdx + 1),
{ type: "categories", items },
]);
return;
}
// Lazy fetch children
setLoadingCol(colIdx);
try {
const children = await api.get<any[]>(`/categories/${item.id}/children`);
if (!children || children.length === 0) {
// Actually a leaf — navigate
navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId: vehicleId, categoryId: item.id },
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
return;
}
const items: ColumnItem[] = children.map((c: any) => ({
kind: "category" as const,
id: c.id,
name: c.name,
isLeaf: c.children !== undefined && c.children.length === 0,
children: c.children,
}));
setColumns((prev) => [
...prev.slice(0, colIdx + 1),
{ type: "categories", items },
]);
} catch {
// On error, do nothing
} finally {
setLoadingCol(null);
}
},
[brandName, navigate, truncateColumns, setSelectedInColumn],
);
const handleItemClick = useCallback(
(item: ColumnItem, colIdx: number) => {
if (item.kind === "model") handleModelSelect(item, colIdx);
else if (item.kind === "restriction") handleRestrictionSelect(item, colIdx);
else if (item.kind === "category") handleCategorySelect(item, colIdx);
},
[handleModelSelect, handleRestrictionSelect, handleCategorySelect],
);
return (
<div className="space-y-2">
{inCategoryMode && (
<button
type="button"
onClick={handleBackToModels}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-3.5" />
<span>{selectedModelLabelRef.current ?? "Modellere Dön"}</span>
</button>
)}
<div
ref={scrollRef}
className="flex border rounded-lg overflow-x-auto"
style={{ minHeight: 320 }}
>
{columns.map((col, colIdx) => (
<div
key={colIdx}
className={cn(
"w-[220px] shrink-0 overflow-y-auto",
colIdx < columns.length - 1 && "border-r",
)}
style={{ maxHeight: 480 }}
>
{col.items.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground">
Sonuç yok
</div>
) : (
col.items.map((item) => {
const itemId = item.kind === "restriction" ? item.code : item.id;
const isSelected = col.selectedId === itemId;
const isLoading = loadingCol === colIdx && isSelected;
return (
<button
key={itemId}
type="button"
onClick={() => handleItemClick(item, colIdx)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground font-medium",
)}
>
<ItemIcon item={item} />
<div className="flex-1 min-w-0">
<p className="truncate">
{item.kind === "model" ? item.label : item.name}
</p>
{item.kind === "model" && item.sublabel && (
<p className="text-xs text-muted-foreground truncate">{item.sublabel}</p>
)}
</div>
{isLoading ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
) : item.kind === "category" && item.isLeaf ? null : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})
)}
</div>
))}
</div>
</div>
);
}
function ItemIcon({ item }: { item: ColumnItem }) {
if (item.kind === "model") {
return <Car className="size-4 shrink-0 text-muted-foreground" />;
}
if (item.kind === "category") {
const Icon = getCategoryIcon(item.name);
return <Icon className="size-4 shrink-0 text-muted-foreground" />;
}
return null;
}

View File

@@ -0,0 +1,46 @@
import { Link } from "@tanstack/react-router";
import { Car, ChevronRight } from "lucide-react";
interface CatalogVehicle {
id: string;
brandName: string;
model: string;
year: string | null;
engine: string | null;
bodyType: string | null;
transmission: string | null;
}
export function ModelListTree({
models,
brandName,
}: {
models: CatalogVehicle[];
brandName: string;
}) {
return (
<div className="divide-y rounded-lg border">
{models.map((model) => (
<Link
key={model.id}
to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{model.model}</p>
<div className="flex flex-wrap gap-x-2 gap-y-0 text-xs text-muted-foreground">
{model.year && <span>{model.year}</span>}
{model.engine && <span>{model.engine}</span>}
{model.bodyType && <span>{model.bodyType}</span>}
{model.transmission && <span>{model.transmission}</span>}
</div>
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { ArrowLeft, Loader2 } from "lucide-react";
interface RestrictionOption {
code: string;
name: string;
path: string;
}
interface P5RestrictionsResponse {
options: RestrictionOption[];
isFinal: boolean;
}
interface P5RestrictionSelectorProps {
vehicleId: string;
onComplete: (mainGroupsPath: string) => void;
}
export function P5RestrictionSelector({ vehicleId, onComplete }: P5RestrictionSelectorProps) {
const [steps, setSteps] = useState<
Array<{ label: string; selectedCode: string; selectedPath: string }>
>([]);
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const { data, isLoading } = useQuery<P5RestrictionsResponse>({
queryKey: ["p5-restrictions", vehicleId, currentPath ?? "initial"],
queryFn: () => {
const pathParam = currentPath
? `?path=${encodeURIComponent(currentPath)}`
: "";
return api.get<P5RestrictionsResponse>(
`/catalog/vehicles/${vehicleId}/p5-restrictions${pathParam}`,
);
},
enabled: !!vehicleId,
});
const options = data?.options ?? [];
const handleSelect = (option: RestrictionOption) => {
if (data?.isFinal) {
// This is the last selection step — option.path leads to mainGroups
onComplete(option.path);
} else {
// More levels needed — advance to next restriction
setSteps((prev) => [
...prev,
{ label: option.name, selectedCode: option.code, selectedPath: option.path },
]);
setCurrentPath(option.path);
}
};
const handleBack = () => {
if (steps.length === 0) return;
const newSteps = steps.slice(0, -1);
setSteps(newSteps);
setCurrentPath(newSteps.length > 0 ? newSteps[newSteps.length - 1].selectedPath : undefined);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Araç Konfigürasyonu</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{steps.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
Geri
</button>
<span className="text-muted-foreground">|</span>
{steps.map((step, i) => (
<span key={step.selectedCode} className="text-muted-foreground">
{i > 0 && " / "}
<span className="font-medium text-foreground">{step.label}</span>
</span>
))}
</div>
)}
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Seçenekler yükleniyor...
</div>
) : options.length === 0 ? (
<p className="text-sm text-muted-foreground">Seçenek bulunamadı</p>
) : (
<div className="flex flex-wrap gap-2">
{options.map((option) => (
<button
key={option.code}
type="button"
onClick={() => handleSelect(option)}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
{option.name}
</button>
))}
</div>
)}
</CardContent>
</Card>
);
}