feat: CarBrandLogo component + PL24 image pre-download + catalog index fix

- Add CarBrandLogo component with SVG assets for 11 brands (Alpine, Citroën,
  Cupra, Dacia, MAN, Opel, Peugeot, Renault, SEAT, Škoda, Suzuki); falls back
  to logoUrl prop then initial-letter avatar
- Use CarBrandLogo across brand-selector, catalog index, subscription page,
  dashboard home, and vehicle detail page
- PL24: pre-download schema image buffer while auth headers are fresh to avoid
  token expiry on cached URLs; strip buffer before Redis caching
- PL24: fetchVehicleList tries de account first, falls back to main token
- DB: widen catalogVehicles unique index to include serviceName column
- CSS: add Tailwind v4 dark variant declaration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-07 15:47:43 +00:00
parent 9d2e534e56
commit 14e43bc808
22 changed files with 439 additions and 39 deletions

View File

@@ -256,7 +256,7 @@ export const catalogVehicles = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("catalog_vehicles_source_vid_idx").on(table.source, table.serviceVehicleId),
uniqueIndex("catalog_vehicles_source_svc_vid_idx").on(table.source, table.serviceName, table.serviceVehicleId),
index("catalog_vehicles_brand_name_idx").on(table.brandName),
index("catalog_vehicles_service_name_idx").on(table.serviceName),
],

View File

@@ -315,6 +315,9 @@ export class PL24Service {
const groupName = crumbs[crumbs.length - 1]?.name || "";
const illustrationId =
linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
// Temporary: log raw images field to diagnose 404 issue
const rawImages = (data?.data as any)?.images || data?.images;
if (rawImages) this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`);
const imageData = this.extractImageData(data);
let parts = this.parsePartsResponse(data);
@@ -340,7 +343,19 @@ export class PL24Service {
hotspots: imageData.hotspots,
};
await this.redis.setJson(cacheKey, result, 3600);
// Pre-download image buffer while auth headers are fresh (avoids token expiry on cached URL)
if (result.schemaImageUrl) {
const downloaded = await this.tryDownloadImageBuffer(result.schemaImageUrl, headers);
if (downloaded) {
result.schemaImageBuffer = downloaded.buffer;
result.schemaImageContentType = downloaded.contentType;
result.schemaImageUrl = undefined; // buffer takes precedence
}
}
// Cache without buffer (binary data not suited for Redis)
const { schemaImageBuffer: _buf, ...toCache } = result as any;
await this.redis.setJson(cacheKey, toCache, 3600);
return result;
} catch (error) {
const err = error as Error;
@@ -1295,6 +1310,36 @@ export class PL24Service {
// ==================== PRIVATE: Image extraction ====================
private async tryDownloadImageBuffer(
imageUrl: string,
headers: Record<string, string>,
): Promise<{ buffer: Buffer; contentType: string } | null> {
this.logger.log(`[imgdl] Downloading image: ${imageUrl}`);
try {
const response = await fetch(imageUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
this.logger.warn(`Image pre-download failed: HTTP ${response.status}`);
return null;
}
const contentType = response.headers.get("content-type") || "image/png";
if (contentType.includes("application/json")) {
const json = (await response.json()) as Record<string, any>;
if (json.image && typeof json.image === "string") {
return { buffer: Buffer.from(json.image, "base64"), contentType: "image/png" };
}
return null;
}
return { buffer: Buffer.from(await response.arrayBuffer()), contentType };
} catch (err) {
this.logger.warn(`Image pre-download error: ${(err as Error).message}`);
return null;
}
}
private extractIllustrationUrl(response: unknown): string | null {
const responseData = response as Record<string, unknown>;
const data =
@@ -1816,13 +1861,18 @@ export class PL24Service {
return this.fordLegacyService.fetchVehicleListForVolvo(serviceName);
}
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle_list:${serviceName}`;
const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached;
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
// Try de account; fall back to main token (demo mode) if that also fails
let headers: Record<string, string>;
try {
await this.authService.authorizeServiceForAccount(serviceName, "de");
headers = await this.authService.buildAuthHeadersForAccount("de", serviceName);
} catch {
this.logger.warn(
`fetchVehicleList: de auth failed for ${serviceName}, using main token`,
);
headers = await this.authService.buildAuthHeaders();
}
const catalogBase = getServiceApiPath(serviceName);
@@ -1885,7 +1935,6 @@ export class PL24Service {
}
if (vehicles.length > 0) {
await this.redis.setJson(cacheKey, vehicles, 86400); // 24h
return vehicles;
}
}