@bytebrand/fe-ui-core-autobahn 1.0.38 → 1.0.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
5
5
  "main": "index.ts",
6
6
  "license": "UNLICENSED",
@@ -26,7 +26,7 @@ import { TranslationFunction } from '../types';
26
26
 
27
27
  import { Button, Icon, Spec, StatChip, Stars, Switch } from '../ui';
28
28
  import { CO2_CLASSES, CO2_COLORS, TIER_LABELS, TIER_KEYS, TIER_COLORS } from '../constants';
29
- import { defaultVdpSpecs, EquipmentGroups, VehicleDetail, VdpSpec, VdpReview } from './types';
29
+ import { defaultVdpSpecs, EquipmentGroups, PriceRatingBands, VehicleDetail, VdpSpec, VdpReview } from './types';
30
30
  import { CalcRow, expanderBtnStyle, FinanceMode, Slider, TermChips } from './FinanceCalculator';
31
31
 
32
32
  const eur = (n: number): string => n.toLocaleString('de-DE');
@@ -609,7 +609,9 @@ export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehic
609
609
  0,
610
610
  Math.min(1, (vehicle.price - vehicle.marketLow!) / (vehicle.marketHigh! - vehicle.marketLow!)),
611
611
  );
612
- const delta = vehicle.marketAvg! - vehicle.price;
612
+ // marketAvg is the raw ML-predicted price (a float, e.g. 14284.277) — round before display,
613
+ // else eur()'s toLocaleString prints the fractional part as a bogus "1.035,277 €".
614
+ const delta = Math.round(vehicle.marketAvg! - vehicle.price);
613
615
  marketBlock = (
614
616
  <div>
615
617
  <div className="md-title-sm" style={{ marginBottom: 12 }}>
@@ -674,18 +676,157 @@ export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehic
674
676
  );
675
677
  }
676
678
 
679
+ // mobile-only (2026-07-17 round 10 decision): the real market-value gauge is scoped to
680
+ // phone widths for now — desktop keeps its exact pre-existing needs-data behaviour
681
+ // (hidden unless a real price-history sparkline exists) by rendering the ORIGINAL
682
+ // hide-mobile grid unconditionally, so it stays byte-identical whenever hasHistory is
683
+ // false (today, always). Once priceHistory gets a real source, both paths coexist fine.
684
+ const mobileOnlyMarket = hasMarket && !hasHistory;
685
+
677
686
  return (
678
- <div className="mcard" id="vdp-preis" style={{ padding: 22 }}>
687
+ <div className={'mcard' + (mobileOnlyMarket ? ' show-mobile' : '')} id="vdp-preis" style={{ padding: 22, ...(mobileOnlyMarket ? { flexDirection: 'column' } : {}) }}>
679
688
  <h2 className="md-headline-sm" style={{ marginBottom: 4 }}>
680
689
  {t('autobahn:priceMarket.heading')}
681
690
  </h2>
682
691
  <p className="md-body-sm" style={{ marginBottom: 18 }}>
683
692
  {t('autobahn:priceMarket.description')}
684
693
  </p>
685
- <div className="pmv-grid">
686
- {historyBlock}
687
- {marketBlock}
688
- </div>
694
+ {mobileOnlyMarket ? (
695
+ <>
696
+ {marketBlock}
697
+ <PriceRatingBreakdown vehicle={vehicle} t={t} />
698
+ </>
699
+ ) : (
700
+ <div className="pmv-grid hide-mobile">
701
+ {historyBlock}
702
+ {marketBlock}
703
+ </div>
704
+ )}
705
+ </div>
706
+ );
707
+ };
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // "Details zur Preisbewertung" expandable — criteria grid + 5-tier band list
711
+ // (mobile-only, round 10; real data — see toVehicleDetail's getPriceRatingConfig call)
712
+ // ---------------------------------------------------------------------------
713
+
714
+ /** [tier key, tone color var]. Order matches the Figma "Bewertungskategorien" list. */
715
+ const RATING_TIERS: ReadonlyArray<readonly [keyof PriceRatingBands, string]> = [
716
+ ['topPrice', 'var(--md-good)'],
717
+ ['goodPrice', '#4C9A2A'],
718
+ ['fairPrice', '#C7A61A'],
719
+ ['increasedPrice', '#D97B1F'],
720
+ ['highPrice', 'var(--md-error)'],
721
+ ];
722
+
723
+ const PriceRatingBreakdown: React.FunctionComponent<{ vehicle: VehicleDetail; t: TranslationFunction }> = ({ vehicle, t }) => {
724
+ const [open, setOpen] = useState(false);
725
+ const bands = vehicle.priceRatingBands;
726
+ if (!bands) return null;
727
+
728
+ const totalEquipment = Object.values(vehicle.equipment || {}).reduce((s, a) => s + a.length, 0);
729
+ const allCriteria: ReadonlyArray<readonly [icon: string, label: string, value: string]> = [
730
+ ['car-front', t('autobahn:priceMarket.criteria.makeModel'), `${vehicle.make} ${vehicle.model}`],
731
+ ['cog', t('autobahn:priceMarket.criteria.gear'), vehicle.gear],
732
+ ['calendar', t('autobahn:priceMarket.criteria.reg'), vehicle.reg],
733
+ ['droplet', t('autobahn:priceMarket.criteria.cons'), vehicle.cons],
734
+ ['fuel', t('autobahn:priceMarket.criteria.fuel'), vehicle.fuel],
735
+ ['leaf', t('autobahn:priceMarket.criteria.co2'), vehicle.co2],
736
+ ['zap', t('autobahn:priceMarket.criteria.power'), vehicle.power],
737
+ ['palette', t('autobahn:priceMarket.criteria.color'), vehicle.color],
738
+ ['gauge', t('autobahn:priceMarket.criteria.km'), fmtKm(vehicle.km)],
739
+ ['star', t('autobahn:priceMarket.criteria.equipment'), t('autobahn:priceMarket.criteria.equipmentCount', { count: totalEquipment })],
740
+ ];
741
+ const criteria = allCriteria.filter(([, , v]) => !!v);
742
+
743
+ return (
744
+ <div style={{ marginTop: 18 }}>
745
+ <button
746
+ type="button"
747
+ onClick={() => setOpen((o) => !o)}
748
+ style={{ ...expanderBtnStyle, justifyContent: 'flex-start', gap: 6, padding: '2px 0' }}
749
+ >
750
+ {t('autobahn:priceMarket.detailsLink')}
751
+ <Icon name={open ? 'chevron-up' : 'chevron-down'} size={16} color="var(--md-primary)" />
752
+ </button>
753
+ {open && (
754
+ <div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 18 }}>
755
+ <div className="mcard mcard--filled" style={{ padding: 16 }}>
756
+ <div className="md-title-sm" style={{ marginBottom: 12 }}>
757
+ {t('autobahn:priceMarket.criteriaTitle')}
758
+ </div>
759
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 14 }}>
760
+ {criteria.map(([ic, label, value]) => (
761
+ <div key={label} style={{ display: 'flex', alignItems: 'flex-start', gap: 9, minWidth: '44%', flex: '1 1 44%' }}>
762
+ <Icon name={ic} size={17} color="var(--md-primary)" stroke={1.8} style={{ marginTop: 1, flex: 'none' }} />
763
+ <span style={{ minWidth: 0 }}>
764
+ <span className="md-label-sm" style={{ display: 'block' }}>
765
+ {label}
766
+ </span>
767
+ <span className="md-title-sm" style={{ display: 'block', fontSize: 13.5 }}>
768
+ {value}
769
+ </span>
770
+ </span>
771
+ </div>
772
+ ))}
773
+ </div>
774
+ </div>
775
+
776
+ <div className="mcard mcard--filled" style={{ padding: 16 }}>
777
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
778
+ <Icon name="bar-chart-3" size={18} color="var(--md-primary)" />
779
+ <div className="md-title-sm">{t('autobahn:priceMarket.categoriesTitle')}</div>
780
+ </div>
781
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
782
+ {RATING_TIERS.map(([key, color], i) => (
783
+ <div
784
+ key={key}
785
+ style={{ padding: '12px 0', borderTop: i > 0 ? '1px solid var(--md-outline-variant)' : 0 }}
786
+ >
787
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
788
+ <span
789
+ className="md-label-sm"
790
+ style={{
791
+ fontWeight: 700,
792
+ padding: '4px 11px',
793
+ borderRadius: 'var(--md-r-full)',
794
+ color,
795
+ background: `color-mix(in srgb, ${color} 16%, transparent)`,
796
+ }}
797
+ >
798
+ {t(`autobahn:priceMarket.categories.${key}.label`)}
799
+ </span>
800
+ <span className="num md-label-sm" style={{ fontWeight: 700 }}>
801
+ {eur(bands[key].min)} – {eur(bands[key].max)} €
802
+ </span>
803
+ </div>
804
+ <p className="md-body-sm" style={{ margin: '6px 0 0' }}>
805
+ {t(`autobahn:priceMarket.categories.${key}.description`)}
806
+ </p>
807
+ </div>
808
+ ))}
809
+ <div style={{ padding: '12px 0 0', borderTop: '1px solid var(--md-outline-variant)' }}>
810
+ <span
811
+ className="md-label-sm"
812
+ style={{
813
+ fontWeight: 700,
814
+ padding: '4px 11px',
815
+ borderRadius: 'var(--md-r-full)',
816
+ color: 'var(--md-on-surface-variant)',
817
+ background: 'var(--md-sc)',
818
+ }}
819
+ >
820
+ {t('autobahn:priceMarket.categories.noRating.label')}
821
+ </span>
822
+ <p className="md-body-sm" style={{ margin: '6px 0 0' }}>
823
+ {t('autobahn:priceMarket.categories.noRating.description')}
824
+ </p>
825
+ </div>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ )}
689
830
  </div>
690
831
  );
691
832
  };
@@ -1647,6 +1788,60 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
1647
1788
  // Customer ratings (auto.de Google reviews) — REAL data, loaded by the page
1648
1789
  // ---------------------------------------------------------------------------
1649
1790
 
1791
+ export interface ReviewsTeaserProps {
1792
+ /** review count (REAL — reviews.length from the same WP json-quotes source). */
1793
+ count: number;
1794
+ /** overall Google rating average — same real aggregate CustomerRatings displays. */
1795
+ average: number;
1796
+ /** i18n function (drilled from the page). */
1797
+ t: TranslationFunction;
1798
+ }
1799
+
1800
+ /**
1801
+ * Compact "jump to reviews" teaser — mobile-only (round 10), sits between the offer card
1802
+ * and the highlights card (Figma "Google Review Teaser / Card"). Scrolls the CustomerRatings
1803
+ * section into view on tap; renders nothing when there are no real reviews to jump to.
1804
+ */
1805
+ export const ReviewsTeaser: React.FunctionComponent<ReviewsTeaserProps> = ({ count, average, t }) => {
1806
+ if (!count) return null;
1807
+ const go = (): void => {
1808
+ if (typeof document === 'undefined') return;
1809
+ const el = document.getElementById('vdp-bewertungen');
1810
+ if (!el) return;
1811
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
1812
+ };
1813
+ return (
1814
+ <button
1815
+ type="button"
1816
+ onClick={go}
1817
+ className="mcard show-mobile"
1818
+ style={{
1819
+ width: '100%',
1820
+ textAlign: 'left',
1821
+ cursor: 'pointer',
1822
+ padding: '16px 18px',
1823
+ alignItems: 'center',
1824
+ justifyContent: 'space-between',
1825
+ gap: 12,
1826
+ // sits between the finance card (order:3) and the highlights card (order:4) in the
1827
+ // mobile .vdp-grid flex flow — tie-broken to AFTER the aside by JSX/DOM source order.
1828
+ order: 3,
1829
+ }}
1830
+ >
1831
+ <span style={{ minWidth: 0 }}>
1832
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
1833
+ <Stars value={average} size={16} />
1834
+ <span className="num md-title-sm">{average.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}/5</span>
1835
+ </span>
1836
+ <span className="md-label-sm" style={{ display: 'block', marginTop: 2 }}>
1837
+ {t('autobahn:reviews.teaserCount', { count })}
1838
+ </span>
1839
+ </span>
1840
+ <Icon name="chevron-right" size={19} color="var(--md-on-surface-variant)" style={{ flex: 'none' }} />
1841
+ </button>
1842
+ );
1843
+ };
1844
+
1650
1845
  export interface CustomerRatingsProps {
1651
1846
  /** the loaded reviews (REAL — from the WP json-quotes source). */
1652
1847
  reviews: VdpReview[];
package/vdp/index.ts CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  VdpBottomBar,
40
40
  InspectionBlock,
41
41
  HowItWorks,
42
+ ReviewsTeaser,
42
43
  CustomerRatings,
43
44
  VdpEnquiry,
44
45
  FinanceDetailsModal,
@@ -62,6 +63,7 @@ export type {
62
63
  InspectionBlockProps,
63
64
  HowItWorksProps,
64
65
  HowItWorksStep,
66
+ ReviewsTeaserProps,
65
67
  CustomerRatingsProps,
66
68
  VdpEnquiryProps,
67
69
  FinanceDetailsModalProps,
@@ -75,6 +77,8 @@ export type {
75
77
  VdpSpec,
76
78
  EquipmentGroups,
77
79
  PriceHistoryPoint,
80
+ PriceRatingBand,
81
+ PriceRatingBands,
78
82
  VdpReview,
79
83
  AutobahnPhev,
80
84
  } from './types';
package/vdp/types.ts CHANGED
@@ -66,6 +66,25 @@ export interface PriceHistoryPoint {
66
66
  price: number;
67
67
  }
68
68
 
69
+ /** One [min,max] EUR band of the 5-tier Preisbewertung (mirrors legacy IRatingConfig). */
70
+ export interface PriceRatingBand {
71
+ min: number;
72
+ max: number;
73
+ }
74
+
75
+ /**
76
+ * The full 5-tier price-rating band breakdown (EUR), same source/math as the header's
77
+ * rating pill (getPriceRatingConfig(currentSalesPrice, predictedPrice)) — REAL data, not
78
+ * needs-data. Powers the mobile "Die auto.de Bewertungskategorien" expandable list.
79
+ */
80
+ export interface PriceRatingBands {
81
+ topPrice: PriceRatingBand;
82
+ goodPrice: PriceRatingBand;
83
+ fairPrice: PriceRatingBand;
84
+ increasedPrice: PriceRatingBand;
85
+ highPrice: PriceRatingBand;
86
+ }
87
+
69
88
  /**
70
89
  * View-model for a single vehicle as rendered by the Autobahn VDP modules.
71
90
  *
@@ -172,6 +191,9 @@ export interface VehicleDetail {
172
191
  marketHigh?: number;
173
192
  /** number of buyers currently watching this car. */
174
193
  watchers?: number;
194
+ /** full 5-tier Preisbewertung bands — REAL (same source as marketLow/marketHigh); powers the
195
+ * mobile "Die auto.de Bewertungskategorien" breakdown. Undefined when rating is unavailable. */
196
+ priceRatingBands?: PriceRatingBands;
175
197
  }
176
198
 
177
199
  /**