@bytebrand/fe-ui-core-autobahn 1.0.55 → 1.0.57

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/vdp/VdpViewer.tsx CHANGED
@@ -1,9 +1,31 @@
1
1
  // Autobahn (auto.de redesign) — fullscreen gallery viewer ("full image view").
2
2
  //
3
3
  // Typed React 18 + TS port of `VdpViewer` from the design kit
4
- // (docs/redesign/design-source/ui_kits/website/ScreenVDP.jsx). Light stage for the
5
- // white studio photos: keyboard (←/→/Esc) + swipe nav, a thumbnail strip, and it
6
- // locks page scroll while open.
4
+ // (docs/redesign/design-source/ui_kits/website/ScreenVDP.jsx). Renders one of THREE completely
5
+ // separate layouts depending on live viewport width (re-evaluated on resize):
6
+ // - width <= 693 ("portrait" layout): the Figma "Component | Galerie | Portrait 390x693 | V01"
7
+ // frame (node 424:8154), rebuilt pixel-for-pixel — full-width header (logo + close + call), a
8
+ // photo-count / 360° pill pair over the image, always-visible prev/next circles, a vertical
9
+ // zoom +/- pill, a single fixed thumbnail strip with a "+N" cap tile, and a full-width bottom
10
+ // bar with the two primary CTAs. DO NOT MODIFY for landscape work — this range is done.
11
+ // - 693 < width <= 860 ("landscape" layout): the Figma "FRAME | Galerie | Landscape 693x390 |
12
+ // V01" frame (node 424:7525, a DIFFERENT Figma file from the portrait one) — no full-width
13
+ // header/footer bars at all; every control floats directly over the white canvas with its own
14
+ // translucent circle/pill background. Distinct from portrait: close/prev/next share ONE circle
15
+ // style (black/50%) with no caption text, the counter pill is black/50% (not portrait's navy),
16
+ // 360° is a small outlined circle + 8px label stacked into the zoom column instead of a top
17
+ // pill, thumbnails are 68px (not 64) with only 3 real + a "+N" tile, and the CTA bar sits
18
+ // inline with the thumbnails (not a separate full-width bar) using the exact token blue
19
+ // (`--md-primary` == literal `#005cff` here, not portrait's `#0b63f6` approximation) and a flat
20
+ // `#1c222c` call button. Any icon shared with portrait that is pixel-identical (X, chevrons,
21
+ // phone, zoom magnifier, offer-request) is reused as-is; anything visually different gets its
22
+ // own `Ls*`-prefixed component so the portrait ones stay untouched.
23
+ // - width > 860 ("desktop" layout): the ORIGINAL desktop layout from before this redesign work
24
+ // (single header row with counter + zoom + email + call + close, hover-reveal `.car-arrow`
25
+ // prev/next, a wrapping thumbnail row) — deliberately left untouched so desktop keeps behaving
26
+ // exactly as it did.
27
+ // All three layouts share the same state (index/zoom/pan, keyboard nav, scroll lock) — only the
28
+ // chrome around the photo differs.
7
29
  //
8
30
  // Pure / presentational: images arrive ALREADY RESOLVED (the design kit's
9
31
  // `assetUrl(...)` lookup is the caller's job, matching CardCarousel) and the only
@@ -24,10 +46,26 @@ import * as ReactDOM from 'react-dom';
24
46
  import { TranslationFunction } from '../types';
25
47
 
26
48
  import { Icon, IconButton } from '../ui';
49
+ import logoUrl from '../assets/autode-logo.svg';
27
50
 
28
51
  const ZOOM_MIN = 1;
29
52
  const ZOOM_MAX = 3;
30
53
  const ZOOM_STEP = 0.5;
54
+ const THUMB_W = 64;
55
+ const THUMB_H = 50;
56
+ const THUMB_GAP = 8;
57
+ const THUMBS_PAD = 12;
58
+ /** <693px renders the Figma "Portrait" gallery. 693 itself is the landscape frame's OWN reference
59
+ * width (node 424:7525 is exactly 693x390), so it must land in 'landscape', not 'portrait' — a
60
+ * phone rotated from 390x693 to 693x390 needs to actually cross into the other bucket. */
61
+ const PORTRAIT_MAX = 692;
62
+ /** <=860px (and >=693) renders the Figma "Landscape" gallery; wider renders the original desktop layout. */
63
+ const MOBILE_BREAKPOINT = 860;
64
+ /** Landscape thumbnails (Figma node 424:7566 etc.) are 68px, not portrait's 64. */
65
+ const LS_THUMB_W = 68;
66
+
67
+ type Layout = 'portrait' | 'landscape' | 'desktop';
68
+ const layoutForWidth = (w: number): Layout => (w <= PORTRAIT_MAX ? 'portrait' : w <= MOBILE_BREAKPOINT ? 'landscape' : 'desktop');
31
69
 
32
70
  export interface VdpViewerProps {
33
71
  /** ready-to-use image URLs (already resolved by the caller). */
@@ -42,19 +80,499 @@ export interface VdpViewerProps {
42
80
  phone?: string;
43
81
  /** call action (tel: link owned by the caller) — omit to hide the call button. */
44
82
  onCall?: () => void;
45
- /** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide. */
83
+ /** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide the
84
+ * "Jetzt Anfragen" CTA (mobile) / envelope button (desktop). */
46
85
  onEmail?: () => void;
86
+ /** 360°-view toggle (mobile layout only) — presentational only for now (no vehicle in the
87
+ * current DTO ships a 360 image set), wired here so a real spin viewer can hang off it once
88
+ * that data exists. */
89
+ on360?: () => void;
47
90
  }
48
91
 
49
- const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail }) => {
92
+ /** Figma "360 / Icon" (node 424:8093) — two counter-rotating arcs; not part of the lucide set
93
+ * used elsewhere in this file, so it's inlined verbatim from the design's exported SVG. */
94
+ const Icon360: React.FunctionComponent = () => (
95
+ <svg width={18} height={18} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
96
+ <path
97
+ d="M5.25 6.3579C5.5593 5.55736 6.06014 4.84485 6.70866 4.28275C7.35719 3.72066 8.13362 3.32612 8.96998 3.13368C9.80634 2.94125 10.6771 2.95678 11.5061 3.17892C12.3351 3.40106 13.0969 3.82303 13.725 4.4079"
98
+ stroke="white"
99
+ strokeWidth={1.5}
100
+ strokeLinecap="round"
101
+ />
102
+ <path d="M14.2754 2.17499V5.17499H11.2754" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
103
+ <path
104
+ d="M14.7504 11.75C14.4411 12.5505 13.9403 13.2631 13.2917 13.8251C12.6432 14.3872 11.8668 14.7818 11.0304 14.9742C10.194 15.1666 9.32326 15.1511 8.49429 14.929C7.66533 14.7068 6.90346 14.2849 6.27539 13.7"
105
+ stroke="white"
106
+ strokeWidth={1.5}
107
+ strokeLinecap="round"
108
+ />
109
+ <path d="M5.72461 16V13H8.72461" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
110
+ </svg>
111
+ );
112
+
113
+ /** Figma "Close / Icon" (node 424:8083). */
114
+ const IconClose: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
115
+ <svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
116
+ <path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
117
+ </svg>
118
+ );
119
+
120
+ /** Figma "Call / Icon" (node 424:8087) / "CTA Call / Icon" (node 424:8131) — same handset path at
121
+ * two sizes (20px header, 22px bottom CTA); the design scales the identical shape, not a redraw. */
122
+ const IconPhone: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
123
+ <svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
124
+ <path
125
+ d="M6.5 4C6.91667 3.58333 7.66667 3.58333 8.08333 4L9.66667 5.58333C10.0833 6 10.0833 6.66667 9.75 7.16667L8.83333 8.33333C8.66667 8.58333 8.66667 8.91667 8.83333 9.16667C9.75 10.5833 11 11.8333 12.4167 12.75C12.6667 12.9167 13 12.9167 13.25 12.75L14.4167 11.8333C14.9167 11.5 15.5833 11.5 16 11.9167L17.5833 13.5C18 13.9167 18 14.6667 17.5833 15.0833L16.5833 16.0833C15.9167 16.75 14.9167 17 14 16.75C11.9167 16.1667 9.75 14.6667 7.83333 12.75C5.91667 10.8333 4.41667 8.66667 3.83333 6.58333C3.58333 5.66667 3.83333 4.66667 4.5 4H6.5Z"
126
+ stroke="white"
127
+ strokeWidth={1.8}
128
+ strokeLinejoin="round"
129
+ />
130
+ </svg>
131
+ );
132
+
133
+ /** Figma "Prev / Icon" (node 424:8100). */
134
+ const IconChevronLeft: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
135
+ <svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
136
+ <path d="M12.0833 4.58333L6.66667 10L12.0833 15.4167" stroke="white" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
137
+ </svg>
138
+ );
139
+
140
+ /** Figma "Next / Icon" (node 424:8103). */
141
+ const IconChevronRight: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
142
+ <svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
143
+ <path d="M7.91667 4.58333L13.3333 10L7.91667 15.4167" stroke="white" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
144
+ </svg>
145
+ );
146
+
147
+ /** Figma "Zoom / Plus Icon" + "Zoom / Minus Icon" (nodes 424:8145, 424:8150) are both a magnifying
148
+ * glass (circle + handle, decoded from the design's exported vectors) with a +/− bar drawn inside
149
+ * it — NOT a bare lucide plus/minus glyph. Shared geometry, only the inner bar(s) differ. */
150
+ const IconZoomIn: React.FunctionComponent<{ dim?: boolean }> = ({ dim }) => (
151
+ <svg width={32} height={32} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style={{ opacity: dim ? 0.4 : 1 }}>
152
+ <path
153
+ d="M15.321 23.9871C20.1074 23.9871 23.9876 20.1069 23.9876 15.3205C23.9876 10.534 20.1074 6.65381 15.321 6.65381C10.5345 6.65381 6.6543 10.534 6.6543 15.3205C6.6543 20.1069 10.5345 23.9871 15.321 23.9871Z"
154
+ stroke="white"
155
+ strokeWidth={1.9}
156
+ />
157
+ <path d="M21.9863 21.9872L27.3197 27.3205" stroke="white" strokeWidth={1.9} strokeLinecap="round" />
158
+ <path
159
+ d="M14.3558 20.2905V11.2393H16.6442V20.2905H14.3558ZM10.9744 16.9091V14.6207H20.0256V16.9091H10.9744Z"
160
+ fill="white"
161
+ />
162
+ </svg>
163
+ );
164
+ const IconZoomOut: React.FunctionComponent = () => (
165
+ <svg width={30} height={30} viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
166
+ <path
167
+ d="M16.3125 22.8125C21.2831 22.8125 25.3125 18.7831 25.3125 13.8125C25.3125 8.84194 21.2831 4.8125 16.3125 4.8125C11.3419 4.8125 7.3125 8.84194 7.3125 13.8125C7.3125 18.7831 11.3419 22.8125 16.3125 22.8125Z"
168
+ stroke="white"
169
+ strokeWidth={1.9}
170
+ />
171
+ <path d="M23.3125 19.8125L28.3125 25.8125" stroke="white" strokeWidth={1.9} strokeLinecap="round" />
172
+ <path d="M11.625 15V12.625H21V15H11.625Z" fill="white" />
173
+ </svg>
174
+ );
175
+
176
+ /** Figma "auto_de_icon_offer_request_white 1" (node 424:8134) — a document (with folded corner +
177
+ * 3 text lines) plus a checkmark badge overlapping its bottom-right corner. The badge fill is
178
+ * `#005CFF`, which is exactly `--autode-blau`/`--md-primary` in this codebase's tokens (not a
179
+ * coincidental near-match like the CTA background blue), so it's wired to the token, not hardcoded. */
180
+ const IconOfferRequest: React.FunctionComponent<{ size?: number }> = ({ size = 22 }) => (
181
+ <svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
182
+ <path
183
+ d="M8.5 4.5H18.8L24.5 10.2V25.5C24.5 26.6046 23.6046 27.5 22.5 27.5H8.5C7.39543 27.5 6.5 26.6046 6.5 25.5V6.5C6.5 5.39543 7.39543 4.5 8.5 4.5Z"
184
+ stroke="white"
185
+ strokeLinecap="round"
186
+ strokeLinejoin="round"
187
+ />
188
+ <path d="M18.5 4.75V10.5H24.25" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
189
+ <path d="M11 14H18" stroke="white" strokeLinecap="round" />
190
+ <path d="M11 18H16.5" stroke="white" strokeLinecap="round" />
191
+ <path d="M11 22H15.5" stroke="white" strokeLinecap="round" />
192
+ <path
193
+ d="M22.75 28.5C25.6495 28.5 28 26.1495 28 23.25C28 20.3505 25.6495 18 22.75 18C19.8505 18 17.5 20.3505 17.5 23.25C17.5 26.1495 19.8505 28.5 22.75 28.5Z"
194
+ fill="var(--md-primary)"
195
+ stroke="white"
196
+ />
197
+ <path d="M21 22.55L22.85 24.4L29.25 18" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
198
+ <path d="M26.65 22L28.65 20" stroke="var(--md-primary)" strokeLinecap="round" strokeLinejoin="round" />
199
+ <path d="M24.85 21L26.85 19" stroke="var(--md-primary)" strokeLinecap="round" strokeLinejoin="round" />
200
+ </svg>
201
+ );
202
+
203
+ /** Figma "Logo" (node 424:8073, 160×71) — the auto.de wordmark. Authored white for the viewer's own
204
+ * dark header bar (this component's original call site, hence the default), but the PhotoSheet
205
+ * reuses it at the brand blue (`#005CFF`) instead — so `color` is a prop, not hardcoded. */
206
+ const LogoMark: React.FunctionComponent<{ color?: string }> = ({ color = 'white' }) => (
207
+ <svg width={160} height={71} viewBox="0 0 160 71" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img">
208
+ <path d="M143.317 34.3796C142.516 31.079 140.75 28.5118 137.618 26.9569C135.243 25.7833 132.675 25.2112 130.063 24.9178C126.338 24.4924 122.612 24.1697 118.887 23.8029C115.191 23.4362 111.258 22.7321 110.724 22.6294C110.694 22.6294 108.334 22.1746 107.562 21.8666C106.375 21.3825 103.08 17.9352 91.7998 14.4878C82.835 11.7447 65.618 12.7422 59.6217 13.7837C54.1746 14.7372 43.2655 17.7151 38.7237 19.4314C34.7757 20.9277 24.1634 24.4484 20.1708 27.2943C17.9148 28.8932 15.7478 30.6095 16.1931 36.0812C16.1931 36.0812 16.5493 31.8858 19.7256 29.8907C24.3118 27.0155 31.4658 25.71 34.0335 24.9325C38.8721 23.4655 52.0966 19.3434 60.2154 18.2872C67.1912 17.3777 73.7515 16.6296 81.5586 17.3777C90.2413 18.2139 95.3916 20.5463 95.6439 20.6197C95.8963 20.693 106.197 25.5046 107.355 25.8126C108.512 26.1794 110.189 26.2381 111.392 26.3701C114.568 26.7221 117.818 26.8982 121.009 27.1035C124.008 27.3089 127.006 27.529 129.989 27.8517C132.72 28.1451 135.377 28.7465 137.915 29.8321C140.171 30.8149 142.1 32.1498 143.154 34.4529C143.184 34.5116 143.347 34.849 143.406 34.893C143.421 34.8197 143.332 34.4383 143.317 34.3796Z" fill={color} />
209
+ <path d="M39.5398 45.5724C39.2874 46.8193 38.4711 48.4916 36.4377 48.3009C35.2058 48.1835 34.7605 46.966 34.8348 46.1591C34.8348 46.0858 34.8644 45.9098 34.8644 45.9098C32.7717 47.8608 30.5453 48.829 27.8737 48.9023C22.6937 49.0343 19.1613 46.0565 19.0425 41.685C18.8941 35.9639 23.0796 31.343 30.1149 31.167C32.742 31.0936 35.3691 31.7831 37.1205 34.3062C37.2986 33.5141 38.011 31.9005 39.4062 31.8711C40.6381 31.8418 41.7067 32.5019 41.7364 33.5728C41.7364 33.6755 41.7067 33.9835 41.6622 34.1302L39.5398 45.5724ZM36.126 38.8978C36.0667 36.624 33.8997 34.8197 31.0648 34.893C27.2058 34.9957 24.0741 37.9296 24.1483 40.9662C24.2077 43.3133 26.1223 45.2056 29.0314 45.1323C32.742 45.0443 36.2151 42.3451 36.126 38.8978Z" fill={color} />
210
+ <path d="M59.0131 47.626C56.8164 48.7116 54.501 48.917 52.3489 48.917C47.6884 48.917 43.8145 46.5552 43.8145 43.0345C43.8145 42.4771 43.9184 41.1275 44.0372 40.5407L45.1503 34.0715C45.4472 32.7366 46.6791 31.8124 47.9555 31.8124C49.232 31.8124 49.9592 32.7659 49.9592 33.5727C49.9592 33.7928 49.9147 34.0568 49.885 34.2182L48.5344 40.8928C48.4602 41.2889 48.4008 42.0663 48.5344 42.4771C49.0984 44.2374 50.7459 45.0589 53.1207 45.0589C55.7923 45.0589 58.4787 43.1079 58.924 41.5382C59.5177 39.4258 60.4973 33.9688 60.4973 33.9688C60.7941 32.6339 62.0854 31.7977 63.347 31.9004C64.7125 32.0178 65.3359 33.294 65.1429 34.6876C65.039 35.4944 64.6086 37.9736 64.5789 38.1349C64.0001 41.4649 62.8572 45.7337 59.0131 47.626Z" fill={color} />
211
+ <path d="M69.2544 31.7978L69.7294 29.4507C69.9669 28.2771 71.0355 27.3236 72.5791 27.2943C73.7071 27.2649 74.5828 28.0131 74.6125 28.9519C74.6125 29.1133 74.5828 29.348 74.5234 29.6854L74.0633 32.0031H78.0708C79.5847 32.0031 80.1042 32.6339 79.9557 33.7635C79.7776 35.0104 78.2489 35.4945 76.7498 35.5238L73.3509 35.4505L72.0596 42.125C72.0003 42.4918 71.837 43.2106 72.0745 43.8414C72.2823 44.4135 72.7721 44.8389 73.6181 45.0883C74.1672 45.2496 75.9038 45.2936 75.9038 45.2936C76.9873 45.2643 77.7887 45.6164 77.8184 46.5259C77.8481 47.8314 76.9427 48.9317 75.7257 48.9317C75.7257 48.9317 73.3361 49.1077 71.2136 48.8436C68.6014 48.5209 66.939 46.3792 67.1023 43.9881C67.221 42.2424 67.6663 40.13 67.7257 39.7339L68.5123 35.7585L69.2544 31.7978Z" fill={color} />
212
+ <path d="M89.2908 48.9757C83.6359 48.9757 80.0589 45.6751 80.0589 41.4649C80.0589 35.5238 85.2388 31.1377 92.3186 31.1377C97.6915 31.1377 101.387 34.4383 101.387 38.7218C101.387 44.4135 96.5338 48.9757 89.2908 48.9757ZM96.8604 39.1765C96.8604 36.9761 94.5746 35.1424 91.8288 35.1424C87.8066 35.1424 84.9865 37.8563 84.9865 40.9808C84.9865 43.2106 86.7824 44.9416 89.6025 45.0003C93.788 45.0736 96.8604 42.4038 96.8604 39.1765Z" fill={color} />
213
+ <path d="M122.686 46.8046C122.479 47.6261 122.004 48.1982 120.831 48.1982C119.911 48.1982 119.421 47.5087 119.421 46.9659C119.421 46.9219 119.451 46.7312 119.451 46.7312C118.59 47.3473 117.15 48.7703 114.701 48.7703C110.367 48.7703 108.141 46.2911 108.141 43.3719C108.141 38.9418 112.43 36.1252 116.794 36.1252C118.887 36.1252 120.653 37.5775 121.039 38.2816L122.33 32.7073C122.538 31.8858 122.79 31.3283 124.037 31.3283C125.017 31.3283 125.566 31.9151 125.566 32.5459C125.566 32.7219 125.536 32.8833 125.506 32.986L122.686 46.8046ZM116.556 38.8831C113.915 39.0591 111.822 40.8341 111.822 43.0932C111.822 44.6922 112.994 46.1738 115.043 46.1738C117.373 46.1738 120.133 44.5455 120.401 42.7558C120.92 39.4405 117.774 38.7951 116.556 38.8831Z" fill={color} />
214
+ <path d="M129.558 43.5773C129.588 44.8975 131.235 45.9684 132.913 45.9684C135.525 45.9684 135.495 45.5137 136.727 45.5137C137.558 45.5137 138.137 45.9831 138.137 46.6139C138.137 47.3913 137.395 47.8754 136.668 48.1395C136.059 48.3595 134.619 48.7556 132.571 48.7556C128.504 48.7556 126.041 46.0271 126.041 43.6213C126.041 39.1911 130.078 36.0666 134.753 36.0666C138.671 36.0666 140.942 38.3697 140.942 40.9075C140.942 42.4037 139.651 43.5626 137.261 43.5773C137.217 43.5773 129.558 43.5773 129.558 43.5773ZM130.048 41.4209H137.677C137.677 40.1447 136.564 38.7804 134.412 38.7804C132.482 38.7804 130.478 40.174 130.048 41.4209Z" fill={color} />
215
+ <path d="M103.911 45.0736C104.98 45.0736 105.707 45.7337 105.707 46.5846C105.707 47.7434 104.416 48.7556 103.08 48.7556C102.011 48.7556 101.284 48.1249 101.284 47.274C101.284 46.1592 102.486 45.0736 103.911 45.0736Z" fill={color} />
216
+ </svg>
217
+ );
218
+
219
+ // ============================================================================
220
+ // LANDSCAPE-ONLY (693 < width <= 860) — Figma file qyZFhH6grmzQ1ptNwPcilw, node
221
+ // 424:7525. Icons pixel-identical to portrait's (close X, chevrons, phone, zoom
222
+ // magnifier, offer-request) are reused directly; anything visually different gets
223
+ // its own `Ls*` component here so the portrait versions above stay untouched.
224
+ // ============================================================================
225
+
226
+ /** Figma "360 / Circle" (node 424:9246 group, landscape only) — an unfilled outline circle (no
227
+ * bg fill), NOT the pill-shaped 360 button portrait uses. Distinct asset, own component. */
228
+ const Ls360Circle: React.FunctionComponent = () => (
229
+ <svg width={30} height={30} viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
230
+ <circle cx="15" cy="15" r="14.4" stroke="white" strokeWidth={1.2} />
231
+ </svg>
232
+ );
233
+
234
+ /** Close/Prev/Next circle background, landscape only (Figma node 424:7556/7560/7563) — ONE shared
235
+ * glass tint for all three (black/50%, thin white/16% ring), unlike portrait's two different
236
+ * tints for header-vs-stage circles. Decoded from Figma's flattened SVG export, not a guess. */
237
+ const lsCircleBg: React.CSSProperties = {
238
+ width: 48,
239
+ height: 48,
240
+ borderRadius: '50%',
241
+ background: 'rgba(0,0,0,0.5)',
242
+ border: '1.5px solid rgba(255,255,255,0.16)',
243
+ display: 'flex',
244
+ alignItems: 'center',
245
+ justifyContent: 'center',
246
+ };
247
+
248
+ /** Icon-only circle button (no caption) — landscape's close/prev/next (Figma nodes 424:7556-7564).
249
+ * Portrait's HeaderAction/StageNavButton both show a caption or use a different tint, so this is
250
+ * its own component rather than a reuse. */
251
+ const LsCircleButton: React.FunctionComponent<{
252
+ icon: React.ReactNode;
253
+ ariaLabel: string;
254
+ onClick: () => void;
255
+ style?: React.CSSProperties;
256
+ }> = ({ icon, ariaLabel, onClick, style }) => (
257
+ <button type="button" onClick={onClick} aria-label={ariaLabel} style={{ ...lsCircleBg, position: 'absolute', border: 0, cursor: 'pointer', padding: 0, ...style }}>
258
+ <span style={lsCircleBg}>{icon}</span>
259
+ </button>
260
+ );
261
+
262
+ /** Counter pill, landscape only (Figma node 424:9277 "Counter / BG") — black/50%, fixed 72×42; NOT
263
+ * portrait's navy-tinted `pillStyle` (which is also used there for the 360 pill, absent here). */
264
+ const lsCounterStyle: React.CSSProperties = {
265
+ position: 'absolute',
266
+ left: 108,
267
+ top: 12,
268
+ width: 72,
269
+ height: 42,
270
+ borderRadius: 21,
271
+ background: 'rgba(0,0,0,0.5)',
272
+ border: '1px solid rgba(255,255,255,0.14)',
273
+ display: 'flex',
274
+ alignItems: 'center',
275
+ justifyContent: 'center',
276
+ color: '#FFFFFF',
277
+ fontFamily: "'Inter', var(--md-font)",
278
+ fontWeight: 500,
279
+ fontSize: 14,
280
+ lineHeight: '20px',
281
+ textAlign: 'center',
282
+ };
283
+
284
+ /** Header close/call action: circle icon button (Figma "Close|Call / Circle+Icon", node 424:8082-8089)
285
+ * with its caption stacked below. The circle fill/stroke are decoded from Figma's flattened PNG
286
+ * export (`get_design_context` rasterizes it because of the translucent glass fill) — not a guess.
287
+ * Mobile layout only. */
288
+ const HeaderAction: React.FunctionComponent<{
289
+ icon: React.ReactNode;
290
+ label: string;
291
+ ariaLabel: string;
292
+ onClick: () => void;
293
+ /** Hides the visible caption below the circle, keeping only the icon — used when this is reused
294
+ * inside the PhotoSheet's compact header, where a second "schließen" caption right under the
295
+ * sheet's own title would be redundant. `ariaLabel` is unaffected: it's always announced,
296
+ * visible caption or not. Defaults to true so the viewer's own header (this component's
297
+ * original call site) is unchanged. */
298
+ showLabel?: boolean;
299
+ }> = ({ icon, label, ariaLabel, onClick, showLabel = true }) => (
300
+ <button
301
+ type="button"
302
+ onClick={onClick}
303
+ aria-label={ariaLabel}
304
+ style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}
305
+ >
306
+ <span
307
+ style={{
308
+ width: 48,
309
+ height: 48,
310
+ borderRadius: '50%',
311
+ background: 'rgba(11,19,32,0.35)',
312
+ border: '1.5px solid rgba(255,255,255,0.25)',
313
+ display: 'flex',
314
+ alignItems: 'center',
315
+ justifyContent: 'center',
316
+ }}
317
+ >
318
+ {icon}
319
+ </span>
320
+ {showLabel && (
321
+ <span style={{ fontFamily: "'Inter', var(--md-font)", fontSize: 13, lineHeight: '20px', fontWeight: 400, color: '#fff', textTransform: 'capitalize' }}>
322
+ {label}
323
+ </span>
324
+ )}
325
+ </button>
326
+ );
327
+
328
+ /** Prev/next circle over the image (Figma "Prev|Next / Circle+Icon", node 424:8099-8103) — same
329
+ * decoding note as HeaderAction, different glass tint (darker, less border contrast). Mobile
330
+ * layout only — desktop keeps the original hover-reveal `.car-arrow` buttons. */
331
+ const StageNavButton: React.FunctionComponent<{ icon: React.ReactNode; ariaLabel: string; side: 'left' | 'right'; onClick: () => void }> = ({
332
+ icon,
333
+ ariaLabel,
334
+ side,
335
+ onClick,
336
+ }) => (
337
+ <button
338
+ type="button"
339
+ onClick={onClick}
340
+ aria-label={ariaLabel}
341
+ style={{
342
+ position: 'absolute',
343
+ ...(side === 'left' ? { left: 12 } : { right: 12 }),
344
+ top: '50%',
345
+ transform: 'translateY(-50%)',
346
+ width: 48,
347
+ height: 48,
348
+ borderRadius: '50%',
349
+ background: 'rgba(11,17,24,0.38)',
350
+ border: '1.5px solid rgba(255,255,255,0.16)',
351
+ display: 'flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'center',
354
+ cursor: 'pointer',
355
+ zIndex: 2,
356
+ }}
357
+ >
358
+ {icon}
359
+ </button>
360
+ );
361
+
362
+ /** Counter (top-left) / 360° (top-right) pill — Figma nodes 424:8090 (Counter/BG) and 424:8092
363
+ * (360/BG), the one pair of overlay backgrounds Figma resolved to plain CSS instead of a flattened
364
+ * image, so these values are the literal fills, not an approximation. Mobile layout only. */
365
+ const pillStyle: React.CSSProperties = {
366
+ position: 'absolute',
367
+ top: 18,
368
+ width: 72,
369
+ height: 42,
370
+ borderRadius: 21,
371
+ background: 'rgba(11,17,24,0.47)',
372
+ border: '1px solid rgba(255,255,255,0.14)',
373
+ display: 'flex',
374
+ alignItems: 'center',
375
+ justifyContent: 'center',
376
+ gap: 5,
377
+ color: '#FFFFFF',
378
+ fontFamily: "'Inter', var(--md-font)",
379
+ fontWeight: 500,
380
+ fontSize: 14,
381
+ lineHeight: '20px',
382
+ textAlign: 'center',
383
+ };
384
+
385
+ /** "Alle Fotos" bottom sheet, triggered by the "+N" thumbnail tile in both portrait and landscape
386
+ * (no equivalent on desktop — its thumbnail row already shows every photo, wrapped). Shares the
387
+ * active-index/select state with the main viewer; only the header row (close button + logo)
388
+ * differs per layout, so it's passed in as `headerContent` from the two call sites below rather
389
+ * than duplicated here — title/subtitle/grid/footer are the one shared, layout-agnostic part the
390
+ * spec calls for. All static styling lives in `md3.css` under `.vdp-photosheet*`; the only inline
391
+ * styles here are the live drag/open transform and the backdrop's open-state opacity, both of
392
+ * which depend on state that CSS alone can't express. */
393
+ const PhotoSheet: React.FunctionComponent<{
394
+ images: string[];
395
+ activeIndex: number;
396
+ open: boolean;
397
+ onSelect: (index: number) => void;
398
+ onCloseSheet: () => void;
399
+ t: TranslationFunction;
400
+ headerContent: React.ReactNode;
401
+ }> = ({ images, activeIndex, open, onSelect, onCloseSheet, t, headerContent }) => {
402
+ const n = images.length;
403
+ const gridRef = useRef<HTMLDivElement | null>(null);
404
+
405
+ // Scroll the CURRENT photo into view on open — only the grid's own scrollTop, never an ancestor
406
+ // (scrollIntoView can walk up the tree; computing the offset directly against this one ref can't).
407
+ useEffect(() => {
408
+ if (!open) return;
409
+ const grid = gridRef.current;
410
+ if (!grid) return;
411
+ const tile = grid.querySelector<HTMLElement>(`[data-photosheet-index="${activeIndex}"]`);
412
+ if (!tile) return;
413
+ grid.scrollTop = Math.max(0, tile.offsetTop - (grid.clientHeight - tile.offsetHeight) / 2);
414
+ // Only on open, deliberately: re-scrolling every time `activeIndex` changes while already open
415
+ // (e.g. the user just clicked a different photo, a click that's about to close the sheet
416
+ // anyway) would yank the scroll position out from under them.
417
+ // eslint-disable-next-line react-hooks/exhaustive-deps
418
+ }, [open]);
419
+
420
+ // Drag-to-dismiss from the header row only (not the grid itself, which needs its own vertical
421
+ // scroll — dragging the header is the conventional "handle" gesture and can't fight that scroll).
422
+ const dragRef = useRef<{ startY: number; startTime: number } | null>(null);
423
+ const [dragOffset, setDragOffset] = useState(0);
424
+ const onHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
425
+ dragRef.current = { startY: e.clientY, startTime: Date.now() };
426
+ try {
427
+ e.currentTarget.setPointerCapture(e.pointerId);
428
+ } catch {
429
+ // See VdpViewer's onStagePointerDown for why this is safe to swallow.
430
+ }
431
+ };
432
+ const onHeaderPointerMove = (e: React.PointerEvent<HTMLDivElement>): void => {
433
+ if (!dragRef.current) return;
434
+ setDragOffset(Math.max(0, e.clientY - dragRef.current.startY));
435
+ };
436
+ const onHeaderPointerUp = (e: React.PointerEvent<HTMLDivElement>): void => {
437
+ const start = dragRef.current;
438
+ dragRef.current = null;
439
+ if (start) {
440
+ const dy = Math.max(0, e.clientY - start.startY);
441
+ const dt = Date.now() - start.startTime;
442
+ const isFlick = dy > 40 && dt < 250;
443
+ if (dy > 90 || isFlick) onCloseSheet();
444
+ }
445
+ setDragOffset(0);
446
+ };
447
+
448
+ return (
449
+ <>
450
+ <div
451
+ className="vdp-photosheet-backdrop"
452
+ onClick={onCloseSheet}
453
+ style={{ opacity: open ? 1 : 0, pointerEvents: open ? 'auto' : 'none' }}
454
+ />
455
+ <div
456
+ className="vdp-photosheet"
457
+ style={{
458
+ transform: open ? `translateY(${dragOffset}px)` : 'translateY(100%)',
459
+ transition: dragRef.current ? 'none' : 'transform 280ms var(--ease-standard, ease)',
460
+ }}
461
+ >
462
+ <div
463
+ className="vdp-photosheet__header"
464
+ onPointerDown={onHeaderPointerDown}
465
+ onPointerMove={onHeaderPointerMove}
466
+ onPointerUp={onHeaderPointerUp}
467
+ onPointerCancel={onHeaderPointerUp}
468
+ >
469
+ <div className="vdp-photosheet__handle" />
470
+ {headerContent}
471
+ </div>
472
+ <div className="vdp-photosheet__titles">
473
+ <p className="vdp-photosheet__title">{t('autobahn:viewer.allPhotosTitle', { count: n })}</p>
474
+ <p className="vdp-photosheet__subtitle">{t('autobahn:viewer.allPhotosSubtitle')}</p>
475
+ </div>
476
+ <div className="vdp-photosheet__scroll" ref={gridRef}>
477
+ <div className="vdp-photosheet__grid">
478
+ {images.map((src, k) => {
479
+ const active = k === activeIndex;
480
+ return (
481
+ <button
482
+ key={k}
483
+ type="button"
484
+ data-photosheet-index={k}
485
+ className={'vdp-photosheet__tile' + (active ? ' vdp-photosheet__tile--active' : '')}
486
+ onClick={() => onSelect(k)}
487
+ aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
488
+ >
489
+ <img src={src} alt="" loading="lazy" />
490
+ {active && (
491
+ <span className="vdp-photosheet__badge">
492
+ <Icon name="check" size={12} color="#fff" />
493
+ </span>
494
+ )}
495
+ </button>
496
+ );
497
+ })}
498
+ </div>
499
+ </div>
500
+ <div className="vdp-photosheet__footer">
501
+ <Icon name="info" size={16} color="rgba(255,255,255,0.72)" />
502
+ <span>{t('autobahn:viewer.allPhotosFooter', { count: n })}</span>
503
+ </div>
504
+ </div>
505
+ </>
506
+ );
507
+ };
508
+
509
+ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail, on360 }) => {
50
510
  const [i, setI] = useState(start);
51
511
  const [zoom, setZoom] = useState(ZOOM_MIN);
512
+ const [pan, setPan] = useState({ x: 0, y: 0 });
52
513
  const n = images.length;
53
514
  const go = (d: number) => setI((p) => (p + d + n) % n);
515
+ const stageRef = useRef<HTMLDivElement | null>(null);
516
+
517
+ // "Alle Fotos" bottom sheet (mobile only — see PhotoSheet above). Opened from the "+N" thumbnail
518
+ // tile in portrait/landscape; picking a photo in it just reuses `i`/`setI`, so the counter and
519
+ // main image update for free. Read via a ref inside the keydown handler below (registered once
520
+ // with an empty dep array) so Escape can close the sheet first without resubscribing that effect.
521
+ const [sheetOpen, setSheetOpen] = useState(false);
522
+ const sheetOpenRef = useRef(sheetOpen);
523
+ sheetOpenRef.current = sheetOpen;
524
+ const selectFromSheet = (k: number): void => {
525
+ setI(k);
526
+ setSheetOpen(false);
527
+ };
528
+
529
+ // Which of the three layouts to render — re-checked on every resize/rotation so turning the
530
+ // phone from 390x693 to 693x390 while the viewer is OPEN swaps layouts live, not just on next
531
+ // open. Both 'resize' (fires on rotation in every current mobile browser) and 'orientationchange'
532
+ // are listened for: some browsers deliver 'orientationchange' a tick before window.innerWidth
533
+ // has actually updated to the new orientation's value, so that handler re-reads it on the next
534
+ // frame rather than trusting the value at event-fire time. `isMobile` (both portrait and
535
+ // landscape) is kept as a derived boolean for the one spot that still needs it: the desktop-only
536
+ // whole-overlay touch-swipe guard below.
537
+ const [layout, setLayout] = useState<Layout>(() => (typeof window !== 'undefined' ? layoutForWidth(window.innerWidth) : 'portrait'));
538
+ useEffect(() => {
539
+ const onResize = (): void => setLayout(layoutForWidth(window.innerWidth));
540
+ const onOrientationChange = (): void => {
541
+ requestAnimationFrame(onResize);
542
+ };
543
+ window.addEventListener('resize', onResize);
544
+ window.addEventListener('orientationchange', onOrientationChange);
545
+ return () => {
546
+ window.removeEventListener('resize', onResize);
547
+ window.removeEventListener('orientationchange', onOrientationChange);
548
+ };
549
+ }, []);
550
+ const isMobile = layout !== 'desktop';
551
+
552
+ // Reset zoom (and pan) on every slide change — a lingering zoom/pan from the previous photo
553
+ // reads as a stuck/broken control on the new one.
554
+ useEffect(() => {
555
+ setZoom(ZOOM_MIN);
556
+ setPan({ x: 0, y: 0 });
557
+ }, [i]);
54
558
 
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]);
559
+ // The zoomed image is `transform: translate(pan) scale(zoom)` on a box that's exactly the
560
+ // stage's size (the <img> is width/height:100%), so at a given zoom it can overhang the stage
561
+ // by at most (zoom-1)/2 of the stage's width/height on each side — clamp pan there so dragging
562
+ // can never pan the photo fully out of view, and re-clamp whenever zoom changes (zooming out
563
+ // shrinks the allowed range, so a pan that was valid before may now be out of bounds). Mobile
564
+ // layout only — the desktop image only ever scales (never translates).
565
+ const clampPan = (p: { x: number; y: number }, z: number): { x: number; y: number } => {
566
+ const el = stageRef.current;
567
+ if (!el || z <= ZOOM_MIN) return { x: 0, y: 0 };
568
+ const maxX = ((z - 1) * el.clientWidth) / 2;
569
+ const maxY = ((z - 1) * el.clientHeight) / 2;
570
+ return { x: Math.max(-maxX, Math.min(maxX, p.x)), y: Math.max(-maxY, Math.min(maxY, p.y)) };
571
+ };
572
+ useEffect(() => {
573
+ setPan((p) => clampPan(p, zoom));
574
+ // eslint-disable-next-line react-hooks/exhaustive-deps
575
+ }, [zoom]);
58
576
 
59
577
  // Keyboard nav + scroll lock while the overlay is mounted. `go`/`onClose` are
60
578
  // read from refs so the listener stays stable for the lifetime of the viewer.
@@ -64,7 +582,14 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
64
582
  closeRef.current = onClose;
65
583
  useEffect(() => {
66
584
  const onKey = (e: KeyboardEvent) => {
67
- if (e.key === 'Escape') closeRef.current();
585
+ if (e.key === 'Escape') {
586
+ // Escape closes the "Alle Fotos" sheet first if it's open — it's a modal-within-the-modal,
587
+ // so it owns Escape until it's dismissed — and only closes the whole viewer otherwise.
588
+ if (sheetOpenRef.current) setSheetOpen(false);
589
+ else closeRef.current();
590
+ return;
591
+ }
592
+ if (sheetOpenRef.current) return; // arrow-key photo nav belongs to the viewer, not the sheet
68
593
  if (e.key === 'ArrowRight') goRef.current(1);
69
594
  if (e.key === 'ArrowLeft') goRef.current(-1);
70
595
  };
@@ -77,10 +602,87 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
77
602
  };
78
603
  }, []);
79
604
 
80
- const tx = useRef<number | null>(null);
81
605
  const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
82
606
  const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
83
607
 
608
+ // Desktop swipe-to-navigate — the original behaviour, unchanged: a plain touch delta on the
609
+ // whole overlay, no pan.
610
+ const tx = useRef<number | null>(null);
611
+
612
+ // Mobile: one pointer-drag gesture serves two purposes depending on zoom level: at 1x it's a
613
+ // left/right swipe to change photo (mouse OR touch); zoomed in, it pans the enlarged photo
614
+ // around instead (that's the whole point of zooming in — being able to look at every part of
615
+ // it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
616
+ const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number } } | null>(null);
617
+ const onStagePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
618
+ // Don't hijack clicks on the buttons layered over the stage (arrows, counter, 360°, zoom).
619
+ if ((e.target as HTMLElement).closest('button')) return;
620
+ dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
621
+ try {
622
+ e.currentTarget.setPointerCapture(e.pointerId);
623
+ } catch {
624
+ // Capture is a "nice to have" (keeps the drag alive if the pointer strays outside the
625
+ // stage) — some browser/input combinations reject it for a pointerId that isn't tracked
626
+ // as active. The drag still works via normal bubbling as long as the pointer stays over
627
+ // the stage, so this is safe to swallow rather than let it surface as an uncaught error.
628
+ }
629
+ };
630
+ const onStagePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
631
+ const start = dragRef.current;
632
+ if (!start || zoom <= ZOOM_MIN) return;
633
+ const dx = e.clientX - start.x;
634
+ const dy = e.clientY - start.y;
635
+ setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
636
+ };
637
+ const onStagePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
638
+ const start = dragRef.current;
639
+ dragRef.current = null;
640
+ if (!start) return;
641
+ if (zoom <= ZOOM_MIN) {
642
+ const dx = e.clientX - start.x;
643
+ if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
644
+ }
645
+ };
646
+
647
+ // How many thumbnail slots fit in the strip — NOT a scroller (see render below): when there
648
+ // are more photos than fit, the last slot becomes a "+N remaining" tile (Figma node 424:8122
649
+ // "Thumb 05", Label Scrim + count) and nothing is rendered past it. Recomputed on resize (and
650
+ // whenever the layout itself flips) so the count stays correct across breakpoints. Mobile
651
+ // layout only — desktop keeps the original wrapping thumbnail row.
652
+ const thumbsWrapRef = useRef<HTMLDivElement | null>(null);
653
+ const [fitCount, setFitCount] = useState(images.length);
654
+ useEffect(() => {
655
+ const el = thumbsWrapRef.current;
656
+ if (!el) return undefined;
657
+ const measure = (): void => {
658
+ const usable = el.clientWidth - THUMBS_PAD * 2;
659
+ const count = Math.max(1, Math.floor((usable + THUMB_GAP) / (THUMB_W + THUMB_GAP)));
660
+ setFitCount(count);
661
+ };
662
+ measure();
663
+ window.addEventListener('resize', measure);
664
+ return () => window.removeEventListener('resize', measure);
665
+ }, [images.length, layout]);
666
+
667
+ // Landscape: same "fixed strip, cap tile if it doesn't fit" idea as portrait's fitCount above,
668
+ // but measured against its own ref/thumb width (68px, Figma node 424:7566) since the landscape
669
+ // thumbnail group sits in a flex row it shares with the CTA buttons (see render below) rather
670
+ // than owning the full row width the way portrait's does.
671
+ const lsThumbsWrapRef = useRef<HTMLDivElement | null>(null);
672
+ const [lsFitCount, setLsFitCount] = useState(images.length);
673
+ useEffect(() => {
674
+ const el = lsThumbsWrapRef.current;
675
+ if (!el) return undefined;
676
+ const measure = (): void => {
677
+ const count = Math.max(1, Math.floor((el.clientWidth + THUMB_GAP) / (LS_THUMB_W + THUMB_GAP)));
678
+ setLsFitCount(count);
679
+ };
680
+ measure();
681
+ window.addEventListener('resize', measure);
682
+ return () => window.removeEventListener('resize', measure);
683
+ }, [images.length, layout]);
684
+
685
+ // Desktop-only styles, unchanged from the original layout.
84
686
  const iconBtnStyle: React.CSSProperties = { background: 'var(--md-sc-lowest, #fff)', boxShadow: 'var(--md-e1)' };
85
687
  const contactBtnStyle: React.CSSProperties = { background: 'var(--md-primary)', boxShadow: 'var(--md-e1)' };
86
688
 
@@ -96,109 +698,696 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
96
698
  flexDirection: 'column',
97
699
  }}
98
700
  onTouchStart={(e) => {
701
+ if (isMobile) return; // mobile pans/swipes via the stage's own pointer handlers instead.
99
702
  tx.current = e.touches[0].clientX;
100
703
  }}
101
704
  onTouchEnd={(e) => {
102
- if (tx.current == null) return;
705
+ if (isMobile || tx.current == null) return;
103
706
  const dx = e.changedTouches[0].clientX - tx.current;
104
707
  tx.current = null;
105
708
  if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
106
709
  }}
107
710
  >
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
- }}
118
- >
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" />
711
+ {layout === 'portrait' ? (
712
+ <>
713
+ {/* full-width header: close (left) / auto.de logo (center) / call (right) — Figma "Top
714
+ Overlay" + "Header Divider" (nodes 424:8070-8089). */}
715
+ <div
716
+ style={{
717
+ position: 'relative',
718
+ flex: 'none',
719
+ display: 'flex',
720
+ alignItems: 'flex-start',
721
+ justifyContent: 'space-between',
722
+ padding: '12px 12px 10px',
723
+ background: 'rgba(6,11,18,0.8)',
724
+ borderBottom: '1px solid rgba(255,255,255,0.12)',
725
+ }}
726
+ >
727
+ <HeaderAction
728
+ icon={<IconClose />}
729
+ label={t('autobahn:viewer.closeLabel')}
730
+ ariaLabel={t('autobahn:viewer.closeLabel')}
731
+ onClick={onClose}
732
+ />
733
+ <div style={{ position: 'absolute', left: '50%', top: 10, transform: 'translateX(-50%)', width: 160, height: 71 }}>
734
+ <LogoMark />
735
+ </div>
736
+ {onCall ? (
737
+ <HeaderAction
738
+ icon={<IconPhone />}
739
+ label={t('autobahn:finance.callCta')}
740
+ ariaLabel={t('autobahn:viewer.callLabel', { phone })}
741
+ onClick={onCall}
742
+ />
743
+ ) : (
744
+ <span style={{ width: 48 }} />
745
+ )}
746
+ </div>
747
+
748
+ {/* stage: photo + counter/360 pills + prev/next + vertical zoom pill. */}
749
+ <div
750
+ ref={stageRef}
751
+ style={{ flex: 1, position: 'relative', minHeight: 0, background: '#797979', overflow: 'hidden', touchAction: 'none' }}
752
+ onPointerDown={onStagePointerDown}
753
+ onPointerMove={onStagePointerMove}
754
+ onPointerUp={onStagePointerUp}
755
+ onPointerCancel={onStagePointerUp}
756
+ >
757
+ <img
758
+ src={images[i]}
759
+ alt=""
760
+ draggable={false}
761
+ style={{
762
+ position: 'absolute',
763
+ inset: 0,
764
+ width: '100%',
765
+ height: '100%',
766
+ objectFit: 'contain',
767
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
768
+ transition: dragRef.current ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
769
+ cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
770
+ }}
771
+ />
772
+
773
+ <div style={{ ...pillStyle, left: 12 }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
774
+ <span className="num viewer-counter-num">
775
+ {i + 1} | {n}
776
+ </span>
777
+ </div>
778
+
779
+ <button
780
+ type="button"
781
+ onClick={() => on360 && on360()}
782
+ aria-label={t('autobahn:viewer.threeSixtyLabel')}
783
+ style={{ ...pillStyle, right: 12, border: '1px solid rgba(255,255,255,0.14)', cursor: 'pointer', gap: 3 }}
784
+ >
785
+ <Icon360 />
786
+ 360°
787
+ </button>
788
+
789
+ {n > 1 && (
790
+ <>
791
+ <StageNavButton icon={<IconChevronLeft />} side="left" ariaLabel={t('autobahn:viewer.previousLabel')} onClick={() => go(-1)} />
792
+ <StageNavButton icon={<IconChevronRight />} side="right" ariaLabel={t('autobahn:viewer.nextLabel')} onClick={() => go(1)} />
793
+ </>
794
+ )}
795
+
796
+ <div
797
+ style={{
798
+ position: 'absolute',
799
+ right: 12,
800
+ bottom: 16,
801
+ width: 48,
802
+ height: 125,
803
+ borderRadius: 51,
804
+ background: 'rgba(11,17,24,0.5)',
805
+ border: '1.2px solid rgba(255,255,255,0.18)',
806
+ display: 'flex',
807
+ flexDirection: 'column',
808
+ alignItems: 'center',
809
+ padding: '12px 0',
810
+ }}
811
+ >
812
+ <button
813
+ type="button"
814
+ onClick={zoomIn}
815
+ disabled={zoom === ZOOM_MAX}
816
+ aria-label={t('autobahn:viewer.zoomInLabel')}
817
+ style={{ width: 40, height: 40, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
818
+ >
819
+ <IconZoomIn dim={zoom === ZOOM_MAX} />
820
+ </button>
821
+ <div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '10px 0' }} />
822
+ <button
823
+ type="button"
824
+ onClick={zoomOut}
825
+ disabled={zoom === ZOOM_MIN}
826
+ aria-label={t('autobahn:viewer.zoomOutLabel')}
827
+ style={{ width: 40, height: 40, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
828
+ >
829
+ <IconZoomOut />
830
+ </button>
831
+ </div>
832
+ </div>
833
+
834
+ {/* fixed thumbnail strip — Figma "Thumb 0N" nodes 424:8105-8127. Not a scroller: it always
835
+ shows exactly as many real thumbnails as fit, and if there are more photos than that, the
836
+ LAST slot becomes a "+N remaining" tile and nothing is rendered past it. */}
837
+ {(() => {
838
+ const overflow = images.length > fitCount;
839
+ const visibleCount = overflow ? fitCount - 1 : images.length;
840
+ const remaining = images.length - visibleCount;
841
+ return (
842
+ <div ref={thumbsWrapRef} style={{ display: 'flex', gap: THUMB_GAP, padding: THUMBS_PAD, flex: 'none', overflow: 'hidden', background: '#797979' }}>
843
+ {images.slice(0, visibleCount).map((src, k) => {
844
+ const selected = k === i;
845
+ return (
846
+ <button
847
+ key={k}
848
+ type="button"
849
+ onClick={() => setI(k)}
850
+ aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
851
+ style={{
852
+ flex: 'none',
853
+ width: THUMB_W,
854
+ height: THUMB_H,
855
+ padding: 0,
856
+ borderRadius: selected ? 16 : 10,
857
+ overflow: 'hidden',
858
+ cursor: 'pointer',
859
+ background: '#797979',
860
+ border: selected ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
861
+ outline: 'none',
862
+ outlineOffset: 0,
863
+ }}
864
+ >
865
+ <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
866
+ </button>
867
+ );
868
+ })}
869
+ {remaining > 0 && (
870
+ <button
871
+ key="more"
872
+ type="button"
873
+ onClick={() => setSheetOpen(true)}
874
+ aria-label={t('autobahn:viewer.moreThumbsLabel', { count: remaining })}
875
+ style={{
876
+ position: 'relative',
877
+ flex: 'none',
878
+ width: THUMB_W,
879
+ height: THUMB_H,
880
+ padding: 0,
881
+ borderRadius: i >= visibleCount ? 16 : 10,
882
+ overflow: 'hidden',
883
+ cursor: 'pointer',
884
+ background: '#797979',
885
+ border: i >= visibleCount ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
886
+ outline: 'none',
887
+ outlineOffset: 0,
888
+ }}
889
+ >
890
+ <img src={images[visibleCount]} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
891
+ <span
892
+ style={{
893
+ position: 'absolute',
894
+ inset: 0,
895
+ background: 'rgba(5,8,13,0.5)',
896
+ display: 'flex',
897
+ alignItems: 'center',
898
+ justifyContent: 'center',
899
+ color: '#fff',
900
+ fontFamily: "'Inter', var(--md-font-display)",
901
+ fontSize: 18,
902
+ lineHeight: '24px',
903
+ fontWeight: 700,
904
+ }}
905
+ >
906
+ +{remaining}
907
+ </span>
908
+ </button>
909
+ )}
910
+ </div>
911
+ );
912
+ })()}
913
+
914
+ {/* full-width bottom bar — "Jetzt Anfragen" (primary) + "Anrufen" (dark), Figma nodes
915
+ 424:8128-8134. */}
916
+ {(onEmail || onCall) && (
917
+ <div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: '#797979', flex: 'none' }}>
918
+ {onEmail && (
919
+ <button
920
+ type="button"
921
+ onClick={onEmail}
922
+ style={{
923
+ flex: '0 0 auto',
924
+ width: 220,
925
+ height: 56,
926
+ border: 0,
927
+ borderRadius: 16,
928
+ background: '#0B63F6',
929
+ color: '#FFFFFF',
930
+ display: 'flex',
931
+ alignItems: 'center',
932
+ justifyContent: 'center',
933
+ gap: 5,
934
+ fontFamily: "'Inter', var(--md-font)",
935
+ fontSize: 20,
936
+ lineHeight: '28px',
937
+ fontWeight: 700,
938
+ cursor: 'pointer',
939
+ }}
940
+ >
941
+ <IconOfferRequest size={32} />
942
+ {t('autobahn:viewer.requestLabel')}
943
+ </button>
944
+ )}
945
+ {onCall && (
946
+ <button
947
+ type="button"
948
+ onClick={onCall}
949
+ style={{
950
+ flex: 1,
951
+ height: 56,
952
+ border: 0,
953
+ borderRadius: 'var(--md-r-md)',
954
+ background: 'rgba(29,35,45,0.96)',
955
+ color: '#fff',
956
+ display: 'flex',
957
+ alignItems: 'center',
958
+ justifyContent: 'center',
959
+ gap: 8,
960
+ fontFamily: "'Inter', var(--md-font)",
961
+ fontSize: 20,
962
+ lineHeight: '28px',
963
+ fontWeight: 700,
964
+ cursor: 'pointer',
965
+ }}
966
+ >
967
+ <IconPhone size={22} />
968
+ {t('autobahn:finance.callCta')}
969
+ </button>
970
+ )}
971
+ </div>
137
972
  )}
138
- {onCall && (
139
- <IconButton
140
- name="phone"
141
- label={t('autobahn:viewer.callLabel', { phone })}
142
- onClick={onCall}
143
- style={contactBtnStyle}
144
- color="#fff"
973
+
974
+ {/* "Alle Fotos" bottom sheet — triggered by the "+N" tile above. Header reuses the SAME
975
+ close button (HeaderAction) and logo (LogoMark) as this layout's own top bar. */}
976
+ <PhotoSheet
977
+ images={images}
978
+ activeIndex={i}
979
+ open={sheetOpen}
980
+ onSelect={selectFromSheet}
981
+ onCloseSheet={() => setSheetOpen(false)}
982
+ t={t}
983
+ headerContent={
984
+ <>
985
+ <div className="vdp-photosheet__close">
986
+ <HeaderAction icon={<IconClose />} label={t('autobahn:viewer.closeLabel')} ariaLabel={t('autobahn:viewer.closeLabel')} onClick={() => setSheetOpen(false)} showLabel={false} />
987
+ </div>
988
+ <div className="vdp-photosheet__logo vdp-photosheet__logo--portrait">
989
+ <LogoMark color="#005CFF" />
990
+ </div>
991
+ </>
992
+ }
993
+ />
994
+ </>
995
+ ) : layout === 'landscape' ? (
996
+ // ====================================================================
997
+ // LANDSCAPE (693 < width <= 860) — Figma file qyZFhH6grmzQ1ptNwPcilw,
998
+ // node 424:7525 "FRAME | Galerie | Landscape 693x390 | V01". Everything
999
+ // below is scoped to THIS branch only; portrait (above) and desktop
1000
+ // (below) are untouched.
1001
+ // ====================================================================
1002
+ <div style={{ position: 'relative', flex: 1, overflow: 'hidden', background: '#797979' }}>
1003
+ {/* image stage — full-bleed (edge-to-edge over the whole landscape canvas): the photo
1004
+ fills 100% width/height via `inset:0` + the <img>'s own `object-fit:cover` below.
1005
+ The floating controls (logo/counter/close/prev-next/zoom/thumbnails) are independent
1006
+ SIBLINGS of this div, each positioned with their own absolute coordinates against
1007
+ the same landscape root — not children of the stage — so widening the stage to full
1008
+ bleed doesn't move or resize any of them. */}
1009
+ <div
1010
+ ref={stageRef}
1011
+ style={{ position: 'absolute', inset: 0, touchAction: 'none' }}
1012
+ onPointerDown={onStagePointerDown}
1013
+ onPointerMove={onStagePointerMove}
1014
+ onPointerUp={onStagePointerUp}
1015
+ onPointerCancel={onStagePointerUp}
1016
+ >
1017
+ <img
1018
+ src={images[i]}
1019
+ alt=""
1020
+ draggable={false}
1021
+ style={{
1022
+ position: 'absolute',
1023
+ inset: 0,
1024
+ width: '100%',
1025
+ height: '100%',
1026
+ objectFit: 'cover',
1027
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
1028
+ transition: dragRef.current ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
1029
+ cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
1030
+ }}
145
1031
  />
1032
+ </div>
1033
+
1034
+ {/* logo — Figma node 424:9330 "auto-de_RGB (2) 1": the actual asset there is the
1035
+ brand-blue/gradient mark (verified against its raw export), i.e. the SAME svg this
1036
+ codebase already ships as `assets/autode-logo.svg` — reused directly, not portrait's
1037
+ white LogoMark (landscape has no dark bar behind it to need a white variant). */}
1038
+ <div style={{ position: 'absolute', left: 4, top: 10, width: 93, height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
1039
+ <img src={logoUrl} alt="auto.de" style={{ width: 74, height: 21.46, display: 'block' }} />
1040
+ </div>
1041
+
1042
+ {/* counter pill — Figma node 424:9277 "Counter / BG" */}
1043
+ <div style={lsCounterStyle} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
1044
+ <span className="num">
1045
+ {i + 1} | {n}
1046
+ </span>
1047
+ </div>
1048
+
1049
+ {/* close — Figma nodes 424:7556/7557, no caption text in this layout */}
1050
+ <LsCircleButton icon={<IconClose />} ariaLabel={t('autobahn:viewer.closeLabel')} onClick={onClose} style={{ right: 12, top: 12 }} />
1051
+
1052
+ {/* prev/next — Figma nodes 424:7560/7561 and 424:7563/7564 */}
1053
+ {n > 1 && (
1054
+ <>
1055
+ <LsCircleButton icon={<IconChevronLeft />} ariaLabel={t('autobahn:viewer.previousLabel')} onClick={() => go(-1)} style={{ left: 12, top: 126 }} />
1056
+ <LsCircleButton icon={<IconChevronRight />} ariaLabel={t('autobahn:viewer.nextLabel')} onClick={() => go(1)} style={{ right: 12, top: 126 }} />
1057
+ </>
146
1058
  )}
147
- <IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
1059
+
1060
+ {/* zoom +/- and 360° column — Figma node 424:9246 "Zoom / BG" (h=150, taller than
1061
+ portrait's 110 because 360° lives inside this column instead of its own top pill). */}
1062
+ <div
1063
+ style={{
1064
+ position: 'absolute',
1065
+ right: 12,
1066
+ bottom: 12,
1067
+ width: 48,
1068
+ height: 150,
1069
+ borderRadius: 'var(--md-r-full)',
1070
+ background: 'rgba(0,0,0,0.5)',
1071
+ border: '1.2px solid rgba(255,255,255,0.18)',
1072
+ display: 'flex',
1073
+ flexDirection: 'column',
1074
+ alignItems: 'center',
1075
+ padding: '9px 0',
1076
+ }}
1077
+ >
1078
+ <button
1079
+ type="button"
1080
+ onClick={zoomIn}
1081
+ disabled={zoom === ZOOM_MAX}
1082
+ aria-label={t('autobahn:viewer.zoomInLabel')}
1083
+ style={{ width: 32, height: 32, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
1084
+ >
1085
+ <IconZoomIn dim={zoom === ZOOM_MAX} />
1086
+ </button>
1087
+ <div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '8px 0' }} />
1088
+ <button
1089
+ type="button"
1090
+ onClick={zoomOut}
1091
+ disabled={zoom === ZOOM_MIN}
1092
+ aria-label={t('autobahn:viewer.zoomOutLabel')}
1093
+ style={{ width: 32, height: 32, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
1094
+ >
1095
+ <IconZoomOut />
1096
+ </button>
1097
+ <div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '8px 0' }} />
1098
+ <button
1099
+ type="button"
1100
+ onClick={() => on360 && on360()}
1101
+ aria-label={t('autobahn:viewer.threeSixtyLabel')}
1102
+ style={{ position: 'relative', width: 30, height: 30, border: 0, background: 'none', padding: 0, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
1103
+ >
1104
+ <Ls360Circle />
1105
+ <span
1106
+ style={{
1107
+ position: 'absolute',
1108
+ top: '50%',
1109
+ left: '50%',
1110
+ transform: 'translate(-50%, -50%)',
1111
+ fontFamily: "'Inter', var(--md-font)",
1112
+ fontWeight: 700,
1113
+ fontSize: 8,
1114
+ lineHeight: '8px',
1115
+ color: '#fff',
1116
+ pointerEvents: 'none',
1117
+ }}
1118
+ >
1119
+ 360°
1120
+ </span>
1121
+ </button>
1122
+ </div>
1123
+
1124
+ {/* bottom band: thumbnails (left, flexible width) + CTA buttons (right, natural width)
1125
+ sharing one row at bottom:12 (Figma nodes 424:7566-7594) so both groups optically
1126
+ align the way the design does, without hardcoding a container width that would break
1127
+ across the 693-860 range. `right: 81` keeps this band clear of the zoom/360 column
1128
+ (12 right-margin + 48 wide + the 21px gap Figma leaves before it = 81). */}
1129
+ <div style={{ position: 'absolute', left: 12, right: 81, bottom: 12, height: 50, display: 'flex', alignItems: 'center' }}>
1130
+ {(() => {
1131
+ const overflow = images.length > lsFitCount;
1132
+ const visibleCount = overflow ? lsFitCount - 1 : images.length;
1133
+ const remaining = images.length - visibleCount;
1134
+ return (
1135
+ <div ref={lsThumbsWrapRef} style={{ flex: '1 1 0', minWidth: 0, display: 'flex', alignItems: 'center', gap: THUMB_GAP, overflow: 'hidden' }}>
1136
+ {images.slice(0, visibleCount).map((src, k) => {
1137
+ const selected = k === i;
1138
+ return (
1139
+ <button
1140
+ key={k}
1141
+ type="button"
1142
+ onClick={() => setI(k)}
1143
+ aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
1144
+ style={{
1145
+ flex: 'none',
1146
+ width: LS_THUMB_W,
1147
+ height: THUMB_H,
1148
+ padding: 0,
1149
+ borderRadius: selected ? 16 : 'var(--md-r-md)',
1150
+ overflow: 'hidden',
1151
+ cursor: 'pointer',
1152
+ background: '#fff',
1153
+ border: selected ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
1154
+ outline: 'none',
1155
+ outlineOffset: 0,
1156
+ }}
1157
+ >
1158
+ <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
1159
+ </button>
1160
+ );
1161
+ })}
1162
+ {remaining > 0 && (
1163
+ <button
1164
+ key="more"
1165
+ type="button"
1166
+ onClick={() => setSheetOpen(true)}
1167
+ aria-label={t('autobahn:viewer.moreThumbsLabel', { count: remaining })}
1168
+ style={{
1169
+ position: 'relative',
1170
+ flex: 'none',
1171
+ width: LS_THUMB_W,
1172
+ height: THUMB_H,
1173
+ padding: 0,
1174
+ borderRadius: i >= visibleCount ? 16 : 'var(--md-r-md)',
1175
+ overflow: 'hidden',
1176
+ cursor: 'pointer',
1177
+ background: '#fff',
1178
+ border: i >= visibleCount ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
1179
+ outline: 'none',
1180
+ outlineOffset: 0,
1181
+ }}
1182
+ >
1183
+ <img src={images[visibleCount]} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
1184
+ {/* landscape's cap tile uses the SAME light 0.14 overlay as every other
1185
+ thumb (Figma node 424:9348), not portrait's heavier 0.5 "Label Scrim". */}
1186
+ <span
1187
+ style={{
1188
+ position: 'absolute',
1189
+ inset: 0,
1190
+ background: 'rgba(0,0,0,0.14)',
1191
+ display: 'flex',
1192
+ alignItems: 'center',
1193
+ justifyContent: 'center',
1194
+ color: '#fff',
1195
+ fontFamily: "'Inter', var(--md-font-display)",
1196
+ fontSize: 18,
1197
+ lineHeight: '24px',
1198
+ fontWeight: 700,
1199
+ }}
1200
+ >
1201
+ +{remaining}
1202
+ </span>
1203
+ </button>
1204
+ )}
1205
+ </div>
1206
+ );
1207
+ })()}
1208
+ {(onEmail || onCall) && (
1209
+ <div style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: THUMB_GAP }}>
1210
+ {onEmail && (
1211
+ <button
1212
+ type="button"
1213
+ onClick={onEmail}
1214
+ style={{
1215
+ flex: 'none',
1216
+ height: 40,
1217
+ padding: '0 16px',
1218
+ border: 0,
1219
+ borderRadius: 'var(--md-r-md)',
1220
+ background: 'var(--md-primary)',
1221
+ color: '#fff',
1222
+ display: 'flex',
1223
+ alignItems: 'center',
1224
+ justifyContent: 'center',
1225
+ gap: 8,
1226
+ fontFamily: "'Inter', var(--md-font)",
1227
+ fontSize: 13,
1228
+ lineHeight: '28px',
1229
+ fontWeight: 700,
1230
+ cursor: 'pointer',
1231
+ whiteSpace: 'nowrap',
1232
+ }}
1233
+ >
1234
+ <IconOfferRequest size={32} />
1235
+ {t('autobahn:viewer.requestLabel')}
1236
+ </button>
1237
+ )}
1238
+ {onCall && (
1239
+ <button
1240
+ type="button"
1241
+ onClick={onCall}
1242
+ style={{
1243
+ flex: 'none',
1244
+ height: 40,
1245
+ padding: '0 16px',
1246
+ border: 0,
1247
+ borderRadius: 'var(--md-r-md)',
1248
+ background: '#1c222c',
1249
+ color: '#fff',
1250
+ display: 'flex',
1251
+ alignItems: 'center',
1252
+ justifyContent: 'center',
1253
+ gap: 8,
1254
+ fontFamily: "'Inter', var(--md-font)",
1255
+ fontSize: 13,
1256
+ lineHeight: '28px',
1257
+ fontWeight: 700,
1258
+ cursor: 'pointer',
1259
+ whiteSpace: 'nowrap',
1260
+ }}
1261
+ >
1262
+ <IconPhone size={18} />
1263
+ {t('autobahn:finance.callCta')}
1264
+ </button>
1265
+ )}
1266
+ </div>
1267
+ )}
1268
+ </div>
1269
+
1270
+ {/* "Alle Fotos" bottom sheet — triggered by the "+N" tile above. Header reuses the SAME
1271
+ close button (LsCircleButton, re-parented into normal flow via a position override)
1272
+ and logo (the real logoUrl asset) as this layout's own floating controls. */}
1273
+ <PhotoSheet
1274
+ images={images}
1275
+ activeIndex={i}
1276
+ open={sheetOpen}
1277
+ onSelect={selectFromSheet}
1278
+ onCloseSheet={() => setSheetOpen(false)}
1279
+ t={t}
1280
+ headerContent={
1281
+ <>
1282
+ <div className="vdp-photosheet__close">
1283
+ <LsCircleButton icon={<IconClose />} ariaLabel={t('autobahn:viewer.closeLabel')} onClick={() => setSheetOpen(false)} style={{ position: 'relative' }} />
1284
+ </div>
1285
+ <div className="vdp-photosheet__logo">
1286
+ <img src={logoUrl} alt="auto.de" style={{ width: 74, height: 21.46, display: 'block' }} />
1287
+ </div>
1288
+ </>
1289
+ }
1290
+ />
148
1291
  </div>
149
- </div>
150
- <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
151
- <img
152
- src={images[i]}
153
- alt=""
154
- style={{
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)',
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)}
169
- >
170
- <Icon name="chevron-left" size={22} />
171
- </button>
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 })}
1292
+ ) : (
1293
+ <>
1294
+ {/* Original desktop layout — unchanged. */}
1295
+ <div
187
1296
  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',
1297
+ display: 'flex',
1298
+ alignItems: 'center',
1299
+ justifyContent: 'space-between',
1300
+ gap: 8,
1301
+ padding: '12px 16px',
1302
+ borderBottom: '1px solid var(--md-outline-variant)',
1303
+ flexWrap: 'wrap',
196
1304
  }}
197
1305
  >
198
- <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
199
- </button>
200
- ))}
201
- </div>
1306
+ <span className="num md-title-sm">
1307
+ {i + 1} / {n}
1308
+ </span>
1309
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1310
+ <IconButton
1311
+ name="minus"
1312
+ label={t('autobahn:viewer.zoomOutLabel')}
1313
+ onClick={zoomOut}
1314
+ style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MIN ? 0.4 : 1 }}
1315
+ />
1316
+ <IconButton
1317
+ name="plus"
1318
+ label={t('autobahn:viewer.zoomInLabel')}
1319
+ onClick={zoomIn}
1320
+ style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
1321
+ />
1322
+ {onEmail && (
1323
+ <IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
1324
+ )}
1325
+ {onCall && (
1326
+ <IconButton
1327
+ name="phone"
1328
+ label={t('autobahn:viewer.callLabel', { phone })}
1329
+ onClick={onCall}
1330
+ style={contactBtnStyle}
1331
+ color="#fff"
1332
+ />
1333
+ )}
1334
+ <IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
1335
+ </div>
1336
+ </div>
1337
+ <div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
1338
+ <img
1339
+ src={images[i]}
1340
+ alt=""
1341
+ style={{
1342
+ position: 'absolute',
1343
+ inset: 0,
1344
+ width: '100%',
1345
+ height: '100%',
1346
+ objectFit: 'contain',
1347
+ transform: `scale(${zoom})`,
1348
+ transition: 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
1349
+ }}
1350
+ />
1351
+ <button
1352
+ className="car-arrow car-arrow--l hide-mobile"
1353
+ style={{ opacity: 1 }}
1354
+ aria-label={t('autobahn:viewer.previousLabel')}
1355
+ onClick={() => go(-1)}
1356
+ >
1357
+ <Icon name="chevron-left" size={22} />
1358
+ </button>
1359
+ <button
1360
+ className="car-arrow car-arrow--r hide-mobile"
1361
+ style={{ opacity: 1 }}
1362
+ aria-label={t('autobahn:viewer.nextLabel')}
1363
+ onClick={() => go(1)}
1364
+ >
1365
+ <Icon name="chevron-right" size={22} />
1366
+ </button>
1367
+ </div>
1368
+ <div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '12px 16px', flexWrap: 'wrap' }}>
1369
+ {images.map((src, k) => (
1370
+ <button
1371
+ key={k}
1372
+ onClick={() => setI(k)}
1373
+ aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
1374
+ style={{
1375
+ width: 64,
1376
+ height: 44,
1377
+ padding: 0,
1378
+ borderRadius: 'var(--md-r-sm)',
1379
+ overflow: 'hidden',
1380
+ cursor: 'pointer',
1381
+ border: k === i ? '2px solid var(--md-primary)' : '1px solid var(--md-outline-variant)',
1382
+ background: '#fff',
1383
+ }}
1384
+ >
1385
+ <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
1386
+ </button>
1387
+ ))}
1388
+ </div>
1389
+ </>
1390
+ )}
202
1391
  </div>
203
1392
  </div>
204
1393
  );