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>
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cardog-icons/react": "^1.1.1",
|
||||
"@grafana/faro-web-sdk": "^2.2.4",
|
||||
"@grafana/faro-web-tracing": "^2.2.4",
|
||||
"@remotion/player": "^4.0.422",
|
||||
|
||||
1
apps/web/src/assets/brand-logos/alpine.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="#fff" d="M0 0h192.756v192.756H0V0z"/><path fill="#060a0b" d="M62.707 87.96l2.49-2.49h-6.28l-7.794 7.795L39.7 104.688l-2.653 2.652H43.327l7.796-7.795L62.707 87.96zM96.648 85.47h-5.899v21.925l18.079.107v-5.143l-12.18-.054V85.47zM76.62 85.47h-6.28l-7.633 7.633-11.584 11.585-2.653 2.652H54.75l7.957-7.957L76.62 85.47zM28.278 88.123l2.652-2.653h-6.279l-2.057 2.111-19.76 19.759h6.28l13.48-13.533 5.684-5.684zM51.123 88.123l2.652-2.653h-6.28L39.7 93.265l-11.422 11.423-2.599 2.652h6.226l7.795-7.795 11.423-11.422zM39.7 88.123l2.653-2.653H36.073l-7.795 7.795-5.684 5.739-8.337 8.336h6.226l2.111-2.111 5.684-5.684L39.7 88.123zM62.707 104.85l-2.49 2.49h7.957l15.753-15.916v15.971h5.793l.054-21.925h-7.741l-19.326 19.38zM125.99 85.578H115.433v-.108h-5.848v21.925l5.848.055v-7.256H125.99c4.33 0 5.738-2.922 5.738-7.253.001-4.331-1.408-7.363-5.738-7.363zm.215 8.121c0 1.353-1.189 1.353-1.189 1.353h-9.475V90.18s2.762 0 5.305.054h4.006c1.354 0 1.354 1.245 1.354 1.245l-.001 2.22zM188.785 105.066c0-.541-.326-.703-.922-.703h-.811v2.219h.486v-.92h.217l.107.217.379.703h.598l-.598-.975c.327 0 .544-.162.544-.541zm-.812.217h-.433v-.541h.324c.164 0 .434.053.434.27s-.163.271-.325.271z"/><path d="M187.863 103.443c-1.082 0-2.057.756-2.057 2.057 0 1.244.975 2.002 2.057 2.002 1.084 0 2.059-.758 2.059-2.002 0-1.301-.975-2.057-2.059-2.057zm0 3.625v-.053c-.811.053-1.461-.596-1.461-1.516 0-.975.65-1.57 1.461-1.57.812 0 1.463.596 1.463 1.57 0 .921-.65 1.569-1.463 1.569zM163.775 90.18v17.322h20.842v-4.926h-15.158v-4.061h13.316v-4.817h-13.316V90.18h15.158v-4.927H168.43s-4.655 1.029-4.655 4.927zM157.008 98.895c-1.623-2.002-7.74-9.364-9.689-11.8 0 0-1.191-1.678-3.465-1.678h-4.33v21.979l5.521.055V93.482l11.584 13.913 5.848.055V85.47h-5.469v13.425zM132.594 107.395l5.9.054V85.47h-5.9v21.925z" fill="#060a0b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/web/src/assets/brand-logos/citroen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="2369" viewBox="70.8 77.5 453.6 440.3" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m131.3 517h-36.7c-5.9 0-11.9-2.4-16.4-6.5-4.8-4.3-7.4-10-7.4-16.1s2.6-11.7 7.4-16.1c4.5-4.1 10.5-6.5 16.4-6.5h36.7v8.9h-36.2c-7.9 0-14.1 6.4-14.1 13.6 0 7.3 6.1 13.6 14.1 13.6h36.2zm99-45.1h-61.1v8.9h25.7v36.2h9.7v-36.2h25.7zm-82.6 0h9.7v45h-9.7zm211.6 45.9h-19.7c-12.6 0-23.8-8.9-23.8-22.9 0-14.1 11.5-23.8 24.6-23.8h18c13.1 0 24.6 8.9 24.6 23.8.1 14-11 22.9-23.7 22.9zm-18.8-37.9c-8.2 0-14.5 6.7-14.5 14.9 0 7.7 5.9 14.1 13.6 14.1h19.7c7.7 0 13.6-6.3 13.6-14.1 0-8.2-6.3-14.9-14.5-14.9zm77.5-13.2h-14c-1.9 0-3.4-1.5-3.4-3.4s1.5-3.4 3.4-3.4h14c1.9 0 3.4 1.5 3.4 3.4-.1 1.9-1.6 3.4-3.4 3.4zm25.4 0h-14c-1.9 0-3.4-1.5-3.4-3.4s1.5-3.4 3.4-3.4h14c1.9 0 3.4 1.5 3.4 3.4s-1.6 3.4-3.4 3.4zm7.7 14.1v-8.9h-54.8v45.1h54.8v-8.9h-45.1v-9.2h39.4v-8.9h-39.4v-9.2zm-171.1 18.3h8.7c7.5 0 14.8-3.4 14.8-13.2 0-8.8-7.3-14-14.8-14h-46.8v45h9.7v-36.1h36.6c3 0 5.1 3 5.1 5.5s-2.2 5.5-5.1 5.5h-27.9v3.1l30.9 21.9h14.3zm224.6-27.2h-39.7v45h9.7v-36.1h28.4c6.7 0 12.1 5.4 12.1 12.1v24.1h9.3v-25.2c0-11-8.9-19.9-19.8-19.9zm-111.6-344.8c-25.4-32-59.2-49.6-95.4-49.6s-70 17.6-95.4 49.6c-24.9 31.5-38.7 73.3-38.7 117.6s13.7 86.1 38.7 117.6c25.4 32 59.2 49.6 95.4 49.6s70-17.6 95.4-49.6c24.9-31.5 38.7-73.3 38.7-117.6s-13.8-86.1-38.7-117.6zm-95.4-34.6c64.4 0 117 65.8 118.9 147.6l-118.9-88.9-118.9 88.9c1.9-81.8 54.5-147.6 118.9-147.6zm116.4 184.1c-1.5 9.2-3.7 18.1-6.5 26.7l-109.9-82.1-109.9 82.1c-2.8-8.5-5-17.5-6.5-26.7l116.4-87zm-116.4 120.4c-40.2 0-75.8-25.6-97.3-64.8l97.3-72.8 97.3 72.8c-21.5 39.2-57.1 64.8-97.3 64.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
apps/web/src/assets/brand-logos/cupra.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="206pt" viewBox=".26 .95 312.19 206.3" width="313pt" xmlns="http://www.w3.org/2000/svg"><path d="m64.87.95c29.9 10.4 59.64 21.28 89.61 31.47 1.06.41 2.11.41 3.17 0 29.92-10.24 59.64-21.03 89.49-31.47-10.59 16.88-21.28 33.7-32.1 50.43-5-2.15-10.15-3.94-15.19-6 2.92-5.14 6.06-10.16 9.02-15.28-9.64 4.85-19.45 9.36-29.02 14.35 6.69 2.55 13.39 5.05 20.03 7.74 3.7 1.51 7.67 4.42 7.92 8.76.59 4.82-2.7 8.76-5.03 12.62-14.42 22.56-28.76 45.18-43.25 67.7.08-5.94-.6-11.94.1-17.84 5.7-18.09 11.33-36.2 16.52-54.44 1.02-2.71-2.01-4.16-3.96-5.11-5.5-2.43-10.65-5.65-16.28-7.73-5.71 2.47-11.19 5.46-16.79 8.17-1.73.8-4.17 2.38-3.18 4.64 5.15 18.25 10.84 36.35 16.5 54.45.63 5.91.03 11.91.06 17.86-14.26-22.32-28.49-44.67-42.73-67-2.48-4.11-6.08-8.31-5.46-13.44.33-4.31 4.28-7.17 7.98-8.67 6.58-2.68 13.26-5.13 19.89-7.72-9.55-4.97-19.32-9.52-28.95-14.33 2.95 5.12 6.09 10.13 9 15.28-5.07 2.02-10.2 3.89-15.25 5.96-10.78-16.75-21.45-33.58-32.1-50.4zm63.36 204.24c.1-9.86-.07-19.71.09-29.57 16.22.11 32.45-.03 48.67.06 3.17-.11 6.87 1.48 7.5 4.9.39 3.35.3 6.75.06 10.1-.2 3.7-4.08 5.94-7.5 5.7-13.23.03-26.46-.02-39.7.01.01 2.96.02 5.91.06 8.87-3.06.08-6.12.04-9.18-.07m9.09-22.88c.04 2.3.06 4.6.11 6.91 12.56-.12 25.12.11 37.68-.11 2.28-1.32.73-4.49 1.16-6.61-12.97-.44-25.97-.09-38.95-.19z"/><path d="m1.16 180.17c1.3-2.94 4.67-4.58 7.8-4.51 15.9-.04 31.8.02 47.71-.02.03 2.72.04 5.45.06 8.17-15.58.11-31.18-.14-46.75.12-.29.23-.86.7-1.15.94-.18 3.91.04 7.84-.12 11.76 1.41.35 2.84.71 4.31.65 14.56-.04 29.12.02 43.68-.04.01 2.63.02 5.26.02 7.89-15.25.44-30.52.06-45.77.22-3.74.15-8.38-.57-9.86-4.57-.81-3.54-.25-7.21-.4-10.8.05-3.26-.43-6.62.47-9.81zm63.15 17.84c-.03-7.46-.05-14.92.04-22.37 3.26.01 6.53-.01 9.8.05.11 7.15-.12 14.31.14 21.46 12.1.24 24.21.1 36.31.08.5-7.18.12-14.38.23-21.56 3.3-.02 6.6-.01 9.9-.05.07 7.45.09 14.91.02 22.37.04 4-3.75 7.34-7.66 7.27-11.36.34-22.73-.11-34.1.09-3.28-.01-6.62.19-9.83-.62-2.72-1.02-5-3.71-4.85-6.72zm127.93-22.37c15.93.04 31.86 0 47.79.01 3.44-.07 7.49 1.72 8.35 5.35.24 3.33.23 6.69-.01 10.02-.5 2.8-2.97 4.27-5.23 5.57 2.16 2.74 4.08 5.65 6.02 8.55-3.94-.25-9.92 2.11-12.12-2.35-1.32-2.1-2.57-4.24-3.85-6.36-10.53-.09-21.06-.04-31.59-.03.02 2.9.03 5.8.06 8.71-3.15.28-6.31.29-9.45.08.05-9.85-.04-19.7.03-29.55m9.46 6.34c-.08 2.4-.08 4.81-.07 7.21 12.44-.09 24.89.17 37.32-.12.31-.22.93-.67 1.25-.9-.18-2.02 1.03-5.07-1.39-6.07-12.36-.26-24.74-.01-37.11-.12zm54.48 1.01c-.13-4.51 4.58-7.65 8.76-7.34 15.78-.01 31.56.03 47.34-.02.05 9.85.17 19.71-.12 29.56-2.74.12-5.51.38-8.24.04-1.24-2.59-.19-5.97-.49-8.84-12.68-.01-25.36 0-38.04-.01-.02 2.96 0 5.93.02 8.9-3.07.08-6.13.04-9.19-.09.09-7.4-.19-14.8-.04-22.2m9.25-.59c-.04 2.27-.03 4.54.01 6.82 12.64-.07 25.29-.02 37.94-.03.02-2.29.03-4.57.06-6.86-12.67 0-25.34-.13-38.01.07z" fill="#010101"/></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
1
apps/web/src/assets/brand-logos/dacia.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1365" viewBox="0 0 1875.9290000000005 1024.029" width="2500" xmlns="http://www.w3.org/2000/svg"><g fill="#241f21"><path d="M105.362 0v157.405h582.54v158.727h-582.54v157.405h643.203c14.753 0 24.479-4.093 34.535-14.204l169.345-164.726 169.351 164.726c10.056 10.11 19.782 14.204 34.535 14.204h643.203V316.132H1216.99V157.405h582.544V0H1156.33c-14.753 0-24.479 4.093-34.535 14.203L952.446 178.93 783.1 14.203C773.044 4.093 763.318 0 748.565 0zM0 827.021v65.048l239.407 1.534c5.715 0 8.88 1.311 12.514 4.946l25.46 22.002c1.863 1.862 2.716 3.052 2.716 4.972 0 1.919-.853 3.114-2.716 4.976l-25.46 22.002c-3.635 3.635-6.799 4.946-12.514 4.946L0 958.981v65.048h269.382c9.62 0 14.363-1.573 20.534-7.743l76.975-79.567c2.899-2.899 4.796-7.426 4.796-11.196s-1.897-8.293-4.796-11.192l-76.975-79.572c-6.171-6.17-10.914-7.738-20.534-7.738z" fill-rule="evenodd"/><path d="M607.747 827.021c-4.237 0-6.638.85-8.3 2.511l-188.984 189.76c-1.984 1.994-1.089 4.737 1.995 4.737h91.89l97.46-101.098c2.022-2.022 3.54-2.866 5.939-2.866 2.397 0 3.916.844 5.939 2.866l97.458 101.098h91.891c3.084 0 3.98-2.743 1.995-4.736l-188.984-189.76c-1.662-1.663-4.063-2.512-8.3-2.512z"/><path d="M1217.07 827.021v65.048l-239.407 1.534c-5.715 0-8.879 1.311-12.514 4.946l-25.46 22.002c-1.862 1.862-2.716 3.052-2.716 4.972 0 1.919.854 3.114 2.716 4.976l25.46 22.002c3.635 3.635 6.8 4.946 12.514 4.946l239.407 1.534v65.048H947.688c-9.62 0-14.363-1.573-20.533-7.743l-76.976-79.567c-2.898-2.899-4.796-7.426-4.796-11.196s1.898-8.293 4.796-11.192l76.976-79.572c6.17-6.17 10.913-7.738 20.533-7.738z" fill-rule="evenodd"/><path d="M1329.535 827.021h75.303v197.008h-75.303zM1676.66 827.021c-4.236 0-6.637.85-8.299 2.511l-188.983 189.76c-1.985 1.994-1.09 4.737 1.994 4.737h91.891l97.459-101.098c2.022-2.022 3.541-2.866 5.939-2.866s3.917.844 5.939 2.866l97.458 101.098h91.891c3.084 0 3.98-2.743 1.995-4.736l-188.984-189.76c-1.662-1.663-4.063-2.512-8.3-2.512z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/web/src/assets/brand-logos/man.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 -13 720 719.998"><path fill="none" d="M0-13h720v719.998H0z"/><path d="M468.828 404.51h-37.054v72.36h37.369v-32.647l44.11 32.647h37.348v-72.36h-37.376v32.938l-44.397-32.938zM358.146 213.959c-131.229 0-237.604 106.379-237.604 237.602 0 8.554.453 16.991 1.332 25.312h24.883a214.88 214.88 0 0 1-1.488-25.312c0-117.566 95.31-212.875 212.878-212.875 117.569 0 212.88 95.309 212.88 212.875 0 8.567-.506 17.012-1.487 25.312h24.881a240.465 240.465 0 0 0 1.332-25.312c-.005-131.223-106.382-237.602-237.607-237.602zm19.941 190.551h-39.733l-47.091 72.36h38.569l5.234-8.096h46.311l5.238 8.096h38.567l-47.095-72.36zm-9.029 45.031h-21.672l10.836-16.742 10.836 16.742zM200.069 404.51h-35.055v72.36h37.377v-28.614l22.327 22.324 22.322-22.324v28.614h37.376v-72.36H249.36l-24.643 24.646-24.648-24.646z" fill="#222"/></svg>
|
||||
|
After Width: | Height: | Size: 881 B |
1
apps/web/src/assets/brand-logos/opel.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
apps/web/src/assets/brand-logos/peugeot.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
apps/web/src/assets/brand-logos/renault.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="2500" viewBox=".306 .394 379.652 500" width="1900" xmlns="http://www.w3.org/2000/svg"><path d="m304.375 250.394-133.72 250h-36.628l-133.721-250 133.14-250h37.79l-133.14 250 113.954 214.536 113.953-214.536-66.279-124.997 19.186-35.47zm-56.975-250h-38.372l-132.558 250 85.465 160.467 18.604-35.47-66.279-124.997 113.953-215.117 113.953 215.117-133.14 250h38.374l132.558-250z" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 414 B |
1
apps/web/src/assets/brand-logos/seat.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
1
apps/web/src/assets/brand-logos/skoda.svg
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
1
apps/web/src/assets/brand-logos/suzuki.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2075" height="2500" viewBox="0 0 153.588 185.084"><path d="M55.487 39.154c1.426-4.656 5.037-2.28 5.037-2.28S69.882 44 95.549 60.333 153.215 54 153.215 54l-76-54c-33.334 35.333-76 48-76 48l.121.047 97.147 68.121c-1.426 4.656-5.037 2.281-5.037 2.281s-9.358-7.127-35.025-23.461C32.754 78.655.755 101.322.755 101.322l76 54c33.334-35.334 76-48 76-48l-.123-.048-97.145-68.12z" fill="#ff0016"/><g fill="#015cd0"><path d="M0 176.761l11.692.062c.201 1.1-.115 1.096.5 1.67.963.896 2.457 1.1 3.711.879 1.027-.18 2.619-.494 2.695-1.769.083-1.384-.875-1.866-2.077-2.024-1.662-.217-3.339-.274-4.996-.539-4.269-.701-6.539-1.015-8.357-2.174-1.834-1.16-1.992-3.686-1.992-5.484 0-1.182.469-2.297 1.407-3.35.938-1.049 2.349-1.875 4.232-2.478 1.884-.601 4.465-.899 7.746-.899 4.024 0 7.095.545 9.206 1.635 2.114 1.09 3.371 3.521 3.772 5.896h-9.941c.074-.141-.195-.777-.238-.936-.381-1.379-1.712-1.781-2.987-2.063-1.045-.231-2.239-.251-3.181.325-.573.352-1.018.723-1.018 1.227 0 .365.236.694.71.987.461.302 1.55.585 3.269.845 4.255.672 7.302 1.348 9.144 2.031 1.84.688 3.179 1.535 4.018 2.548.837 1.013 1.257 2.147 1.257 3.401 0 1.474-.559 2.832-1.677 4.074-1.116 1.242-2.677 2.188-4.682 2.83-2.006.643-4.533.963-7.583.963-5.355 0-9.066-.752-11.127-2.256C1.439 180.66.272 179.083 0 176.761zM47.439 161.087h8.641v16.246c0 3.334-2.071 5.695-6.214 7.09-1.917.636-4.215.66-6.817.66-2.333 0-4.426.043-6.362-.461-4.56-1.822-6.839-4.252-6.839-7.289v-16.246h8.702V175c0 2.533.336 4.418 4.5 4.418 4.248 0 4.333-1.967 4.333-4.5l.056-13.831zM103.938 161.087h8.642v16.246c0 3.334-2.071 5.695-6.214 7.09-1.916.636-4.215.66-6.817.66-2.333 0-4.427.043-6.362-.461-4.561-1.822-6.84-4.252-6.84-7.289v-16.246h8.702V175c0 2.533.337 4.418 4.5 4.418 4.248 0 4.333-1.967 4.333-4.5l.056-13.831zM57.858 161.255h26.766v4.994L71.549 178h13.076v6.541H57.858v-5.623l12.774-11.584h-12.75l-.024-6.079zM114.72 184.5v-23.285h8.579l-.002 8.619 9.253-8.619h10.737L130.966 172.5l12.834 12h-11.584l-8.834-8.332.084 8.332h-8.746zM153.588 161.212V184.5h-8.562v-23.285h8.562v-.003z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -7,6 +7,7 @@ import { cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
@@ -111,17 +112,7 @@ export function BrandSelector({
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center p-4">
|
||||
<div className="relative">
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={brand.name}
|
||||
className="mb-2 h-12 w-12 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-lg font-bold text-muted-foreground">
|
||||
{brand.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<CarBrandLogo brandName={brand.name} logoUrl={brand.logoUrl} size={48} className="mb-2" />
|
||||
{isSelected && (
|
||||
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
|
||||
190
apps/web/src/components/ui/car-brand-logo.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { SVGProps } from "react";
|
||||
import {
|
||||
AlfaRomeoLogo,
|
||||
AlfaRomeoLogoDark,
|
||||
AudiLogo,
|
||||
AudiLogoDark,
|
||||
BentleyLogo,
|
||||
BentleyLogoDark,
|
||||
BMWLogo,
|
||||
BMWLogoDark,
|
||||
FiatLogo,
|
||||
FiatLogoDark,
|
||||
FordLogo,
|
||||
FordLogoDark,
|
||||
GenesisLogo,
|
||||
GenesisLogoDark,
|
||||
HondaLogo,
|
||||
HondaLogoDark,
|
||||
HyundaiLogo,
|
||||
HyundaiLogoDark,
|
||||
InfinitiLogo,
|
||||
InfinitiLogoDark,
|
||||
JaguarLogo,
|
||||
JaguarLogoDark,
|
||||
KiaLogo,
|
||||
KiaLogoDark,
|
||||
LandroverLogo,
|
||||
LandroverLogoDark,
|
||||
LexusLogo,
|
||||
LexusLogoDark,
|
||||
MazdaLogo,
|
||||
MazdaLogoDark,
|
||||
MBLogo,
|
||||
MBLogoDark,
|
||||
MiniLogo,
|
||||
MiniLogoDark,
|
||||
MitsubishiLogo,
|
||||
MitsubishiLogoDark,
|
||||
NissanLogo,
|
||||
NissanLogoDark,
|
||||
PolestarLogo,
|
||||
PolestarLogoDark,
|
||||
PorscheLogo,
|
||||
PorscheLogoDark,
|
||||
SubaruLogo,
|
||||
SubaruLogoDark,
|
||||
ToyotaLogo,
|
||||
ToyotaLogoDark,
|
||||
VolkswagenLogo,
|
||||
VolkswagenLogoDark,
|
||||
VolvoLogo,
|
||||
VolvoLogoDark,
|
||||
} from "@cardog-icons/react";
|
||||
import { cn } from "@sase/ui";
|
||||
|
||||
// Static SVG asset imports (brands not in @cardog-icons/react)
|
||||
import alpineUrl from "@/assets/brand-logos/alpine.svg";
|
||||
import citroenUrl from "@/assets/brand-logos/citroen.svg";
|
||||
import cupraUrl from "@/assets/brand-logos/cupra.svg";
|
||||
import daciaUrl from "@/assets/brand-logos/dacia.svg";
|
||||
import manUrl from "@/assets/brand-logos/man.svg";
|
||||
import opelUrl from "@/assets/brand-logos/opel.svg";
|
||||
import peugeotUrl from "@/assets/brand-logos/peugeot.svg";
|
||||
import renaultUrl from "@/assets/brand-logos/renault.svg";
|
||||
import seatUrl from "@/assets/brand-logos/seat.svg";
|
||||
import skodaUrl from "@/assets/brand-logos/skoda.svg";
|
||||
import suzukiUrl from "@/assets/brand-logos/suzuki.svg";
|
||||
|
||||
type SvgComponent = React.ForwardRefExoticComponent<
|
||||
Omit<SVGProps<SVGSVGElement>, "ref"> & React.RefAttributes<SVGSVGElement>
|
||||
>;
|
||||
|
||||
interface BrandEntry {
|
||||
light: SvgComponent;
|
||||
dark: SvgComponent;
|
||||
}
|
||||
|
||||
interface StaticEntry {
|
||||
url: string;
|
||||
invertDark: boolean;
|
||||
}
|
||||
|
||||
const BRAND_MAP: Record<string, BrandEntry> = {
|
||||
volkswagen: { light: VolkswagenLogo, dark: VolkswagenLogoDark },
|
||||
vw: { light: VolkswagenLogo, dark: VolkswagenLogoDark },
|
||||
audi: { light: AudiLogo, dark: AudiLogoDark },
|
||||
porsche: { light: PorscheLogo, dark: PorscheLogoDark },
|
||||
bentley: { light: BentleyLogo, dark: BentleyLogoDark },
|
||||
bmw: { light: BMWLogo, dark: BMWLogoDark },
|
||||
mini: { light: MiniLogo, dark: MiniLogoDark },
|
||||
mercedes: { light: MBLogo, dark: MBLogoDark },
|
||||
"mercedes-benz": { light: MBLogo, dark: MBLogoDark },
|
||||
toyota: { light: ToyotaLogo, dark: ToyotaLogoDark },
|
||||
lexus: { light: LexusLogo, dark: LexusLogoDark },
|
||||
ford: { light: FordLogo, dark: FordLogoDark },
|
||||
hyundai: { light: HyundaiLogo, dark: HyundaiLogoDark },
|
||||
kia: { light: KiaLogo, dark: KiaLogoDark },
|
||||
genesis: { light: GenesisLogo, dark: GenesisLogoDark },
|
||||
nissan: { light: NissanLogo, dark: NissanLogoDark },
|
||||
infiniti: { light: InfinitiLogo, dark: InfinitiLogoDark },
|
||||
volvo: { light: VolvoLogo, dark: VolvoLogoDark },
|
||||
polestar: { light: PolestarLogo, dark: PolestarLogoDark },
|
||||
fiat: { light: FiatLogo, dark: FiatLogoDark },
|
||||
"alfa romeo": { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
"alfa-romeo": { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
alfaromeo: { light: AlfaRomeoLogo, dark: AlfaRomeoLogoDark },
|
||||
mitsubishi: { light: MitsubishiLogo, dark: MitsubishiLogoDark },
|
||||
jaguar: { light: JaguarLogo, dark: JaguarLogoDark },
|
||||
"land rover": { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
"land-rover": { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
landrover: { light: LandroverLogo, dark: LandroverLogoDark },
|
||||
mazda: { light: MazdaLogo, dark: MazdaLogoDark },
|
||||
subaru: { light: SubaruLogo, dark: SubaruLogoDark },
|
||||
honda: { light: HondaLogo, dark: HondaLogoDark },
|
||||
};
|
||||
|
||||
const STATIC_LOGO_MAP: Record<string, StaticEntry> = {
|
||||
renault: { url: renaultUrl, invertDark: true },
|
||||
alpine: { url: alpineUrl, invertDark: true },
|
||||
cupra: { url: cupraUrl, invertDark: true },
|
||||
opel: { url: opelUrl, invertDark: true },
|
||||
dacia: { url: daciaUrl, invertDark: true },
|
||||
peugeot: { url: peugeotUrl, invertDark: true },
|
||||
citroen: { url: citroenUrl, invertDark: true },
|
||||
"citroën": { url: citroenUrl, invertDark: true },
|
||||
man: { url: manUrl, invertDark: true },
|
||||
skoda: { url: skodaUrl, invertDark: false },
|
||||
"škoda": { url: skodaUrl, invertDark: false },
|
||||
seat: { url: seatUrl, invertDark: false },
|
||||
suzuki: { url: suzukiUrl, invertDark: false },
|
||||
};
|
||||
|
||||
interface CarBrandLogoProps {
|
||||
brandName: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
export function CarBrandLogo({ brandName, size = 40, className, logoUrl }: CarBrandLogoProps) {
|
||||
const key = brandName.toLowerCase();
|
||||
|
||||
const cardog = BRAND_MAP[key];
|
||||
if (cardog) {
|
||||
const { light: LightLogo, dark: DarkLogo } = cardog;
|
||||
return (
|
||||
<>
|
||||
<LightLogo width={size} height={size} className={cn("dark:hidden", className)} />
|
||||
<DarkLogo width={size} height={size} className={cn("hidden dark:block", className)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const staticEntry = STATIC_LOGO_MAP[key];
|
||||
if (staticEntry) {
|
||||
return (
|
||||
<img
|
||||
src={staticEntry.url}
|
||||
alt={brandName}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("object-contain", staticEntry.invertDark && "dark:invert", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={brandName}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("object-contain", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg bg-muted text-sm font-bold text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{brandName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0a0a0a;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Library, Lock } from "lucide-react";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||
component: CatalogBrandsPage,
|
||||
@@ -60,7 +61,12 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
if (!brand.hasAccess) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none">
|
||||
<Lock className="mb-2 size-5 text-muted-foreground" />
|
||||
<div className="relative mb-2">
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} />
|
||||
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-muted-foreground/60">
|
||||
<Lock className="size-2.5 text-background" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
|
||||
<Link
|
||||
@@ -80,17 +86,7 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
search={{ catalog: undefined }}
|
||||
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"
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={brand.brandName}
|
||||
className="mb-2 h-10 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<Library className="size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} className="mb-2" />
|
||||
<p className="text-sm font-semibold">{brand.brandName}</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Badge, Skeleton, Separator } from "@sase/ui";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
@@ -325,7 +326,8 @@ function DashboardHome() {
|
||||
{subscription.brands && subscription.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge key={b.brandId} variant="outline">
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { capture, setPeopleProperties } from "@/lib/posthog";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -441,7 +442,8 @@ function SubscriptionPage() {
|
||||
<p className="mb-2 text-sm font-medium">{t("subscription.accessibleBrands")}:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge key={b.brandId} variant="outline">
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
|
||||
import { Button } from "@sase/ui";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
component: VehicleDetailPage,
|
||||
@@ -59,11 +60,16 @@ function VehicleDetailPage() {
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
|
||||
</h2>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{vehicle?.brandName && (
|
||||
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
|
||||
</h2>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||