@bytebrand/fe-ui-core-autobahn 1.0.57 → 1.0.59
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/chrome/TopBar.tsx +250 -39
- package/constants.ts +31 -31
- package/locales/de/autobahn.json +4 -0
- package/locales/en/autobahn.json +4 -0
- package/package.json +1 -1
- package/theme/md3.css +88 -0
- package/utils/financeFormat.ts +62 -62
- package/utils/priceRating.ts +58 -58
package/chrome/TopBar.tsx
CHANGED
|
@@ -68,9 +68,19 @@ const Flag: React.FunctionComponent<{ code: string }> = ({ code }) =>
|
|
|
68
68
|
</svg>
|
|
69
69
|
);
|
|
70
70
|
|
|
71
|
+
/** Which reduced icon set the MOBILE header row shows (≤600px) — desktop is unaffected, it always
|
|
72
|
+
* shows the full search/heart/bell/lang/account set. Per the Figma mobile header spec:
|
|
73
|
+
* - 'default': search + account ("Globaler Default Header")
|
|
74
|
+
* - 'browsing': search + heart + account ("Browsing-Header")
|
|
75
|
+
* - 'conversion':search + phone + account ("Conversion-Header")
|
|
76
|
+
* No page-type → variant mapping exists yet in this codebase; the caller (App.tsx) decides. */
|
|
77
|
+
export type TopBarVariant = 'default' | 'browsing' | 'conversion';
|
|
78
|
+
|
|
71
79
|
export interface TopBarProps {
|
|
72
80
|
/** i18n function (drilled from the page wrapper's this.props.t). */
|
|
73
81
|
t: TranslationFunction;
|
|
82
|
+
/** mobile-only header icon set — see TopBarVariant. Defaults to 'default'. */
|
|
83
|
+
variant?: TopBarVariant;
|
|
74
84
|
/** real auth state (authStore.isAuthorized) — guest vs profile menu. */
|
|
75
85
|
isAuthorized?: boolean;
|
|
76
86
|
/** label for the authed menu header (authStore.userEmail / profile name). */
|
|
@@ -96,6 +106,10 @@ export interface TopBarProps {
|
|
|
96
106
|
/** SPA navigation for internal routes (fe-main passes history.push). Hosts without a
|
|
97
107
|
* client-side router (fe-landing, WP widgets) omit it → full page navigation. */
|
|
98
108
|
onNavigate?: (to: string) => void;
|
|
109
|
+
/** where the mobile "conversion" variant's phone icon goes — no single real business phone
|
|
110
|
+
* number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
|
|
111
|
+
* href here once one is confirmed. */
|
|
112
|
+
phoneHref?: string;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
/**
|
|
@@ -109,6 +123,7 @@ export interface TopBarProps {
|
|
|
109
123
|
*/
|
|
110
124
|
const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
111
125
|
t,
|
|
126
|
+
variant = 'default',
|
|
112
127
|
isAuthorized = false,
|
|
113
128
|
userLabel,
|
|
114
129
|
favoritesCount = 0,
|
|
@@ -121,6 +136,7 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
121
136
|
onSignOut,
|
|
122
137
|
onLangSelect,
|
|
123
138
|
onNavigate,
|
|
139
|
+
phoneHref = '/service/kontakt/',
|
|
124
140
|
}) => {
|
|
125
141
|
const [menu, setMenu] = useState(false); // mobile hamburger dropdown
|
|
126
142
|
const [account, setAccount] = useState(false); // account popover
|
|
@@ -199,9 +215,179 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
199
215
|
<div key={key} style={{ height: 1, background: 'var(--md-outline-variant)', margin: '6px 0' }} />
|
|
200
216
|
);
|
|
201
217
|
|
|
218
|
+
/** First letter of the email's local-part, e.g. "adminvika@…" → "A" — a placeholder single-letter
|
|
219
|
+
* avatar. ProfileStore already carries a real firstName/lastName (privateAddress) that isn't
|
|
220
|
+
* threaded down to TopBar yet; wire that through for a proper two-letter "AV"-style initial. */
|
|
221
|
+
const avatarInitial = (userLabel || '?').trim().charAt(0).toUpperCase() || '?';
|
|
222
|
+
|
|
223
|
+
/** one Account Layer row (mobile design): icon + label + optional badge + trailing chevron —
|
|
224
|
+
* distinct from the desktop popover's `row()` (no chevron, no avatar section there). */
|
|
225
|
+
const mrow = (key: string, label: string, onClick: () => void, opts?: { icon?: string; badge?: number }): JSX.Element => (
|
|
226
|
+
<button
|
|
227
|
+
key={key}
|
|
228
|
+
type="button"
|
|
229
|
+
className="amenu-mobile-row"
|
|
230
|
+
onClick={onClick}
|
|
231
|
+
style={{
|
|
232
|
+
display: 'flex',
|
|
233
|
+
alignItems: 'center',
|
|
234
|
+
gap: 12,
|
|
235
|
+
width: '100%',
|
|
236
|
+
padding: '13px 4px',
|
|
237
|
+
border: 0,
|
|
238
|
+
background: 'transparent',
|
|
239
|
+
color: 'var(--md-on-surface)',
|
|
240
|
+
cursor: 'pointer',
|
|
241
|
+
font: 'inherit',
|
|
242
|
+
fontWeight: 500,
|
|
243
|
+
textAlign: 'left',
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{opts && opts.icon && <Icon name={opts.icon} size={19} style={{ flex: 'none', color: 'var(--md-on-surface-variant)' }} />}
|
|
247
|
+
<span style={{ flex: 1, fontSize: 15 }}>{label}</span>
|
|
248
|
+
{opts && typeof opts.badge === 'number' && opts.badge > 0 && (
|
|
249
|
+
<span className="md-label-sm" style={{ fontWeight: 700 }}>{opts.badge}</span>
|
|
250
|
+
)}
|
|
251
|
+
<Icon name="chevron-right" size={18} style={{ flex: 'none', color: 'var(--md-on-surface-variant)' }} />
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
/** language row (mobile design): real flag + label, checkmark bubble on the active one — matches
|
|
256
|
+
* the Figma "Sprache" section exactly (the desktop popover's globe/check-icon version is left
|
|
257
|
+
* as-is, out of scope for this mobile-only pass). */
|
|
258
|
+
const mLangRow = (code: string, label: string): JSX.Element => {
|
|
259
|
+
const active = language === code;
|
|
260
|
+
return (
|
|
261
|
+
<button
|
|
262
|
+
key={`mlang-${code}`}
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={() => { setAccount(false); if (onLangSelect) onLangSelect(code); }}
|
|
265
|
+
style={{
|
|
266
|
+
display: 'flex',
|
|
267
|
+
alignItems: 'center',
|
|
268
|
+
gap: 12,
|
|
269
|
+
width: '100%',
|
|
270
|
+
padding: '10px 4px',
|
|
271
|
+
border: 0,
|
|
272
|
+
background: 'transparent',
|
|
273
|
+
cursor: 'pointer',
|
|
274
|
+
font: 'inherit',
|
|
275
|
+
textAlign: 'left',
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<Flag code={code} />
|
|
279
|
+
<span style={{ flex: 1, fontSize: 15, color: 'var(--md-on-surface)' }}>{label}</span>
|
|
280
|
+
{active && (
|
|
281
|
+
<span
|
|
282
|
+
style={{
|
|
283
|
+
width: 20, height: 20, borderRadius: '50%', background: 'var(--md-primary)',
|
|
284
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none',
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<Icon name="check" size={13} color="#fff" stroke={3} />
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
</button>
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/** Mobile-only "Account Layer" — a full-width sheet under the header (Figma: "03 | Account
|
|
295
|
+
* Layer"), replacing the desktop popover's small 264px card on mobile widths. Shown/hidden
|
|
296
|
+
* purely via the `show-mobile`/`hide-mobile` CSS classes (this project's existing convention —
|
|
297
|
+
* see the hamburger button/topbar-menu above), not JS width checks. */
|
|
298
|
+
const renderMobileAccountLayer = (): JSX.Element => (
|
|
299
|
+
<div className="amenu-mobile show-mobile mcard" style={{ flexDirection: 'column' }}>
|
|
300
|
+
<div style={{ padding: '20px 20px 8px', textAlign: isAuthorized ? 'left' : 'center' }}>
|
|
301
|
+
{isAuthorized ? (
|
|
302
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
303
|
+
<span
|
|
304
|
+
style={{
|
|
305
|
+
width: 44, height: 44, borderRadius: '50%', flex: 'none',
|
|
306
|
+
background: 'color-mix(in srgb, var(--md-primary) 15%, transparent)',
|
|
307
|
+
color: 'var(--md-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
308
|
+
fontWeight: 700, fontSize: 16,
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
{avatarInitial}
|
|
312
|
+
</span>
|
|
313
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
314
|
+
<div className="md-title-sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
315
|
+
{userLabel}
|
|
316
|
+
</div>
|
|
317
|
+
<a
|
|
318
|
+
onClick={() => goRoute(R_SETTINGS)}
|
|
319
|
+
className="md-label"
|
|
320
|
+
style={{ color: 'var(--md-primary)', cursor: 'pointer', fontWeight: 600 }}
|
|
321
|
+
>
|
|
322
|
+
{t('autobahn:chrome.myProfile')} ›
|
|
323
|
+
</a>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
) : (
|
|
327
|
+
<>
|
|
328
|
+
<span
|
|
329
|
+
style={{
|
|
330
|
+
width: 44, height: 44, borderRadius: '50%', margin: '0 auto 14px',
|
|
331
|
+
background: 'var(--md-outline-variant)', color: 'var(--md-on-surface-variant)',
|
|
332
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
<Icon name="user" size={22} />
|
|
336
|
+
</span>
|
|
337
|
+
<div className="md-headline-sm" style={{ fontWeight: 700, marginBottom: 6 }}>
|
|
338
|
+
{t('autobahn:chrome.welcome')}
|
|
339
|
+
</div>
|
|
340
|
+
<div className="md-body" style={{ marginBottom: 16 }}>
|
|
341
|
+
{t('autobahn:chrome.welcomeSubtext')}
|
|
342
|
+
</div>
|
|
343
|
+
</>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div style={{ padding: '4px 20px 8px' }}>
|
|
348
|
+
{isAuthorized ? (
|
|
349
|
+
<>
|
|
350
|
+
{!isDealerMain &&
|
|
351
|
+
mrow('m-dashboard', t('autobahn:chrome.dashboard'), () => goRoute(R_DASHBOARD), { icon: 'layout-grid' })}
|
|
352
|
+
{mrow('m-vehicles', t('autobahn:chrome.myVehicles'), () => goRoute(R_MY_VEHICLES), { icon: 'car' })}
|
|
353
|
+
{mrow('m-searches', t('autobahn:chrome.savedSearches'), () => goRoute(R_SAVED_SEARCHES), { icon: 'bell' })}
|
|
354
|
+
{mrow('m-favorites', t('autobahn:chrome.favorites'), () => goRoute(R_FAVOURITES), { icon: 'heart', badge: favoritesCount })}
|
|
355
|
+
{mrow('m-settings', t('autobahn:chrome.settings'), () => goRoute(R_SETTINGS), { icon: 'settings' })}
|
|
356
|
+
{mrow('m-help', t('autobahn:chrome.helpContact'), () => goRoute(R_KONTAKT), { icon: 'help-circle' })}
|
|
357
|
+
{isDealerMain && mrow('m-dealer', t('autobahn:chrome.dealerDashboard'), () => goRoute(R_DASHBOARD), { icon: 'store' })}
|
|
358
|
+
{mrow('m-signout', t('autobahn:chrome.signOut'), () => { setAccount(false); if (onSignOut) onSignOut(); }, { icon: 'log-out' })}
|
|
359
|
+
</>
|
|
360
|
+
) : (
|
|
361
|
+
<>
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
className="mbtn mbtn--filled mbtn--block"
|
|
365
|
+
style={{ width: '100%', marginBottom: 4 }}
|
|
366
|
+
onClick={() => goRoute(R_SIGN_IN)}
|
|
367
|
+
>
|
|
368
|
+
{t('autobahn:chrome.signIn')}
|
|
369
|
+
</button>
|
|
370
|
+
{mrow('m-register', t('autobahn:chrome.register'), () => goRoute(R_SIGN_UP), { icon: 'user-plus' })}
|
|
371
|
+
{mrow('m-dealer-signup', t('autobahn:chrome.dealerSignUp'), () => goRoute(R_DEALER), { icon: 'store' })}
|
|
372
|
+
{mrow('m-favorites', t('autobahn:chrome.favorites'), () => goRoute(R_FAVOURITES), { icon: 'heart', badge: favoritesCount })}
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{divider('m-d1')}
|
|
378
|
+
|
|
379
|
+
<div style={{ padding: '8px 20px 20px' }}>
|
|
380
|
+
<div className="md-label-sm" style={{ color: 'var(--md-on-surface-variant)', padding: '4px 4px 6px' }}>
|
|
381
|
+
{t('autobahn:chrome.language')}
|
|
382
|
+
</div>
|
|
383
|
+
{LANGS.map(([code, label]) => mLangRow(code, label))}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
|
|
202
388
|
const renderAccountMenu = (): JSX.Element => (
|
|
203
389
|
<div
|
|
204
|
-
className="amenu mcard"
|
|
390
|
+
className="amenu mcard hide-mobile"
|
|
205
391
|
style={{
|
|
206
392
|
position: 'absolute',
|
|
207
393
|
top: 'calc(100% + 8px)',
|
|
@@ -280,8 +466,8 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
280
466
|
}}
|
|
281
467
|
>
|
|
282
468
|
<div
|
|
283
|
-
className="container"
|
|
284
|
-
style={{ height: 64, display: 'flex', alignItems: 'center', gap: 16
|
|
469
|
+
className="container topbar-container"
|
|
470
|
+
style={{ height: 64, display: 'flex', alignItems: 'center', gap: 16 }}
|
|
285
471
|
>
|
|
286
472
|
{/* mobile hamburger */}
|
|
287
473
|
<button
|
|
@@ -295,7 +481,7 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
295
481
|
|
|
296
482
|
{/* full navigation — '/' belongs to WordPress behind traefik, not the SPA (see HOME_PATH). */}
|
|
297
483
|
<a href={HOME_PATH} style={{ flex: 'none', display: 'inline-flex' }}>
|
|
298
|
-
<img src={logoUrl} height="32" style={{ cursor: 'pointer', display: 'block' }} alt="auto.de" />
|
|
484
|
+
<img src={logoUrl} height="32" className="topbar-logo" style={{ cursor: 'pointer', display: 'block' }} alt="auto.de" />
|
|
299
485
|
</a>
|
|
300
486
|
|
|
301
487
|
{renderNav('hide-mobile', {
|
|
@@ -307,47 +493,72 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
307
493
|
|
|
308
494
|
<div style={{ flex: 1 }} />
|
|
309
495
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
496
|
+
{/* Action icons cluster — its own (smaller) gap, separate from the outer 16px gap between
|
|
497
|
+
hamburger/logo/nav: the Header-System spec calls for ~2-6px between actions but keeps
|
|
498
|
+
the wider spacing everywhere else. */}
|
|
499
|
+
<div className="topbar-actions" style={{ display: 'flex', alignItems: 'center' }}>
|
|
500
|
+
<IconButton name="search" label={t('autobahn:chrome.search')} onClick={goSearch} />
|
|
501
|
+
{/* Merkliste → /favourites with a real count badge — desktop-only now; the mobile row
|
|
502
|
+
below shows it ONLY for the 'browsing' variant instead. */}
|
|
503
|
+
<span className="hide-mobile" style={{ position: 'relative', display: 'inline-flex' }}>
|
|
504
|
+
<IconButton name="heart" label={t('autobahn:chrome.favorites')} onClick={() => goRoute(R_FAVOURITES)} />
|
|
505
|
+
{favoritesCount > 0 && (
|
|
506
|
+
<span style={badgeStyle}>{favoritesCount}</span>
|
|
507
|
+
)}
|
|
508
|
+
</span>
|
|
509
|
+
{/* Suchaufträge → /my-searches (saved searches list) — desktop-only; no Figma mobile
|
|
510
|
+
header variant includes a bell icon. */}
|
|
511
|
+
<span className="hide-mobile" style={{ position: 'relative', display: 'inline-flex' }}>
|
|
512
|
+
<IconButton name="bell" label={t('autobahn:chrome.savedSearches')} onClick={() => goRoute(R_SAVED_SEARCHES)} />
|
|
513
|
+
{notificationCount > 0 && (
|
|
514
|
+
<span style={badgeStyle}>{notificationCount}</span>
|
|
515
|
+
)}
|
|
516
|
+
</span>
|
|
517
|
+
|
|
518
|
+
{/* Mobile-only reduced icon set — which ONE extra icon (besides search+account, both
|
|
519
|
+
always shown) appears depends on `variant`, per the 3 Figma mobile header specs. */}
|
|
520
|
+
{variant === 'browsing' && (
|
|
521
|
+
<span className="show-mobile" style={{ position: 'relative', display: 'inline-flex' }}>
|
|
522
|
+
<IconButton name="heart" label={t('autobahn:chrome.favorites')} onClick={() => goRoute(R_FAVOURITES)} />
|
|
523
|
+
{favoritesCount > 0 && <span style={badgeStyle}>{favoritesCount}</span>}
|
|
524
|
+
</span>
|
|
316
525
|
)}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
{notificationCount > 0 && (
|
|
322
|
-
<span style={badgeStyle}>{notificationCount}</span>
|
|
526
|
+
{variant === 'conversion' && (
|
|
527
|
+
<span className="show-mobile" style={{ position: 'relative', display: 'inline-flex' }}>
|
|
528
|
+
<IconButton name="phone" label={t('autobahn:chrome.callUs')} onClick={() => goNav({ href: phoneHref })} />
|
|
529
|
+
</span>
|
|
323
530
|
)}
|
|
324
|
-
</span>
|
|
325
531
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
532
|
+
{/* language segmented control — hidden ≤600px (the account menu carries language rows). */}
|
|
533
|
+
{onLangSelect && (
|
|
534
|
+
<div className="lang-seg hide-mobile" aria-label={t('autobahn:chrome.switchLanguage')}>
|
|
535
|
+
<button type="button" className={'lang-seg-btn' + (language !== 'en' ? ' on' : '')} onClick={() => onLangSelect('de')}>DE</button>
|
|
536
|
+
<button type="button" className={'lang-seg-btn' + (language === 'en' ? ' on' : '')} onClick={() => onLangSelect('en')}>EN</button>
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
333
539
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
540
|
+
{/* Konto trigger + account layer — visible at EVERY width: this menu is the only place
|
|
541
|
+
sign-out / settings / dealer switching exist, so it must never be display:none'd (the
|
|
542
|
+
pre-cutover hide-tablet left 601–980px with no logout at all). Desktop keeps the
|
|
543
|
+
tonal "Konto" pill; ≤600px it collapses to a plain icon (topbar-account-btn CSS) and
|
|
544
|
+
opens the full-width mobile Account Layer instead of the small popover. */}
|
|
545
|
+
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
|
546
|
+
<button
|
|
547
|
+
className="mbtn mbtn--tonal mbtn--sm topbar-account-btn"
|
|
548
|
+
onClick={() => setAccount((a) => !a)}
|
|
549
|
+
aria-expanded={account}
|
|
550
|
+
aria-label={t('autobahn:chrome.account')}
|
|
551
|
+
>
|
|
552
|
+
<Icon name="user" size={18} />
|
|
553
|
+
<span className="hide-mobile">{t('autobahn:chrome.account')}</span>
|
|
554
|
+
</button>
|
|
555
|
+
{account && renderAccountMenu()}
|
|
556
|
+
</span>
|
|
557
|
+
</div>
|
|
349
558
|
</div>
|
|
350
559
|
|
|
560
|
+
{account && renderMobileAccountLayer()}
|
|
561
|
+
|
|
351
562
|
{/* click-away backdrop for the account popover */}
|
|
352
563
|
{account && (
|
|
353
564
|
<div
|
package/constants.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
// Autobahn (auto.de redesign) — shared lookup constants barrel.
|
|
2
|
-
// Pure data re-exported from ./constants/autobahn (kept import-safe for plain Node consumers,
|
|
3
|
-
// see that file's header) plus the legacy fe-ui-core shim re-exports below.
|
|
4
|
-
export * from './constants/autobahn';
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Legacy shared constants (moved here from fe-ui-core).
|
|
8
|
-
//
|
|
9
|
-
// This block mirrors the public symbol set of the old
|
|
10
|
-
// `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
|
|
11
|
-
// specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
|
|
12
|
-
// implementations live under ./constants/ (core.ts, app.ts, price.ts,
|
|
13
|
-
// Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
export {
|
|
17
|
-
offers,
|
|
18
|
-
checkoutOffers,
|
|
19
|
-
checkoutWarranties,
|
|
20
|
-
checkoutSteps,
|
|
21
|
-
checkoutSubStep,
|
|
22
|
-
DELIVERY_PERIODS_EXTRA,
|
|
23
|
-
THREE,
|
|
24
|
-
WLTP_ENERGY_EFFICIENCY_CLASS
|
|
25
|
-
} from './constants/core';
|
|
26
|
-
|
|
27
|
-
export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
|
|
28
|
-
|
|
29
|
-
export { priceRatings } from './constants/price';
|
|
30
|
-
|
|
31
|
-
export { THROTTLE_DELAY } from './constants/Search';
|
|
1
|
+
// Autobahn (auto.de redesign) — shared lookup constants barrel.
|
|
2
|
+
// Pure data re-exported from ./constants/autobahn (kept import-safe for plain Node consumers,
|
|
3
|
+
// see that file's header) plus the legacy fe-ui-core shim re-exports below.
|
|
4
|
+
export * from './constants/autobahn';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Legacy shared constants (moved here from fe-ui-core).
|
|
8
|
+
//
|
|
9
|
+
// This block mirrors the public symbol set of the old
|
|
10
|
+
// `@bytebrand/fe-ui-core/constants` barrel so consumers can swap that
|
|
11
|
+
// specifier for `@bytebrand/fe-ui-core-autobahn/constants` 1:1. The
|
|
12
|
+
// implementations live under ./constants/ (core.ts, app.ts, price.ts,
|
|
13
|
+
// Search.ts, …); fe-ui-core re-exports them via shims until it is deleted.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
offers,
|
|
18
|
+
checkoutOffers,
|
|
19
|
+
checkoutWarranties,
|
|
20
|
+
checkoutSteps,
|
|
21
|
+
checkoutSubStep,
|
|
22
|
+
DELIVERY_PERIODS_EXTRA,
|
|
23
|
+
THREE,
|
|
24
|
+
WLTP_ENERGY_EFFICIENCY_CLASS
|
|
25
|
+
} from './constants/core';
|
|
26
|
+
|
|
27
|
+
export { CUSTOM_ERROR_MESSAGES, supportPhone, supportPhoneLink } from './constants/app';
|
|
28
|
+
|
|
29
|
+
export { priceRatings } from './constants/price';
|
|
30
|
+
|
|
31
|
+
export { THROTTLE_DELAY } from './constants/Search';
|
package/locales/de/autobahn.json
CHANGED
|
@@ -207,6 +207,10 @@
|
|
|
207
207
|
"dealerSignUp": "Als Händler anmelden",
|
|
208
208
|
"language": "Sprache",
|
|
209
209
|
"myAccount": "Mein Konto",
|
|
210
|
+
"myProfile": "Mein Profil",
|
|
211
|
+
"welcome": "Willkommen bei auto.de",
|
|
212
|
+
"welcomeSubtext": "Melde dich an oder erstelle ein Konto, um alle Vorteile zu nutzen.",
|
|
213
|
+
"callUs": "Anrufen",
|
|
210
214
|
"usedCars": "Gebrauchtwagen",
|
|
211
215
|
"newCars": "Neuwagen",
|
|
212
216
|
"leasing": "Leasing",
|
package/locales/en/autobahn.json
CHANGED
|
@@ -207,6 +207,10 @@
|
|
|
207
207
|
"dealerSignUp": "Become a dealer",
|
|
208
208
|
"language": "Language",
|
|
209
209
|
"myAccount": "My account",
|
|
210
|
+
"myProfile": "My profile",
|
|
211
|
+
"welcome": "Welcome to auto.de",
|
|
212
|
+
"welcomeSubtext": "Sign in or create an account to use all the benefits.",
|
|
213
|
+
"callUs": "Call us",
|
|
210
214
|
"usedCars": "Used cars",
|
|
211
215
|
"newCars": "New cars",
|
|
212
216
|
"leasing": "Leasing",
|
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.59",
|
|
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
|
@@ -5102,6 +5102,94 @@ line-height: 31px;
|
|
|
5102
5102
|
box-shadow: var(--md-e2);
|
|
5103
5103
|
padding: 8px 16px 16px;
|
|
5104
5104
|
}
|
|
5105
|
+
|
|
5106
|
+
/* Base (desktop) values for the two TopBar layout knobs the mobile Header-System spec overrides
|
|
5107
|
+
below — kept as real CSS rules (not inline styles) since an inline style on the element would
|
|
5108
|
+
always beat the media-query override regardless of specificity. */
|
|
5109
|
+
.md3 .topbar-container {
|
|
5110
|
+
padding: 0 16px;
|
|
5111
|
+
}
|
|
5112
|
+
.md3 .topbar-actions {
|
|
5113
|
+
gap: 16px;
|
|
5114
|
+
}
|
|
5115
|
+
|
|
5116
|
+
/* mobile-only "Account Layer" sheet (TopBar) — full-width panel under the header, per the Figma
|
|
5117
|
+
"03 | Account Layer" mockups. .show-mobile forces display:flex (see the ≤600px override
|
|
5118
|
+
below) — flex-direction:column comes from the inline style on the element itself. */
|
|
5119
|
+
.md3 .amenu-mobile {
|
|
5120
|
+
/* NOT full-width — measured against the real Figma export: the panel is anchored to the
|
|
5121
|
+
RIGHT edge, leaving ~22% of the viewport showing the dimmed page on the left (both the
|
|
5122
|
+
logged-out and logged-in mockups measured close to this, not identical px-for-px, so this
|
|
5123
|
+
is the representative middle value rather than either exact reading). */
|
|
5124
|
+
position: absolute;
|
|
5125
|
+
top: 100%;
|
|
5126
|
+
left: 22%;
|
|
5127
|
+
right: 0;
|
|
5128
|
+
background: var(--md-surface);
|
|
5129
|
+
box-shadow: var(--md-elev-2);
|
|
5130
|
+
z-index: 60;
|
|
5131
|
+
max-height: calc(100vh - 64px);
|
|
5132
|
+
overflow-y: auto;
|
|
5133
|
+
/* only the LEFT corners are visually rounded (top-left, bottom-left) — the right edge sits
|
|
5134
|
+
flush against the viewport's own right edge, so rounding there would be invisible anyway.
|
|
5135
|
+
(This element is already show-mobile-gated, so no extra media query needed here.) */
|
|
5136
|
+
border-radius: 20px 0 0 20px;
|
|
5137
|
+
}
|
|
5138
|
+
.md3 .amenu-mobile-row:active {
|
|
5139
|
+
background: color-mix(in srgb, var(--md-primary) 6%, transparent);
|
|
5140
|
+
}
|
|
5141
|
+
/* desktop "Konto" pill collapses to a plain icon-only button on mobile — the account layer above
|
|
5142
|
+
replaces the small popover the pill normally opens on wider screens. */
|
|
5143
|
+
@media (max-width: 600px) {
|
|
5144
|
+
/* "Header-System" spec dimensions (390px reference) — all scoped to the TopBar's own <header>
|
|
5145
|
+
(or a dedicated class) so none of this touches .icon-btn/.mbtn/.container usage elsewhere in
|
|
5146
|
+
the app (VDP gallery controls, financing modal, other pages' own containers, etc.). */
|
|
5147
|
+
.md3 .topbar-account-btn {
|
|
5148
|
+
background: transparent;
|
|
5149
|
+
border: 0;
|
|
5150
|
+
padding: 0;
|
|
5151
|
+
/* Touch target 48×48 — WCAG 2.2 minimum is 24×24, but the spec recommends 48×48 consistently
|
|
5152
|
+
for auto.de given the broad age range and high mobile share. */
|
|
5153
|
+
width: 48px;
|
|
5154
|
+
height: 48px;
|
|
5155
|
+
min-width: 0;
|
|
5156
|
+
border-radius: 50%;
|
|
5157
|
+
}
|
|
5158
|
+
.md3 .topbar-account-btn:hover {
|
|
5159
|
+
background: color-mix(in srgb, var(--md-on-surface) 8%, transparent);
|
|
5160
|
+
}
|
|
5161
|
+
/* Visible icon glyphs 24-28px (spec range) — the shared .icon-btn/.mbtn rules force 20px/18px
|
|
5162
|
+
everywhere else in the app, which is fine there but read visibly smaller than the Figma
|
|
5163
|
+
mobile header reference. */
|
|
5164
|
+
.md3 header .icon-btn {
|
|
5165
|
+
width: 48px;
|
|
5166
|
+
height: 48px;
|
|
5167
|
+
}
|
|
5168
|
+
.md3 header .icon-btn .lucide {
|
|
5169
|
+
width: 24px;
|
|
5170
|
+
height: 24px;
|
|
5171
|
+
}
|
|
5172
|
+
.md3 header .topbar-account-btn .lucide {
|
|
5173
|
+
width: 24px;
|
|
5174
|
+
height: 24px;
|
|
5175
|
+
}
|
|
5176
|
+
/* Horizontal margin 12px (spec: "Horizontaler Rand"). */
|
|
5177
|
+
.md3 .topbar-container {
|
|
5178
|
+
padding: 0 12px;
|
|
5179
|
+
}
|
|
5180
|
+
/* Logo 118-126px wide (spec range) — the SVG's own aspect ratio (120:34.909) means the desktop
|
|
5181
|
+
height=32 attribute renders ~110px wide, just under range; this bumps it into range on mobile
|
|
5182
|
+
specifically via a CSS width override (wins over the HTML height attribute). */
|
|
5183
|
+
.md3 .topbar-logo {
|
|
5184
|
+
width: 122px;
|
|
5185
|
+
height: auto;
|
|
5186
|
+
}
|
|
5187
|
+
/* Gap between action icons 2-6px (spec: "Abstand Aktionen") — separate from the wider gap the
|
|
5188
|
+
hamburger/logo/nav still use (that's the outer .container's own gap, untouched here). */
|
|
5189
|
+
.md3 .topbar-actions {
|
|
5190
|
+
gap: 4px;
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5105
5193
|
/* language segmented DE|EN — active tab filled, inactive ghost */
|
|
5106
5194
|
.md3 .lang-seg {
|
|
5107
5195
|
display: inline-flex;
|
package/utils/financeFormat.ts
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
// Autobahn (auto.de redesign) — shared financing-number formatting.
|
|
2
|
-
//
|
|
3
|
-
// Extracted from vdp/VdpModules.tsx's FinanceDetailsModal so non-React consumers (e.g. the
|
|
4
|
-
// Meta AIA marketing-image renderer) format the SAME real numbers the same way, instead of a
|
|
5
|
-
// second copy drifting from the VDP's. Pure TS, no React/JSX — safe to import from a plain
|
|
6
|
-
// Node service.
|
|
7
|
-
//
|
|
8
|
-
// The "only real figures, never a fabricated total" rule is load-bearing (legal: balloon
|
|
9
|
-
// financing means monthly×term is NOT the total) and lives here once, not per-caller.
|
|
10
|
-
|
|
11
|
-
/** Monetary figure → de-DE grouped string (e.g. 47400 → "47.400"). Feed rounded integers —
|
|
12
|
-
* toLocaleString on a fractional value prints a bogus 3-decimal grouping. */
|
|
13
|
-
export function formatEur(n: number): string {
|
|
14
|
-
return n.toLocaleString('de-DE');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Rate → de-DE percent string (e.g. 6.49 → "6,49 %"). */
|
|
18
|
-
export function formatPct(n: number): string {
|
|
19
|
-
return `${n.toFixed(2).replace('.', ',')} %`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface FinanceExampleFields {
|
|
23
|
-
price: number;
|
|
24
|
-
apr: number;
|
|
25
|
-
down: number;
|
|
26
|
-
/** Real config figure — falls back to price-down ONLY when genuinely absent. */
|
|
27
|
-
netLoanAmount?: number;
|
|
28
|
-
nominalRate?: number;
|
|
29
|
-
finalInstallment?: number;
|
|
30
|
-
grossAmount?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface FinanceExampleLabels {
|
|
34
|
-
vehiclePrice: string;
|
|
35
|
-
priceSuffix: string;
|
|
36
|
-
apr: string;
|
|
37
|
-
nominal: string;
|
|
38
|
-
net: string;
|
|
39
|
-
down: string;
|
|
40
|
-
final: string;
|
|
41
|
-
gross: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The "Repräsentatives Beispiel" disclaimer line (PAngV). Only fields with a genuine value are
|
|
46
|
-
* included — nominalRate/finalInstallment/grossAmount are optional real config figures, never
|
|
47
|
-
* derived (see fields doc). Matches production wording/format exactly (VdpModules.tsx's
|
|
48
|
-
* `exampleLine`, byte-for-byte).
|
|
49
|
-
*/
|
|
50
|
-
export function buildFinanceExampleLine(fields: FinanceExampleFields, labels: FinanceExampleLabels, footnoteMarker = '³'): string {
|
|
51
|
-
const netto = fields.netLoanAmount != null ? fields.netLoanAmount : Math.max(0, fields.price - fields.down);
|
|
52
|
-
const parts: string[] = [
|
|
53
|
-
`${labels.vehiclePrice}: ${formatEur(fields.price)} € ${labels.priceSuffix}`,
|
|
54
|
-
...(fields.apr > 0 ? [`${labels.apr}: ${formatPct(fields.apr)}`] : []),
|
|
55
|
-
...(fields.nominalRate != null && fields.nominalRate > 0 ? [`${labels.nominal}: ${formatPct(fields.nominalRate)}`] : []),
|
|
56
|
-
`${labels.net}: ${formatEur(netto)} €`,
|
|
57
|
-
`${labels.down}: ${formatEur(fields.down)} €`,
|
|
58
|
-
...(fields.finalInstallment != null && fields.finalInstallment > 0 ? [`${labels.final}: ${formatEur(fields.finalInstallment)} €`] : []),
|
|
59
|
-
...(fields.grossAmount != null && fields.grossAmount > 0 ? [`${labels.gross}: ${formatEur(fields.grossAmount)} €`] : []),
|
|
60
|
-
];
|
|
61
|
-
return `${footnoteMarker} ${parts.join('; ')};`;
|
|
62
|
-
}
|
|
1
|
+
// Autobahn (auto.de redesign) — shared financing-number formatting.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from vdp/VdpModules.tsx's FinanceDetailsModal so non-React consumers (e.g. the
|
|
4
|
+
// Meta AIA marketing-image renderer) format the SAME real numbers the same way, instead of a
|
|
5
|
+
// second copy drifting from the VDP's. Pure TS, no React/JSX — safe to import from a plain
|
|
6
|
+
// Node service.
|
|
7
|
+
//
|
|
8
|
+
// The "only real figures, never a fabricated total" rule is load-bearing (legal: balloon
|
|
9
|
+
// financing means monthly×term is NOT the total) and lives here once, not per-caller.
|
|
10
|
+
|
|
11
|
+
/** Monetary figure → de-DE grouped string (e.g. 47400 → "47.400"). Feed rounded integers —
|
|
12
|
+
* toLocaleString on a fractional value prints a bogus 3-decimal grouping. */
|
|
13
|
+
export function formatEur(n: number): string {
|
|
14
|
+
return n.toLocaleString('de-DE');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Rate → de-DE percent string (e.g. 6.49 → "6,49 %"). */
|
|
18
|
+
export function formatPct(n: number): string {
|
|
19
|
+
return `${n.toFixed(2).replace('.', ',')} %`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FinanceExampleFields {
|
|
23
|
+
price: number;
|
|
24
|
+
apr: number;
|
|
25
|
+
down: number;
|
|
26
|
+
/** Real config figure — falls back to price-down ONLY when genuinely absent. */
|
|
27
|
+
netLoanAmount?: number;
|
|
28
|
+
nominalRate?: number;
|
|
29
|
+
finalInstallment?: number;
|
|
30
|
+
grossAmount?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FinanceExampleLabels {
|
|
34
|
+
vehiclePrice: string;
|
|
35
|
+
priceSuffix: string;
|
|
36
|
+
apr: string;
|
|
37
|
+
nominal: string;
|
|
38
|
+
net: string;
|
|
39
|
+
down: string;
|
|
40
|
+
final: string;
|
|
41
|
+
gross: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The "Repräsentatives Beispiel" disclaimer line (PAngV). Only fields with a genuine value are
|
|
46
|
+
* included — nominalRate/finalInstallment/grossAmount are optional real config figures, never
|
|
47
|
+
* derived (see fields doc). Matches production wording/format exactly (VdpModules.tsx's
|
|
48
|
+
* `exampleLine`, byte-for-byte).
|
|
49
|
+
*/
|
|
50
|
+
export function buildFinanceExampleLine(fields: FinanceExampleFields, labels: FinanceExampleLabels, footnoteMarker = '³'): string {
|
|
51
|
+
const netto = fields.netLoanAmount != null ? fields.netLoanAmount : Math.max(0, fields.price - fields.down);
|
|
52
|
+
const parts: string[] = [
|
|
53
|
+
`${labels.vehiclePrice}: ${formatEur(fields.price)} € ${labels.priceSuffix}`,
|
|
54
|
+
...(fields.apr > 0 ? [`${labels.apr}: ${formatPct(fields.apr)}`] : []),
|
|
55
|
+
...(fields.nominalRate != null && fields.nominalRate > 0 ? [`${labels.nominal}: ${formatPct(fields.nominalRate)}`] : []),
|
|
56
|
+
`${labels.net}: ${formatEur(netto)} €`,
|
|
57
|
+
`${labels.down}: ${formatEur(fields.down)} €`,
|
|
58
|
+
...(fields.finalInstallment != null && fields.finalInstallment > 0 ? [`${labels.final}: ${formatEur(fields.finalInstallment)} €`] : []),
|
|
59
|
+
...(fields.grossAmount != null && fields.grossAmount > 0 ? [`${labels.gross}: ${formatEur(fields.grossAmount)} €`] : []),
|
|
60
|
+
];
|
|
61
|
+
return `${footnoteMarker} ${parts.join('; ')};`;
|
|
62
|
+
}
|
package/utils/priceRating.ts
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
// Autobahn (auto.de redesign) — Preisbewertung rating math, extracted from CommonUtils.ts.
|
|
2
|
-
//
|
|
3
|
-
// Pulled out on purpose: CommonUtils.ts transitively imports `../constants/Search`, which
|
|
4
|
-
// `require()`s image assets (webpack-only — breaks any plain Node `require`/`tsc` consumer).
|
|
5
|
-
// This file only imports `../constants/price` (zero further imports) — a pure, testable leaf
|
|
6
|
-
// module. CommonUtils.ts re-exports these for existing consumers — one implementation, not a fork.
|
|
7
|
-
// (image-generator's Meta AIA renderer vendors a copy of this math directly instead of depending
|
|
8
|
-
// on this package — see services/image-generator/src/services/metaAia/vendoredAutobahnMath.ts —
|
|
9
|
-
// so a plain-Node backend doesn't need to npm-install this whole design system's dependency tree.)
|
|
10
|
-
import { priceRatings, priceRatingConfig } from '../constants/price';
|
|
11
|
-
import { PREDICTABLE_PRICE_INDEX } from '../constants/core';
|
|
12
|
-
|
|
13
|
-
export const getPriceRatingConfig = (currentPrice: number, pricePredicted: number) => { // tslint:disable-line
|
|
14
|
-
const round = (value: number) => {
|
|
15
|
-
return Number.isFinite(pricePredicted)
|
|
16
|
-
? Math.round((pricePredicted * (1 + (value / 100))) / 100) * 100 // tslint:disable-line
|
|
17
|
-
: '???';
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
if (currentPrice === 0) {
|
|
21
|
-
console.log('Empty current price');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return Object.keys(priceRatingConfig).reduce((acc: any, key: any) => {
|
|
25
|
-
const { min, max } = priceRatingConfig[key];
|
|
26
|
-
return { ...acc, [key]: { min: round(min), max: round(max) } };
|
|
27
|
-
}, {}); // tslint:disable-line
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export const getPredictablePrice = (currentSalesPrice: number, predictablePrice: number) => {
|
|
31
|
-
if (Number.isFinite(Number.parseFloat(`${predictablePrice}`))) {
|
|
32
|
-
return predictablePrice;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return currentSalesPrice * PREDICTABLE_PRICE_INDEX;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const getPriceRating = (currentSalesPrice: number, predictablePrice: number) => {
|
|
39
|
-
const isCorrectCurrentSalesPrice = Number.isFinite(Number.parseFloat(`${currentSalesPrice}`));
|
|
40
|
-
// const isCorrectPredictablePrice = Number.isFinite(Number.parseFloat(`${predictablePrice}`));
|
|
41
|
-
|
|
42
|
-
if (!isCorrectCurrentSalesPrice) return 0;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const validatedPredictablePrice = getPredictablePrice(currentSalesPrice, predictablePrice);
|
|
46
|
-
|
|
47
|
-
const ratingConfig = getPriceRatingConfig(currentSalesPrice, validatedPredictablePrice);
|
|
48
|
-
|
|
49
|
-
for (const key of Object.keys(ratingConfig)) {
|
|
50
|
-
const { min, max } = ratingConfig[key];
|
|
51
|
-
const TOP_PRICE = 5;
|
|
52
|
-
if (key === 'highPrice' && currentSalesPrice === max) return 1;
|
|
53
|
-
if (key === 'topPrice' && currentSalesPrice <= min) return TOP_PRICE;
|
|
54
|
-
if (currentSalesPrice >= min && currentSalesPrice < max) return priceRatings.indexOf(key);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return 0;
|
|
58
|
-
};
|
|
1
|
+
// Autobahn (auto.de redesign) — Preisbewertung rating math, extracted from CommonUtils.ts.
|
|
2
|
+
//
|
|
3
|
+
// Pulled out on purpose: CommonUtils.ts transitively imports `../constants/Search`, which
|
|
4
|
+
// `require()`s image assets (webpack-only — breaks any plain Node `require`/`tsc` consumer).
|
|
5
|
+
// This file only imports `../constants/price` (zero further imports) — a pure, testable leaf
|
|
6
|
+
// module. CommonUtils.ts re-exports these for existing consumers — one implementation, not a fork.
|
|
7
|
+
// (image-generator's Meta AIA renderer vendors a copy of this math directly instead of depending
|
|
8
|
+
// on this package — see services/image-generator/src/services/metaAia/vendoredAutobahnMath.ts —
|
|
9
|
+
// so a plain-Node backend doesn't need to npm-install this whole design system's dependency tree.)
|
|
10
|
+
import { priceRatings, priceRatingConfig } from '../constants/price';
|
|
11
|
+
import { PREDICTABLE_PRICE_INDEX } from '../constants/core';
|
|
12
|
+
|
|
13
|
+
export const getPriceRatingConfig = (currentPrice: number, pricePredicted: number) => { // tslint:disable-line
|
|
14
|
+
const round = (value: number) => {
|
|
15
|
+
return Number.isFinite(pricePredicted)
|
|
16
|
+
? Math.round((pricePredicted * (1 + (value / 100))) / 100) * 100 // tslint:disable-line
|
|
17
|
+
: '???';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (currentPrice === 0) {
|
|
21
|
+
console.log('Empty current price');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Object.keys(priceRatingConfig).reduce((acc: any, key: any) => {
|
|
25
|
+
const { min, max } = priceRatingConfig[key];
|
|
26
|
+
return { ...acc, [key]: { min: round(min), max: round(max) } };
|
|
27
|
+
}, {}); // tslint:disable-line
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getPredictablePrice = (currentSalesPrice: number, predictablePrice: number) => {
|
|
31
|
+
if (Number.isFinite(Number.parseFloat(`${predictablePrice}`))) {
|
|
32
|
+
return predictablePrice;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return currentSalesPrice * PREDICTABLE_PRICE_INDEX;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const getPriceRating = (currentSalesPrice: number, predictablePrice: number) => {
|
|
39
|
+
const isCorrectCurrentSalesPrice = Number.isFinite(Number.parseFloat(`${currentSalesPrice}`));
|
|
40
|
+
// const isCorrectPredictablePrice = Number.isFinite(Number.parseFloat(`${predictablePrice}`));
|
|
41
|
+
|
|
42
|
+
if (!isCorrectCurrentSalesPrice) return 0;
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
const validatedPredictablePrice = getPredictablePrice(currentSalesPrice, predictablePrice);
|
|
46
|
+
|
|
47
|
+
const ratingConfig = getPriceRatingConfig(currentSalesPrice, validatedPredictablePrice);
|
|
48
|
+
|
|
49
|
+
for (const key of Object.keys(ratingConfig)) {
|
|
50
|
+
const { min, max } = ratingConfig[key];
|
|
51
|
+
const TOP_PRICE = 5;
|
|
52
|
+
if (key === 'highPrice' && currentSalesPrice === max) return 1;
|
|
53
|
+
if (key === 'topPrice' && currentSalesPrice <= min) return TOP_PRICE;
|
|
54
|
+
if (currentSalesPrice >= min && currentSalesPrice < max) return priceRatings.indexOf(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return 0;
|
|
58
|
+
};
|