feat(emex): wizard-based vehicle selection via GetWizard2 API
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Replace static DB-based dropdown chain with live wizard from emexdwc.ae.
The wizard API (GetWizard2) works without auth and shows all models/options
for each catalog (e.g. AU1587 now shows 103 Audi models vs 11 before).

Backend: proxy GetWizard2 + search DB for matching vehicles after wizard completes.
Frontend: step-by-step wizard with breadcrumb navigation, search, and reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:43:08 +00:00
parent e7b1350876
commit 184bf4741e
6 changed files with 887 additions and 825 deletions

View File

@@ -4,11 +4,33 @@ 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, Car, ChevronRight } from "lucide-react";
import {
ArrowLeft,
Car,
ChevronRight,
RotateCcw,
AlertCircle,
} from "lucide-react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
component: EmexVehicleListPage,
});
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")(
{
component: EmexVehicleListPage,
},
);
// ── Types ────────────────────────────────────────────
interface WizardRow {
name: string;
value: string | null;
determined: boolean;
options: WizardOption[];
}
interface WizardOption {
key: string;
value: string;
}
interface EmexVehicle {
id: string;
@@ -25,249 +47,100 @@ interface EmexVehicle {
optionsRaw: string | null;
}
// ── Helpers ──────────────────────────────────────────
/** Parse "Key: Value;Key2: Value2" into a map */
function parseOptions(raw: string | null): Record<string, string> {
if (!raw) return {};
const map: Record<string, string> = {};
for (const part of raw.split(";")) {
const idx = part.indexOf(":");
if (idx > 0) {
map[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
}
}
return map;
}
function hasStructuredOptions(vehicles: EmexVehicle[]): boolean {
const sample = vehicles.find((v) => v.optionsRaw);
return sample?.optionsRaw?.includes(":") ?? false;
}
function distinctValues(
vehicles: EmexVehicle[],
getter: (v: EmexVehicle) => string | null | undefined,
): string[] {
const set = new Set<string>();
for (const v of vehicles) {
const val = getter(v);
if (val) set.add(val);
}
return [...set].sort();
}
// ── Dynamic filter builder ───────────────────────────
interface FilterDef {
key: string;
label: string;
getter: (v: EmexVehicle) => string | null | undefined;
}
/**
* Build filter chain dynamically from what the data actually contains.
* For structured options_raw (Renault/Dacia/Toyota/Lexus), parse the key:value pairs.
* For others, use DB fields in a sensible order and skip optionsRaw if it
* duplicates another field or is the only remaining differentiator (shown as row text).
*/
function buildFilterDefs(
vehicles: EmexVehicle[],
structured: boolean,
): FilterDef[] {
if (structured) {
return buildStructuredFilters(vehicles);
}
return buildFieldFilters(vehicles);
}
function buildStructuredFilters(vehicles: EmexVehicle[]): FilterDef[] {
// Collect all option keys and count usage
const keyCount = new Map<string, number>();
for (const v of vehicles) {
for (const k of Object.keys(parseOptions(v.optionsRaw))) {
keyCount.set(k, (keyCount.get(k) || 0) + 1);
}
}
const defs: FilterDef[] = [];
const labelMap: Record<string, string> = {
"Engine type": "Motor",
"Gearbox type": "Şanzıman",
"Vehicle type": "Araç Tipi",
};
for (const [optKey, label] of Object.entries(labelMap)) {
if (keyCount.has(optKey)) {
const k = optKey;
defs.push({
key: k.replace(/\s+/g, "_"),
label,
getter: (v) => parseOptions(v.optionsRaw)[k] || null,
});
}
}
return defs;
}
function buildFieldFilters(vehicles: EmexVehicle[]): FilterDef[] {
// Candidate fields in priority order
const candidates: {
key: string;
label: string;
getter: (v: EmexVehicle) => string | null | undefined;
}[] = [
{ key: "engine", label: "Motor", getter: (v) => v.engine },
{ key: "bodyType", label: "Kasa Tipi", getter: (v) => v.bodyType },
{ key: "transmission", label: "Şanzıman", getter: (v) => v.transmission },
{ key: "driveType", label: "Sürüş", getter: (v) => v.driveType },
{ key: "fuelType", label: "Yakıt", getter: (v) => v.fuelType },
{ key: "optionsRaw", label: "Varyant", getter: (v) => v.optionsRaw },
];
// Collect distinct values per field
const fieldVals = new Map<string, string[]>();
for (const c of candidates) {
const vals = distinctValues(vehicles, c.getter);
if (vals.length >= 2) fieldVals.set(c.key, vals);
}
const optRawVals = fieldVals.get("optionsRaw") || [];
// Pre-compute optionsRaw values as lowercase set for containment check
const optRawLower = new Set(
vehicles
.map((v) => v.optionsRaw?.toLowerCase())
.filter((x): x is string => !!x),
);
const defs: FilterDef[] = [];
for (const c of candidates) {
if (!fieldVals.has(c.key)) continue;
const vals = fieldVals.get(c.key)!;
const valSet = new Set(vals);
// Skip if this field's distinct values exactly match an already-added field
// (e.g. engine="LHD/RHD" and driveType="LHD/RHD")
const isDuplicateOfPrev = defs.some((d) => {
const dVals = fieldVals.get(d.key) || [];
return (
dVals.length === vals.length && dVals.every((v) => valSet.has(v))
);
});
if (isDuplicateOfPrev) continue;
// Skip optionsRaw if its values exactly match another already-added field
if (c.key === "optionsRaw" && isDuplicateOfPrev) continue;
// Skip a field if every one of its values is a substring of the
// corresponding vehicle's optionsRaw (e.g. driveType="LEFT" inside
// optionsRaw="LEFT (GSX)"). The optionsRaw filter alone is enough.
if (c.key !== "optionsRaw" && optRawVals.length >= 2) {
const isRedundant = vehicles.every((v) => {
const fv = c.getter(v);
const ov = v.optionsRaw;
if (!fv) return true; // no value → not contradicting
if (!ov) return false; // field has value but no optionsRaw → keep field
return ov.toLowerCase().includes(fv.toLowerCase());
});
if (isRedundant) continue;
}
defs.push(c);
}
return defs;
}
// ── Component ────────────────────────────────────────
function EmexVehicleListPage() {
const { t } = useTranslation();
const { catalogCode } = Route.useParams();
const { data: vehicles, isLoading } = useQuery({
queryKey: ["emex-vehicles", catalogCode],
// Current SSD state for wizard navigation
const [ssd, setSsd] = useState("");
// History of SSD selections for display
const [selections, setSelections] = useState<
{ name: string; value: string; ssd: string }[]
>([]);
// Fetch wizard data for current SSD
const {
data: wizardRows,
isLoading: wizardLoading,
isError: wizardError,
} = useQuery({
queryKey: ["emex-wizard", catalogCode, ssd],
queryFn: () =>
api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
api.get<WizardRow[]>(
`/catalog/emex/brands/${catalogCode}/wizard?ssd=${encodeURIComponent(ssd)}`,
),
});
const structured = useMemo(
() => (vehicles ? hasStructuredOptions(vehicles) : false),
[vehicles],
// Parse wizard state
const determined = useMemo(
() => wizardRows?.filter((r) => r.determined) ?? [],
[wizardRows],
);
const allFilterDefs = useMemo(
() => (vehicles ? buildFilterDefs(vehicles, structured) : []),
[vehicles, structured],
const undetermined = useMemo(
() => wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
[wizardRows],
);
const allDetermined = wizardRows
? wizardRows.length > 0 && undetermined.length === 0
: false;
const modelNames = useMemo(
() => (vehicles ? distinctValues(vehicles, (v) => v.name) : []),
[vehicles],
);
const [selectedModel, setSelectedModel] = useState("");
const [filters, setFilters] = useState<Record<string, string>>({});
const modelVehicles = useMemo(
() =>
selectedModel && vehicles
? vehicles.filter((v) => v.name === selectedModel)
: [],
[vehicles, selectedModel],
);
// Cascading filter chain: compute options per filter, narrowing the pool
const filterChain = useMemo(() => {
const chain: { def: FilterDef; options: string[] }[] = [];
let pool = modelVehicles;
for (const def of allFilterDefs) {
const opts = distinctValues(pool, def.getter);
if (opts.length > 1) {
chain.push({ def, options: opts });
}
const selected = filters[def.key];
if (selected) {
pool = pool.filter((v) => def.getter(v) === selected);
}
// Get the "Sales Designation" or "Name" from determined params
// to match against DB vehicles
const salesDesignation = useMemo(() => {
if (!allDetermined || !determined.length) return null;
// Try known name fields
for (const key of [
"Sales Designation",
"Name",
"Modification",
"Model name",
]) {
const row = determined.find((r) => r.name === key);
if (row?.value && row.value !== "None") return row.value;
}
return chain;
}, [modelVehicles, allFilterDefs, filters]);
return null;
}, [allDetermined, determined]);
// Final filtered list
const filteredVehicles = useMemo(() => {
let list = modelVehicles;
for (const def of allFilterDefs) {
const selected = filters[def.key];
if (selected) {
list = list.filter((v) => def.getter(v) === selected);
}
}
return list;
}, [modelVehicles, allFilterDefs, filters]);
// When all wizard params are determined, search DB for matching vehicles
const { data: matchedVehicles, isLoading: matchLoading } = useQuery({
queryKey: ["emex-wizard-vehicles", catalogCode, salesDesignation],
queryFn: () =>
api.get<EmexVehicle[]>(
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?name=${encodeURIComponent(salesDesignation!)}`,
),
enabled: !!salesDesignation,
});
const handleModelChange = useCallback((name: string) => {
setSelectedModel(name);
setFilters({});
// Handle wizard option selection
const handleSelect = useCallback(
(rowName: string, option: WizardOption) => {
setSelections((prev) => [...prev, { name: rowName, value: option.value, ssd }]);
setSsd(option.key);
},
[ssd],
);
// Reset wizard to initial state
const handleReset = useCallback(() => {
setSsd("");
setSelections([]);
}, []);
const handleFilterChange = useCallback(
(key: string, value: string) => {
const idx = allFilterDefs.findIndex((d) => d.key === key);
const newFilters: Record<string, string> = {};
for (let i = 0; i < idx; i++) {
const k = allFilterDefs[i].key;
if (filters[k]) newFilters[k] = filters[k];
}
if (value) newFilters[key] = value;
setFilters(newFilters);
// Go back to a previous step
const handleBack = useCallback(
(stepIndex: number) => {
const target = selections[stepIndex];
setSsd(target.ssd);
setSelections((prev) => prev.slice(0, stepIndex));
},
[allFilterDefs, filters],
[selections],
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog" search={{}}>
<Button variant="ghost" size="sm">
@@ -278,107 +151,180 @@ function EmexVehicleListPage() {
<h1 className="text-xl font-bold">
{decodeURIComponent(catalogCode)}
</h1>
{selections.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" />
{t("catalog.resetSelection")}
</Button>
)}
</div>
{isLoading ? (
{/* Breadcrumb: show selected wizard steps */}
{selections.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
{selections.map((sel, i) => (
<span key={i} className="flex items-center gap-1">
<button
type="button"
onClick={() => handleBack(i)}
className="rounded px-1.5 py-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
{sel.value}
</button>
<ChevronRight className="size-3 text-muted-foreground" />
</span>
))}
{allDetermined && salesDesignation && (
<span className="font-medium">{salesDesignation}</span>
)}
</div>
)}
{/* Error state */}
{wizardError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
<p>Katalog verileri yüklenemedi. Lütfen tekrar deneyin.</p>
</div>
)}
{/* Loading */}
{wizardLoading && (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : !vehicles || vehicles.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noModels")}
</p>
) : (
<>
{/* Dropdown chain */}
<div className="flex flex-wrap gap-3">
<FilterSelect
label={t("catalog.models")}
placeholder={t("catalog.selectModel")}
value={selectedModel}
options={modelNames}
onChange={handleModelChange}
/>
)}
{selectedModel &&
filterChain.map(({ def, options }) => (
<FilterSelect
key={def.key}
label={def.label}
placeholder={t("catalog.fordVariant.showAll")}
value={filters[def.key] || ""}
options={options}
onChange={(val) => handleFilterChange(def.key, val)}
/>
{/* Wizard: show undetermined rows as selection lists */}
{!wizardLoading && !wizardError && !allDetermined && undetermined.length > 0 && (
<WizardStep
row={undetermined[0]}
onSelect={handleSelect}
/>
)}
{/* All determined: show matched vehicles from DB */}
{allDetermined && (
<div className="space-y-3">
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Seçilen araç parametreleri
</p>
<div className="flex flex-wrap gap-2">
{determined.map((r) => (
<span
key={r.name}
className="rounded-md bg-background px-2 py-1 text-xs"
>
<span className="text-muted-foreground">{r.name}:</span>{" "}
{r.value}
</span>
))}
</div>
</div>
{/* Vehicle list */}
{selectedModel ? (
filteredVehicles.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{t("catalog.noModels")}
{matchLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : matchedVehicles && matchedVehicles.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{matchedVehicles.length} varyant
</p>
) : (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{filteredVehicles.length} varyant
</p>
{filteredVehicles.map((v) => (
<VehicleRow
key={v.id}
vehicle={v}
catalogCode={catalogCode}
structured={structured}
/>
))}
</div>
)
{matchedVehicles.map((v) => (
<VehicleRow
key={v.id}
vehicle={v}
catalogCode={catalogCode}
/>
))}
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("catalog.selectModel")}
</p>
<div className="rounded-lg border border-dashed border-border py-8 text-center">
<Car className="mx-auto mb-2 size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
Bu araç konfigürasyonu için parça verisi henüz mevcut değil.
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Farklı bir model veya varyant seçmeyi deneyin.
</p>
</div>
)}
</>
</div>
)}
{/* Empty state: no wizard rows */}
{!wizardLoading &&
!wizardError &&
wizardRows &&
wizardRows.length === 0 && (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noModels")}
</p>
)}
</div>
);
}
// ── Reusable select ──────────────────────────────────
// ── Wizard step: show options for first undetermined row ──
function FilterSelect({
label,
placeholder,
value,
options,
onChange,
function WizardStep({
row,
onSelect,
}: {
label: string;
placeholder: string;
value: string;
options: string[];
onChange: (val: string) => void;
row: WizardRow;
onSelect: (rowName: string, option: WizardOption) => void;
}) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
if (!search) return row.options;
const q = search.toLowerCase();
return row.options.filter((o) => o.value.toLowerCase().includes(q));
}, [row.options, search]);
return (
<div className="min-w-[160px] flex-1">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{label}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{row.name}</h2>
<span className="text-xs text-muted-foreground">
{row.options.length} seçenek
</span>
</div>
{row.options.length > 8 && (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..."
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
<div className="grid gap-1">
{filtered.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => onSelect(row.name, opt)}
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
>
<span className="min-w-0 flex-1 truncate">{opt.value}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
))}
</select>
{filtered.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Sonuç bulunamadı
</p>
)}
</div>
</div>
);
}
@@ -388,45 +334,28 @@ function FilterSelect({
function VehicleRow({
vehicle,
catalogCode,
structured,
}: {
vehicle: EmexVehicle;
catalogCode: string;
structured: boolean;
}) {
const parts: string[] = [];
if (structured) {
const opts = parseOptions(vehicle.optionsRaw);
if (opts["Engine type and suffix"])
parts.push(opts["Engine type and suffix"]);
else if (opts["Engine type"]) parts.push(opts["Engine type"]);
if (opts["Gearbox type"]) parts.push(opts["Gearbox type"]);
if (opts["Vehicle type"]) parts.push(opts["Vehicle type"]);
} else {
// Use optionsRaw as primary description when available — it's the most
// informative string in most catalogs (trim, engine spec, market etc.)
if (vehicle.optionsRaw) {
parts.push(vehicle.optionsRaw);
}
// Add fields not already contained in optionsRaw
const raw = vehicle.optionsRaw?.toLowerCase() || "";
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
parts.push(vehicle.engine);
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
parts.push(vehicle.bodyType);
if (
vehicle.transmission &&
!raw.includes(vehicle.transmission.toLowerCase())
)
parts.push(vehicle.transmission);
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
parts.push(vehicle.driveType);
if (vehicle.fuelType && !raw.includes(vehicle.fuelType.toLowerCase()))
parts.push(vehicle.fuelType);
if (vehicle.optionsRaw) {
parts.push(vehicle.optionsRaw);
}
const raw = vehicle.optionsRaw?.toLowerCase() || "";
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
parts.push(vehicle.engine);
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
parts.push(vehicle.bodyType);
if (
vehicle.transmission &&
!raw.includes(vehicle.transmission.toLowerCase())
)
parts.push(vehicle.transmission);
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
parts.push(vehicle.driveType);
// Fallback if nothing
if (parts.length === 0 && vehicle.engine) parts.push(vehicle.engine);
return (