dev #81

Merged
root merged 5 commits from dev into main 2026-06-03 01:49:49 +03:00
8 changed files with 319 additions and 5 deletions

View File

@@ -1462,7 +1462,7 @@ export class CategoriesService {
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: dbParts,
parts: await this.resolvePartReferences(dbParts, category.vehicleId),
schemaPics: mappedPics,
hotspots: mappedHotspots,
// Only meaningful when the lists are empty: true means a source fetch
@@ -1471,6 +1471,140 @@ export class CategoriesService {
};
}
// PL24 BOM cross-reference rows point to another illustration, not a real part
// (oem_code "N/A", target code in `remark`). Upstream gives us NO navigable
// link — only the text code — so we resolve it ourselves: every illustration's
// code lives in its category name as `{NNN-NNN}`, so we build a code→categoryId
// index for this vehicle and attach it. Codes whose target branch hasn't been
// lazily seeded yet resolve to null; the UI then drills on demand / falls back
// to a pre-filled catalog search.
//
// The "see table" phrasing varies by PL24's own translation: "bk. tablo:" and
// "bakınız tablo, konum:" both occur — match either (the code-in-remark gate
// below keeps this from flagging real parts that merely mention "tablo").
private static readonly REF_CODE_RE = /\d{3}-\d{3}/g;
private static readonly REF_CODE_IN_NAME_RE = /\{(\d{3}-\d{3})\}/g;
private static readonly REF_NAME_RE = /b(?:k\.?|akınız)\s+tablo/i;
// Build a code→categoryId index for a vehicle from the `{NNN-NNN}` illustration
// codes embedded in category names. Reflects only what's been seeded so far.
private async buildReferenceIndex(vehicleId: string): Promise<Map<string, string>> {
const cats = await this.db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const index = new Map<string, string>();
for (const c of cats) {
for (const m of c.name.matchAll(CategoriesService.REF_CODE_IN_NAME_RE)) {
if (!index.has(m[1])) index.set(m[1], c.id);
}
}
return index;
}
private async resolvePartReferences<T extends { name: string; remark: string | null }>(
rows: T[],
vehicleId: string | null,
): Promise<(T & { references?: Array<{ code: string; categoryId: string | null }> })[]> {
const isRef = (p: T) => CategoriesService.REF_NAME_RE.test(p.name ?? "");
if (!vehicleId || !rows.some(isRef)) return rows;
const index = await this.buildReferenceIndex(vehicleId);
return rows.map((p) => {
if (!isRef(p)) return p;
// remark stores the code possibly escaped ("819\-031") and sometimes
// multiple codes concatenated ("141-045141-065141-075") plus position
// hints ("803-070 POS.28+29") — extract every NNN-NNN occurrence.
const codes = [
...new Set((p.remark?.replace(/\\/g, "") ?? "").match(CategoriesService.REF_CODE_RE) ?? []),
];
if (codes.length === 0) return p;
return {
...p,
references: codes.map((code) => ({ code, categoryId: index.get(code) ?? null })),
};
});
}
// On-demand resolution of a single "bk. tablo:" code. The target illustration
// lives as a direct (occasionally one-deeper) child of the main-group root
// whose number is the code's first digit — but that branch may not be drilled
// yet, so the load-time index missed it. Drill that root (one PL24 call seeds
// the whole illustration list), then re-resolve. Bounded: root + its group
// children only, capped, stopping on first hit. getChildren is DB-first, so
// repeat clicks on the same code are cheap.
private static readonly REF_DRILL_CALL_CAP = 25;
async resolveReferenceCode(
vehicleId: string,
rawCode: string,
): Promise<{ code: string; categoryId: string | null }> {
const code =
(rawCode ?? "")
.replace(/\\/g, "")
.trim()
.match(/\d{3}-\d{3}/)?.[0] ?? "";
if (!code) return { code: rawCode, categoryId: null };
// Already seeded? cheap path.
let index = await this.buildReferenceIndex(vehicleId);
if (index.has(code)) return { code, categoryId: index.get(code) ?? null };
// Locate the main-group root: its external_id is the code's leading digit.
const mainGroup = code[0];
const roots = await this.db
.select()
.from(categories)
.where(
and(
eq(categories.vehicleId, vehicleId),
isNull(categories.parentId),
eq(categories.externalId, mainGroup),
),
);
if (roots.length === 0) return { code, categoryId: null };
let calls = 0;
const drillAndCheck = async (categoryId: string): Promise<string | null> => {
if (calls >= CategoriesService.REF_DRILL_CALL_CAP) return null;
calls++;
try {
await this.getChildren(categoryId);
} catch (err) {
this.logger.warn(`[ref-drill] getChildren ${categoryId} failed: ${(err as Error).message}`);
return null;
}
index = await this.buildReferenceIndex(vehicleId);
return index.get(code) ?? null;
};
for (const root of roots) {
const hit = await drillAndCheck(root.id);
if (hit) return { code, categoryId: hit };
// Not a direct child — descend one level into freshly-seeded group nodes.
const subs = await this.db
.select({
id: categories.id,
linkWid: categories.linkWid,
hasSubgroups: categories.hasSubgroups,
})
.from(categories)
.where(eq(categories.parentId, root.id));
for (const sub of subs) {
const isGroup = sub.hasSubgroups === true || sub.linkWid?.includes("Group");
if (!isGroup) continue;
const subHit = await drillAndCheck(sub.id);
if (subHit) return { code, categoryId: subHit };
if (calls >= CategoriesService.REF_DRILL_CALL_CAP) break;
}
}
return { code, categoryId: null };
}
async getById(categoryId: string) {
const [category] = await this.db
.select()

View File

@@ -94,6 +94,15 @@ export class VehiclesController {
return this.categoriesService.getCategoryWithParts(categoryId);
}
// On-demand resolution of a "bk. tablo:" cross-reference code whose target
// illustration wasn't seeded at category-load time — drills the relevant
// main group, then returns the resolved category (or null if not found).
@Get(":vehicleId/references/resolve")
async resolveReference(@Param("vehicleId") vehicleId: string, @Query("code") code: string) {
await this.vehiclesService.getById(vehicleId);
return this.categoriesService.resolveReferenceCode(vehicleId, code ?? "");
}
@Get(":id")
async getById(@Param("id") id: string) {
return this.vehiclesService.getById(id);

View File

@@ -68,13 +68,16 @@ function Breadcrumb({ trail, tail }: { trail: Crumb[]; tail?: string }) {
export function CatalogSearch({
vehicleId,
onActiveChange,
initialQuery,
}: {
vehicleId: string;
onActiveChange?: (active: boolean) => void;
/** Pre-fill the search box (e.g. deep-linked from a "bk. tablo:" reference). */
initialQuery?: string;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [input, setInput] = useState("");
const [input, setInput] = useState(initialQuery ?? "");
const debounced = useDebounced(input.trim(), 300);
const active = debounced.length >= 2;
@@ -83,6 +86,11 @@ export function CatalogSearch({
onActiveChange?.(active);
}, [active]);
// Adopt a freshly deep-linked query even if this component stays mounted.
useEffect(() => {
if (initialQuery) setInput(initialQuery);
}, [initialQuery]);
const { data, isLoading, isFetching } = useQuery({
queryKey: ["catalog-search", vehicleId, debounced],
queryFn: () =>

View File

@@ -3,7 +3,8 @@ import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton, cn } from "@sase/ui";
import { Check, Copy } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { ArrowRight, Check, Copy, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface PartsPanelProps {
@@ -18,9 +19,66 @@ const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as co
export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPanelProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const navigate = useNavigate();
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
// target illustration directly. Unresolved (target branch not seeded yet) →
// ask the server to drill it on demand; if that finds it, jump there, else
// fall back to a pre-filled catalog search.
const goToReference = useCallback(
async (ref: { code: string; categoryId: string | null }) => {
if (!vehicleId) return;
capture("part_reference_clicked", {
code: ref.code,
resolved: ref.categoryId != null,
vehicle_id: vehicleId,
category_id: categoryId,
});
const openCategory = (targetId: string) =>
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id: vehicleId, categoryId: targetId },
});
if (ref.categoryId) {
openCategory(ref.categoryId);
return;
}
// On-demand drill: seed the referenced illustration, then jump to it.
setResolvingCode(ref.code);
try {
const res = await api.get<{ code: string; categoryId: string | null }>(
`/vehicles/${vehicleId}/references/resolve?code=${encodeURIComponent(ref.code)}`,
);
capture("part_reference_drilled", {
code: ref.code,
resolved: res.categoryId != null,
vehicle_id: vehicleId,
});
if (res.categoryId) {
openCategory(res.categoryId);
return;
}
} catch {
// fall through to search
} finally {
setResolvingCode(null);
}
navigate({
to: "/dashboard/vehicles/$id",
params: { id: vehicleId },
search: { q: ref.code },
});
},
[vehicleId, categoryId, navigate],
);
const hasPrices = parts.some((p) => p.price != null);
@@ -144,6 +202,52 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
const isSelected = group != null && selectedGroup === group;
const isUnavailable = part.unavailable === true;
// PL24 cross-reference row ("bk. tablo:" / "bakınız tablo, konum:")
// — not a real part, but a jump to another illustration. Render
// the target table code(s) as navigable links instead of a dead
// "N/A" OEM cell, dropping the "see table" tail from the label.
const refs = part.references;
if (refs && refs.length > 0) {
const label = part.name
.replace(/b(?:k\.?|akınız)\s+tablo.*$/is, "")
.replace(/\s*\/\s*$/, "")
.trim();
return (
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
{label && <span className="font-medium">{label}</span>}
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{refs.map((ref) => {
const isResolving = resolvingCode === ref.code;
return (
<button
key={ref.code}
type="button"
disabled={isResolving}
onClick={() => goToReference(ref)}
className="inline-flex items-center gap-1 text-sm font-semibold text-primary underline decoration-primary/50 underline-offset-2 hover:decoration-primary disabled:opacity-60"
title={
ref.categoryId
? `Tabloya git: ${ref.code}`
: `Tabloyu bul: ${ref.code}`
}
>
{isResolving ? (
<Loader2 className="size-4 shrink-0 animate-spin" />
) : (
<ArrowRight className="size-4 shrink-0" />
)}
Tabloya git: {ref.code}
</button>
);
})}
</span>
</td>
</tr>
);
}
return (
<tr
key={part.id}

View File

@@ -15,6 +15,10 @@ export interface Part {
price?: number | null;
currency?: string | null;
note?: string;
/** PL24 "bk. tablo:NNN-NNN" cross-references. Present only on reference rows
* (no real OEM). `categoryId` is the resolved target illustration, or null
* when its branch isn't seeded yet → fall back to catalog search by `code`. */
references?: Array<{ code: string; categoryId: string | null }>;
}
export interface Hotspot {

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the underlying posthog-js SDK (not @/lib/posthog) so we exercise the
// real capturePageView implementation.
const captureMock = vi.fn();
vi.mock("posthog-js", () => ({
default: {
init: vi.fn(),
capture: captureMock,
identify: vi.fn(),
reset: vi.fn(),
people: { set: vi.fn() },
},
}));
import { capturePageView } from "../posthog";
describe("capturePageView", () => {
beforeEach(() => {
captureMock.mockClear();
});
it("includes the query string so UTM params are not dropped (attribution regression)", async () => {
window.history.pushState({}, "", "/?utm_source=facebook&utm_campaign=traffic_lpv_v1");
capturePageView("/");
await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1));
const [event, props] = captureMock.mock.calls[0];
expect(event).toBe("$pageview");
// The whole point: $current_url must carry the UTMs, not just origin+path.
expect(props.$current_url).toContain("utm_source=facebook");
expect(props.$current_url).toContain("utm_campaign=traffic_lpv_v1");
expect(props.$current_url).toBe(window.location.href);
expect(props.$pathname).toBe("/");
});
});

View File

@@ -25,7 +25,11 @@ export function initPostHog(): void {
ph.init(key, {
api_host: "https://t.sase.tr",
defaults: "2026-01-30",
person_profiles: "identified_only",
// "always" (not "identified_only") so anonymous ad visitors get a person
// profile that captures first-touch UTM/referrer ($initial_utm_*). Required
// for ad→signup attribution. Volume is ~34k events/mo (PostHog free tier is
// 1M/mo), so the cost impact is negligible at current scale.
person_profiles: "always",
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
@@ -61,7 +65,14 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
}
export function capturePageView(path: string): void {
load().then((ph) => ph.capture("$pageview", { $current_url: window.location.origin + path }));
// Pass the full URL (incl. query string) so PostHog can parse UTM params and
// persist first-touch attribution ($initial_utm_*, $utm_*). Previously this
// sent only `origin + path`, silently dropping every ad UTM — so all paid
// traffic was mis-bucketed as "direct". The index route keeps unknown search
// params, so window.location.href still holds the UTMs at mount.
load().then((ph) =>
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
);
}
export function setPeopleProperties(properties: Record<string, unknown>): void {

View File

@@ -32,6 +32,11 @@ import { useEffect, useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
component: VehicleDetailPage,
// `q` deep-links the catalog search (e.g. from a "bk. tablo:" reference whose
// target illustration isn't seeded yet).
validateSearch: (search: Record<string, unknown>): { q?: string } => ({
q: typeof search.q === "string" ? search.q : undefined,
}),
});
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -39,6 +44,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
function VehicleDetailPage() {
const { t } = useTranslation();
const { id } = Route.useParams();
const { q: initialSearch } = Route.useSearch();
const idValid = UUID_RE.test(id);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
@@ -293,6 +299,7 @@ function VehicleDetailPage() {
<div className={viewMode === "columns" && !searchActive ? "p-6 pb-0" : undefined}>
<CatalogSearch
vehicleId={id}
initialQuery={initialSearch}
onActiveChange={(active) => {
setSearchActive(active);
if (active) capture("catalog_search_opened", { vehicle_id: id });