@bytebrand/fe-ui-core-autobahn 1.0.55 → 1.0.56
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/constants/autobahn.ts +69 -68
- package/constants.ts +31 -31
- package/locales/de/autobahn.json +790 -783
- package/locales/en/autobahn.json +790 -783
- package/package.json +1 -1
- package/theme/autobahn.css +7 -4
- package/theme/md3.css +8184 -1585
- package/ui/CardCarousel.tsx +6 -2
- package/utils/financeFormat.ts +62 -62
- package/utils/priceRating.ts +58 -58
- package/vdp/FinanceCalculator.tsx +746 -651
- package/vdp/VdpModules.tsx +2995 -2384
- package/vdp/VdpViewer.tsx +698 -99
package/vdp/VdpViewer.tsx
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
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).
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
9
|
+
// photo-count / 360° pill pair over the image, always-visible prev/next circles, a vertical
|
|
10
|
+
// 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
|
|
16
|
+
// chrome around the photo differs.
|
|
7
17
|
//
|
|
8
18
|
// Pure / presentational: images arrive ALREADY RESOLVED (the design kit's
|
|
9
19
|
// `assetUrl(...)` lookup is the caller's job, matching CardCarousel) and the only
|
|
@@ -28,6 +38,12 @@ import { Icon, IconButton } from '../ui';
|
|
|
28
38
|
const ZOOM_MIN = 1;
|
|
29
39
|
const ZOOM_MAX = 3;
|
|
30
40
|
const ZOOM_STEP = 0.5;
|
|
41
|
+
const THUMB_W = 64;
|
|
42
|
+
const THUMB_H = 50;
|
|
43
|
+
const THUMB_GAP = 8;
|
|
44
|
+
const THUMBS_PAD = 12;
|
|
45
|
+
/** <=860px renders the Figma mobile gallery; wider renders the original desktop layout. */
|
|
46
|
+
const MOBILE_BREAKPOINT = 860;
|
|
31
47
|
|
|
32
48
|
export interface VdpViewerProps {
|
|
33
49
|
/** ready-to-use image URLs (already resolved by the caller). */
|
|
@@ -42,19 +58,274 @@ export interface VdpViewerProps {
|
|
|
42
58
|
phone?: string;
|
|
43
59
|
/** call action (tel: link owned by the caller) — omit to hide the call button. */
|
|
44
60
|
onCall?: () => void;
|
|
45
|
-
/** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide
|
|
61
|
+
/** "request this car" action (mirrors the legacy slider's envelope button) — omit to hide the
|
|
62
|
+
* "Jetzt Anfragen" CTA (mobile) / envelope button (desktop). */
|
|
46
63
|
onEmail?: () => void;
|
|
64
|
+
/** 360°-view toggle (mobile layout only) — presentational only for now (no vehicle in the
|
|
65
|
+
* current DTO ships a 360 image set), wired here so a real spin viewer can hang off it once
|
|
66
|
+
* that data exists. */
|
|
67
|
+
on360?: () => void;
|
|
47
68
|
}
|
|
48
69
|
|
|
49
|
-
|
|
70
|
+
/** Figma "360 / Icon" (node 424:8093) — two counter-rotating arcs; not part of the lucide set
|
|
71
|
+
* used elsewhere in this file, so it's inlined verbatim from the design's exported SVG. */
|
|
72
|
+
const Icon360: React.FunctionComponent = () => (
|
|
73
|
+
<svg width={18} height={18} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
74
|
+
<path
|
|
75
|
+
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"
|
|
76
|
+
stroke="white"
|
|
77
|
+
strokeWidth={1.5}
|
|
78
|
+
strokeLinecap="round"
|
|
79
|
+
/>
|
|
80
|
+
<path d="M14.2754 2.17499V5.17499H11.2754" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
81
|
+
<path
|
|
82
|
+
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"
|
|
83
|
+
stroke="white"
|
|
84
|
+
strokeWidth={1.5}
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
/>
|
|
87
|
+
<path d="M5.72461 16V13H8.72461" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
88
|
+
</svg>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** Figma "Close / Icon" (node 424:8083). */
|
|
92
|
+
const IconClose: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
|
|
93
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
94
|
+
<path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
|
|
95
|
+
</svg>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/** Figma "Call / Icon" (node 424:8087) / "CTA Call / Icon" (node 424:8131) — same handset path at
|
|
99
|
+
* two sizes (20px header, 22px bottom CTA); the design scales the identical shape, not a redraw. */
|
|
100
|
+
const IconPhone: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
|
|
101
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
102
|
+
<path
|
|
103
|
+
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"
|
|
104
|
+
stroke="white"
|
|
105
|
+
strokeWidth={1.8}
|
|
106
|
+
strokeLinejoin="round"
|
|
107
|
+
/>
|
|
108
|
+
</svg>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** Figma "Prev / Icon" (node 424:8100). */
|
|
112
|
+
const IconChevronLeft: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
|
|
113
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
114
|
+
<path d="M12.0833 4.58333L6.66667 10L12.0833 15.4167" stroke="white" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
|
|
115
|
+
</svg>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/** Figma "Next / Icon" (node 424:8103). */
|
|
119
|
+
const IconChevronRight: React.FunctionComponent<{ size?: number }> = ({ size = 20 }) => (
|
|
120
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
121
|
+
<path d="M7.91667 4.58333L13.3333 10L7.91667 15.4167" stroke="white" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
|
|
122
|
+
</svg>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** Figma "Zoom / Plus Icon" + "Zoom / Minus Icon" (nodes 424:8145, 424:8150) are both a magnifying
|
|
126
|
+
* glass (circle + handle, decoded from the design's exported vectors) with a +/− bar drawn inside
|
|
127
|
+
* it — NOT a bare lucide plus/minus glyph. Shared geometry, only the inner bar(s) differ. */
|
|
128
|
+
const IconZoomIn: React.FunctionComponent<{ dim?: boolean }> = ({ dim }) => (
|
|
129
|
+
<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 }}>
|
|
130
|
+
<path
|
|
131
|
+
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"
|
|
132
|
+
stroke="white"
|
|
133
|
+
strokeWidth={1.9}
|
|
134
|
+
/>
|
|
135
|
+
<path d="M21.9863 21.9872L27.3197 27.3205" stroke="white" strokeWidth={1.9} strokeLinecap="round" />
|
|
136
|
+
<path
|
|
137
|
+
d="M14.3558 20.2905V11.2393H16.6442V20.2905H14.3558ZM10.9744 16.9091V14.6207H20.0256V16.9091H10.9744Z"
|
|
138
|
+
fill="white"
|
|
139
|
+
/>
|
|
140
|
+
</svg>
|
|
141
|
+
);
|
|
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 }}>
|
|
144
|
+
<path
|
|
145
|
+
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
|
+
stroke="white"
|
|
147
|
+
strokeWidth={1.9}
|
|
148
|
+
/>
|
|
149
|
+
<path d="M23.3125 19.8125L28.3125 25.8125" stroke="white" strokeWidth={1.9} strokeLinecap="round" />
|
|
150
|
+
</svg>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/** Figma "auto_de_icon_offer_request_white 1" (node 424:8134) — a document (with folded corner +
|
|
154
|
+
* 3 text lines) plus a checkmark badge overlapping its bottom-right corner. The badge fill is
|
|
155
|
+
* `#005CFF`, which is exactly `--autode-blau`/`--md-primary` in this codebase's tokens (not a
|
|
156
|
+
* coincidental near-match like the CTA background blue), so it's wired to the token, not hardcoded. */
|
|
157
|
+
const IconOfferRequest: React.FunctionComponent<{ size?: number }> = ({ size = 22 }) => (
|
|
158
|
+
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
159
|
+
<path
|
|
160
|
+
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"
|
|
161
|
+
stroke="white"
|
|
162
|
+
strokeLinecap="round"
|
|
163
|
+
strokeLinejoin="round"
|
|
164
|
+
/>
|
|
165
|
+
<path d="M18.5 4.75V10.5H24.25" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
|
166
|
+
<path d="M11 14H18" stroke="white" strokeLinecap="round" />
|
|
167
|
+
<path d="M11 18H16.5" stroke="white" strokeLinecap="round" />
|
|
168
|
+
<path d="M11 22H15.5" stroke="white" strokeLinecap="round" />
|
|
169
|
+
<path
|
|
170
|
+
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"
|
|
171
|
+
fill="var(--md-primary)"
|
|
172
|
+
stroke="white"
|
|
173
|
+
/>
|
|
174
|
+
<path d="M21 22.55L22.85 24.4L29.25 18" stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
|
175
|
+
<path d="M26.65 22L28.65 20" stroke="var(--md-primary)" strokeLinecap="round" strokeLinejoin="round" />
|
|
176
|
+
<path d="M24.85 21L26.85 19" stroke="var(--md-primary)" strokeLinecap="round" strokeLinejoin="round" />
|
|
177
|
+
</svg>
|
|
178
|
+
);
|
|
179
|
+
|
|
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 = () => (
|
|
183
|
+
<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" />
|
|
192
|
+
</svg>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
/** Header close/call action: circle icon button (Figma "Close|Call / Circle+Icon", node 424:8082-8089)
|
|
196
|
+
* with its caption stacked below. The circle fill/stroke are decoded from Figma's flattened PNG
|
|
197
|
+
* export (`get_design_context` rasterizes it because of the translucent glass fill) — not a guess.
|
|
198
|
+
* 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
|
+
}) => (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onClick}
|
|
208
|
+
aria-label={ariaLabel}
|
|
209
|
+
style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}
|
|
210
|
+
>
|
|
211
|
+
<span
|
|
212
|
+
style={{
|
|
213
|
+
width: 48,
|
|
214
|
+
height: 48,
|
|
215
|
+
borderRadius: '50%',
|
|
216
|
+
background: 'rgba(11,19,32,0.35)',
|
|
217
|
+
border: '1.5px solid rgba(255,255,255,0.25)',
|
|
218
|
+
display: 'flex',
|
|
219
|
+
alignItems: 'center',
|
|
220
|
+
justifyContent: 'center',
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{icon}
|
|
224
|
+
</span>
|
|
225
|
+
<span style={{ fontFamily: "'Inter', var(--md-font)", fontSize: 13, lineHeight: '20px', fontWeight: 400, color: '#fff', textTransform: 'capitalize' }}>
|
|
226
|
+
{label}
|
|
227
|
+
</span>
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/** Prev/next circle over the image (Figma "Prev|Next / Circle+Icon", node 424:8099-8103) — same
|
|
232
|
+
* decoding note as HeaderAction, different glass tint (darker, less border contrast). Mobile
|
|
233
|
+
* layout only — desktop keeps the original hover-reveal `.car-arrow` buttons. */
|
|
234
|
+
const StageNavButton: React.FunctionComponent<{ icon: React.ReactNode; ariaLabel: string; side: 'left' | 'right'; onClick: () => void }> = ({
|
|
235
|
+
icon,
|
|
236
|
+
ariaLabel,
|
|
237
|
+
side,
|
|
238
|
+
onClick,
|
|
239
|
+
}) => (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={onClick}
|
|
243
|
+
aria-label={ariaLabel}
|
|
244
|
+
style={{
|
|
245
|
+
position: 'absolute',
|
|
246
|
+
...(side === 'left' ? { left: 12 } : { right: 12 }),
|
|
247
|
+
top: '50%',
|
|
248
|
+
transform: 'translateY(-50%)',
|
|
249
|
+
width: 48,
|
|
250
|
+
height: 48,
|
|
251
|
+
borderRadius: '50%',
|
|
252
|
+
background: 'rgba(11,17,24,0.38)',
|
|
253
|
+
border: '1.5px solid rgba(255,255,255,0.16)',
|
|
254
|
+
display: 'flex',
|
|
255
|
+
alignItems: 'center',
|
|
256
|
+
justifyContent: 'center',
|
|
257
|
+
cursor: 'pointer',
|
|
258
|
+
zIndex: 2,
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{icon}
|
|
262
|
+
</button>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
/** Counter (top-left) / 360° (top-right) pill — Figma nodes 424:8090 (Counter/BG) and 424:8092
|
|
266
|
+
* (360/BG), the one pair of overlay backgrounds Figma resolved to plain CSS instead of a flattened
|
|
267
|
+
* image, so these values are the literal fills, not an approximation. Mobile layout only. */
|
|
268
|
+
const pillStyle: React.CSSProperties = {
|
|
269
|
+
position: 'absolute',
|
|
270
|
+
top: 18,
|
|
271
|
+
width: 72,
|
|
272
|
+
height: 42,
|
|
273
|
+
borderRadius: 21,
|
|
274
|
+
background: 'rgba(11,17,24,0.47)',
|
|
275
|
+
border: '1px solid rgba(255,255,255,0.14)',
|
|
276
|
+
display: 'flex',
|
|
277
|
+
alignItems: 'center',
|
|
278
|
+
justifyContent: 'center',
|
|
279
|
+
gap: 5,
|
|
280
|
+
color: '#FFFFFF',
|
|
281
|
+
fontFamily: "'Inter', var(--md-font)",
|
|
282
|
+
fontWeight: 500,
|
|
283
|
+
fontSize: 14,
|
|
284
|
+
lineHeight: '20px',
|
|
285
|
+
textAlign: 'center',
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0, onClose, t, phone, onCall, onEmail, on360 }) => {
|
|
50
289
|
const [i, setI] = useState(start);
|
|
51
290
|
const [zoom, setZoom] = useState(ZOOM_MIN);
|
|
291
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
52
292
|
const n = images.length;
|
|
53
293
|
const go = (d: number) => setI((p) => (p + d + n) % n);
|
|
294
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
295
|
+
|
|
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));
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
const onResize = (): void => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
|
301
|
+
window.addEventListener('resize', onResize);
|
|
302
|
+
return () => window.removeEventListener('resize', onResize);
|
|
303
|
+
}, []);
|
|
54
304
|
|
|
55
|
-
// Reset zoom on every slide change — a lingering zoom
|
|
56
|
-
// a stuck/broken control on the new one.
|
|
57
|
-
useEffect(() =>
|
|
305
|
+
// Reset zoom (and pan) on every slide change — a lingering zoom/pan from the previous photo
|
|
306
|
+
// reads as a stuck/broken control on the new one.
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
setZoom(ZOOM_MIN);
|
|
309
|
+
setPan({ x: 0, y: 0 });
|
|
310
|
+
}, [i]);
|
|
311
|
+
|
|
312
|
+
// The zoomed image is `transform: translate(pan) scale(zoom)` on a box that's exactly the
|
|
313
|
+
// stage's size (the <img> is width/height:100%), so at a given zoom it can overhang the stage
|
|
314
|
+
// by at most (zoom-1)/2 of the stage's width/height on each side — clamp pan there so dragging
|
|
315
|
+
// can never pan the photo fully out of view, and re-clamp whenever zoom changes (zooming out
|
|
316
|
+
// shrinks the allowed range, so a pan that was valid before may now be out of bounds). Mobile
|
|
317
|
+
// layout only — the desktop image only ever scales (never translates).
|
|
318
|
+
const clampPan = (p: { x: number; y: number }, z: number): { x: number; y: number } => {
|
|
319
|
+
const el = stageRef.current;
|
|
320
|
+
if (!el || z <= ZOOM_MIN) return { x: 0, y: 0 };
|
|
321
|
+
const maxX = ((z - 1) * el.clientWidth) / 2;
|
|
322
|
+
const maxY = ((z - 1) * el.clientHeight) / 2;
|
|
323
|
+
return { x: Math.max(-maxX, Math.min(maxX, p.x)), y: Math.max(-maxY, Math.min(maxY, p.y)) };
|
|
324
|
+
};
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
setPan((p) => clampPan(p, zoom));
|
|
327
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
328
|
+
}, [zoom]);
|
|
58
329
|
|
|
59
330
|
// Keyboard nav + scroll lock while the overlay is mounted. `go`/`onClose` are
|
|
60
331
|
// read from refs so the listener stays stable for the lifetime of the viewer.
|
|
@@ -77,10 +348,69 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
77
348
|
};
|
|
78
349
|
}, []);
|
|
79
350
|
|
|
80
|
-
const tx = useRef<number | null>(null);
|
|
81
351
|
const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
|
|
82
352
|
const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
|
|
83
353
|
|
|
354
|
+
// Desktop swipe-to-navigate — the original behaviour, unchanged: a plain touch delta on the
|
|
355
|
+
// whole overlay, no pan.
|
|
356
|
+
const tx = useRef<number | null>(null);
|
|
357
|
+
|
|
358
|
+
// Mobile: one pointer-drag gesture serves two purposes depending on zoom level: at 1x it's a
|
|
359
|
+
// left/right swipe to change photo (mouse OR touch); zoomed in, it pans the enlarged photo
|
|
360
|
+
// around instead (that's the whole point of zooming in — being able to look at every part of
|
|
361
|
+
// it, not just the centered crop). Pointer Events cover mouse + touch + pen in one handler set.
|
|
362
|
+
const dragRef = useRef<{ x: number; y: number; panStart: { x: number; y: number } } | null>(null);
|
|
363
|
+
const onStagePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
364
|
+
// Don't hijack clicks on the buttons layered over the stage (arrows, counter, 360°, zoom).
|
|
365
|
+
if ((e.target as HTMLElement).closest('button')) return;
|
|
366
|
+
dragRef.current = { x: e.clientX, y: e.clientY, panStart: pan };
|
|
367
|
+
try {
|
|
368
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
369
|
+
} catch {
|
|
370
|
+
// Capture is a "nice to have" (keeps the drag alive if the pointer strays outside the
|
|
371
|
+
// stage) — some browser/input combinations reject it for a pointerId that isn't tracked
|
|
372
|
+
// as active. The drag still works via normal bubbling as long as the pointer stays over
|
|
373
|
+
// the stage, so this is safe to swallow rather than let it surface as an uncaught error.
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const onStagePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
377
|
+
const start = dragRef.current;
|
|
378
|
+
if (!start || zoom <= ZOOM_MIN) return;
|
|
379
|
+
const dx = e.clientX - start.x;
|
|
380
|
+
const dy = e.clientY - start.y;
|
|
381
|
+
setPan(clampPan({ x: start.panStart.x + dx, y: start.panStart.y + dy }, zoom));
|
|
382
|
+
};
|
|
383
|
+
const onStagePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
384
|
+
const start = dragRef.current;
|
|
385
|
+
dragRef.current = null;
|
|
386
|
+
if (!start) return;
|
|
387
|
+
if (zoom <= ZOOM_MIN) {
|
|
388
|
+
const dx = e.clientX - start.x;
|
|
389
|
+
if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// How many thumbnail slots fit in the strip — NOT a scroller (see render below): when there
|
|
394
|
+
// are more photos than fit, the last slot becomes a "+N remaining" tile (Figma node 424:8122
|
|
395
|
+
// "Thumb 05", Label Scrim + count) and nothing is rendered past it. Recomputed on resize (and
|
|
396
|
+
// whenever the layout itself flips) so the count stays correct across breakpoints. Mobile
|
|
397
|
+
// layout only — desktop keeps the original wrapping thumbnail row.
|
|
398
|
+
const thumbsWrapRef = useRef<HTMLDivElement | null>(null);
|
|
399
|
+
const [fitCount, setFitCount] = useState(images.length);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const el = thumbsWrapRef.current;
|
|
402
|
+
if (!el) return undefined;
|
|
403
|
+
const measure = (): void => {
|
|
404
|
+
const usable = el.clientWidth - THUMBS_PAD * 2;
|
|
405
|
+
const count = Math.max(1, Math.floor((usable + THUMB_GAP) / (THUMB_W + THUMB_GAP)));
|
|
406
|
+
setFitCount(count);
|
|
407
|
+
};
|
|
408
|
+
measure();
|
|
409
|
+
window.addEventListener('resize', measure);
|
|
410
|
+
return () => window.removeEventListener('resize', measure);
|
|
411
|
+
}, [images.length, isMobile]);
|
|
412
|
+
|
|
413
|
+
// Desktop-only styles, unchanged from the original layout.
|
|
84
414
|
const iconBtnStyle: React.CSSProperties = { background: 'var(--md-sc-lowest, #fff)', boxShadow: 'var(--md-e1)' };
|
|
85
415
|
const contactBtnStyle: React.CSSProperties = { background: 'var(--md-primary)', boxShadow: 'var(--md-e1)' };
|
|
86
416
|
|
|
@@ -96,109 +426,378 @@ const VdpViewer: React.FunctionComponent<VdpViewerProps> = ({ images, start = 0,
|
|
|
96
426
|
flexDirection: 'column',
|
|
97
427
|
}}
|
|
98
428
|
onTouchStart={(e) => {
|
|
429
|
+
if (isMobile) return; // mobile pans/swipes via the stage's own pointer handlers instead.
|
|
99
430
|
tx.current = e.touches[0].clientX;
|
|
100
431
|
}}
|
|
101
432
|
onTouchEnd={(e) => {
|
|
102
|
-
if (tx.current == null) return;
|
|
433
|
+
if (isMobile || tx.current == null) return;
|
|
103
434
|
const dx = e.changedTouches[0].clientX - tx.current;
|
|
104
435
|
tx.current = null;
|
|
105
436
|
if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
|
|
106
437
|
}}
|
|
107
438
|
>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<IconButton
|
|
130
|
-
name="plus"
|
|
131
|
-
label={t('autobahn:viewer.zoomInLabel')}
|
|
132
|
-
onClick={zoomIn}
|
|
133
|
-
style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
|
|
134
|
-
/>
|
|
135
|
-
{onEmail && (
|
|
136
|
-
<IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
|
|
137
|
-
)}
|
|
138
|
-
{onCall && (
|
|
139
|
-
<IconButton
|
|
140
|
-
name="phone"
|
|
141
|
-
label={t('autobahn:viewer.callLabel', { phone })}
|
|
142
|
-
onClick={onCall}
|
|
143
|
-
style={contactBtnStyle}
|
|
144
|
-
color="#fff"
|
|
439
|
+
{isMobile ? (
|
|
440
|
+
<>
|
|
441
|
+
{/* full-width header: close (left) / auto.de logo (center) / call (right) — Figma "Top
|
|
442
|
+
Overlay" + "Header Divider" (nodes 424:8070-8089). */}
|
|
443
|
+
<div
|
|
444
|
+
style={{
|
|
445
|
+
position: 'relative',
|
|
446
|
+
flex: 'none',
|
|
447
|
+
display: 'flex',
|
|
448
|
+
alignItems: 'flex-start',
|
|
449
|
+
justifyContent: 'space-between',
|
|
450
|
+
padding: '12px 12px 10px',
|
|
451
|
+
background: 'rgba(6,11,18,0.8)',
|
|
452
|
+
borderBottom: '1px solid rgba(255,255,255,0.12)',
|
|
453
|
+
}}
|
|
454
|
+
>
|
|
455
|
+
<HeaderAction
|
|
456
|
+
icon={<IconClose />}
|
|
457
|
+
label={t('autobahn:viewer.closeLabel')}
|
|
458
|
+
ariaLabel={t('autobahn:viewer.closeLabel')}
|
|
459
|
+
onClick={onClose}
|
|
145
460
|
/>
|
|
461
|
+
<div style={{ position: 'absolute', left: '50%', top: 10, transform: 'translateX(-50%)', width: 160, height: 71 }}>
|
|
462
|
+
<LogoMark />
|
|
463
|
+
</div>
|
|
464
|
+
{onCall ? (
|
|
465
|
+
<HeaderAction
|
|
466
|
+
icon={<IconPhone />}
|
|
467
|
+
label={t('autobahn:finance.callCta')}
|
|
468
|
+
ariaLabel={t('autobahn:viewer.callLabel', { phone })}
|
|
469
|
+
onClick={onCall}
|
|
470
|
+
/>
|
|
471
|
+
) : (
|
|
472
|
+
<span style={{ width: 48 }} />
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* stage: photo + counter/360 pills + prev/next + vertical zoom pill. */}
|
|
477
|
+
<div
|
|
478
|
+
ref={stageRef}
|
|
479
|
+
style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden', touchAction: 'none' }}
|
|
480
|
+
onPointerDown={onStagePointerDown}
|
|
481
|
+
onPointerMove={onStagePointerMove}
|
|
482
|
+
onPointerUp={onStagePointerUp}
|
|
483
|
+
onPointerCancel={onStagePointerUp}
|
|
484
|
+
>
|
|
485
|
+
<img
|
|
486
|
+
src={images[i]}
|
|
487
|
+
alt=""
|
|
488
|
+
draggable={false}
|
|
489
|
+
style={{
|
|
490
|
+
position: 'absolute',
|
|
491
|
+
inset: 0,
|
|
492
|
+
width: '100%',
|
|
493
|
+
height: '100%',
|
|
494
|
+
objectFit: 'contain',
|
|
495
|
+
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
496
|
+
transition: dragRef.current ? 'none' : 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
|
|
497
|
+
cursor: zoom > ZOOM_MIN ? 'grab' : undefined,
|
|
498
|
+
}}
|
|
499
|
+
/>
|
|
500
|
+
|
|
501
|
+
<div style={{ ...pillStyle, left: 12 }} aria-label={t('autobahn:viewer.counterLabel', { current: i + 1, total: n })}>
|
|
502
|
+
<span className="num viewer-counter-num">
|
|
503
|
+
{i + 1} | {n}
|
|
504
|
+
</span>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
onClick={() => on360 && on360()}
|
|
510
|
+
aria-label={t('autobahn:viewer.threeSixtyLabel')}
|
|
511
|
+
style={{ ...pillStyle, right: 12, border: '1px solid rgba(255,255,255,0.14)', cursor: 'pointer', gap: 3 }}
|
|
512
|
+
>
|
|
513
|
+
<Icon360 />
|
|
514
|
+
360°
|
|
515
|
+
</button>
|
|
516
|
+
|
|
517
|
+
{n > 1 && (
|
|
518
|
+
<>
|
|
519
|
+
<StageNavButton icon={<IconChevronLeft />} side="left" ariaLabel={t('autobahn:viewer.previousLabel')} onClick={() => go(-1)} />
|
|
520
|
+
<StageNavButton icon={<IconChevronRight />} side="right" ariaLabel={t('autobahn:viewer.nextLabel')} onClick={() => go(1)} />
|
|
521
|
+
</>
|
|
522
|
+
)}
|
|
523
|
+
|
|
524
|
+
<div
|
|
525
|
+
style={{
|
|
526
|
+
position: 'absolute',
|
|
527
|
+
right: 12,
|
|
528
|
+
bottom: 16,
|
|
529
|
+
width: 48,
|
|
530
|
+
height: 125,
|
|
531
|
+
borderRadius: 51,
|
|
532
|
+
background: 'rgba(11,17,24,0.5)',
|
|
533
|
+
border: '1.2px solid rgba(255,255,255,0.18)',
|
|
534
|
+
display: 'flex',
|
|
535
|
+
flexDirection: 'column',
|
|
536
|
+
alignItems: 'center',
|
|
537
|
+
padding: '12px 0',
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<button
|
|
541
|
+
type="button"
|
|
542
|
+
onClick={zoomIn}
|
|
543
|
+
disabled={zoom === ZOOM_MAX}
|
|
544
|
+
aria-label={t('autobahn:viewer.zoomInLabel')}
|
|
545
|
+
style={{ width: 40, height: 40, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
|
546
|
+
>
|
|
547
|
+
<IconZoomIn dim={zoom === ZOOM_MAX} />
|
|
548
|
+
</button>
|
|
549
|
+
<div style={{ width: 28, height: 1, background: 'rgba(255,255,255,0.16)', margin: '10px 0' }} />
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
onClick={zoomOut}
|
|
553
|
+
disabled={zoom === ZOOM_MIN}
|
|
554
|
+
aria-label={t('autobahn:viewer.zoomOutLabel')}
|
|
555
|
+
style={{ width: 40, height: 40, border: 0, background: 'none', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
|
556
|
+
>
|
|
557
|
+
<IconZoomOut dim={zoom === ZOOM_MIN} />
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
{/* fixed thumbnail strip — Figma "Thumb 0N" nodes 424:8105-8127. Not a scroller: it always
|
|
563
|
+
shows exactly as many real thumbnails as fit, and if there are more photos than that, the
|
|
564
|
+
LAST slot becomes a "+N remaining" tile and nothing is rendered past it. */}
|
|
565
|
+
{(() => {
|
|
566
|
+
const overflow = images.length > fitCount;
|
|
567
|
+
const visibleCount = overflow ? fitCount - 1 : images.length;
|
|
568
|
+
const remaining = images.length - visibleCount;
|
|
569
|
+
return (
|
|
570
|
+
<div ref={thumbsWrapRef} style={{ display: 'flex', gap: THUMB_GAP, padding: THUMBS_PAD, flex: 'none', overflow: 'hidden' }}>
|
|
571
|
+
{images.slice(0, visibleCount).map((src, k) => {
|
|
572
|
+
const selected = k === i;
|
|
573
|
+
return (
|
|
574
|
+
<button
|
|
575
|
+
key={k}
|
|
576
|
+
type="button"
|
|
577
|
+
onClick={() => setI(k)}
|
|
578
|
+
aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
|
|
579
|
+
style={{
|
|
580
|
+
flex: 'none',
|
|
581
|
+
width: THUMB_W,
|
|
582
|
+
height: THUMB_H,
|
|
583
|
+
padding: 0,
|
|
584
|
+
borderRadius: 10,
|
|
585
|
+
overflow: 'hidden',
|
|
586
|
+
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,
|
|
591
|
+
}}
|
|
592
|
+
>
|
|
593
|
+
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
594
|
+
</button>
|
|
595
|
+
);
|
|
596
|
+
})}
|
|
597
|
+
{remaining > 0 && (
|
|
598
|
+
<button
|
|
599
|
+
key="more"
|
|
600
|
+
type="button"
|
|
601
|
+
onClick={() => setI(visibleCount)}
|
|
602
|
+
aria-label={t('autobahn:viewer.moreThumbsLabel', { count: remaining })}
|
|
603
|
+
style={{
|
|
604
|
+
position: 'relative',
|
|
605
|
+
flex: 'none',
|
|
606
|
+
width: THUMB_W,
|
|
607
|
+
height: THUMB_H,
|
|
608
|
+
padding: 0,
|
|
609
|
+
borderRadius: 10,
|
|
610
|
+
overflow: 'hidden',
|
|
611
|
+
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,
|
|
616
|
+
}}
|
|
617
|
+
>
|
|
618
|
+
<img src={images[visibleCount]} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
619
|
+
<span
|
|
620
|
+
style={{
|
|
621
|
+
position: 'absolute',
|
|
622
|
+
inset: 0,
|
|
623
|
+
background: 'rgba(5,8,13,0.5)',
|
|
624
|
+
display: 'flex',
|
|
625
|
+
alignItems: 'center',
|
|
626
|
+
justifyContent: 'center',
|
|
627
|
+
color: '#fff',
|
|
628
|
+
fontFamily: "'Inter', var(--md-font-display)",
|
|
629
|
+
fontSize: 18,
|
|
630
|
+
lineHeight: '24px',
|
|
631
|
+
fontWeight: 700,
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
+{remaining}
|
|
635
|
+
</span>
|
|
636
|
+
</button>
|
|
637
|
+
)}
|
|
638
|
+
</div>
|
|
639
|
+
);
|
|
640
|
+
})()}
|
|
641
|
+
|
|
642
|
+
{/* full-width bottom bar — "Jetzt Anfragen" (primary) + "Anrufen" (dark), Figma nodes
|
|
643
|
+
424:8128-8134. */}
|
|
644
|
+
{(onEmail || onCall) && (
|
|
645
|
+
<div style={{ display: 'flex', gap: THUMB_GAP, padding: 12, height: 82, background: 'rgba(0,0,0,0.5)', flex: 'none' }}>
|
|
646
|
+
{onEmail && (
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
onClick={onEmail}
|
|
650
|
+
style={{
|
|
651
|
+
flex: '0 0 auto',
|
|
652
|
+
width: 220,
|
|
653
|
+
height: 56,
|
|
654
|
+
border: 0,
|
|
655
|
+
borderRadius: 16,
|
|
656
|
+
background: '#0B63F6',
|
|
657
|
+
color: '#FFFFFF',
|
|
658
|
+
display: 'flex',
|
|
659
|
+
alignItems: 'center',
|
|
660
|
+
justifyContent: 'center',
|
|
661
|
+
gap: 5,
|
|
662
|
+
fontFamily: "'Inter', var(--md-font)",
|
|
663
|
+
fontSize: 20,
|
|
664
|
+
lineHeight: '28px',
|
|
665
|
+
fontWeight: 700,
|
|
666
|
+
cursor: 'pointer',
|
|
667
|
+
}}
|
|
668
|
+
>
|
|
669
|
+
<IconOfferRequest size={32} />
|
|
670
|
+
{t('autobahn:viewer.requestLabel')}
|
|
671
|
+
</button>
|
|
672
|
+
)}
|
|
673
|
+
{onCall && (
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
onClick={onCall}
|
|
677
|
+
style={{
|
|
678
|
+
flex: 1,
|
|
679
|
+
height: 56,
|
|
680
|
+
border: 0,
|
|
681
|
+
borderRadius: 'var(--md-r-md)',
|
|
682
|
+
background: 'rgba(29,35,45,0.96)',
|
|
683
|
+
color: '#fff',
|
|
684
|
+
display: 'flex',
|
|
685
|
+
alignItems: 'center',
|
|
686
|
+
justifyContent: 'center',
|
|
687
|
+
gap: 8,
|
|
688
|
+
fontFamily: "'Inter', var(--md-font)",
|
|
689
|
+
fontSize: 20,
|
|
690
|
+
lineHeight: '28px',
|
|
691
|
+
fontWeight: 700,
|
|
692
|
+
cursor: 'pointer',
|
|
693
|
+
}}
|
|
694
|
+
>
|
|
695
|
+
<IconPhone size={22} />
|
|
696
|
+
{t('autobahn:finance.callCta')}
|
|
697
|
+
</button>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
146
700
|
)}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 })}
|
|
701
|
+
</>
|
|
702
|
+
) : (
|
|
703
|
+
<>
|
|
704
|
+
{/* Original desktop layout — unchanged. */}
|
|
705
|
+
<div
|
|
187
706
|
style={{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
background: '#fff',
|
|
707
|
+
display: 'flex',
|
|
708
|
+
alignItems: 'center',
|
|
709
|
+
justifyContent: 'space-between',
|
|
710
|
+
gap: 8,
|
|
711
|
+
padding: '12px 16px',
|
|
712
|
+
borderBottom: '1px solid var(--md-outline-variant)',
|
|
713
|
+
flexWrap: 'wrap',
|
|
196
714
|
}}
|
|
197
715
|
>
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
716
|
+
<span className="num md-title-sm">
|
|
717
|
+
{i + 1} / {n}
|
|
718
|
+
</span>
|
|
719
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
720
|
+
<IconButton
|
|
721
|
+
name="minus"
|
|
722
|
+
label={t('autobahn:viewer.zoomOutLabel')}
|
|
723
|
+
onClick={zoomOut}
|
|
724
|
+
style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MIN ? 0.4 : 1 }}
|
|
725
|
+
/>
|
|
726
|
+
<IconButton
|
|
727
|
+
name="plus"
|
|
728
|
+
label={t('autobahn:viewer.zoomInLabel')}
|
|
729
|
+
onClick={zoomIn}
|
|
730
|
+
style={{ ...iconBtnStyle, opacity: zoom === ZOOM_MAX ? 0.4 : 1 }}
|
|
731
|
+
/>
|
|
732
|
+
{onEmail && (
|
|
733
|
+
<IconButton name="mail" label={t('autobahn:viewer.emailLabel')} onClick={onEmail} style={contactBtnStyle} color="#fff" />
|
|
734
|
+
)}
|
|
735
|
+
{onCall && (
|
|
736
|
+
<IconButton
|
|
737
|
+
name="phone"
|
|
738
|
+
label={t('autobahn:viewer.callLabel', { phone })}
|
|
739
|
+
onClick={onCall}
|
|
740
|
+
style={contactBtnStyle}
|
|
741
|
+
color="#fff"
|
|
742
|
+
/>
|
|
743
|
+
)}
|
|
744
|
+
<IconButton name="x" filled label={t('autobahn:viewer.closeLabel')} onClick={onClose} />
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
<div style={{ flex: 1, position: 'relative', minHeight: 0, background: '#fff', overflow: 'hidden' }}>
|
|
748
|
+
<img
|
|
749
|
+
src={images[i]}
|
|
750
|
+
alt=""
|
|
751
|
+
style={{
|
|
752
|
+
position: 'absolute',
|
|
753
|
+
inset: 0,
|
|
754
|
+
width: '100%',
|
|
755
|
+
height: '100%',
|
|
756
|
+
objectFit: 'contain',
|
|
757
|
+
transform: `scale(${zoom})`,
|
|
758
|
+
transition: 'transform var(--dur-2, 200ms) var(--ease-standard, ease)',
|
|
759
|
+
}}
|
|
760
|
+
/>
|
|
761
|
+
<button
|
|
762
|
+
className="car-arrow car-arrow--l hide-mobile"
|
|
763
|
+
style={{ opacity: 1 }}
|
|
764
|
+
aria-label={t('autobahn:viewer.previousLabel')}
|
|
765
|
+
onClick={() => go(-1)}
|
|
766
|
+
>
|
|
767
|
+
<Icon name="chevron-left" size={22} />
|
|
768
|
+
</button>
|
|
769
|
+
<button
|
|
770
|
+
className="car-arrow car-arrow--r hide-mobile"
|
|
771
|
+
style={{ opacity: 1 }}
|
|
772
|
+
aria-label={t('autobahn:viewer.nextLabel')}
|
|
773
|
+
onClick={() => go(1)}
|
|
774
|
+
>
|
|
775
|
+
<Icon name="chevron-right" size={22} />
|
|
776
|
+
</button>
|
|
777
|
+
</div>
|
|
778
|
+
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '12px 16px', flexWrap: 'wrap' }}>
|
|
779
|
+
{images.map((src, k) => (
|
|
780
|
+
<button
|
|
781
|
+
key={k}
|
|
782
|
+
onClick={() => setI(k)}
|
|
783
|
+
aria-label={t('autobahn:viewer.thumbnailLabel', { number: k + 1 })}
|
|
784
|
+
style={{
|
|
785
|
+
width: 64,
|
|
786
|
+
height: 44,
|
|
787
|
+
padding: 0,
|
|
788
|
+
borderRadius: 'var(--md-r-sm)',
|
|
789
|
+
overflow: 'hidden',
|
|
790
|
+
cursor: 'pointer',
|
|
791
|
+
border: k === i ? '2px solid var(--md-primary)' : '1px solid var(--md-outline-variant)',
|
|
792
|
+
background: '#fff',
|
|
793
|
+
}}
|
|
794
|
+
>
|
|
795
|
+
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
796
|
+
</button>
|
|
797
|
+
))}
|
|
798
|
+
</div>
|
|
799
|
+
</>
|
|
800
|
+
)}
|
|
202
801
|
</div>
|
|
203
802
|
</div>
|
|
204
803
|
);
|