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:
368
apps/web/src/components/catalog/model-list-columns.tsx
Normal file
368
apps/web/src/components/catalog/model-list-columns.tsx
Normal 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;
|
||||
}
|
||||
46
apps/web/src/components/catalog/model-list-tree.tsx
Normal file
46
apps/web/src/components/catalog/model-list-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/catalog/p5-restriction-selector.tsx
Normal file
115
apps/web/src/components/catalog/p5-restriction-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
apps/web/src/components/categories/category-columns.tsx
Normal file
259
apps/web/src/components/categories/category-columns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
“Sase.tr'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.”
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
© {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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user