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>
);
}

View File

@@ -0,0 +1,259 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@sase/ui";
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
interface Category {
id: string;
name: string;
children?: Category[];
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
source?: string;
}
interface CategoryColumnsProps {
categories: Category[];
vehicleId: string;
catalogMode?: boolean;
brandName?: string;
variantSearch?: { body?: string; engine?: string; gearbox?: string };
}
export function CategoryColumns({
categories,
vehicleId,
catalogMode,
brandName,
variantSearch,
}: CategoryColumnsProps) {
// columns[0] = root categories, columns[1] = children of selected[0], etc.
const [columns, setColumns] = useState<Category[][]>([categories]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [loadingId, setLoadingId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
// Reset when root categories change
useEffect(() => {
setColumns([categories]);
setSelectedIds([]);
}, [categories]);
// Auto-scroll right when new column added
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
}
}, [columns.length]);
const handleSelect = useCallback(
async (category: Category, columnIndex: number) => {
// Update selected path up to this column, clear deeper selections
setSelectedIds((prev) => {
const next = prev.slice(0, columnIndex);
next[columnIndex] = category.id;
return next;
});
// If already have children in initial data, use them
const knownChildren = category.children;
if (knownChildren && knownChildren.length > 0) {
setColumns((prev) => [...prev.slice(0, columnIndex + 1), knownChildren]);
return;
}
// Lazy fetch
setLoadingId(category.id);
try {
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
const children = data || [];
if (children.length === 0) {
// True leaf — navigate to schema page
navigate({
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) as any,
});
return;
}
setColumns((prev) => [...prev.slice(0, columnIndex + 1), children]);
} catch {
setColumns((prev) => [...prev.slice(0, columnIndex + 1), []]);
} finally {
setLoadingId(null);
}
},
[queryClient, navigate, catalogMode, brandName, vehicleId, variantSearch],
);
if (!categories || categories.length === 0) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>
);
}
return (
<div
ref={scrollRef}
className="flex overflow-x-auto border rounded-md"
style={{ minHeight: 320 }}
>
{columns.map((col, colIdx) => (
<ColumnPanel
key={colIdx}
categories={col}
selectedId={selectedIds[colIdx]}
columnIndex={colIdx}
loadingId={loadingId}
vehicleId={vehicleId}
onSelect={handleSelect}
isLast={colIdx === columns.length - 1}
/>
))}
</div>
);
}
function ColumnPanel({
categories,
selectedId,
columnIndex,
loadingId,
vehicleId,
onSelect,
isLast,
}: {
categories: Category[];
selectedId?: string;
columnIndex: number;
loadingId: string | null;
vehicleId: string;
onSelect: (cat: Category, colIdx: number) => void;
isLast: boolean;
}) {
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
const prefetchedRef = useRef<Set<string>>(new Set());
// Prefetch schema images for leaf categories in this column
useEffect(() => {
prefetchedRef.current.clear();
setImageOverrides(new Map());
const leafs = categories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
!c.schemaImageUrl &&
c.source !== "parts-catalogs",
);
if (leafs.length === 0) return;
const parentId = categories[0]?.parentId;
let cancelled = false;
const BATCH_SIZE = 2;
(async () => {
for (let i = 0; i < leafs.length; i += BATCH_SIZE) {
if (cancelled) break;
const batch = leafs.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
);
for (const c of batch) prefetchedRef.current.add(c.id);
if (!cancelled && parentId) {
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
if (!cancelled && refreshed?.length) {
setImageOverrides((prev) => {
const next = new Map(prev);
for (const r of refreshed) {
if (r.schemaImageUrl) next.set(r.id, r.schemaImageUrl);
}
return next;
});
}
} catch {}
}
}
})();
return () => {
cancelled = true;
};
}, [categories, vehicleId]);
if (categories.length === 0) {
return (
<div
className={cn(
"w-[220px] shrink-0 flex items-center justify-center text-xs text-muted-foreground",
!isLast && "border-r",
)}
>
Sonuç yok
</div>
);
}
return (
<div
className={cn(
"w-[220px] shrink-0 overflow-y-auto",
!isLast && "border-r",
)}
style={{ maxHeight: 420 }}
>
{categories.map((category) => {
const isSelected = selectedId === category.id;
const isLoading = loadingId === category.id;
const Icon = getCategoryIcon(category.name);
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
return (
<button
key={category.id}
type="button"
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent text-accent-foreground font-medium",
category.unavailable && "opacity-40",
)}
onClick={() => onSelect(category, columnIndex)}
>
{schemaImageUrl ? (
<img
src={schemaImageUrl}
alt={category.name}
className="h-6 w-6 shrink-0 object-contain"
/>
) : (
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate">{category.name}</span>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})}
</div>
);
}

View File

@@ -4,44 +4,88 @@
@variant dark (&:where(.dark, .dark *));
@theme {
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-muted: #f5f5f5;
--color-muted-foreground: #737373;
--color-border: #e5e5e5;
--color-input: #e5e5e5;
--color-ring: #0a0a0a;
--color-primary: #0a0a0a;
--color-primary-foreground: #fafafa;
--color-secondary: #f5f5f5;
--color-secondary-foreground: #171717;
--color-accent: #f5f5f5;
--color-accent-foreground: #171717;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
--color-surface: #f5f5f5;
--color-surface-foreground: #171717;
--color-surface-alt: #eaeaea;
--color-card: #ffffff;
--color-card-foreground: #0a0a0a;
--color-popover: #ffffff;
--color-popover-foreground: #0a0a0a;
/* Light theme — warm-tinted neutrals (not pure white/black) */
--color-background: oklch(99.2% 0.003 80);
--color-foreground: oklch(15% 0.01 250);
--color-muted: oklch(96.5% 0.004 80);
--color-muted-foreground: oklch(50% 0.012 250);
--color-border: oklch(91% 0.005 250);
--color-input: oklch(91% 0.005 250);
--color-ring: oklch(15% 0.01 250);
--color-primary: oklch(15% 0.01 250);
--color-primary-foreground: oklch(99.2% 0.003 80);
--color-secondary: oklch(96.5% 0.004 80);
--color-secondary-foreground: oklch(20% 0.01 250);
--color-accent: oklch(96.5% 0.004 80);
--color-accent-foreground: oklch(20% 0.01 250);
--color-destructive: oklch(58% 0.18 28);
--color-destructive-foreground: oklch(99.2% 0.003 80);
--color-surface: oklch(96.5% 0.004 80);
--color-surface-foreground: oklch(20% 0.01 250);
--color-surface-alt: oklch(94% 0.005 80);
--color-card: oklch(99.2% 0.003 80);
--color-card-foreground: oklch(15% 0.01 250);
--color-popover: oklch(99.2% 0.003 80);
--color-popover-foreground: oklch(15% 0.01 250);
/* Brand accent — single semantic token; replaces ad-hoc emerald usage */
--color-brand: oklch(56% 0.13 158);
--color-brand-foreground: oklch(99.2% 0.003 80);
--color-brand-muted: oklch(94% 0.04 158);
--color-brand-soft: oklch(56% 0.13 158 / 0.12);
/* Tinted shadows — carry the cool-neutral hue rather than pure black */
--shadow-sm: 0 1px 2px oklch(15% 0.01 250 / 0.06);
--shadow-md: 0 4px 14px oklch(15% 0.01 250 / 0.08);
--shadow-lg: 0 16px 40px oklch(15% 0.01 250 / 0.12);
--shadow-brand: 0 8px 28px oklch(56% 0.13 158 / 0.18);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.25rem;
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
}
@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background text-foreground antialiased font-sans;
font-feature-settings: "cv11", "ss01", "ss03";
}
/* Fix autofill contrast in dark mode — Chrome/Safari force a light bg on autofilled inputs */
/* Headlines: tighter tracking, balanced wraps (no orphans) */
h1, h2, h3 {
text-wrap: balance;
letter-spacing: -0.025em;
}
h4, h5, p {
text-wrap: pretty;
}
/* Numbers in data contexts use tabular alignment */
.tabular,
[data-tabular],
input[type="number"],
.font-mono {
font-variant-numeric: tabular-nums;
}
/* Skip-to-content link for keyboard users */
.skip-link {
@apply sr-only;
}
.skip-link:focus {
@apply not-sr-only fixed left-4 top-4 z-50 rounded-md bg-foreground px-4 py-2 text-background shadow-lg;
}
/* Fix autofill contrast — Chrome/Safari force a light bg on autofilled inputs */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
@@ -62,26 +106,33 @@
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-scroll-left { animation: scroll-left 30s linear infinite; }
.animate-float { animation: float 3s ease-in-out infinite; }
.animate-fade-in-up { animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-fade-in { animation: fade-in 0.4s ease-out; }
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
.scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
/* Sileo toast: deeper state colors for light mode */
:root {
--sileo-state-success: oklch(0.52 0.24 142);
--sileo-state-success: oklch(0.56 0.13 158);
--sileo-state-error: oklch(0.48 0.26 25);
--sileo-state-warning: oklch(0.58 0.2 70);
--sileo-state-info: oklch(0.50 0.2 237);
@@ -89,32 +140,43 @@
}
[data-sileo-description] {
color: #333;
color: oklch(25% 0.01 250);
}
.dark {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-muted: #262626;
--color-muted-foreground: #a3a3a3;
--color-border: #262626;
--color-input: #262626;
--color-ring: #d4d4d4;
--color-primary: #fafafa;
--color-primary-foreground: #171717;
--color-secondary: #262626;
--color-secondary-foreground: #fafafa;
--color-accent: #262626;
--color-accent-foreground: #fafafa;
--color-destructive: #dc2626;
--color-destructive-foreground: #fafafa;
--color-surface: #1a1a1a;
--color-surface-foreground: #fafafa;
--color-surface-alt: #0f0f0f;
--color-card: #0a0a0a;
--color-card-foreground: #fafafa;
--color-popover: #0a0a0a;
--color-popover-foreground: #fafafa;
/* Dark theme — off-black with cool tint, never pure black */
--color-background: oklch(13% 0.008 250);
--color-foreground: oklch(97% 0.004 80);
--color-muted: oklch(20% 0.008 250);
--color-muted-foreground: oklch(65% 0.012 250);
--color-border: oklch(22% 0.008 250);
--color-input: oklch(22% 0.008 250);
--color-ring: oklch(80% 0.005 250);
--color-primary: oklch(97% 0.004 80);
--color-primary-foreground: oklch(15% 0.01 250);
--color-secondary: oklch(20% 0.008 250);
--color-secondary-foreground: oklch(97% 0.004 80);
--color-accent: oklch(20% 0.008 250);
--color-accent-foreground: oklch(97% 0.004 80);
--color-destructive: oklch(54% 0.20 28);
--color-destructive-foreground: oklch(97% 0.004 80);
--color-surface: oklch(17% 0.008 250);
--color-surface-foreground: oklch(97% 0.004 80);
--color-surface-alt: oklch(15% 0.008 250);
--color-card: oklch(13% 0.008 250);
--color-card-foreground: oklch(97% 0.004 80);
--color-popover: oklch(13% 0.008 250);
--color-popover-foreground: oklch(97% 0.004 80);
--color-brand: oklch(68% 0.16 158);
--color-brand-foreground: oklch(13% 0.008 250);
--color-brand-muted: oklch(28% 0.06 158);
--color-brand-soft: oklch(68% 0.16 158 / 0.16);
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4);
--shadow-md: 0 4px 14px oklch(0% 0 0 / 0.5);
--shadow-lg: 0 16px 40px oklch(0% 0 0 / 0.6);
--shadow-brand: 0 8px 32px oklch(68% 0.16 158 / 0.22);
/* Sileo toast: dark pill/body, light text, subtler shadow */
--sileo-state-loading: oklch(0.7 0 0);
@@ -122,13 +184,13 @@
.dark [data-sileo-pill],
.dark [data-sileo-body] {
fill: #1c1c1e !important;
fill: oklch(17% 0.008 250) !important;
}
.dark [data-sileo-description] {
color: #d4d4d4;
color: oklch(85% 0.005 80);
}
.dark [data-sileo-toast] {
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4));
filter: drop-shadow(0 0 12px oklch(0% 0 0 / 0.4));
}

View File

@@ -23,7 +23,8 @@ export function initPostHog(): void {
_initialized = true;
load().then((ph) => {
ph.init(key, {
api_host: "https://eu.i.posthog.com",
api_host: "https://t.sase.tr",
defaults: "2026-01-30",
person_profiles: "identified_only",
capture_pageview: false,
capture_pageleave: false,

View File

@@ -1,7 +1,9 @@
const STORAGE_KEY = "userSettings";
interface UserSettings {
categoryViewMode?: "grid" | "tree";
categoryViewMode?: "grid" | "tree" | "columns";
modelViewMode?: "grid" | "tree" | "columns";
brandViewMode?: "grid" | "tree" | "columns";
sidebarCollapsed?: boolean;
theme?: "light" | "dark" | "system";
}

View File

@@ -164,10 +164,10 @@
"expired": "Expired"
},
"popular": "Popular",
"trialTitle": "7-Day Full Package Trial",
"trialDescription": "Free access to all brands for 7 days. No credit card required.",
"trialTitle": "30-Day Full Package Trial",
"trialDescription": "Free access to all brands for 30 days. No credit card required.",
"startTrial": "Start Free Trial",
"trialStarted": "Your 7-day Full Package trial has started!",
"trialStarted": "Your 30-day Full Package trial has started!",
"onboarding": {
"provisioning": "Setting up your free trial",
"step1": "Verifying account",
@@ -175,7 +175,7 @@
"step3": "Activating Full Package",
"step4": "Completed!",
"completed": "You can test all catalogs without limits!",
"trialDuration": "7-Day Trial",
"trialDuration": "30-Day Trial",
"startSearching": "Start Searching",
"error": "An error occurred while starting your trial.",
"retry": "Try Again"

View File

@@ -164,10 +164,10 @@
"expired": "Süresi Doldu"
},
"popular": "Popüler",
"trialTitle": "7 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"trialTitle": "30 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"startTrial": "Ücretsiz Denemeyi Başlat",
"trialStarted": "7 günlük Full Paket denemeniz başlatıldı!",
"trialStarted": "30 günlük Full Paket denemeniz başlatıldı!",
"onboarding": {
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
"step1": "Hesap doğrulanıyor",
@@ -175,7 +175,7 @@
"step3": "Full Paket aktif ediliyor",
"step4": "Tamamlandı!",
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
"trialDuration": "7 Gün Deneme",
"trialDuration": "30 Gün Deneme",
"startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene"

View File

@@ -1,10 +1,12 @@
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
import { createRootRouteWithContext, Link, Outlet, useLocation } from "@tanstack/react-router";
import { Toaster } from "@/lib/toast";
import type { QueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { getUserSettings } from "@/lib/user-settings";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@sase/ui";
import { ArrowLeft, Home, Search } from "lucide-react";
interface RouterContext {
queryClient: QueryClient;
@@ -12,8 +14,71 @@ interface RouterContext {
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
notFoundComponent: NotFoundComponent,
});
function NotFoundComponent() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-12">
{/* Ambient brand glow */}
<div className="pointer-events-none absolute -left-32 top-1/4 h-[500px] w-[500px] rounded-full bg-brand/8 blur-[140px]" />
<div className="pointer-events-none absolute -right-32 bottom-1/4 h-[400px] w-[400px] rounded-full bg-brand/5 blur-[120px]" />
<div className="relative max-w-xl text-center">
<p className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
404 — sayfa bulunamadı
</p>
<h1 className="mt-6 font-[family-name:var(--font-display)] text-6xl font-bold tracking-tight sm:text-7xl">
Yanlış parça,
<br />
<span className="text-muted-foreground">yanlış adres.</span>
</h1>
<p className="mx-auto mt-6 max-w-md text-base text-muted-foreground">
Aradığın sayfa silinmiş ya da hiç olmamış olabilir. Aşağıdan ana sayfaya
dönebilir veya doğrudan şase aramaya gidebilirsin.
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link to="/">
<Button variant="outline" className="rounded-full">
<ArrowLeft className="size-4" />
Ana sayfaya dön
</Button>
</Link>
<Link to="/dashboard/search">
<Button variant="brand" className="rounded-full">
<Search className="size-4" />
Şase aramaya git
</Button>
</Link>
</div>
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
<Link
to="/"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<Home className="size-3.5" />
Anasayfa
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/pricing" className="transition-colors hover:text-foreground">
Fiyatlandırma
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/demo" className="transition-colors hover:text-foreground">
Demo
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/contact" className="transition-colors hover:text-foreground">
İletişim
</Link>
</div>
</div>
</main>
);
}
function applyTheme(theme: "light" | "dark" | "system") {
const isDark =
theme === "dark" ||
@@ -59,6 +124,9 @@ function RootComponent() {
return (
<>
<a href="#main-content" className="skip-link">
İçeriğe atla
</a>
<Outlet />
<Toaster position="top-center" />
</>

View File

@@ -9,7 +9,7 @@ function AuthLayout() {
return (
<div className="flex min-h-screen">
{/* Left Panel — Form */}
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
<main id="main-content" className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<Outlet />
@@ -23,17 +23,38 @@ function AuthLayout() {
Sase.tr
</Link>
</div>
</div>
</main>
{/* Right Panel — Promo (always dark, hidden on mobile) */}
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
<div className="flex flex-1 flex-col justify-center space-y-8">
{/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
<div className="dark relative hidden overflow-hidden border-l border-border bg-background text-foreground lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
{/* Brand glow ambient */}
<div className="pointer-events-none absolute -left-32 top-0 h-[500px] w-[500px] rounded-full bg-brand/10 blur-[140px]" />
<div className="pointer-events-none absolute -right-24 bottom-0 h-[400px] w-[400px] rounded-full bg-brand/8 blur-[120px]" />
{/* Subtle grid */}
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
<svg width="100%" height="100%" aria-hidden="true">
<defs>
<pattern id="auth-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-grid)" />
</svg>
</div>
<div className="relative flex flex-1 flex-col justify-center space-y-8">
{/* Heading */}
<div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight">
Doğru Parçayı İlk Seferde Bulun
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
Sase.tr
</span>
<h2 className="font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight">
Doğru parçayı
<br />
<span className="text-foreground/60">ilk seferde bulun.</span>
</h2>
<p className="text-base leading-relaxed text-neutral-400">
<p className="text-base leading-relaxed text-muted-foreground">
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
bulun.
@@ -42,11 +63,11 @@ function AuthLayout() {
{/* Stats */}
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Zap className="size-3" />
1.2sn Sorgu
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<svg
className="size-3"
viewBox="0 0 24 24"
@@ -62,36 +83,36 @@ function AuthLayout() {
</svg>
27 Marka
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Database className="size-3" />
243K+ OEM Parça
<span className="tabular">243K+ OEM Parça</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<ShieldCheck className="size-3" />
%99.9 Uptime
<span className="tabular">%99.9 Uptime</span>
</span>
</div>
{/* Brand row */}
<p className="text-sm text-neutral-500">
<p className="text-sm text-muted-foreground">
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
Hyundai · Ford · Opel · Skoda
</p>
{/* Testimonial */}
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<p className="text-sm leading-relaxed text-neutral-300">
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
<p className="text-sm leading-relaxed text-foreground/85">
&ldquo;Sase.tr&apos;ye geçtiğimizden beri yanlış parça
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
zaman tasarrufu sağlıyoruz.&rdquo;
</p>
<div className="mt-4 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium">
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
MK
</div>
<div>
<p className="text-sm font-medium">Mehmet K.</p>
<p className="text-xs text-neutral-400">
<p className="text-xs text-muted-foreground">
Yedek Parça İşletme Sahibi
</p>
</div>
@@ -100,9 +121,9 @@ function AuthLayout() {
</div>
{/* Bottom trial badge */}
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
<ShieldCheck className="size-4" />
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
<div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
<ShieldCheck className="size-4 text-brand" />
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
</div>
</div>
</div>

View File

@@ -67,9 +67,9 @@ function RegisterPage() {
</p>
{/* Trial messaging */}
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="size-4 shrink-0" />
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
<div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
<ShieldCheck className="size-4 shrink-0 text-brand" />
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
</div>
</div>

View File

@@ -326,7 +326,7 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
<p>
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin —
ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin —
kredi kartı gerektirmez.
</p>
</div>

View File

@@ -103,12 +103,17 @@ function NavLink({
<Link
to={to}
title={collapsed ? label : undefined}
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }}
activeOptions={exact ? { exact: true } : undefined}
onClick={onClick}
>
<Icon className="size-4 shrink-0" />
{/* Left accent bar — only visible when active */}
<span
className={`absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100 ${collapsed ? "hidden" : ""}`}
aria-hidden="true"
/>
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
{!collapsed && <span>{label}</span>}
</Link>
);
@@ -376,7 +381,7 @@ function DashboardLayout() {
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<Outlet />
</main>

View File

@@ -1,10 +1,13 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Skeleton } from "@sase/ui";
import { Library, Lock } from "lucide-react";
import { Skeleton, cn } from "@sase/ui";
import { Button } from "@sase/ui";
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
@@ -21,6 +24,15 @@ interface CatalogBrand {
function CatalogBrandsPage() {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().brandViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("brandViewMode", mode);
};
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
@@ -28,9 +40,41 @@ function CatalogBrandsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
</div>
<div
role="tablist"
aria-label="Görünüm modu"
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
>
{(
[
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
{ mode: "tree" as const, Icon: List, label: "Liste" },
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
]
).map(({ mode, Icon, label }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => changeViewMode(mode)}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
viewMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
title={label}
>
<Icon className="size-3.5" />
</button>
))}
</div>
</div>
{isLoading ? (
@@ -44,36 +88,59 @@ function CatalogBrandsPage() {
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div>
) : (
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
) : viewMode === "tree" ? (
<BrandListTree brands={brands} />
) : (
<BrandListColumns brands={brands} />
)}
</div>
);
}
/* ── Grid card (existing) ── */
function BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation();
if (!brand.hasAccess) {
return (
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none">
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
{/* Diagonal stripe overlay for locked feel */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 8px)",
}}
aria-hidden="true"
/>
<div className="relative mb-2">
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} />
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-muted-foreground/60">
<CarBrandLogo
brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="grayscale opacity-70"
/>
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2.5 text-background" />
</div>
</div>
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
<p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
{t("catalog.locked")}
</p>
<Link
to="/dashboard/subscription"
className="mt-2 text-xs font-medium text-primary hover:underline"
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
>
{t("catalog.upgradeCta")}
<ChevronRight className="size-3" />
</Link>
</div>
);
@@ -84,10 +151,125 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} className="mb-2" />
<CarBrandLogo
brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="mb-2 transition-transform duration-200 group-hover:scale-105"
/>
<p className="text-sm font-semibold">{brand.brandName}</p>
</Link>
);
}
/* ── Tree (flat list) ── */
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
return (
<div className="divide-y rounded-lg border">
{brands.map((brand) => {
if (!brand.hasAccess) {
return (
<div
key={brand.brandName}
className="flex items-center gap-3 px-4 py-3 opacity-50"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
return (
<Link
key={brand.brandName}
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
</div>
);
}
/* ── Columns (left: brand list, right: detail + CTA) ── */
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string | null>(null);
const navigate = useNavigate();
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
return (
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
{/* Left panel */}
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
{brands.map((brand) => (
<button
key={brand.brandName}
type="button"
onClick={() => setSelectedName(brand.brandName)}
disabled={!brand.hasAccess}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
selectedName === brand.brandName && "bg-accent font-medium",
)}
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={20} />
<span className="flex-1 truncate">{brand.brandName}</span>
{brand.hasAccess ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Lock className="size-3 shrink-0 text-muted-foreground" />
)}
</button>
))}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
{selected ? (
<div className="space-y-4">
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
<p className="text-lg font-semibold">{selected.brandName}</p>
{selected.hasAccess ? (
<Button
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName: encodeURIComponent(selected.brandName) },
search: { catalog: undefined },
})
}
>
Modellere Git
</Button>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
<Button variant="outline" asChild>
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft, BookOpen, Car, ChevronRight, Columns2, LayoutGrid, List, Loader2 } from "lucide-react";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
validateSearch: (search: Record<string, unknown>) => ({
@@ -27,6 +31,7 @@ interface CatalogVehicle {
bodyType: string | null;
transmission: string | null;
architecture: string | null;
catalogPath: string | null;
}
function CatalogModelsPage() {
@@ -37,6 +42,15 @@ function CatalogModelsPage() {
const decodedBrandName = decodeURIComponent(brandName);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().modelViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("modelViewMode", mode);
};
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
queryKey: ["catalog-catalogs", decodedBrandName],
@@ -139,10 +153,46 @@ function CatalogModelsPage() {
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
<div className="space-y-3">
{/* View toggle */}
<div className="flex items-center justify-end 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="size-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="Liste"
>
<List className="size-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="size-4" />
</button>
</div>
{viewMode === "grid" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
</div>
) : viewMode === "tree" ? (
<ModelListTree models={models} brandName={brandName} />
) : (
<ModelListColumns models={models} brandName={brandName} />
)}
</div>
)}
</div>
@@ -196,7 +246,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
<Link
to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: undefined }}
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<p className="font-semibold">{model.model}</p>

View File

@@ -1,11 +1,14 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
@@ -36,6 +39,7 @@ export const Route = createFileRoute(
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}),
component: CatalogCategoryPage,
});
@@ -59,7 +63,17 @@ function CatalogCategoryPage() {
const engine = search.engine;
const gearbox = search.gearbox;
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined;
const mgp = search.mgp;
const variantSearch = body || engine || gearbox || mgp ? { body, engine, gearbox, mgp } : undefined;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const { data, isLoading, error } = useQuery({
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
@@ -77,13 +91,13 @@ function CatalogCategoryPage() {
navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId, categoryId: data.parentId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
} else {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
}
};
@@ -111,30 +125,78 @@ function CatalogCategoryPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
</div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</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="size-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="size-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="size-4" />
</button>
</div>
)}
</div>
{/* Content */}
{hasChildren ? (
<CategoryGrid
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
parentId={categoryId}
variantSearch={variantSearch}
/>
viewMode === "grid" ? (
<CategoryGrid
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
parentId={categoryId}
variantSearch={variantSearch}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryColumns
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)
) : (
<Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer

View File

@@ -4,11 +4,13 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
@@ -16,6 +18,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}),
component: CatalogVehiclePage,
});
@@ -38,15 +41,16 @@ function CatalogVehiclePage() {
const body = search.body;
const engine = search.engine;
const gearbox = search.gearbox;
const hasVariant = !!(body || engine || gearbox);
const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp);
const [viewMode, setViewMode] = useState<"grid" | "tree">(
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree") => {
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
@@ -62,19 +66,39 @@ function CatalogVehiclePage() {
"LEGACY_FORD",
"LEGACY_VOLVO",
].includes(vehicle?.architecture);
const isP5WithRestrictions =
vehicle?.architecture === "P5_MODERN" &&
!!vehicle?.catalogPath &&
!vehicle.catalogPath.includes("/mainGroup");
const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector;
const showP5RestrictionSelector = isP5WithRestrictions && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector || showP5RestrictionSelector;
const variantSearch = hasVariant ? { body, engine, gearbox } : undefined;
const variantSearch = hasVariant ? { body, engine, gearbox, mgp } : undefined;
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox],
queryFn: () =>
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`),
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
queryFn: () => {
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
if (mgp) params.set("mgp", mgp);
const qs = params.toString();
return api.get<any[]>(`/catalog/vehicles/${modelId}/categories${qs ? `?${qs}` : ""}`);
},
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
});
const handleP5RestrictionComplete = (mainGroupsPath: string) => {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: { mgp: mainGroupsPath, body: undefined, engine: undefined, gearbox: undefined },
});
};
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
navigate({
@@ -87,6 +111,7 @@ function CatalogVehiclePage() {
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine),
gearbox: norm(selectedGearbox),
mgp: undefined,
},
});
};
@@ -103,21 +128,22 @@ function CatalogVehiclePage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Header / Breadcrumb */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName },
search: { catalog: undefined },
})
}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName },
search: { catalog: undefined },
})
}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
@@ -153,6 +179,34 @@ function CatalogVehiclePage() {
{vehicle?.model}
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
</h1>
</div>
</div>
{/* View toggle — always visible */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Agac"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
</div>
@@ -198,28 +252,14 @@ function CatalogVehiclePage() {
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showFordVariantSelector ? (
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showP5RestrictionSelector ? (
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
) : (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader>
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<List className="size-4" />
</button>
</div>
</CardHeader>
<CardContent>
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
@@ -234,7 +274,7 @@ function CatalogVehiclePage() {
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
) : viewMode === "tree" ? (
<CategoryTree
categories={categoryTree || []}
vehicleId={modelId}
@@ -242,6 +282,14 @@ function CatalogVehiclePage() {
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryColumns
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)}
</CardContent>
</Card>

View File

@@ -309,7 +309,7 @@ function DashboardHome() {
<h3 className="text-lg font-bold">
{subscription.plan?.name ?? "Aktif Plan"}
</h3>
<Badge variant="default" className="bg-emerald-600 text-xs">
<Badge variant="default" className="bg-brand text-xs text-brand-foreground hover:bg-brand/90">
{subscription.status === "trial" ? "Deneme" : "Aktif"}
</Badge>
</div>
@@ -371,7 +371,7 @@ function DashboardHome() {
key={f}
className="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-3.5 text-emerald-500" />
<CheckCircle2 className="size-3.5 text-brand" />
{f}
</span>
))}

View File

@@ -304,7 +304,7 @@ function SearchPage() {
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-muted"
i < vin.length ? "bg-brand" : "bg-muted"
}`}
/>
))}
@@ -398,10 +398,10 @@ function SearchPage() {
)}
{preview && !previewLoading && (
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
<div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
<Car className="size-5 text-emerald-500" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
<Car className="size-5 text-brand" />
</div>
<div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
@@ -414,7 +414,7 @@ function SearchPage() {
<div className="mt-3 flex flex-wrap gap-2">
<Badge
variant="default"
className="bg-emerald-600 text-xs text-white"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
Araç tanımlandı
</Badge>

View File

@@ -296,12 +296,12 @@ function SubscriptionPage() {
if (onboardingPhase === "provisioning") {
return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardContent className="relative flex flex-col items-center gap-6 py-10">
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
<h2 className="text-xl font-bold text-foreground">
{t("subscription.onboarding.provisioning")}
</h2>
</div>
@@ -309,7 +309,7 @@ function SubscriptionPage() {
<Suspense
fallback={
<div className="flex h-[200px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
<Loader2 className="h-8 w-8 animate-spin text-brand" />
</div>
}
>
@@ -326,7 +326,7 @@ function SubscriptionPage() {
{/* If animation finished but mutation still pending */}
{animationEnded && trialMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}...
</div>
@@ -357,20 +357,20 @@ function SubscriptionPage() {
const freshSub = subData?.subscription;
return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardContent className="relative flex flex-col items-center gap-6 py-10">
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
<CheckCircle2 className="h-16 w-16 text-brand" />
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
<h2 className="text-center text-2xl font-bold text-foreground">
{t("subscription.onboarding.completed")}
</h2>
{/* Subscription info box */}
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5">
<div className="w-full max-w-md space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5 backdrop-blur-sm dark:bg-background/30">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
<Badge className="bg-emerald-600 text-white">Full Paket</Badge>
<Badge className="bg-brand text-brand-foreground">Full Paket</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
@@ -387,8 +387,8 @@ function SubscriptionPage() {
<Separator />
<ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
@@ -397,7 +397,7 @@ function SubscriptionPage() {
<Button
size="lg"
className="bg-emerald-600 hover:bg-emerald-700 text-white"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => navigate({ to: "/dashboard/search" })}
>
{t("subscription.onboarding.startSearching")}
@@ -471,7 +471,7 @@ function SubscriptionPage() {
</div>
{subscription.status === "trial" && subscription.endDate && (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
<div className="flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
<Sparkles className="h-4 w-4" />
{(() => {
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
@@ -528,30 +528,30 @@ function SubscriptionPage() {
{/* Trial CTA Card */}
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<Card className="relative overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardHeader className="relative">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
<CardTitle className="text-emerald-900 dark:text-emerald-100">
<Sparkles className="h-5 w-5 text-brand" />
<CardTitle className="text-foreground">
{t("subscription.trialTitle")}
</CardTitle>
</div>
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
<CardDescription className="text-muted-foreground">
{t("subscription.trialDescription")}
</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
<ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => {
startAction("trial-start");
capture("trial_started");

View File

@@ -1,10 +1,12 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCategoryParts } from "@/hooks/use-parts";
import { CategoryGrid } from "@/components/categories/category-grid";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
@@ -49,6 +51,15 @@ function VehicleCategoryPage() {
const hasChildren = data?.children && data.children.length > 0;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const handleBack = () => {
if (data?.parentId) {
navigate({
@@ -66,25 +77,55 @@ function VehicleCategoryPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri don"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">
{data?.name || "Kategori Detayi"}
</h1>
{data?.description && (
<p className="text-sm text-muted-foreground">
{data.description}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri don"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">
{data?.name || "Kategori Detayi"}
</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>
)}
</div>
{/* Error state */}
@@ -99,12 +140,24 @@ function VehicleCategoryPage() {
<CategoryGridFallback />
)}
{/* Parent category — show children grid */}
{/* Parent category — show children */}
{hasChildren && (
<CategoryGrid
categories={data.children!}
vehicleId={id}
/>
viewMode === "grid" ? (
<CategoryGrid
categories={data.children!}
vehicleId={id}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={data.children!}
vehicleId={id}
/>
) : (
<CategoryColumns
categories={data.children!}
vehicleId={id}
/>
)
)}
{/* Leaf category — show schema viewer */}

View File

@@ -4,9 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryColumns } from "@/components/categories/category-columns";
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Button } from "@sase/ui";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
@@ -17,11 +18,11 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
function VehicleDetailPage() {
const { id } = Route.useParams();
const [viewMode, setViewMode] = useState<"grid" | "tree">(
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree") => {
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
@@ -97,6 +98,7 @@ function VehicleDetailPage() {
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Izgara"
>
<LayoutGrid className="h-4 w-4" />
</button>
@@ -109,12 +111,26 @@ function VehicleDetailPage() {
? "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>
</CardHeader>
<CardContent>
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
@@ -126,11 +142,16 @@ function VehicleDetailPage() {
categories={categoryTree || []}
vehicleId={id}
/>
) : (
) : viewMode === "tree" ? (
<CategoryTree
categories={categoryTree || []}
vehicleId={id}
/>
) : (
<CategoryColumns
categories={categoryTree || []}
vehicleId={id}
/>
)}
</CardContent>
</Card>

View File

@@ -124,7 +124,8 @@ function DemoPage() {
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-1.5 rounded-full bg-brand" />
Demo
</span>
<Link to="/register">
@@ -137,7 +138,7 @@ function DemoPage() {
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
{/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<button
@@ -193,7 +194,7 @@ function DemoPage() {
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-border"
i < vin.length ? "bg-brand" : "bg-border"
}`}
/>
))}
@@ -207,9 +208,9 @@ function DemoPage() {
)}
{vinPreview && !vinLoading && (
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6">
<div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
<div className="flex items-center gap-3">
<Car className="size-6 text-emerald-500" />
<Car className="size-6 text-brand" />
<div>
<p className="font-semibold text-foreground">
{vinPreview.make} {vinPreview.model}
@@ -221,7 +222,8 @@ function DemoPage() {
</div>
<Button
onClick={() => setStep("categories")}
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
variant="brand"
className="mt-4 w-full rounded-full"
>
Parça Kataloğuna Devam Et
<ArrowRight className="ml-2 size-4" />
@@ -332,7 +334,7 @@ function DemoPage() {
<div
key={i}
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50"
[2, 5, 9, 13].includes(i) ? "border-brand/50 bg-brand/10 text-brand" : "bg-muted/50"
}`}
>
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
@@ -364,7 +366,7 @@ function DemoPage() {
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
</div>
{idx < 2 ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500">
<span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
Görünür
</span>
) : (
@@ -380,7 +382,7 @@ function DemoPage() {
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
<p className="mt-2 text-sm text-muted-foreground">
7 gün ücretsiz deneyin — kredi kartı gerekmez
30 gün ücretsiz deneyin — kredi kartı gerekmez
</p>
<Link to="/register">
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">

View File

@@ -607,7 +607,7 @@ function HomePage() {
</Link>
<Link to="/register">
<Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
7 Gün Ücretsiz Deneyin
30 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
@@ -677,7 +677,7 @@ function HomePage() {
</Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
7 Gün Ücretsiz Deneyin
30 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
@@ -687,6 +687,7 @@ function HomePage() {
)}
</header>
<main id="main-content">
{/* ─── 2. HERO SECTION ──────────────────────────────────────────── */}
<section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28">
{/* Decorative glow */}
@@ -697,7 +698,10 @@ function HomePage() {
<div className="relative mx-auto max-w-4xl text-center">
{/* Pill badge — loss framing */}
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted px-4 py-1.5 text-xs text-muted-foreground">
<span className="size-2 rounded-full bg-amber-500" />
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive/60 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-destructive" />
</span>
Her yanlış parça siparişi ortalama 450 TL'ye mal olur
</div>
@@ -764,7 +768,7 @@ function HomePage() {
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-border"
i < vin.length ? "bg-brand" : "bg-border"
}`}
/>
))}
@@ -781,9 +785,9 @@ function HomePage() {
)}
{vinPreview && !vinLoading && (
<div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-emerald-500/30 bg-surface p-5 text-left">
<div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-brand/30 bg-surface p-5 text-left">
<div className="flex flex-wrap items-center gap-3">
<Car className="size-5 text-emerald-500" />
<Car className="size-5 text-brand" />
<span className="font-semibold text-foreground">
{vinPreview.make} {vinPreview.model}
</span>
@@ -801,7 +805,8 @@ function HomePage() {
size="sm"
onClick={handleVinSearch}
disabled={decodeLoading}
className="rounded-full bg-emerald-500 text-white hover:bg-emerald-600"
variant="brand"
className="rounded-full"
>
{decodeLoading ? (
<>
@@ -921,7 +926,7 @@ function HomePage() {
key={b}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
<CheckCircle2 className="size-4 shrink-0 text-brand" />
{b}
</li>
))}
@@ -993,7 +998,7 @@ function HomePage() {
<td className="px-6 py-4 text-center text-sm text-muted-foreground">
{row.manual}
</td>
<td className="px-6 py-4 text-center text-sm font-medium text-emerald-500">
<td className="px-6 py-4 text-center text-sm font-medium text-brand">
{row.sase}
</td>
</tr>
@@ -1015,9 +1020,9 @@ function HomePage() {
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">Manuel</p>
<p className="mt-1 text-sm text-muted-foreground">{row.manual}</p>
</div>
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-center">
<p className="text-[10px] font-medium uppercase tracking-wider text-emerald-500/70">Sase.tr</p>
<p className="mt-1 text-sm font-medium text-emerald-500">{row.sase}</p>
<div className="rounded-lg bg-brand/10 p-2.5 text-center">
<p className="text-[10px] font-medium uppercase tracking-wider text-brand/70">Sase.tr</p>
<p className="mt-1 text-sm font-medium text-brand">{row.sase}</p>
</div>
</div>
</div>
@@ -1043,17 +1048,17 @@ function HomePage() {
return (
<div
key={item.title}
className={`rounded-2xl border border-border bg-surface p-6 sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`}
className={`group relative overflow-hidden rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`}
>
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5">
<Icon className="size-5 text-muted-foreground" />
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
<Icon className="size-5" />
</div>
<h3 className="text-lg font-semibold">{item.title}</h3>
<h3 className="text-lg font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{item.description}
</p>
{item.stat && (
<p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold">
<p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
{item.stat}
</p>
)}
@@ -1075,17 +1080,17 @@ function HomePage() {
</div>
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{USE_CASES.map((uc) => {
{USE_CASES.map((uc, idx) => {
const Icon = uc.icon;
return (
<div
key={uc.title}
className="rounded-2xl border border-border bg-surface p-6"
className={`group relative rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx % 2 === 1 ? "lg:translate-y-6" : ""}`}
>
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5">
<Icon className="size-5 text-muted-foreground" />
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
<Icon className="size-5" />
</div>
<h3 className="text-lg font-semibold">{uc.title}</h3>
<h3 className="text-lg font-semibold tracking-tight">{uc.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{uc.description}
</p>
@@ -1120,29 +1125,47 @@ function HomePage() {
</section>
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
<section className="bg-[#09090b] px-4 py-20 text-white sm:px-6">
<div className="mx-auto max-w-7xl">
<div className="text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-neutral-400">
<section className="relative overflow-hidden bg-foreground px-4 py-24 text-background sm:px-6">
{/* Ambient grid pattern — breaks digital flatness */}
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
<svg width="100%" height="100%" aria-hidden="true">
<defs>
<pattern id="stats-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#stats-grid)" />
</svg>
</div>
{/* Brand accent glow */}
<div className="pointer-events-none absolute -left-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/20 blur-[120px]" />
<div className="pointer-events-none absolute -right-32 top-0 h-[300px] w-[300px] rounded-full bg-brand/10 blur-[100px]" />
<div className="relative mx-auto max-w-7xl">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-4 py-1.5 text-xs font-medium text-background/70 backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
Platform
</span>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight text-white sm:text-4xl lg:text-5xl">
Rakamlarla Sase.tr
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Rakamlarla<br />
<span className="text-background/50">Sase.tr</span>
</h2>
</div>
<div className="mt-12 grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4">
<div className="mt-14 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-background/10 bg-background/10 lg:grid-cols-4">
{STATS.map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center sm:p-8"
className="group relative bg-foreground p-7 transition-colors duration-300 hover:bg-background/[0.04] sm:p-9"
>
<p className="font-[family-name:var(--font-display)] text-4xl font-bold text-white sm:text-5xl">
<p className="font-[family-name:var(--font-display)] text-5xl font-bold tracking-tight tabular sm:text-6xl">
{stat.value}
</p>
<p className="mt-2 text-sm text-neutral-400">
<p className="mt-3 text-sm text-background/60">
{stat.label}
</p>
<div className="absolute bottom-0 left-0 h-px w-0 bg-brand transition-all duration-500 group-hover:w-full" />
</div>
))}
</div>
@@ -1244,7 +1267,7 @@ function HomePage() {
key={b}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
<CheckCircle2 className="size-4 shrink-0 text-brand" />
{b}
</li>
))}
@@ -1273,7 +1296,7 @@ function HomePage() {
key={stat.label}
className="rounded-2xl border border-border bg-surface p-6 sm:p-8 text-center"
>
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-emerald-500 sm:text-4xl">
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-brand sm:text-4xl">
{stat.value}
</p>
<p className="mt-2 text-sm text-muted-foreground">
@@ -1297,10 +1320,17 @@ function HomePage() {
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{TESTIMONIALS.map((t) => (
<div key={t.name} className="rounded-2xl border border-border bg-surface p-6">
<div key={t.name} className="group rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]">
<div className="flex gap-0.5">
{Array.from({ length: t.rating }).map((_, i) => (
<span key={i} className="text-amber-400">⭐</span>
<svg
key={i}
viewBox="0 0 20 20"
className="size-3.5 fill-foreground/85"
aria-hidden="true"
>
<path d="M9.05.927c.3-.921 1.603-.921 1.902 0l1.794 5.522a1 1 0 00.95.69h5.806c.969 0 1.371 1.24.588 1.81l-4.696 3.412a1 1 0 00-.363 1.118l1.793 5.522c.3.921-.755 1.688-1.539 1.118l-4.695-3.413a1 1 0 00-1.176 0l-4.695 3.413c-.784.57-1.838-.197-1.539-1.118l1.793-5.522a1 1 0 00-.363-1.118L2.31 8.95c-.783-.57-.38-1.81.588-1.81h5.807a1 1 0 00.95-.69L9.05.927z" />
</svg>
))}
</div>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
@@ -1322,104 +1352,159 @@ function HomePage() {
</section>
{/* ─── 10. PRICING ─────────────────────────────────────────────── */}
<section className="px-4 py-20 sm:px-6">
<section className="px-4 py-24 sm:px-6">
<div className="mx-auto max-w-6xl">
<div className="text-center">
<div className="max-w-2xl">
<SectionBadge>Fiyatlandırma</SectionBadge>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
Günde 7 TL'den Başlayan Fiyatlar
<h2 className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Günde 7 TL'den
<br />
<span className="text-muted-foreground">başlayan fiyatlar.</span>
</h2>
<p className="mx-auto mt-4 max-w-xl text-muted-foreground">
Tek bir yanlış parça iadesinin maliyetinden daha az.
<p className="mt-5 max-w-xl text-base text-muted-foreground">
Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm planlarda 30 gün ücretsiz deneme var, kart bilgisi gerekmez.
</p>
</div>
{/* Horizontally scrollable on mobile, grid on desktop */}
<div className="mt-12 flex gap-4 overflow-x-auto pb-4 sm:grid sm:grid-cols-2 sm:overflow-visible sm:pb-0 lg:grid-cols-4">
{/* Tier tiles — horizontal row, compact, breaks 4-tower symmetry */}
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-5">
{[
{
name: "1 Marka",
description: "Tek marka için yedek parça erişimi",
description: "Tek marka için erişim",
price: "200",
yearly: "2.000",
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
},
{
name: "2 Marka",
description: "İki farklı marka için erişim",
description: "İki farklı marka",
price: "350",
yearly: "3.500",
popular: true,
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
},
{
name: "3 Marka",
description: "Üç marka için kapsamlı erişim",
description: "Üç marka kapsamlı",
price: "500",
yearly: "5.000",
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
},
{
name: "Full Paket",
description: "Tüm markalara sınırsız erişim",
price: "999",
yearly: "9.990",
features: ["Tüm markalar", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek", "OEM parça arama"],
},
].map((plan) => (
].map((plan, idx) => (
<div
key={plan.name}
className={`flex min-w-[260px] flex-1 flex-col rounded-2xl border bg-surface p-6 sm:p-8 ${plan.popular ? "border-2 border-foreground/20" : "border-border"}`}
className={`group relative flex flex-col rounded-2xl border border-border bg-surface p-7 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx === 1 ? "sm:translate-y-3" : ""} ${idx === 2 ? "sm:translate-y-6" : ""}`}
>
{plan.popular && (
<div className="mb-3 inline-flex self-start rounded-full bg-foreground px-3 py-1 text-xs font-medium text-background">
En Popüler
</div>
)}
<h3 className="font-[family-name:var(--font-display)] text-xl font-bold">
{plan.name}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{plan.description}
</p>
<div className="mt-4">
<span className="font-[family-name:var(--font-display)] text-4xl font-bold">
{plan.price} TL
<div className="flex items-baseline justify-between">
<h3 className="font-[family-name:var(--font-display)] text-lg font-semibold">
{plan.name}
</h3>
<span className="text-xs text-muted-foreground tabular">
/ay
</span>
<span className="text-muted-foreground">/ay</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
veya {plan.yearly} TL/yıl
<p className="mt-1 text-sm text-muted-foreground">{plan.description}</p>
<div className="mt-5 flex items-baseline gap-1">
<span className="font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
{plan.price}
</span>
<span className="text-base font-medium text-muted-foreground">TL</span>
</div>
<p className="mt-1 text-xs text-muted-foreground tabular">
yıllık {plan.yearly} TL
</p>
<ul className="mt-6 flex-1 space-y-2 text-sm">
<ul className="mt-6 flex-1 space-y-2.5 text-sm">
{plan.features.map((f) => (
<li
key={f}
className="flex items-center gap-2 text-muted-foreground"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
{f}
<li key={f} className="flex items-start gap-2 text-muted-foreground">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground/40" />
<span>{f}</span>
</li>
))}
</ul>
<Link to="/register" className="mt-6">
<Button
className={`w-full rounded-full ${plan.popular ? "bg-foreground text-background hover:bg-foreground/90" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"}`}
variant={plan.popular ? "default" : "outline"}
>
7 Gün Ücretsiz Deneyin
<Link to="/register" className="mt-7">
<Button variant="outline" className="w-full rounded-full">
30 gün ücretsiz dene
</Button>
</Link>
</div>
))}
</div>
<div className="mt-8 text-center">
{/* Hero plan — Full Paket, asymmetric horizontal card with brand emphasis */}
<div className="mt-8 sm:mt-10">
<div className="relative overflow-hidden rounded-2xl border border-foreground/10 bg-foreground text-background shadow-[var(--shadow-lg)]">
{/* Brand accent glow */}
<div className="pointer-events-none absolute -right-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/30 blur-[120px]" />
<div className="pointer-events-none absolute -left-24 -top-24 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<div className="relative grid gap-8 p-8 sm:p-10 lg:grid-cols-[1.1fr_1fr] lg:gap-12 lg:p-14">
{/* Left: pitch + price */}
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-3 py-1 text-xs font-medium text-background/80 backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
En çok tercih edilen
</div>
<h3 className="mt-5 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl">
Full Paket
</h3>
<p className="mt-2 max-w-md text-background/70">
Tüm markalara sınırsız erişim. Tek bir aboneliğe sığdırdık.
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-[family-name:var(--font-display)] text-6xl font-bold tabular tracking-tight">
999
</span>
<span className="text-lg font-medium text-background/60">TL/ay</span>
</div>
<p className="mt-1 text-sm text-background/50 tabular">
yıllık 9.990 TL — ayda 832 TL'ye denk gelir
</p>
<Link to="/register" className="mt-8 inline-block">
<Button variant="brand" size="lg" className="rounded-full">
30 gün ücretsiz dene
<ArrowRight className="ml-2 size-4" />
</Button>
</Link>
<p className="mt-3 text-xs text-background/50">
Kart bilgisi gerekmez. İstediğin zaman iptal et.
</p>
</div>
{/* Right: feature list, two columns */}
<div className="border-t border-background/10 pt-8 lg:border-l lg:border-t-0 lg:pl-12 lg:pt-0">
<p className="text-xs font-medium uppercase tracking-wider text-background/50">
Pakete dahil
</p>
<ul className="mt-5 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{[
"Tüm 27+ markaya erişim",
"Sınırsız VIN arama",
"OEM parça kataloğu",
"İnteraktif şema görüntüleyici",
"Geçmiş sorgular & favoriler",
"Öncelikli e-posta desteği",
"API erişimi (talep üzerine)",
"Toplu sorgu özelliği",
].map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-background/85">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-brand" />
<span>{f}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
<div className="mt-10 flex items-center justify-center">
<Link
to="/pricing"
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition hover:text-foreground"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/30 hover:text-foreground hover:shadow-sm"
>
Detaylı karşılaştırma
Detaylı karşılaştırmayı gör
<ArrowRight className="size-3.5" />
</Link>
</div>
@@ -1499,6 +1584,7 @@ function HomePage() {
</Link>
</div>
</section>
</main>
{/* ─── 13. FOOTER ───────────────────────────────────────────────── */}
<footer className="bg-background px-4 sm:px-6">
@@ -1595,7 +1681,7 @@ function HomePage() {
&copy; {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground/70">
<span className="size-2 rounded-full bg-emerald-500" />
<span className="size-2 rounded-full bg-brand" />
Tüm servisler aktif
</div>
</div>

View File

@@ -63,7 +63,7 @@ function PricingPage() {
usePageMeta({
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
description:
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.",
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
canonical: "https://sase.tr/pricing",
});
@@ -85,7 +85,7 @@ function PricingPage() {
</div>
</header>
<main className="container mx-auto px-4 py-24">
<main id="main-content" className="container mx-auto px-4 py-24">
<div className="text-center">
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
<p className="mt-4 text-lg text-muted-foreground">