Merge pull request 'dev' (#67) from dev into main

Reviewed-on: #67
This commit was merged in pull request #67.
This commit is contained in:
2026-05-31 21:46:34 +00:00
24 changed files with 2108 additions and 820 deletions

View File

@@ -58,6 +58,7 @@
"drizzle-orm": "^0.41.0",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"mysql2": "^3.22.4",
"openai": "^6.37.0",
"postgres": "^3.4.0",
"posthog-node": "^5.34.1",

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { CatalogModule } from "../catalog/catalog.module";
import { CatalogSourceDbModule } from "../integrations/catalog-source-db/catalog-source-db.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { PL24Module } from "../integrations/pl24/pl24.module";
@@ -8,7 +9,14 @@ import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
@Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
imports: [
PL24Module,
EmexModule,
PartsCatalogsModule,
CatalogModule,
TranslationsModule,
CatalogSourceDbModule,
],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -37,6 +37,8 @@ function createService(db: any) {
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
),
};
const pcatSourceDb = { fetchParts: vi.fn().mockResolvedValue(null) };
const emexSourceDb = { fetchCategoryParts: vi.fn().mockResolvedValue(null) };
const service = new CategoriesService(
db as any,
redis as any,
@@ -46,6 +48,8 @@ function createService(db: any) {
storage as any,
pl24FordLegacyService as any,
translationsService as any,
pcatSourceDb as any,
emexSourceDb as any,
);
return { service, db, redis, pl24Service, translationsService };
}

View File

@@ -2,6 +2,8 @@ import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
@@ -24,6 +26,8 @@ export class CategoriesService {
private storage: StorageService,
private pl24FordLegacyService: PL24FordLegacyService,
private translationsService: TranslationsService,
private pcatSourceDb: PcatSourceDbService,
private emexSourceDb: EmexSourceDbService,
) {}
async getCategoryTree(vehicleId: string) {
@@ -928,12 +932,19 @@ export class CategoriesService {
const vehicleRawData = vehicle.rawData as any;
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
const partsResult = await this.partsCatalogsService.fetchParts(
catalogId,
carId,
groupId,
carParams,
);
// Local dump first; live upstream fallback. Logs hit/miss so prod
// verification can measure source-DB coverage.
let partsResult = await this.pcatSourceDb.fetchParts(catalogId, carId, groupId);
if (partsResult) {
this.logger.debug(`[source-db hit pcat] car=${carId} group=${groupId}`);
} else {
partsResult = await this.partsCatalogsService.fetchParts(
catalogId,
carId,
groupId,
carParams,
);
}
if (partsResult) {
// Flatten part groups into parts
@@ -1081,7 +1092,20 @@ export class CategoriesService {
} else if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
try {
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
// Local dump first (uses vehicles.rawData.ssd persisted by the
// emex mapper). Falls back to live upstream on miss.
const emexSsd = (vehicle.rawData as { ssd?: string } | null)?.ssd;
let emexResult = await this.emexSourceDb.fetchCategoryParts(
emexSsd,
category.linkPath ?? "",
);
if (emexResult) {
this.logger.debug(
`[source-db hit emex] ssd=${emexSsd?.slice(0, 12)}... gid=${category.externalId}`,
);
} else {
emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
}
// Build position code → sequential integer mapping for hotspot linking
const posCodeToIndex = new Map<string, number>();

View File

@@ -69,6 +69,11 @@ export default () => ({
chatwoot: {
hmacToken: process.env.CHATWOOT_HMAC_TOKEN,
},
catalogSource: {
enabled: process.env.CATALOG_SOURCE_DB_ENABLED === "true",
pcatUrl: process.env.PCAT_SOURCE_DB_URL,
emexUrl: process.env.EMEX_SOURCE_DB_URL,
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { EmexSourceDbService } from "./emex-source-db.service";
import { PcatSourceDbService } from "./pcat-source-db.service";
/**
* Local catalog-dump lookup. Both services are always provided; whether they
* connect is decided at runtime from `CATALOG_SOURCE_DB_ENABLED` + the two
* `*_SOURCE_DB_URL` env vars. When disabled / unconfigured, every lookup
* returns null so the caller transparently falls back to the live upstream.
*
* Intentionally no DB schema modelling here — these are raw read-only queries
* against external dump DBs (pc2 Postgres + emex MariaDB) whose shapes are
* frozen snapshots and don't share Drizzle types with the sase schema.
*/
@Module({
providers: [PcatSourceDbService, EmexSourceDbService],
exports: [PcatSourceDbService, EmexSourceDbService],
})
export class CatalogSourceDbModule {}

View File

@@ -0,0 +1,183 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import mysql, { type Pool, type RowDataPacket } from "mysql2/promise";
import type { EmexHotspot, EmexHotspotArea, EmexPart, EmexPartsResult } from "../emex/emex.types";
/**
* Look up a vehicle's parts + schema for a category (PNC group) in the local
* EMEX dump (sase-catalog-src-emex MariaDB). Returns null on any miss so the
* caller falls through to the live emex scrape.
*
* Inputs:
* - vehicleSsd: emex's per-vehicle session-state-descriptor — captured into
* `vehicles.rawData.emexSsd` during live VIN decode. Required (no VIN
* column in the dump; SSD is the only stable vehicle identifier).
* - categoryUrl: the QuickDetails.aspx URL stored in `categories.linkPath`.
* We parse `gid` (the group id) out of it.
*/
@Injectable()
export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EmexSourceDbService.name);
private pool: Pool | null = null;
private enabled = false;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
const enabled = this.config.get<boolean>("catalogSource.enabled");
const url = this.config.get<string>("catalogSource.emexUrl");
if (!enabled || !url) {
this.logger.log(
`[emex-src] disabled (enabled=${enabled}, urlSet=${Boolean(url)}); upstream-only`,
);
return;
}
this.pool = mysql.createPool({
uri: url,
connectionLimit: 5,
connectTimeout: 10_000,
waitForConnections: true,
});
this.enabled = true;
this.logger.log("[emex-src] connected, lookup-first enabled");
}
async onModuleDestroy() {
if (this.pool) {
await this.pool.end();
this.pool = null;
}
}
/**
* Mirror the live `EmexCatalogService.fetchCategoryParts` shape.
* Returns null on miss; never throws.
*/
async fetchCategoryParts(
vehicleSsd: string | null | undefined,
categoryUrl: string,
): Promise<EmexPartsResult | null> {
if (!this.enabled || !this.pool) return null;
if (!vehicleSsd) return null; // no SSD captured → can't locate vehicle in dump
const gid = extractGid(categoryUrl);
if (!gid) return null;
try {
// 1) Resolve vehicle.id from SSD. We try `ssd = ?` first; if the dump
// canonicalized into `unique_key` (hash), the second arm catches it.
const [vehicleRows] = await this.pool.execute<RowDataPacket[]>(
"SELECT id, catalog_id FROM vehicles WHERE ssd = ? OR unique_key = ? LIMIT 1",
[vehicleSsd, vehicleSsd],
);
if (vehicleRows.length === 0) return null;
const vehicleId = vehicleRows[0].id as number;
const catalogId = vehicleRows[0].catalog_id as number;
// 2) Resolve part_group.id from external gid scoped to this catalog.
const [groupRows] = await this.pool.execute<RowDataPacket[]>(
"SELECT id FROM part_groups WHERE catalog_id = ? AND group_id = ? LIMIT 1",
[catalogId, gid],
);
if (groupRows.length === 0) return null;
const groupPk = groupRows[0].id as number;
// 3) Parts for this vehicle in this group.
const [partRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT p.id AS part_id, p.part_number, p.name, p.position_number, p.pnc
FROM vehicle_parts vp
JOIN parts p ON p.id = vp.part_id
WHERE vp.vehicle_id = ? AND vp.group_id = ?
ORDER BY p.position_number, p.id`,
[vehicleId, groupPk],
);
const parts: EmexPart[] = partRows.map((r) => ({
oemCode: String(r.part_number ?? ""),
nameEn: String(r.name ?? ""),
positionCode: r.position_number ?? r.pnc ?? undefined,
}));
// 4) Schema image + hotspots for this group.
const [imgRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT original_url, width, height, hotspots
FROM part_images
WHERE group_id = ? AND image_type IN ('DIAGRAM','SCHEMATIC')
ORDER BY is_primary DESC, sort_order
LIMIT 1`,
[groupPk],
);
let schemaImageUrl: string | null = null;
let schemaWidth = 0;
let schemaHeight = 0;
let hotspots: EmexHotspot[] = [];
if (imgRows.length > 0) {
const img = imgRows[0];
schemaImageUrl = (img.original_url as string) || null;
schemaWidth = (img.width as number) ?? 0;
schemaHeight = (img.height as number) ?? 0;
const rawHotspots = img.hotspots;
if (rawHotspots) {
try {
const parsed = typeof rawHotspots === "string" ? JSON.parse(rawHotspots) : rawHotspots;
hotspots = normalizeHotspots(parsed, partRows);
} catch (err) {
this.logger.debug(
`[emex-src] hotspot JSON parse failed for group ${groupPk}: ${(err as Error).message}`,
);
}
}
}
// Treat fully-empty result as miss so caller falls back to live.
if (parts.length === 0 && !schemaImageUrl) return null;
return { parts, schemaImageUrl, hotspots, schemaWidth, schemaHeight };
} catch (err) {
this.logger.warn(`[emex-src] lookup failed (gid=${gid}): ${(err as Error).message}`);
return null;
}
}
}
/** Extract `gid=...` from a QuickDetails.aspx / similar URL. */
function extractGid(url: string): string | null {
if (!url) return null;
const m = url.match(/[?&]gid=([^&#]+)/i);
if (!m) return null;
try {
return decodeURIComponent(m[1]);
} catch {
return m[1];
}
}
/**
* Convert the dump's hotspots JSON ([{x,y,w,h,part_id}]) into the EmexHotspot
* shape used by the live scraper (grouped by position code).
*/
function normalizeHotspots(parsed: unknown, partRows: RowDataPacket[]): EmexHotspot[] {
if (!Array.isArray(parsed)) return [];
// Map part_id → positionCode using the partRows we already have.
const positionByPartId = new Map<number, string>();
for (const r of partRows) {
const pid = r.part_id as number | undefined;
const pos = (r.position_number ?? r.pnc) as string | undefined;
if (pid && pos) positionByPartId.set(pid, pos);
}
const byKey = new Map<string, EmexHotspotArea[]>();
for (const h of parsed as Array<Record<string, unknown>>) {
const x = Number(h.x ?? h.left ?? 0);
const y = Number(h.y ?? h.top ?? 0);
const w = Number(h.w ?? h.width ?? 0);
const ht = Number(h.h ?? h.height ?? 0);
const pid = Number(h.part_id ?? h.partId ?? 0);
const key = positionByPartId.get(pid) ?? String(h.position ?? h.pnc ?? pid ?? "");
if (!key) continue;
const arr = byKey.get(key) ?? [];
arr.push({ left: x, top: y, width: w, height: ht });
byKey.set(key, arr);
}
return Array.from(byKey.entries()).map(([key, areas]) => ({ key, areas }));
}

View File

@@ -0,0 +1,154 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import postgres, { type Sql } from "postgres";
import type {
PcatPartGroup,
PcatPartsResult,
PcatPosition,
} from "../parts-catalogs/parts-catalogs.types";
/**
* Look up parts for a (catalogId, carId, groupId) triple in the local
* parts-catalogs dump (sase-catalog-src-pcat). On any miss — feature disabled,
* connection failure, no schema image, or no matching parts — returns null so
* the caller can fall through to the live upstream service.
*
* Read-only by design: uses the `pcat_reader` user (or owner if reader not set).
*/
@Injectable()
export class PcatSourceDbService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PcatSourceDbService.name);
private sql: Sql | null = null;
private enabled = false;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
const enabled = this.config.get<boolean>("catalogSource.enabled");
const url = this.config.get<string>("catalogSource.pcatUrl");
if (!enabled || !url) {
this.logger.log(
`[pcat-src] disabled (enabled=${enabled}, urlSet=${Boolean(url)}); upstream-only`,
);
return;
}
this.sql = postgres(url, {
max: 5,
idle_timeout: 30,
connect_timeout: 10,
prepare: false,
});
this.enabled = true;
this.logger.log("[pcat-src] connected, lookup-first enabled");
}
async onModuleDestroy() {
if (this.sql) {
await this.sql.end({ timeout: 5 });
this.sql = null;
}
}
/**
* Mirror the live `PartsCatalogsService.fetchParts` shape. Returns null on
* miss; never throws (DB blips fall back to live upstream).
*/
async fetchParts(
_catalogId: string,
_carId: string,
groupId: string,
): Promise<PcatPartsResult | null> {
if (!this.enabled || !this.sql) return null;
try {
// sase's pcat `groupId` (stored as `categories.linkPath` tail) is the
// page-level schema identifier that maps to the dump's `schema_ext_id`.
// The dump's `groups.id` is a higher-level category and does NOT match.
// Coverage is ~7% schema-hit × ~10% with-parts ≈ ~3-5% net; the misses
// fall through to upstream cleanly (this method returns null).
// Single round-trip: schema + parts + hotspot coordinates via schema_parts.
// We deliberately bypass the dump's `part_groups`+`part_group_items` tables
// — sampling against prod data shows that linkage covers 0 of our hits,
// whereas `schema_parts.part_id → parts` covers all 260 schemas that have
// any part annotation. Everything goes into a single un-named PcatPartGroup
// (the downstream insert flattens partGroups anyway).
const rows = await this.sql<
Array<{
schema_id: string;
img_url: string | null;
img_description: string | null;
part_id: string | null;
part_number: string | null;
part_name: string | null;
part_notice: string | null;
position_number: string | null;
position_x: number | null;
position_y: number | null;
position_width: number | null;
position_height: number | null;
}>
>`
SELECT
si.id::text AS schema_id,
si.img_url AS img_url,
si.img_description AS img_description,
p.id::text AS part_id,
p.part_number AS part_number,
p.name AS part_name,
p.notice AS part_notice,
sp.position_number AS position_number,
sp.position_x AS position_x,
sp.position_y AS position_y,
sp.position_width AS position_width,
sp.position_height AS position_height
FROM schema_images si
LEFT JOIN schema_parts sp ON sp.schema_image_id = si.id
LEFT JOIN parts p ON p.id = sp.part_id
WHERE si.schema_ext_id = ${groupId}
ORDER BY sp.position_number, p.id
`;
if (rows.length === 0) return null;
const first = rows[0];
const partsBucket: PcatPartGroup = { parts: [] };
const positionsByNumber = new Map<string, PcatPosition>();
for (const r of rows) {
if (r.part_number) {
partsBucket.parts.push({
id: r.part_id ?? undefined,
number: r.part_number,
name: r.part_name ?? "",
notice: r.part_notice ?? undefined,
positionNumber: r.position_number ?? undefined,
});
}
if (r.position_number && !positionsByNumber.has(r.position_number)) {
positionsByNumber.set(r.position_number, {
number: r.position_number,
coordinates: [
r.position_x ?? 0,
r.position_y ?? 0,
r.position_width ?? 0,
r.position_height ?? 0,
],
});
}
}
const result: PcatPartsResult = {
img: first.img_url ?? "",
imgDescription: first.img_description ?? undefined,
partGroups: partsBucket.parts.length > 0 ? [partsBucket] : [],
positions: Array.from(positionsByNumber.values()),
};
// Treat schema-image-only (no parts, no positions) as miss so caller falls
// back to live upstream — an image alone isn't useful enough to skip live.
if (result.partGroups.length === 0 && result.positions.length === 0) {
return null;
}
return result;
} catch (err) {
this.logger.warn(`[pcat-src] lookup failed (group=${groupId}): ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -0,0 +1,72 @@
import { Button } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import type React from "react";
interface Crumb {
label: string;
to?: string;
search?: Record<string, unknown>;
}
/**
* Shared header for catalog surfaces. Renders a breadcrumb trail (last item
* non-link), a title (the last crumb by default) and a slot for a trailing
* action (typically a view-mode toggle). Keeps drill pages visually
* consistent — main catalog, brand detail, sub-catalog selectors.
*/
export function CatalogHeader({
crumbs,
title,
subtitle,
onBack,
actions,
}: {
crumbs?: Crumb[];
title: string;
subtitle?: string;
onBack?: () => void;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex items-start gap-3">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
<ArrowLeft className="size-4" />
</Button>
)}
<div className="min-w-0">
{crumbs && crumbs.length > 0 && (
<nav aria-label="breadcrumb" className="mb-1 text-xs text-muted-foreground">
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={`${c.label}-${i}`}>
{c.to && !isLast ? (
<Link
to={c.to}
search={c.search as Record<string, string | undefined> | undefined}
className="hover:underline"
>
{c.label}
</Link>
) : (
<span className={isLast ? "font-medium text-foreground" : undefined}>
{c.label}
</span>
)}
{!isLast && <span className="mx-1.5">/</span>}
</span>
);
})}
</nav>
)}
<h1 className="text-2xl font-bold leading-tight">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{actions && <div className="shrink-0">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import type { RecentBrand } from "@/lib/recently-used-brands";
import { Link } from "@tanstack/react-router";
import { Clock, Lock } from "lucide-react";
interface RecentBrandsStripProps {
recents: RecentBrand[];
// Caller passes the canonical access map so each chip knows whether to
// route to the catalog or the subscription upsell. Avoids re-querying.
accessByName: Map<string, boolean>;
}
/**
* Horizontal scrollable strip of up to 8 brands the user has recently
* opened in the catalog. Lives above the main brand grid and renders
* only when there is at least one entry — silent in cold-start state.
*/
export function RecentBrandsStrip({ recents, accessByName }: RecentBrandsStripProps) {
const { t } = useTranslation();
if (recents.length === 0) return null;
return (
<section aria-labelledby="recent-brands-heading" className="space-y-3">
<div className="flex items-center gap-2">
<Clock className="size-3.5 text-muted-foreground" />
<h2
id="recent-brands-heading"
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"
>
{t("catalog.recentSection")}
</h2>
</div>
<ul className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:thin]">
{recents.map((b) => {
const hasAccess = accessByName.get(b.brandName) ?? true;
return (
<li key={b.brandName} className="shrink-0">
{hasAccess ? (
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName: b.brandName }}
search={{ catalog: undefined }}
onClick={() =>
capture("catalog_recent_brand_clicked", { brand_name: b.brandName })
}
className="group flex w-24 flex-col items-center gap-1.5 rounded-xl border border-border bg-card p-2.5 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<CarBrandLogo brandName={b.brandName} logoUrl={b.logoUrl} size={32} />
<span className="truncate w-full text-center text-[11px] font-medium leading-3">
{b.brandName}
</span>
</Link>
) : (
<Link
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", {
brand_name: b.brandName,
surface: "recents",
})
}
className="group flex w-24 flex-col items-center gap-1.5 rounded-xl border border-border/50 bg-muted/30 p-2.5"
title={t("catalog.locked")}
>
<div className="relative">
<CarBrandLogo
brandName={b.brandName}
logoUrl={b.logoUrl}
size={32}
className="grayscale opacity-70"
/>
<div className="absolute -bottom-1 -right-1 flex size-3.5 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2 text-background" />
</div>
</div>
<span className="truncate w-full text-center text-[11px] font-medium leading-3 text-foreground/70">
{b.brandName}
</span>
</Link>
)}
</li>
);
})}
</ul>
</section>
);
}

View File

@@ -0,0 +1,77 @@
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
type ViewIconProps,
} from "@/components/categories/view-toggle-icons";
import { useTranslation } from "@/lib/i18n";
import { cn } from "@sase/ui";
export type ViewMode = "grid" | "tree" | "columns";
const OPTIONS: Array<{
value: ViewMode;
labelKey: string;
Icon: React.ComponentType<ViewIconProps>;
}> = [
{ value: "grid", labelKey: "catalog.view.grid", Icon: CardsViewIcon },
{ value: "tree", labelKey: "catalog.view.list", Icon: TreeViewIcon },
{ value: "columns", labelKey: "catalog.view.columns", Icon: ListViewIcon },
];
/**
* Shared 3-mode view toggle (grid / tree / columns) used across catalog
* surfaces. Replaces three separate inline implementations that drifted
* apart on labels, sizing, and a11y. `groupLabelKey` lets the caller scope
* the aria-label to its context (e.g. "brand view", "model view").
*/
export function ViewModeToggle({
value,
onChange,
groupLabelKey = "catalog.view.groupLabel",
className,
}: {
value: ViewMode;
onChange: (mode: ViewMode) => void;
groupLabelKey?: string;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
// biome-ignore lint/a11y/useSemanticElements: a button group, not a form fieldset; role="group" is the correct ARIA here.
role="group"
aria-label={t(groupLabelKey)}
className={cn(
"inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5",
className,
)}
>
{OPTIONS.map(({ value: v, labelKey, Icon }) => {
const active = value === v;
const label = t(labelKey);
return (
<button
key={v}
type="button"
aria-pressed={active}
aria-label={label}
title={label}
onClick={() => onChange(v)}
data-faro-user-action-name={`view-mode-${v}`}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md",
"transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
)}
>
<Icon isActive={active} className="size-4" />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,61 @@
// User- and device-scoped "recently-used brands" log for the catalog.
//
// Why localStorage + userId-scope: this is per-device behavioural shortcut
// (last brands you clicked in the catalog), not authoritative cross-device
// state. Backend doesn't track it yet and adding an endpoint would be
// premature — we'd lose the value of the data we don't have. Scoping the
// key by userId keeps a second user on the same browser from inheriting
// the first user's list (mirrors the trial-banner pattern).
const MAX_ITEMS = 12; // store more than we show, room for ranking later
const KEY_PREFIX = "sase-recent-brands";
export interface RecentBrand {
brandName: string;
logoUrl: string | null;
at: number; // epoch ms
}
function storageKey(userId: string | null | undefined): string {
return `${KEY_PREFIX}-${userId ?? "anon"}`;
}
export function readRecentBrands(userId: string | null | undefined, limit = 8): RecentBrand[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(it): it is RecentBrand =>
!!it &&
typeof it === "object" &&
typeof it.brandName === "string" &&
(it.logoUrl === null || typeof it.logoUrl === "string") &&
typeof it.at === "number",
)
.slice(0, limit);
} catch {
return [];
}
}
export function recordRecentBrand(
userId: string | null | undefined,
entry: { brandName: string; logoUrl: string | null },
): void {
if (typeof window === "undefined") return;
try {
const existing = readRecentBrands(userId, MAX_ITEMS);
const filtered = existing.filter((it) => it.brandName !== entry.brandName);
const next: RecentBrand[] = [
{ brandName: entry.brandName, logoUrl: entry.logoUrl, at: Date.now() },
...filtered,
].slice(0, MAX_ITEMS);
window.localStorage.setItem(storageKey(userId), JSON.stringify(next));
} catch {
// localStorage blocked (private mode, quota) — silently degrade.
}
}

View File

@@ -0,0 +1,26 @@
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { useCallback, useState } from "react";
type Setting = "brandViewMode" | "modelViewMode";
export type ViewMode = "grid" | "tree" | "columns";
/**
* View-mode state that mirrors localStorage via user-settings — same pattern
* the catalog pages were inlining. Returns the active mode and an updater
* that also persists. Decoupled from the toggle so any surface can read its
* persisted preference.
*/
export function useViewMode(
key: Setting,
fallback: ViewMode = "grid",
): [ViewMode, (next: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(() => getUserSettings()[key] ?? fallback);
const set = useCallback(
(next: ViewMode) => {
setMode(next);
setUserSetting(key, next);
},
[key],
);
return [mode, set];
}

View File

@@ -34,6 +34,42 @@
"a11y": {
"skipToContent": "Skip to content"
},
"history": {
"title": "Search History",
"subtitle": "VINs you've decoded — reopen, copy or remove.",
"newSearch": "Decode New VIN",
"searchPlaceholder": "Search by brand, model, VIN or year",
"itemCount": "{count} vehicle(s)",
"sort": {
"label": "Sort",
"newest": "Newest first",
"oldest": "Oldest first",
"brandAZ": "Brand (A→Z)",
"yearDesc": "Year (newest first)"
},
"copyVin": "Copy VIN",
"vinCopied": "VIN copied",
"deleteAction": "Remove",
"deleteConfirmTitle": "Remove this entry?",
"deleteConfirmDescription": "{label} will be removed from your history. The shared vehicle data is preserved for other users.",
"deleteConfirmAction": "Yes, remove",
"deleteCancelAction": "Cancel",
"deleted": "Removed from history.",
"deleteFailed": "Couldn't remove. Please try again.",
"planLocked": "Off-plan",
"planLockedTooltip": "This brand isn't in your current plan — upgrade to reopen.",
"loadMore": "Show more",
"empty": {
"title": "No VINs decoded yet",
"description": "Decode a VIN to see results here. You'll land back on its parts catalog and diagrams in one click.",
"cta": "Decode your first VIN"
},
"noResults": {
"title": "No matches",
"subtitle": "Try changing the query or the sort order.",
"clear": "Clear filters"
}
},
"nav": {
"dashboard": "Dashboard",
"search": "Search",
@@ -88,6 +124,7 @@
},
"catalog": {
"title": "Parts Catalog",
"subtitle": "Browse brand catalogs — find parts without a VIN.",
"brands": "Brands",
"models": "Models",
"allBrands": "All Brands",
@@ -97,6 +134,32 @@
"locked": "This brand is not in your plan",
"upgradeCta": "Upgrade Plan",
"loadingModels": "Loading models...",
"view": {
"groupLabel": "View mode",
"grid": "Grid",
"list": "List",
"columns": "Columns"
},
"brandSearchPlaceholder": "Search brand",
"brandSearchNoMatch": "No brand matches \"{query}\"",
"modelSearchPlaceholder": "Search model, engine or year",
"modelSearchNoMatch": "No matching models",
"hideLocked": "Hide brands not in plan",
"showLocked": "Show brands not in plan",
"lockedSection": "Brands not in your plan",
"lockedSectionHint": "Upgrade to access these brands.",
"inPlanSection": "Brands in your plan",
"recentSection": "Recently visited",
"goToModels": "Browse models",
"selectBrandHint": "Select a brand from the left",
"columnsBrowseHint": "Pick a brand on the left and its models appear here.",
"modelCount2": "{count} model(s)",
"sortLabel": "Sort",
"sort": {
"newest": "Year (newest first)",
"oldest": "Year (oldest first)",
"alphabetical": "Model (A→Z)"
},
"tabSasetr": "SASE",
"tabPl24": "Pl24",
"tabPcat": "Pcat",

View File

@@ -34,6 +34,42 @@
"a11y": {
"skipToContent": "İçeriğe atla"
},
"history": {
"title": "Arama Geçmişi",
"subtitle": "Daha önce çözdüğün şase numaraları — yeniden aç, kopyala veya sil.",
"newSearch": "Yeni Şase Çöz",
"searchPlaceholder": "Marka, model, şase veya yıl ara",
"itemCount": "{count} araç",
"sort": {
"label": "Sırala",
"newest": "En yeniden eskiye",
"oldest": "En eskiden yeniye",
"brandAZ": "Markaya göre (A→Z)",
"yearDesc": "Yıla göre (yeniden eskiye)"
},
"copyVin": "Şase numarasını kopyala",
"vinCopied": "Şase numarası kopyalandı",
"deleteAction": "Sil",
"deleteConfirmTitle": "Bu kaydı sil?",
"deleteConfirmDescription": "{label} aracı geçmişinizden kaldırılacak. Araç verileri başka kullanıcılar için saklı kalır.",
"deleteConfirmAction": "Evet, sil",
"deleteCancelAction": "Vazgeç",
"deleted": "Geçmişten kaldırıldı.",
"deleteFailed": "Silinemedi. Lütfen tekrar deneyin.",
"planLocked": "Plan dışı",
"planLockedTooltip": "Bu marka mevcut planınızda yok — açabilmek için planınızı yükseltin.",
"loadMore": "Daha fazla göster",
"empty": {
"title": "Henüz şase çözmedin",
"description": "Bir şase numarası gir, sonuçları burada bul. Parça kataloğuna ve şemalara tek tıkla dönersin.",
"cta": "İlk şase aramayı yap"
},
"noResults": {
"title": "Eşleşen kayıt yok",
"subtitle": "Aramayı veya sıralamayı değiştirmeyi dene.",
"clear": "Filtreleri temizle"
}
},
"nav": {
"dashboard": "Gösterge Paneli",
"search": "Arama",
@@ -88,6 +124,7 @@
},
"catalog": {
"title": "Parça Kataloğu",
"subtitle": "Marka kataloglarına göz at — şase bilmeden parça bul.",
"brands": "Markalar",
"models": "Modeller",
"allBrands": "Tüm Markalar",
@@ -97,6 +134,32 @@
"locked": "Bu marka planınızda yok",
"upgradeCta": "Planını Yükselt",
"loadingModels": "Modeller yükleniyor...",
"view": {
"groupLabel": "Görünüm modu",
"grid": "Izgara",
"list": "Liste",
"columns": "Sütun"
},
"brandSearchPlaceholder": "Marka ara",
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok",
"modelSearchPlaceholder": "Model, motor veya yıl ara",
"modelSearchNoMatch": "Eşleşen model yok",
"hideLocked": "Plan dışı markaları gizle",
"showLocked": "Plan dışı markaları göster",
"lockedSection": "Plan dışı markalar",
"lockedSectionHint": "Erişmek istediğin markalar için planını yükselt.",
"inPlanSection": "Planındaki markalar",
"recentSection": "Son ziyaret ettiklerin",
"goToModels": "Modellere Git",
"selectBrandHint": "Soldan bir marka seç",
"columnsBrowseHint": "Soldaki listeden bir marka seçince modeller burada görünür.",
"modelCount2": "{count} model",
"sortLabel": "Sırala",
"sort": {
"newest": "Yıla göre (yeniden eskiye)",
"oldest": "Yıla göre (eskiden yeniye)",
"alphabetical": "Modele göre (A→Z)"
},
"tabSasetr": "SASE",
"tabPl24": "Pl24",
"tabPcat": "Pcat",

View File

@@ -36,7 +36,6 @@ import {
CreditCard,
FlaskConical,
History,
LayoutDashboard,
Library,
LogOut,
Mail,
@@ -69,7 +68,6 @@ type NavItem = {
};
const mainMenuItems: readonly NavItem[] = [
{ to: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard, exact: true },
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },

View File

@@ -1,20 +1,20 @@
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
} from "@/components/categories/view-toggle-icons";
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { RecentBrandsStrip } from "@/components/catalog/recent-brands-strip";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Skeleton, cn } from "@sase/ui";
import { Button } from "@sase/ui";
import { KEYS_10 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { readRecentBrands, recordRecentBrand } from "@/lib/recently-used-brands";
import { useViewMode } from "@/lib/use-view-mode";
import { Button, Input, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronRight, Library, Lock } from "lucide-react";
import { useState } from "react";
import { ChevronRight, Library, Lock, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
});
@@ -29,93 +29,243 @@ interface CatalogBrand {
function CatalogBrandsPage() {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().brandViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("brandViewMode", mode);
};
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [viewMode, setViewMode] = useViewMode("brandViewMode");
const [query, setQuery] = useState("");
const [hideLocked, setHideLocked] = useState(false);
const viewedRef = useRef(false);
// Read recents once per user — they don't change inside this page session.
const recents = useMemo(() => readRecentBrands(userId, 8), [userId]);
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
staleTime: 5 * 60 * 1000,
});
const accessByName = useMemo(() => {
const m = new Map<string, boolean>();
for (const b of brands ?? []) m.set(b.brandName, b.hasAccess);
return m;
}, [brands]);
useEffect(() => {
if (brands && !viewedRef.current) {
viewedRef.current = true;
const inPlan = brands.filter((b) => b.hasAccess).length;
capture("catalog_brands_viewed", {
total: brands.length,
in_plan: inPlan,
locked: brands.length - inPlan,
});
}
}, [brands]);
// Filter + sort: in-plan brands first (alphabetical), locked second
// (alphabetical). Search wipes the section split; matches all in one list.
const { inPlan, locked, hasQuery, filteredTotal } = useMemo(() => {
const empty = {
inPlan: [] as CatalogBrand[],
locked: [] as CatalogBrand[],
hasQuery: false,
filteredTotal: 0,
};
if (!brands) return empty;
const q = query.trim().toLocaleLowerCase("tr");
const matches = q
? brands.filter((b) => b.brandName.toLocaleLowerCase("tr").includes(q))
: brands;
const ip = matches.filter((b) => b.hasAccess).sort(byBrandName);
const lk = matches.filter((b) => !b.hasAccess).sort(byBrandName);
return { inPlan: ip, locked: lk, hasQuery: q.length > 0, filteredTotal: matches.length };
}, [brands, query]);
function changeViewMode(next: typeof viewMode) {
setViewMode(next);
capture("catalog_view_mode_changed", { mode: next, surface: "brands" });
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
<CatalogHeader
title={t("catalog.title")}
subtitle={t("catalog.subtitle")}
actions={
<ViewModeToggle
value={viewMode}
onChange={changeViewMode}
groupLabelKey="catalog.view.groupLabel"
/>
}
/>
{/* Recently used — shown only when the user has actually visited some
brand catalog before, and the brands list has loaded so we know
access state for each chip. Hidden in cold-start state. */}
{recents.length > 0 && brands && (
<RecentBrandsStrip recents={recents} accessByName={accessByName} />
)}
{/* Toolbar — search + hide-locked toggle */}
{!isLoading && brands && brands.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (e.target.value.trim()) {
capture("catalog_brand_search_used", { length: e.target.value.length });
}
}}
placeholder={t("catalog.brandSearchPlaceholder")}
aria-label={t("catalog.brandSearchPlaceholder")}
className="pl-9"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => {
const next = !hideLocked;
setHideLocked(next);
capture("catalog_hide_locked_toggled", { hidden: next });
}}
aria-pressed={hideLocked}
className="inline-flex items-center gap-2 self-start rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Lock className="size-3.5" />
{hideLocked ? t("catalog.showLocked") : t("catalog.hideLocked")}
</button>
</div>
<div
role="tablist"
aria-label="Görünüm modu"
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
>
{[
{ mode: "grid" as const, Icon: CardsViewIcon, label: "Izgara" },
{ mode: "tree" as const, Icon: TreeViewIcon, label: "Liste" },
{ mode: "columns" as const, Icon: ListViewIcon, label: "Sütun" },
].map(({ mode, Icon, label }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => changeViewMode(mode)}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
viewMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
title={label}
>
<Icon isActive={viewMode === mode} className="size-3.5" />
</button>
))}
</div>
</div>
)}
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
<BrandGridSkeleton />
) : !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>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
) : filteredTotal === 0 ? (
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium">{t("catalog.brandSearchNoMatch", { query })}</p>
</div>
) : viewMode === "tree" ? (
<BrandListTree brands={brands} />
<BrandListTree inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
) : viewMode === "columns" ? (
<BrandListColumns inPlan={inPlan} locked={hideLocked ? [] : locked} />
) : (
<BrandListColumns brands={brands} />
<BrandGrid inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
)}
</div>
);
}
/* ── Grid card (existing) ── */
function byBrandName(a: CatalogBrand, b: CatalogBrand) {
return a.brandName.localeCompare(b.brandName, "tr");
}
function BrandGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
);
}
/* ── Grid ── */
function BrandGrid({
inPlan,
locked,
hasQuery,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
hasQuery: boolean;
}) {
const { t } = useTranslation();
// When the user searched, drop the section headings — a single visual list
// matches the user's mental model ("show me matches").
if (hasQuery) {
const all = [...inPlan, ...locked];
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{all.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
);
}
return (
<div className="space-y-8">
{inPlan.length > 0 && (
<section>
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{inPlan.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
</section>
)}
{locked.length > 0 && (
<section>
<SectionHeader
label={t("catalog.lockedSection")}
count={locked.length}
hint={t("catalog.lockedSectionHint")}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{locked.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
</section>
)}
</div>
);
}
function SectionHeader({ label, count, hint }: { label: string; count: number; hint?: string }) {
return (
<div className="mb-3 flex flex-wrap items-baseline gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</h2>
<span className="text-xs tabular-nums text-muted-foreground/70">({count})</span>
{hint && <p className="text-xs text-muted-foreground/80">{hint}</p>}
</div>
);
}
function BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation();
if (!brand.hasAccess) {
return (
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
{/* Diagonal stripe overlay for locked feel */}
<Link
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
}
className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center transition-colors hover:border-primary/30 hover:bg-muted/50"
>
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
@@ -131,7 +281,7 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
size={40}
className="grayscale opacity-70"
/>
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<div className="absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2.5 text-background" />
</div>
</div>
@@ -139,23 +289,26 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
{t("catalog.locked")}
</p>
<Link
to="/dashboard/subscription"
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
>
<span className="relative mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand transition-colors group-hover:text-brand/80">
{t("catalog.upgradeCta")}
<ChevronRight className="size-3" />
</Link>
</div>
</span>
</Link>
);
}
return (
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
params={{ brandName: brand.brandName }}
search={{ catalog: undefined }}
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
onClick={() =>
capture("catalog_brand_clicked", {
brand_name: brand.brandName,
service_names: brand.serviceNames,
})
}
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:border-primary/30 hover:bg-accent/40 hover:scale-[1.02]"
>
<CarBrandLogo
brandName={brand.brandName}
@@ -170,62 +323,111 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
/* ── Tree (flat list) ── */
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
function BrandListTree({
inPlan,
locked,
hasQuery,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
hasQuery: boolean;
}) {
const { t } = useTranslation();
if (hasQuery) {
return <TreeBlock brands={[...inPlan, ...locked]} />;
}
return (
<div className="space-y-6">
{inPlan.length > 0 && (
<section>
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
<TreeBlock brands={inPlan} />
</section>
)}
{locked.length > 0 && (
<section>
<SectionHeader
label={t("catalog.lockedSection")}
count={locked.length}
hint={t("catalog.lockedSectionHint")}
/>
<TreeBlock brands={locked} />
</section>
)}
</div>
);
}
function TreeBlock({ brands }: { brands: CatalogBrand[] }) {
return (
<div className="divide-y rounded-lg border">
{brands.map((brand) => {
if (!brand.hasAccess) {
return (
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
return (
{brands.map((brand) =>
brand.hasAccess ? (
<Link
key={brand.brandName}
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
params={{ brandName: brand.brandName }}
search={{ catalog: undefined }}
onClick={() =>
capture("catalog_brand_clicked", {
brand_name: brand.brandName,
service_names: brand.serviceNames,
})
}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
) : (
<Link
key={brand.brandName}
to="/dashboard/subscription"
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
}
className="flex items-center gap-3 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
),
)}
</div>
);
}
/* ── Columns (left: brand list, right: detail + CTA) ── */
/* ── Columns ── */
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
function BrandListColumns({
inPlan,
locked,
}: {
inPlan: CatalogBrand[];
locked: CatalogBrand[];
}) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string | null>(null);
const ordered = [...inPlan, ...locked];
const [selectedName, setSelectedName] = useState<string | null>(
() => ordered[0]?.brandName ?? null,
);
const navigate = useNavigate();
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
const selected = ordered.find((b) => b.brandName === selectedName) ?? null;
return (
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
{/* Left panel */}
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
{brands.map((brand) => (
<div className="flex min-h-80 max-h-[480px] overflow-hidden rounded-lg border">
<div className="w-[240px] shrink-0 overflow-y-auto border-r">
{ordered.map((brand) => (
<button
key={brand.brandName}
type="button"
onClick={() => setSelectedName(brand.brandName)}
disabled={!brand.hasAccess}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
"focus-visible:outline-none focus-visible:bg-accent",
brand.hasAccess ? "hover:bg-accent" : "opacity-60 hover:opacity-100",
selectedName === brand.brandName && "bg-accent font-medium",
)}
>
@@ -239,36 +441,49 @@ function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
</button>
))}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
{selected ? (
<div className="space-y-4">
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
<p className="text-lg font-semibold">{selected.brandName}</p>
{selected.hasAccess ? (
<Button
onClick={() =>
onClick={() => {
capture("catalog_brand_clicked", {
brand_name: selected.brandName,
service_names: selected.serviceNames,
surface: "columns",
});
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName: encodeURIComponent(selected.brandName) },
params: { brandName: selected.brandName },
search: { catalog: undefined },
})
}
});
}}
>
Modellere Git
{t("catalog.goToModels")}
<ChevronRight className="ml-1 size-4" />
</Button>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
<Button variant="outline" asChild>
<Button
variant="outline"
asChild
onClick={() =>
capture("catalog_locked_brand_upgrade_clicked", {
brand_name: selected.brandName,
surface: "columns",
})
}
>
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
<p className="text-sm text-muted-foreground">{t("catalog.columnsBrowseHint")}</p>
)}
</div>
</div>

View File

@@ -1,19 +1,29 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
} from "@/components/categories/view-toggle-icons";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_4, KEYS_9 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { capture } from "@/lib/posthog";
import { recordRecentBrand } from "@/lib/recently-used-brands";
import { useViewMode } from "@/lib/use-view-mode";
import { Badge, Input, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
import { useState } from "react";
import {
BookOpen,
Car,
ChevronRight,
Database,
Layers,
Search as SearchIcon,
Wrench,
X,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
validateSearch: (search: Record<string, unknown>) => ({
@@ -40,33 +50,40 @@ interface CatalogVehicle {
catalogPath: string | null;
}
type SortMode = "newest" | "oldest" | "alphabetical";
// Each catalog service gets its own icon so the multi-source selector reads
// as a real menu rather than four identical BookOpen tiles.
function iconForService(serviceName: string) {
const lower = serviceName.toLowerCase();
if (lower.includes("pl24")) return Layers;
if (lower.includes("pcat")) return Database;
if (lower.includes("emex")) return Zap;
if (lower.includes("tecdoc")) return Wrench;
return BookOpen;
}
function CatalogModelsPage() {
const { brandName } = Route.useParams();
const { catalog: activeCatalog } = Route.useSearch();
const { t } = useTranslation();
const navigate = useNavigate();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [viewMode, setViewMode] = useViewMode("modelViewMode");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("newest");
const viewedRef = useRef(false);
const decodedBrandName = decodeURIComponent(brandName);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().modelViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("modelViewMode", mode);
};
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
queryKey: ["catalog-catalogs", decodedBrandName],
queryFn: () =>
api.get<CatalogEntry[]>(`/catalog/brands/${encodeURIComponent(decodedBrandName)}/catalogs`),
staleTime: 1000 * 60 * 30, // 30 min
queryFn: () => api.get<CatalogEntry[]>(`/catalog/brands/${decodedBrandName}/catalogs`),
staleTime: 30 * 60 * 1000,
});
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
@@ -77,73 +94,117 @@ function CatalogModelsPage() {
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
queryFn: () => {
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
return api.get<CatalogVehicle[]>(
`/catalog/brands/${encodeURIComponent(decodedBrandName)}/models${serviceParam}`,
);
return api.get<CatalogVehicle[]>(`/catalog/brands/${decodedBrandName}/models${serviceParam}`);
},
enabled: shouldShowModels && !catalogsLoading,
});
const handleBack = () => {
useEffect(() => {
if (models && !viewedRef.current && shouldShowModels) {
viewedRef.current = true;
capture("catalog_models_viewed", {
brand_name: decodedBrandName,
catalog: activeCatalog ?? null,
count: models.length,
});
// Visit-tracked here rather than at the link click — a click that
// never resolves into a real visit (auth gate, slow nav cancel) is
// not a "recently used" signal worth surfacing.
recordRecentBrand(userId, { brandName: decodedBrandName, logoUrl: null });
}
}, [models, shouldShowModels, decodedBrandName, activeCatalog, userId]);
// Reset the "models viewed" lock when the active catalog changes so we
// capture one event per (brand × catalog) pair.
useEffect(() => {
viewedRef.current = false;
}, []);
const sortedFiltered = useMemo(() => {
if (!models) return [];
const q = query.trim().toLocaleLowerCase("tr");
const filtered = q
? models.filter((m) => {
const hay = `${m.model} ${m.engine ?? ""} ${m.year ?? ""} ${
m.bodyType ?? ""
}`.toLocaleLowerCase("tr");
return hay.includes(q);
})
: models;
const sorted = [...filtered];
switch (sort) {
case "oldest":
sorted.sort((a, b) => (Number(a.year ?? 0) || 0) - (Number(b.year ?? 0) || 0));
break;
case "alphabetical":
sorted.sort((a, b) => a.model.localeCompare(b.model, "tr"));
break;
default:
sorted.sort((a, b) => (Number(b.year ?? 0) || 0) - (Number(a.year ?? 0) || 0));
}
return sorted;
}, [models, query, sort]);
function handleBack() {
if (isMultiCatalog && activeCatalog) {
// Go back to catalog selector
navigate({ to: ".", search: { catalog: undefined } });
} else {
navigate({ to: "/dashboard/catalog" });
}
};
}
function changeViewMode(next: typeof viewMode) {
setViewMode(next);
capture("catalog_view_mode_changed", { mode: next, surface: "models" });
}
const crumbs =
isMultiCatalog && activeCatalog
? [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: decodedBrandName,
to: "/dashboard/catalog/$brandName",
search: { catalog: undefined },
},
{ label: activeCatalogLabel ?? activeCatalog },
]
: [{ label: t("catalog.title"), to: "/dashboard/catalog" }, { label: decodedBrandName }];
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>
<CatalogHeader
crumbs={crumbs}
title={
isMultiCatalog && activeCatalog
? (activeCatalogLabel ?? decodedBrandName)
: decodedBrandName
}
onBack={handleBack}
actions={
shouldShowModels && models && models.length > 0 ? (
<ViewModeToggle
value={viewMode}
onChange={changeViewMode}
groupLabelKey="catalog.view.groupLabel"
/>
) : undefined
}
/>
{catalogsLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
))}
</div>
) : isMultiCatalog && !activeCatalog && catalogs ? (
// 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">
{KEYS_9.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{KEYS_9.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
) : !models || models.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
@@ -151,56 +212,78 @@ function CatalogModelsPage() {
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div>
) : (
<div className="space-y-3">
{/* View toggle */}
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn(
"rounded p-1.5",
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (e.target.value.trim()) {
capture("catalog_model_search_used", {
brand_name: decodedBrandName,
length: e.target.value.length,
});
}
}}
placeholder={t("catalog.modelSearchPlaceholder")}
aria-label={t("catalog.modelSearchPlaceholder")}
className="pl-9"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
title="Izgara"
>
<CardsViewIcon isActive={viewMode === "grid"} />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn(
"rounded p-1.5",
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Liste"
>
<TreeViewIcon isActive={viewMode === "tree"} />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"rounded p-1.5",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<ListViewIcon isActive={viewMode === "columns"} />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.modelCount2", { count: sortedFiltered.length })}
</span>
<select
value={sort}
onChange={(e) => {
const v = e.target.value as SortMode;
setSort(v);
capture("catalog_model_sort_changed", { sort: v });
}}
aria-label={t("catalog.sortLabel")}
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<option value="newest">{t("catalog.sort.newest")}</option>
<option value="oldest">{t("catalog.sort.oldest")}</option>
<option value="alphabetical">{t("catalog.sort.alphabetical")}</option>
</select>
</div>
</div>
{viewMode === "grid" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
{sortedFiltered.length === 0 ? (
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium">{t("catalog.modelSearchNoMatch")}</p>
</div>
) : viewMode === "tree" ? (
<ModelListTree models={models} brandName={brandName} />
<ModelListTree models={sortedFiltered} brandName={brandName} />
) : viewMode === "columns" ? (
<ModelListColumns models={sortedFiltered} brandName={brandName} />
) : (
<ModelListColumns models={models} brandName={brandName} />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sortedFiltered.map((model) => (
<ModelCard
key={model.id}
model={model}
brandName={brandName}
brandLabel={decodedBrandName}
/>
))}
</div>
)}
</div>
)}
@@ -218,52 +301,80 @@ function CatalogSelector({
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" />
{catalogs.map((cat) => {
const Icon = iconForService(cat.serviceName);
return (
<Link
key={cat.serviceName}
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: cat.serviceName }}
onClick={() =>
capture("catalog_subcatalog_selected", {
brand_name: brandLabel,
service: cat.serviceName,
})
}
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/30 hover:bg-accent/40"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-5 text-primary" />
</div>
<div>
<p className="font-semibold">{cat.displayName}</p>
</div>
</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>
))}
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
);
})}
</div>
</div>
);
}
function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: string }) {
function ModelCard({
model,
brandName,
brandLabel,
}: {
model: CatalogVehicle;
brandName: string;
brandLabel: string;
}) {
return (
<Link
to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
onClick={() =>
capture("catalog_model_clicked", {
brand_name: brandLabel,
model: model.model,
year: model.year,
service: model.serviceName,
})
}
preload="intent"
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/30 hover:bg-accent/40"
>
<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>}
<p className="font-semibold leading-5">{model.model}</p>
<div className="mt-2 flex flex-wrap gap-1">
{model.year && (
<Badge variant="secondary" className="tabular-nums">
{model.year}
</Badge>
)}
{model.engine && <Badge variant="outline">{model.engine}</Badge>}
{model.bodyType && <Badge variant="outline">{model.bodyType}</Badge>}
{model.transmission && <Badge variant="outline">{model.transmission}</Badge>}
</div>
</Link>
);

View File

@@ -1,10 +1,12 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, ChevronRight, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
component: EmexVehicleListPage,
@@ -44,10 +46,19 @@ interface EmexVehicle {
function EmexVehicleListPage() {
const { t } = useTranslation();
const { catalogCode } = Route.useParams();
const navigate = useNavigate();
const viewedRef = useRef(false);
// Current SSD state for wizard navigation
const [ssd, setSsd] = useState("");
useEffect(() => {
if (!viewedRef.current) {
viewedRef.current = true;
capture("catalog_emex_wizard_opened", { catalog_code: catalogCode });
}
}, [catalogCode]);
// Fetch wizard data for current SSD
const {
data: wizardRows,
@@ -117,22 +128,22 @@ function EmexVehicleListPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog" search={{}}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToBrands")}
</Button>
</Link>
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
{determined.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" />
{t("catalog.resetSelection")}
</Button>
)}
</div>
<CatalogHeader
crumbs={[
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{ label: decodeURIComponent(catalogCode) },
]}
title={decodeURIComponent(catalogCode)}
onBack={() => navigate({ to: "/dashboard/catalog" })}
actions={
determined.length > 0 ? (
<Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" />
{t("catalog.resetSelection")}
</Button>
) : undefined
}
/>
{/* Determined params — shown as tags */}
{determined.length > 0 && (

View File

@@ -1,9 +1,13 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { KEYS_10 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Library } from "lucide-react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Library } from "lucide-react";
import { useEffect, useRef } from "react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
component: PcatModelsPage,
@@ -22,28 +26,39 @@ interface PcatModel {
function PcatModelsPage() {
const { t } = useTranslation();
const { catalogId } = Route.useParams();
const navigate = useNavigate();
const viewedRef = useRef(false);
const { data: models, isLoading } = useQuery({
queryKey: ["pcat-models", catalogId],
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
});
useEffect(() => {
if (models && !viewedRef.current) {
viewedRef.current = true;
capture("catalog_pcat_models_viewed", {
catalog_id: catalogId,
count: models.length,
});
}
}, [models, catalogId]);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog" search={{}}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToBrands")}
</Button>
</Link>
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
</div>
<CatalogHeader
crumbs={[
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{ label: catalogId.toUpperCase() },
]}
title={catalogId.toUpperCase()}
onBack={() => navigate({ to: "/dashboard/catalog" })}
/>
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
) : !models || models.length === 0 ? (
@@ -55,7 +70,15 @@ function PcatModelsPage() {
key={model.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId"
params={{ catalogId, modelId: model.id }}
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"
onClick={() =>
capture("catalog_pcat_model_clicked", {
catalog_id: catalogId,
model_id: model.id,
model_name: model.name,
})
}
preload="intent"
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/30 hover:bg-accent/40"
>
{model.imgUrl ? (
<img
@@ -64,18 +87,20 @@ function PcatModelsPage() {
className="mb-2 h-16 w-auto object-contain"
/>
) : (
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
<Library className="size-5 text-emerald-500" />
<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">{model.name}</p>
{(model.yearFrom || model.yearTo) && (
<p className="mt-0.5 text-xs text-muted-foreground">
{model.yearFrom || "?"} - {model.yearTo || "..."}
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
{model.yearFrom || "?"} {model.yearTo || ""}
</p>
)}
{model.carsCount > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">{model.carsCount} araç</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("catalog.modelCount2", { count: model.carsCount })}
</p>
)}
</Link>
))}

View File

@@ -1,66 +1,453 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Skeleton,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Car, Clock, Copy, Lock, Search as SearchIcon, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
interface VehicleHistoryItem {
id: string;
vin: string;
brandId: string;
brandName: string;
model: string | null;
year: number | null;
engine: string | null;
bodyType: string | null;
lastAccessedAt: string | null;
}
interface Subscription {
status: string;
plan?: { name: string; key: string };
brands?: { brandId: string; brandName: string }[];
}
const PAGE_SIZE = 24;
type SortMode = "newest" | "oldest" | "brand" | "yearDesc";
export const Route = createFileRoute("/dashboard/history")({
component: HistoryPage,
});
function relativeFromNow(iso: string | null, locale: "tr" | "en"): string {
if (!iso) return "";
const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return "";
const diffSec = Math.round((ts - Date.now()) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale === "tr" ? "tr" : "en", {
numeric: "auto",
});
const abs = Math.abs(diffSec);
if (abs < 60) return rtf.format(diffSec, "second");
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
if (abs < 86_400) return rtf.format(Math.round(diffSec / 3600), "hour");
if (abs < 86_400 * 30) return rtf.format(Math.round(diffSec / 86_400), "day");
if (abs < 86_400 * 365) return rtf.format(Math.round(diffSec / (86_400 * 30)), "month");
return rtf.format(Math.round(diffSec / (86_400 * 365)), "year");
}
function absoluteDate(iso: string | null, locale: "tr" | "en"): string {
if (!iso) return "";
return new Date(iso).toLocaleString(locale === "tr" ? "tr-TR" : "en-US", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function HistoryPage() {
const { data, isLoading } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<any[]>("/vehicles/history"),
const { t, locale } = useTranslation();
const queryClient = useQueryClient();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortMode>("newest");
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<VehicleHistoryItem | null>(null);
const viewedRef = useRef(false);
const { data: pages, isLoading } = useQuery({
queryKey: ["vehicles", "history", "all"],
queryFn: () => api.get<VehicleHistoryItem[]>(`/vehicles/history?page=1&limit=${PAGE_SIZE * 8}`),
staleTime: 30 * 1000,
});
const { data: subData } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<{ subscription: Subscription | null }>("/subscriptions/me"),
staleTime: 60 * 1000,
});
useEffect(() => {
if (pages && !viewedRef.current) {
viewedRef.current = true;
capture("history_viewed", { count: pages.length });
}
}, [pages]);
const accessibleBrandIds = useMemo(() => {
const sub = subData?.subscription;
if (!sub || sub.plan?.key === "full") return null; // null = no restriction
return new Set((sub.brands ?? []).map((b) => b.brandId));
}, [subData]);
const filteredSorted = useMemo(() => {
if (!pages) return [];
const q = query.trim().toLocaleLowerCase("tr");
const filtered = q
? pages.filter((v) => {
const hay = `${v.brandName} ${v.model ?? ""} ${v.vin} ${v.year ?? ""} ${
v.engine ?? ""
}`.toLocaleLowerCase("tr");
return hay.includes(q);
})
: pages;
const sorted = [...filtered];
switch (sort) {
case "oldest":
sorted.sort((a, b) => {
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
return ta - tb;
});
break;
case "brand":
sorted.sort((a, b) => a.brandName.localeCompare(b.brandName, "tr"));
break;
case "yearDesc":
sorted.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
break;
default:
sorted.sort((a, b) => {
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
return tb - ta;
});
}
return sorted;
}, [pages, query, sort]);
const visible = useMemo(() => filteredSorted.slice(0, page * PAGE_SIZE), [filteredSorted, page]);
const hasMore = visible.length < filteredSorted.length;
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/vehicles/${id}`),
onSuccess: (_data, id) => {
queryClient.setQueryData<VehicleHistoryItem[]>(["vehicles", "history", "all"], (prev) =>
prev ? prev.filter((v) => v.id !== id) : prev,
);
queryClient.invalidateQueries({ queryKey: ["vehicles", "history"] });
toast.success(t("history.deleted"));
capture("history_item_deleted", { vehicle_id: id });
},
onError: () => toast.error(t("history.deleteFailed")),
});
function onCopyVin(vin: string) {
navigator.clipboard.writeText(vin).then(
() => toast.success(t("history.vinCopied")),
() => toast.error(t("common.error")),
);
capture("history_vin_copied", { vin });
}
function clearFilters() {
setQuery("");
setSort("newest");
setPage(1);
}
const hasActiveFilter = query.trim() !== "" || sort !== "newest";
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h2 className="text-2xl font-bold">Arama Geçmişi</h2>
<p className="text-muted-foreground">Daha önce aradığınız araçlar</p>
<div className="mx-auto max-w-5xl space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-bold leading-tight">{t("history.title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t("history.subtitle")}</p>
</div>
<Link to="/dashboard/search">
<Button>
<SearchIcon className="mr-1.5 size-4" />
{t("history.newSearch")}
</Button>
</Link>
</div>
{isLoading ? (
<div className="space-y-4">
{HISTORY_SKEL_KEYS.map((k) => (
<Skeleton key={k} className="h-24 w-full" />
))}
</div>
) : !data || data.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Henüz arama yapmadınız.
<br />
<Link to="/dashboard/search" className="text-primary hover:underline">
Şase arama sayfasına gidin
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{data.map((vehicle: any) => (
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
<Card className="transition-shadow hover:shadow-md cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{vehicle.brandName} {vehicle.model}
</CardTitle>
<Badge variant="secondary">{vehicle.year}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="font-mono text-sm text-muted-foreground">{vehicle.vin}</p>
</CardContent>
</Card>
</Link>
))}
{/* Toolbar */}
{!isLoading && pages && pages.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
if (e.target.value.trim()) {
capture("history_search_used", { length: e.target.value.length });
}
}}
placeholder={t("history.searchPlaceholder")}
className="pl-9"
aria-label={t("history.searchPlaceholder")}
/>
{query && (
<button
type="button"
onClick={() => {
setQuery("");
setPage(1);
}}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-muted-foreground">
{t("history.itemCount", { count: filteredSorted.length })}
</span>
<select
value={sort}
onChange={(e) => {
const v = e.target.value as SortMode;
setSort(v);
setPage(1);
capture("history_sort_changed", { sort: v });
}}
aria-label={t("history.sort.label")}
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<option value="newest">{t("history.sort.newest")}</option>
<option value="oldest">{t("history.sort.oldest")}</option>
<option value="brand">{t("history.sort.brandAZ")}</option>
<option value="yearDesc">{t("history.sort.yearDesc")}</option>
</select>
</div>
</div>
)}
{/* Loading */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{["s1", "s2", "s3", "s4", "s5", "s6"].map((k) => (
<Skeleton key={k} className="h-[148px] w-full rounded-2xl" />
))}
</div>
) : !pages || pages.length === 0 ? (
/* Empty — no history at all */
<div className="rounded-2xl border border-dashed border-border bg-muted/20 p-10 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Car className="size-6" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("history.empty.title")}</h3>
<p className="mt-2 text-sm text-muted-foreground">{t("history.empty.description")}</p>
<Link to="/dashboard/search">
<Button className="mt-4">
{t("history.empty.cta")}
<ArrowRight className="ml-1.5 size-4" />
</Button>
</Link>
</div>
) : filteredSorted.length === 0 ? (
/* Filter wiped everything */
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium text-foreground">{t("history.noResults.title")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("history.noResults.subtitle")}</p>
<Button variant="outline" size="sm" className="mt-3" onClick={clearFilters}>
{t("history.noResults.clear")}
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((v) => (
<HistoryCard
key={v.id}
vehicle={v}
locked={accessibleBrandIds ? !accessibleBrandIds.has(v.brandId) : false}
onCopyVin={onCopyVin}
onDelete={() => setDeleteTarget(v)}
t={t}
locale={locale === "en" ? "en" : "tr"}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => {
setPage((p) => p + 1);
capture("history_load_more", { page: page + 1 });
}}
>
{t("history.loadMore")}
</Button>
</div>
)}
{hasActiveFilter && (
<div className="flex justify-center">
<Button variant="ghost" size="sm" onClick={clearFilters}>
{t("history.noResults.clear")}
</Button>
</div>
)}
</>
)}
{/* Delete confirm */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("history.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("history.deleteConfirmDescription", {
label: deleteTarget
? `${deleteTarget.brandName} ${deleteTarget.model ?? ""}`.trim()
: "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("history.deleteCancelAction")}
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (!deleteTarget) return;
const id = deleteTarget.id;
deleteMutation.mutate(id);
setDeleteTarget(null);
}}
>
<Trash2 className="mr-1.5 size-4" />
{t("history.deleteConfirmAction")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function HistoryCard({
vehicle,
locked,
onCopyVin,
onDelete,
t,
locale,
}: {
vehicle: VehicleHistoryItem;
locked: boolean;
onCopyVin: (vin: string) => void;
onDelete: () => void;
t: (key: string, params?: Record<string, string | number>) => string;
locale: "tr" | "en";
}) {
const when = relativeFromNow(vehicle.lastAccessedAt, locale);
const whenAbs = absoluteDate(vehicle.lastAccessedAt, locale);
return (
<div
className={`group flex flex-col rounded-2xl border bg-background p-4 transition-colors ${
locked
? "border-border opacity-80"
: "border-border hover:border-primary/40 hover:bg-accent/40"
}`}
>
<div className="flex items-start justify-between gap-3">
<Link
to="/dashboard/vehicles/$id"
params={{ id: vehicle.id }}
preload="intent"
onClick={() => capture("history_item_clicked", { vehicle_id: vehicle.id })}
className="flex flex-1 items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-lg"
>
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-5">
{vehicle.brandName} {vehicle.model ?? ""}
</p>
{vehicle.engine && (
<p className="truncate text-xs text-muted-foreground">{vehicle.engine}</p>
)}
</div>
</Link>
{vehicle.year && (
<Badge variant="secondary" className="shrink-0 tabular-nums">
{vehicle.year}
</Badge>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs">
{vehicle.vin}
</code>
<button
type="button"
onClick={() => onCopyVin(vehicle.vin)}
aria-label={t("history.copyVin")}
title={t("history.copyVin")}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Copy className="size-3.5" />
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title={whenAbs} aria-label={whenAbs}>
<Clock className="size-3" />
{when}
</span>
<div className="flex items-center gap-1">
{locked && (
<Link
to="/dashboard/subscription"
className="inline-flex items-center gap-1 rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
title={t("history.planLockedTooltip")}
aria-label={t("history.planLockedTooltip")}
>
<Lock className="size-3" />
{t("history.planLocked")}
</Link>
)}
<button
type="button"
onClick={onDelete}
aria-label={t("history.deleteAction")}
title={t("history.deleteAction")}
className="rounded-md p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,460 +1,13 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { KEYS_4 } from "@/lib/keys";
import { Badge, Button, Separator, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
ArrowRight,
Calendar,
Car,
CheckCircle2,
Crown,
Database,
Search,
User,
} from "lucide-react";
import { createFileRoute, redirect } from "@tanstack/react-router";
// The old /dashboard "home" page was a low-value stat-and-profile screen that
// duplicated information already shown on /subscription and /billing, and for
// new users showed mostly zeros. The product's core motion is VIN decoding,
// so /dashboard now bounces straight to /dashboard/search. Trial urgency,
// subscription status and recent searches are surfaced by the dashboard
// layout shell + the search page itself.
export const Route = createFileRoute("/dashboard/")({
component: DashboardHome,
beforeLoad: () => {
throw redirect({ to: "/dashboard/search" });
},
});
// ─── TYPES ────────────────────────────────────────────────────────────────────
interface DashboardStats {
totalSearches: number;
monthlySearches: number;
totalVehicles: number;
recentVehicles: number;
}
interface SubscriptionBrand {
brandId: string;
brandName: string;
}
interface Subscription {
status: string;
plan?: { name: string; key: string };
billingPeriod: string;
brands?: SubscriptionBrand[];
startDate?: string;
endDate?: string;
}
// ─── STAT CARD ────────────────────────────────────────────────────────────────
function StatCard({
icon: Icon,
value,
label,
detail,
detailValue,
buttonLabel,
buttonTo,
progress,
}: {
icon: React.ComponentType<{ className?: string }>;
value: string;
label: string;
detail?: string;
detailValue?: string;
buttonLabel?: string;
buttonTo?: string;
progress?: number;
}) {
return (
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
{/* Icon + Value + Label */}
<div className="flex-1 space-y-4">
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
<Icon className="size-5 text-muted-foreground" />
</div>
<div>
<p className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
{value}
</p>
<p className="mt-0.5 text-sm font-medium text-muted-foreground">{label}</p>
</div>
{detail && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{detail}</span>
<span className="font-medium">{detailValue}</span>
</div>
)}
</div>
{/* Divider */}
<Separator className="my-4 bg-border" />
{/* Progress bar or button */}
{progress !== undefined ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Kullanım</span>
<span className="font-semibold">{progress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
) : buttonLabel && buttonTo ? (
<Link to={buttonTo}>
<Button
variant="outline"
className="w-full justify-center rounded-lg border-border text-sm"
>
{buttonLabel}
<ArrowRight className="ml-1.5 size-3.5" />
</Button>
</Link>
) : null}
</div>
);
}
// ─── PROFILE CARD ─────────────────────────────────────────────────────────────
function ProfileCard({
name,
email,
initials,
}: {
name: string;
email: string;
initials: string;
}) {
return (
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
<div className="flex-1 space-y-4">
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
<User className="size-5 text-muted-foreground" />
</div>
<div>
<p className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
{name}
</p>
<p className="mt-0.5 text-sm text-muted-foreground">{email}</p>
</div>
</div>
<Separator className="my-4 bg-border" />
<Link to="/dashboard/settings">
<Button
variant="outline"
className="w-full justify-center rounded-lg border-border text-sm"
>
Profil Ayarları
<ArrowRight className="ml-1.5 size-3.5" />
</Button>
</Link>
</div>
);
}
// ─── PAGE ─────────────────────────────────────────────────────────────────────
function DashboardHome() {
const { user } = useAuth();
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["dashboard", "stats"],
queryFn: async () => {
// Try to get stats from the API, fall back to defaults
try {
return await api.get<DashboardStats>("/dashboard/stats");
} catch {
return null;
}
},
});
const { data: subData, isLoading: subLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: async () => {
try {
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
);
} catch {
return null;
}
},
});
const subscription = subData?.subscription ?? null;
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: async () => {
try {
return await api.get<any[]>("/vehicles/history?limit=5");
} catch {
return [];
}
},
});
const initials = user?.name
? user.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "?";
const totalSearches = stats?.totalSearches ?? history?.length ?? 0;
const monthlySearches = stats?.monthlySearches ?? 0;
const totalVehicles = stats?.totalVehicles ?? history?.length ?? 0;
const recentVehicles = stats?.recentVehicles ?? 0;
const brandCount = subscription?.brands?.length ?? 0;
const planKey: string | null = subscription?.plan?.name
? ({
"1 Marka": "brand1",
"2 Marka": "brand2",
"3 Marka": "brand3",
"Full Paket": "full",
}[subscription.plan.name] ?? null)
: null;
const maxBrands =
planKey === "full"
? 27
: planKey === "brand3"
? 3
: planKey === "brand2"
? 2
: planKey === "brand1"
? 1
: 0;
const isLoadingCards = statsLoading || subLoading;
return (
<div className="mx-auto max-w-6xl space-y-8">
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
{isLoadingCards ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-64 w-full rounded-2xl" />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* 1. Toplam Arama */}
<StatCard
icon={Search}
value={String(totalSearches)}
label="Toplam Arama"
detail="Bu Ay"
detailValue={String(monthlySearches)}
buttonLabel="Şase Çöz"
buttonTo="/dashboard/search"
/>
{/* 2. Araç Kaydı */}
<StatCard
icon={Car}
value={String(totalVehicles)}
label="Araç Kaydı"
detail="Son 30 Gün"
detailValue={String(recentVehicles)}
buttonLabel="Geçmişe Git"
buttonTo="/dashboard/history"
/>
{/* 3. Aktif Marka */}
<StatCard
icon={Database}
value={maxBrands > 0 ? `${brandCount}/${maxBrands}` : "—"}
label="Aktif Marka"
detail="Erişilebilir Marka"
detailValue={maxBrands > 0 ? String(maxBrands) : "—"}
progress={maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined}
buttonLabel={maxBrands === 0 ? "Plan Seçin" : undefined}
buttonTo={maxBrands === 0 ? "/dashboard/subscription" : undefined}
/>
{/* 4. Profil */}
<ProfileCard name={user?.name ?? "—"} email={user?.email ?? "—"} initials={initials} />
</div>
)}
{/* ─── ACTIVE SUBSCRIPTION ────────────────────────────────────── */}
<div className="space-y-4">
<div>
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
Aktif Aboneliğiniz
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Şase çözme ve parça kataloğuna tek tıkla erişin
</p>
</div>
{subLoading ? (
<Skeleton className="h-48 w-full rounded-2xl" />
) : subscription &&
(subscription.status === "active" ||
(subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
{/* Plan Info */}
<div className="flex-1 space-y-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10">
<Crown className="size-5 text-primary" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold">
{subscription.plan?.name ?? "Aktif Plan"}
</h3>
<Badge
variant="default"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
{subscription.status === "trial" ? "Deneme" : "Aktif"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{subscription.billingPeriod === "yearly" ? "Yıllık" : "Aylık"} abonelik
</p>
</div>
</div>
{/* Brands */}
{subscription.brands && subscription.brands.length > 0 && (
<div className="flex flex-wrap gap-2">
{subscription.brands.map((b) => (
<Badge
key={b.brandId}
variant="outline"
className="flex items-center gap-1.5"
>
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
{b.brandName}
</Badge>
))}
</div>
)}
{/* Dates */}
<div className="flex flex-wrap gap-6 text-sm">
{subscription.startDate && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="size-3.5" />
Başlangıç:{" "}
<span className="font-medium text-foreground">
{new Date(subscription.startDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
{subscription.endDate && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="size-3.5" />
Bitiş:{" "}
<span className="font-medium text-foreground">
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
</div>
{/* Features */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{["Sınırsız şase arama", "Parça kataloğu", "İnteraktif şema"].map((f) => (
<span
key={f}
className="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-3.5 text-brand" />
{f}
</span>
))}
</div>
</div>
{/* CTA */}
<div className="flex flex-col gap-2">
<Link to="/dashboard/search">
<Button className="w-full rounded-lg sm:w-auto">
Şase Ara
<ArrowRight className="ml-1.5 size-4" />
</Button>
</Link>
<Link to="/dashboard/subscription">
<Button
variant="outline"
className="w-full rounded-lg border-border text-sm sm:w-auto"
>
Plan Detayları
</Button>
</Link>
</div>
</div>
</div>
) : (
/* No subscription */
<div className="rounded-2xl border border-dashed border-border bg-background p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted">
<Crown className="size-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-semibold">Henüz aktif planınız yok</h3>
<p className="mt-2 text-sm text-muted-foreground">
Şase çözme, parça kataloğu ve interaktif şemalara erişmek için bir plan seçin.
</p>
<Link to="/dashboard/subscription">
<Button className="mt-4 rounded-lg">
Plan Seçin
<ArrowRight className="ml-1.5 size-4" />
</Button>
</Link>
</div>
)}
</div>
{/* ─── SON ARAMALAR ───────────────────────────────────────────── */}
{history && history.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
Son Aramalar
</h2>
<Link
to="/dashboard/history"
className="text-sm text-muted-foreground transition hover:text-foreground"
>
Tümünü Gör
</Link>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{history.slice(0, 3).map((v: any) => (
<Link key={v.id} to="/dashboard/vehicles/$id" params={{ id: v.id }}>
<div className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 transition-colors hover:bg-accent">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Car className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{v.brandName} {v.model}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
</div>
<Badge variant="secondary" className="shrink-0">
{v.year}
</Badge>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -109,6 +109,17 @@ export const envSchema = z.object({
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
// endpoint returns 503 and the widget falls back to anonymous visitors.
CHATWOOT_HMAC_TOKEN: z.string().optional(),
// Catalog-source dumps — local DB-first lookup before live scrape.
// When CATALOG_SOURCE_DB_ENABLED is "true" AND the URL for a source is set,
// prefetch/category fetches will try the local dump DB first and only fall
// back to the live upstream on a miss. Both URLs optional independently.
CATALOG_SOURCE_DB_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
PCAT_SOURCE_DB_URL: z.string().url().optional(),
EMEX_SOURCE_DB_URL: z.string().optional(), // mysql://... — not a strict URL per WHATWG
});
export type Env = z.infer<typeof envSchema>;

151
pnpm-lock.yaml generated
View File

@@ -26,7 +26,7 @@ importers:
version: 3.988.0
'@kubiks/otel-drizzle':
specifier: ^2.1.0
version: 2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))
version: 2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -110,7 +110,7 @@ importers:
version: 10.52.0(@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0))
better-auth:
specifier: ^1.2.0
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
bullmq:
specifier: ^5.30.0
version: 5.68.0
@@ -125,13 +125,16 @@ importers:
version: 0.31.9
drizzle-orm:
specifier: ^0.41.0
version: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
version: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
helmet:
specifier: ^8.1.0
version: 8.1.0
ioredis:
specifier: ^5.4.0
version: 5.9.2
mysql2:
specifier: ^3.22.4
version: 3.22.4(@types/node@22.19.11)
openai:
specifier: ^6.37.0
version: 6.37.0(zod@3.25.76)
@@ -219,7 +222,7 @@ importers:
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-auth:
specifier: ^1.2.0
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
@@ -3688,6 +3691,10 @@ packages:
ast-v8-to-istanbul@0.3.11:
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
aws-ssl-profiles@1.1.2:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -4503,6 +4510,9 @@ packages:
engines: {node: '>= 18.0.0'}
hasBin: true
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -4605,6 +4615,10 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -4691,6 +4705,9 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
@@ -4917,6 +4934,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lru.min@1.1.4:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
lucide-react@0.468.0:
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
peerDependencies:
@@ -5181,6 +5202,16 @@ packages:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
mysql2@3.22.4:
resolution: {integrity: sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
named-placeholders@1.1.6:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5748,6 +5779,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
sql-escaper@1.3.3:
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -7572,10 +7607,10 @@ snapshots:
'@js-sdsl/ordered-map@4.4.2': {}
'@kubiks/otel-drizzle@2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))':
'@kubiks/otel-drizzle@2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))':
dependencies:
'@opentelemetry/api': 1.9.0
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
'@ljharb/through@2.3.14':
dependencies:
@@ -10028,6 +10063,8 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 10.0.0
aws-ssl-profiles@1.1.2: {}
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
@@ -10047,7 +10084,7 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
@@ -10063,8 +10100,32 @@ snapshots:
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
mysql2: 3.22.4(@types/node@22.19.11)
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
'@noble/hashes': 2.0.1
better-call: 1.1.8(zod@4.3.6)
defu: 6.1.4
jose: 6.1.3
kysely: 0.28.11
nanostores: 1.1.0
zod: 4.3.6
optionalDependencies:
drizzle-kit: 0.31.9
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
mysql2: 3.22.4(@types/node@22.19.11)
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -10488,14 +10549,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8):
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.15.6
gel: 2.2.0
kysely: 0.28.11
mysql2: 3.22.4(@types/node@22.19.11)
postgres: 3.4.8
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/pg': 8.15.6
gel: 2.2.0
kysely: 0.28.11
mysql2: 3.22.4(@types/node@22.19.11)
postgres: 3.4.8
optional: true
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -10814,6 +10886,10 @@ snapshots:
- supports-color
optional: true
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@@ -10941,6 +11017,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
import-fresh@3.3.1:
@@ -11053,6 +11133,8 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
is-property@1.0.2: {}
is-unicode-supported@0.1.0: {}
isbot@5.1.35: {}
@@ -11245,6 +11327,8 @@ snapshots:
dependencies:
yallist: 3.1.1
lru.min@1.1.4: {}
lucide-react@0.468.0(react@19.2.4):
dependencies:
react: 19.2.4
@@ -11707,6 +11791,22 @@ snapshots:
mute-stream@1.0.0: {}
mysql2@3.22.4(@types/node@22.19.11):
dependencies:
'@types/node': 22.19.11
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
iconv-lite: 0.7.2
long: 5.3.2
lru.min: 1.1.4
named-placeholders: 1.1.6
sql-escaper: 1.3.3
named-placeholders@1.1.6:
dependencies:
lru.min: 1.1.4
nanoid@3.3.11: {}
nanostores@1.1.0: {}
@@ -11715,7 +11815,34 @@ snapshots:
neo-async@2.6.2: {}
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001769
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
optional: true
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
@@ -12360,6 +12487,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
sql-escaper@1.3.3: {}
stackback@0.0.2: {}
standard-as-callback@2.1.0: {}