@bytebrand/fe-ui-core-autobahn 1.0.56 → 1.0.58

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,18 +1,30 @@
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). Renders one of two completely
5
- // separate layouts depending on viewport width (re-evaluated live on resize, breakpoint
6
- // MOBILE_BREAKPOINT = 860px):
7
- // - width <= 860: the Figma "Component | Galerie | Portrait 390x693 | V01" frame (node
8
- // 424:8154), rebuilt pixel-for-pixel — full-width header (logo + close + call), a
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
9
8
  // photo-count / 360° pill pair over the image, always-visible prev/next circles, a vertical
10
9
  // zoom +/- pill, a single fixed thumbnail strip with a "+N" cap tile, and a full-width bottom
11
- // bar with the two primary CTAs.
12
- // - width > 860: the ORIGINAL desktop layout from before that redesign work (single header row
13
- // with counter + zoom + email + call + close, hover-reveal `.car-arrow` prev/next, a wrapping
14
- // thumbnail row) — deliberately left untouched so desktop keeps behaving exactly as it did.
15
- // The two layouts share the same state (index/zoom/pan, keyboard nav, scroll lock) — only the
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
16
28
  // chrome around the photo differs.
17
29
  //
18
30
  // Pure / presentational: images arrive ALREADY RESOLVED (the design kit's
@@ -34,6 +46,7 @@ import * as ReactDOM from 'react-dom';
34
46
  import { TranslationFunction } from '../types';
35
47
 
36
48
  import { Icon, IconButton } from '../ui';
49
+ import logoUrl from '../assets/autode-logo.svg';
37
50
 
38
51
  const ZOOM_MIN = 1;
39
52
  const ZOOM_MAX = 3;
@@ -42,8 +55,17 @@ const THUMB_W = 64;
42
55
  const THUMB_H = 50;
43
56
  const THUMB_GAP = 8;
44
57
  const THUMBS_PAD = 12;
45
- /** <=860px renders the Figma mobile gallery; wider renders the original desktop layout. */
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. */
46
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');
47
69
 
48
70
  export interface VdpViewerProps {
49
71
  /** ready-to-use image URLs (already resolved by the caller). */
@@ -139,14 +161,15 @@ const IconZoomIn: React.FunctionComponent<{ dim?: boolean }> = ({ dim }) => (
139
161
  />
140
162
  </svg>
141
163
  );
142
- const IconZoomOut: React.FunctionComponent<{ dim?: boolean }> = ({ dim }) => (
143
- <svg width={30} height={30} viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style={{ opacity: dim ? 0.4 : 1 }}>
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">
144
166
  <path
145
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"
146
168
  stroke="white"
147
169
  strokeWidth={1.9}
148
170
  />
149
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" />
150
173
  </svg>
151
174
  );
152
175
 
@@ -177,31 +200,103 @@ const IconOfferRequest: React.FunctionComponent<{ size?: number }> = ({ size = 2
177
200
  </svg>
178
201
  );
179
202
 
180
- /** Figma "Logo" (node 424:8073, 160×71) — the auto.de wordmark, already authored in white for this
181
- * dark-header context (not the brand-blue mark used elsewhere, so no CSS filter trick is needed). */
182
- const LogoMark: React.FunctionComponent = () => (
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' }) => (
183
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">
184
- <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="white" />
185
- <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="white" />
186
- <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="white" />
187
- <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="white" />
188
- <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="white" />
189
- <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="white" />
190
- <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="white" />
191
- <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="white" />
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} />
192
231
  </svg>
193
232
  );
194
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
+
195
284
  /** Header close/call action: circle icon button (Figma "Close|Call / Circle+Icon", node 424:8082-8089)
196
285
  * with its caption stacked below. The circle fill/stroke are decoded from Figma's flattened PNG
197
286
  * export (`get_design_context` rasterizes it because of the translucent glass fill) — not a guess.
198
287
  * Mobile layout only. */
199
- const HeaderAction: React.FunctionComponent<{ icon: React.ReactNode; label: string; ariaLabel: string; onClick: () => void }> = ({
200
- icon,
201
- label,
202
- ariaLabel,
203
- onClick,
204
- }) => (
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 }) => (
205
300
  <button
206
301
  type="button"
207
302
  onClick={onClick}
@@ -222,9 +317,11 @@ const HeaderAction: React.FunctionComponent<{ icon: React.ReactNode; label: stri
222
317
  >
223
318
  {icon}
224
319
  </span>
225
- <span style={{ fontFamily: "'Inter', var(--md-font)", fontSize: 13, lineHeight: '20px', fontWeight: 400, color: '#fff', textTransform: 'capitalize' }}>
226
- {label}
227
- </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
+ )}
228
325
  </button>
229
326
  );
230
327
 
@@ -285,6 +382,130 @@ const pillStyle: React.CSSProperties = {
285
382
  textAlign: 'center',
286
383
  };
287
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
+
288
509
  const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail, on360 }) => {
289
510
  const [i, setI] = useState(start);
290
511
  const [zoom, setZoom] = useState(ZOOM_MIN);
@@ -293,14 +514,40 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
293
514
  const go = (d: number) => setI((p) => (p + d + n) % n);
294
515
  const stageRef = useRef<HTMLDivElement | null>(null);
295
516
 
296
- // Which layout to render — re-checked on every resize so rotating a tablet or resizing a
297
- // desktop window across the breakpoint switches layouts live instead of only on next open.
298
- const [isMobile, setIsMobile] = useState(() => (typeof window !== 'undefined' ? window.innerWidth <= MOBILE_BREAKPOINT : true));
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'));
299
538
  useEffect(() => {
300
- const onResize = (): void => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
539
+ const onResize = (): void => setLayout(layoutForWidth(window.innerWidth));
540
+ const onOrientationChange = (): void => {
541
+ requestAnimationFrame(onResize);
542
+ };
301
543
  window.addEventListener('resize', onResize);
302
- return () => window.removeEventListener('resize', onResize);
544
+ window.addEventListener('orientationchange', onOrientationChange);
545
+ return () => {
546
+ window.removeEventListener('resize', onResize);
547
+ window.removeEventListener('orientationchange', onOrientationChange);
548
+ };
303
549
  }, []);
550
+ const isMobile = layout !== 'desktop';
304
551
 
305
552
  // Reset zoom (and pan) on every slide change — a lingering zoom/pan from the previous photo
306
553
  // reads as a stuck/broken control on the new one.
@@ -335,7 +582,14 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
335
582
  closeRef.current = onClose;
336
583
  useEffect(() => {
337
584
  const onKey = (e: KeyboardEvent) => {
338
- 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
339
593
  if (e.key === 'ArrowRight') goRef.current(1);
340
594
  if (e.key === 'ArrowLeft') goRef.current(-1);
341
595
  };
@@ -408,7 +662,25 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
408
662
  measure();
409
663
  window.addEventListener('resize', measure);
410
664
  return () => window.removeEventListener('resize', measure);
411
- }, [images.length, isMobile]);
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]);
412
684
 
413
685
  // Desktop-only styles, unchanged from the original layout.
414
686
  const iconBtnStyle: React.CSSProperties = { background: 'var(--md-sc-lowest, #fff)', boxShadow: 'var(--md-e1)' };
@@ -436,7 +708,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
436
708
  if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
437
709
  }}
438
710
  >
439
- {isMobile ? (
711
+ {layout === 'portrait' ? (
440
712
  <>
441
713
  {/* full-width header: close (left) / auto.de logo (center) / call (right) — Figma "Top
442
714
  Overlay" + "Header Divider" (nodes 424:8070-8089). */}
@@ -476,7 +748,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
476
748
  {/* stage: photo + counter/360 pills + prev/next + vertical zoom pill. */}
477
749
  <div
478
750
  ref={stageRef}
479
- style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden', touchAction: 'none' }}
751
+ style={{ flex: 1, position: 'relative', minHeight: 0, background: '#797979', overflow: 'hidden', touchAction: 'none' }}
480
752
  onPointerDown={onStagePointerDown}
481
753
  onPointerMove={onStagePointerMove}
482
754
  onPointerUp={onStagePointerUp}
@@ -554,7 +826,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
554
826
  aria-label={t('autobahn:viewer.zoomOutLabel')}
555
827
  style={{ width: 40, height: 40, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
556
828
  >
557
- <IconZoomOut dim={zoom === ZOOM_MIN} />
829
+ <IconZoomOut />
558
830
  </button>
559
831
  </div>
560
832
  </div>
@@ -567,7 +839,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
567
839
  const visibleCount = overflow ? fitCount - 1 : images.length;
568
840
  const remaining = images.length - visibleCount;
569
841
  return (
570
- <div ref={thumbsWrapRef} style={{ display: 'flex', gap: THUMB_GAP, padding: THUMBS_PAD, flex: 'none', overflow: 'hidden' }}>
842
+ <div ref={thumbsWrapRef} style={{ display: 'flex', gap: THUMB_GAP, padding: THUMBS_PAD, flex: 'none', overflow: 'hidden', background: '#797979' }}>
571
843
  {images.slice(0, visibleCount).map((src, k) => {
572
844
  const selected = k === i;
573
845
  return (
@@ -581,13 +853,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
581
853
  width: THUMB_W,
582
854
  height: THUMB_H,
583
855
  padding: 0,
584
- borderRadius: 10,
856
+ borderRadius: selected ? 16 : 10,
585
857
  overflow: 'hidden',
586
858
  cursor: 'pointer',
587
- background: '#fff',
588
- border: selected ? 'none' : '1px solid var(--fig-gray)',
589
- outline: selected ? '3px solid var(--md-primary)' : 'none',
590
- outlineOffset: selected ? 1 : 0,
859
+ background: '#797979',
860
+ border: selected ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
861
+ outline: 'none',
862
+ outlineOffset: 0,
591
863
  }}
592
864
  >
593
865
  <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -598,7 +870,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
598
870
  <button
599
871
  key="more"
600
872
  type="button"
601
- onClick={() => setI(visibleCount)}
873
+ onClick={() => setSheetOpen(true)}
602
874
  aria-label={t('autobahn:viewer.moreThumbsLabel', { count: remaining })}
603
875
  style={{
604
876
  position: 'relative',
@@ -606,13 +878,13 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
606
878
  width: THUMB_W,
607
879
  height: THUMB_H,
608
880
  padding: 0,
609
- borderRadius: 10,
881
+ borderRadius: i >= visibleCount ? 16 : 10,
610
882
  overflow: 'hidden',
611
883
  cursor: 'pointer',
612
- background: '#fff',
613
- border: i >= visibleCount ? 'none' : '1px solid var(--fig-gray)',
614
- outline: i >= visibleCount ? '3px solid var(--md-primary)' : 'none',
615
- outlineOffset: i >= visibleCount ? 1 : 0,
884
+ background: '#797979',
885
+ border: i >= visibleCount ? '3px solid #0B63F6' : '1px solid var(--fig-gray)',
886
+ outline: 'none',
887
+ outlineOffset: 0,
616
888
  }}
617
889
  >
618
890
  <img src={images[visibleCount]} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -642,7 +914,7 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
642
914
  {/* full-width bottom bar — "Jetzt Anfragen" (primary) + "Anrufen" (dark), Figma nodes
643
915
  424:8128-8134. */}
644
916
  {(onEmail || onCall) && (
645
- <div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: 'rgba(0,0,0,0.5)', flex: 'none' }}>
917
+ <div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: '#797979', flex: 'none' }}>
646
918
  {onEmail && (
647
919
  <button
648
920
  type="button"
@@ -698,7 +970,325 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
698
970
  )}
699
971
  </div>
700
972
  )}
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
+ />
701
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
+ }}
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
+ </>
1058
+ )}
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
+ />
1291
+ </div>
702
1292
  ) : (
703
1293
  <>
704
1294
  {/* Original desktop layout — unchanged. */}