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:
175
apps/web/src/components/catalog/ford-variant-selector.tsx
Normal file
175
apps/web/src/components/catalog/ford-variant-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
apps/web/src/components/catalog/psa-variant-selector.tsx
Normal file
209
apps/web/src/components/catalog/psa-variant-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
97
apps/web/src/routes/dashboard/catalog/index.tsx
Normal file
97
apps/web/src/routes/dashboard/catalog/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
apps/web/src/routes/dashboard/catalog_/$brandName/index.tsx
Normal file
211
apps/web/src/routes/dashboard/catalog_/$brandName/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user