@bytebrand/fe-ui-core-autobahn 1.0.90 → 1.0.92

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/cards/CarCard.tsx CHANGED
@@ -100,16 +100,17 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
100
100
  const [mode, setMode] = useState<PaymentMode>('financing');
101
101
  const [photoIndex, setPhotoIndex] = useState(0);
102
102
  const [viewerOpen, setViewerOpen] = useState(false);
103
- const showSave = car.savings != null && car.savings > 600;
104
- const showOfferBadge = !!(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown);
103
+ // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
104
+ // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
105
+ const asGoodAsNew = car.asGoodAsNew !== false;
106
+ const showDrop = asGoodAsNew && car.drop != null;
107
+ const showSave = asGoodAsNew && !showDrop && car.savings != null && car.savings > 600;
108
+ const showPct = asGoodAsNew && car.dropPct != null && car.dropPct > 0;
109
+ const showOfferBadge = !!(car.isNew || showDrop || showSave || showPct || car.zeroDown);
105
110
  // PM call: the badge row only fits 3 pills — when Drop/Save + Percent + ZeroDown are all
106
111
  // active that's already 3, so NEU is dropped rather than causing a 4th to overflow.
107
- const activeDealChips = [
108
- car.drop != null || showSave,
109
- car.dropPct != null && car.dropPct > 0,
110
- !!car.zeroDown,
111
- ].filter(Boolean).length;
112
- const showNeu = car.isNew && activeDealChips < 3;
112
+ const activeDealChips = [showDrop || showSave, showPct, !!car.zeroDown].filter(Boolean).length;
113
+ const showNeu = asGoodAsNew && car.isNew && activeDealChips < 3;
113
114
  return (
114
115
  <article
115
116
  className="mcard mcard--interactive cc-card"
@@ -134,7 +135,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
134
135
  {/* top-left signal stack (heart is top-right): NEU badge + ONE deal signal + the
135
136
  "⚡ Angebot" badge (own line, Figma) below it. DropChip takes precedence over
136
137
  SaveChip; each only renders when its real data exists. */}
137
- {(showOfferBadge || car.dropPct != null) && (
138
+ {(showOfferBadge || showPct) && (
138
139
  <div
139
140
  className="cc-badges"
140
141
  style={{
@@ -150,8 +151,8 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
150
151
  }}
151
152
  >
152
153
  <div style={{ display: 'flex', flexWrap: 'nowrap', gap: 5, alignItems: 'center' }}>
153
- {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
154
- <PercentChip pct={car.dropPct} />
154
+ {showDrop ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
155
+ <PercentChip pct={showPct ? car.dropPct : undefined} />
155
156
  <ZeroDownChip
156
157
  t={t}
157
158
  show={car.zeroDown}
@@ -205,7 +206,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
205
206
  <div className="cc-price" style={{ marginTop: 4, borderTop: '1px solid var(--md-outline-variant)' }}>
206
207
  <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
207
208
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
208
- <PriceHero car={car} mode={mode} t={t} />
209
+ <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
209
210
  <PriceEvalCol car={car} t={t} />
210
211
  </div>
211
212
  {/* same fallback CardCtaRow already uses below: pages that haven't wired a real
package/cards/CarRow.tsx CHANGED
@@ -81,15 +81,16 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
81
81
  }) => {
82
82
  const [mode, setMode] = useState<PaymentMode>('financing');
83
83
  const [photoIndex, setPhotoIndex] = useState(0);
84
- const showSave = car.savings != null && car.savings > 600;
85
- const showOfferBadge = !!(car.isNew || car.drop != null || showSave || car.dropPct != null || car.zeroDown);
84
+ // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
85
+ // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
86
+ const asGoodAsNew = car.asGoodAsNew !== false;
87
+ const showDrop = asGoodAsNew && car.drop != null;
88
+ const showSave = asGoodAsNew && !showDrop && car.savings != null && car.savings > 600;
89
+ const showPct = asGoodAsNew && car.dropPct != null && car.dropPct > 0;
90
+ const showOfferBadge = !!(car.isNew || showDrop || showSave || showPct || car.zeroDown);
86
91
  // same overflow rule as CarCard: the row only fits 3 pills, so NEU drops when the other 3 are all active.
87
- const activeDealChips = [
88
- car.drop != null || showSave,
89
- car.dropPct != null && car.dropPct > 0,
90
- !!car.zeroDown,
91
- ].filter(Boolean).length;
92
- const showNeu = car.isNew && activeDealChips < 3;
92
+ const activeDealChips = [showDrop || showSave, showPct, !!car.zeroDown].filter(Boolean).length;
93
+ const showNeu = asGoodAsNew && car.isNew && activeDealChips < 3;
93
94
  const facts: Array<[icon: string, value: string]> = [
94
95
  ['gauge', fmtKm(car.km)],
95
96
  ['calendar', t('autobahn:card.specs.firstRegistration', { reg: car.reg })],
@@ -112,9 +113,10 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
112
113
  {/* 'auto': desktop picks cover/contain per photo (crops only when it wouldn't lose too
113
114
  much of the shot); below 601px this is identical to 'contain', so mobile is unchanged. */}
114
115
  <CardCarousel images={car.gallery} height="100%" fit="auto" onChange={setPhotoIndex} />
115
- {/* deal badges — ONE compact line: NEU · −€ · 0 € Anz. · −%, plus the "⚡ Angebot" badge
116
- (Figma) on its own line below. The price RATING ('Guter Preis') is NOT here — it
117
- lives in the price column (PriceMeter). */}
116
+ {/* deal badges — ONE compact line, Figma order: −€ (drop/save) · −% · 0 € Anz. · NEU
117
+ (NEU always last, dropped first on overflow — see activeDealChips below), plus the
118
+ "⚡ Angebot" badge (Figma) on its own line below. The price RATING ('Guter Preis') is
119
+ NOT here — it lives in the price column (PriceMeter). */}
118
120
  <div
119
121
  style={{
120
122
  position: 'absolute',
@@ -129,8 +131,8 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
129
131
  }}
130
132
  >
131
133
  <div style={{ display: 'flex', flexWrap: 'nowrap', alignItems: 'center', gap: 5 }}>
132
- {showNeu && <span className="neu-badge">NEU</span>}
133
- {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
134
+ {showDrop ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
135
+ <PercentChip pct={showPct ? car.dropPct : undefined} />
134
136
  <ZeroDownChip t={t} show={car.zeroDown} compact className="show-mobile" />
135
137
  <ZeroDownChip
136
138
  t={t}
@@ -147,7 +149,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
147
149
  fontWeight: 700,
148
150
  }}
149
151
  />
150
- <PercentChip pct={car.dropPct} />
152
+ {showNeu && <span className="neu-badge">NEU</span>}
151
153
  </div>
152
154
  <OfferBadge show={showOfferBadge} t={t} />
153
155
  </div>
@@ -244,7 +246,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
244
246
  <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} className="show-mobile" />
245
247
  <div className="pp-purchase show-mobile">
246
248
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
247
- <PriceHero car={car} mode={mode} t={t} />
249
+ <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
248
250
  {car.rating && (
249
251
  <StatChip tone={ratingTone(car.rating)} icon={ratingTone(car.rating) === 'good' ? 'check' : 'minus'}>
250
252
  {t('autobahn:tier.' + car.rating)}
package/cards/types.ts CHANGED
@@ -118,6 +118,13 @@ export interface AutobahnCar {
118
118
  dropPct?: number;
119
119
  /** 0 € Anzahlung available (offer.showDownPayment) — drives the "0 € Anz." badge. */
120
120
  zeroDown?: boolean;
121
+ /**
122
+ * as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
123
+ * product rule for the drop/percent/NEU deal signals AND the strike-price/UVP-Rate display
124
+ * (see toAutobahnCar.ts's isStrikeShown). Independent of NEU's own showNewLabel (which also
125
+ * requires a recent modificationDate). Does NOT gate zeroDown.
126
+ */
127
+ asGoodAsNew?: boolean;
121
128
 
122
129
  // --- NEEDS DATA: no real source yet (NEVER fabricate) ---
123
130
  /** original list price (for the struck-through reference price). */
@@ -42,9 +42,14 @@ const SURFACE: React.CSSProperties = {
42
42
  const BasicModal = ({ isVisible, toggleModal, name, modalProps, handleSentryInit }: IBasicModal) => {
43
43
  if (!isVisible || typeof document === 'undefined') return null;
44
44
  return ReactDOM.createPortal(
45
- <div style={OVERLAY} role='dialog' aria-modal='true' aria-label='Cookie-Einstellungen'>
46
- <div style={SURFACE}>
47
- {ModalsConfig[name] && ModalsConfig[name]({ ...modalProps, toggleModal, handleSentryInit })}
45
+ // wrapped in .md3: autobahn.css scopes every token/utility class (incl.
46
+ // .mbtn used by the modal buttons) under `.md3` and intentionally does
47
+ // nothing outside it — a bare document.body portal loses the theme.
48
+ <div className='md3'>
49
+ <div style={OVERLAY} role='dialog' aria-modal='true' aria-label='Cookie-Einstellungen'>
50
+ <div style={SURFACE}>
51
+ {ModalsConfig[name] && ModalsConfig[name]({ ...modalProps, toggleModal, handleSentryInit })}
52
+ </div>
48
53
  </div>
49
54
  </div>,
50
55
  document.body
@@ -96,6 +96,22 @@ function num(v: any): number | undefined {
96
96
  return Number.isFinite(n) ? n : undefined;
97
97
  }
98
98
 
99
+ // strike-price ("UVP-Rate"/list-price) eligibility — a separate product rule from the NEU
100
+ // badge's own showNewLabel (which also requires a recent modificationDate; see fe-main's
101
+ // DataTransformers.ts shouldShowNewLabel). Here: as-good-as-new only — mileage under 1,000 km
102
+ // AND first registration either unknown or under 12 months old. Mirrors the identical gate
103
+ // added to VehicleDetailedNextPage.tsx's getFinancing() (VDP "Dein Angebot" card).
104
+ const STRIKE_MAX_MILEAGE_KM = 1000;
105
+ const STRIKE_MAX_AGE_MONTHS = 12;
106
+ const STRIKE_AVG_MS_PER_MONTH = 1000 * 60 * 60 * 24 * 30.44;
107
+
108
+ /** unix-seconds first registration (info.regDate) — unknown can't disqualify what we can't check. */
109
+ function isStrikeAgeEligible(firstRegistration?: number | null): boolean {
110
+ if (firstRegistration == null || !Number.isFinite(firstRegistration) || firstRegistration <= 0) return true;
111
+ const ageMonths = (Date.now() - firstRegistration * 1000) / STRIKE_AVG_MS_PER_MONTH;
112
+ return ageMonths < STRIKE_MAX_AGE_MONTHS;
113
+ }
114
+
99
115
  /**
100
116
  * Minimal de-DE fallback for the handful of cbd:* selector codes the cards show
101
117
  * (fuel + gearbox). Used only when no `t` is injected (e.g. in unit tests).
@@ -391,7 +407,10 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
391
407
  // purchase-price strike on the search payload today, so we leave listPrice /
392
408
  // savings UNDEFINED unless a real highestPrice strike exists above the sale
393
409
  // price. See fieldMapping.ts — flagged for backend confirmation.
394
- const isStrikeShown = !!price?.financing?.isStrikeShown;
410
+ // asGoodAsNew — mileage (info.mileage, same source as `km` below) + regTs (info.regDate,
411
+ // same raw timestamp as `reg` above) gate: see isStrikeAgeEligible.
412
+ const asGoodAsNew = Number(num(info.mileage) ?? 0) < STRIKE_MAX_MILEAGE_KM && isStrikeAgeEligible(regTs);
413
+ const isStrikeShown = !!price?.financing?.isStrikeShown && asGoodAsNew;
395
414
  const highest = num(price?.financing?.highestPrice) ?? num(price?.highestPrice);
396
415
  let listPrice: number | undefined;
397
416
  let savings: number | undefined;
@@ -492,6 +511,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
492
511
  ...(dropPct != null ? { dropPct } : {}),
493
512
  ...(zeroDown ? { zeroDown } : {}),
494
513
  ...(isNew ? { isNew } : {}),
514
+ asGoodAsNew,
495
515
  ...(availability ? { availability } : {}),
496
516
  ...(viewsCount != null ? { viewsCount } : {}),
497
517
  ...(favoritesCount != null ? { favoritesCount } : {}),
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.90",
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
- "main": "index.ts",
6
- "license": "UNLICENSED",
7
- "peerDependencies": {
8
- "react": "^18.0.0",
9
- "react-dom": "^18.0.0"
10
- },
11
- "dependencies": {
12
- "@bytebrand/car-schema-selectors": "^2.0.7",
13
- "@bytebrand/fe-financing": "^5.14.12",
14
- "classnames": "2.2.6",
15
- "clsx": "^2.1.1",
16
- "d3-format": "1.4.5",
17
- "js-cookie": "^2.2.0",
18
- "lodash": "^4.17.21",
19
- "lucide-react": "^0.288.0",
20
- "mobx-react": "^7.6.0",
21
- "moment": "^2.29.1",
22
- "qs": "^6.6.0",
23
- "react-device-detect": "^2.1.2",
24
- "react-grid-system": "^5.0.2",
25
- "react-lazyload": "^3.2.0",
26
- "react-scroll": "^1.9.3",
27
- "react-slick": "^0.29.0",
28
- "slick-carousel": "1.8.1"
29
- }
30
- }
1
+ {
2
+ "name": "@bytebrand/fe-ui-core-autobahn",
3
+ "version": "1.0.92",
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
+ "main": "index.ts",
6
+ "license": "UNLICENSED",
7
+ "peerDependencies": {
8
+ "react": "^18.0.0",
9
+ "react-dom": "^18.0.0"
10
+ },
11
+ "dependencies": {
12
+ "@bytebrand/car-schema-selectors": "^2.0.7",
13
+ "@bytebrand/fe-financing": "^5.14.12",
14
+ "classnames": "2.2.6",
15
+ "clsx": "^2.1.1",
16
+ "d3-format": "1.4.5",
17
+ "js-cookie": "^2.2.0",
18
+ "lodash": "^4.17.21",
19
+ "lucide-react": "^0.288.0",
20
+ "mobx-react": "^7.6.0",
21
+ "moment": "^2.29.1",
22
+ "qs": "^6.6.0",
23
+ "react-device-detect": "^2.1.2",
24
+ "react-grid-system": "^5.0.2",
25
+ "react-lazyload": "^3.2.0",
26
+ "react-scroll": "^1.9.3",
27
+ "react-slick": "^0.29.0",
28
+ "slick-carousel": "1.8.1"
29
+ }
30
+ }
package/theme/md3.css CHANGED
@@ -4471,7 +4471,7 @@ line-height: 31px;
4471
4471
  flex: none;
4472
4472
  }
4473
4473
  .md3 .cc-price .pp-hero-rate {
4474
- align-items: center;
4474
+ align-items: flex-end;
4475
4475
  flex-wrap: nowrap;
4476
4476
  }
4477
4477
  /* separator line between the financing block and the leasing row (legacy-design parity) */
@@ -757,7 +757,12 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
757
757
  </s>
758
758
  </span>
759
759
  )}
760
- <span className="fin-rate-example-suffix">
760
+ {/* no struck old rate above it (strike hidden) — nudge "/mtl." down so it
761
+ sits level with the blue hero rate instead of centering high in the row. */}
762
+ <span
763
+ className="fin-rate-example-suffix"
764
+ style={oldMonthly == null ? { paddingTop: 20 } : undefined}
765
+ >
761
766
  {t('autobahn:finance.perMonth')}
762
767
  </span>
763
768
  </span>
@@ -1655,6 +1655,8 @@ export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
1655
1655
  </s>
1656
1656
  </span>
1657
1657
  )}
1658
+ {/* no struck old rate above it (strike hidden) — nudge "/ mtl." down so it
1659
+ sits level with the blue hero rate instead of centering high in the row. */}
1658
1660
  <span
1659
1661
  style={{
1660
1662
  fontFamily: 'Arial, Helvetica, sans-serif',
@@ -1662,6 +1664,7 @@ export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
1662
1664
  fontSize: 18,
1663
1665
  lineHeight: '21px',
1664
1666
  color: '#5B6B7F',
1667
+ ...(oldMonthly == null ? { paddingTop: 20 } : {}),
1665
1668
  }}
1666
1669
  >
1667
1670
  {t('autobahn:bottom.perMonth').replace(/^\/\s*/, '/ ')}