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