@bytebrand/fe-ui-core-autobahn 1.0.113 → 1.0.114
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 +50 -3
- package/cards/CarRow.tsx +53 -4
- package/cards/cardParts.tsx +23 -4
- package/chrome/TopBar.tsx +8 -3
- package/locales/de/autobahn.json +6 -1
- package/locales/en/autobahn.json +6 -1
- package/package.json +1 -1
- package/theme/md3.css +116 -40
- package/ui/CardCarousel.tsx +12 -1
- package/vdp/VdpModules.tsx +4069 -4050
- package/vdp/VdpViewer.tsx +97 -12
package/cards/CarCard.tsx
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// page, landing pages and "similar cars" grids.
|
|
7
7
|
//
|
|
8
8
|
// Pure / presentational: all data comes via the `AutobahnCar` prop, all behaviour
|
|
9
|
-
// via injected callbacks. No store
|
|
10
|
-
//
|
|
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.
|
|
11
13
|
//
|
|
12
14
|
// Differs from the design kit on purpose:
|
|
13
15
|
// - favorite state is INJECTED (isFavorite + onToggleFavorite) instead of the
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
// - the needs-data deal signals (DropChip / SaveChip) only appear when real
|
|
16
18
|
// `drop` / `savings` data is present.
|
|
17
19
|
|
|
18
|
-
import React, { useState } from 'react';
|
|
20
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
19
21
|
import { TranslationFunction } from '../types';
|
|
20
22
|
|
|
21
23
|
import { CardCarousel, IconButton, SaveChip, Button } from '../ui';
|
|
@@ -53,6 +55,20 @@ export interface CarCardProps {
|
|
|
53
55
|
onOpen: (car: AutobahnCar) => void;
|
|
54
56
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
55
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;
|
|
56
72
|
/** i18n translate fn (drilled from the page). */
|
|
57
73
|
t: TranslationFunction;
|
|
58
74
|
/**
|
|
@@ -90,6 +106,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
90
106
|
onToggleFavorite,
|
|
91
107
|
onOpen,
|
|
92
108
|
onMediaEnter,
|
|
109
|
+
eagerMedia,
|
|
93
110
|
t,
|
|
94
111
|
adminAction,
|
|
95
112
|
onRequestOffer,
|
|
@@ -100,6 +117,35 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
100
117
|
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
101
118
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
102
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]);
|
|
103
149
|
// as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
|
|
104
150
|
// undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
|
|
105
151
|
const asGoodAsNew = car.asGoodAsNew !== false;
|
|
@@ -119,6 +165,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
119
165
|
>
|
|
120
166
|
<div
|
|
121
167
|
className="cc-media"
|
|
168
|
+
ref={mediaRef}
|
|
122
169
|
style={{ position: 'relative' }}
|
|
123
170
|
onMouseEnter={onMediaEnter}
|
|
124
171
|
onTouchStart={onMediaEnter}
|
package/cards/CarRow.tsx
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// CTA stop propagation so they don't also trigger the card open.
|
|
7
7
|
//
|
|
8
8
|
// Pure / presentational: all data via the `AutobahnCar` prop, all behaviour via
|
|
9
|
-
// injected callbacks. No store
|
|
9
|
+
// injected callbacks. No store/API access — the one guarded `window`-adjacent read is
|
|
10
|
+
// IntersectionObserver (see `eagerMedia` below), feature-checked so a non-browser render
|
|
11
|
+
// (tests without a polyfill) degrades to a no-op, not a crash. Renders correctly only
|
|
10
12
|
// inside a `<div className="md3">` subtree.
|
|
11
13
|
//
|
|
12
14
|
// Differs from the design kit on purpose:
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
// no className prop) so the scoped `.pp-cta` margin / responsive rules and the
|
|
19
21
|
// `.pp-cta + .pp-activity` adjacency in md3.css still apply.
|
|
20
22
|
|
|
21
|
-
import React, { useState } from 'react';
|
|
23
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
22
24
|
import { TranslationFunction } from '../types';
|
|
23
25
|
|
|
24
26
|
import { CardCarousel, IconButton, SaveChip, Button, Icon, StatChip } from '../ui';
|
|
@@ -53,6 +55,16 @@ export interface CarRowProps {
|
|
|
53
55
|
onOpen: (car: AutobahnCar) => void;
|
|
54
56
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
55
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. See CarCard's identically-named prop for the full rationale —
|
|
61
|
+
* same trade-off applies here (SRP's list view is the other consumer of this same concern).
|
|
62
|
+
* Safe to enable even on an effectively-unbounded list AS LONG AS the caller's `onMediaEnter`
|
|
63
|
+
* itself caps concurrent fetches (e.g. SearchNextPage.tsx's loadCarImages queues through
|
|
64
|
+
* runQueuedImageLoad) — this component has no opinion on that, it only decides WHEN to call
|
|
65
|
+
* onMediaEnter, never how many may run at once.
|
|
66
|
+
*/
|
|
67
|
+
eagerMedia?: boolean;
|
|
56
68
|
/** i18n translate fn (drilled from the page). */
|
|
57
69
|
t: TranslationFunction;
|
|
58
70
|
/**
|
|
@@ -75,12 +87,39 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
75
87
|
onToggleFavorite,
|
|
76
88
|
onOpen,
|
|
77
89
|
onMediaEnter,
|
|
90
|
+
eagerMedia,
|
|
78
91
|
t,
|
|
79
92
|
adminAction,
|
|
80
93
|
onShowFinancingDetails,
|
|
81
94
|
}) => {
|
|
82
95
|
const [mode, setMode] = useState<PaymentMode>('financing');
|
|
83
96
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
97
|
+
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
98
|
+
// always call the LATEST onMediaEnter without re-arming the observer on every render (the
|
|
99
|
+
// page callers construct a fresh closure each render) — the effect itself only needs to run
|
|
100
|
+
// once per mount. Identical pattern to CarCard.tsx — see its own comment for the rationale.
|
|
101
|
+
const onMediaEnterRef = useRef(onMediaEnter);
|
|
102
|
+
onMediaEnterRef.current = onMediaEnter;
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!eagerMedia || typeof IntersectionObserver === 'undefined') return undefined;
|
|
105
|
+
const el = mediaRef.current;
|
|
106
|
+
if (!el) return undefined;
|
|
107
|
+
let fired = false;
|
|
108
|
+
const observer = new IntersectionObserver(
|
|
109
|
+
(entries) => {
|
|
110
|
+
entries.forEach((entry) => {
|
|
111
|
+
if (entry.isIntersecting && !fired) {
|
|
112
|
+
fired = true;
|
|
113
|
+
onMediaEnterRef.current && onMediaEnterRef.current();
|
|
114
|
+
observer.disconnect();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
{ rootMargin: '200px 0px', threshold: 0 },
|
|
119
|
+
);
|
|
120
|
+
observer.observe(el);
|
|
121
|
+
return () => observer.disconnect();
|
|
122
|
+
}, [eagerMedia]);
|
|
84
123
|
// as-good-as-new gate (mileage < 1,000 km AND first registration unknown or < 12 months) —
|
|
85
124
|
// undefined (older AutobahnCar producer not yet updated) fails OPEN, keeping prior behaviour.
|
|
86
125
|
const asGoodAsNew = car.asGoodAsNew !== false;
|
|
@@ -109,7 +148,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
109
148
|
onClick={() => onOpen(car)}
|
|
110
149
|
style={{ background: 'var(--md-sc-lowest)' }}
|
|
111
150
|
>
|
|
112
|
-
<div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
151
|
+
<div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
113
152
|
{/* 'auto': desktop picks cover/contain per photo (crops only when it wouldn't lose too
|
|
114
153
|
much of the shot); below 601px this is identical to 'contain', so mobile is unchanged. */}
|
|
115
154
|
<CardCarousel images={car.gallery} height="100%" fit="auto" onChange={setPhotoIndex} />
|
|
@@ -173,7 +212,17 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
173
212
|
<GalleryBadges car={car} t={t} />
|
|
174
213
|
</div>
|
|
175
214
|
{/* desktop: single row — light "1|N" photo counter + a combined eye/heart chip, no
|
|
176
|
-
availability/Geprüft pill (not present in the Figma "mcard" export).
|
|
215
|
+
availability/Geprüft pill (not present in the Figma "mcard" export).
|
|
216
|
+
Same 3-state policy as the mobile pills above (GalleryCounter/GalleryBadges,
|
|
217
|
+
cardParts.tsx) and CarCard's GalleryCounter/GalleryActivityPill, just inlined here
|
|
218
|
+
instead of reusing those components (different chip markup — one shared "N|M" +
|
|
219
|
+
eye+heart pill instead of three separate ones):
|
|
220
|
+
- single-image: car.photos is the real, eagerly-known count (not the lazily-loaded
|
|
221
|
+
gallery array length) — accurate even before the extra photos have loaded.
|
|
222
|
+
- loading: viewsCount/favoritesCount are undefined until stats resolve for this car
|
|
223
|
+
— hidden, no skeleton, same as everywhere else.
|
|
224
|
+
- zero-value: a real 0 is intentionally treated the same as "not loaded" — hidden,
|
|
225
|
+
not shown as "eye 0" / "heart 0" (noise on a dense results list otherwise). */}
|
|
177
226
|
<div
|
|
178
227
|
className="hide-mobile"
|
|
179
228
|
style={{ position: 'absolute', bottom: 20, left: 12, zIndex: 3, display: 'flex', alignItems: 'center', gap: 6 }}
|
package/cards/cardParts.tsx
CHANGED
|
@@ -203,6 +203,7 @@ export const DropChip: React.FunctionComponent<DropChipProps> = ({ amount, t })
|
|
|
203
203
|
color: 'var(--md-good)',
|
|
204
204
|
fontSize: 10.5,
|
|
205
205
|
fontWeight: 700,
|
|
206
|
+
lineHeight: '14px',
|
|
206
207
|
boxShadow: 'var(--md-e1)',
|
|
207
208
|
whiteSpace: 'nowrap',
|
|
208
209
|
}}
|
|
@@ -230,7 +231,7 @@ const dealPill: React.CSSProperties = {
|
|
|
230
231
|
export const PercentChip: React.FunctionComponent<{ pct?: number }> = ({ pct }) => {
|
|
231
232
|
if (pct == null || pct <= 0) return null;
|
|
232
233
|
return (
|
|
233
|
-
<span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff' }}>
|
|
234
|
+
<span className="deal-chip deal-chip--percent" style={{ ...dealPill, background: 'var(--md-error)', color: '#fff', lineHeight: '14px' }}>
|
|
234
235
|
−{pct}%
|
|
235
236
|
</span>
|
|
236
237
|
);
|
|
@@ -835,7 +836,15 @@ export interface GalleryCounterProps {
|
|
|
835
836
|
* Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
|
|
836
837
|
* `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
|
|
837
838
|
* class — same dark translucent pill, just with the maximize icon instead of the image icon.
|
|
838
|
-
*
|
|
839
|
+
*
|
|
840
|
+
* States (shared policy across GalleryCounter, the carousel's own arrows/dots in CardCarousel,
|
|
841
|
+
* and GalleryActivityPill below — see its own doc comment for the other two):
|
|
842
|
+
* - single-image: `total` is the car's REAL, eagerly-known photo count (car.photos, from the
|
|
843
|
+
* search payload — not the lazily-loaded gallery array length), so this is accurate even
|
|
844
|
+
* before any image beyond the first has actually loaded. total<=1 → hidden: nothing to expand
|
|
845
|
+
* into. Never a "1|1" placeholder.
|
|
846
|
+
* - loading: while `total` itself is still unknown (car not yet resolved) the caller simply
|
|
847
|
+
* hasn't rendered this component yet — there is no separate in-between skeleton state here.
|
|
839
848
|
*/
|
|
840
849
|
export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
|
|
841
850
|
if (total <= 1) return null;
|
|
@@ -864,8 +873,18 @@ export interface GalleryActivityPillProps {
|
|
|
864
873
|
|
|
865
874
|
/**
|
|
866
875
|
* Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
|
|
867
|
-
* Likes", node 297:1037). Each half renders independently
|
|
868
|
-
*
|
|
876
|
+
* Likes", node 297:1037). Each half renders independently.
|
|
877
|
+
*
|
|
878
|
+
* States (chosen ONE policy, applied identically to both halves):
|
|
879
|
+
* - loading: `viewsCount`/`favoritesCount` are `undefined` until the async stats call
|
|
880
|
+
* (UserActionsAPI 'stats/viewers') resolves for this car. No skeleton — the half (or the
|
|
881
|
+
* whole pill, if both are still pending) simply doesn't render yet, same "hidden, not a
|
|
882
|
+
* placeholder" discipline as GalleryCounter/CardCarousel's arrows above.
|
|
883
|
+
* - zero-value: a REAL `0` is treated the same as "not there" — hidden, not shown as "👁 0".
|
|
884
|
+
* Deliberate choice over spelling out "0" (the other option the ticket allows): on a dense
|
|
885
|
+
* grid of fresh listings a "👁 0 ❤ 0" pill on every card is noise, not signal. `!viewsCount`/
|
|
886
|
+
* `!favoritesCount` below intentionally don't distinguish real-0 from not-yet-loaded — both
|
|
887
|
+
* read as "nothing worth showing (yet)", which is the same outcome either way.
|
|
869
888
|
*/
|
|
870
889
|
export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
|
|
871
890
|
if (!viewsCount && !favoritesCount) return null;
|
package/chrome/TopBar.tsx
CHANGED
|
@@ -110,9 +110,14 @@ export interface TopBarProps {
|
|
|
110
110
|
* number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
|
|
111
111
|
* href here once one is confirmed. */
|
|
112
112
|
phoneHref?: string;
|
|
113
|
-
/** mobile-only
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
/** mobile-only (the CSS that actually moves the header, `.topbar--hidden`, only exists inside
|
|
114
|
+
* the `@media (max-width: 600px)` block — desktop keeps this prop's `hidden` state but never
|
|
115
|
+
* renders it): slide the header out of view on scroll-down, back in on scroll-up. On the
|
|
116
|
+
* car-detail page this is additionally coordinated with the VDP sticky offer bottom bar (hides
|
|
117
|
+
* well before that bar would ever dock — see STICKY_SHOW_AT in VehicleDetailedNextPage.tsx).
|
|
118
|
+
* Off by default — the car-detail page (topBarVariant 'conversion'), the homepage ('/') and
|
|
119
|
+
* the SRP list page ('/search') opt in (see App.tsx); other pages keep the header always
|
|
120
|
+
* docked. */
|
|
116
121
|
autoHideOnScroll?: boolean;
|
|
117
122
|
}
|
|
118
123
|
|
package/locales/de/autobahn.json
CHANGED
|
@@ -382,6 +382,7 @@
|
|
|
382
382
|
},
|
|
383
383
|
"rate": {
|
|
384
384
|
"upTo": "Rate bis",
|
|
385
|
+
"upToPlaceholder": "Rate bis, z. B. 350 €/mtl.",
|
|
385
386
|
"perMonth": "/mtl.",
|
|
386
387
|
"downPayment": "Anzahlung",
|
|
387
388
|
"none": "keine",
|
|
@@ -389,6 +390,10 @@
|
|
|
389
390
|
"months": "{{months}} Monate",
|
|
390
391
|
"months_plural": "{{months}} Monate"
|
|
391
392
|
},
|
|
393
|
+
"mileage": {
|
|
394
|
+
"upTo": "km bis",
|
|
395
|
+
"upToPlaceholder": "km bis, z. B. 50000 km"
|
|
396
|
+
},
|
|
392
397
|
"consumption": {
|
|
393
398
|
"unitLabel": "l / 100 km"
|
|
394
399
|
},
|
|
@@ -723,7 +728,7 @@
|
|
|
723
728
|
"leaseVehiclePrice": "Anschaffungspreis¹",
|
|
724
729
|
"leaseDownPayment": "Einmalige Sonderzahlung",
|
|
725
730
|
"finalPaymentDue": "Schlusszahlung (fällig am Vertragsende)",
|
|
726
|
-
"residualValue": "
|
|
731
|
+
"residualValue": "Verpflichtender Kaufpreis am Vertragsende",
|
|
727
732
|
"rateSumLabel": "Summe der Leasingraten ({{term}} × {{amount}} €)",
|
|
728
733
|
"addServicesRowLease": "Rundum-Sorglos-Paket (Einmalzahlung)",
|
|
729
734
|
"subtotalWithPackage": "Gesamt inkl. Sonderzahl. & Paket",
|
package/locales/en/autobahn.json
CHANGED
|
@@ -382,6 +382,7 @@
|
|
|
382
382
|
},
|
|
383
383
|
"rate": {
|
|
384
384
|
"upTo": "Rate up to",
|
|
385
|
+
"upToPlaceholder": "Rate up to, e.g. 350 €/month",
|
|
385
386
|
"perMonth": "/month",
|
|
386
387
|
"downPayment": "Down payment",
|
|
387
388
|
"none": "none",
|
|
@@ -389,6 +390,10 @@
|
|
|
389
390
|
"months": "{{months}} months",
|
|
390
391
|
"months_plural": "{{months}} months"
|
|
391
392
|
},
|
|
393
|
+
"mileage": {
|
|
394
|
+
"upTo": "Mileage up to",
|
|
395
|
+
"upToPlaceholder": "Mileage up to, e.g. 50000 km"
|
|
396
|
+
},
|
|
392
397
|
"consumption": {
|
|
393
398
|
"unitLabel": "l / 100 km"
|
|
394
399
|
},
|
|
@@ -723,7 +728,7 @@
|
|
|
723
728
|
"leaseVehiclePrice": "Acquisition price¹",
|
|
724
729
|
"leaseDownPayment": "One-time special payment",
|
|
725
730
|
"finalPaymentDue": "Final payment (due at contract end)",
|
|
726
|
-
"residualValue": "
|
|
731
|
+
"residualValue": "Mandatory purchase price at contract end",
|
|
727
732
|
"rateSumLabel": "Sum of leasing installments ({{term}} × {{amount}} €)",
|
|
728
733
|
"addServicesRowLease": "Complete care package (one-time payment)",
|
|
729
734
|
"subtotalWithPackage": "Total incl. special payment & package",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/fe-ui-core-autobahn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.114",
|
|
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",
|
package/theme/md3.css
CHANGED
|
@@ -453,6 +453,44 @@ line-height: 31px;
|
|
|
453
453
|
width: 14px;
|
|
454
454
|
height: 14px;
|
|
455
455
|
}
|
|
456
|
+
/* Figma spec for the rating chip in its two "Guter Preis" locations — the VDP header
|
|
457
|
+
(VdpHeaderSummary, .vdp-headrow) and the SRP list-card's price column (CarRow.tsx,
|
|
458
|
+
.listcard-price, both its mobile Finanzierung row and desktop Kaufen row share this one class).
|
|
459
|
+
Scoped to THESE locations only, not every .statchip site-wide (e.g. the price-history "drop"
|
|
460
|
+
chip a few sections down reuses tone="good" for a completely different purpose — a €-amount,
|
|
461
|
+
not a price rating — and keeps the theme-aware --md-good-container tokens untouched). min-width
|
|
462
|
+
(not a hard width) + white-space:nowrap: 108px is tight for icon+"Guter Preis"+⁴ on one line,
|
|
463
|
+
so this grows past it instead of wrapping/clipping rather than clamping to a width that doesn't fit. */
|
|
464
|
+
.md3 .vdp-headrow .statchip,
|
|
465
|
+
.md3 .listcard-price .statchip {
|
|
466
|
+
min-width: 108px;
|
|
467
|
+
height: 27px;
|
|
468
|
+
border-radius: 11px;
|
|
469
|
+
font-family: 'Arial';
|
|
470
|
+
font-weight: 700;
|
|
471
|
+
font-size: 12px;
|
|
472
|
+
line-height: 14px;
|
|
473
|
+
text-align: center;
|
|
474
|
+
white-space: nowrap;
|
|
475
|
+
justify-content: center;
|
|
476
|
+
}
|
|
477
|
+
.md3 .vdp-headrow .statchip sup,
|
|
478
|
+
.md3 .listcard-price .statchip sup {
|
|
479
|
+
position: relative;
|
|
480
|
+
top: -3px;
|
|
481
|
+
left: -4px;
|
|
482
|
+
font-size: 9px;
|
|
483
|
+
line-height: 9px;
|
|
484
|
+
}
|
|
485
|
+
/* 'good' tone here is specifically the "Guter Preis" rating (same canonical green as the
|
|
486
|
+
fin-eval-pill "good" tier elsewhere on this page) — literal color, not the --md-good-container
|
|
487
|
+
token, matching that other pill's own literal-color treatment. --warn (Fairer Preis) is
|
|
488
|
+
untouched, still tokenized. */
|
|
489
|
+
.md3 .vdp-headrow .statchip--good,
|
|
490
|
+
.md3 .listcard-price .statchip--good {
|
|
491
|
+
color: rgb(22, 138, 69);
|
|
492
|
+
background: color-mix(in srgb, rgb(22, 138, 69) 16%, transparent);
|
|
493
|
+
}
|
|
456
494
|
|
|
457
495
|
/* small image label (views, photo count) */
|
|
458
496
|
.md3 .imglabel {
|
|
@@ -3747,7 +3785,7 @@ line-height: 31px;
|
|
|
3747
3785
|
min-width: 0;
|
|
3748
3786
|
}
|
|
3749
3787
|
/* "NEU" badge on car cards (legacy new-car label). */
|
|
3750
|
-
.md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
|
|
3788
|
+
.md3 .neu-badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--md-r-full); background: var(--md-primary); color: #fff; font-size: 10.5px; font-weight: 700; line-height: 14px; letter-spacing: .3px; white-space: nowrap; box-shadow: var(--md-e1); }
|
|
3751
3789
|
/* "⚡ Angebot" badge — dark neutral pill above the gallery (card mockup), distinct from NEU. */
|
|
3752
3790
|
.md3 .offer-badge { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px; border-radius: var(--md-r-full); background: var(--md-on-surface); color: #fff; font-size: 11px; font-weight: 600; white-space: nowrap; }
|
|
3753
3791
|
.md3 .offer-badge .lucide { width: 13px; height: 13px; }
|
|
@@ -5164,37 +5202,50 @@ line-height: 31px;
|
|
|
5164
5202
|
width: 112px;
|
|
5165
5203
|
flex: none;
|
|
5166
5204
|
}
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5205
|
+
/* Figma spec, now identical across all three "Guter Preis" pill locations (CarCard's own
|
|
5206
|
+
.cc-price, incl. the VDP "Ähnliche Fahrzeuge" rail; .fin-calc-card; .fin-modal) — one shared
|
|
5207
|
+
rule instead of three copies. `background`/`color` are deliberately NOT set here: they stay
|
|
5208
|
+
driven by the tier-color inline style each render site sets (see PriceEvalCol in
|
|
5209
|
+
cardParts.tsx / FinanceCalculator.tsx / VdpModules.tsx, all of which already resolve to the
|
|
5210
|
+
canonical rgb(22,138,69) for the 'good' tier) — a `!important` used to hardcode this pill's
|
|
5211
|
+
background to a fixed #dff3e5 here, silently overriding that inline value regardless of tier. */
|
|
5212
|
+
.md3 .cc-price .fin-eval-pill,
|
|
5213
|
+
.md3 .fin-calc-card .fin-eval-pill,
|
|
5214
|
+
.md3 .fin-modal .fin-eval-pill {
|
|
5215
|
+
font-family: 'Arial';
|
|
5170
5216
|
font-weight: 700;
|
|
5171
5217
|
font-size: 12px;
|
|
5172
5218
|
line-height: 14px;
|
|
5173
5219
|
text-align: center;
|
|
5174
5220
|
}
|
|
5175
|
-
.md3 .cc-price .fin-eval-pill--good
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
width:
|
|
5221
|
+
.md3 .cc-price .fin-eval-pill--good,
|
|
5222
|
+
.md3 .fin-calc-card .fin-eval-pill--good,
|
|
5223
|
+
.md3 .fin-modal .fin-eval-pill--good {
|
|
5224
|
+
width: 108px;
|
|
5179
5225
|
height: 27px;
|
|
5226
|
+
border-radius: 11px;
|
|
5180
5227
|
box-sizing: border-box;
|
|
5228
|
+
display: flex;
|
|
5229
|
+
align-items: center;
|
|
5181
5230
|
justify-content: center;
|
|
5182
5231
|
}
|
|
5183
5232
|
/* footnote 4 ("Top-Preis" claim) — .fin-eval-pill is a flex container, so vertical-align has
|
|
5184
5233
|
no effect on the sup (it's a flex item, not inline content); position:relative/top raises
|
|
5185
|
-
it instead, same technique as the hero price's own 1/2/3 footnote markers.
|
|
5186
|
-
|
|
5187
|
-
FinanceDetailsModal's embedded mobile rate sheet (.fin-modal). */
|
|
5234
|
+
it instead, same technique as the hero price's own 1/2/3 footnote markers. Same -3px lift now
|
|
5235
|
+
shared by all three locations above. */
|
|
5188
5236
|
.md3 .cc-price .fin-eval-pill sup,
|
|
5189
5237
|
.md3 .fin-calc-card .fin-eval-pill sup,
|
|
5190
5238
|
.md3 .fin-modal .fin-eval-pill sup {
|
|
5191
5239
|
position: relative;
|
|
5192
|
-
top:
|
|
5240
|
+
top: -3px;
|
|
5193
5241
|
left: -4px;
|
|
5194
5242
|
font-size: 9px;
|
|
5195
5243
|
line-height: 9px;
|
|
5196
5244
|
}
|
|
5197
5245
|
.md3 .cc-price .fin-eval-bar {
|
|
5246
|
+
/* matches the pill's own fixed 108px right above (both sit in the same 112px .fin-eval-col),
|
|
5247
|
+
so the two stay visually aligned instead of the bar stretching to the full column width. */
|
|
5248
|
+
width: 108px;
|
|
5198
5249
|
height: 7px;
|
|
5199
5250
|
}
|
|
5200
5251
|
.md3 .cc-price .fin-eval-seg:first-child {
|
|
@@ -5424,14 +5475,8 @@ line-height: 31px;
|
|
|
5424
5475
|
width: 112px;
|
|
5425
5476
|
justify-self: end;
|
|
5426
5477
|
}
|
|
5427
|
-
/*
|
|
5428
|
-
|
|
5429
|
-
.md3 .fin-calc-card .fin-eval-pill--good {
|
|
5430
|
-
width: 100%;
|
|
5431
|
-
display: flex;
|
|
5432
|
-
align-items: center;
|
|
5433
|
-
justify-content: center;
|
|
5434
|
-
}
|
|
5478
|
+
/* .fin-calc-card/.fin-modal's own "Guter Preis" pill text/box/sup sizing now lives in the one
|
|
5479
|
+
shared rule (with .cc-price) further up this file, near .cc-price's own .fin-eval-* rules. */
|
|
5435
5480
|
/* .fin-calc-card's own "Rate anpassen" panel is compact — the mobile-sheet/modal padding
|
|
5436
5481
|
(22/28px top, 20px bottom) is too roomy here. */
|
|
5437
5482
|
.md3 .fin-calc-card .fin-toggle-final,
|
|
@@ -6662,13 +6707,36 @@ line-height: 31px;
|
|
|
6662
6707
|
}
|
|
6663
6708
|
.md3 .srp-bar {
|
|
6664
6709
|
position: sticky;
|
|
6665
|
-
|
|
6710
|
+
/* was 64px (docked under the always-visible header) — now 0: once the header slides away
|
|
6711
|
+
(autoHideOnScroll, TopBar.tsx) this bar takes its place at the very top instead of leaving
|
|
6712
|
+
a gap, and sits flush under it again the moment the header slides back in. */
|
|
6713
|
+
top: 0px;
|
|
6666
6714
|
z-index: 20;
|
|
6667
|
-
background:
|
|
6715
|
+
background: #fff;
|
|
6668
6716
|
padding: 8px 0;
|
|
6669
6717
|
margin: 0 0 8px;
|
|
6670
6718
|
box-shadow: 0 6px 8px -8px rgba(0, 0, 0, 0.3);
|
|
6671
6719
|
}
|
|
6720
|
+
/* full-bleed white to the true viewport edges, WITHOUT moving .srp-bar's own text — it stays
|
|
6721
|
+
inside .srp-main's own side padding, this pseudo-element just paints white past it on both
|
|
6722
|
+
sides so no gap of page background shows while the bar is sticky. NOT a box-shadow spread
|
|
6723
|
+
(that bleeds in all 4 directions equally, including a huge slab below the bar covering the
|
|
6724
|
+
cards underneath it — that was tried and broke the page) — an absolutely-positioned layer
|
|
6725
|
+
confined to top:0/bottom:0 of .srp-bar's own box (position:sticky is a containing block for
|
|
6726
|
+
it, same as position:relative) stays exactly as tall as the bar, only wider. z-index:-1 is
|
|
6727
|
+
scoped to .srp-bar's own stacking context (it has z-index:20), so it paints behind the bar's
|
|
6728
|
+
text/buttons but still above the page content scrolling underneath. */
|
|
6729
|
+
.md3 .srp-bar::before {
|
|
6730
|
+
content: '';
|
|
6731
|
+
position: absolute;
|
|
6732
|
+
top: 0;
|
|
6733
|
+
bottom: 0;
|
|
6734
|
+
left: 50%;
|
|
6735
|
+
width: 100vw;
|
|
6736
|
+
transform: translateX(-50%);
|
|
6737
|
+
background: #fff;
|
|
6738
|
+
z-index: -1;
|
|
6739
|
+
}
|
|
6672
6740
|
.md3 .field-grid-2 {
|
|
6673
6741
|
grid-template-columns: 1fr;
|
|
6674
6742
|
gap: 10px;
|
|
@@ -8703,14 +8771,10 @@ color: #005CFF;
|
|
|
8703
8771
|
gap: 16px;
|
|
8704
8772
|
grid-area: evalcol;
|
|
8705
8773
|
}
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
height: 27px;
|
|
8711
|
-
box-sizing: border-box;
|
|
8712
|
-
justify-content: center;
|
|
8713
|
-
}
|
|
8774
|
+
/* box sizing for this pill (all three of its locations) lives in the shared, higher-specificity
|
|
8775
|
+
.cc-price/.fin-calc-card/.fin-modal rule near the top of this file — this generic, unscoped
|
|
8776
|
+
copy used to ALSO force `background: #dff3e5 !important`, silently overriding every tier's
|
|
8777
|
+
real inline color (the 'good' tier's canonical rgb(22,138,69) included) at this breakpoint. */
|
|
8714
8778
|
.md3 .fin-eval-bar {
|
|
8715
8779
|
height: 7px;
|
|
8716
8780
|
}
|
|
@@ -10334,10 +10398,14 @@ color: #005CFF;
|
|
|
10334
10398
|
}
|
|
10335
10399
|
.md3 .vdp-photosheet {
|
|
10336
10400
|
position: absolute;
|
|
10337
|
-
left: 0
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
|
|
10401
|
+
/* was `left/right/bottom: 0` + `height: 100vh` — over-constrained (top was the free value, so
|
|
10402
|
+
it was computed as containerHeight - 100vh) and, on the affected mobile browsers, 100vh can
|
|
10403
|
+
exceed the CURRENT visible height (e.g. address bar still shown), which solved to a NEGATIVE
|
|
10404
|
+
top: the sheet's top edge — its own header, with a close button — rendered above y=0 of this
|
|
10405
|
+
already-safely-sized fixed overlay (see VdpViewer.tsx's own top-of-file portal comment), i.e.
|
|
10406
|
+
behind the browser's UI. `inset: 0` instead just fills the (correctly-sized) positioned
|
|
10407
|
+
ancestor exactly, no viewport unit involved. */
|
|
10408
|
+
inset: 0;
|
|
10341
10409
|
z-index: 211;
|
|
10342
10410
|
display: flex;
|
|
10343
10411
|
flex-direction: column;
|
|
@@ -10356,7 +10424,9 @@ color: #005CFF;
|
|
|
10356
10424
|
align-items: center;
|
|
10357
10425
|
justify-content: center;
|
|
10358
10426
|
min-height: 88px;
|
|
10359
|
-
|
|
10427
|
+
/* safe-area-aware: with the `inset: 0` fix above this sits flush against the true top edge of
|
|
10428
|
+
the fullscreen overlay — same notch/status-bar territory as the main viewer's own header. */
|
|
10429
|
+
padding-top: max(10px, env(safe-area-inset-top));
|
|
10360
10430
|
touch-action: none;
|
|
10361
10431
|
}
|
|
10362
10432
|
.md3 .vdp-photosheet__handle {
|
|
@@ -10371,7 +10441,8 @@ color: #005CFF;
|
|
|
10371
10441
|
}
|
|
10372
10442
|
.md3 .vdp-photosheet__close {
|
|
10373
10443
|
position: absolute;
|
|
10374
|
-
|
|
10444
|
+
/* safe-area-aware: in the landscape layout the notch can rotate onto this side. */
|
|
10445
|
+
left: max(12px, env(safe-area-inset-left));
|
|
10375
10446
|
top: 50%;
|
|
10376
10447
|
transform: translateY(-50%);
|
|
10377
10448
|
}
|
|
@@ -10385,7 +10456,8 @@ color: #005CFF;
|
|
|
10385
10456
|
}
|
|
10386
10457
|
.md3 .vdp-photosheet__titles {
|
|
10387
10458
|
flex: none;
|
|
10388
|
-
|
|
10459
|
+
/* safe-area-aware left/right: landscape can rotate the notch onto either side. */
|
|
10460
|
+
padding: 0 max(16px, env(safe-area-inset-right)) 12px max(16px, env(safe-area-inset-left));
|
|
10389
10461
|
text-align: left;
|
|
10390
10462
|
}
|
|
10391
10463
|
.md3 .vdp-photosheet__title {
|
|
@@ -10408,7 +10480,9 @@ color: #005CFF;
|
|
|
10408
10480
|
flex: 1;
|
|
10409
10481
|
min-height: 0;
|
|
10410
10482
|
overflow-y: auto;
|
|
10411
|
-
|
|
10483
|
+
/* safe-area-aware left/right — otherwise the outer grid tiles can sit partly under a
|
|
10484
|
+
landscape-rotated notch, tappable in theory but visually cut off. */
|
|
10485
|
+
padding: 0 max(12px, env(safe-area-inset-right)) 12px max(12px, env(safe-area-inset-left));
|
|
10412
10486
|
}
|
|
10413
10487
|
.md3 .vdp-photosheet__grid {
|
|
10414
10488
|
display: grid;
|
|
@@ -10454,8 +10528,10 @@ color: #005CFF;
|
|
|
10454
10528
|
align-items: center;
|
|
10455
10529
|
justify-content: flex-start;
|
|
10456
10530
|
gap: 6px;
|
|
10457
|
-
height: 44px
|
|
10458
|
-
|
|
10531
|
+
/* was a fixed `height: 44px` — swapped for `min-height` so the safe-area bottom padding below
|
|
10532
|
+
(home indicator) grows the bar instead of pushing its text outside a clipped fixed box. */
|
|
10533
|
+
min-height: 44px;
|
|
10534
|
+
padding: 0 max(16px, env(safe-area-inset-right)) max(0px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
|
10459
10535
|
/* rgba(0,0,0,0.5) was invisible here — it darkens whatever's behind it, and the sheet's own
|
|
10460
10536
|
panel (rgb(6,11,18)) is already almost black, so a 50%-black overlay on top of near-black
|
|
10461
10537
|
reads as no change at all. #1c222c is lighter/bluer and already used elsewhere in this same
|
package/ui/CardCarousel.tsx
CHANGED
|
@@ -132,7 +132,18 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
132
132
|
))}
|
|
133
133
|
</div>
|
|
134
134
|
{/* arrows + dots only when there is actually something to switch to — with a single
|
|
135
|
-
image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls.
|
|
135
|
+
image (e.g. SRP cards before their gallery is lazy-loaded) they are dead controls.
|
|
136
|
+
States: single-image → n<=1 → hidden here (same "nothing to expand into" case as
|
|
137
|
+
GalleryCounter, cardParts.tsx). Loading → `images` starts as a 1-element array (the
|
|
138
|
+
card's main thumbnail) until the caller's lazy-load resolves, so this rule ALSO covers
|
|
139
|
+
"still loading" — arrows/dots simply aren't rendered yet, no skeleton, same discipline
|
|
140
|
+
as the other overlay controls. On touch devices this used to mean "hidden until the
|
|
141
|
+
user's first hover/touch on this exact card" (mouseenter/touchstart was the ONLY lazy-
|
|
142
|
+
load trigger) — CarCard's `eagerMedia` (see its own doc comment) starts that same load
|
|
143
|
+
on scroll-into-view instead, so this loading window is normally already closed by the
|
|
144
|
+
time the user reaches the card, without changing anything in this component. Desktop
|
|
145
|
+
hover-to-reveal (`.cardcar:hover .car-arrow`) vs. always-visible on touch is a CSS-only
|
|
146
|
+
concern — see `@media (hover: none)` in md3.css, not component state. */}
|
|
136
147
|
{n > 1 && (
|
|
137
148
|
<>
|
|
138
149
|
<button className="car-arrow car-arrow--l" aria-label="zurück" onClick={(e) => go(-1, e)}>
|