@bytebrand/fe-ui-core-autobahn 1.0.131 → 1.0.133

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.
@@ -26,7 +26,7 @@ import { TranslationFunction } from '../types';
26
26
 
27
27
  import { Button, Dropdown, DropdownOption, Icon, Spec, StatChip, Stars } from '../ui';
28
28
  import { CO2_CLASSES, CO2_COLORS, TIER_LABELS, TIER_KEYS, TIER_COLORS, TIER_BACKGROUNDS } from '../constants';
29
- import { defaultVdpSpecs, EquipmentGroups, PriceRatingBands, VehicleDetail, VdpSpec, VdpReview } from './types';
29
+ import { defaultVdpSpecs, EquipmentGroups, PriceRatingBands, VehicleDetail, VdpSpec, VdpReview, RunningCostRow, EnergyTaxSummary } from './types';
30
30
  import { AddServicesTooltipBody, CalcRow, DocCheckIcon, EvalBar, expanderBtnStyle, FinanceMode, Slider, TermChips, ToggleRow } from './FinanceCalculator';
31
31
  import { formatEur as eur, formatEurCents as eurCents, formatPct as pct } from '../utils/financeFormat';
32
32
 
@@ -364,6 +364,216 @@ export const VdpSpecTable: React.FunctionComponent<VdpSpecTableProps> = ({ vehic
364
364
  );
365
365
  };
366
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // "Energiekosten und Kraftfahrzeugsteuer pro Jahr" summary card — new Figma design.
369
+ // Deliberately its OWN literal inline styles straight from the Figma spec (hex colors,
370
+ // 'Arimo', exact px) rather than the shared mcard/Spec/md-headline-sm grammar — this frame
371
+ // is a one-off design, not part of the .md3 token system. Covers petrol/diesel, BEV, and
372
+ // PHEV (fuel-type-branching data built by toVehicleDetail.ts) — this replaced the legacy-
373
+ // parity "Laufende Kosten pro Jahr" card (formerly VdpRunningCosts here), which showed the
374
+ // SAME figures via the old accordion.tableCostsPerYear layout/wording and has been removed
375
+ // now that this card covers every fuel type; keeping both would show the same numbers
376
+ // twice with inconsistent wording (e.g. old block's "0,00 EUR/Jahr" Kfz-Steuer vs. this
377
+ // card's real "befristet steuerbefreit" text for a BEV).
378
+ // ---------------------------------------------------------------------------
379
+
380
+ export interface VdpEnergyTaxSummaryProps {
381
+ vehicle: VehicleDetail;
382
+ t: TranslationFunction;
383
+ }
384
+
385
+ const energyTaxLabelStyle: React.CSSProperties = {
386
+ fontFamily: 'Arimo',
387
+ fontStyle: 'normal',
388
+ fontWeight: 400,
389
+ fontSize: 13,
390
+ lineHeight: '18px',
391
+ color: '#5B6B7F',
392
+ };
393
+
394
+ const energyTaxValueStyle: React.CSSProperties = {
395
+ fontFamily: 'Arimo',
396
+ fontStyle: 'normal',
397
+ fontWeight: 700,
398
+ fontSize: 18,
399
+ lineHeight: '24px',
400
+ color: '#0E2238',
401
+ };
402
+
403
+ const energyTaxNoteStyle: React.CSSProperties = {
404
+ fontFamily: 'Arimo',
405
+ fontStyle: 'normal',
406
+ fontWeight: 400,
407
+ fontSize: 11,
408
+ lineHeight: '15px',
409
+ color: '#5B6B7F',
410
+ };
411
+
412
+ // Shared by both new-Figma-design cards below (VdpEnergyTaxSummary + VdpCo2CostsSummary) —
413
+ // the "⁵"/"⁶" superscript footnote markers, and the h2 title.
414
+ const footnoteStyle: React.CSSProperties = {
415
+ fontFamily: 'Arimo',
416
+ fontStyle: 'normal',
417
+ fontWeight: 700,
418
+ fontSize: 18,
419
+ lineHeight: '24px',
420
+ color: '#005CFF',
421
+ };
422
+
423
+ const summaryCardTitleStyle: React.CSSProperties = {
424
+ // <h2> carries a UA-stylesheet default margin (top + bottom) unless reset — zero it
425
+ // explicitly so ONLY marginBottom: 10 (the spec'd value) applies, no stray top padding/gap.
426
+ margin: 0,
427
+ marginBottom: 10,
428
+ fontFamily: 'Arimo',
429
+ fontStyle: 'normal',
430
+ fontWeight: 700,
431
+ fontSize: 15,
432
+ lineHeight: '20px',
433
+ color: '#0E2238',
434
+ };
435
+
436
+ export const VdpEnergyTaxSummary: React.FunctionComponent<VdpEnergyTaxSummaryProps> = ({ vehicle, t }) => {
437
+ const s: EnergyTaxSummary | undefined = vehicle.energyTaxSummary;
438
+ if (!s) return null;
439
+ return (
440
+ <div id="vdp-energy-tax">
441
+ <h2 style={summaryCardTitleStyle}>{t('vehicleProps:title.energyTaxSummaryTitle')}</h2>
442
+ <div
443
+ className="vdp-ets-row"
444
+ style={{
445
+ display: 'flex',
446
+ width: '100%',
447
+ gap: 4,
448
+ background: 'var(--md-sc-lowest)',
449
+ borderRadius: 'var(--md-r-md)',
450
+ border: '1px solid var(--md-outline-variant)',
451
+ }}
452
+ >
453
+ <div className="vdp-ets-col vdp-ets-col--first" style={{ flex: 1 }}>
454
+ <div style={energyTaxLabelStyle}>{t('vehicleProps:title.energyCostsYearLabel')}</div>
455
+ <div style={energyTaxValueStyle}>{s.energyCost}</div>
456
+ {/* PHEV: both a Kraftstoffpreis AND a Strompreis line (getBaseFuelPrice returns
457
+ both when hybridPlugin===true) — ICE/BEV each only ever populate one. */}
458
+ {s.fuelPriceNote && s.fuelPriceNote.map((note, i) => (
459
+ <div key={i} style={energyTaxNoteStyle}>
460
+ {note}
461
+ </div>
462
+ ))}
463
+ </div>
464
+ <div className="vdp-ets-col" style={{ flex: 1 }}>
465
+ <div style={energyTaxLabelStyle}>{t('vehicleProps:title.vehicleTaxYearLabel')}</div>
466
+ <div style={{ height: 24, ...energyTaxValueStyle }}>
467
+ {s.tax}
468
+ <span style={footnoteStyle}>⁶</span>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ );
474
+ };
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // "Mögliche CO₂-Kosten über die nächsten 10 Jahre (15.000 km/Jahr)⁵" — new Figma design,
478
+ // same card grammar as VdpEnergyTaxSummary above (own literal inline styles), 3 equal
479
+ // columns (low/middle/high reference CO2 price) instead of 2.
480
+ // ---------------------------------------------------------------------------
481
+
482
+ export interface VdpCo2CostsSummaryProps {
483
+ vehicle: VehicleDetail;
484
+ t: TranslationFunction;
485
+ }
486
+
487
+ export const VdpCo2CostsSummary: React.FunctionComponent<VdpCo2CostsSummaryProps> = ({ vehicle, t }) => {
488
+ const s = vehicle.co2CostsSummary;
489
+ const rows: RunningCostRow[] = s ? ([s.low, s.middle, s.high].filter(Boolean) as RunningCostRow[]) : [];
490
+ if (!rows.length) return null;
491
+ return (
492
+ <div id="vdp-co2-costs">
493
+ <h2 style={summaryCardTitleStyle}>
494
+ {t('vehicleProps:title.co2CostsSummaryTitle')}
495
+ <span style={{ ...footnoteStyle, fontSize: 19 }}>⁵</span>
496
+ </h2>
497
+ <div
498
+ className="vdp-ccs-row"
499
+ style={{
500
+ display: 'flex',
501
+ width: '100%',
502
+ gap: 4,
503
+ background: 'var(--md-sc-lowest)',
504
+ borderRadius: 'var(--md-r-md)',
505
+ border: '1px solid var(--md-outline-variant)',
506
+ }}
507
+ >
508
+ {rows.map((row, i) => (
509
+ <div
510
+ key={i}
511
+ className={`vdp-ccs-col${i < rows.length - 1 ? ' vdp-ccs-col--divider' : ''}`}
512
+ style={{ flex: 1 }}
513
+ >
514
+ <div style={energyTaxLabelStyle}>{row.label}</div>
515
+ <div style={energyTaxValueStyle}>{row.value}</div>
516
+ </div>
517
+ ))}
518
+ </div>
519
+ </div>
520
+ );
521
+ };
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Legal footnote text for the "⁵"/"⁶" markers used in VdpCo2CostsSummary/VdpEnergyTaxSummary
525
+ // above — real Pkw-EnVKV/§3d KraftStG disclosure copy (verbatim, not paraphrased), plain
526
+ // text (no card/background). Needs-data: each paragraph only shows when its corresponding
527
+ // card above actually rendered (never an orphaned footnote for a marker nobody saw).
528
+ // ---------------------------------------------------------------------------
529
+
530
+ export interface VdpCostFootnotesProps {
531
+ vehicle: VehicleDetail;
532
+ t: TranslationFunction;
533
+ }
534
+
535
+ const costFootnoteStyle: React.CSSProperties = {
536
+ fontFamily: 'Arimo',
537
+ fontStyle: 'normal',
538
+ fontWeight: 700,
539
+ fontSize: 13.5,
540
+ lineHeight: '16px',
541
+ color: '#000000',
542
+ };
543
+
544
+ export const VdpCostFootnotes: React.FunctionComponent<VdpCostFootnotesProps> = ({ vehicle, t }) => {
545
+ const showCo2Footnote = !!vehicle.co2CostsSummary;
546
+ const showTaxFootnote = !!vehicle.energyTaxSummary;
547
+ if (!showCo2Footnote && !showTaxFootnote) return null;
548
+ return (
549
+ <div id="vdp-cost-footnotes" style={costFootnoteStyle}>
550
+ {showCo2Footnote && (
551
+ <>
552
+ {t('vehicleProps:value.co2CostsFootnoteTitle')}
553
+ <br />
554
+ <br />
555
+ {t('vehicleProps:value.co2CostsFootnote')}
556
+ </>
557
+ )}
558
+ {showCo2Footnote && showTaxFootnote && (
559
+ <>
560
+ <br />
561
+ <br />
562
+ <br />
563
+ </>
564
+ )}
565
+ {showTaxFootnote && (
566
+ <>
567
+ {t('vehicleProps:value.taxExemptFootnoteTitle')}
568
+ <br />
569
+ <br />
570
+ {t('vehicleProps:value.taxExemptFootnote')}
571
+ </>
572
+ )}
573
+ </div>
574
+ );
575
+ };
576
+
367
577
  // ---------------------------------------------------------------------------
368
578
  // Equipment list (grouped + searchable)
369
579
  // ---------------------------------------------------------------------------
package/vdp/index.ts CHANGED
@@ -29,6 +29,9 @@ export {
29
29
  VdpHeaderCondition,
30
30
  VdpTrustRow,
31
31
  VdpSpecTable,
32
+ VdpEnergyTaxSummary,
33
+ VdpCo2CostsSummary,
34
+ VdpCostFootnotes,
32
35
  EquipmentList,
33
36
  Co2Label,
34
37
  VehicleDescription,
@@ -49,6 +52,9 @@ export type {
49
52
  VdpHeaderCardProps,
50
53
  VdpTrustRowProps,
51
54
  VdpSpecTableProps,
55
+ VdpEnergyTaxSummaryProps,
56
+ VdpCo2CostsSummaryProps,
57
+ VdpCostFootnotesProps,
52
58
  EquipmentListProps,
53
59
  Co2LabelProps,
54
60
  VehicleDescriptionProps,
@@ -76,6 +82,9 @@ export type {
76
82
  VehicleDetail,
77
83
  VehicleCondition,
78
84
  VdpSpec,
85
+ RunningCostRow,
86
+ EnergyTaxSummary,
87
+ Co2CostsSummary,
79
88
  EquipmentGroups,
80
89
  PriceHistoryPoint,
81
90
  PriceRatingBand,
package/vdp/types.ts CHANGED
@@ -60,6 +60,53 @@ export function isZeroSpecValue(formatted: string): boolean {
60
60
  */
61
61
  export type EquipmentGroups = Record<string, string[]>;
62
62
 
63
+ /**
64
+ * One "Laufende Kosten pro Jahr" row (ADV-123/ADV-242 — Pkw-EnVKV mandatory cost
65
+ * disclosure). `label` and `value` are already the fully-formatted, translated strings
66
+ * the legacy VDP showed (e.g. label "bei niedrigem Ø CO2-Preis von 50 EUR/t", value
67
+ * "312,50 EUR/Jahr"); `description`, when present, is the required explanatory/
68
+ * uncertainty note for the CO2-cost-scenario rows and must always render visibly —
69
+ * never hide it behind a tooltip-only interaction (ticket requirement).
70
+ */
71
+ export interface RunningCostRow {
72
+ label: string;
73
+ value: string;
74
+ description?: string;
75
+ }
76
+
77
+ /**
78
+ * "Energiekosten und Kraftfahrzeugsteuer pro Jahr" summary card — new Figma design.
79
+ * Covers petrol/diesel, BEV, and PHEV (fuel-type-branching data). Needs-data: undefined
80
+ * when energyCost/tax can't be computed — fuelPriceNote is separately optional (omitted
81
+ * for BEVs, which have no per-liter fuel price).
82
+ */
83
+ export interface EnergyTaxSummary {
84
+ /** e.g. "1.508,40 €" — for a BEV, the electricity-cost equivalent (getFuelPrice already
85
+ * branches on fuel type internally). */
86
+ energyCost: string;
87
+ /** e.g. "314,00 €" — OR, for a BEV, the real §3d KraftStG time-limited exemption text
88
+ * ("befristet steuerbefreit"), never a fabricated "0,00 €". */
89
+ tax: string;
90
+ /** 1 line for ICE ("Kraftstoffpreis: 1,95 EUR/l (Jahresdurchschnitt 2023)") or BEV
91
+ * ("Strompreis: 41,60 ct/kWh (Jahresdurchschnitt 2023)"); BOTH lines for a plug-in
92
+ * hybrid (getBaseFuelPrice returns fuelPrice AND powerPrice when hybridPlugin===true) —
93
+ * each rendered on its own line, same style. Omitted entirely (never a guessed value)
94
+ * when the car has no computable per-liter fuel price NOR ct/kWh electricity price. */
95
+ fuelPriceNote?: string[];
96
+ }
97
+
98
+ /**
99
+ * "Mögliche CO₂-Kosten über die nächsten 10 Jahre (15.000 km/Jahr)" summary card — new
100
+ * Figma design, 3-column sibling of EnergyTaxSummary. The label already carries the
101
+ * reference €/t price, so `RunningCostRow.description` is unused here. Needs-data: a
102
+ * scenario missing its value is simply omitted.
103
+ */
104
+ export interface Co2CostsSummary {
105
+ low?: RunningCostRow;
106
+ middle?: RunningCostRow;
107
+ high?: RunningCostRow;
108
+ }
109
+
63
110
  /** Vehicle condition block rendered as the header card's zustand tiles. */
64
111
  export interface VehicleCondition {
65
112
  /** TÜV/HU validity, e.g. '03/2027' — or 'Neu' for a new HU. */
@@ -163,6 +210,12 @@ export interface VehicleDetail {
163
210
  */
164
211
  specs?: VdpSpec[];
165
212
 
213
+ // --- Pkw-EnVKV mandatory cost disclosure (ADV-123/ADV-242) ---
214
+ /** Needs-data: undefined when energyCost/tax can't be computed. */
215
+ energyTaxSummary?: EnergyTaxSummary;
216
+ /** Needs-data: undefined when no CO2-cost scenario can be computed. */
217
+ co2CostsSummary?: Co2CostsSummary;
218
+
166
219
  // --- price (always real) ---
167
220
  /** financing monthly rate in EUR — the lead price shown in sticky/bottom bars. */
168
221
  monthly: number;
@@ -165,7 +165,10 @@ const getDecoratedLightProps = (
165
165
  if (hybridPlugin) {
166
166
  if (wltpWeightedPowerCombinedContent && wltpWeightedCombinedContent) {
167
167
  const dischargedCombined = Number.isFinite(wltpConsumptionCombined) ? wltpConsumptionCombined : 19.9;
168
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || 'selector_energyEfficiencyClass_g';
168
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
169
+ // backend could not derive it the field is selector_unknown — hide the segment, never
170
+ // fall back to a hard-coded class here.
171
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
169
172
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
170
173
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
171
174
  const co2WeightedContent = Number.isFinite(wltpCo2)
@@ -174,14 +177,16 @@ const getDecoratedLightProps = (
174
177
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
175
178
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
176
179
  : null;
177
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
180
+ const co2ClassDischarged = isPropDefined(dischargedClass)
181
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
182
+ : null;
178
183
  return <React.Fragment>
179
184
  {`${wltpWeightedPowerCombinedContent} + `}
180
185
  {`${weightedCombinedLabeled}, `}
181
186
  {`${dischargedCombinedLabeled}`}
182
187
  {co2WeightedContent && <>, {co2WeightedContent}</>}
183
188
  {co2ClassWeighted && <>, {co2ClassWeighted}</>}
184
- {<>, {co2ClassDischarged}</>}
189
+ {co2ClassDischarged && <>, {co2ClassDischarged}</>}
185
190
  </React.Fragment>
186
191
  }
187
192
 
@@ -316,7 +321,10 @@ const getDecoratedLightProps = (
316
321
  if (hybridPlugin) {
317
322
  if (wltpWeightedPowerCombinedContent && wltpWeightedCombinedContent) {
318
323
  const dischargedCombined = Number.isFinite(wltpConsumptionCombined) ? wltpConsumptionCombined : 19.9;
319
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || 'selector_energyEfficiencyClass_g';
324
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
325
+ // backend could not derive it the field is selector_unknown — hide the segment, never
326
+ // fall back to a hard-coded class here.
327
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
320
328
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
321
329
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
322
330
  const co2WeightedContent = Number.isFinite(wltpCo2)
@@ -325,14 +333,16 @@ const getDecoratedLightProps = (
325
333
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
326
334
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
327
335
  : null;
328
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
336
+ const co2ClassDischarged = isPropDefined(dischargedClass)
337
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
338
+ : null;
329
339
  return <React.Fragment>
330
340
  {`${wltpWeightedPowerCombinedContent} + `}
331
341
  {`${weightedCombinedLabeled}, `}
332
342
  {`${dischargedCombinedLabeled}`}
333
343
  {co2WeightedContent && <>, {co2WeightedContent}</>}
334
344
  {co2ClassWeighted && <>, {co2ClassWeighted}</>}
335
- {<>, {co2ClassDischarged}</>}
345
+ {co2ClassDischarged && <>, {co2ClassDischarged}</>}
336
346
  </React.Fragment>
337
347
  }
338
348
 
@@ -880,10 +880,12 @@ const getDecoratedProps = (
880
880
  // (entladen, komb) fuel consumption + a discharged CO2 class.
881
881
  if (hybridPlugin && wltpConsumptionWeightedPowerCombinedContent && wltpConsumptionWeightedCombinedContent) {
882
882
  const ADV88_DEFAULT_DISCHARGED_COMBINED = 19.9;
883
- const ADV88_DEFAULT_DISCHARGED_CO2_CLASS = 'selector_energyEfficiencyClass_g';
884
883
 
885
884
  const dischargedCombined = Number.isFinite(wltpCombined) ? wltpCombined : ADV88_DEFAULT_DISCHARGED_COMBINED;
886
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || ADV88_DEFAULT_DISCHARGED_CO2_CLASS;
885
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
886
+ // backend could not derive it the field is selector_unknown — hide the segment, never
887
+ // fall back to a hard-coded class here.
888
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
887
889
 
888
890
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
889
891
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
@@ -893,7 +895,9 @@ const getDecoratedProps = (
893
895
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
894
896
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
895
897
  : null;
896
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
898
+ const co2ClassDischarged = isPropDefined(dischargedClass)
899
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
900
+ : null;
897
901
 
898
902
  return <React.Fragment>
899
903
  {wrapValue(wltpConsumptionWeightedPowerCombinedContent)}{' + '}
@@ -901,7 +905,7 @@ const getDecoratedProps = (
901
905
  {wrapValue(dischargedCombinedLabeled)}
902
906
  {co2WeightedContent && <>{', '}{wrapValue(co2WeightedContent)}</>}
903
907
  {co2ClassWeighted && <>{', '}{co2ClassWeighted}</>}
904
- {', '}{co2ClassDischarged}
908
+ {co2ClassDischarged && <>{', '}{co2ClassDischarged}</>}
905
909
  </React.Fragment>
906
910
  }
907
911