@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 +384 -354
- package/package.json +1 -1
- package/theme/md3.css +11374 -11338
- package/vdp/VdpModules.tsx +74 -27
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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;
|