@bytebrand/fe-ui-core-autobahn 1.0.51 → 1.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/constants.ts CHANGED
@@ -1,91 +1,31 @@
1
- // Autobahn (auto.de redesign) — shared lookup constants.
2
- // Typed replacement for the design kit's `window.*` globals (data.jsx / ui.jsx / CdpModules.jsx).
3
- // Pure data only — no DOM, no store access. Imported by the ported presentational components.
4
- //
5
- // NOTE on counts: the BODY_TYPES / QUICK_FILTERS sample counts below are the design's
6
- // placeholder figures. They are only used by the W2 filter UI, which will replace them with
7
- // REAL aggregation counts from searchStore.aggregation. They are not rendered as real data in W1.
8
-
9
- /** Pkw-EnVKV CO₂ efficiency classes (best → worst) and their band colors. */
10
- export const CO2_CLASSES = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const;
11
- export type Co2Class = (typeof CO2_CLASSES)[number];
12
- export const CO2_COLORS: readonly string[] = [
13
- '#1FA01F', '#5DAE2E', '#9FC21C', '#F2C200', '#F2900D', '#E8601A', '#D4202B',
14
- ];
15
-
16
- /** Preisbewertung (price-rating) tiers, index 0 = Top … 4 = Hoch. green → red. */
17
- export const TIER_LABELS = ['Top Preis', 'Guter Preis', 'Fairer Preis', 'Erhöht', 'Hoher Preis'] as const;
18
- // i18n key suffixes parallel to TIER_LABELS — components render t('autobahn:tier.<key>') so the
19
- // Preisbewertung label localizes (TIER_LABELS stays the German source-of-truth used for tier→index logic).
20
- export const TIER_KEYS = ['top', 'good', 'fair', 'raised', 'high'] as const;
21
- export const TIER_COLORS: readonly string[] = ['#0F7B43', '#4FA86B', '#C7B53A', '#E08A2B', '#D4502B'];
22
- export type RatingTier = 0 | 1 | 2 | 3 | 4;
23
-
24
- /** SRP sort options (labels match the design; mapped to real sort params by the SRP container). */
25
- export const SORTS = [
26
- 'Relevanz',
27
- 'Preis aufsteigend',
28
- 'Preis absteigend',
29
- 'Erstzulassung',
30
- 'Kilometerstand',
31
- 'Leistung',
32
- ] as const;
33
- export type SortLabel = (typeof SORTS)[number];
34
-
35
- /** Quick-filter chips for the srp-bar: [lucide icon, label, defaultActive?]. */
36
- export type QuickFilter = readonly [icon: string, label: string, defaultActive?: boolean];
37
- export const QUICK_FILTERS: readonly QuickFilter[] = [
38
- ['sliders-horizontal', 'Filter', true],
39
- ['fuel', 'Diesel'],
40
- ['cog', 'Automatik'],
41
- ['battery-charging', 'Hybrid'],
42
- ['gauge', 'bis 50.000 km'],
43
- ['wallet', 'bis 300 €/mtl.'],
44
- ['calendar', 'ab 2022'],
45
- ];
46
-
47
- /** Brand shortlist for the Marke select (full list comes from appStore.manufacturers at runtime). */
48
- export const BRANDS: readonly string[] = [
49
- 'VW', 'Mercedes-Benz', 'BMW', 'Audi', 'Opel', 'Škoda', 'Ford', 'Hyundai',
50
- ];
51
-
52
- /** Body-type tiles: [label, lucide icon, placeholder count]. Counts are design placeholders (W2 → real aggregations). */
53
- export type BodyType = readonly [label: string, icon: string, placeholderCount: string];
54
- export const BODY_TYPES: readonly BodyType[] = [
55
- ['Kleinwagen', 'car', '4.812'],
56
- ['Limousine', 'car-front', '5.530'],
57
- ['Kombi', 'caravan', '6.118'],
58
- ['SUV', 'truck', '7.940'],
59
- ['Cabrio', 'wind', '1.270'],
60
- ['Van', 'bus', '2.691'],
61
- ];
62
-
63
- /** Marker text rendered wherever the design shows a field we have no real data source for yet. */
64
- export const NEEDS_DATA_LABEL = 'Daten folgt';
65
-
66
- // ---------------------------------------------------------------------------
67
- // Legacy shared constants (moved here from fe-ui-core).
68
- //
69
- // This block mirrors the public symbol set of the old
70
- // `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
71
- // specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
72
- // implementations live under ./constants/ (core.ts, app.ts, price.ts,
73
- // Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
74
- // ---------------------------------------------------------------------------
75
-
76
- export {
77
- offers,
78
- checkoutOffers,
79
- checkoutWarranties,
80
- checkoutSteps,
81
- checkoutSubStep,
82
- DELIVERY_PERIODS_EXTRA,
83
- THREE,
84
- WLTP_ENERGY_EFFICIENCY_CLASS
85
- } from './constants/core';
86
-
87
- export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
88
-
89
- export { priceRatings } from './constants/price';
90
-
91
- export { THROTTLE_DELAY } from './constants/Search';
1
+ // Autobahn (auto.de redesign) — shared lookup constants barrel.
2
+ // Pure data re-exported from ./constants/autobahn (kept import-safe for plain Node consumers,
3
+ // see that file's header) plus the legacy fe-ui-core shim re-exports below.
4
+ export * from './constants/autobahn';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Legacy shared constants (moved here from fe-ui-core).
8
+ //
9
+ // This block mirrors the public symbol set of the old
10
+ // `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
11
+ // specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
12
+ // implementations live under ./constants/ (core.ts, app.ts, price.ts,
13
+ // Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export {
17
+ offers,
18
+ checkoutOffers,
19
+ checkoutWarranties,
20
+ checkoutSteps,
21
+ checkoutSubStep,
22
+ DELIVERY_PERIODS_EXTRA,
23
+ THREE,
24
+ WLTP_ENERGY_EFFICIENCY_CLASS
25
+ } from './constants/core';
26
+
27
+ export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
28
+
29
+ export { priceRatings } from './constants/price';
30
+
31
+ export { THROTTLE_DELAY } from './constants/Search';
@@ -0,0 +1,89 @@
1
+ import { AutobahnCar } from '../cards/types';
2
+ import { Co2Class } from '../constants/autobahn';
3
+ /**
4
+ * Last-resort placeholder shown in the carousel until the real images for a car
5
+ * are loaded. The SRP container SHOULD inject the app's real no-image asset via
6
+ * `deps.placeholder` (fe-main exposes PLACEHOLDER_IMAGE_LARGE_URL /
7
+ * PLACEHOLDER_IMAGE_SMALL_URL in framework/constants/app.ts, both derived from
8
+ * config.imagesAPI.imgUrlBase). We do NOT import that here to keep the mapper
9
+ * free of app-config coupling; this empty-data-URI default just guarantees a
10
+ * non-broken <img> in tests / before the container wires the real one.
11
+ */
12
+ export declare const PLACEHOLDER_IMAGE = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
13
+ /**
14
+ * Dependencies the SRP container injects. Everything here is what the mapper
15
+ * needs from the outside world — keeping the function itself pure.
16
+ */
17
+ export interface ToAutobahnCarDeps {
18
+ /**
19
+ * Build a ready-to-use image URL for a car image.
20
+ * Pass `imagesStore.getSupportedImageFormat` straight through (it already
21
+ * picks webp/jpeg and the cached size). Signature matches ImagesStore:
22
+ * (vehicleID, imageIdentifier, size) => url
23
+ */
24
+ getImageUrl?: (vehicleID: string, imageIdentifier: string, size: 'small' | 'medium' | 'large') => string;
25
+ /**
26
+ * Loaded gallery images for this car from `imagesStore.images.get(id)`
27
+ * (array of { imageUrlSmall, imageUrlMedium, imageUrlLarge, imageUrl, ... }).
28
+ * When present we use these real URLs; otherwise we fall back to the single
29
+ * main image via getImageUrl, then to the placeholder. Never faked.
30
+ */
31
+ loadedImages?: Array<{
32
+ imageUrlSmall?: string;
33
+ imageUrlMedium?: string;
34
+ imageUrlLarge?: string;
35
+ imageUrl?: string;
36
+ }>;
37
+ /** image size used when building gallery URLs from the main image id. */
38
+ imageSize?: 'small' | 'medium' | 'large';
39
+ /** translator for cbd:* selector codes (fuel/gearbox). Falls back to a built-in de map, then the raw code. */
40
+ t?: (key: string, options?: object) => string;
41
+ /** active UI language ('de' | 'en' …). Drives de-DE vs en-IN number grouping for the EnVKV line. */
42
+ language?: string;
43
+ /** placeholder image URL override (defaults to PLACEHOLDER_IMAGE). */
44
+ placeholder?: string;
45
+ /**
46
+ * Buyer-activity stats for this car — UserActionsAPI 'stats/viewers' response, loaded async and
47
+ * keyed by carId (`searchStore.statsData.get(storeCar.id)`). Pass it straight through; absent
48
+ * (not loaded yet, or no stats for this car) → viewsCount/favoritesCount stay undefined and the
49
+ * gallery badge row simply omits those pills. Never fabricated.
50
+ */
51
+ stats?: {
52
+ totalCarImpCount?: number;
53
+ totalFavCount?: number;
54
+ };
55
+ }
56
+ /** translate a cbd:* selector code → human label. Exported: also used by the Meta AIA
57
+ * marketing-image renderer (plain Node backend, no i18next — relies on the built-in de fallback). */
58
+ export declare function cbd(code: any, t?: (key: string, options?: object) => string): string;
59
+ /**
60
+ * Map the store's energy-efficiency-class selector code (or a bare letter) to a
61
+ * single CO2_CLASSES letter ('A'…'G'). A+/A++/A+++ collapse to 'A' so the card's
62
+ * colored InlineCls (which does CO2_CLASSES.indexOf) still resolves a band color.
63
+ * Returns '' when there is no class (caller decides what to render).
64
+ */
65
+ export declare function co2ClassLetter(code: any): Co2Class | '';
66
+ export declare function mapRating(currentSalesPrice?: number, predicted?: number): {
67
+ rating: string;
68
+ ratingLabel: string;
69
+ };
70
+ /**
71
+ * Map the 5-tier rating to a 2-tone signal — only 'top'/'good' read as a genuinely good deal
72
+ * (green); 'fair'/'raised'/'high' all get the warn/peach tone (Figma: "Erhöhter-Preis" is
73
+ * peach, not green). Pure/non-React on purpose: shared by the web card's StatChip (via
74
+ * cards/cardParts.tsx's re-export) AND the Meta AIA marketing-image renderer (a plain Node
75
+ * service that can't import React components).
76
+ */
77
+ export declare function ratingTone(rating: string): 'good' | 'warn';
78
+ /**
79
+ * Convert one fe-main store car → AutobahnCar.
80
+ *
81
+ * @param storeCar the object from SearchResultsStore.getCarsForPage()
82
+ * (shape = transformDataForVehicles output). `any` because the store car is
83
+ * itself untyped (`getCarsForPage: (page) => any[]`).
84
+ * @param deps injected services/locale (see ToAutobahnCarDeps). Optional so the
85
+ * mapper still runs in tests with zero wiring (gallery → placeholder, labels →
86
+ * built-in de fallback).
87
+ */
88
+ export declare function toAutobahnCar(storeCar: any, deps?: ToAutobahnCarDeps): AutobahnCar;
89
+ export default toAutobahnCar;
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ // Autobahn (auto.de redesign) — store-car → AutobahnCar mapper (AB-P0-6).
3
+ //
4
+ // PURE adapter: converts the existing fe-main "store car" (the object
5
+ // `SearchResultsStore.getCarsForPage()` returns to the SRP, produced by
6
+ // `transformDataForVehicles` in framework/DataTransformers.ts) into the
7
+ // `AutobahnCar` view-model the new Autobahn cards consume
8
+ // (components/autobahn/cards/types.ts).
9
+ //
10
+ // WHY a pure function with injected deps:
11
+ // The Autobahn cards are pure/presentational and never touch the store. This
12
+ // mapper is the single seam that reads the real payload. It is kept pure (no
13
+ // store singletons imported here) so it is unit-testable and the SRP container
14
+ // injects the real stores/services (imagesStore.getSupportedImageFormat,
15
+ // appStore.language). Everything it needs arrives through `deps`.
16
+ //
17
+ // NEEDS-DATA discipline (the point of the ticket):
18
+ // Fields with NO real source on the search payload are left `undefined` so the
19
+ // cards render their 'Daten folgt' marker instead of a fabricated number. See
20
+ // the per-field rationale in ./fieldMapping.ts. We NEVER synthesize:
21
+ // drop, marketLow, marketAvg, marketHigh, watchers, views.
22
+ // listPrice / savings are emitted ONLY if a real strike/list price exists.
23
+ //
24
+ // Every field path below was confirmed against the real code (file:line in
25
+ // ./fieldMapping.ts), not guessed.
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.PLACEHOLDER_IMAGE = void 0;
28
+ exports.cbd = cbd;
29
+ exports.co2ClassLetter = co2ClassLetter;
30
+ exports.mapRating = mapRating;
31
+ exports.ratingTone = ratingTone;
32
+ exports.toAutobahnCar = toAutobahnCar;
33
+ const priceRating_1 = require("../utils/priceRating");
34
+ const DateUtils_1 = require("../utils/DateUtils");
35
+ const power_1 = require("../utils/power");
36
+ const autobahn_1 = require("../constants/autobahn");
37
+ /**
38
+ * Last-resort placeholder shown in the carousel until the real images for a car
39
+ * are loaded. The SRP container SHOULD inject the app's real no-image asset via
40
+ * `deps.placeholder` (fe-main exposes PLACEHOLDER_IMAGE_LARGE_URL /
41
+ * PLACEHOLDER_IMAGE_SMALL_URL in framework/constants/app.ts, both derived from
42
+ * config.imagesAPI.imgUrlBase). We do NOT import that here to keep the mapper
43
+ * free of app-config coupling; this empty-data-URI default just guarantees a
44
+ * non-broken <img> in tests / before the container wires the real one.
45
+ */
46
+ exports.PLACEHOLDER_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
47
+ // ---------------------------------------------------------------------------
48
+ // small pure helpers
49
+ // ---------------------------------------------------------------------------
50
+ const DEFAULT_LANG = 'de';
51
+ /** locale tag for Number#toLocaleString — de-DE (comma decimals) unless en. */
52
+ function localeTag(language) {
53
+ return language === 'en' ? 'en-IN' : 'de-DE';
54
+ }
55
+ /** finite-number coercion: accepts number | numeric string, else undefined. */
56
+ function num(v) {
57
+ if (v == null)
58
+ return undefined;
59
+ const n = typeof v === 'number' ? v : Number.parseFloat(String(v));
60
+ return Number.isFinite(n) ? n : undefined;
61
+ }
62
+ /**
63
+ * Minimal de-DE fallback for the handful of cbd:* selector codes the cards show
64
+ * (fuel + gearbox). Used only when no `t` is injected (e.g. in unit tests).
65
+ * The full set lives in the cbd locale dictionary (@bytebrand/i18n-dictionaries).
66
+ */
67
+ const CBD_DE_FALLBACK = {
68
+ selector_fuel_petrol: 'Benzin',
69
+ selector_fuel_diesel: 'Diesel',
70
+ selector_fuel_electrical: 'Elektro',
71
+ selector_fuel_ethanol: 'Ethanol',
72
+ selector_fuel_hybridElectricalDiesel: 'Hybrid (Elektro / Diesel)',
73
+ selector_fuel_hybridElectricalPetrol: 'Hybrid (Elektro / Benzin)',
74
+ selector_fuel_hydrogen: 'Wasserstoff',
75
+ selector_fuel_naturalGas: 'Erdgas',
76
+ selector_gearbox_automatic: 'Automatik',
77
+ selector_gearbox_manualShift: 'Manuell',
78
+ selector_gearbox_semiAutomatic: 'Halbautomatik',
79
+ // highlight-chip keys (DataTransformers highlights) — used when no `t` is injected
80
+ multimedia_navigationSystem: 'Navigationssystem',
81
+ lights_ledHeadlights: 'LED-Scheinwerfer',
82
+ lights_xenonHeadlights: 'Xenon-Scheinwerfer',
83
+ window_panoramicGlassRoof: 'Panoramadach',
84
+ assistants_headUpDisplay: 'Head-up-Display',
85
+ assistants_adaptiveCruiseControl: 'Abstandstempomat',
86
+ search_seatHeating: 'Sitzheizung',
87
+ selector_climatisation_automaticAirCondition: 'Klimaautomatik',
88
+ selector_climatisation_airCondition: 'Klimaanlage',
89
+ search_parkingAssistants: 'Einparkhilfe',
90
+ selector_driveType_allWheelDrive: 'Allradantrieb',
91
+ search_trailerHitch: 'Anhängerkupplung',
92
+ assistants_cruiseControl: 'Tempomat',
93
+ assistants_trafficSignRecognition: 'Verkehrszeichenerkennung',
94
+ assistants_collisionAvoidance: 'Notbremsassistent',
95
+ window_sunroof: 'Schiebedach',
96
+ seats_electricAdjustable: 'Elektr. Sitze',
97
+ exterior_metallic: 'Metallic-Lackierung',
98
+ multimedia_handsFreePhoneSystem: 'Freisprecheinrichtung',
99
+ heatingCooling_auxiliaryHeating: 'Standheizung',
100
+ multimedia_bluetooth: 'Bluetooth',
101
+ };
102
+ /** translate a cbd:* selector code → human label. Exported: also used by the Meta AIA
103
+ * marketing-image renderer (plain Node backend, no i18next — relies on the built-in de fallback). */
104
+ function cbd(code, t) {
105
+ if (code == null || code === 'N/A')
106
+ return '';
107
+ const key = String(code);
108
+ if (t) {
109
+ const out = t(`cbd:${key}`);
110
+ // i18next returns the key back when missing; treat that as "no translation".
111
+ if (out && out !== `cbd:${key}` && out !== key)
112
+ return out;
113
+ }
114
+ return CBD_DE_FALLBACK[key] || key;
115
+ }
116
+ /**
117
+ * Map the store's energy-efficiency-class selector code (or a bare letter) to a
118
+ * single CO2_CLASSES letter ('A'…'G'). A+/A++/A+++ collapse to 'A' so the card's
119
+ * colored InlineCls (which does CO2_CLASSES.indexOf) still resolves a band color.
120
+ * Returns '' when there is no class (caller decides what to render).
121
+ */
122
+ function co2ClassLetter(code) {
123
+ if (code == null || code === 'N/A')
124
+ return '';
125
+ const raw = String(code);
126
+ // selector_energyEfficiencyClass_appp → 'a', _ap → 'a', _b → 'b', …
127
+ const m = raw.match(/selector_energyEfficiencyClass_(a|b|c|d|e|f|g)/i);
128
+ const letter = (m ? m[1] : raw.replace(/\+/g, '').trim().charAt(0)).toUpperCase();
129
+ return autobahn_1.CO2_CLASSES.includes(letter) ? letter : '';
130
+ }
131
+ /**
132
+ * Real Preisbewertung → AutobahnCar rating tone + German label.
133
+ *
134
+ * Source of truth (confirmed against the live SRP card,
135
+ * VehicleSmallCard/VehicleInfo.tsx:160): the legacy card renders
136
+ * getPriceRating(price.currentSalesPrice, price.mlCurrentSalesPricePredicted)
137
+ * which returns a numeric tier using priceRatings:
138
+ * 0 noRating · 1 highPrice · 2 increasedPrice · 3 fairPrice · 4 goodPrice · 5 topPrice
139
+ * (constants/price.ts in this package).
140
+ *
141
+ * We map that real number onto the Autobahn 5-tier scale (TIER_LABELS,
142
+ * components/autobahn/constants.ts), where index 0=Top … 4=Hoch:
143
+ * topPrice(5)→0 'Top Preis' · goodPrice(4)→1 'Guter Preis' ·
144
+ * fairPrice(3)→2 'Fairer Preis' · increasedPrice(2)→3 'Erhöht' ·
145
+ * highPrice(1)→4 'Hoher Preis'.
146
+ * noRating(0) has no real comparison → neutral 'Fairer Preis' (tier 2) WITHOUT a
147
+ * flattering label (documented in fieldMapping.ts). The card only special-cases
148
+ * the 'fair' tone (warn pill); every other tone is the good/green pill.
149
+ *
150
+ * AutobahnCar.rating is a free string; CarCard does `rating === 'fair' ? warn : good`.
151
+ * We emit the autobahn tier key ('top'|'good'|'fair'|'raised'|'high') so the
152
+ * neutral 'fair' maps to the warn pill exactly like the design intends.
153
+ */
154
+ const LEGACY_TO_TIER = {
155
+ 5: 0, // topPrice → Top Preis
156
+ 4: 1, // goodPrice → Guter Preis
157
+ 3: 2, // fairPrice → Fairer Preis
158
+ 2: 3, // increasedPrice → Erhöht
159
+ 1: 4, // highPrice → Hoher Preis
160
+ };
161
+ const TIER_TONE = {
162
+ 0: 'top',
163
+ 1: 'good',
164
+ 2: 'fair',
165
+ 3: 'raised',
166
+ 4: 'high',
167
+ };
168
+ function mapRating(currentSalesPrice, predicted) {
169
+ var _a;
170
+ const legacy = (0, priceRating_1.getPriceRating)(currentSalesPrice, predicted); // 0..5
171
+ // 0 = noRating: keep neutral (tier 2) — never invent a flattering rating.
172
+ const tier = legacy === 0 ? 2 : (_a = LEGACY_TO_TIER[legacy]) !== null && _a !== void 0 ? _a : 2;
173
+ return { rating: TIER_TONE[tier], ratingLabel: autobahn_1.TIER_LABELS[tier] };
174
+ }
175
+ /**
176
+ * Map the 5-tier rating to a 2-tone signal — only 'top'/'good' read as a genuinely good deal
177
+ * (green); 'fair'/'raised'/'high' all get the warn/peach tone (Figma: "Erhöhter-Preis" is
178
+ * peach, not green). Pure/non-React on purpose: shared by the web card's StatChip (via
179
+ * cards/cardParts.tsx's re-export) AND the Meta AIA marketing-image renderer (a plain Node
180
+ * service that can't import React components).
181
+ */
182
+ function ratingTone(rating) {
183
+ return rating === 'top' || rating === 'good' ? 'good' : 'warn';
184
+ }
185
+ /** Is this car a plug-in hybrid? Determined by fuel type, mirroring ADV-88 in VehicleInfo.tsx. */
186
+ function isPluginHybrid(fuel) {
187
+ return fuel === 'selector_fuel_hybridElectricalDiesel' || fuel === 'selector_fuel_hybridElectricalPetrol';
188
+ }
189
+ /** Build the gallery URL list from real images, falling back to main image, then placeholder. */
190
+ function buildGallery(storeCar, deps) {
191
+ const placeholder = deps.placeholder || exports.PLACEHOLDER_IMAGE;
192
+ // Only 'small' and 'large' variants are pre-generated by image-generator; 'medium' has no cached file.
193
+ const size = deps.imageSize || 'small';
194
+ // 1) Real loaded images from imagesStore.images.get(id).
195
+ if (Array.isArray(deps.loadedImages) && deps.loadedImages.length) {
196
+ const urls = deps.loadedImages
197
+ .map((img) => size === 'large'
198
+ ? img.imageUrlLarge || img.imageUrlMedium || img.imageUrl
199
+ : size === 'small'
200
+ ? img.imageUrlSmall || img.imageUrlMedium || img.imageUrl
201
+ : img.imageUrlMedium || img.imageUrlLarge || img.imageUrl)
202
+ .filter((u) => !!u);
203
+ if (urls.length)
204
+ return urls;
205
+ }
206
+ // 2) Not loaded yet, but we have a main image id + a URL builder → single real image.
207
+ const id = storeCar.id;
208
+ const mainImageId = storeCar.mainImageId;
209
+ const hasImages = num(storeCar.imagesCount);
210
+ if (deps.getImageUrl && id && mainImageId && hasImages) {
211
+ const url = deps.getImageUrl(id, mainImageId, size);
212
+ if (url)
213
+ return [url];
214
+ }
215
+ // 3) Nothing real yet — single placeholder (never a fabricated photo).
216
+ return [placeholder];
217
+ }
218
+ /** Build the optional PHEV EnVKV sub-block when the car is a plug-in hybrid and weighted data exists. */
219
+ function buildPhev(storeCar, language) {
220
+ var _a, _b, _c;
221
+ const c = storeCar.consumption || {};
222
+ const e = storeCar.environmentEmissions || {};
223
+ if (!isPluginHybrid(c.fuel))
224
+ return undefined;
225
+ const consEl = (_a = num(c.wltpWeightedPowerCombined)) !== null && _a !== void 0 ? _a : num(c.consumptionPowerCombined);
226
+ const consFuel = num(c.wltpWeightedCombined);
227
+ const consDis = (_b = num(c.wltpCombined)) !== null && _b !== void 0 ? _b : num(c.consumptionCombined);
228
+ const co2w = (_c = num(e.wltpCo2)) !== null && _c !== void 0 ? _c : num(e.co2);
229
+ const clsW = co2ClassLetter(e.wltpEnergyEfficiencyClass);
230
+ const clsD = co2ClassLetter(e.wltpDischargedEnergyEfficiencyClass);
231
+ // Only emit the dual PHEV line when we actually have the weighted figures the
232
+ // EcoLine PHEV branch needs; otherwise let the normal single line render.
233
+ if (consEl == null || consFuel == null || co2w == null)
234
+ return undefined;
235
+ return {
236
+ consEl: `${consEl.toLocaleString(localeTag(language))} kWh/100 km`,
237
+ consFuel: `${consFuel.toLocaleString(localeTag(language))} l/100 km`,
238
+ consDis: consDis != null ? `${consDis.toLocaleString(localeTag(language))} l/100 km` : '–',
239
+ co2w: `${co2w.toLocaleString(localeTag(language))} g/km`,
240
+ co2classW: clsW || '–',
241
+ co2classD: clsD || clsW || '–',
242
+ };
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ // main mapper
246
+ // ---------------------------------------------------------------------------
247
+ /**
248
+ * Convert one fe-main store car → AutobahnCar.
249
+ *
250
+ * @param storeCar the object from SearchResultsStore.getCarsForPage()
251
+ * (shape = transformDataForVehicles output). `any` because the store car is
252
+ * itself untyped (`getCarsForPage: (page) => any[]`).
253
+ * @param deps injected services/locale (see ToAutobahnCarDeps). Optional so the
254
+ * mapper still runs in tests with zero wiring (gallery → placeholder, labels →
255
+ * built-in de fallback).
256
+ */
257
+ function toAutobahnCar(storeCar, deps = {}) {
258
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z;
259
+ const language = deps.language || DEFAULT_LANG;
260
+ const t = deps.t;
261
+ const title = storeCar.title || {};
262
+ const info = storeCar.info || {};
263
+ const engineData = storeCar.engineData || {};
264
+ const consumption = storeCar.consumption || {};
265
+ const emissions = storeCar.environmentEmissions || {};
266
+ const price = storeCar.price || {};
267
+ // --- price / monthly (REQUIRED) ---
268
+ // monthly ← price.financing.monthlyInstallment. This is the financing rate
269
+ // produced by FinancingStore via getComputedFinancing(config).financing
270
+ // (getFinancing.ts:82 `monthlyInstallment`), spread into price.financing by
271
+ // transformDataForVehicles (DataTransformers.ts:345). It is the LEAD price.
272
+ const monthly = (_b = num((_a = price === null || price === void 0 ? void 0 : price.financing) === null || _a === void 0 ? void 0 : _a.monthlyInstallment)) !== null && _b !== void 0 ? _b : 0;
273
+ // financing terms the monthly rate was computed with — same merged per-car
274
+ // config (dealer rate + the user's saved localStorage adjustments, ADV-157).
275
+ // Emitted only when real; the cards fall back to the default-config terms.
276
+ // paybackPeriod FIRST: it carries the user's saved term, while rawPaybackPeriod
277
+ // is stamped from the dealer rate at normalize time and never reflects
278
+ // adjustments (normalizeRateObject.ts `rawPaybackPeriod: paybackPeriod`).
279
+ const finFirstInstallment = num((_c = price === null || price === void 0 ? void 0 : price.financing) === null || _c === void 0 ? void 0 : _c.firstInstallment);
280
+ const finMonths = (_e = num((_d = price === null || price === void 0 ? void 0 : price.financing) === null || _d === void 0 ? void 0 : _d.paybackPeriod)) !== null && _e !== void 0 ? _e : num((_f = price === null || price === void 0 ? void 0 : price.financing) === null || _f === void 0 ? void 0 : _f.rawPaybackPeriod);
281
+ // price ← price.currentSalesPrice (DataTransformers.ts:352).
282
+ const purchase = (_g = num(price === null || price === void 0 ? void 0 : price.currentSalesPrice)) !== null && _g !== void 0 ? _g : 0;
283
+ // --- rating (REQUIRED-WHEN-PRICED) ← real Preisbewertung ---
284
+ // BL-08 (P1, MONEY): getPriceRating(0, 0) mathematically resolves to tier 5
285
+ // ("Top Preis" — the BEST possible rating). Its internal min/max bands are all
286
+ // `predicted * (1 ± pct)`, which collapse to 0 when predicted is 0, and the
287
+ // topPrice branch checks `currentSalesPrice <= min` — 0 <= 0 is true. A
288
+ // missing/broken price payload must NEVER get dressed up as the best deal on
289
+ // the lot. Only compute/emit a rating when there is a real, positive sales
290
+ // price; otherwise rating/ratingLabel stay '' (falsy) — every consumer
291
+ // (CarCard.tsx, cardParts.tsx, FinanceCalculator.tsx) already treats a falsy
292
+ // rating as "no badge", so no type change is needed here.
293
+ const predicted = purchase > 0 ? (0, priceRating_1.getPredictablePrice)(purchase, num(price === null || price === void 0 ? void 0 : price.mlCurrentSalesPricePredicted)) : undefined;
294
+ const { rating, ratingLabel } = purchase > 0 ? mapRating(purchase, predicted) : { rating: '', ratingLabel: '' };
295
+ // --- key specs ---
296
+ const powerKW = num(engineData.powerKW);
297
+ const powerPS = num(engineData.powerPS);
298
+ const power = (0, power_1.getPowerLabel)(powerKW, powerPS); // "150 kW (204 PS)"
299
+ const regTs = num(info.regDate);
300
+ const reg = regTs ? (0, DateUtils_1.formatTimestamp)({ timestamp: regTs, format: 'MM/YYYY' }) : '';
301
+ const ownersN = num(info.numberOfPreviousOwners);
302
+ // --- EnVKV consumption line (REQUIRED on every German car ad) ---
303
+ // Prefer WLTP figures when present (the legacy cards do too), else NEDC.
304
+ const consVal = (_h = num(consumption.wltpCombined)) !== null && _h !== void 0 ? _h : num(consumption.consumptionCombined);
305
+ const co2Val = (_j = num(emissions.wltpCo2)) !== null && _j !== void 0 ? _j : num(emissions.co2);
306
+ const cons = consVal != null ? `${consVal.toLocaleString(localeTag(language))} l/100 km` : '';
307
+ const co2 = co2Val != null ? `${co2Val.toLocaleString(localeTag(language))} g/km` : '';
308
+ const co2class = co2ClassLetter(emissions.wltpEnergyEfficiencyClass);
309
+ const phev = buildPhev(storeCar, language);
310
+ // --- media ---
311
+ const gallery = buildGallery(storeCar, deps);
312
+ const photos = (_k = num(storeCar.imagesCount)) !== null && _k !== void 0 ? _k : 0;
313
+ // --- optional: leasing rate — ONLY when leasing is actually active for this car.
314
+ // SearchResultsStore.getCarsForPage stamps price.leasing.isActive via getLeasingActive
315
+ // (car age + mileage within the leasing limits). Legacy SRP shows "Leasing ---" when
316
+ // inactive; we mirror that by leaving `lease` undefined so PricePaths omits the row
317
+ // (instead of computing/showing a leasing rate the car isn't eligible for).
318
+ const leasingActive = !!(price && price.leasing && price.leasing.isActive);
319
+ const lease = leasingActive ? num((_l = price === null || price === void 0 ? void 0 : price.leasing) === null || _l === void 0 ? void 0 : _l.monthlyInstallment) : undefined;
320
+ // leasing terms the lease rate was computed with — same merged per-car config
321
+ // as the financing terms above (annualMileage/paybackPeriod carry the user's
322
+ // saved adjustments). Only meaningful alongside an active lease rate.
323
+ const leaseMileage = leasingActive ? num((_m = price === null || price === void 0 ? void 0 : price.leasing) === null || _m === void 0 ? void 0 : _m.annualMileage) : undefined;
324
+ const leaseMonths = leasingActive ? num((_o = price === null || price === void 0 ? void 0 : price.leasing) === null || _o === void 0 ? void 0 : _o.paybackPeriod) : undefined;
325
+ // --- optional: list price / savings — ONLY from a real strike price ---
326
+ // transformDataForVehicles exposes oldMonthlyInstallment / highestPrice via
327
+ // the financing spread, and price.historyPriceDifference. There is NO reliable
328
+ // purchase-price strike on the search payload today, so we leave listPrice /
329
+ // savings UNDEFINED unless a real highestPrice strike exists above the sale
330
+ // price. See fieldMapping.ts — flagged for backend confirmation.
331
+ const isStrikeShown = !!((_p = price === null || price === void 0 ? void 0 : price.financing) === null || _p === void 0 ? void 0 : _p.isStrikeShown);
332
+ const highest = (_r = num((_q = price === null || price === void 0 ? void 0 : price.financing) === null || _q === void 0 ? void 0 : _q.highestPrice)) !== null && _r !== void 0 ? _r : num(price === null || price === void 0 ? void 0 : price.highestPrice);
333
+ let listPrice;
334
+ let savings;
335
+ if (isStrikeShown && highest != null && purchase > 0 && highest > purchase) {
336
+ listPrice = highest;
337
+ savings = highest - purchase;
338
+ }
339
+ // --- struck-through old rate per payment path — same isStrikeShown gate as the Kaufen
340
+ // strike above. getFinancing.ts/getLeasing.ts each compute their own real
341
+ // oldMonthlyInstallment from the same common.highestPrice; only emitted when the tabbed
342
+ // hero price actually has something real to strike against.
343
+ const oldMonthly = isStrikeShown ? num((_s = price === null || price === void 0 ? void 0 : price.financing) === null || _s === void 0 ? void 0 : _s.oldMonthlyInstallment) : undefined;
344
+ const oldLease = isStrikeShown && leasingActive ? num((_t = price === null || price === void 0 ? void 0 : price.leasing) === null || _t === void 0 ? void 0 : _t.oldMonthlyInstallment) : undefined;
345
+ // --- price drop (REAL) — mirrors the legacy SRL green "−X €" badge: show the reduction only
346
+ // when price.historyPriceDifference is a meaningful amount (≥ 500 €, the legacy threshold).
347
+ const histDrop = num(price === null || price === void 0 ? void 0 : price.historyPriceDifference);
348
+ const drop = histDrop != null && histDrop >= 500 ? histDrop : undefined;
349
+ // percent reduction (historyPriceDifferencePerCent) — paired with `drop` for the −X% badge.
350
+ const histPct = num(price === null || price === void 0 ? void 0 : price.historyPriceDifferencePerCent);
351
+ const dropPct = drop != null && histPct != null && histPct > 0 ? Math.round(histPct) : undefined;
352
+ // --- NEU badge (REAL) — the legacy showNewLabel flag carried on the store car. ---
353
+ const isNew = !!storeCar.showNewLabel;
354
+ // --- availability (REAL) — offer.availabilityMode. 'always' → immediately available. ---
355
+ const offer = storeCar.offer && typeof storeCar.offer === 'object' ? storeCar.offer : {};
356
+ const availability = offer.availabilityMode === 'selector_availabilityMode_always'
357
+ ? t
358
+ ? t('autobahn:availability.immediately')
359
+ : 'Sofort verfügbar'
360
+ : undefined;
361
+ // 0 € Anzahlung available — transform sets offer.showDownPayment = (raw showDownPayment && zero first installment).
362
+ const zeroDown = !!offer.showDownPayment;
363
+ // --- gallery activity strip (REAL) — UserActionsAPI 'stats/viewers', injected via deps.stats. ---
364
+ const viewsCount = num((_u = deps.stats) === null || _u === void 0 ? void 0 : _u.totalCarImpCount);
365
+ const favoritesCount = num((_v = deps.stats) === null || _v === void 0 ? void 0 : _v.totalFavCount);
366
+ const carView = {
367
+ // identity / headline
368
+ make: String((_w = title.make) !== null && _w !== void 0 ? _w : ''),
369
+ model: String((_x = title.model) !== null && _x !== void 0 ? _x : ''),
370
+ trim: String((_y = title.subModel) !== null && _y !== void 0 ? _y : ''),
371
+ // key specs
372
+ km: (_z = num(info.mileage)) !== null && _z !== void 0 ? _z : 0,
373
+ reg,
374
+ power,
375
+ fuel: cbd(consumption.fuel, t),
376
+ gear: cbd(info.gearbox, t),
377
+ owners: ownersN !== null && ownersN !== void 0 ? ownersN : 0,
378
+ // price
379
+ monthly,
380
+ ...(finFirstInstallment != null ? { finFirstInstallment } : {}),
381
+ ...(finMonths != null ? { finMonths } : {}),
382
+ price: purchase,
383
+ // media
384
+ gallery,
385
+ photos,
386
+ // EnVKV
387
+ cons,
388
+ co2,
389
+ co2class: co2class || '',
390
+ ...(phev ? { phev } : {}),
391
+ // trust / features — equipment highlight chips (Matrix-LED, Virtual Cockpit, AHK, ACC, …).
392
+ // transformDataForVehicles extracted the present highlight selector-keys via getActualHighlights
393
+ // (reading the raw equipment categories); translate them to labels here (cbd → t('cbd:<key>')).
394
+ // First 4, like the design's card. Empty when the car has no notable equipment.
395
+ features: (Array.isArray(storeCar.highlights) ? storeCar.highlights : [])
396
+ .map((k) => cbd(k, t))
397
+ .filter((s) => !!s)
398
+ .slice(0, 4),
399
+ rating,
400
+ ratingLabel,
401
+ // optional (only when real)
402
+ ...(listPrice != null ? { listPrice } : {}),
403
+ ...(savings != null ? { savings } : {}),
404
+ ...(oldMonthly != null && oldMonthly > monthly ? { oldMonthly } : {}),
405
+ ...(oldLease != null && lease != null && oldLease > lease ? { oldLease } : {}),
406
+ ...(lease != null ? { lease } : {}),
407
+ ...(leaseMileage != null ? { leaseMileage } : {}),
408
+ ...(leaseMonths != null ? { leaseMonths } : {}),
409
+ ...(drop != null ? { drop } : {}),
410
+ ...(dropPct != null ? { dropPct } : {}),
411
+ ...(zeroDown ? { zeroDown } : {}),
412
+ ...(isNew ? { isNew } : {}),
413
+ ...(availability ? { availability } : {}),
414
+ ...(viewsCount != null ? { viewsCount } : {}),
415
+ ...(favoritesCount != null ? { favoritesCount } : {}),
416
+ // NEEDS-DATA: deliberately omitted (marketLow, marketAvg, marketHigh, watchers, views).
417
+ // The cards render 'Daten folgt' for these until a real backend exists.
418
+ };
419
+ return carView;
420
+ }
421
+ exports.default = toAutobahnCar;