@bytebrand/fe-ui-core-autobahn 1.0.146 → 1.0.148

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
@@ -1,354 +1,384 @@
1
- // Autobahn (auto.de redesign) — vertical grid car card.
2
- //
3
- // Typed React 18 + TS port of `CarCard` from the design kit
4
- // (docs/redesign/design-source/ui_kits/website/CarCard.jsx). Dense, professional
5
- // (AutoScout24 / Carvana style); the whole card is clickable. Used on the home
6
- // page, landing pages and "similar cars" grids.
7
- //
8
- // Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
9
- // via injected callbacks. No store/API access — the one guarded `window`-adjacent
10
- // read is IntersectionObserver (see `eagerMedia` below), feature-checked so a
11
- // non-browser render (tests without a polyfill) degrades to a no-op, not a crash.
12
- // Renders correctly only inside a `<div className="md3">` subtree.
13
- //
14
- // Differs from the design kit on purpose:
15
- // - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
16
- // design's internal useState, so the parent owns the merkliste.
17
- // - the needs-data deal signals (DropChip / SaveChip) only appear when real
18
- // `drop` / `savings` data is present.
19
-
20
- import React, { useState, useEffect, useRef } from 'react';
21
- import { TranslationFunction } from '../types';
22
-
23
- import { CardCarousel, SaveChip, Button } from '../ui';
24
- import {
25
- SpecLine,
26
- EcoLine,
27
- SeoLine,
28
- DropChip,
29
- PercentChip,
30
- ZeroDownChip,
31
- OfferBadge,
32
- PriceTabs,
33
- PriceHero,
34
- PriceEvalCol,
35
- PriceTerms,
36
- CardCtaRow,
37
- GalleryCounter,
38
- GalleryActivityPill,
39
- cardSpec,
40
- PaymentMode,
41
- callDealerSalesPhone,
42
- DEALER_SALES_PHONE,
43
- } from './cardParts';
44
- import { AutobahnCar } from './types';
45
- import VdpViewer from '../vdp/VdpViewer';
46
-
47
- export interface CarCardProps {
48
- /** fully-resolved car view-model. */
49
- car: AutobahnCar;
50
- /** whether this car is on the user's merkliste (drives the heart). */
51
- isFavorite: boolean;
52
- /** toggle the favorite; the heart calls this (click is kept off the card). */
53
- onToggleFavorite: () => void;
54
- /** fired when the card is clicked (opens the detail page). */
55
- onOpen: (car: AutobahnCar) => void;
56
- /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
57
- onMediaEnter?: () => void;
58
- /**
59
- * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
- * hover/touch. Off by default — the SRP's grid is effectively unbounded (pagination/infinite
61
- * scroll), and firing one getImages() request per card the instant it enters the viewport
62
- * reintroduced exactly the failure mode `loadCarImages` was deliberately scoped away from
63
- * (SearchNextPage.tsx: "eager per-card getImages() ... raised an error toast PER failed
64
- * request, stacked toasts on mobile scroll" whenever the image service is degraded).
65
- * Opt into this only for small, BOUNDED grids (e.g. the homepage's ≤6-card "Top-Angebote"),
66
- * where without it the gallery's prev/next arrows and image-count dots stay hidden until the
67
- * user's first hover/touch on THAT card resolves (async) — on touch devices this reads as
68
- * "controls need a preliminary tap", because the very same tap that reveals them is usually
69
- * also the tap the user meant as their real interaction with the card.
70
- */
71
- eagerMedia?: boolean;
72
- /** i18n translate fn (drilled from the page). */
73
- t: TranslationFunction;
74
- /**
75
- * Optional admin-only action (e.g. dealer super admins' "Zum Schieberegler hinzufügen").
76
- * Rendered as a quiet button at the card foot; the page decides visibility (JWT claim).
77
- */
78
- adminAction?: { label: string; onClick: () => void };
79
- /**
80
- * Mobile CTA row (Figma: Angebot anfragen / phone / Rate anpassen) — omitted entirely when
81
- * the page hasn't wired these yet (same optional pattern as adminAction), so pages migrate
82
- * one at a time instead of all breaking at once.
83
- */
84
- onRequestOffer?: () => void;
85
- onCall?: () => void;
86
- /**
87
- * opens the shared FinanceDetailsModal "Rate anpassen" sheet for this car (page-owned) —
88
- * receives the tab the card was showing (Finanzierung/Leasing/Kaufen) so the sheet opens on
89
- * the same one.
90
- */
91
- onOpenRateAdjust?: (mode: PaymentMode) => void;
92
- /**
93
- * takes over the "Finanzierungsdetails"/"Details" link under the price row — e.g. navigate to
94
- * this car's own VDP and open its finance modal already expanded/scrolled to the breakdown,
95
- * instead of the default plain onOpen(car) navigation. Receives the tab the card was showing
96
- * (Finanzierung/Leasing/Kaufen) so the destination modal opens on the same one. Optional/
97
- * page-owned like onOpenRateAdjust; the mobile CTA row's "Rate anpassen" button is untouched
98
- * (still onOpenRateAdjust).
99
- */
100
- onShowFinancingDetails?: (car: AutobahnCar, mode: PaymentMode) => void;
101
- }
102
-
103
- const CarCard: React.FunctionComponent<CarCardProps> = ({
104
- car,
105
- isFavorite,
106
- onToggleFavorite,
107
- onOpen,
108
- onMediaEnter,
109
- eagerMedia,
110
- t,
111
- adminAction,
112
- onRequestOffer,
113
- onCall,
114
- onOpenRateAdjust,
115
- onShowFinancingDetails,
116
- }) => {
117
- const [mode, setMode] = useState<PaymentMode>('financing');
118
- const [photoIndex, setPhotoIndex] = useState(0);
119
- const [viewerOpen, setViewerOpen] = useState(false);
120
- const mediaRef = useRef<HTMLDivElement | null>(null);
121
- // always call the LATEST onMediaEnter without re-arming the observer on every render (the
122
- // page callers below construct a fresh closure each render) — the effect itself only needs
123
- // to run once per mount.
124
- const onMediaEnterRef = useRef(onMediaEnter);
125
- onMediaEnterRef.current = onMediaEnter;
126
- useEffect(() => {
127
- if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
128
- const el = mediaRef.current;
129
- if (!el) return undefined;
130
- let fired = false;
131
- // rootMargin starts the fetch a bit before the card is actually on-screen, so by the time
132
- // the user's eye (or finger, on touch) reaches it the gallery has usually already resolved
133
- // — the whole point being no tap is required to make prev/next + the counter "real".
134
- const observer = new IntersectionObserver(
135
- (entries) => {
136
- entries.forEach((entry) => {
137
- if (entry.isIntersecting && !fired) {
138
- fired = true;
139
- onMediaEnterRef.current && onMediaEnterRef.current();
140
- observer.disconnect();
141
- }
142
- });
143
- },
144
- { rootMargin: '200px 0px', threshold: 0 },
145
- );
146
- observer.observe(el);
147
- return () => observer.disconnect();
148
- }, [eagerMedia]);
149
- // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
150
- // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
151
- const asGoodAsNew = car.asGoodAsNew !== false;
152
- const showDrop = asGoodAsNew && car.drop != null;
153
- const showSave = asGoodAsNew && !showDrop && car.savings != null && car.savings > 600;
154
- const showPct = asGoodAsNew && car.dropPct != null && car.dropPct > 0;
155
- const showOfferBadge = !!(car.isNew || showDrop || showSave || showPct || car.zeroDown);
156
- // PM call: the badge row only fits 3 pills — when Drop/Save + Percent + ZeroDown are all
157
- // active that's already 3, so NEU is dropped rather than causing a 4th to overflow.
158
- const activeDealChips = [showDrop || showSave, showPct, !!car.zeroDown].filter(Boolean).length;
159
- const showNeu = asGoodAsNew && car.isNew && activeDealChips < 3;
160
- return (
161
- <article
162
- className="mcard mcard--interactive cc-card"
163
- onClick={() => onOpen(car)}
164
- style={{ display: 'flex', flexDirection: 'column' }}
165
- >
166
- <div
167
- className="cc-media"
168
- ref={mediaRef}
169
- style={{ position: 'relative' }}
170
- onMouseEnter={onMediaEnter}
171
- onTouchStart={onMediaEnter}
172
- >
173
- <CardCarousel images={car.gallery} height="100%" fit="contain" padded={false} onChange={setPhotoIndex} />
174
- {/* max two overlays: heart + ONE deal signal — rating moved next to the price */}
175
- <div
176
- className="cc-fav"
177
- style={{ position: 'absolute', top: 14, right: 9, zIndex: 6 }}
178
- onClick={(e) => e.stopPropagation()}
179
- >
180
- {/* Custom heart (not lucide's default 'heart') — scoped here via a hand-rolled
181
- button+svg instead of IconButton/Icon's name-based registry lookup, so this
182
- doesn't change the "heart" icon anywhere else it's used (e.g. CarRow). Same
183
- active/inactive color logic IconButton+Icon had: active fills solid
184
- var(--md-primary), inactive stays an outline (previously 'currentColor',
185
- now this Figma-exact icon's own #08162A stroke). */}
186
- <button
187
- className="icon-btn icon-btn--filled"
188
- onClick={onToggleFavorite}
189
- aria-label={t('autobahn:card.action.save')}
190
- >
191
- <svg width="22" height="22" viewBox="0 0 22 22" fill={isFavorite ? 'var(--md-primary)' : 'none'} xmlns="http://www.w3.org/2000/svg">
192
- <path
193
- d="M11 18.7915C11 18.7915 4.125 14.8498 4.125 9.34981C4.12463 8.54932 4.37379 7.76862 4.83781 7.11634C5.30183 6.46405 5.95763 5.97262 6.71396 5.71043C7.47029 5.44823 8.28955 5.42831 9.05773 5.65343C9.82591 5.87855 10.5048 6.33752 11 6.96648C11.4952 6.33752 12.1741 5.87855 12.9423 5.65343C13.7105 5.42831 14.5297 5.44823 15.286 5.71043C16.0424 5.97262 16.6982 6.46405 17.1622 7.11634C17.6262 7.76862 17.8754 8.54932 17.875 9.34981C17.875 14.8498 11 18.7915 11 18.7915Z"
194
- stroke={isFavorite ? 'var(--md-primary)' : '#08162A'}
195
- strokeWidth={1.8}
196
- strokeLinecap="round"
197
- strokeLinejoin="round"
198
- />
199
- </svg>
200
- </button>
201
- </div>
202
- {/* top-left signal stack (heart is top-right): NEU badge + ONE deal signal + the
203
- "⚡ Angebot" badge (own line, Figma) below it. DropChip takes precedence over
204
- SaveChip; each only renders when its real data exists. */}
205
- {(showOfferBadge || showPct) && (
206
- <div
207
- className="cc-badges"
208
- style={{
209
- position: 'absolute',
210
- top: 16,
211
- left: 10,
212
- zIndex: 3,
213
- display: 'flex',
214
- flexDirection: 'column',
215
- alignItems: 'flex-start',
216
- gap: 5,
217
- maxWidth: 'calc(100% - 52px)',
218
- }}
219
- >
220
- <div style={{ display: 'flex', flexWrap: 'nowrap', gap: 5, alignItems: 'center' }}>
221
- {showDrop ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
222
- <PercentChip pct={showPct ? car.dropPct : undefined} />
223
- <ZeroDownChip
224
- t={t}
225
- show={car.zeroDown}
226
- style={{
227
- // "€0 down payment" (en) doesn't fit the German pill's 104px — only German's
228
- // "0 € Anzahlung" gets the narrow width, every other locale gets more room.
229
- width: t('autobahn:card.price.zeroDown') === '0 € Anzahlung' ? 104 : 125,
230
- height: 30,
231
- padding: 0,
232
- justifyContent: 'center',
233
- background: 'rgb(204, 222, 255)',
234
- border: '1px solid rgba(0, 92, 255, 0.05)',
235
- borderRadius: 15,
236
- fontFamily: 'Arial, Arimo, sans-serif',
237
- fontWeight: 700,
238
- fontSize: 13,
239
- lineHeight: '15px',
240
- color: '#005CFF',
241
- }}
242
- />
243
- {showNeu && <span className="neu-badge">NEU</span>}
244
- </div>
245
- <OfferBadge show={showOfferBadge} t={t} />
246
- </div>
247
- )}
248
- {/* bottom-left: expand icon + "current | total" photo counter (Figma light pill, node
249
- 281:1016) — opens the full VdpViewer lightbox; stopPropagation keeps the click off
250
- the card's own onOpen (React portals still bubble through the React tree, not the
251
- DOM tree, so this guard is needed even though VdpViewer renders via createPortal). */}
252
- <div
253
- style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}
254
- onClick={(e) => e.stopPropagation()}
255
- >
256
- <GalleryCounter current={photoIndex + 1} total={car.photos} light onClick={() => setViewerOpen(true)} />
257
- </div>
258
- {/* bottom-right: eye/heart activity pill — real stats only (Figma "Push Chips / Likes", node 297:1037) */}
259
- <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
260
- <GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
261
- </div>
262
- </div>
263
- {/* padding-top: 0 (was 8px) + marginTop: -5 — not just 0, the ask was 0 AND 5px higher
264
- still than that zero-padding baseline, so a negative margin pulls the whole block up
265
- past where padding-top:0 alone would leave it. Bottom/side padding (8px/18px)
266
- untouched. */}
267
- <div style={{ padding: '0 18px 8px', marginTop: -3, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
268
- <div>
269
- <div className="md-title" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
270
- {car.make} {car.model}
271
- </div>
272
- <div className="md-body-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
273
- {car.trim}
274
- </div>
275
- </div>
276
- <SpecLine items={cardSpec(car)} oneLine />
277
- <div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
278
- <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
279
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
280
- <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
281
- <PriceEvalCol car={car} t={t} />
282
- </div>
283
- {/* same fallback CardCtaRow already uses below: pages that haven't wired a real
284
- "Rate anpassen" sheet yet still get a working "Details" link (opens the car). */}
285
- <PriceTerms
286
- car={car}
287
- mode={mode}
288
- onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
289
- onShowDetails={onShowFinancingDetails ? () => onShowFinancingDetails(car, mode) : undefined}
290
- t={t}
291
- />
292
- </div>
293
- {/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
294
- rendered, with real fallbacks the page can override once it wires its own
295
- handlers (e.g. the "Rate anpassen" FinanceDetailsModal sheet). Never hidden. */}
296
- <CardCtaRow
297
- car={car}
298
- mode={mode}
299
- onRequestOffer={onRequestOffer || (() => onOpen(car))}
300
- onCall={onCall || callDealerSalesPhone}
301
- onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
302
- t={t}
303
- />
304
- <EcoLine
305
- t={t}
306
- car={car}
307
- style={{
308
- marginTop: 'auto',
309
- borderTop: 'none',
310
- fontFamily: 'Arial, Helvetica, Arimo, sans-serif',
311
- fontStyle: 'normal',
312
- fontWeight: 400,
313
- fontSize: 11,
314
- lineHeight: '11px',
315
- textAlign: 'justify',
316
- color: '#0e2238',
317
- }}
318
- />
319
- {/* legacy VehicleSmallCard's "SeoText" footer line (full spec + equipment list) —
320
- renders nothing when the caller hasn't populated car.seoText. */}
321
- <SeoLine car={car} style={{ marginTop: 0 }} />
322
- {adminAction && (
323
- <div style={{ marginTop: 8 }}>
324
- <Button
325
- variant="outlined"
326
- block
327
- onClick={(e) => {
328
- e.stopPropagation();
329
- adminAction.onClick();
330
- }}
331
- >
332
- {adminAction.label}
333
- </Button>
334
- </div>
335
- )}
336
- </div>
337
- {viewerOpen && (
338
- <div onClick={(e) => e.stopPropagation()}>
339
- <VdpViewer
340
- images={car.galleryLarge || car.gallery}
341
- start={photoIndex}
342
- onClose={() => setViewerOpen(false)}
343
- t={t}
344
- phone={DEALER_SALES_PHONE}
345
- onCall={onCall || callDealerSalesPhone}
346
- onEmail={onRequestOffer || (() => onOpen(car))}
347
- />
348
- </div>
349
- )}
350
- </article>
351
- );
352
- };
353
-
354
- export default CarCard;
1
+ // Autobahn (auto.de redesign) — vertical grid car card.
2
+ //
3
+ // Typed React 18 + TS port of `CarCard` from the design kit
4
+ // (docs/redesign/design-source/ui_kits/website/CarCard.jsx). Dense, professional
5
+ // (AutoScout24 / Carvana style); the whole card is clickable. Used on the home
6
+ // page, landing pages and "similar cars" grids.
7
+ //
8
+ // Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
9
+ // via injected callbacks. No store/API access — the one guarded `window`-adjacent
10
+ // read is IntersectionObserver (see `eagerMedia` below), feature-checked so a
11
+ // non-browser render (tests without a polyfill) degrades to a no-op, not a crash.
12
+ // Renders correctly only inside a `<div className="md3">` subtree.
13
+ //
14
+ // Differs from the design kit on purpose:
15
+ // - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
16
+ // design's internal useState, so the parent owns the merkliste.
17
+ // - the needs-data deal signals (DropChip / SaveChip) only appear when real
18
+ // `drop` / `savings` data is present.
19
+
20
+ import React, { useState, useEffect, useRef } from 'react';
21
+ import { TranslationFunction } from '../types';
22
+
23
+ import { CardCarousel, SaveChip, Button } from '../ui';
24
+ import {
25
+ SpecLine,
26
+ EcoLine,
27
+ SeoLine,
28
+ DropChip,
29
+ PercentChip,
30
+ ZeroDownChip,
31
+ OfferBadge,
32
+ PriceTabs,
33
+ PriceHero,
34
+ PriceEvalCol,
35
+ PriceTerms,
36
+ CardCtaRow,
37
+ GalleryCounter,
38
+ GalleryActivityPill,
39
+ cardSpec,
40
+ PaymentMode,
41
+ callDealerSalesPhone,
42
+ DEALER_SALES_PHONE,
43
+ } from './cardParts';
44
+ import { AutobahnCar } from './types';
45
+ import VdpViewer from '../vdp/VdpViewer';
46
+
47
+ export interface CarCardProps {
48
+ /** fully-resolved car view-model. */
49
+ car: AutobahnCar;
50
+ /** whether this car is on the user's merkliste (drives the heart). */
51
+ isFavorite: boolean;
52
+ /** toggle the favorite; the heart calls this (click is kept off the card). */
53
+ onToggleFavorite: () => void;
54
+ /** fired when the card is clicked (opens the detail page). */
55
+ onOpen: (car: AutobahnCar) => void;
56
+ /** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
57
+ onMediaEnter?: () => void;
58
+ /**
59
+ * Also fire `onMediaEnter` as soon as the card scrolls into view, instead of waiting for a
60
+ * hover/touch. Off by default — the SRP's grid is effectively unbounded (pagination/infinite
61
+ * scroll), and firing one getImages() request per card the instant it enters the viewport
62
+ * reintroduced exactly the failure mode `loadCarImages` was deliberately scoped away from
63
+ * (SearchNextPage.tsx: "eager per-card getImages() ... raised an error toast PER failed
64
+ * request, stacked toasts on mobile scroll" whenever the image service is degraded).
65
+ * Opt into this only for small, BOUNDED grids (e.g. the homepage's ≤6-card "Top-Angebote"),
66
+ * where without it the gallery's prev/next arrows and image-count dots stay hidden until the
67
+ * user's first hover/touch on THAT card resolves (async) — on touch devices this reads as
68
+ * "controls need a preliminary tap", because the very same tap that reveals them is usually
69
+ * also the tap the user meant as their real interaction with the card.
70
+ */
71
+ eagerMedia?: boolean;
72
+ /** i18n translate fn (drilled from the page). */
73
+ t: TranslationFunction;
74
+ /**
75
+ * Optional admin-only action (e.g. dealer super admins' "Zum Schieberegler hinzufügen").
76
+ * Rendered as a quiet button at the card foot; the page decides visibility (JWT claim).
77
+ */
78
+ adminAction?: { label: string; onClick: () => void };
79
+ /**
80
+ * Mobile CTA row (Figma: Angebot anfragen / phone / Rate anpassen) — omitted entirely when
81
+ * the page hasn't wired these yet (same optional pattern as adminAction), so pages migrate
82
+ * one at a time instead of all breaking at once.
83
+ */
84
+ onRequestOffer?: () => void;
85
+ onCall?: () => void;
86
+ /**
87
+ * opens the shared FinanceDetailsModal "Rate anpassen" sheet for this car (page-owned) —
88
+ * receives the tab the card was showing (Finanzierung/Leasing/Kaufen) so the sheet opens on
89
+ * the same one.
90
+ */
91
+ onOpenRateAdjust?: (mode: PaymentMode) => void;
92
+ /**
93
+ * takes over the "Finanzierungsdetails"/"Details" link under the price row — e.g. navigate to
94
+ * this car's own VDP and open its finance modal already expanded/scrolled to the breakdown,
95
+ * instead of the default plain onOpen(car) navigation. Receives the tab the card was showing
96
+ * (Finanzierung/Leasing/Kaufen) so the destination modal opens on the same one. Optional/
97
+ * page-owned like onOpenRateAdjust; the mobile CTA row's "Rate anpassen" button is untouched
98
+ * (still onOpenRateAdjust).
99
+ */
100
+ onShowFinancingDetails?: (car: AutobahnCar, mode: PaymentMode) => void;
101
+ }
102
+
103
+ const CarCard: React.FunctionComponent<CarCardProps> = ({
104
+ car,
105
+ isFavorite,
106
+ onToggleFavorite,
107
+ onOpen,
108
+ onMediaEnter,
109
+ eagerMedia,
110
+ t,
111
+ adminAction,
112
+ onRequestOffer,
113
+ onCall,
114
+ onOpenRateAdjust,
115
+ onShowFinancingDetails,
116
+ }) => {
117
+ const [mode, setMode] = useState<PaymentMode>('financing');
118
+ const [photoIndex, setPhotoIndex] = useState(0);
119
+ const [viewerOpen, setViewerOpen] = useState(false);
120
+ const mediaRef = useRef<HTMLDivElement | null>(null);
121
+ // always call the LATEST onMediaEnter without re-arming the observer on every render (the
122
+ // page callers below construct a fresh closure each render) — the effect itself only needs
123
+ // to run once per mount.
124
+ const onMediaEnterRef = useRef(onMediaEnter);
125
+ onMediaEnterRef.current = onMediaEnter;
126
+ useEffect(() => {
127
+ if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
128
+ const el = mediaRef.current;
129
+ if (!el) return undefined;
130
+ let fired = false;
131
+ // rootMargin starts the fetch a bit before the card is actually on-screen, so by the time
132
+ // the user's eye (or finger, on touch) reaches it the gallery has usually already resolved
133
+ // — the whole point being no tap is required to make prev/next + the counter "real".
134
+ const observer = new IntersectionObserver(
135
+ (entries) => {
136
+ entries.forEach((entry) => {
137
+ if (entry.isIntersecting && !fired) {
138
+ fired = true;
139
+ onMediaEnterRef.current && onMediaEnterRef.current();
140
+ observer.disconnect();
141
+ }
142
+ });
143
+ },
144
+ { rootMargin: '200px 0px', threshold: 0 },
145
+ );
146
+ observer.observe(el);
147
+ return () => observer.disconnect();
148
+ }, [eagerMedia]);
149
+ // as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
150
+ // undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
151
+ const asGoodAsNew = car.asGoodAsNew !== false;
152
+ const showDrop = asGoodAsNew && car.drop != null;
153
+ const showSave = asGoodAsNew && !showDrop && car.savings != null && car.savings > 600;
154
+ const showPct = asGoodAsNew && car.dropPct != null && car.dropPct > 0;
155
+ const showOfferBadge = !!(car.isNew || showDrop || showSave || showPct || car.zeroDown);
156
+ // PM call: the badge row only fits 3 pills — when Drop/Save + Percent + ZeroDown are all
157
+ // active that's already 3, so NEU is dropped rather than causing a 4th to overflow.
158
+ const activeDealChips = [showDrop || showSave, showPct, !!car.zeroDown].filter(Boolean).length;
159
+ const showNeu = asGoodAsNew && car.isNew && activeDealChips < 3;
160
+ // tabs the price block can switch to (PriceTabs shows Leasing only when the car has a lease rate)
161
+ const paymentModes: PaymentMode[] = car.lease != null ? ['financing', 'leasing', 'buy'] : ['financing', 'buy'];
162
+ return (
163
+ <article
164
+ className="mcard mcard--interactive cc-card"
165
+ onClick={() => onOpen(car)}
166
+ style={{ display: 'flex', flexDirection: 'column' }}
167
+ >
168
+ <div
169
+ className="cc-media"
170
+ ref={mediaRef}
171
+ style={{ position: 'relative' }}
172
+ onMouseEnter={onMediaEnter}
173
+ onTouchStart={onMediaEnter}
174
+ >
175
+ <CardCarousel images={car.gallery} height="100%" fit="contain" padded={false} onChange={setPhotoIndex} />
176
+ {/* max two overlays: heart + ONE deal signal — rating moved next to the price */}
177
+ <div
178
+ className="cc-fav"
179
+ style={{ position: 'absolute', top: 14, right: 9, zIndex: 6 }}
180
+ onClick={(e) => e.stopPropagation()}
181
+ >
182
+ {/* Custom heart (not lucide's default 'heart') — scoped here via a hand-rolled
183
+ button+svg instead of IconButton/Icon's name-based registry lookup, so this
184
+ doesn't change the "heart" icon anywhere else it's used (e.g. CarRow). Same
185
+ active/inactive color logic IconButton+Icon had: active fills solid
186
+ var(--md-primary), inactive stays an outline (previously 'currentColor',
187
+ now this Figma-exact icon's own #08162A stroke). */}
188
+ <button
189
+ className="icon-btn icon-btn--filled"
190
+ onClick={onToggleFavorite}
191
+ aria-label={t('autobahn:card.action.save')}
192
+ >
193
+ <svg width="22" height="22" viewBox="0 0 22 22" fill={isFavorite ? 'var(--md-primary)' : 'none'} xmlns="http://www.w3.org/2000/svg">
194
+ <path
195
+ d="M11 18.7915C11 18.7915 4.125 14.8498 4.125 9.34981C4.12463 8.54932 4.37379 7.76862 4.83781 7.11634C5.30183 6.46405 5.95763 5.97262 6.71396 5.71043C7.47029 5.44823 8.28955 5.42831 9.05773 5.65343C9.82591 5.87855 10.5048 6.33752 11 6.96648C11.4952 6.33752 12.1741 5.87855 12.9423 5.65343C13.7105 5.42831 14.5297 5.44823 15.286 5.71043C16.0424 5.97262 16.6982 6.46405 17.1622 7.11634C17.6262 7.76862 17.8754 8.54932 17.875 9.34981C17.875 14.8498 11 18.7915 11 18.7915Z"
196
+ stroke={isFavorite ? 'var(--md-primary)' : '#08162A'}
197
+ strokeWidth={1.8}
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ />
201
+ </svg>
202
+ </button>
203
+ </div>
204
+ {/* top-left signal stack (heart is top-right): NEU badge + ONE deal signal + the
205
+ "⚡ Angebot" badge (own line, Figma) below it. DropChip takes precedence over
206
+ SaveChip; each only renders when its real data exists. */}
207
+ {(showOfferBadge || showPct) && (
208
+ <div
209
+ className="cc-badges"
210
+ style={{
211
+ position: 'absolute',
212
+ top: 16,
213
+ left: 10,
214
+ zIndex: 3,
215
+ display: 'flex',
216
+ flexDirection: 'column',
217
+ alignItems: 'flex-start',
218
+ gap: 5,
219
+ maxWidth: 'calc(100% - 52px)',
220
+ }}
221
+ >
222
+ <div style={{ display: 'flex', flexWrap: 'nowrap', gap: 5, alignItems: 'center' }}>
223
+ {showDrop ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
224
+ <PercentChip pct={showPct ? car.dropPct : undefined} />
225
+ <ZeroDownChip
226
+ t={t}
227
+ show={car.zeroDown}
228
+ style={{
229
+ // "€0 down payment" (en) doesn't fit the German pill's 104px — only German's
230
+ // "0 € Anzahlung" gets the narrow width, every other locale gets more room.
231
+ width: t('autobahn:card.price.zeroDown') === '0 € Anzahlung' ? 104 : 125,
232
+ height: 30,
233
+ padding: 0,
234
+ justifyContent: 'center',
235
+ background: 'rgb(204, 222, 255)',
236
+ border: '1px solid rgba(0, 92, 255, 0.05)',
237
+ borderRadius: 15,
238
+ fontFamily: 'Arial, Arimo, sans-serif',
239
+ fontWeight: 700,
240
+ fontSize: 13,
241
+ lineHeight: '15px',
242
+ color: '#005CFF',
243
+ }}
244
+ />
245
+ {showNeu && <span className="neu-badge">NEU</span>}
246
+ </div>
247
+ <OfferBadge show={showOfferBadge} t={t} />
248
+ </div>
249
+ )}
250
+ {/* bottom-left: expand icon + "current | total" photo counter (Figma light pill, node
251
+ 281:1016) — opens the full VdpViewer lightbox; stopPropagation keeps the click off
252
+ the card's own onOpen (React portals still bubble through the React tree, not the
253
+ DOM tree, so this guard is needed even though VdpViewer renders via createPortal). */}
254
+ <div
255
+ style={{ position: 'absolute', bottom: 10, left: 10, zIndex: 3 }}
256
+ onClick={(e) => e.stopPropagation()}
257
+ >
258
+ <GalleryCounter current={photoIndex + 1} total={car.photos} light onClick={() => setViewerOpen(true)} />
259
+ </div>
260
+ {/* bottom-right: eye/heart activity pill — real stats only (Figma "Push Chips / Likes", node 297:1037) */}
261
+ <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
262
+ <GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
263
+ </div>
264
+ </div>
265
+ {/* padding-top: 0 (was 8px) + marginTop: -5 — not just 0, the ask was 0 AND 5px higher
266
+ still than that zero-padding baseline, so a negative margin pulls the whole block up
267
+ past where padding-top:0 alone would leave it. Bottom/side padding (8px/18px)
268
+ untouched. */}
269
+ <div style={{ padding: '0 18px 8px', marginTop: -3, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
270
+ <div>
271
+ <div className="md-title" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
272
+ {car.make} {car.model}
273
+ </div>
274
+ <div className="md-body-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
275
+ {car.trim}
276
+ </div>
277
+ </div>
278
+ <SpecLine items={cardSpec(car)} oneLine />
279
+ <div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
280
+ <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
281
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
282
+ {/* every tab's hero stacked in one grid cell (.cc-hero-stack), only the active one
283
+ visible, bottom-aligned — Kaufen's struck list price sits on its own line above
284
+ the price, so without this the row (and the card) grew/shrank by that line on
285
+ every tab switch. Same trick as .cc-terms-stack below. */}
286
+ <div className="cc-hero-stack">
287
+ {paymentModes.map((m) => (
288
+ <div
289
+ key={m}
290
+ className={'cc-hero-slot' + (m === mode ? '' : ' cc-hero-slot--hidden')}
291
+ aria-hidden={m === mode ? undefined : true}
292
+ >
293
+ <PriceHero car={car} mode={m} t={t} alwaysShowUnit />
294
+ </div>
295
+ ))}
296
+ </div>
297
+ <PriceEvalCol car={car} t={t} />
298
+ </div>
299
+ {/* same fallback CardCtaRow already uses below: pages that haven't wired a real
300
+ "Rate anpassen" sheet yet still get a working "Details" link (opens the car).
301
+ Every tab's terms line is rendered into the same grid cell (.cc-terms-stack) and
302
+ only the active one is visible — the cell is as tall as the tallest line, so the
303
+ card keeps one height across Finanzierung/Leasing/Kaufen (Leasing's longer line
304
+ wraps to 2 lines on narrow phones). No change to the visible line itself. */}
305
+ <div className="cc-terms-stack">
306
+ {paymentModes.map((m) => (
307
+ <div
308
+ key={m}
309
+ className={'cc-terms-slot' + (m === mode ? '' : ' cc-terms-slot--hidden')}
310
+ aria-hidden={m === mode ? undefined : true}
311
+ >
312
+ <PriceTerms
313
+ car={car}
314
+ mode={m}
315
+ onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
316
+ onShowDetails={onShowFinancingDetails ? () => onShowFinancingDetails(car, m) : undefined}
317
+ t={t}
318
+ />
319
+ </div>
320
+ ))}
321
+ </div>
322
+ </div>
323
+ {/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
324
+ rendered, with real fallbacks the page can override once it wires its own
325
+ handlers (e.g. the "Rate anpassen" FinanceDetailsModal sheet). Never hidden. */}
326
+ <CardCtaRow
327
+ car={car}
328
+ mode={mode}
329
+ onRequestOffer={onRequestOffer || (() => onOpen(car))}
330
+ onCall={onCall || callDealerSalesPhone}
331
+ onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
332
+ t={t}
333
+ />
334
+ <EcoLine
335
+ t={t}
336
+ car={car}
337
+ style={{
338
+ marginTop: 'auto',
339
+ borderTop: 'none',
340
+ fontFamily: 'Arial, Helvetica, Arimo, sans-serif',
341
+ fontStyle: 'normal',
342
+ fontWeight: 400,
343
+ fontSize: 11,
344
+ lineHeight: '11px',
345
+ textAlign: 'justify',
346
+ color: '#0e2238',
347
+ }}
348
+ />
349
+ {/* legacy VehicleSmallCard's "SeoText" footer line (full spec + equipment list) —
350
+ renders nothing when the caller hasn't populated car.seoText. */}
351
+ <SeoLine car={car} style={{ marginTop: 0 }} />
352
+ {adminAction && (
353
+ <div style={{ marginTop: 8 }}>
354
+ <Button
355
+ variant="outlined"
356
+ block
357
+ onClick={(e) => {
358
+ e.stopPropagation();
359
+ adminAction.onClick();
360
+ }}
361
+ >
362
+ {adminAction.label}
363
+ </Button>
364
+ </div>
365
+ )}
366
+ </div>
367
+ {viewerOpen && (
368
+ <div onClick={(e) => e.stopPropagation()}>
369
+ <VdpViewer
370
+ images={car.galleryLarge || car.gallery}
371
+ start={photoIndex}
372
+ onClose={() => setViewerOpen(false)}
373
+ t={t}
374
+ phone={DEALER_SALES_PHONE}
375
+ onCall={onCall || callDealerSalesPhone}
376
+ onEmail={onRequestOffer || (() => onOpen(car))}
377
+ />
378
+ </div>
379
+ )}
380
+ </article>
381
+ );
382
+ };
383
+
384
+ export default CarCard;