@bytebrand/fe-ui-core-autobahn 1.0.0 → 1.0.2

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/CarRow.tsx CHANGED
@@ -22,7 +22,7 @@ import React from 'react';
22
22
  import { TranslationFunction } from '../types';
23
23
 
24
24
  import { CardCarousel, IconButton, SaveChip, Button, Icon } from '../ui';
25
- import { EcoLine, PricePaths, PriceKauf, PriceMeter, PpActivity, DropChip, PercentChip, ZeroDownChip, fmtKm } from './cardParts';
25
+ import { EcoLine, PricePaths, PriceKauf, PriceMeter, PpActivity, GalleryBadges, DropChip, PercentChip, ZeroDownChip, fmtKm } from './cardParts';
26
26
  import { AutobahnCar } from './types';
27
27
 
28
28
  export interface CarRowProps {
@@ -65,42 +65,46 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({ car, isFavorite, onToggl
65
65
  onClick={() => onOpen(car)}
66
66
  style={{ background: 'var(--md-sc-lowest)' }}
67
67
  >
68
- <div className="listcard-media" style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
69
- <CardCarousel images={car.gallery} height="100%" fit="contain" />
70
- {/* deal badges — ONE compact line: NEU · −€ · 0 € Anz. · −%. The price RATING
71
- ('Guter Preis') is NOT here — it lives in the price column (PricePaths meter). */}
72
- <div
73
- style={{
74
- position: 'absolute',
75
- top: 10,
76
- left: 10,
77
- right: 10,
78
- zIndex: 3,
79
- display: 'flex',
80
- flexDirection: 'row',
81
- flexWrap: 'nowrap',
82
- alignItems: 'center',
83
- gap: 5,
84
- }}
85
- >
86
- {car.isNew && <span className="neu-badge">NEU</span>}
87
- {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
88
- <ZeroDownChip t={t} show={car.zeroDown} compact />
89
- <PercentChip pct={car.dropPct} />
90
- </div>
91
- <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
92
- <span className="imglabel">
93
- <Icon name="image" size={13} />
94
- {car.photos}
95
- </span>
96
- </div>
97
- <div
98
- onClick={(e) => e.stopPropagation()}
99
- className="show-mobile"
100
- style={{ position: 'absolute', top: 8, right: 8, zIndex: 3 }}
101
- >
102
- <IconButton name="heart" filled active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
68
+ <div className="listcard-media" onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
69
+ <div className="listcard-media-img">
70
+ <CardCarousel images={car.gallery} height="100%" fit="contain" />
71
+ {/* deal badges — ONE compact line: NEU · −€ · 0 € Anz. · −%. The price RATING
72
+ ('Guter Preis') is NOT here — it lives in the price column (PricePaths meter). */}
73
+ <div
74
+ style={{
75
+ position: 'absolute',
76
+ top: 10,
77
+ left: 10,
78
+ right: 10,
79
+ zIndex: 3,
80
+ display: 'flex',
81
+ flexDirection: 'row',
82
+ flexWrap: 'nowrap',
83
+ alignItems: 'center',
84
+ gap: 5,
85
+ }}
86
+ >
87
+ {car.isNew && <span className="neu-badge">NEU</span>}
88
+ {car.drop != null ? <DropChip t={t} amount={car.drop} /> : showSave && <SaveChip t={t} amount={car.savings!} />}
89
+ <ZeroDownChip t={t} show={car.zeroDown} compact />
90
+ <PercentChip pct={car.dropPct} />
91
+ </div>
92
+ <div style={{ position: 'absolute', bottom: 10, right: 10, zIndex: 3 }}>
93
+ <span className="imglabel">
94
+ <Icon name="image" size={13} />
95
+ {car.photos}
96
+ </span>
97
+ </div>
98
+ <div
99
+ onClick={(e) => e.stopPropagation()}
100
+ className="show-mobile"
101
+ style={{ position: 'absolute', top: 8, right: 8, zIndex: 3 }}
102
+ >
103
+ <IconButton name="heart" filled active={isFavorite} onClick={onToggleFavorite} label={t('autobahn:card.action.save')} />
104
+ </div>
103
105
  </div>
106
+ {/* activity/trust pill row — always under the image, never overlaid on it. */}
107
+ <GalleryBadges car={car} t={t} />
104
108
  </div>
105
109
 
106
110
  <div className="listcard-info">
@@ -487,3 +487,43 @@ export const PpActivity: React.FunctionComponent<PpActivityProps> = ({ car, t })
487
487
  </div>
488
488
  );
489
489
  };
490
+
491
+ export interface GalleryBadgesProps {
492
+ car: AutobahnCar;
493
+ t: TranslationFunction;
494
+ }
495
+
496
+ /**
497
+ * Trust/activity pill row rendered under the gallery on the SRP list card — mirrors the mobile
498
+ * VDP gallery header (availability · views · favorites · Geprüft). Availability/views/favorites
499
+ * are real data and each pill is omitted when its value isn't present yet; 'Geprüft' is auto.de's
500
+ * general inspection/warranty promise (same claim as the legacy VDP trust strip, e.g.
501
+ * VehicleDetailedSliderModal/Sidebar.tsx), not per-car data, so it always renders — it exists here
502
+ * mainly so the row never looks empty while the async view/favorite counts are still loading.
503
+ */
504
+ export const GalleryBadges: React.FunctionComponent<GalleryBadgesProps> = ({ car, t }) => (
505
+ <div className="gallery-badges">
506
+ {car.availability && (
507
+ <span className="gallery-pill gallery-pill--good">
508
+ <Icon name="circle-check" size={14} stroke={2} />
509
+ {car.availability}
510
+ </span>
511
+ )}
512
+ {car.viewsCount != null && (
513
+ <span className="gallery-pill">
514
+ <Icon name="eye" size={14} stroke={1.9} />
515
+ {t('autobahn:card.activity.viewsCount', { count: car.viewsCount })}
516
+ </span>
517
+ )}
518
+ {car.favoritesCount != null && (
519
+ <span className="gallery-pill">
520
+ <Icon name="heart" size={14} stroke={1.9} />
521
+ {t('autobahn:card.activity.favoritesCount', { count: car.favoritesCount })}
522
+ </span>
523
+ )}
524
+ <span className="gallery-pill">
525
+ <Icon name="shield-check" size={14} stroke={1.9} />
526
+ {t('autobahn:card.trust.checked')}
527
+ </span>
528
+ </div>
529
+ );
package/cards/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  PriceKauf,
23
23
  PriceMeter,
24
24
  PpActivity,
25
+ GalleryBadges,
25
26
  } from './cardParts';
26
27
  export type {
27
28
  SpecLineProps,
@@ -30,6 +31,7 @@ export type {
30
31
  EcoLineProps,
31
32
  PricePathsProps,
32
33
  PpActivityProps,
34
+ GalleryBadgesProps,
33
35
  } from './cardParts';
34
36
 
35
37
  export type { AutobahnCar, AutobahnPhev } from './types';
package/cards/types.ts CHANGED
@@ -111,4 +111,12 @@ export interface AutobahnCar {
111
111
  watchers?: number;
112
112
  /** number of recent views (drives the 'vor N Std' cue). */
113
113
  views?: number;
114
+
115
+ // --- gallery activity strip (real data: UserActionsAPI 'stats/viewers', loaded async and
116
+ // keyed by carId in SearchResultsStore.statsData — see fieldMapping.ts). Absent until
117
+ // that fetch resolves; the gallery badge row simply omits the pill until then. ---
118
+ /** total detail-page views (stats totalCarImpCount) — shown as the eye pill under the image. */
119
+ viewsCount?: number;
120
+ /** total users who favorited this car (stats totalFavCount) — shown as the heart pill under the image. */
121
+ favoritesCount?: number;
114
122
  }
@@ -328,6 +328,28 @@ export const FIELD_MAPPINGS: readonly FieldMapping[] = [
328
328
  confirmedAt: 'as watchers (searchStore.statsData, separate from the store car)',
329
329
  notes: 'Recent-views cue. Same as watchers — not synthesized.',
330
330
  },
331
+
332
+ // ------------------------------------------- gallery activity strip (WIRED, AB-P0-9)
333
+ {
334
+ field: 'viewsCount',
335
+ required: false,
336
+ status: 'wired-optional',
337
+ source: 'deps.stats.totalCarImpCount ← searchStore.statsData.get(id) (UserActionsAPI stats/viewers)',
338
+ transform: 'number passthrough; emitted only once the async stats fetch resolves for this car',
339
+ confirmedAt:
340
+ 'SearchResultsStore.ts:211-232 (getStatsDataByIds/getStatsData already fetched by SearchNextPage.runSearch, getStats=true) · UserActionsAPI.ts:97-98 (stats/viewers) · withStats.tsx:11 (IStatsData.totalCarImpCount, same endpoint already rendered on the legacy card)',
341
+ notes:
342
+ 'Distinct from `views` on purpose: `views` feeds PpActivity’s fabricated "vor N Std" cue (car.views % 6) — reusing it here would have silently turned that dormant text on everywhere. viewsCount only drives the new gallery eye pill.',
343
+ },
344
+ {
345
+ field: 'favoritesCount',
346
+ required: false,
347
+ status: 'wired-optional',
348
+ source: 'deps.stats.totalFavCount ← searchStore.statsData.get(id) (UserActionsAPI stats/viewers)',
349
+ transform: 'number passthrough; emitted only once the async stats fetch resolves for this car',
350
+ confirmedAt: 'same source as viewsCount · withStats.tsx:11 (IStatsData.totalFavCount)',
351
+ notes: 'Distinct from `watchers` for the same reason as viewsCount (keeps PpActivity untouched).',
352
+ },
331
353
  ] as const;
332
354
 
333
355
  /** Quick lookups for tooling / tests. */
@@ -69,6 +69,13 @@ export interface ToAutobahnCarDeps {
69
69
  language?: string;
70
70
  /** placeholder image URL override (defaults to PLACEHOLDER_IMAGE). */
71
71
  placeholder?: string;
72
+ /**
73
+ * Buyer-activity stats for this car — UserActionsAPI 'stats/viewers' response, loaded async and
74
+ * keyed by carId (`searchStore.statsData.get(storeCar.id)`). Pass it straight through; absent
75
+ * (not loaded yet, or no stats for this car) → viewsCount/favoritesCount stay undefined and the
76
+ * gallery badge row simply omits those pills. Never fabricated.
77
+ */
78
+ stats?: { totalCarImpCount?: number; totalFavCount?: number };
72
79
  }
73
80
 
74
81
  // ---------------------------------------------------------------------------
@@ -383,6 +390,10 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
383
390
  // 0 € Anzahlung available — transform sets offer.showDownPayment = (raw showDownPayment && zero first installment).
384
391
  const zeroDown = !!offer.showDownPayment;
385
392
 
393
+ // --- gallery activity strip (REAL) — UserActionsAPI 'stats/viewers', injected via deps.stats. ---
394
+ const viewsCount = num(deps.stats?.totalCarImpCount);
395
+ const favoritesCount = num(deps.stats?.totalFavCount);
396
+
386
397
  const carView: AutobahnCar = {
387
398
  // identity / headline
388
399
  make: String(title.make ?? ''),
@@ -431,6 +442,8 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
431
442
  ...(zeroDown ? { zeroDown } : {}),
432
443
  ...(isNew ? { isNew } : {}),
433
444
  ...(availability ? { availability } : {}),
445
+ ...(viewsCount != null ? { viewsCount } : {}),
446
+ ...(favoritesCount != null ? { favoritesCount } : {}),
434
447
 
435
448
  // NEEDS-DATA: deliberately omitted (marketLow, marketAvg, marketHigh, watchers, views).
436
449
  // The cards render 'Daten folgt' for these until a real backend exists.
@@ -33,7 +33,12 @@
33
33
  },
34
34
  "activity": {
35
35
  "watchers": "{{watchers}} Interessent · vor {{hrs}} Std",
36
- "watchers_plural": "{{watchers}} Interessenten · vor {{hrs}} Std"
36
+ "watchers_plural": "{{watchers}} Interessenten · vor {{hrs}} Std",
37
+ "viewsCount": "{{count}}x",
38
+ "favoritesCount": "{{count}}x"
39
+ },
40
+ "trust": {
41
+ "checked": "Geprüft"
37
42
  },
38
43
  "specs": {
39
44
  "firstRegistration": "EZ {{reg}}",
@@ -33,7 +33,12 @@
33
33
  },
34
34
  "activity": {
35
35
  "watchers": "{{watchers}} interested · {{hrs}} hours ago",
36
- "watchers_plural": "{{watchers}} interested · {{hrs}} hours ago"
36
+ "watchers_plural": "{{watchers}} interested · {{hrs}} hours ago",
37
+ "viewsCount": "{{count}}x",
38
+ "favoritesCount": "{{count}}x"
39
+ },
40
+ "trust": {
41
+ "checked": "Checked"
37
42
  },
38
43
  "specs": {
39
44
  "firstRegistration": "Reg. {{reg}}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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
@@ -1002,7 +1002,17 @@
1002
1002
 
1003
1003
  /* horizontal listing card (SRP): image | info | price */
1004
1004
  .md3 .listcard { display: grid; grid-template-columns: 300px 1fr 240px; align-items: stretch; }
1005
- .md3 .listcard-media { position: relative; min-height: 100%; }
1005
+ .md3 .listcard-media { position: relative; min-height: 100%; display: flex; flex-direction: column; }
1006
+ /* wraps the carousel + its absolute overlays; flexes to fill whatever height the badge row below
1007
+ doesn't need, so the row height stays governed by the grid (like before this row existed). */
1008
+ .md3 .listcard-media-img { position: relative; flex: 1 1 auto; min-height: 0; }
1009
+ /* activity/trust pill row under the SRP card image (availability · views · favorites · Geprüft) */
1010
+ .md3 .gallery-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 10px;
1011
+ border-top: 1px solid var(--md-outline-variant); background: var(--md-sc-lowest); flex: none; }
1012
+ .md3 .gallery-pill { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px;
1013
+ border-radius: var(--md-r-full); border: 1px solid var(--md-outline-variant); background: var(--md-sc-lowest);
1014
+ color: var(--md-on-surface); font-size: 11.5px; font-weight: 600; white-space: nowrap; }
1015
+ .md3 .gallery-pill--good { color: var(--md-good); border-color: var(--md-good); }
1006
1016
  .md3 .listcard-info { padding: 16px 18px; display: flex; flex-direction: column; gap: 11px; min-width: 0; }
1007
1017
  .md3 .listcard-price { position: relative; padding: 16px 18px; border-left: 1px solid var(--md-outline-variant);
1008
1018
  display: flex; flex-direction: column; justify-content: flex-start; gap: 16px; background: var(--md-sc-low); }
@@ -1288,9 +1298,10 @@
1288
1298
 
1289
1299
  @media (max-width: 600px) {
1290
1300
  .md3 .cards-3, .md3 .cards-2 { grid-template-columns: 1fr; }
1291
- /* listing card stacks: image (4:3) → info → price */
1301
+ /* listing card stacks: image (4:3) → badges → info → price */
1292
1302
  .md3 .listcard { grid-template-columns: 1fr; }
1293
- .md3 .listcard-media { aspect-ratio: 4 / 3; min-height: 0; }
1303
+ .md3 .listcard-media { min-height: 0; }
1304
+ .md3 .listcard-media-img { aspect-ratio: 4 / 3; flex: none; }
1294
1305
  .md3 .listcard-price { border-left: 0; border-top: 1px solid var(--md-outline-variant); padding: 14px 16px;
1295
1306
  flex-direction: column; justify-content: flex-start; align-items: stretch; gap: 14px; }
1296
1307
  .md3 .listcard-price .pricepaths { flex: none; min-width: 0; }
package/vdp/VdpViewer.tsx CHANGED
@@ -10,12 +10,25 @@
10
10
  // inputs are props + callbacks. The keyboard / scroll-lock effect touches the
11
11
  // document the way any focus-trapping modal must — there is no store / API /
12
12
  // data access. Renders correctly only inside a `<div className="md3">` subtree.
13
+ //
14
+ // PORTAL: rendered into document.body (re-wrapped in its own `.md3` scope for the
15
+ // --md-* custom properties, which are only defined under `.md3`) instead of in
16
+ // place. The app root (`App.styl` `.app`) sets `overflow: clip`, which on several
17
+ // mobile browsers (notably iOS Safari) clips `position: fixed` descendants to the
18
+ // ancestor's box instead of the true viewport — the TopBar stayed visible above
19
+ // this "fullscreen" overlay and the header row (counter + actions) was pushed out
20
+ // of the clipped area. A portal to `document.body` escapes that ancestor entirely.
13
21
 
14
22
  import React, { useEffect, useRef, useState } from 'react';
23
+ import * as ReactDOM from 'react-dom';
15
24
  import { TranslationFunction } from '../types';
16
25
 
17
26
  import { Icon, IconButton } from '../ui';
18
27
 
28
+ const ZOOM_MIN = 1;
29
+ const ZOOM_MAX = 3;
30
+ const ZOOM_STEP = 0.5;
31
+
19
32
  export interface VdpViewerProps {
20
33
  /** ready-to-use image URLs (already resolved by the caller). */
21
34
  images: string[];
@@ -25,13 +38,24 @@ export interface VdpViewerProps {
25
38
  onClose: () => void;
26
39
  /** i18n function (drilled from the page). */
27
40
  t: TranslationFunction;
41
+ /** sales phone number shown/dialled by the call action — omit to hide it. */
42
+ phone?: string;
43
+ /** call action (tel: link owned by the caller) — omit to hide the call button. */
44
+ onCall?: () => void;
45
+ /** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide. */
46
+ onEmail?: () => void;
28
47
  }
29
48
 
30
- const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t }) => {
49
+ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail }) => {
31
50
  const [i, setI] = useState(start);
51
+ const [zoom, setZoom] = useState(ZOOM_MIN);
32
52
  const n = images.length;
33
53
  const go = (d: number) => setI((p) => (p + d + n) % n);
34
54
 
55
+ // Reset zoom on every slide change — a lingering zoom level from the previous photo reads as
56
+ // a stuck/broken control on the new one.
57
+ useEffect(() => setZoom(ZOOM_MIN), [i]);
58
+
35
59
  // Keyboard nav + scroll lock while the overlay is mounted. `go`/`onClose` are
36
60
  // read from refs so the listener stays stable for the lifetime of the viewer.
37
61
  const goRef = useRef(go);
@@ -54,86 +78,132 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
54
78
  }, []);
55
79
 
56
80
  const tx = useRef<number | null>(null);
57
- return (
58
- <div
59
- style={{
60
- position: 'fixed',
61
- inset: 0,
62
- zIndex: 200,
63
- background: 'var(--md-surface)',
64
- display: 'flex',
65
- flexDirection: 'column',
66
- }}
67
- onTouchStart={(e) => {
68
- tx.current = e.touches[0].clientX;
69
- }}
70
- onTouchEnd={(e) => {
71
- if (tx.current == null) return;
72
- const dx = e.changedTouches[0].clientX - tx.current;
73
- tx.current = null;
74
- if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
75
- }}
76
- >
81
+ const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
82
+ const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
83
+
84
+ const iconBtnStyle: React.CSSProperties = { background: 'var(--md-sc-lowest, #fff)', boxShadow: 'var(--md-e1)' };
85
+ const contactBtnStyle: React.CSSProperties = { background: 'var(--md-primary)', boxShadow: 'var(--md-e1)' };
86
+
87
+ const overlay = (
88
+ <div className="md3">
77
89
  <div
78
90
  style={{
91
+ position: 'fixed',
92
+ inset: 0,
93
+ zIndex: 200,
94
+ background: 'var(--md-surface)',
79
95
  display: 'flex',
80
- alignItems: 'center',
81
- justifyContent: 'space-between',
82
- padding: '12px 16px',
83
- borderBottom: '1px solid var(--md-outline-variant)',
96
+ flexDirection: 'column',
97
+ }}
98
+ onTouchStart={(e) => {
99
+ tx.current = e.touches[0].clientX;
100
+ }}
101
+ onTouchEnd={(e) => {
102
+ if (tx.current == null) return;
103
+ const dx = e.changedTouches[0].clientX - tx.current;
104
+ tx.current = null;
105
+ if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
84
106
  }}
85
107
  >
86
- <span className="num md-title-sm">
87
- {i + 1} / {n}
88
- </span>
89
- <IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
90
- </div>
91
- <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff' }}>
92
- <img
93
- src={images[i]}
94
- alt=""
95
- style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain' }}
96
- />
97
- <button
98
- className="car-arrow car-arrow--l hide-mobile"
99
- style={{ opacity: 1 }}
100
- aria-label={t('autobahn:viewer.previousLabel')}
101
- onClick={() => go(-1)}
108
+ <div
109
+ style={{
110
+ display: 'flex',
111
+ alignItems: 'center',
112
+ justifyContent: 'space-between',
113
+ gap: 8,
114
+ padding: '12px 16px',
115
+ borderBottom: '1px solid var(--md-outline-variant)',
116
+ flexWrap: 'wrap',
117
+ }}
102
118
  >
103
- <Icon name="chevron-left" size={22} />
104
- </button>
105
- <button
106
- className="car-arrow car-arrow--r hide-mobile"
107
- style={{ opacity: 1 }}
108
- aria-label={t('autobahn:viewer.nextLabel')}
109
- onClick={() => go(1)}
110
- >
111
- <Icon name="chevron-right" size={22} />
112
- </button>
113
- </div>
114
- <div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '12px 16px', flexWrap: 'wrap' }}>
115
- {images.map((src, k) => (
116
- <button
117
- key={k}
118
- onClick={() => setI(k)}
119
- aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
119
+ <span className="num md-title-sm">
120
+ {i + 1} / {n}
121
+ </span>
122
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
123
+ <IconButton
124
+ name="minus"
125
+ label={t('autobahn:viewer.zoomOutLabel')}
126
+ onClick={zoomOut}
127
+ style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MIN ? 0.4 : 1 }}
128
+ />
129
+ <IconButton
130
+ name="plus"
131
+ label={t('autobahn:viewer.zoomInLabel')}
132
+ onClick={zoomIn}
133
+ style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
134
+ />
135
+ {onEmail && (
136
+ <IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
137
+ )}
138
+ {onCall && (
139
+ <IconButton
140
+ name="phone"
141
+ label={t('autobahn:viewer.callLabel', { phone })}
142
+ onClick={onCall}
143
+ style={contactBtnStyle}
144
+ color="#fff"
145
+ />
146
+ )}
147
+ <IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
148
+ </div>
149
+ </div>
150
+ <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
151
+ <img
152
+ src={images[i]}
153
+ alt=""
120
154
  style={{
121
- width: 64,
122
- height: 44,
123
- padding: 0,
124
- borderRadius: 'var(--md-r-sm)',
125
- overflow: 'hidden',
126
- cursor: 'pointer',
127
- border: k === i ? '2px solid var(--md-primary)' : '1px solid var(--md-outline-variant)',
128
- background: '#fff',
155
+ position: 'absolute',
156
+ inset: 0,
157
+ width: '100%',
158
+ height: '100%',
159
+ objectFit: 'contain',
160
+ transform: `scale(${zoom})`,
161
+ transition: 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
129
162
  }}
163
+ />
164
+ <button
165
+ className="car-arrow car-arrow--l hide-mobile"
166
+ style={{ opacity: 1 }}
167
+ aria-label={t('autobahn:viewer.previousLabel')}
168
+ onClick={() => go(-1)}
130
169
  >
131
- <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
170
+ <Icon name="chevron-left" size={22} />
132
171
  </button>
133
- ))}
172
+ <button
173
+ className="car-arrow car-arrow--r hide-mobile"
174
+ style={{ opacity: 1 }}
175
+ aria-label={t('autobahn:viewer.nextLabel')}
176
+ onClick={() => go(1)}
177
+ >
178
+ <Icon name="chevron-right" size={22} />
179
+ </button>
180
+ </div>
181
+ <div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '12px 16px', flexWrap: 'wrap' }}>
182
+ {images.map((src, k) => (
183
+ <button
184
+ key={k}
185
+ onClick={() => setI(k)}
186
+ aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
187
+ style={{
188
+ width: 64,
189
+ height: 44,
190
+ padding: 0,
191
+ borderRadius: 'var(--md-r-sm)',
192
+ overflow: 'hidden',
193
+ cursor: 'pointer',
194
+ border: k === i ? '2px solid var(--md-primary)' : '1px solid var(--md-outline-variant)',
195
+ background: '#fff',
196
+ }}
197
+ >
198
+ <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
199
+ </button>
200
+ ))}
201
+ </div>
134
202
  </div>
135
203
  </div>
136
204
  );
205
+
206
+ return typeof document !== 'undefined' ? ReactDOM.createPortal(overlay, document.body) : overlay;
137
207
  };
138
208
 
139
209
  export default VdpViewer;