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:
@@ -58,6 +58,7 @@
|
|||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
|
"mysql2": "^3.22.4",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"postgres": "^3.4.0",
|
"postgres": "^3.4.0",
|
||||||
"posthog-node": "^5.34.1",
|
"posthog-node": "^5.34.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { CatalogModule } from "../catalog/catalog.module";
|
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 { EmexModule } from "../integrations/emex/emex.module";
|
||||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
@@ -8,7 +9,14 @@ import { CategoriesController } from "./categories.controller";
|
|||||||
import { CategoriesService } from "./categories.service";
|
import { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
|
imports: [
|
||||||
|
PL24Module,
|
||||||
|
EmexModule,
|
||||||
|
PartsCatalogsModule,
|
||||||
|
CatalogModule,
|
||||||
|
TranslationsModule,
|
||||||
|
CatalogSourceDbModule,
|
||||||
|
],
|
||||||
controllers: [CategoriesController],
|
controllers: [CategoriesController],
|
||||||
providers: [CategoriesService],
|
providers: [CategoriesService],
|
||||||
exports: [CategoriesService],
|
exports: [CategoriesService],
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ function createService(db: any) {
|
|||||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
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(
|
const service = new CategoriesService(
|
||||||
db as any,
|
db as any,
|
||||||
redis as any,
|
redis as any,
|
||||||
@@ -46,6 +48,8 @@ function createService(db: any) {
|
|||||||
storage as any,
|
storage as any,
|
||||||
pl24FordLegacyService as any,
|
pl24FordLegacyService as any,
|
||||||
translationsService as any,
|
translationsService as any,
|
||||||
|
pcatSourceDb as any,
|
||||||
|
emexSourceDb as any,
|
||||||
);
|
);
|
||||||
return { service, db, redis, pl24Service, translationsService };
|
return { service, db, redis, pl24Service, translationsService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|||||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
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 { EmexService } from "../integrations/emex/emex.service";
|
||||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
@@ -24,6 +26,8 @@ export class CategoriesService {
|
|||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
private pl24FordLegacyService: PL24FordLegacyService,
|
private pl24FordLegacyService: PL24FordLegacyService,
|
||||||
private translationsService: TranslationsService,
|
private translationsService: TranslationsService,
|
||||||
|
private pcatSourceDb: PcatSourceDbService,
|
||||||
|
private emexSourceDb: EmexSourceDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCategoryTree(vehicleId: string) {
|
async getCategoryTree(vehicleId: string) {
|
||||||
@@ -928,12 +932,19 @@ export class CategoriesService {
|
|||||||
const vehicleRawData = vehicle.rawData as any;
|
const vehicleRawData = vehicle.rawData as any;
|
||||||
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
|
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
|
||||||
|
|
||||||
const partsResult = await this.partsCatalogsService.fetchParts(
|
// 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,
|
catalogId,
|
||||||
carId,
|
carId,
|
||||||
groupId,
|
groupId,
|
||||||
carParams,
|
carParams,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (partsResult) {
|
if (partsResult) {
|
||||||
// Flatten part groups into parts
|
// Flatten part groups into parts
|
||||||
@@ -1081,7 +1092,20 @@ export class CategoriesService {
|
|||||||
} else if (vehicle && category.source === "emex") {
|
} else if (vehicle && category.source === "emex") {
|
||||||
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
||||||
try {
|
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
|
// Build position code → sequential integer mapping for hotspot linking
|
||||||
const posCodeToIndex = new Map<string, number>();
|
const posCodeToIndex = new Map<string, number>();
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export default () => ({
|
|||||||
chatwoot: {
|
chatwoot: {
|
||||||
hmacToken: process.env.CHATWOOT_HMAC_TOKEN,
|
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: {
|
otel: {
|
||||||
enabled: process.env.OTEL_ENABLED === "true",
|
enabled: process.env.OTEL_ENABLED === "true",
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/web/src/components/catalog/catalog-header.tsx
Normal file
72
apps/web/src/components/catalog/catalog-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/web/src/components/catalog/recent-brands-strip.tsx
Normal file
88
apps/web/src/components/catalog/recent-brands-strip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/src/components/catalog/view-mode-toggle.tsx
Normal file
77
apps/web/src/components/catalog/view-mode-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/src/lib/recently-used-brands.ts
Normal file
61
apps/web/src/lib/recently-used-brands.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/web/src/lib/use-view-mode.ts
Normal file
26
apps/web/src/lib/use-view-mode.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -34,6 +34,42 @@
|
|||||||
"a11y": {
|
"a11y": {
|
||||||
"skipToContent": "Skip to content"
|
"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": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -88,6 +124,7 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"title": "Parts Catalog",
|
"title": "Parts Catalog",
|
||||||
|
"subtitle": "Browse brand catalogs — find parts without a VIN.",
|
||||||
"brands": "Brands",
|
"brands": "Brands",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"allBrands": "All Brands",
|
"allBrands": "All Brands",
|
||||||
@@ -97,6 +134,32 @@
|
|||||||
"locked": "This brand is not in your plan",
|
"locked": "This brand is not in your plan",
|
||||||
"upgradeCta": "Upgrade Plan",
|
"upgradeCta": "Upgrade Plan",
|
||||||
"loadingModels": "Loading models...",
|
"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",
|
"tabSasetr": "SASE",
|
||||||
"tabPl24": "Pl24",
|
"tabPl24": "Pl24",
|
||||||
"tabPcat": "Pcat",
|
"tabPcat": "Pcat",
|
||||||
|
|||||||
@@ -34,6 +34,42 @@
|
|||||||
"a11y": {
|
"a11y": {
|
||||||
"skipToContent": "İçeriğe atla"
|
"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": {
|
"nav": {
|
||||||
"dashboard": "Gösterge Paneli",
|
"dashboard": "Gösterge Paneli",
|
||||||
"search": "Arama",
|
"search": "Arama",
|
||||||
@@ -88,6 +124,7 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"title": "Parça Kataloğu",
|
"title": "Parça Kataloğu",
|
||||||
|
"subtitle": "Marka kataloglarına göz at — şase bilmeden parça bul.",
|
||||||
"brands": "Markalar",
|
"brands": "Markalar",
|
||||||
"models": "Modeller",
|
"models": "Modeller",
|
||||||
"allBrands": "Tüm Markalar",
|
"allBrands": "Tüm Markalar",
|
||||||
@@ -97,6 +134,32 @@
|
|||||||
"locked": "Bu marka planınızda yok",
|
"locked": "Bu marka planınızda yok",
|
||||||
"upgradeCta": "Planını Yükselt",
|
"upgradeCta": "Planını Yükselt",
|
||||||
"loadingModels": "Modeller yükleniyor...",
|
"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",
|
"tabSasetr": "SASE",
|
||||||
"tabPl24": "Pl24",
|
"tabPl24": "Pl24",
|
||||||
"tabPcat": "Pcat",
|
"tabPcat": "Pcat",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
History,
|
History,
|
||||||
LayoutDashboard,
|
|
||||||
Library,
|
Library,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -69,7 +68,6 @@ type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mainMenuItems: readonly NavItem[] = [
|
const mainMenuItems: readonly NavItem[] = [
|
||||||
{ to: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard, exact: true },
|
|
||||||
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
||||||
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
||||||
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import {
|
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||||
CardsViewIcon,
|
import { RecentBrandsStrip } from "@/components/catalog/recent-brands-strip";
|
||||||
ListViewIcon,
|
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
|
||||||
TreeViewIcon,
|
|
||||||
} from "@/components/categories/view-toggle-icons";
|
|
||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
import { KEYS_10 } from "@/lib/keys";
|
||||||
import { Skeleton, cn } from "@sase/ui";
|
import { capture } from "@/lib/posthog";
|
||||||
import { Button } from "@sase/ui";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { ChevronRight, Library, Lock } from "lucide-react";
|
import { ChevronRight, Library, Lock, Search, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { KEYS_10 } from "@/lib/keys";
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||||
component: CatalogBrandsPage,
|
component: CatalogBrandsPage,
|
||||||
});
|
});
|
||||||
@@ -29,93 +29,243 @@ interface CatalogBrand {
|
|||||||
|
|
||||||
function CatalogBrandsPage() {
|
function CatalogBrandsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
const userId = session?.user?.id ?? null;
|
||||||
() => getUserSettings().brandViewMode ?? "grid",
|
const [viewMode, setViewMode] = useViewMode("brandViewMode");
|
||||||
);
|
const [query, setQuery] = useState("");
|
||||||
|
const [hideLocked, setHideLocked] = useState(false);
|
||||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
const viewedRef = useRef(false);
|
||||||
setViewMode(mode);
|
// Read recents once per user — they don't change inside this page session.
|
||||||
setUserSetting("brandViewMode", mode);
|
const recents = useMemo(() => readRecentBrands(userId, 8), [userId]);
|
||||||
};
|
|
||||||
|
|
||||||
const { data: brands, isLoading } = useQuery({
|
const { data: brands, isLoading } = useQuery({
|
||||||
queryKey: ["catalog-brands"],
|
queryKey: ["catalog-brands"],
|
||||||
queryFn: () => api.get<CatalogBrand[]>("/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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<CatalogHeader
|
||||||
<div>
|
title={t("catalog.title")}
|
||||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
subtitle={t("catalog.subtitle")}
|
||||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
actions={
|
||||||
</div>
|
<ViewModeToggle
|
||||||
<div
|
value={viewMode}
|
||||||
role="tablist"
|
onChange={changeViewMode}
|
||||||
aria-label="Görünüm modu"
|
groupLabelKey="catalog.view.groupLabel"
|
||||||
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" },
|
{/* Recently used — shown only when the user has actually visited some
|
||||||
{ mode: "columns" as const, Icon: ListViewIcon, label: "Sütun" },
|
brand catalog before, and the brands list has loaded so we know
|
||||||
].map(({ mode, Icon, label }) => (
|
access state for each chip. Hidden in cold-start state. */}
|
||||||
<button
|
{recents.length > 0 && brands && (
|
||||||
key={mode}
|
<RecentBrandsStrip recents={recents} accessByName={accessByName} />
|
||||||
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}
|
|
||||||
|
{/* 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"
|
||||||
>
|
>
|
||||||
<Icon isActive={viewMode === mode} className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<BrandGridSkeleton />
|
||||||
{KEYS_10.map((__k) => (
|
|
||||||
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : !brands || brands.length === 0 ? (
|
) : !brands || brands.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : filteredTotal === 0 ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
|
||||||
{brands.map((brand) => (
|
<p className="text-sm font-medium">{t("catalog.brandSearchNoMatch", { query })}</p>
|
||||||
<BrandCard key={brand.brandName} brand={brand} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "tree" ? (
|
) : 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>
|
</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 }) {
|
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!brand.hasAccess) {
|
if (!brand.hasAccess) {
|
||||||
return (
|
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">
|
<Link
|
||||||
{/* Diagonal stripe overlay for locked feel */}
|
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
|
<div
|
||||||
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||||
style={{
|
style={{
|
||||||
@@ -131,7 +281,7 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
|||||||
size={40}
|
size={40}
|
||||||
className="grayscale opacity-70"
|
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" />
|
<Lock className="size-2.5 text-background" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
|
||||||
{t("catalog.locked")}
|
{t("catalog.locked")}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<span className="relative mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand transition-colors group-hover:text-brand/80">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{t("catalog.upgradeCta")}
|
{t("catalog.upgradeCta")}
|
||||||
<ChevronRight className="size-3" />
|
<ChevronRight className="size-3" />
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/catalog/$brandName"
|
to="/dashboard/catalog/$brandName"
|
||||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
params={{ brandName: brand.brandName }}
|
||||||
search={{ catalog: undefined }}
|
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
|
<CarBrandLogo
|
||||||
brandName={brand.brandName}
|
brandName={brand.brandName}
|
||||||
@@ -170,62 +323,111 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
|||||||
|
|
||||||
/* ── Tree (flat list) ── */
|
/* ── Tree (flat list) ── */
|
||||||
|
|
||||||
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
|
function BrandListTree({
|
||||||
|
inPlan,
|
||||||
|
locked,
|
||||||
|
hasQuery,
|
||||||
|
}: {
|
||||||
|
inPlan: CatalogBrand[];
|
||||||
|
locked: CatalogBrand[];
|
||||||
|
hasQuery: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
if (hasQuery) {
|
||||||
|
return <TreeBlock brands={[...inPlan, ...locked]} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="divide-y rounded-lg border">
|
<div className="space-y-6">
|
||||||
{brands.map((brand) => {
|
{inPlan.length > 0 && (
|
||||||
if (!brand.hasAccess) {
|
<section>
|
||||||
return (
|
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
|
||||||
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
|
<TreeBlock brands={inPlan} />
|
||||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
</section>
|
||||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
)}
|
||||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
{locked.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader
|
||||||
|
label={t("catalog.lockedSection")}
|
||||||
|
count={locked.length}
|
||||||
|
hint={t("catalog.lockedSectionHint")}
|
||||||
|
/>
|
||||||
|
<TreeBlock brands={locked} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TreeBlock({ brands }: { brands: CatalogBrand[] }) {
|
||||||
return (
|
return (
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
{brands.map((brand) =>
|
||||||
|
brand.hasAccess ? (
|
||||||
<Link
|
<Link
|
||||||
key={brand.brandName}
|
key={brand.brandName}
|
||||||
to="/dashboard/catalog/$brandName"
|
to="/dashboard/catalog/$brandName"
|
||||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
params={{ brandName: brand.brandName }}
|
||||||
search={{ catalog: undefined }}
|
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"
|
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
</Link>
|
</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>
|
</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 { 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 navigate = useNavigate();
|
||||||
|
const selected = ordered.find((b) => b.brandName === selectedName) ?? null;
|
||||||
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
|
<div className="flex min-h-80 max-h-[480px] overflow-hidden rounded-lg border">
|
||||||
{/* Left panel */}
|
<div className="w-[240px] shrink-0 overflow-y-auto border-r">
|
||||||
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
|
{ordered.map((brand) => (
|
||||||
{brands.map((brand) => (
|
|
||||||
<button
|
<button
|
||||||
key={brand.brandName}
|
key={brand.brandName}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedName(brand.brandName)}
|
onClick={() => setSelectedName(brand.brandName)}
|
||||||
disabled={!brand.hasAccess}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
|
"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",
|
selectedName === brand.brandName && "bg-accent font-medium",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -239,36 +441,49 @@ function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
|
||||||
{/* Right panel */}
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
|
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
|
||||||
<p className="text-lg font-semibold">{selected.brandName}</p>
|
<p className="text-lg font-semibold">{selected.brandName}</p>
|
||||||
{selected.hasAccess ? (
|
{selected.hasAccess ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
capture("catalog_brand_clicked", {
|
||||||
|
brand_name: selected.brandName,
|
||||||
|
service_names: selected.serviceNames,
|
||||||
|
surface: "columns",
|
||||||
|
});
|
||||||
navigate({
|
navigate({
|
||||||
to: "/dashboard/catalog/$brandName",
|
to: "/dashboard/catalog/$brandName",
|
||||||
params: { brandName: encodeURIComponent(selected.brandName) },
|
params: { brandName: selected.brandName },
|
||||||
search: { catalog: undefined },
|
search: { catalog: undefined },
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
Modellere Git
|
{t("catalog.goToModels")}
|
||||||
|
<ChevronRight className="ml-1 size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
|
<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>
|
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
|
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||||
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||||
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||||
import {
|
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
|
||||||
CardsViewIcon,
|
|
||||||
ListViewIcon,
|
|
||||||
TreeViewIcon,
|
|
||||||
} from "@/components/categories/view-toggle-icons";
|
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
||||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
import { capture } from "@/lib/posthog";
|
||||||
import { Button, Skeleton, cn } from "@sase/ui";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
import {
|
||||||
import { useState } from "react";
|
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/")({
|
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
@@ -40,33 +50,40 @@ interface CatalogVehicle {
|
|||||||
catalogPath: string | null;
|
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() {
|
function CatalogModelsPage() {
|
||||||
const { brandName } = Route.useParams();
|
const { brandName } = Route.useParams();
|
||||||
const { catalog: activeCatalog } = Route.useSearch();
|
const { catalog: activeCatalog } = Route.useSearch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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 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({
|
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||||
queryFn: () =>
|
queryFn: () => api.get<CatalogEntry[]>(`/catalog/brands/${decodedBrandName}/catalogs`),
|
||||||
api.get<CatalogEntry[]>(`/catalog/brands/${encodeURIComponent(decodedBrandName)}/catalogs`),
|
staleTime: 30 * 60 * 1000,
|
||||||
staleTime: 1000 * 60 * 30, // 30 min
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMultiCatalog = (catalogs?.length ?? 0) > 1;
|
const isMultiCatalog = (catalogs?.length ?? 0) > 1;
|
||||||
// Show models when: single-service brand, OR user has selected a sub-catalog
|
|
||||||
const shouldShowModels = !isMultiCatalog || !!activeCatalog;
|
const shouldShowModels = !isMultiCatalog || !!activeCatalog;
|
||||||
|
|
||||||
const activeCatalogLabel = activeCatalog
|
const activeCatalogLabel = activeCatalog
|
||||||
@@ -77,130 +94,196 @@ function CatalogModelsPage() {
|
|||||||
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
|
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
|
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
|
||||||
return api.get<CatalogVehicle[]>(
|
return api.get<CatalogVehicle[]>(`/catalog/brands/${decodedBrandName}/models${serviceParam}`);
|
||||||
`/catalog/brands/${encodeURIComponent(decodedBrandName)}/models${serviceParam}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
enabled: shouldShowModels && !catalogsLoading,
|
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) {
|
if (isMultiCatalog && activeCatalog) {
|
||||||
// Go back to catalog selector
|
|
||||||
navigate({ to: ".", search: { catalog: undefined } });
|
navigate({ to: ".", search: { catalog: undefined } });
|
||||||
} else {
|
} else {
|
||||||
navigate({ to: "/dashboard/catalog" });
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Breadcrumb */}
|
<CatalogHeader
|
||||||
<div className="flex items-center gap-3">
|
crumbs={crumbs}
|
||||||
<Button variant="ghost" size="icon" onClick={handleBack}>
|
title={
|
||||||
<ArrowLeft className="size-4" />
|
isMultiCatalog && activeCatalog
|
||||||
</Button>
|
? (activeCatalogLabel ?? decodedBrandName)
|
||||||
<div>
|
: decodedBrandName
|
||||||
<div className="text-xs text-muted-foreground">
|
}
|
||||||
<Link to="/dashboard/catalog" className="hover:underline">
|
onBack={handleBack}
|
||||||
{t("catalog.title")}
|
actions={
|
||||||
</Link>
|
shouldShowModels && models && models.length > 0 ? (
|
||||||
{" / "}
|
<ViewModeToggle
|
||||||
{isMultiCatalog && activeCatalog ? (
|
value={viewMode}
|
||||||
<>
|
onChange={changeViewMode}
|
||||||
<Link to="." search={{ catalog: undefined }} className="hover:underline">
|
groupLabelKey="catalog.view.groupLabel"
|
||||||
{decodedBrandName}
|
/>
|
||||||
</Link>
|
) : undefined
|
||||||
{" / "}
|
}
|
||||||
<span className="font-medium text-foreground">{activeCatalogLabel}</span>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium text-foreground">{decodedBrandName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-bold">
|
|
||||||
{isMultiCatalog && activeCatalog ? activeCatalogLabel : decodedBrandName}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{catalogsLoading ? (
|
{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) => (
|
{KEYS_4.map((__k) => (
|
||||||
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
|
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : isMultiCatalog && !activeCatalog && catalogs ? (
|
) : isMultiCatalog && !activeCatalog && catalogs ? (
|
||||||
// Sub-catalog selector
|
|
||||||
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
|
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
|
||||||
) : modelsLoading ? (
|
) : 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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{KEYS_9.map((__k) => (
|
{KEYS_9.map((__k) => (
|
||||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : !models || models.length === 0 ? (
|
) : !models || models.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<Car className="mb-4 size-12 text-muted-foreground/40" />
|
<Car className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* View toggle */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeViewMode("grid")}
|
onClick={() => setQuery("")}
|
||||||
className={cn(
|
aria-label={t("common.cancel")}
|
||||||
"rounded p-1.5",
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
title="Izgara"
|
|
||||||
>
|
>
|
||||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
<X className="size-3.5" />
|
||||||
</button>
|
</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"
|
</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"
|
||||||
>
|
>
|
||||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
<option value="newest">{t("catalog.sort.newest")}</option>
|
||||||
</button>
|
<option value="oldest">{t("catalog.sort.oldest")}</option>
|
||||||
<button
|
<option value="alphabetical">{t("catalog.sort.alphabetical")}</option>
|
||||||
type="button"
|
</select>
|
||||||
onClick={() => changeViewMode("columns")}
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
{viewMode === "grid" ? (
|
{sortedFiltered.length === 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
|
||||||
{models.map((model) => (
|
<p className="text-sm font-medium">{t("catalog.modelSearchNoMatch")}</p>
|
||||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "tree" ? (
|
) : 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -218,52 +301,80 @@ function CatalogSelector({
|
|||||||
brandLabel: string;
|
brandLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("catalog.selectCatalog")} — {brandLabel}
|
{t("catalog.selectCatalog")} — {brandLabel}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{catalogs.map((cat) => (
|
{catalogs.map((cat) => {
|
||||||
|
const Icon = iconForService(cat.serviceName);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={cat.serviceName}
|
key={cat.serviceName}
|
||||||
to="/dashboard/catalog/$brandName"
|
to="/dashboard/catalog/$brandName"
|
||||||
params={{ brandName }}
|
params={{ brandName }}
|
||||||
search={{ catalog: cat.serviceName }}
|
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"
|
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 items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
<BookOpen className="size-5 text-primary" />
|
<Icon className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{cat.displayName}</p>
|
<p className="font-semibold">{cat.displayName}</p>
|
||||||
<p className="text-xs text-muted-foreground">{cat.serviceName}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="size-4 text-muted-foreground" />
|
<ChevronRight className="size-4 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: string }) {
|
function ModelCard({
|
||||||
|
model,
|
||||||
|
brandName,
|
||||||
|
brandLabel,
|
||||||
|
}: {
|
||||||
|
model: CatalogVehicle;
|
||||||
|
brandName: string;
|
||||||
|
brandLabel: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/catalog/$brandName/$modelId"
|
to="/dashboard/catalog/$brandName/$modelId"
|
||||||
params={{ brandName, modelId: model.id }}
|
params={{ brandName, modelId: model.id }}
|
||||||
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
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>
|
<p className="font-semibold leading-5">{model.model}</p>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{model.year && <span>{model.year}</span>}
|
{model.year && (
|
||||||
{model.engine && <span>{model.engine}</span>}
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
{model.bodyType && <span>{model.bodyType}</span>}
|
{model.year}
|
||||||
{model.transmission && <span>{model.transmission}</span>}
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
import { Button, Skeleton } from "@sase/ui";
|
import { Button, Skeleton } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
|
import { AlertCircle, Car, ChevronRight, RotateCcw } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
||||||
component: EmexVehicleListPage,
|
component: EmexVehicleListPage,
|
||||||
@@ -44,10 +46,19 @@ interface EmexVehicle {
|
|||||||
function EmexVehicleListPage() {
|
function EmexVehicleListPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { catalogCode } = Route.useParams();
|
const { catalogCode } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const viewedRef = useRef(false);
|
||||||
|
|
||||||
// Current SSD state for wizard navigation
|
// Current SSD state for wizard navigation
|
||||||
const [ssd, setSsd] = useState("");
|
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
|
// Fetch wizard data for current SSD
|
||||||
const {
|
const {
|
||||||
data: wizardRows,
|
data: wizardRows,
|
||||||
@@ -117,22 +128,22 @@ function EmexVehicleListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
<CatalogHeader
|
||||||
<div className="flex items-center gap-3">
|
crumbs={[
|
||||||
<Link to="/dashboard/catalog" search={{}}>
|
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||||
<Button variant="ghost" size="sm">
|
{ label: decodeURIComponent(catalogCode) },
|
||||||
<ArrowLeft className="mr-1 size-4" />
|
]}
|
||||||
{t("catalog.backToBrands")}
|
title={decodeURIComponent(catalogCode)}
|
||||||
</Button>
|
onBack={() => navigate({ to: "/dashboard/catalog" })}
|
||||||
</Link>
|
actions={
|
||||||
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
|
determined.length > 0 ? (
|
||||||
{determined.length > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||||
<RotateCcw className="mr-1 size-3.5" />
|
<RotateCcw className="mr-1 size-3.5" />
|
||||||
{t("catalog.resetSelection")}
|
{t("catalog.resetSelection")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : undefined
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Determined params — shown as tags */}
|
{/* Determined params — shown as tags */}
|
||||||
{determined.length > 0 && (
|
{determined.length > 0 && (
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { ArrowLeft, Library } from "lucide-react";
|
import { Library } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
|
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
|
||||||
component: PcatModelsPage,
|
component: PcatModelsPage,
|
||||||
@@ -22,28 +26,39 @@ interface PcatModel {
|
|||||||
function PcatModelsPage() {
|
function PcatModelsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { catalogId } = Route.useParams();
|
const { catalogId } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const viewedRef = useRef(false);
|
||||||
|
|
||||||
const { data: models, isLoading } = useQuery({
|
const { data: models, isLoading } = useQuery({
|
||||||
queryKey: ["pcat-models", catalogId],
|
queryKey: ["pcat-models", catalogId],
|
||||||
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<CatalogHeader
|
||||||
<Link to="/dashboard/catalog" search={{}}>
|
crumbs={[
|
||||||
<Button variant="ghost" size="sm">
|
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||||
<ArrowLeft className="mr-1 size-4" />
|
{ label: catalogId.toUpperCase() },
|
||||||
{t("catalog.backToBrands")}
|
]}
|
||||||
</Button>
|
title={catalogId.toUpperCase()}
|
||||||
</Link>
|
onBack={() => navigate({ to: "/dashboard/catalog" })}
|
||||||
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<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) => (
|
{KEYS_10.map((__k) => (
|
||||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : !models || models.length === 0 ? (
|
) : !models || models.length === 0 ? (
|
||||||
@@ -55,7 +70,15 @@ function PcatModelsPage() {
|
|||||||
key={model.id}
|
key={model.id}
|
||||||
to="/dashboard/catalog/pcat/$catalogId/$modelId"
|
to="/dashboard/catalog/pcat/$catalogId/$modelId"
|
||||||
params={{ catalogId, modelId: model.id }}
|
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 ? (
|
{model.imgUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -64,18 +87,20 @@ function PcatModelsPage() {
|
|||||||
className="mb-2 h-16 w-auto object-contain"
|
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">
|
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
<Library className="size-5 text-emerald-500" />
|
<Library className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm font-semibold">{model.name}</p>
|
<p className="text-sm font-semibold">{model.name}</p>
|
||||||
{(model.yearFrom || model.yearTo) && (
|
{(model.yearFrom || model.yearTo) && (
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
|
||||||
{model.yearFrom || "?"} - {model.yearTo || "..."}
|
{model.yearFrom || "?"} – {model.yearTo || "…"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{model.carsCount > 0 && (
|
{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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,66 +1,453 @@
|
|||||||
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
|
import { toast } from "@/lib/toast";
|
||||||
import { Badge } from "@sase/ui";
|
import {
|
||||||
import { Skeleton } from "@sase/ui";
|
Badge,
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { 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")({
|
export const Route = createFileRoute("/dashboard/history")({
|
||||||
component: HistoryPage,
|
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() {
|
function HistoryPage() {
|
||||||
const { data, isLoading } = useQuery({
|
const { t, locale } = useTranslation();
|
||||||
queryKey: ["vehicles", "history"],
|
const queryClient = useQueryClient();
|
||||||
queryFn: () => api.get<any[]>("/vehicles/history"),
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<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>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">Arama Geçmişi</h2>
|
<h2 className="text-2xl font-bold leading-tight">{t("history.title")}</h2>
|
||||||
<p className="text-muted-foreground">Daha önce aradığınız araçlar</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Toolbar */}
|
||||||
<div className="space-y-4">
|
{!isLoading && pages && pages.length > 0 && (
|
||||||
{HISTORY_SKEL_KEYS.map((k) => (
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Skeleton key={k} className="h-24 w-full" />
|
<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>
|
||||||
) : !data || data.length === 0 ? (
|
<div className="flex items-center gap-3">
|
||||||
<Card>
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
{t("history.itemCount", { count: filteredSorted.length })}
|
||||||
Henüz arama yapmadınız.
|
</span>
|
||||||
<br />
|
<select
|
||||||
<Link to="/dashboard/search" className="text-primary hover:underline">
|
value={sort}
|
||||||
Şase arama sayfasına gidin
|
onChange={(e) => {
|
||||||
</Link>
|
const v = e.target.value as SortMode;
|
||||||
</CardContent>
|
setSort(v);
|
||||||
</Card>
|
setPage(1);
|
||||||
) : (
|
capture("history_sort_changed", { sort: v });
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
}}
|
||||||
{data.map((vehicle: any) => (
|
aria-label={t("history.sort.label")}
|
||||||
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
|
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"
|
||||||
<Card className="transition-shadow hover:shadow-md cursor-pointer">
|
>
|
||||||
<CardHeader className="pb-2">
|
<option value="newest">{t("history.sort.newest")}</option>
|
||||||
<div className="flex items-center justify-between">
|
<option value="oldest">{t("history.sort.oldest")}</option>
|
||||||
<CardTitle className="text-base">
|
<option value="brand">{t("history.sort.brandAZ")}</option>
|
||||||
{vehicle.brandName} {vehicle.model}
|
<option value="yearDesc">{t("history.sort.yearDesc")}</option>
|
||||||
</CardTitle>
|
</select>
|
||||||
<Badge variant="secondary">{vehicle.year}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="font-mono text-sm text-muted-foreground">{vehicle.vin}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,460 +1,13 @@
|
|||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
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";
|
|
||||||
|
|
||||||
|
// 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/")({
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ export const envSchema = z.object({
|
|||||||
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
|
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
|
||||||
// endpoint returns 503 and the widget falls back to anonymous visitors.
|
// endpoint returns 503 and the widget falls back to anonymous visitors.
|
||||||
CHATWOOT_HMAC_TOKEN: z.string().optional(),
|
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>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
151
pnpm-lock.yaml
generated
151
pnpm-lock.yaml
generated
@@ -26,7 +26,7 @@ importers:
|
|||||||
version: 3.988.0
|
version: 3.988.0
|
||||||
'@kubiks/otel-drizzle':
|
'@kubiks/otel-drizzle':
|
||||||
specifier: ^2.1.0
|
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':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
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))
|
version: 10.52.0(@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0))
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.0
|
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:
|
bullmq:
|
||||||
specifier: ^5.30.0
|
specifier: ^5.30.0
|
||||||
version: 5.68.0
|
version: 5.68.0
|
||||||
@@ -125,13 +125,16 @@ importers:
|
|||||||
version: 0.31.9
|
version: 0.31.9
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.41.0
|
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:
|
helmet:
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.1.0
|
version: 8.1.0
|
||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.9.2
|
version: 5.9.2
|
||||||
|
mysql2:
|
||||||
|
specifier: ^3.22.4
|
||||||
|
version: 3.22.4(@types/node@22.19.11)
|
||||||
openai:
|
openai:
|
||||||
specifier: ^6.37.0
|
specifier: ^6.37.0
|
||||||
version: 6.37.0(zod@3.25.76)
|
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)
|
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.0
|
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:
|
canvas-confetti:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
@@ -3688,6 +3691,10 @@ packages:
|
|||||||
ast-v8-to-istanbul@0.3.11:
|
ast-v8-to-istanbul@0.3.11:
|
||||||
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
|
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:
|
babel-dead-code-elimination@1.0.12:
|
||||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||||
|
|
||||||
@@ -4503,6 +4510,9 @@ packages:
|
|||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
generate-function@2.3.1:
|
||||||
|
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -4605,6 +4615,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
@@ -4691,6 +4705,9 @@ packages:
|
|||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
|
is-property@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
is-unicode-supported@0.1.0:
|
is-unicode-supported@0.1.0:
|
||||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4917,6 +4934,10 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
lucide-react@0.468.0:
|
||||||
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
|
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5181,6 +5202,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
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:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -5748,6 +5779,10 @@ packages:
|
|||||||
space-separated-tokens@2.0.2:
|
space-separated-tokens@2.0.2:
|
||||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
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:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
@@ -7572,10 +7607,10 @@ snapshots:
|
|||||||
|
|
||||||
'@js-sdsl/ordered-map@4.4.2': {}
|
'@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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.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)
|
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':
|
'@ljharb/through@2.3.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10028,6 +10063,8 @@ snapshots:
|
|||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
js-tokens: 10.0.0
|
js-tokens: 10.0.0
|
||||||
|
|
||||||
|
aws-ssl-profiles@1.1.2: {}
|
||||||
|
|
||||||
babel-dead-code-elimination@1.0.12:
|
babel-dead-code-elimination@1.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -10047,7 +10084,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
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:
|
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/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/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
|
zod: 4.3.6
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
drizzle-kit: 0.31.9
|
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)
|
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)
|
||||||
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)
|
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: 19.2.4
|
||||||
react-dom: 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)
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
gel: 2.2.0
|
gel: 2.2.0
|
||||||
kysely: 0.28.11
|
kysely: 0.28.11
|
||||||
|
mysql2: 3.22.4(@types/node@22.19.11)
|
||||||
postgres: 3.4.8
|
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:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -10814,6 +10886,10 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
generate-function@2.3.1:
|
||||||
|
dependencies:
|
||||||
|
is-property: 1.0.2
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
@@ -10941,6 +11017,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -11053,6 +11133,8 @@ snapshots:
|
|||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
|
is-property@1.0.2: {}
|
||||||
|
|
||||||
is-unicode-supported@0.1.0: {}
|
is-unicode-supported@0.1.0: {}
|
||||||
|
|
||||||
isbot@5.1.35: {}
|
isbot@5.1.35: {}
|
||||||
@@ -11245,6 +11327,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
lucide-react@0.468.0(react@19.2.4):
|
lucide-react@0.468.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -11707,6 +11791,22 @@ snapshots:
|
|||||||
|
|
||||||
mute-stream@1.0.0: {}
|
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: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
nanostores@1.1.0: {}
|
nanostores@1.1.0: {}
|
||||||
@@ -11715,7 +11815,34 @@ snapshots:
|
|||||||
|
|
||||||
neo-async@2.6.2: {}
|
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:
|
dependencies:
|
||||||
'@next/env': 16.1.6
|
'@next/env': 16.1.6
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -12360,6 +12487,8 @@ snapshots:
|
|||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
|
|
||||||
|
sql-escaper@1.3.3: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
standard-as-callback@2.1.0: {}
|
standard-as-callback@2.1.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user