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",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"mysql2": "^3.22.4",
|
||||
"openai": "^6.37.0",
|
||||
"postgres": "^3.4.0",
|
||||
"posthog-node": "^5.34.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CatalogModule } from "../catalog/catalog.module";
|
||||
import { CatalogSourceDbModule } from "../integrations/catalog-source-db/catalog-source-db.module";
|
||||
import { EmexModule } from "../integrations/emex/emex.module";
|
||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
@@ -8,7 +9,14 @@ import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
|
||||
imports: [
|
||||
PL24Module,
|
||||
EmexModule,
|
||||
PartsCatalogsModule,
|
||||
CatalogModule,
|
||||
TranslationsModule,
|
||||
CatalogSourceDbModule,
|
||||
],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
|
||||
@@ -37,6 +37,8 @@ function createService(db: any) {
|
||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||
),
|
||||
};
|
||||
const pcatSourceDb = { fetchParts: vi.fn().mockResolvedValue(null) };
|
||||
const emexSourceDb = { fetchCategoryParts: vi.fn().mockResolvedValue(null) };
|
||||
const service = new CategoriesService(
|
||||
db as any,
|
||||
redis as any,
|
||||
@@ -46,6 +48,8 @@ function createService(db: any) {
|
||||
storage as any,
|
||||
pl24FordLegacyService as any,
|
||||
translationsService as any,
|
||||
pcatSourceDb as any,
|
||||
emexSourceDb as any,
|
||||
);
|
||||
return { service, db, redis, pl24Service, translationsService };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
||||
import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
@@ -24,6 +26,8 @@ export class CategoriesService {
|
||||
private storage: StorageService,
|
||||
private pl24FordLegacyService: PL24FordLegacyService,
|
||||
private translationsService: TranslationsService,
|
||||
private pcatSourceDb: PcatSourceDbService,
|
||||
private emexSourceDb: EmexSourceDbService,
|
||||
) {}
|
||||
|
||||
async getCategoryTree(vehicleId: string) {
|
||||
@@ -928,12 +932,19 @@ export class CategoriesService {
|
||||
const vehicleRawData = vehicle.rawData as any;
|
||||
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
|
||||
|
||||
const partsResult = await this.partsCatalogsService.fetchParts(
|
||||
catalogId,
|
||||
carId,
|
||||
groupId,
|
||||
carParams,
|
||||
);
|
||||
// Local dump first; live upstream fallback. Logs hit/miss so prod
|
||||
// verification can measure source-DB coverage.
|
||||
let partsResult = await this.pcatSourceDb.fetchParts(catalogId, carId, groupId);
|
||||
if (partsResult) {
|
||||
this.logger.debug(`[source-db hit pcat] car=${carId} group=${groupId}`);
|
||||
} else {
|
||||
partsResult = await this.partsCatalogsService.fetchParts(
|
||||
catalogId,
|
||||
carId,
|
||||
groupId,
|
||||
carParams,
|
||||
);
|
||||
}
|
||||
|
||||
if (partsResult) {
|
||||
// Flatten part groups into parts
|
||||
@@ -1081,7 +1092,20 @@ export class CategoriesService {
|
||||
} else if (vehicle && category.source === "emex") {
|
||||
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
||||
try {
|
||||
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
// Local dump first (uses vehicles.rawData.ssd persisted by the
|
||||
// emex mapper). Falls back to live upstream on miss.
|
||||
const emexSsd = (vehicle.rawData as { ssd?: string } | null)?.ssd;
|
||||
let emexResult = await this.emexSourceDb.fetchCategoryParts(
|
||||
emexSsd,
|
||||
category.linkPath ?? "",
|
||||
);
|
||||
if (emexResult) {
|
||||
this.logger.debug(
|
||||
`[source-db hit emex] ssd=${emexSsd?.slice(0, 12)}... gid=${category.externalId}`,
|
||||
);
|
||||
} else {
|
||||
emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
}
|
||||
|
||||
// Build position code → sequential integer mapping for hotspot linking
|
||||
const posCodeToIndex = new Map<string, number>();
|
||||
|
||||
@@ -69,6 +69,11 @@ export default () => ({
|
||||
chatwoot: {
|
||||
hmacToken: process.env.CHATWOOT_HMAC_TOKEN,
|
||||
},
|
||||
catalogSource: {
|
||||
enabled: process.env.CATALOG_SOURCE_DB_ENABLED === "true",
|
||||
pcatUrl: process.env.PCAT_SOURCE_DB_URL,
|
||||
emexUrl: process.env.EMEX_SOURCE_DB_URL,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
@@ -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": {
|
||||
"skipToContent": "Skip to content"
|
||||
},
|
||||
"history": {
|
||||
"title": "Search History",
|
||||
"subtitle": "VINs you've decoded — reopen, copy or remove.",
|
||||
"newSearch": "Decode New VIN",
|
||||
"searchPlaceholder": "Search by brand, model, VIN or year",
|
||||
"itemCount": "{count} vehicle(s)",
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first",
|
||||
"brandAZ": "Brand (A→Z)",
|
||||
"yearDesc": "Year (newest first)"
|
||||
},
|
||||
"copyVin": "Copy VIN",
|
||||
"vinCopied": "VIN copied",
|
||||
"deleteAction": "Remove",
|
||||
"deleteConfirmTitle": "Remove this entry?",
|
||||
"deleteConfirmDescription": "{label} will be removed from your history. The shared vehicle data is preserved for other users.",
|
||||
"deleteConfirmAction": "Yes, remove",
|
||||
"deleteCancelAction": "Cancel",
|
||||
"deleted": "Removed from history.",
|
||||
"deleteFailed": "Couldn't remove. Please try again.",
|
||||
"planLocked": "Off-plan",
|
||||
"planLockedTooltip": "This brand isn't in your current plan — upgrade to reopen.",
|
||||
"loadMore": "Show more",
|
||||
"empty": {
|
||||
"title": "No VINs decoded yet",
|
||||
"description": "Decode a VIN to see results here. You'll land back on its parts catalog and diagrams in one click.",
|
||||
"cta": "Decode your first VIN"
|
||||
},
|
||||
"noResults": {
|
||||
"title": "No matches",
|
||||
"subtitle": "Try changing the query or the sort order.",
|
||||
"clear": "Clear filters"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"search": "Search",
|
||||
@@ -88,6 +124,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"title": "Parts Catalog",
|
||||
"subtitle": "Browse brand catalogs — find parts without a VIN.",
|
||||
"brands": "Brands",
|
||||
"models": "Models",
|
||||
"allBrands": "All Brands",
|
||||
@@ -97,6 +134,32 @@
|
||||
"locked": "This brand is not in your plan",
|
||||
"upgradeCta": "Upgrade Plan",
|
||||
"loadingModels": "Loading models...",
|
||||
"view": {
|
||||
"groupLabel": "View mode",
|
||||
"grid": "Grid",
|
||||
"list": "List",
|
||||
"columns": "Columns"
|
||||
},
|
||||
"brandSearchPlaceholder": "Search brand",
|
||||
"brandSearchNoMatch": "No brand matches \"{query}\"",
|
||||
"modelSearchPlaceholder": "Search model, engine or year",
|
||||
"modelSearchNoMatch": "No matching models",
|
||||
"hideLocked": "Hide brands not in plan",
|
||||
"showLocked": "Show brands not in plan",
|
||||
"lockedSection": "Brands not in your plan",
|
||||
"lockedSectionHint": "Upgrade to access these brands.",
|
||||
"inPlanSection": "Brands in your plan",
|
||||
"recentSection": "Recently visited",
|
||||
"goToModels": "Browse models",
|
||||
"selectBrandHint": "Select a brand from the left",
|
||||
"columnsBrowseHint": "Pick a brand on the left and its models appear here.",
|
||||
"modelCount2": "{count} model(s)",
|
||||
"sortLabel": "Sort",
|
||||
"sort": {
|
||||
"newest": "Year (newest first)",
|
||||
"oldest": "Year (oldest first)",
|
||||
"alphabetical": "Model (A→Z)"
|
||||
},
|
||||
"tabSasetr": "SASE",
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
|
||||
@@ -34,6 +34,42 @@
|
||||
"a11y": {
|
||||
"skipToContent": "İçeriğe atla"
|
||||
},
|
||||
"history": {
|
||||
"title": "Arama Geçmişi",
|
||||
"subtitle": "Daha önce çözdüğün şase numaraları — yeniden aç, kopyala veya sil.",
|
||||
"newSearch": "Yeni Şase Çöz",
|
||||
"searchPlaceholder": "Marka, model, şase veya yıl ara",
|
||||
"itemCount": "{count} araç",
|
||||
"sort": {
|
||||
"label": "Sırala",
|
||||
"newest": "En yeniden eskiye",
|
||||
"oldest": "En eskiden yeniye",
|
||||
"brandAZ": "Markaya göre (A→Z)",
|
||||
"yearDesc": "Yıla göre (yeniden eskiye)"
|
||||
},
|
||||
"copyVin": "Şase numarasını kopyala",
|
||||
"vinCopied": "Şase numarası kopyalandı",
|
||||
"deleteAction": "Sil",
|
||||
"deleteConfirmTitle": "Bu kaydı sil?",
|
||||
"deleteConfirmDescription": "{label} aracı geçmişinizden kaldırılacak. Araç verileri başka kullanıcılar için saklı kalır.",
|
||||
"deleteConfirmAction": "Evet, sil",
|
||||
"deleteCancelAction": "Vazgeç",
|
||||
"deleted": "Geçmişten kaldırıldı.",
|
||||
"deleteFailed": "Silinemedi. Lütfen tekrar deneyin.",
|
||||
"planLocked": "Plan dışı",
|
||||
"planLockedTooltip": "Bu marka mevcut planınızda yok — açabilmek için planınızı yükseltin.",
|
||||
"loadMore": "Daha fazla göster",
|
||||
"empty": {
|
||||
"title": "Henüz şase çözmedin",
|
||||
"description": "Bir şase numarası gir, sonuçları burada bul. Parça kataloğuna ve şemalara tek tıkla dönersin.",
|
||||
"cta": "İlk şase aramayı yap"
|
||||
},
|
||||
"noResults": {
|
||||
"title": "Eşleşen kayıt yok",
|
||||
"subtitle": "Aramayı veya sıralamayı değiştirmeyi dene.",
|
||||
"clear": "Filtreleri temizle"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Gösterge Paneli",
|
||||
"search": "Arama",
|
||||
@@ -88,6 +124,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"title": "Parça Kataloğu",
|
||||
"subtitle": "Marka kataloglarına göz at — şase bilmeden parça bul.",
|
||||
"brands": "Markalar",
|
||||
"models": "Modeller",
|
||||
"allBrands": "Tüm Markalar",
|
||||
@@ -97,6 +134,32 @@
|
||||
"locked": "Bu marka planınızda yok",
|
||||
"upgradeCta": "Planını Yükselt",
|
||||
"loadingModels": "Modeller yükleniyor...",
|
||||
"view": {
|
||||
"groupLabel": "Görünüm modu",
|
||||
"grid": "Izgara",
|
||||
"list": "Liste",
|
||||
"columns": "Sütun"
|
||||
},
|
||||
"brandSearchPlaceholder": "Marka ara",
|
||||
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok",
|
||||
"modelSearchPlaceholder": "Model, motor veya yıl ara",
|
||||
"modelSearchNoMatch": "Eşleşen model yok",
|
||||
"hideLocked": "Plan dışı markaları gizle",
|
||||
"showLocked": "Plan dışı markaları göster",
|
||||
"lockedSection": "Plan dışı markalar",
|
||||
"lockedSectionHint": "Erişmek istediğin markalar için planını yükselt.",
|
||||
"inPlanSection": "Planındaki markalar",
|
||||
"recentSection": "Son ziyaret ettiklerin",
|
||||
"goToModels": "Modellere Git",
|
||||
"selectBrandHint": "Soldan bir marka seç",
|
||||
"columnsBrowseHint": "Soldaki listeden bir marka seçince modeller burada görünür.",
|
||||
"modelCount2": "{count} model",
|
||||
"sortLabel": "Sırala",
|
||||
"sort": {
|
||||
"newest": "Yıla göre (yeniden eskiye)",
|
||||
"oldest": "Yıla göre (eskiden yeniye)",
|
||||
"alphabetical": "Modele göre (A→Z)"
|
||||
},
|
||||
"tabSasetr": "SASE",
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
CreditCard,
|
||||
FlaskConical,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
LogOut,
|
||||
Mail,
|
||||
@@ -69,7 +68,6 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const mainMenuItems: readonly NavItem[] = [
|
||||
{ to: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
||||
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
||||
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { RecentBrandsStrip } from "@/components/catalog/recent-brands-strip";
|
||||
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Skeleton, cn } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { readRecentBrands, recordRecentBrand } from "@/lib/recently-used-brands";
|
||||
import { useViewMode } from "@/lib/use-view-mode";
|
||||
import { Button, Input, Skeleton, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Library, Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Library, Lock, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||
component: CatalogBrandsPage,
|
||||
});
|
||||
@@ -29,93 +29,243 @@ interface CatalogBrand {
|
||||
|
||||
function CatalogBrandsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().brandViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("brandViewMode", mode);
|
||||
};
|
||||
const { data: session } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [viewMode, setViewMode] = useViewMode("brandViewMode");
|
||||
const [query, setQuery] = useState("");
|
||||
const [hideLocked, setHideLocked] = useState(false);
|
||||
const viewedRef = useRef(false);
|
||||
// Read recents once per user — they don't change inside this page session.
|
||||
const recents = useMemo(() => readRecentBrands(userId, 8), [userId]);
|
||||
|
||||
const { data: brands, isLoading } = useQuery({
|
||||
queryKey: ["catalog-brands"],
|
||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const accessByName = useMemo(() => {
|
||||
const m = new Map<string, boolean>();
|
||||
for (const b of brands ?? []) m.set(b.brandName, b.hasAccess);
|
||||
return m;
|
||||
}, [brands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (brands && !viewedRef.current) {
|
||||
viewedRef.current = true;
|
||||
const inPlan = brands.filter((b) => b.hasAccess).length;
|
||||
capture("catalog_brands_viewed", {
|
||||
total: brands.length,
|
||||
in_plan: inPlan,
|
||||
locked: brands.length - inPlan,
|
||||
});
|
||||
}
|
||||
}, [brands]);
|
||||
|
||||
// Filter + sort: in-plan brands first (alphabetical), locked second
|
||||
// (alphabetical). Search wipes the section split; matches all in one list.
|
||||
const { inPlan, locked, hasQuery, filteredTotal } = useMemo(() => {
|
||||
const empty = {
|
||||
inPlan: [] as CatalogBrand[],
|
||||
locked: [] as CatalogBrand[],
|
||||
hasQuery: false,
|
||||
filteredTotal: 0,
|
||||
};
|
||||
if (!brands) return empty;
|
||||
const q = query.trim().toLocaleLowerCase("tr");
|
||||
const matches = q
|
||||
? brands.filter((b) => b.brandName.toLocaleLowerCase("tr").includes(q))
|
||||
: brands;
|
||||
const ip = matches.filter((b) => b.hasAccess).sort(byBrandName);
|
||||
const lk = matches.filter((b) => !b.hasAccess).sort(byBrandName);
|
||||
return { inPlan: ip, locked: lk, hasQuery: q.length > 0, filteredTotal: matches.length };
|
||||
}, [brands, query]);
|
||||
|
||||
function changeViewMode(next: typeof viewMode) {
|
||||
setViewMode(next);
|
||||
capture("catalog_view_mode_changed", { mode: next, surface: "brands" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||
<CatalogHeader
|
||||
title={t("catalog.title")}
|
||||
subtitle={t("catalog.subtitle")}
|
||||
actions={
|
||||
<ViewModeToggle
|
||||
value={viewMode}
|
||||
onChange={changeViewMode}
|
||||
groupLabelKey="catalog.view.groupLabel"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Recently used — shown only when the user has actually visited some
|
||||
brand catalog before, and the brands list has loaded so we know
|
||||
access state for each chip. Hidden in cold-start state. */}
|
||||
{recents.length > 0 && brands && (
|
||||
<RecentBrandsStrip recents={recents} accessByName={accessByName} />
|
||||
)}
|
||||
|
||||
{/* Toolbar — search + hide-locked toggle */}
|
||||
{!isLoading && brands && brands.length > 0 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
capture("catalog_brand_search_used", { length: e.target.value.length });
|
||||
}
|
||||
}}
|
||||
placeholder={t("catalog.brandSearchPlaceholder")}
|
||||
aria-label={t("catalog.brandSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label={t("common.cancel")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !hideLocked;
|
||||
setHideLocked(next);
|
||||
capture("catalog_hide_locked_toggled", { hidden: next });
|
||||
}}
|
||||
aria-pressed={hideLocked}
|
||||
className="inline-flex items-center gap-2 self-start rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Lock className="size-3.5" />
|
||||
{hideLocked ? t("catalog.showLocked") : t("catalog.hideLocked")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Görünüm modu"
|
||||
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
|
||||
>
|
||||
{[
|
||||
{ mode: "grid" as const, Icon: CardsViewIcon, label: "Izgara" },
|
||||
{ mode: "tree" as const, Icon: TreeViewIcon, label: "Liste" },
|
||||
{ mode: "columns" as const, Icon: ListViewIcon, label: "Sütun" },
|
||||
].map(({ mode, Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
onClick={() => changeViewMode(mode)}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
|
||||
viewMode === mode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<Icon isActive={viewMode === mode} className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<BrandGridSkeleton />
|
||||
) : !brands || brands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
) : filteredTotal === 0 ? (
|
||||
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
|
||||
<p className="text-sm font-medium">{t("catalog.brandSearchNoMatch", { query })}</p>
|
||||
</div>
|
||||
) : viewMode === "tree" ? (
|
||||
<BrandListTree brands={brands} />
|
||||
<BrandListTree inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
|
||||
) : viewMode === "columns" ? (
|
||||
<BrandListColumns inPlan={inPlan} locked={hideLocked ? [] : locked} />
|
||||
) : (
|
||||
<BrandListColumns brands={brands} />
|
||||
<BrandGrid inPlan={inPlan} locked={hideLocked ? [] : locked} hasQuery={hasQuery} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Grid card (existing) ── */
|
||||
function byBrandName(a: CatalogBrand, b: CatalogBrand) {
|
||||
return a.brandName.localeCompare(b.brandName, "tr");
|
||||
}
|
||||
|
||||
function BrandGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
|
||||
function BrandGrid({
|
||||
inPlan,
|
||||
locked,
|
||||
hasQuery,
|
||||
}: {
|
||||
inPlan: CatalogBrand[];
|
||||
locked: CatalogBrand[];
|
||||
hasQuery: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// When the user searched, drop the section headings — a single visual list
|
||||
// matches the user's mental model ("show me matches").
|
||||
if (hasQuery) {
|
||||
const all = [...inPlan, ...locked];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{all.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{inPlan.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{inPlan.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{locked.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
label={t("catalog.lockedSection")}
|
||||
count={locked.length}
|
||||
hint={t("catalog.lockedSectionHint")}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{locked.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count, hint }: { label: string; count: number; hint?: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</h2>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/70">({count})</span>
|
||||
{hint && <p className="text-xs text-muted-foreground/80">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!brand.hasAccess) {
|
||||
return (
|
||||
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
|
||||
{/* Diagonal stripe overlay for locked feel */}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
onClick={() =>
|
||||
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
|
||||
}
|
||||
className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center transition-colors hover:border-primary/30 hover:bg-muted/50"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||
style={{
|
||||
@@ -131,7 +281,7 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
size={40}
|
||||
className="grayscale opacity-70"
|
||||
/>
|
||||
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
|
||||
<div className="absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-foreground">
|
||||
<Lock className="size-2.5 text-background" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,23 +289,26 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
|
||||
{t("catalog.locked")}
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
|
||||
>
|
||||
<span className="relative mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand transition-colors group-hover:text-brand/80">
|
||||
{t("catalog.upgradeCta")}
|
||||
<ChevronRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||
params={{ brandName: brand.brandName }}
|
||||
search={{ catalog: undefined }}
|
||||
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
|
||||
onClick={() =>
|
||||
capture("catalog_brand_clicked", {
|
||||
brand_name: brand.brandName,
|
||||
service_names: brand.serviceNames,
|
||||
})
|
||||
}
|
||||
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:border-primary/30 hover:bg-accent/40 hover:scale-[1.02]"
|
||||
>
|
||||
<CarBrandLogo
|
||||
brandName={brand.brandName}
|
||||
@@ -170,62 +323,111 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
|
||||
/* ── Tree (flat list) ── */
|
||||
|
||||
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
|
||||
function BrandListTree({
|
||||
inPlan,
|
||||
locked,
|
||||
hasQuery,
|
||||
}: {
|
||||
inPlan: CatalogBrand[];
|
||||
locked: CatalogBrand[];
|
||||
hasQuery: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (hasQuery) {
|
||||
return <TreeBlock brands={[...inPlan, ...locked]} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{inPlan.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader label={t("catalog.inPlanSection")} count={inPlan.length} />
|
||||
<TreeBlock brands={inPlan} />
|
||||
</section>
|
||||
)}
|
||||
{locked.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
label={t("catalog.lockedSection")}
|
||||
count={locked.length}
|
||||
hint={t("catalog.lockedSectionHint")}
|
||||
/>
|
||||
<TreeBlock brands={locked} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeBlock({ brands }: { brands: CatalogBrand[] }) {
|
||||
return (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{brands.map((brand) => {
|
||||
if (!brand.hasAccess) {
|
||||
return (
|
||||
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
{brands.map((brand) =>
|
||||
brand.hasAccess ? (
|
||||
<Link
|
||||
key={brand.brandName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||
params={{ brandName: brand.brandName }}
|
||||
search={{ catalog: undefined }}
|
||||
onClick={() =>
|
||||
capture("catalog_brand_clicked", {
|
||||
brand_name: brand.brandName,
|
||||
service_names: brand.serviceNames,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
<Link
|
||||
key={brand.brandName}
|
||||
to="/dashboard/subscription"
|
||||
onClick={() =>
|
||||
capture("catalog_locked_brand_upgrade_clicked", { brand_name: brand.brandName })
|
||||
}
|
||||
className="flex items-center gap-3 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Columns (left: brand list, right: detail + CTA) ── */
|
||||
/* ── Columns ── */
|
||||
|
||||
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
|
||||
function BrandListColumns({
|
||||
inPlan,
|
||||
locked,
|
||||
}: {
|
||||
inPlan: CatalogBrand[];
|
||||
locked: CatalogBrand[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const ordered = [...inPlan, ...locked];
|
||||
const [selectedName, setSelectedName] = useState<string | null>(
|
||||
() => ordered[0]?.brandName ?? null,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
|
||||
const selected = ordered.find((b) => b.brandName === selectedName) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
|
||||
{/* Left panel */}
|
||||
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
|
||||
{brands.map((brand) => (
|
||||
<div className="flex min-h-80 max-h-[480px] overflow-hidden rounded-lg border">
|
||||
<div className="w-[240px] shrink-0 overflow-y-auto border-r">
|
||||
{ordered.map((brand) => (
|
||||
<button
|
||||
key={brand.brandName}
|
||||
type="button"
|
||||
onClick={() => setSelectedName(brand.brandName)}
|
||||
disabled={!brand.hasAccess}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
|
||||
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:bg-accent",
|
||||
brand.hasAccess ? "hover:bg-accent" : "opacity-60 hover:opacity-100",
|
||||
selectedName === brand.brandName && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
@@ -239,36 +441,49 @@ function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
|
||||
{selected ? (
|
||||
<div className="space-y-4">
|
||||
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
|
||||
<p className="text-lg font-semibold">{selected.brandName}</p>
|
||||
{selected.hasAccess ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
capture("catalog_brand_clicked", {
|
||||
brand_name: selected.brandName,
|
||||
service_names: selected.serviceNames,
|
||||
surface: "columns",
|
||||
});
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName: encodeURIComponent(selected.brandName) },
|
||||
params: { brandName: selected.brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Modellere Git
|
||||
{t("catalog.goToModels")}
|
||||
<ChevronRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
onClick={() =>
|
||||
capture("catalog_locked_brand_upgrade_clicked", {
|
||||
brand_name: selected.brandName,
|
||||
surface: "columns",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.columnsBrowseHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { recordRecentBrand } from "@/lib/recently-used-brands";
|
||||
import { useViewMode } from "@/lib/use-view-mode";
|
||||
import { Badge, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Car,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Layers,
|
||||
Search as SearchIcon,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -40,33 +50,40 @@ interface CatalogVehicle {
|
||||
catalogPath: string | null;
|
||||
}
|
||||
|
||||
type SortMode = "newest" | "oldest" | "alphabetical";
|
||||
|
||||
// Each catalog service gets its own icon so the multi-source selector reads
|
||||
// as a real menu rather than four identical BookOpen tiles.
|
||||
function iconForService(serviceName: string) {
|
||||
const lower = serviceName.toLowerCase();
|
||||
if (lower.includes("pl24")) return Layers;
|
||||
if (lower.includes("pcat")) return Database;
|
||||
if (lower.includes("emex")) return Zap;
|
||||
if (lower.includes("tecdoc")) return Wrench;
|
||||
return BookOpen;
|
||||
}
|
||||
|
||||
function CatalogModelsPage() {
|
||||
const { brandName } = Route.useParams();
|
||||
const { catalog: activeCatalog } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: session } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [viewMode, setViewMode] = useViewMode("modelViewMode");
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("newest");
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().modelViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("modelViewMode", mode);
|
||||
};
|
||||
|
||||
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
|
||||
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||
queryFn: () =>
|
||||
api.get<CatalogEntry[]>(`/catalog/brands/${encodeURIComponent(decodedBrandName)}/catalogs`),
|
||||
staleTime: 1000 * 60 * 30, // 30 min
|
||||
queryFn: () => api.get<CatalogEntry[]>(`/catalog/brands/${decodedBrandName}/catalogs`),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
const isMultiCatalog = (catalogs?.length ?? 0) > 1;
|
||||
// Show models when: single-service brand, OR user has selected a sub-catalog
|
||||
const shouldShowModels = !isMultiCatalog || !!activeCatalog;
|
||||
|
||||
const activeCatalogLabel = activeCatalog
|
||||
@@ -77,73 +94,117 @@ function CatalogModelsPage() {
|
||||
queryKey: ["catalog-models", decodedBrandName, activeCatalog ?? null],
|
||||
queryFn: () => {
|
||||
const serviceParam = activeCatalog ? `?service=${encodeURIComponent(activeCatalog)}` : "";
|
||||
return api.get<CatalogVehicle[]>(
|
||||
`/catalog/brands/${encodeURIComponent(decodedBrandName)}/models${serviceParam}`,
|
||||
);
|
||||
return api.get<CatalogVehicle[]>(`/catalog/brands/${decodedBrandName}/models${serviceParam}`);
|
||||
},
|
||||
enabled: shouldShowModels && !catalogsLoading,
|
||||
});
|
||||
|
||||
const handleBack = () => {
|
||||
useEffect(() => {
|
||||
if (models && !viewedRef.current && shouldShowModels) {
|
||||
viewedRef.current = true;
|
||||
capture("catalog_models_viewed", {
|
||||
brand_name: decodedBrandName,
|
||||
catalog: activeCatalog ?? null,
|
||||
count: models.length,
|
||||
});
|
||||
// Visit-tracked here rather than at the link click — a click that
|
||||
// never resolves into a real visit (auth gate, slow nav cancel) is
|
||||
// not a "recently used" signal worth surfacing.
|
||||
recordRecentBrand(userId, { brandName: decodedBrandName, logoUrl: null });
|
||||
}
|
||||
}, [models, shouldShowModels, decodedBrandName, activeCatalog, userId]);
|
||||
|
||||
// Reset the "models viewed" lock when the active catalog changes so we
|
||||
// capture one event per (brand × catalog) pair.
|
||||
useEffect(() => {
|
||||
viewedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const sortedFiltered = useMemo(() => {
|
||||
if (!models) return [];
|
||||
const q = query.trim().toLocaleLowerCase("tr");
|
||||
const filtered = q
|
||||
? models.filter((m) => {
|
||||
const hay = `${m.model} ${m.engine ?? ""} ${m.year ?? ""} ${
|
||||
m.bodyType ?? ""
|
||||
}`.toLocaleLowerCase("tr");
|
||||
return hay.includes(q);
|
||||
})
|
||||
: models;
|
||||
const sorted = [...filtered];
|
||||
switch (sort) {
|
||||
case "oldest":
|
||||
sorted.sort((a, b) => (Number(a.year ?? 0) || 0) - (Number(b.year ?? 0) || 0));
|
||||
break;
|
||||
case "alphabetical":
|
||||
sorted.sort((a, b) => a.model.localeCompare(b.model, "tr"));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => (Number(b.year ?? 0) || 0) - (Number(a.year ?? 0) || 0));
|
||||
}
|
||||
return sorted;
|
||||
}, [models, query, sort]);
|
||||
|
||||
function handleBack() {
|
||||
if (isMultiCatalog && activeCatalog) {
|
||||
// Go back to catalog selector
|
||||
navigate({ to: ".", search: { catalog: undefined } });
|
||||
} else {
|
||||
navigate({ to: "/dashboard/catalog" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function changeViewMode(next: typeof viewMode) {
|
||||
setViewMode(next);
|
||||
capture("catalog_view_mode_changed", { mode: next, surface: "models" });
|
||||
}
|
||||
|
||||
const crumbs =
|
||||
isMultiCatalog && activeCatalog
|
||||
? [
|
||||
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||
{
|
||||
label: decodedBrandName,
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
search: { catalog: undefined },
|
||||
},
|
||||
{ label: activeCatalogLabel ?? activeCatalog },
|
||||
]
|
||||
: [{ label: t("catalog.title"), to: "/dashboard/catalog" }, { label: decodedBrandName }];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
{" / "}
|
||||
{isMultiCatalog && activeCatalog ? (
|
||||
<>
|
||||
<Link to="." search={{ catalog: undefined }} className="hover:underline">
|
||||
{decodedBrandName}
|
||||
</Link>
|
||||
{" / "}
|
||||
<span className="font-medium text-foreground">{activeCatalogLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{decodedBrandName}</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{isMultiCatalog && activeCatalog ? activeCatalogLabel : decodedBrandName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<CatalogHeader
|
||||
crumbs={crumbs}
|
||||
title={
|
||||
isMultiCatalog && activeCatalog
|
||||
? (activeCatalogLabel ?? decodedBrandName)
|
||||
: decodedBrandName
|
||||
}
|
||||
onBack={handleBack}
|
||||
actions={
|
||||
shouldShowModels && models && models.length > 0 ? (
|
||||
<ViewModeToggle
|
||||
value={viewMode}
|
||||
onChange={changeViewMode}
|
||||
groupLabelKey="catalog.view.groupLabel"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{catalogsLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_4.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : isMultiCatalog && !activeCatalog && catalogs ? (
|
||||
// Sub-catalog selector
|
||||
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
|
||||
) : modelsLoading ? (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("catalog.loadingModels")}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_9.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_9.map((__k) => (
|
||||
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !models || models.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
@@ -151,56 +212,78 @@ function CatalogModelsPage() {
|
||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-md">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
capture("catalog_model_search_used", {
|
||||
brand_name: decodedBrandName,
|
||||
length: e.target.value.length,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder={t("catalog.modelSearchPlaceholder")}
|
||||
aria-label={t("catalog.modelSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label={t("common.cancel")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("tree")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Liste"
|
||||
>
|
||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeViewMode("columns")}
|
||||
className={cn(
|
||||
"rounded p-1.5",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<ListViewIcon isActive={viewMode === "columns"} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("catalog.modelCount2", { count: sortedFiltered.length })}
|
||||
</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as SortMode;
|
||||
setSort(v);
|
||||
capture("catalog_model_sort_changed", { sort: v });
|
||||
}}
|
||||
aria-label={t("catalog.sortLabel")}
|
||||
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<option value="newest">{t("catalog.sort.newest")}</option>
|
||||
<option value="oldest">{t("catalog.sort.oldest")}</option>
|
||||
<option value="alphabetical">{t("catalog.sort.alphabetical")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
||||
))}
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
|
||||
<p className="text-sm font-medium">{t("catalog.modelSearchNoMatch")}</p>
|
||||
</div>
|
||||
) : viewMode === "tree" ? (
|
||||
<ModelListTree models={models} brandName={brandName} />
|
||||
<ModelListTree models={sortedFiltered} brandName={brandName} />
|
||||
) : viewMode === "columns" ? (
|
||||
<ModelListColumns models={sortedFiltered} brandName={brandName} />
|
||||
) : (
|
||||
<ModelListColumns models={models} brandName={brandName} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedFiltered.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
brandName={brandName}
|
||||
brandLabel={decodedBrandName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -218,52 +301,80 @@ function CatalogSelector({
|
||||
brandLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("catalog.selectCatalog")} — {brandLabel}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{catalogs.map((cat) => (
|
||||
<Link
|
||||
key={cat.serviceName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: cat.serviceName }}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<BookOpen className="size-5 text-primary" />
|
||||
{catalogs.map((cat) => {
|
||||
const Icon = iconForService(cat.serviceName);
|
||||
return (
|
||||
<Link
|
||||
key={cat.serviceName}
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName }}
|
||||
search={{ catalog: cat.serviceName }}
|
||||
onClick={() =>
|
||||
capture("catalog_subcatalog_selected", {
|
||||
brand_name: brandLabel,
|
||||
service: cat.serviceName,
|
||||
})
|
||||
}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/30 hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Icon className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{cat.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{cat.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{cat.serviceName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: string }) {
|
||||
function ModelCard({
|
||||
model,
|
||||
brandName,
|
||||
brandLabel,
|
||||
}: {
|
||||
model: CatalogVehicle;
|
||||
brandName: string;
|
||||
brandLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName/$modelId"
|
||||
params={{ brandName, modelId: model.id }}
|
||||
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
onClick={() =>
|
||||
capture("catalog_model_clicked", {
|
||||
brand_name: brandLabel,
|
||||
model: model.model,
|
||||
year: model.year,
|
||||
service: model.serviceName,
|
||||
})
|
||||
}
|
||||
preload="intent"
|
||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/30 hover:bg-accent/40"
|
||||
>
|
||||
<p className="font-semibold">{model.model}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{model.year && <span>{model.year}</span>}
|
||||
{model.engine && <span>{model.engine}</span>}
|
||||
{model.bodyType && <span>{model.bodyType}</span>}
|
||||
{model.transmission && <span>{model.transmission}</span>}
|
||||
<p className="font-semibold leading-5">{model.model}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{model.year && (
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{model.year}
|
||||
</Badge>
|
||||
)}
|
||||
{model.engine && <Badge variant="outline">{model.engine}</Badge>}
|
||||
{model.bodyType && <Badge variant="outline">{model.bodyType}</Badge>}
|
||||
{model.transmission && <Badge variant="outline">{model.transmission}</Badge>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle, Car, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
||||
component: EmexVehicleListPage,
|
||||
@@ -44,10 +46,19 @@ interface EmexVehicle {
|
||||
function EmexVehicleListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
// Current SSD state for wizard navigation
|
||||
const [ssd, setSsd] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewedRef.current) {
|
||||
viewedRef.current = true;
|
||||
capture("catalog_emex_wizard_opened", { catalog_code: catalogCode });
|
||||
}
|
||||
}, [catalogCode]);
|
||||
|
||||
// Fetch wizard data for current SSD
|
||||
const {
|
||||
data: wizardRows,
|
||||
@@ -117,22 +128,22 @@ function EmexVehicleListPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog" search={{}}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
|
||||
{determined.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="mr-1 size-3.5" />
|
||||
{t("catalog.resetSelection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CatalogHeader
|
||||
crumbs={[
|
||||
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||
{ label: decodeURIComponent(catalogCode) },
|
||||
]}
|
||||
title={decodeURIComponent(catalogCode)}
|
||||
onBack={() => navigate({ to: "/dashboard/catalog" })}
|
||||
actions={
|
||||
determined.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="mr-1 size-3.5" />
|
||||
{t("catalog.resetSelection")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Determined params — shown as tags */}
|
||||
{determined.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { CatalogHeader } from "@/components/catalog/catalog-header";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Library } from "lucide-react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Library } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
|
||||
component: PcatModelsPage,
|
||||
@@ -22,28 +26,39 @@ interface PcatModel {
|
||||
function PcatModelsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const { data: models, isLoading } = useQuery({
|
||||
queryKey: ["pcat-models", catalogId],
|
||||
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (models && !viewedRef.current) {
|
||||
viewedRef.current = true;
|
||||
capture("catalog_pcat_models_viewed", {
|
||||
catalog_id: catalogId,
|
||||
count: models.length,
|
||||
});
|
||||
}
|
||||
}, [models, catalogId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog" search={{}}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
|
||||
</div>
|
||||
<CatalogHeader
|
||||
crumbs={[
|
||||
{ label: t("catalog.title"), to: "/dashboard/catalog" },
|
||||
{ label: catalogId.toUpperCase() },
|
||||
]}
|
||||
title={catalogId.toUpperCase()}
|
||||
onBack={() => navigate({ to: "/dashboard/catalog" })}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||
{KEYS_10.map((__k) => (
|
||||
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !models || models.length === 0 ? (
|
||||
@@ -55,7 +70,15 @@ function PcatModelsPage() {
|
||||
key={model.id}
|
||||
to="/dashboard/catalog/pcat/$catalogId/$modelId"
|
||||
params={{ catalogId, modelId: model.id }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
onClick={() =>
|
||||
capture("catalog_pcat_model_clicked", {
|
||||
catalog_id: catalogId,
|
||||
model_id: model.id,
|
||||
model_name: model.name,
|
||||
})
|
||||
}
|
||||
preload="intent"
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/30 hover:bg-accent/40"
|
||||
>
|
||||
{model.imgUrl ? (
|
||||
<img
|
||||
@@ -64,18 +87,20 @@ function PcatModelsPage() {
|
||||
className="mb-2 h-16 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<Library className="size-5 text-emerald-500" />
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Library className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-semibold">{model.name}</p>
|
||||
{(model.yearFrom || model.yearTo) && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{model.yearFrom || "?"} - {model.yearTo || "..."}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{model.yearFrom || "?"} – {model.yearTo || "…"}
|
||||
</p>
|
||||
)}
|
||||
{model.carsCount > 0 && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{model.carsCount} araç</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("catalog.modelCount2", { count: model.carsCount })}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,66 +1,453 @@
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
|
||||
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
|
||||
import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Car, Clock, Copy, Lock, Search as SearchIcon, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface VehicleHistoryItem {
|
||||
id: string;
|
||||
vin: string;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
engine: string | null;
|
||||
bodyType: string | null;
|
||||
lastAccessedAt: string | null;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
brands?: { brandId: string; brandName: string }[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
type SortMode = "newest" | "oldest" | "brand" | "yearDesc";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/history")({
|
||||
component: HistoryPage,
|
||||
});
|
||||
|
||||
function relativeFromNow(iso: string | null, locale: "tr" | "en"): string {
|
||||
if (!iso) return "";
|
||||
const ts = new Date(iso).getTime();
|
||||
if (Number.isNaN(ts)) return "";
|
||||
const diffSec = Math.round((ts - Date.now()) / 1000);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale === "tr" ? "tr" : "en", {
|
||||
numeric: "auto",
|
||||
});
|
||||
const abs = Math.abs(diffSec);
|
||||
if (abs < 60) return rtf.format(diffSec, "second");
|
||||
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
|
||||
if (abs < 86_400) return rtf.format(Math.round(diffSec / 3600), "hour");
|
||||
if (abs < 86_400 * 30) return rtf.format(Math.round(diffSec / 86_400), "day");
|
||||
if (abs < 86_400 * 365) return rtf.format(Math.round(diffSec / (86_400 * 30)), "month");
|
||||
return rtf.format(Math.round(diffSec / (86_400 * 365)), "year");
|
||||
}
|
||||
|
||||
function absoluteDate(iso: string | null, locale: "tr" | "en"): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString(locale === "tr" ? "tr-TR" : "en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<any[]>("/vehicles/history"),
|
||||
const { t, locale } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("newest");
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<VehicleHistoryItem | null>(null);
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ["vehicles", "history", "all"],
|
||||
queryFn: () => api.get<VehicleHistoryItem[]>(`/vehicles/history?page=1&limit=${PAGE_SIZE * 8}`),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const { data: subData } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () => api.get<{ subscription: Subscription | null }>("/subscriptions/me"),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (pages && !viewedRef.current) {
|
||||
viewedRef.current = true;
|
||||
capture("history_viewed", { count: pages.length });
|
||||
}
|
||||
}, [pages]);
|
||||
|
||||
const accessibleBrandIds = useMemo(() => {
|
||||
const sub = subData?.subscription;
|
||||
if (!sub || sub.plan?.key === "full") return null; // null = no restriction
|
||||
return new Set((sub.brands ?? []).map((b) => b.brandId));
|
||||
}, [subData]);
|
||||
|
||||
const filteredSorted = useMemo(() => {
|
||||
if (!pages) return [];
|
||||
const q = query.trim().toLocaleLowerCase("tr");
|
||||
const filtered = q
|
||||
? pages.filter((v) => {
|
||||
const hay = `${v.brandName} ${v.model ?? ""} ${v.vin} ${v.year ?? ""} ${
|
||||
v.engine ?? ""
|
||||
}`.toLocaleLowerCase("tr");
|
||||
return hay.includes(q);
|
||||
})
|
||||
: pages;
|
||||
const sorted = [...filtered];
|
||||
switch (sort) {
|
||||
case "oldest":
|
||||
sorted.sort((a, b) => {
|
||||
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
|
||||
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
|
||||
return ta - tb;
|
||||
});
|
||||
break;
|
||||
case "brand":
|
||||
sorted.sort((a, b) => a.brandName.localeCompare(b.brandName, "tr"));
|
||||
break;
|
||||
case "yearDesc":
|
||||
sorted.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) => {
|
||||
const ta = a.lastAccessedAt ? new Date(a.lastAccessedAt).getTime() : 0;
|
||||
const tb = b.lastAccessedAt ? new Date(b.lastAccessedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}, [pages, query, sort]);
|
||||
|
||||
const visible = useMemo(() => filteredSorted.slice(0, page * PAGE_SIZE), [filteredSorted, page]);
|
||||
const hasMore = visible.length < filteredSorted.length;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/vehicles/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
queryClient.setQueryData<VehicleHistoryItem[]>(["vehicles", "history", "all"], (prev) =>
|
||||
prev ? prev.filter((v) => v.id !== id) : prev,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicles", "history"] });
|
||||
toast.success(t("history.deleted"));
|
||||
capture("history_item_deleted", { vehicle_id: id });
|
||||
},
|
||||
onError: () => toast.error(t("history.deleteFailed")),
|
||||
});
|
||||
|
||||
function onCopyVin(vin: string) {
|
||||
navigator.clipboard.writeText(vin).then(
|
||||
() => toast.success(t("history.vinCopied")),
|
||||
() => toast.error(t("common.error")),
|
||||
);
|
||||
capture("history_vin_copied", { vin });
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setQuery("");
|
||||
setSort("newest");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const hasActiveFilter = query.trim() !== "" || sort !== "newest";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Arama Geçmişi</h2>
|
||||
<p className="text-muted-foreground">Daha önce aradığınız araçlar</p>
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-tight">{t("history.title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("history.subtitle")}</p>
|
||||
</div>
|
||||
<Link to="/dashboard/search">
|
||||
<Button>
|
||||
<SearchIcon className="mr-1.5 size-4" />
|
||||
{t("history.newSearch")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{HISTORY_SKEL_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
Henüz arama yapmadınız.
|
||||
<br />
|
||||
<Link to="/dashboard/search" className="text-primary hover:underline">
|
||||
Şase arama sayfasına gidin
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.map((vehicle: any) => (
|
||||
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
|
||||
<Card className="transition-shadow hover:shadow-md cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{vehicle.brandName} {vehicle.model}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">{vehicle.year}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle.vin}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{/* Toolbar */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-md">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(1);
|
||||
if (e.target.value.trim()) {
|
||||
capture("history_search_used", { length: e.target.value.length });
|
||||
}
|
||||
}}
|
||||
placeholder={t("history.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
aria-label={t("history.searchPlaceholder")}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={t("common.cancel")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("history.itemCount", { count: filteredSorted.length })}
|
||||
</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as SortMode;
|
||||
setSort(v);
|
||||
setPage(1);
|
||||
capture("history_sort_changed", { sort: v });
|
||||
}}
|
||||
aria-label={t("history.sort.label")}
|
||||
className="h-9 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<option value="newest">{t("history.sort.newest")}</option>
|
||||
<option value="oldest">{t("history.sort.oldest")}</option>
|
||||
<option value="brand">{t("history.sort.brandAZ")}</option>
|
||||
<option value="yearDesc">{t("history.sort.yearDesc")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{["s1", "s2", "s3", "s4", "s5", "s6"].map((k) => (
|
||||
<Skeleton key={k} className="h-[148px] w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !pages || pages.length === 0 ? (
|
||||
/* Empty — no history at all */
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/20 p-10 text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Car className="size-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t("history.empty.title")}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("history.empty.description")}</p>
|
||||
<Link to="/dashboard/search">
|
||||
<Button className="mt-4">
|
||||
{t("history.empty.cta")}
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : filteredSorted.length === 0 ? (
|
||||
/* Filter wiped everything */
|
||||
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
|
||||
<p className="text-sm font-medium text-foreground">{t("history.noResults.title")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("history.noResults.subtitle")}</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={clearFilters}>
|
||||
{t("history.noResults.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((v) => (
|
||||
<HistoryCard
|
||||
key={v.id}
|
||||
vehicle={v}
|
||||
locked={accessibleBrandIds ? !accessibleBrandIds.has(v.brandId) : false}
|
||||
onCopyVin={onCopyVin}
|
||||
onDelete={() => setDeleteTarget(v)}
|
||||
t={t}
|
||||
locale={locale === "en" ? "en" : "tr"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPage((p) => p + 1);
|
||||
capture("history_load_more", { page: page + 1 });
|
||||
}}
|
||||
>
|
||||
{t("history.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{hasActiveFilter && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
{t("history.noResults.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("history.deleteConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("history.deleteConfirmDescription", {
|
||||
label: deleteTarget
|
||||
? `${deleteTarget.brandName} ${deleteTarget.model ?? ""}`.trim()
|
||||
: "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
{t("history.deleteCancelAction")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
const id = deleteTarget.id;
|
||||
deleteMutation.mutate(id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-4" />
|
||||
{t("history.deleteConfirmAction")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryCard({
|
||||
vehicle,
|
||||
locked,
|
||||
onCopyVin,
|
||||
onDelete,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
vehicle: VehicleHistoryItem;
|
||||
locked: boolean;
|
||||
onCopyVin: (vin: string) => void;
|
||||
onDelete: () => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
locale: "tr" | "en";
|
||||
}) {
|
||||
const when = relativeFromNow(vehicle.lastAccessedAt, locale);
|
||||
const whenAbs = absoluteDate(vehicle.lastAccessedAt, locale);
|
||||
return (
|
||||
<div
|
||||
className={`group flex flex-col rounded-2xl border bg-background p-4 transition-colors ${
|
||||
locked
|
||||
? "border-border opacity-80"
|
||||
: "border-border hover:border-primary/40 hover:bg-accent/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: vehicle.id }}
|
||||
preload="intent"
|
||||
onClick={() => capture("history_item_clicked", { vehicle_id: vehicle.id })}
|
||||
className="flex flex-1 items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-lg"
|
||||
>
|
||||
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold leading-5">
|
||||
{vehicle.brandName} {vehicle.model ?? ""}
|
||||
</p>
|
||||
{vehicle.engine && (
|
||||
<p className="truncate text-xs text-muted-foreground">{vehicle.engine}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{vehicle.year && (
|
||||
<Badge variant="secondary" className="shrink-0 tabular-nums">
|
||||
{vehicle.year}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs">
|
||||
{vehicle.vin}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopyVin(vehicle.vin)}
|
||||
aria-label={t("history.copyVin")}
|
||||
title={t("history.copyVin")}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1" title={whenAbs} aria-label={whenAbs}>
|
||||
<Clock className="size-3" />
|
||||
{when}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{locked && (
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center gap-1 rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
|
||||
title={t("history.planLockedTooltip")}
|
||||
aria-label={t("history.planLockedTooltip")}
|
||||
>
|
||||
<Lock className="size-3" />
|
||||
{t("history.planLocked")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={t("history.deleteAction")}
|
||||
title={t("history.deleteAction")}
|
||||
className="rounded-md p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,460 +1,13 @@
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { KEYS_4 } from "@/lib/keys";
|
||||
import { Badge, Button, Separator, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Car,
|
||||
CheckCircle2,
|
||||
Crown,
|
||||
Database,
|
||||
Search,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
// The old /dashboard "home" page was a low-value stat-and-profile screen that
|
||||
// duplicated information already shown on /subscription and /billing, and for
|
||||
// new users showed mostly zeros. The product's core motion is VIN decoding,
|
||||
// so /dashboard now bounces straight to /dashboard/search. Trial urgency,
|
||||
// subscription status and recent searches are surfaced by the dashboard
|
||||
// layout shell + the search page itself.
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
component: DashboardHome,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/search" });
|
||||
},
|
||||
});
|
||||
|
||||
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DashboardStats {
|
||||
totalSearches: number;
|
||||
monthlySearches: number;
|
||||
totalVehicles: number;
|
||||
recentVehicles: number;
|
||||
}
|
||||
|
||||
interface SubscriptionBrand {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod: string;
|
||||
brands?: SubscriptionBrand[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// ─── STAT CARD ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
detail,
|
||||
detailValue,
|
||||
buttonLabel,
|
||||
buttonTo,
|
||||
progress,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
detailValue?: string;
|
||||
buttonLabel?: string;
|
||||
buttonTo?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
{/* Icon + Value + Label */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{detail}</span>
|
||||
<span className="font-medium">{detailValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<Separator className="my-4 bg-border" />
|
||||
|
||||
{/* Progress bar or button */}
|
||||
{progress !== undefined ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Kullanım</span>
|
||||
<span className="font-semibold">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : buttonLabel && buttonTo ? (
|
||||
<Link to={buttonTo}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center rounded-lg border-border text-sm"
|
||||
>
|
||||
{buttonLabel}
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PROFILE CARD ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileCard({
|
||||
name,
|
||||
email,
|
||||
initials,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<User className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4 bg-border" />
|
||||
|
||||
<Link to="/dashboard/settings">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center rounded-lg border-border text-sm"
|
||||
>
|
||||
Profil Ayarları
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PAGE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DashboardHome() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ["dashboard", "stats"],
|
||||
queryFn: async () => {
|
||||
// Try to get stats from the API, fall back to defaults
|
||||
try {
|
||||
return await api.get<DashboardStats>("/dashboard/stats");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription ?? null;
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<any[]>("/vehicles/history?limit=5");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
const totalSearches = stats?.totalSearches ?? history?.length ?? 0;
|
||||
const monthlySearches = stats?.monthlySearches ?? 0;
|
||||
const totalVehicles = stats?.totalVehicles ?? history?.length ?? 0;
|
||||
const recentVehicles = stats?.recentVehicles ?? 0;
|
||||
const brandCount = subscription?.brands?.length ?? 0;
|
||||
const planKey: string | null = subscription?.plan?.name
|
||||
? ({
|
||||
"1 Marka": "brand1",
|
||||
"2 Marka": "brand2",
|
||||
"3 Marka": "brand3",
|
||||
"Full Paket": "full",
|
||||
}[subscription.plan.name] ?? null)
|
||||
: null;
|
||||
const maxBrands =
|
||||
planKey === "full"
|
||||
? 27
|
||||
: planKey === "brand3"
|
||||
? 3
|
||||
: planKey === "brand2"
|
||||
? 2
|
||||
: planKey === "brand1"
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const isLoadingCards = statsLoading || subLoading;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
|
||||
{isLoadingCards ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{KEYS_4.map((__k) => (
|
||||
<Skeleton key={__k} className="h-64 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 1. Toplam Arama */}
|
||||
<StatCard
|
||||
icon={Search}
|
||||
value={String(totalSearches)}
|
||||
label="Toplam Arama"
|
||||
detail="Bu Ay"
|
||||
detailValue={String(monthlySearches)}
|
||||
buttonLabel="Şase Çöz"
|
||||
buttonTo="/dashboard/search"
|
||||
/>
|
||||
|
||||
{/* 2. Araç Kaydı */}
|
||||
<StatCard
|
||||
icon={Car}
|
||||
value={String(totalVehicles)}
|
||||
label="Araç Kaydı"
|
||||
detail="Son 30 Gün"
|
||||
detailValue={String(recentVehicles)}
|
||||
buttonLabel="Geçmişe Git"
|
||||
buttonTo="/dashboard/history"
|
||||
/>
|
||||
|
||||
{/* 3. Aktif Marka */}
|
||||
<StatCard
|
||||
icon={Database}
|
||||
value={maxBrands > 0 ? `${brandCount}/${maxBrands}` : "—"}
|
||||
label="Aktif Marka"
|
||||
detail="Erişilebilir Marka"
|
||||
detailValue={maxBrands > 0 ? String(maxBrands) : "—"}
|
||||
progress={maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined}
|
||||
buttonLabel={maxBrands === 0 ? "Plan Seçin" : undefined}
|
||||
buttonTo={maxBrands === 0 ? "/dashboard/subscription" : undefined}
|
||||
/>
|
||||
|
||||
{/* 4. Profil */}
|
||||
<ProfileCard name={user?.name ?? "—"} email={user?.email ?? "—"} initials={initials} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── ACTIVE SUBSCRIPTION ────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
Aktif Aboneliğiniz
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Şase çözme ve parça kataloğuna tek tıkla erişin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{subLoading ? (
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
) : subscription &&
|
||||
(subscription.status === "active" ||
|
||||
(subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Plan Info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Crown className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold">
|
||||
{subscription.plan?.name ?? "Aktif Plan"}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
||||
>
|
||||
{subscription.status === "trial" ? "Deneme" : "Aktif"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subscription.billingPeriod === "yearly" ? "Yıllık" : "Aylık"} abonelik
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brands */}
|
||||
{subscription.brands && subscription.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge
|
||||
key={b.brandId}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
|
||||
{b.brandName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
{subscription.startDate && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
Başlangıç:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.startDate).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{subscription.endDate && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
Bitiş:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{["Sınırsız şase arama", "Parça kataloğu", "İnteraktif şema"].map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-brand" />
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link to="/dashboard/search">
|
||||
<Button className="w-full rounded-lg sm:w-auto">
|
||||
Şase Ara
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/subscription">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-lg border-border text-sm sm:w-auto"
|
||||
>
|
||||
Plan Detayları
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* No subscription */
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background p-8 text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Crown className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">Henüz aktif planınız yok</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şase çözme, parça kataloğu ve interaktif şemalara erişmek için bir plan seçin.
|
||||
</p>
|
||||
<Link to="/dashboard/subscription">
|
||||
<Button className="mt-4 rounded-lg">
|
||||
Plan Seçin
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── SON ARAMALAR ───────────────────────────────────────────── */}
|
||||
{history && history.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
Son Aramalar
|
||||
</h2>
|
||||
<Link
|
||||
to="/dashboard/history"
|
||||
className="text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
Tümünü Gör →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{history.slice(0, 3).map((v: any) => (
|
||||
<Link key={v.id} to="/dashboard/vehicles/$id" params={{ id: v.id }}>
|
||||
<div className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 transition-colors hover:bg-accent">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Car className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{v.brandName} {v.model}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{v.year}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,17 @@ export const envSchema = z.object({
|
||||
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
|
||||
// endpoint returns 503 and the widget falls back to anonymous visitors.
|
||||
CHATWOOT_HMAC_TOKEN: z.string().optional(),
|
||||
|
||||
// Catalog-source dumps — local DB-first lookup before live scrape.
|
||||
// When CATALOG_SOURCE_DB_ENABLED is "true" AND the URL for a source is set,
|
||||
// prefetch/category fetches will try the local dump DB first and only fall
|
||||
// back to the live upstream on a miss. Both URLs optional independently.
|
||||
CATALOG_SOURCE_DB_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
PCAT_SOURCE_DB_URL: z.string().url().optional(),
|
||||
EMEX_SOURCE_DB_URL: z.string().optional(), // mysql://... — not a strict URL per WHATWG
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
151
pnpm-lock.yaml
generated
151
pnpm-lock.yaml
generated
@@ -26,7 +26,7 @@ importers:
|
||||
version: 3.988.0
|
||||
'@kubiks/otel-drizzle':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))
|
||||
version: 2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))
|
||||
'@nestjs/common':
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -110,7 +110,7 @@ importers:
|
||||
version: 10.52.0(@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0))
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
bullmq:
|
||||
specifier: ^5.30.0
|
||||
version: 5.68.0
|
||||
@@ -125,13 +125,16 @@ importers:
|
||||
version: 0.31.9
|
||||
drizzle-orm:
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||
version: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
helmet:
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.2
|
||||
mysql2:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@types/node@22.19.11)
|
||||
openai:
|
||||
specifier: ^6.37.0
|
||||
version: 6.37.0(zod@3.25.76)
|
||||
@@ -219,7 +222,7 @@ importers:
|
||||
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -3688,6 +3691,10 @@ packages:
|
||||
ast-v8-to-istanbul@0.3.11:
|
||||
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
|
||||
|
||||
aws-ssl-profiles@1.1.2:
|
||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||
|
||||
@@ -4503,6 +4510,9 @@ packages:
|
||||
engines: {node: '>= 18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
generate-function@2.3.1:
|
||||
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4605,6 +4615,10 @@ packages:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -4691,6 +4705,9 @@ packages:
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-property@1.0.2:
|
||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||
|
||||
is-unicode-supported@0.1.0:
|
||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4917,6 +4934,10 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru.min@1.1.4:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lucide-react@0.468.0:
|
||||
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
|
||||
peerDependencies:
|
||||
@@ -5181,6 +5202,16 @@ packages:
|
||||
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
mysql2@3.22.4:
|
||||
resolution: {integrity: sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>= 8'
|
||||
|
||||
named-placeholders@1.1.6:
|
||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -5748,6 +5779,10 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
sql-escaper@1.3.3:
|
||||
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
@@ -7572,10 +7607,10 @@ snapshots:
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
'@kubiks/otel-drizzle@2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))':
|
||||
'@kubiks/otel-drizzle@2.1.0(@opentelemetry/api@1.9.0)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
dependencies:
|
||||
@@ -10028,6 +10063,8 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -10047,7 +10084,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.9.19: {}
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
@@ -10063,8 +10100,32 @@ snapshots:
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -10488,14 +10549,25 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8):
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/pg': 8.15.6
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.15.6
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -10814,6 +10886,10 @@ snapshots:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
generate-function@2.3.1:
|
||||
dependencies:
|
||||
is-property: 1.0.2
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
@@ -10941,6 +11017,10 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -11053,6 +11133,8 @@ snapshots:
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-property@1.0.2: {}
|
||||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
isbot@5.1.35: {}
|
||||
@@ -11245,6 +11327,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru.min@1.1.4: {}
|
||||
|
||||
lucide-react@0.468.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -11707,6 +11791,22 @@ snapshots:
|
||||
|
||||
mute-stream@1.0.0: {}
|
||||
|
||||
mysql2@3.22.4(@types/node@22.19.11):
|
||||
dependencies:
|
||||
'@types/node': 22.19.11
|
||||
aws-ssl-profiles: 1.1.2
|
||||
denque: 2.1.0
|
||||
generate-function: 2.3.1
|
||||
iconv-lite: 0.7.2
|
||||
long: 5.3.2
|
||||
lru.min: 1.1.4
|
||||
named-placeholders: 1.1.6
|
||||
sql-escaper: 1.3.3
|
||||
|
||||
named-placeholders@1.1.6:
|
||||
dependencies:
|
||||
lru.min: 1.1.4
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanostores@1.1.0: {}
|
||||
@@ -11715,7 +11815,34 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.19
|
||||
caniuse-lite: 1.0.30001769
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12360,6 +12487,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
sql-escaper@1.3.3: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
Reference in New Issue
Block a user