@bytebrand/fe-ui-core-autobahn 1.0.113 → 1.0.115

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
@@ -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 / API / `window` access. Renders correctly
10
- // only inside a `<div className="md3">` subtree.
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}
@@ -193,7 +240,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
193
240
  <GalleryActivityPill viewsCount={car.viewsCount} favoritesCount={car.favoritesCount} />
194
241
  </div>
195
242
  </div>
196
- <div style={{ padding: '10px 18px', display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
243
+ <div style={{ padding: '8px 18px', display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
197
244
  <div>
198
245
  <div className="md-title" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
199
246
  {car.make} {car.model}
@@ -247,7 +294,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
247
294
  />
248
295
  {/* legacy VehicleSmallCard's "SeoText" footer line (full spec + equipment list) —
249
296
  renders nothing when the caller hasn't populated car.seoText. */}
250
- <SeoLine car={car} style={{ marginTop: 6 }} />
297
+ <SeoLine car={car} style={{ marginTop: 0 }} />
251
298
  {adminAction && (
252
299
  <div style={{ marginTop: 8 }}>
253
300
  <Button
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 / API / `window` access. Renders correctly only
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 }}
@@ -19,7 +19,7 @@ import { TranslationFunction } from '../types';
19
19
  import { defaultConfig } from '@bytebrand/fe-financing';
20
20
 
21
21
  import { Icon, Button } from '../ui';
22
- import { DocCheckIcon, EvalBar } from '../vdp/FinanceCalculator';
22
+ import { EvalBar } from '../vdp/FinanceCalculator';
23
23
  import { CO2_CLASSES, CO2_COLORS, TIER_KEYS, TIER_COLORS } from '../constants';
24
24
  import { AutobahnCar } from './types';
25
25
  // pure, non-React — shared with the Meta AIA marketing-image renderer (a plain Node service).
@@ -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
  );
@@ -783,37 +784,144 @@ export interface CardCtaRowProps {
783
784
  export const CardCtaRow: React.FunctionComponent<CardCtaRowProps> = ({ car, mode, onRequestOffer, onCall, onOpenRateAdjust, t }) => (
784
785
  <div
785
786
  className="card-cta-row show-mobile"
786
- style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--md-outline-variant)', paddingTop: 8 }}
787
+ style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--md-outline-variant)', paddingTop: 8 }}
787
788
  >
788
789
  <Button
789
790
  variant="filled"
790
- style={{ flex: 2 }}
791
+ style={{
792
+ // Figma width (157px) as a STARTING size, not a hard fixed one — flex-grow:1 so this
793
+ // button (and "Adjust rate" below) still fill extra row width on wider cards instead of
794
+ // leaving dead space next to the fixed 40px phone button; flex-shrink:1 lets it narrow
795
+ // gracefully (text ellipsizes via the existing .card-cta-row .mbtn overflow rule) rather
796
+ // than overflow on very narrow cards.
797
+ flex: '1 1 157px',
798
+ height: 40,
799
+ background: '#005cff',
800
+ borderRadius: 16,
801
+ padding: '0 8px',
802
+ fontFamily: 'Arial',
803
+ fontStyle: 'normal',
804
+ fontWeight: 700,
805
+ fontSize: 13,
806
+ lineHeight: '15px',
807
+ color: '#ffffff',
808
+ }}
791
809
  onClick={(e) => {
792
810
  e.stopPropagation();
793
811
  onRequestOffer();
794
812
  }}
795
813
  >
796
- <DocCheckIcon size={16} />
814
+ {/* exact design icon (single self-contained SVG) — replaces the composed lucide
815
+ file-text + check-circle badge (DocCheckIcon), which is still used as-is on VDP's own
816
+ "Angebot anfragen" buttons; this swap is scoped to the search-card CTA only.
817
+ flex:none for the same shrink reason as the "Adjust rate" icon above. */}
818
+ <svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flex: 'none' }}>
819
+ <path
820
+ d="M6.90625 3.65625H15.275L19.9062 8.2875V20.7188C19.9062 21.6162 19.1787 22.3438 18.2812 22.3438H6.90625C6.00879 22.3438 5.28125 21.6162 5.28125 20.7188V5.28125C5.28125 4.38379 6.00879 3.65625 6.90625 3.65625Z"
821
+ stroke="white"
822
+ strokeLinecap="round"
823
+ strokeLinejoin="round"
824
+ />
825
+ <path d="M15.0312 3.85938V8.53125H19.7031" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
826
+ <path d="M8.9375 11.375H14.625" stroke="white" strokeLinecap="round" />
827
+ <path d="M8.9375 14.625H13.4062" stroke="white" strokeLinecap="round" />
828
+ <path d="M8.9375 17.875H12.5938" stroke="white" strokeLinecap="round" />
829
+ <path
830
+ d="M18.4844 23.1562C20.8402 23.1562 22.75 21.2465 22.75 18.8906C22.75 16.5348 20.8402 14.625 18.4844 14.625C16.1285 14.625 14.2188 16.5348 14.2188 18.8906C14.2188 21.2465 16.1285 23.1562 18.4844 23.1562Z"
831
+ fill="#005CFF"
832
+ stroke="white"
833
+ />
834
+ <path d="M17.0625 18.3219L18.5656 19.825L23.7656 14.625" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
835
+ <path d="M21.6533 17.875L23.2783 16.25" stroke="#005CFF" strokeLinecap="round" strokeLinejoin="round" />
836
+ <path d="M20.1904 17.0625L21.8154 15.4375" stroke="#005CFF" strokeLinecap="round" strokeLinejoin="round" />
837
+ </svg>
797
838
  {t('autobahn:finance.secureOffer')}
798
839
  </Button>
799
840
  <Button
800
841
  variant="outlined"
801
- icon="phone"
802
- style={{ flex: 'none', width: 44, padding: 0 }}
842
+ style={{
843
+ flex: '0 0 auto',
844
+ width: 40,
845
+ height: 40,
846
+ background: '#ffffff',
847
+ border: '1.6px solid #005cff',
848
+ borderRadius: 16,
849
+ padding: 0,
850
+ }}
803
851
  onClick={(e) => {
804
852
  e.stopPropagation();
805
853
  onCall();
806
854
  }}
807
- />
855
+ >
856
+ {/* exact design icon (not lucide's "phone") — no class="lucide", so the
857
+ .card-cta-row .mbtn .lucide 15px override doesn't touch it; the rect-clip from the
858
+ source SVG was a full-viewBox no-op (already inside 0-20/0-20) and dropped, since a
859
+ static clipPath id would collide across the many CarCard instances on one search page. */}
860
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
861
+ <path
862
+ d="M18.3332 14.1004V16.6004C18.3341 16.8325 18.2866 17.0622 18.1936 17.2749C18.1006 17.4875 17.9643 17.6784 17.7933 17.8353C17.6222 17.9922 17.4203 18.1116 17.2005 18.186C16.9806 18.2603 16.7477 18.288 16.5165 18.2671C13.9523 17.9881 11.4892 17.1119 9.32486 15.7087C7.31139 14.4293 5.60431 12.7222 4.32486 10.7087C2.91679 8.5346 2.04037 6.0595 1.76653 3.48374C1.7457 3.2533 1.77309 3.02104 1.84695 2.80176C1.9208 2.58248 2.03951 2.38098 2.1955 2.21009C2.3515 2.0392 2.54137 1.90266 2.75302 1.80917C2.96468 1.71569 3.19348 1.66729 3.42486 1.66707H5.92486C6.32928 1.66309 6.72136 1.80631 7.028 2.07002C7.33464 2.33373 7.53493 2.69995 7.59153 3.10041C7.69153 3.85041 7.86653 4.57541 8.11653 5.26707C8.22865 5.56534 8.25291 5.8895 8.18645 6.20114C8.11999 6.51278 7.96558 6.79883 7.74153 7.02541L6.74153 8.03374C7.95617 10.2329 9.76739 12.0441 11.9665 13.2587L12.9749 12.2587C13.2014 12.0347 13.4875 11.8803 13.7991 11.8138C14.1108 11.7474 14.4349 11.7716 14.7332 11.8837C15.4249 12.1337 16.1499 12.3087 16.8999 12.4087C17.3047 12.4658 17.6744 12.6697 17.9386 12.9817C18.2029 13.2936 18.3433 13.6917 18.3332 14.1004Z"
863
+ stroke="#005cff"
864
+ strokeWidth={1.66667}
865
+ strokeLinecap="round"
866
+ strokeLinejoin="round"
867
+ />
868
+ </svg>
869
+ </Button>
808
870
  <Button
809
871
  variant="outlined"
810
- icon="sliders-horizontal"
811
- style={{ flex: 1.5 }}
872
+ style={{
873
+ // same "starting size that can still grow/shrink" reasoning as "Request offer" above.
874
+ flex: '1 1 125px',
875
+ height: 40,
876
+ background: '#ffffff',
877
+ border: '1.6px solid #5b6b7f',
878
+ borderRadius: 16,
879
+ padding: '0 6px',
880
+ fontFamily: 'Arial',
881
+ fontStyle: 'normal',
882
+ fontWeight: 700,
883
+ fontSize: 13,
884
+ lineHeight: '15px',
885
+ color: '#5b6b7f',
886
+ }}
812
887
  onClick={(e) => {
813
888
  e.stopPropagation();
814
889
  onOpenRateAdjust(mode);
815
890
  }}
816
891
  >
892
+ {/* exact design icon (not lucide's "sliders-horizontal") — colors are baked into the SVG
893
+ itself (matches the button's #5b6b7f border/text), not currentColor. flex:none is
894
+ load-bearing: this button is a flex row (icon + the "Adjust rate" label) inside a fixed
895
+ 125px width, and without it the icon was a shrinkable flex item — flexbox squeezed its
896
+ WIDTH down to fit the text (rendering ~12.94x18, squashed) while its literal height
897
+ attribute stayed 18. Lucide icons here dodge this via the unrelated .card-cta-row .mbtn
898
+ .lucide CSS rule (flex: none), which doesn't match a plain <svg> with no lucide class. */}
899
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flex: 'none' }}>
900
+ <path d="M3 5.25H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
901
+ <path
902
+ d="M6 6.75C6.82843 6.75 7.5 6.07843 7.5 5.25C7.5 4.42157 6.82843 3.75 6 3.75C5.17157 3.75 4.5 4.42157 4.5 5.25C4.5 6.07843 5.17157 6.75 6 6.75Z"
903
+ fill="#FFFEFE"
904
+ stroke="#5B6B7F"
905
+ strokeLinecap="round"
906
+ strokeLinejoin="round"
907
+ />
908
+ <path d="M3 9H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
909
+ <path
910
+ d="M12.4565 10.5C13.285 10.5 13.9565 9.82843 13.9565 9C13.9565 8.17157 13.285 7.5 12.4565 7.5C11.6281 7.5 10.9565 8.17157 10.9565 9C10.9565 9.82843 11.6281 10.5 12.4565 10.5Z"
911
+ fill="white"
912
+ stroke="#5B6B7F"
913
+ strokeLinecap="round"
914
+ strokeLinejoin="round"
915
+ />
916
+ <path d="M3 12.75H15" stroke="#5B6B7F" strokeWidth={1.91667} strokeLinecap="round" strokeLinejoin="round" />
917
+ <path
918
+ d="M9.81543 14.25C10.6439 14.25 11.3154 13.5784 11.3154 12.75C11.3154 11.9216 10.6439 11.25 9.81543 11.25C8.987 11.25 8.31543 11.9216 8.31543 12.75C8.31543 13.5784 8.987 14.25 9.81543 14.25Z"
919
+ fill="white"
920
+ stroke="#5B6B7F"
921
+ strokeLinecap="round"
922
+ strokeLinejoin="round"
923
+ />
924
+ </svg>
817
925
  {t('autobahn:finance.adjustRate')}
818
926
  </Button>
819
927
  </div>
@@ -835,7 +943,15 @@ export interface GalleryCounterProps {
835
943
  * Bottom-left gallery overlay: expand icon + "current|total" (Figma). Reuses the existing
836
944
  * `.imglabel` pill style (already used for the list-card's photo-count badge) rather than a new
837
945
  * class — same dark translucent pill, just with the maximize icon instead of the image icon.
838
- * Hidden with 0/1 photos (nothing to expand into).
946
+ *
947
+ * States (shared policy across GalleryCounter, the carousel's own arrows/dots in CardCarousel,
948
+ * and GalleryActivityPill below — see its own doc comment for the other two):
949
+ * - single-image: `total` is the car's REAL, eagerly-known photo count (car.photos, from the
950
+ * search payload — not the lazily-loaded gallery array length), so this is accurate even
951
+ * before any image beyond the first has actually loaded. total<=1 → hidden: nothing to expand
952
+ * into. Never a "1|1" placeholder.
953
+ * - loading: while `total` itself is still unknown (car not yet resolved) the caller simply
954
+ * hasn't rendered this component yet — there is no separate in-between skeleton state here.
839
955
  */
840
956
  export const GalleryCounter: React.FunctionComponent<GalleryCounterProps> = ({ current, total, light, onClick }) => {
841
957
  if (total <= 1) return null;
@@ -864,8 +980,18 @@ export interface GalleryActivityPillProps {
864
980
 
865
981
  /**
866
982
  * Bottom-right gallery overlay: eye + views, heart + favorites, in ONE pill (Figma "Push Chips /
867
- * Likes", node 297:1037). Each half renders independently and the whole pill is omitted when
868
- * neither figure has loaded yet (same async-stats/NEEDS-DATA discipline as the rest of this file).
983
+ * Likes", node 297:1037). Each half renders independently.
984
+ *
985
+ * States (chosen ONE policy, applied identically to both halves):
986
+ * - loading: `viewsCount`/`favoritesCount` are `undefined` until the async stats call
987
+ * (UserActionsAPI 'stats/viewers') resolves for this car. No skeleton — the half (or the
988
+ * whole pill, if both are still pending) simply doesn't render yet, same "hidden, not a
989
+ * placeholder" discipline as GalleryCounter/CardCarousel's arrows above.
990
+ * - zero-value: a REAL `0` is treated the same as "not there" — hidden, not shown as "👁 0".
991
+ * Deliberate choice over spelling out "0" (the other option the ticket allows): on a dense
992
+ * grid of fresh listings a "👁 0 ❤ 0" pill on every card is noise, not signal. `!viewsCount`/
993
+ * `!favoritesCount` below intentionally don't distinguish real-0 from not-yet-loaded — both
994
+ * read as "nothing worth showing (yet)", which is the same outcome either way.
869
995
  */
870
996
  export const GalleryActivityPill: React.FunctionComponent<GalleryActivityPillProps> = ({ viewsCount, favoritesCount }) => {
871
997
  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: slide the header out of view on scroll-down, back in on scroll-up (coordinated
114
- * with the VDP sticky offer bottom bar's own show/hide). Off by default — only the car-detail
115
- * page (topBarVariant 'conversion') opts in; every other page keeps the header always docked. */
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
 
@@ -86,7 +86,7 @@
86
86
  "priceRatingAria": "Preisbewertung: {{rating}}",
87
87
  "detailsShow": "Details anzeigen",
88
88
  "paymentLabel": "Zahlung",
89
- "paymentValue": "Zahlung per Überweisung",
89
+ "paymentValue": "Überweisung",
90
90
  "warrantyLabel": "Garantie",
91
91
  "warrantyValue": "1 Jahr inklusive",
92
92
  "deliveryLabel": "Lieferung",
@@ -105,6 +105,7 @@
105
105
  "callCta": "Anrufen",
106
106
  "finalInstallmentToggle": "Mit Schlussrate",
107
107
  "addServicesToggle": "Rundum-Sorglos-Paket",
108
+ "moreInfoAria": "Weitere Informationen zu {{label}}",
108
109
  "addServicesPerMonth": "+{{value}} € / Monat",
109
110
  "addServicesOnce": "+{{value}} €",
110
111
  "addServicesIncluded": "inklusive",
@@ -382,6 +383,7 @@
382
383
  },
383
384
  "rate": {
384
385
  "upTo": "Rate bis",
386
+ "upToPlaceholder": "Rate bis, z. B. 350 €/mtl.",
385
387
  "perMonth": "/mtl.",
386
388
  "downPayment": "Anzahlung",
387
389
  "none": "keine",
@@ -389,6 +391,10 @@
389
391
  "months": "{{months}} Monate",
390
392
  "months_plural": "{{months}} Monate"
391
393
  },
394
+ "mileage": {
395
+ "upTo": "km bis",
396
+ "upToPlaceholder": "km bis, z. B. 50000 km"
397
+ },
392
398
  "consumption": {
393
399
  "unitLabel": "l / 100 km"
394
400
  },
@@ -723,7 +729,7 @@
723
729
  "leaseVehiclePrice": "Anschaffungspreis¹",
724
730
  "leaseDownPayment": "Einmalige Sonderzahlung",
725
731
  "finalPaymentDue": "Schlusszahlung (fällig am Vertragsende)",
726
- "residualValue": "Restwert (verpflichtend)",
732
+ "residualValue": "Verpflichtender Kaufpreis am Vertragsende",
727
733
  "rateSumLabel": "Summe der Leasingraten ({{term}} × {{amount}} €)",
728
734
  "addServicesRowLease": "Rundum-Sorglos-Paket (Einmalzahlung)",
729
735
  "subtotalWithPackage": "Gesamt inkl. Sonderzahl. & Paket",
@@ -105,6 +105,7 @@
105
105
  "callCta": "Call",
106
106
  "finalInstallmentToggle": "With final installment",
107
107
  "addServicesToggle": "All-round care package",
108
+ "moreInfoAria": "More information about {{label}}",
108
109
  "addServicesPerMonth": "+€{{value}} / month",
109
110
  "addServicesOnce": "+€{{value}}",
110
111
  "addServicesIncluded": "included",
@@ -382,6 +383,7 @@
382
383
  },
383
384
  "rate": {
384
385
  "upTo": "Rate up to",
386
+ "upToPlaceholder": "Rate up to, e.g. 350 €/month",
385
387
  "perMonth": "/month",
386
388
  "downPayment": "Down payment",
387
389
  "none": "none",
@@ -389,6 +391,10 @@
389
391
  "months": "{{months}} months",
390
392
  "months_plural": "{{months}} months"
391
393
  },
394
+ "mileage": {
395
+ "upTo": "Mileage up to",
396
+ "upToPlaceholder": "Mileage up to, e.g. 50000 km"
397
+ },
392
398
  "consumption": {
393
399
  "unitLabel": "l / 100 km"
394
400
  },
@@ -723,7 +729,7 @@
723
729
  "leaseVehiclePrice": "Acquisition price¹",
724
730
  "leaseDownPayment": "One-time special payment",
725
731
  "finalPaymentDue": "Final payment (due at contract end)",
726
- "residualValue": "Residual value (mandatory)",
732
+ "residualValue": "Mandatory purchase price at contract end",
727
733
  "rateSumLabel": "Sum of leasing installments ({{term}} × {{amount}} €)",
728
734
  "addServicesRowLease": "Complete care package (one-time payment)",
729
735
  "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.113",
3
+ "version": "1.0.115",
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",