docs: update INDEX.md + add catalog module, Ford/PSA legacy catalog, shared vehicles

- CatalogModule: VIN-less PL24 catalog browser (brands, models, categories, parts)
  - Supports P5 Modern (REST) and P4 Legacy (Ford, PSA) catalog architectures
  - Ford variant selector (model-year/engine/gearbox), PSA variant selector (body/engine/gearbox)
  - New API endpoints: ford-config, psa-bodies, psa-engines, psa-gearboxes, brands/:name/catalogs
- Shared vehicles: vehicles table decoupled from users via userVehicles junction table
- PL24 Ford Legacy service: comprehensive HTML-scraping for Ford/PSA/Hyundai/Kia/Nissan/Opel/Volvo
- PL24 types and service updated for P4 Legacy brand support
- Categories/parts service updated for dual FK (vehicleId + catalogVehicleId) pattern
- Catalog browser frontend routes and components
- docs/INDEX.md: updated with all new endpoints, components, hooks, routes (2026-03-02)
- docs/pl24-catalog/: per-brand catalog exploration docs
- scripts/migration-shared-vehicles.sql, pl24-catalog-explorer.js, posthog-dashboards.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-02 00:31:15 +00:00
parent f2189a014b
commit 72c0de65de
90 changed files with 12043 additions and 419 deletions

View File

@@ -0,0 +1,175 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Loader2 } from "lucide-react";
interface VariantItem {
code: string;
name: string;
}
interface FordModelConfig {
modelYears: VariantItem[];
engines: VariantItem[];
gearboxes: VariantItem[];
}
interface FordVariantSelectorProps {
vehicleId: string;
onSelect: (modelYear: string, engine: string, gearbox: string) => void;
}
export function FordVariantSelector({ vehicleId, onSelect }: FordVariantSelectorProps) {
const { t } = useTranslation();
const [selectedYear, setSelectedYear] = useState<string | null>(null);
const [selectedEngine, setSelectedEngine] = useState<string | null>(null);
const [selectedGearbox, setSelectedGearbox] = useState<string | null>(null);
const { data: config, isLoading } = useQuery<FordModelConfig>({
queryKey: ["ford-config", vehicleId],
queryFn: () => api.get<FordModelConfig>(`/catalog/vehicles/${vehicleId}/ford-config`),
enabled: !!vehicleId,
});
const modelYears = config?.modelYears ?? [];
const engines = config?.engines ?? [];
const gearboxes = config?.gearboxes ?? [];
const hasYears = modelYears.length > 0;
const hasEngines = engines.length > 0;
const hasGearboxes = gearboxes.length > 0;
const handleYearSelect = (code: string) => {
setSelectedYear(code);
setSelectedEngine(null);
setSelectedGearbox(null);
};
const handleEngineSelect = (code: string) => {
setSelectedEngine(code);
setSelectedGearbox(null);
};
const handleGearboxSelect = (code: string) => {
setSelectedGearbox(code);
};
const hasAny = hasYears || hasEngines || hasGearboxes;
const canProceed =
!hasAny || // No variants available — can always proceed
((!hasYears || !!selectedYear) &&
(!hasEngines || !!selectedEngine) &&
(!hasGearboxes || !!selectedGearbox));
const handleProceed = () => {
if (!canProceed) return;
// When no variants, use "_nor_" so hasVariant=true and variant selector is skipped
onSelect(selectedYear ?? "_nor_", selectedEngine ?? "_nor_", selectedGearbox ?? "_nor_");
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("catalog.fordVariant.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("catalog.fordVariant.loading")}
</div>
) : (
<>
{/* Model Year */}
{hasYears && (
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.fordVariant.modelYear")}</p>
<div className="flex flex-wrap gap-2">
{modelYears.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleYearSelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedYear === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
</div>
</div>
)}
{/* Engine — shown after year selected or if no years */}
{hasEngines && (!hasYears || selectedYear) && (
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.fordVariant.engine")}</p>
<div className="flex flex-wrap gap-2">
{engines.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleEngineSelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedEngine === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
</div>
</div>
)}
{/* Gearbox — shown after engine selected or if no engines */}
{hasGearboxes && (!hasYears || selectedYear) && (!hasEngines || selectedEngine) && (
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.fordVariant.gearbox")}</p>
<div className="flex flex-wrap gap-2">
{gearboxes.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleGearboxSelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedGearbox === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
</div>
</div>
)}
{/* No config available — allow skipping */}
{!hasYears && !hasEngines && !hasGearboxes && (
<p className="text-sm text-muted-foreground">{t("catalog.fordVariant.noConfig")}</p>
)}
{/* Proceed button — only enabled when all required dimensions are selected */}
<div className="flex gap-2">
<Button
onClick={handleProceed}
disabled={!canProceed}
className="w-full sm:w-auto"
>
{t("catalog.fordVariant.proceed")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,209 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Loader2 } from "lucide-react";
interface VariantItem {
code: string;
name: string;
}
interface PsaVariantSelectorProps {
vehicleId: string;
onSelect: (body: string, engine: string, gearbox: string) => void;
}
export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorProps) {
const { t } = useTranslation();
const [selectedBody, setSelectedBody] = useState<string | null>(null);
const [selectedEngine, setSelectedEngine] = useState<string | null>(null);
const [selectedGearbox, setSelectedGearbox] = useState<string | null>(null);
const { data: bodies = [], isLoading: loadingBodies } = useQuery<VariantItem[]>({
queryKey: ["psa-bodies", vehicleId],
queryFn: () => api.get<VariantItem[]>(`/catalog/vehicles/${vehicleId}/psa-bodies`),
enabled: !!vehicleId,
});
const { data: engines = [], isLoading: loadingEngines } = useQuery<VariantItem[]>({
queryKey: ["psa-engines", vehicleId, selectedBody],
queryFn: () =>
api.get<VariantItem[]>(
`/catalog/vehicles/${vehicleId}/psa-engines?body=${encodeURIComponent(selectedBody!)}`,
),
enabled: !!vehicleId && !!selectedBody,
});
const { data: gearboxes = [], isLoading: loadingGearboxes } = useQuery<VariantItem[]>({
queryKey: ["psa-gearboxes", vehicleId, selectedBody, selectedEngine],
queryFn: () =>
api.get<VariantItem[]>(
`/catalog/vehicles/${vehicleId}/psa-gearboxes?body=${encodeURIComponent(selectedBody!)}&engine=${encodeURIComponent(selectedEngine!)}`,
),
enabled: !!vehicleId && !!selectedBody && !!selectedEngine,
});
const handleBodySelect = (code: string | "_all_") => {
if (code === "_all_") {
onSelect("_all_", "_all_", "_all_");
return;
}
setSelectedBody(code);
setSelectedEngine(null);
setSelectedGearbox(null);
};
const handleEngineSelect = (code: string | "_all_") => {
if (code === "_all_") {
onSelect(selectedBody!, "_all_", "_all_");
return;
}
setSelectedEngine(code);
setSelectedGearbox(null);
};
const handleGearboxSelect = (code: string | "_all_") => {
if (code === "_all_") {
onSelect(selectedBody!, selectedEngine!, "_all_");
return;
}
setSelectedGearbox(code);
};
const handleProceed = () => {
if (selectedBody && selectedEngine && selectedGearbox) {
onSelect(selectedBody, selectedEngine, selectedGearbox);
}
};
const canProceed = selectedBody && selectedEngine && selectedGearbox;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("catalog.psaVariant.title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("catalog.psaVariant.subtitle")}</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Body Type */}
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.psaVariant.body")}</p>
{loadingBodies ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("catalog.psaVariant.loading")}
</div>
) : (
<div className="flex flex-wrap gap-2">
{bodies.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleBodySelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedBody === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
<button
type="button"
onClick={() => handleBodySelect("_all_")}
className="rounded-md border border-dashed border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
{t("catalog.psaVariant.showAll")}
</button>
</div>
)}
</div>
{/* Engine */}
{selectedBody && (
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.psaVariant.engine")}</p>
{loadingEngines ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("catalog.psaVariant.loading")}
</div>
) : (
<div className="flex flex-wrap gap-2">
{engines.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleEngineSelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedEngine === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
<button
type="button"
onClick={() => handleEngineSelect("_all_")}
className="rounded-md border border-dashed border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
{t("catalog.psaVariant.showAll")}
</button>
</div>
)}
</div>
)}
{/* Gearbox */}
{selectedBody && selectedEngine && (
<div className="space-y-2">
<p className="text-sm font-medium">{t("catalog.psaVariant.gearbox")}</p>
{loadingGearboxes ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("catalog.psaVariant.loading")}
</div>
) : (
<div className="flex flex-wrap gap-2">
{gearboxes.map((item) => (
<button
key={item.code}
type="button"
onClick={() => handleGearboxSelect(item.code)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
selectedGearbox === item.code
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-accent"
}`}
>
{item.name}
</button>
))}
<button
type="button"
onClick={() => handleGearboxSelect("_all_")}
className="rounded-md border border-dashed border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
{t("catalog.psaVariant.showAll")}
</button>
</div>
)}
</div>
)}
{/* Proceed button */}
{canProceed && (
<Button onClick={handleProceed} className="w-full sm:w-auto">
{t("catalog.psaVariant.proceed")}
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -19,9 +19,13 @@ interface Category {
interface CategoryGridProps {
categories: Category[];
vehicleId: string;
catalogMode?: boolean;
brandName?: string;
parentId?: string;
variantSearch?: { body?: string; engine?: string; gearbox?: string };
}
export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
export function CategoryGrid({ categories, vehicleId, catalogMode, brandName, parentId, variantSearch }: CategoryGridProps) {
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
const prefetchedRef = useRef<Set<string>>(new Set());
@@ -104,8 +108,13 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
return (
<Link
key={category.id}
to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
to={catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"}
params={catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }}
search={catalogMode && variantSearch ? variantSearch : undefined}
className={category.unavailable ? "opacity-40" : undefined}
>
<CategoryCard

View File

@@ -17,21 +17,43 @@ interface Category {
source?: string;
}
export function CategoryTree({ categories, vehicleId }: { categories: Category[]; vehicleId: string }) {
export function CategoryTree({
categories,
vehicleId,
catalogMode,
brandName,
variantSearch,
}: {
categories: Category[];
vehicleId: string;
catalogMode?: boolean;
brandName?: string;
variantSearch?: { body?: string; engine?: string; gearbox?: string };
}) {
if (!categories || categories.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
}
return (
<div className="space-y-1">
{categories.map((cat) => (
<CategoryNode key={cat.id} category={cat} vehicleId={vehicleId} level={0} parentPrefetching={false} />
<CategoryNode
key={cat.id}
category={cat}
vehicleId={vehicleId}
level={0}
parentPrefetching={false}
catalogMode={catalogMode}
brandName={brandName}
variantSearch={variantSearch}
/>
))}
</div>
);
}
function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
function CategoryNode({ category, vehicleId, level, parentPrefetching, catalogMode, brandName, variantSearch }: {
category: Category; vehicleId: string; level: number; parentPrefetching: boolean;
catalogMode?: boolean; brandName?: string; variantSearch?: { body?: string; engine?: string; gearbox?: string };
}) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
@@ -131,8 +153,14 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
)}
<SchemaIcon Icon={Icon} schemaImageUrl={category.schemaImageUrl} name={category.name} shimmer={isShimmering} />
{isLeaf ? (
<Link to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
<Link
to={catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"}
params={catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }}
search={catalogMode && variantSearch ? variantSearch : undefined}
className="flex-1 truncate hover:underline">
{category.name}
</Link>
@@ -150,7 +178,8 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
<div>
{children.map((child) => (
<CategoryNode key={child.id} category={child} vehicleId={vehicleId}
level={level + 1} parentPrefetching={prefetching} />
level={level + 1} parentPrefetching={prefetching}
catalogMode={catalogMode} brandName={brandName} variantSearch={variantSearch} />
))}
</div>
)}

View File

@@ -30,12 +30,52 @@
"nav": {
"search": "Search",
"history": "History",
"catalog": "Catalog",
"subscription": "Subscription",
"billing": "Billing",
"settings": "Settings",
"admin": "Admin Panel",
"logout": "Log Out"
},
"catalog": {
"title": "Parts Catalog",
"brands": "Brands",
"models": "Models",
"allBrands": "All Brands",
"modelCount": "{count} models",
"noBrands": "Catalog data is being prepared",
"noModels": "No models found",
"locked": "This brand is not in your plan",
"upgradeCta": "Upgrade Plan",
"loadingModels": "Loading models...",
"categories": "Categories",
"parts": "Parts",
"backToBrands": "Back to Brands",
"backToModels": "Back to Models",
"backToCategories": "Back to Categories",
"selectCatalog": "Select a catalog",
"psaVariant": {
"title": "Select Vehicle Variant",
"subtitle": "Optional — use Show All to browse all variants",
"body": "Body Type",
"engine": "Engine",
"gearbox": "Gearbox",
"showAll": "Show All",
"proceed": "Go to Catalog",
"loading": "Loading..."
},
"fordVariant": {
"title": "Select Model",
"subtitle": "Optional — use Show All to browse all variants",
"modelYear": "Model Year",
"engine": "Engine",
"gearbox": "Gearbox",
"showAll": "Show All",
"proceed": "Go to Catalog",
"loading": "Loading...",
"noConfig": "Variant info unavailable. You can still browse all categories."
}
},
"auth": {
"login": "Log In",
"register": "Sign Up",
@@ -124,10 +164,10 @@
"expired": "Expired"
},
"popular": "Popular",
"trialTitle": "3-Day Full Package Trial",
"trialDescription": "Free access to all brands for 3 days. No credit card required.",
"trialTitle": "7-Day Full Package Trial",
"trialDescription": "Free access to all brands for 7 days. No credit card required.",
"startTrial": "Start Free Trial",
"trialStarted": "Your 3-day Full Package trial has started!",
"trialStarted": "Your 7-day Full Package trial has started!",
"onboarding": {
"provisioning": "Setting up your free trial",
"step1": "Verifying account",
@@ -135,7 +175,7 @@
"step3": "Activating Full Package",
"step4": "Completed!",
"completed": "You can test all catalogs without limits!",
"trialDuration": "3-Day Trial",
"trialDuration": "7-Day Trial",
"startSearching": "Start Searching",
"error": "An error occurred while starting your trial.",
"retry": "Try Again"

View File

@@ -30,12 +30,52 @@
"nav": {
"search": "Arama",
"history": "Geçmiş",
"catalog": "Katalog",
"subscription": "Abonelik",
"billing": "Fatura",
"settings": "Ayarlar",
"admin": "Admin Panel",
"logout": ıkış Yap"
},
"catalog": {
"title": "Parça Kataloğu",
"brands": "Markalar",
"models": "Modeller",
"allBrands": "Tüm Markalar",
"modelCount": "{count} model",
"noBrands": "Katalog verisi hazırlanıyor",
"noModels": "Model bulunamadı",
"locked": "Bu marka planınızda yok",
"upgradeCta": "Planını Yükselt",
"loadingModels": "Modeller yükleniyor...",
"categories": "Kategoriler",
"parts": "Parçalar",
"backToBrands": "Markalara Dön",
"backToModels": "Modellere Dön",
"backToCategories": "Kategorilere Dön",
"selectCatalog": "Bir katalog seçin",
"psaVariant": {
"title": "Araç Varyantını Seçin",
"subtitle": "İsteğe bağlı — tüm varyantlar için Tümü seçeneğini kullanın",
"body": "Kasa Tipi",
"engine": "Motor",
"gearbox": "Şanzıman",
"showAll": "Tümü",
"proceed": "Kataloga Git",
"loading": "Yükleniyor..."
},
"fordVariant": {
"title": "Model Seçin",
"subtitle": "İsteğe bağlı — tüm varyantlar için Tümü seçeneğini kullanın",
"modelYear": "Model Yılı",
"engine": "Motor",
"gearbox": "Şanzıman",
"showAll": "Tümü",
"proceed": "Kataloga Git",
"loading": "Yükleniyor...",
"noConfig": "Varyant bilgisi yüklenemedi. Tüm kategorilere göz atabilirsiniz."
}
},
"auth": {
"login": "Giriş Yap",
"register": "Kayıt Ol",
@@ -124,10 +164,10 @@
"expired": "Süresi Doldu"
},
"popular": "Popüler",
"trialTitle": "3 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 3 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"trialTitle": "7 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"startTrial": "Ücretsiz Denemeyi Başlat",
"trialStarted": "3 günlük Full Paket denemeniz başlatıldı!",
"trialStarted": "7 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",
@@ -135,7 +175,7 @@
"step3": "Full Paket aktif ediliyor",
"step4": "Tamamlandı!",
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
"trialDuration": "3 Gün Deneme",
"trialDuration": "7 Gün Deneme",
"startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene"

View File

@@ -31,6 +31,7 @@ import { Route as AuthRegisterRouteImport } from "./routes/_auth/register"
import { Route as AuthLoginRouteImport } from "./routes/_auth/login"
import { Route as AuthForgotPasswordRouteImport } from "./routes/_auth/forgot-password"
import { Route as DashboardSubscriptionIndexRouteImport } from "./routes/dashboard/subscription/index"
import { Route as DashboardCatalogIndexRouteImport } from "./routes/dashboard/catalog/index"
import { Route as DashboardAdminIndexRouteImport } from "./routes/dashboard/admin/index"
import { Route as DashboardSubscriptionPayRouteImport } from "./routes/dashboard/subscription/pay"
import { Route as DashboardAdminUsersRouteImport } from "./routes/dashboard/admin/users"
@@ -39,7 +40,10 @@ import { Route as DashboardAdminPaymentsRouteImport } from "./routes/dashboard/a
import { Route as DashboardAdminCopyLogsRouteImport } from "./routes/dashboard/admin/copy-logs"
import { Route as DashboardAdminAnalyticsRouteImport } from "./routes/dashboard/admin/analytics"
import { Route as DashboardVehiclesIdIndexRouteImport } from "./routes/dashboard/vehicles_/$id/index"
import { Route as DashboardCatalogBrandNameIndexRouteImport } from "./routes/dashboard/catalog_/$brandName/index"
import { Route as DashboardCatalogBrandNameModelIdIndexRouteImport } from "./routes/dashboard/catalog_/$brandName_/$modelId/index"
import { Route as DashboardVehiclesIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/vehicles_/$id/categories_/$categoryId"
import { Route as DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
const TermsRoute = TermsRouteImport.update({
id: "/terms",
@@ -151,6 +155,11 @@ const DashboardSubscriptionIndexRoute =
path: "/subscription/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogIndexRoute = DashboardCatalogIndexRouteImport.update({
id: "/catalog/",
path: "/catalog/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardAdminIndexRoute = DashboardAdminIndexRouteImport.update({
id: "/admin/",
path: "/admin/",
@@ -193,12 +202,30 @@ const DashboardVehiclesIdIndexRoute =
path: "/vehicles/$id/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogBrandNameIndexRoute =
DashboardCatalogBrandNameIndexRouteImport.update({
id: "/catalog_/$brandName/",
path: "/catalog/$brandName/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogBrandNameModelIdIndexRoute =
DashboardCatalogBrandNameModelIdIndexRouteImport.update({
id: "/catalog_/$brandName_/$modelId/",
path: "/catalog/$brandName/$modelId/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardVehiclesIdCategoriesCategoryIdRoute =
DashboardVehiclesIdCategoriesCategoryIdRouteImport.update({
id: "/vehicles_/$id/categories_/$categoryId",
path: "/vehicles/$id/categories/$categoryId",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute =
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport.update({
id: "/catalog_/$brandName_/$modelId/categories_/$categoryId",
path: "/catalog/$brandName/$modelId/categories/$categoryId",
getParentRoute: () => DashboardRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
@@ -228,9 +255,13 @@ export interface FileRoutesByFullPath {
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
"/dashboard/subscription/pay": typeof DashboardSubscriptionPayRoute
"/dashboard/admin/": typeof DashboardAdminIndexRoute
"/dashboard/catalog/": typeof DashboardCatalogIndexRoute
"/dashboard/subscription/": typeof DashboardSubscriptionIndexRoute
"/dashboard/catalog/$brandName/": typeof DashboardCatalogBrandNameIndexRoute
"/dashboard/vehicles/$id/": typeof DashboardVehiclesIdIndexRoute
"/dashboard/vehicles/$id/categories/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
"/dashboard/catalog/$brandName/$modelId/": typeof DashboardCatalogBrandNameModelIdIndexRoute
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
@@ -259,9 +290,13 @@ export interface FileRoutesByTo {
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
"/dashboard/subscription/pay": typeof DashboardSubscriptionPayRoute
"/dashboard/admin": typeof DashboardAdminIndexRoute
"/dashboard/catalog": typeof DashboardCatalogIndexRoute
"/dashboard/subscription": typeof DashboardSubscriptionIndexRoute
"/dashboard/catalog/$brandName": typeof DashboardCatalogBrandNameIndexRoute
"/dashboard/vehicles/$id": typeof DashboardVehiclesIdIndexRoute
"/dashboard/vehicles/$id/categories/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
"/dashboard/catalog/$brandName/$modelId": typeof DashboardCatalogBrandNameModelIdIndexRoute
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -293,9 +328,13 @@ export interface FileRoutesById {
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
"/dashboard/subscription/pay": typeof DashboardSubscriptionPayRoute
"/dashboard/admin/": typeof DashboardAdminIndexRoute
"/dashboard/catalog/": typeof DashboardCatalogIndexRoute
"/dashboard/subscription/": typeof DashboardSubscriptionIndexRoute
"/dashboard/catalog_/$brandName/": typeof DashboardCatalogBrandNameIndexRoute
"/dashboard/vehicles_/$id/": typeof DashboardVehiclesIdIndexRoute
"/dashboard/vehicles_/$id/categories_/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
"/dashboard/catalog_/$brandName_/$modelId/": typeof DashboardCatalogBrandNameModelIdIndexRoute
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -327,9 +366,13 @@ export interface FileRouteTypes {
| "/dashboard/admin/users"
| "/dashboard/subscription/pay"
| "/dashboard/admin/"
| "/dashboard/catalog/"
| "/dashboard/subscription/"
| "/dashboard/catalog/$brandName/"
| "/dashboard/vehicles/$id/"
| "/dashboard/vehicles/$id/categories/$categoryId"
| "/dashboard/catalog/$brandName/$modelId/"
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
fileRoutesByTo: FileRoutesByTo
to:
| "/"
@@ -358,9 +401,13 @@ export interface FileRouteTypes {
| "/dashboard/admin/users"
| "/dashboard/subscription/pay"
| "/dashboard/admin"
| "/dashboard/catalog"
| "/dashboard/subscription"
| "/dashboard/catalog/$brandName"
| "/dashboard/vehicles/$id"
| "/dashboard/vehicles/$id/categories/$categoryId"
| "/dashboard/catalog/$brandName/$modelId"
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
id:
| "__root__"
| "/"
@@ -391,9 +438,13 @@ export interface FileRouteTypes {
| "/dashboard/admin/users"
| "/dashboard/subscription/pay"
| "/dashboard/admin/"
| "/dashboard/catalog/"
| "/dashboard/subscription/"
| "/dashboard/catalog_/$brandName/"
| "/dashboard/vehicles_/$id/"
| "/dashboard/vehicles_/$id/categories_/$categoryId"
| "/dashboard/catalog_/$brandName_/$modelId/"
| "/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -567,6 +618,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardSubscriptionIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog/": {
id: "/dashboard/catalog/"
path: "/catalog"
fullPath: "/dashboard/catalog/"
preLoaderRoute: typeof DashboardCatalogIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/admin/": {
id: "/dashboard/admin/"
path: "/admin"
@@ -623,6 +681,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardVehiclesIdIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/$brandName/": {
id: "/dashboard/catalog_/$brandName/"
path: "/catalog/$brandName"
fullPath: "/dashboard/catalog/$brandName/"
preLoaderRoute: typeof DashboardCatalogBrandNameIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/$brandName_/$modelId/": {
id: "/dashboard/catalog_/$brandName_/$modelId/"
path: "/catalog/$brandName/$modelId"
fullPath: "/dashboard/catalog/$brandName/$modelId/"
preLoaderRoute: typeof DashboardCatalogBrandNameModelIdIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/vehicles_/$id/categories_/$categoryId": {
id: "/dashboard/vehicles_/$id/categories_/$categoryId"
path: "/vehicles/$id/categories/$categoryId"
@@ -630,6 +702,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId": {
id: "/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
path: "/catalog/$brandName/$modelId/categories/$categoryId"
fullPath: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
preLoaderRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport
parentRoute: typeof DashboardRoute
}
}
}
@@ -662,9 +741,13 @@ interface DashboardRouteChildren {
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
DashboardSubscriptionPayRoute: typeof DashboardSubscriptionPayRoute
DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute
DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute
DashboardSubscriptionIndexRoute: typeof DashboardSubscriptionIndexRoute
DashboardCatalogBrandNameIndexRoute: typeof DashboardCatalogBrandNameIndexRoute
DashboardVehiclesIdIndexRoute: typeof DashboardVehiclesIdIndexRoute
DashboardVehiclesIdCategoriesCategoryIdRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRoute
DashboardCatalogBrandNameModelIdIndexRoute: typeof DashboardCatalogBrandNameModelIdIndexRoute
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
}
const DashboardRouteChildren: DashboardRouteChildren = {
@@ -680,10 +763,16 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardAdminUsersRoute: DashboardAdminUsersRoute,
DashboardSubscriptionPayRoute: DashboardSubscriptionPayRoute,
DashboardAdminIndexRoute: DashboardAdminIndexRoute,
DashboardCatalogIndexRoute: DashboardCatalogIndexRoute,
DashboardSubscriptionIndexRoute: DashboardSubscriptionIndexRoute,
DashboardCatalogBrandNameIndexRoute: DashboardCatalogBrandNameIndexRoute,
DashboardVehiclesIdIndexRoute: DashboardVehiclesIdIndexRoute,
DashboardVehiclesIdCategoriesCategoryIdRoute:
DashboardVehiclesIdCategoriesCategoryIdRoute,
DashboardCatalogBrandNameModelIdIndexRoute:
DashboardCatalogBrandNameModelIdIndexRoute,
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute:
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute,
}
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(

View File

@@ -102,7 +102,7 @@ function AuthLayout() {
{/* Bottom trial badge */}
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
<ShieldCheck className="size-4" />
3 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
7 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
@@ -14,7 +14,6 @@ export const Route = createFileRoute("/_auth/login")({
});
function LoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -41,7 +40,7 @@ function LoginPage() {
}
} else {
capture("user_logged_in", { method: "email" });
navigate({ to: "/dashboard/search" });
window.location.href = "/dashboard/search";
}
setLoading(false);

View File

@@ -69,7 +69,7 @@ function RegisterPage() {
{/* 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" />
3 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
7 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>

View File

@@ -26,6 +26,7 @@ import {
Sun,
Moon,
Copy,
Library,
} from "lucide-react";
import { useState } from "react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
@@ -40,6 +41,7 @@ export const Route = createFileRoute("/dashboard")({
const mainMenuItems = [
{ to: "/dashboard", label: "Gösterge Paneli", icon: LayoutDashboard, exact: true },
{ to: "/dashboard/search", label: "nav.search", translatable: true, icon: Search },
{ to: "/dashboard/catalog", label: "nav.catalog", translatable: true, icon: Library },
{ to: "/dashboard/history", label: "nav.history", translatable: true, icon: History },
] as const;

View File

@@ -0,0 +1,97 @@
import { createFileRoute, Link } 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";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
});
interface CatalogBrand {
brandName: string;
brandId: string | null;
logoUrl: string | null;
serviceNames: string[];
hasAccess: boolean;
}
function CatalogBrandsPage() {
const { t } = useTranslation();
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
});
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>
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
))}
</div>
) : !brands || brands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div>
) : (
<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>
)}
</div>
);
}
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">
<Lock className="mb-2 size-5 text-muted-foreground" />
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
<Link
to="/dashboard/subscription"
className="mt-2 text-xs font-medium text-primary hover:underline"
>
{t("catalog.upgradeCta")}
</Link>
</div>
);
}
return (
<Link
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"
>
{brand.logoUrl ? (
<img
src={brand.logoUrl}
alt={brand.brandName}
className="mb-2 h-10 w-auto object-contain"
/>
) : (
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
<Library className="size-5 text-primary" />
</div>
)}
<p className="text-sm font-semibold">{brand.brandName}</p>
</Link>
);
}

View File

@@ -0,0 +1,211 @@
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";
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
validateSearch: (search: Record<string, unknown>) => ({
catalog: typeof search.catalog === "string" ? search.catalog : undefined,
}),
component: CatalogModelsPage,
});
interface CatalogEntry {
serviceName: string;
displayName: string;
}
interface CatalogVehicle {
id: string;
serviceName: string;
brandName: string;
model: string;
year: string | null;
engine: string | null;
bodyType: string | null;
transmission: string | null;
architecture: string | null;
}
function CatalogModelsPage() {
const { brandName } = Route.useParams();
const { catalog: activeCatalog } = Route.useSearch();
const { t } = useTranslation();
const navigate = useNavigate();
const decodedBrandName = decodeURIComponent(brandName);
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
queryKey: ["catalog-catalogs", decodedBrandName],
queryFn: () =>
api.get<CatalogEntry[]>(`/catalog/brands/${encodeURIComponent(decodedBrandName)}/catalogs`),
staleTime: 1000 * 60 * 30, // 30 min
});
const isMultiCatalog = (catalogs?.length ?? 0) > 1;
// Show models when: single-service brand, OR user has selected a sub-catalog
const shouldShowModels = !isMultiCatalog || !!activeCatalog;
const activeCatalogLabel = activeCatalog
? (catalogs?.find((c) => c.serviceName === activeCatalog)?.displayName ?? activeCatalog)
: null;
const { data: models, isLoading: modelsLoading } = useQuery({
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
queryFn: () => {
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
return api.get<CatalogVehicle[]>(
`/catalog/brands/${encodeURIComponent(decodedBrandName)}/models${serviceParam}`,
);
},
enabled: shouldShowModels && !catalogsLoading,
});
const handleBack = () => {
if (isMultiCatalog && activeCatalog) {
// Go back to catalog selector
navigate({ to: ".", search: { catalog: undefined } });
} else {
navigate({ to: "/dashboard/catalog" });
}
};
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
{" / "}
{isMultiCatalog && activeCatalog ? (
<>
<Link
to="."
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">{activeCatalogLabel}</span>
</>
) : (
<span className="font-medium text-foreground">{decodedBrandName}</span>
)}
</div>
<h1 className="text-xl font-bold">
{isMultiCatalog && activeCatalog ? activeCatalogLabel : decodedBrandName}
</h1>
</div>
</div>
{catalogsLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-24 w-full rounded-xl" />
))}
</div>
) : isMultiCatalog && !activeCatalog ? (
// Sub-catalog selector
<CatalogSelector
catalogs={catalogs!}
brandName={brandName}
brandLabel={decodedBrandName}
/>
) : modelsLoading ? (
<div>
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t("catalog.loadingModels")}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={`model-skel-${i}`} className="h-24 w-full rounded-lg" />
))}
</div>
</div>
) : !models || models.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Car className="mb-4 size-12 text-muted-foreground/40" />
<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>
)}
</div>
);
}
function CatalogSelector({
catalogs,
brandName,
brandLabel,
}: {
catalogs: CatalogEntry[];
brandName: string;
brandLabel: string;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("catalog.selectCatalog")} {brandLabel}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{catalogs.map((cat) => (
<Link
key={cat.serviceName}
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: cat.serviceName }}
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<BookOpen className="size-5 text-primary" />
</div>
<div>
<p className="font-semibold">{cat.displayName}</p>
<p className="text-xs text-muted-foreground">{cat.serviceName}</p>
</div>
</div>
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
))}
</div>
</div>
);
}
function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: string }) {
return (
<Link
to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: 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>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 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>
</Link>
);
}

View File

@@ -0,0 +1,152 @@
import { lazy, Suspense } 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 { CategoryGrid } from "@/components/categories/category-grid";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
})),
);
function SchemaViewerFallback() {
return (
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
))}
</div>
</div>
);
}
export const Route = createFileRoute(
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId",
)({
validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
}),
component: CatalogCategoryPage,
});
function buildVariantQuery(body?: string, engine?: string, gearbox?: string): string {
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function CatalogCategoryPage() {
const { brandName, modelId, categoryId } = Route.useParams();
const search = Route.useSearch();
const { t } = useTranslation();
const navigate = useNavigate();
const body = search.body;
const engine = search.engine;
const gearbox = search.gearbox;
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined;
const { data, isLoading, error } = useQuery({
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
queryFn: () =>
api.get<any>(
`/catalog/vehicles/${modelId}/categories/${categoryId}${buildVariantQuery(body, engine, gearbox)}`,
),
enabled: !!modelId && !!categoryId,
});
const hasChildren = data?.children && data.children.length > 0;
const handleBack = () => {
if (data?.parentId) {
navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId, categoryId: data.parentId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
});
} else {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
});
}
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<SchemaViewerFallback />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-muted-foreground">{t("errors.generic")}</p>
<Button variant="ghost" className="mt-4" onClick={handleBack}>
{t("catalog.backToCategories")}
</Button>
</div>
);
}
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>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div>
</div>
{/* Content */}
{hasChildren ? (
<CategoryGrid
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
parentId={categoryId}
variantSearch={variantSearch}
/>
) : (
<Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer
schemaPic={data?.schemaPics?.[0] ?? null}
hotspots={data?.hotspots ?? []}
parts={data?.parts ?? []}
isLoading={isLoading}
vehicleId={modelId}
categoryId={categoryId}
/>
</Suspense>
)}
</div>
);
}

View File

@@ -0,0 +1,251 @@
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, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
}),
component: CatalogVehiclePage,
});
function buildVariantQuery(body?: string, engine?: string, gearbox?: string): string {
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function CatalogVehiclePage() {
const { brandName, modelId } = Route.useParams();
const search = Route.useSearch();
const { t } = useTranslation();
const navigate = useNavigate();
const body = search.body;
const engine = search.engine;
const gearbox = search.gearbox;
const hasVariant = !!(body || engine || gearbox);
const [viewMode, setViewMode] = useState<"grid" | "tree">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const { data: vehicle, isLoading: vehicleLoading } = useQuery({
queryKey: ["catalog-vehicle", modelId],
queryFn: () => api.get<any>(`/catalog/vehicles/${modelId}`),
enabled: !!modelId,
});
const isPsa = vehicle?.architecture === "LEGACY_PSA";
const isP4Legacy = [
"LEGACY_FORD",
"LEGACY_VOLVO",
].includes(vehicle?.architecture);
const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector;
const variantSearch = hasVariant ? { body, engine, gearbox } : 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)}`),
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
});
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: {
// For Ford (catCode) / Volvo (year) variants: keep the meaningful selection,
// strip _all_ / _nor_ (no-restriction) values to keep URL clean.
// Special case: if ALL are _nor_, pass body="_nor_" so hasVariant=true skips re-showing selector.
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine),
gearbox: norm(selectedGearbox),
},
});
};
if (vehicleLoading) {
return (
<div className="mx-auto max-w-4xl space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
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="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
{" / "}
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">
{vehicle?.model}
</span>
{hasVariant && (
<>
{body && body !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{body}</span></>
)}
{engine && engine !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{engine}</span></>
)}
{gearbox && gearbox !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{gearbox}</span></>
)}
</>
)}
</div>
<h1 className="text-xl font-bold">
{vehicle?.model}
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
</h1>
</div>
</div>
{/* Vehicle Info */}
{vehicle && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("catalog.models")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
{vehicle.engine && (
<div>
<span className="text-muted-foreground">Motor:</span>{" "}
<span className="font-medium">{vehicle.engine}</span>
</div>
)}
{vehicle.bodyType && (
<div>
<span className="text-muted-foreground">Kasa:</span>{" "}
<span className="font-medium">{vehicle.bodyType}</span>
</div>
)}
{vehicle.transmission && (
<div>
<span className="text-muted-foreground">Vites:</span>{" "}
<span className="font-medium">{vehicle.transmission}</span>
</div>
)}
{vehicle.market && (
<div>
<span className="text-muted-foreground">Pazar:</span>{" "}
<span className="font-medium">{vehicle.market}</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Variant Selector OR Categories */}
{showPsaVariantSelector ? (
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showFordVariantSelector ? (
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<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>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (
<CategoryGrid
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryTree
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)}
</CardContent>
</Card>
)}
</div>
);
}