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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/theme/md3.css CHANGED
@@ -465,7 +465,7 @@ line-height: 31px;
465
465
  height: 34px;
466
466
  padding: 0 13px;
467
467
  flex: none;
468
- background: #fff;
468
+ background-color: hsla(0, 0%, 100%, .75);
469
469
  border: 1px solid #d4dde7;
470
470
  border-radius: 16px;
471
471
  }
@@ -3258,6 +3258,16 @@ line-height: 31px;
3258
3258
  opacity: 0.92;
3259
3259
  }
3260
3260
  }
3261
+ /* VDP gallery arrows only — .car-arrow is shared with the SRP card carousels (CarCard/CarRow),
3262
+ so this is scoped to .vdp-gallerybox rather than the base rule. */
3263
+ .md3 .vdp-gallerybox .car-arrow {
3264
+ width: 36px;
3265
+ height: 36px;
3266
+ border-radius: 18px;
3267
+ background-color: hsla(0, 0%, 100%, .75);
3268
+ border: 1px solid #D4DDE7;
3269
+ box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.08);
3270
+ }
3261
3271
 
3262
3272
  /* ==========================================================================
3263
3273
  RESPONSIVE LAYOUT LAYER
@@ -4728,6 +4738,16 @@ line-height: 31px;
4728
4738
  .md3 .enquiry-form {
4729
4739
  padding: 24px;
4730
4740
  }
4741
+ /* Vorname/Nachname/E-Mail carry their border/background as inline style (enquiryInput) —
4742
+ !important is required here to win over the inline style on focus. */
4743
+ .md3 .enquiry-name-row input:focus,
4744
+ .md3 .enquiry-email-wrap input:focus {
4745
+ background: #FFFFFF !important;
4746
+ border: 1px solid #005CFF !important;
4747
+ border-radius: 10px !important;
4748
+ outline: 0;
4749
+ box-shadow: none;
4750
+ }
4731
4751
  /* outlined float-label fields (Figma phone/message row): label sits ON the top border */
4732
4752
  .md3 .enq-float {
4733
4753
  position: relative;
@@ -4754,12 +4774,25 @@ line-height: 31px;
4754
4774
  border: 0;
4755
4775
  background: none;
4756
4776
  outline: 0;
4777
+ box-shadow: none;
4757
4778
  color: var(--md-on-surface);
4758
4779
  font: inherit;
4759
4780
  font-size: 14px;
4760
4781
  height: 46px;
4761
4782
  padding: 0 12px;
4762
4783
  }
4784
+ /* focus state (Figma): the .enq-float wrapper carries the border/background — the
4785
+ input/textarea/mselect-btn inside stay borderless (see above) so only one outline shows. */
4786
+ .md3 .enq-float:focus-within {
4787
+ background: #FFFFFF;
4788
+ border: 1px solid #005CFF;
4789
+ border-radius: 10px;
4790
+ box-shadow: none;
4791
+ }
4792
+ .md3 .enq-float:focus-within .enq-float-label {
4793
+ background: #FFFFFF;
4794
+ color: #005CFF;
4795
+ }
4763
4796
  .md3 .enq-float textarea {
4764
4797
  height: 96px;
4765
4798
  padding: 12px;
@@ -4770,15 +4803,26 @@ line-height: 31px;
4770
4803
  grid-template-columns: 82px 96px 1fr;
4771
4804
  gap: 10px;
4772
4805
  }
4773
- .md3 .enq-country {
4774
- display: flex;
4775
- align-items: center;
4776
- justify-content: space-between;
4777
- gap: 4px;
4806
+ /* "Land" is a real Dropdown (.mselect) now, nested inside the .enq-float outline — strip
4807
+ its own trigger chrome (border/background/box-shadow ring) so the outer .enq-float border
4808
+ stays the only visible outline, and match the 46px height of the sibling inputs. */
4809
+ .md3 .enq-float .mselect-btn {
4778
4810
  height: 46px;
4779
4811
  padding: 0 10px 0 12px;
4780
- font-size: 14px;
4781
- color: var(--md-on-surface);
4812
+ gap: 4px;
4813
+ border: 0;
4814
+ background: none;
4815
+ outline: 0;
4816
+ box-shadow: none;
4817
+ }
4818
+ .md3 .enq-float .mselect-btn:hover,
4819
+ .md3 .enq-float .mselect-btn[aria-expanded="true"] {
4820
+ box-shadow: none;
4821
+ }
4822
+ .md3 .enq-float .mselect-btn .lucide {
4823
+ width: 14px;
4824
+ height: 14px;
4825
+ color: var(--md-on-surface-variant);
4782
4826
  }
4783
4827
  /* revocable-consent checkbox + microtext (Figma round 9) */
4784
4828
  .md3 .enq-consent {
@@ -5058,6 +5102,61 @@ line-height: 31px;
5058
5102
  box-shadow: var(--md-e2);
5059
5103
  padding: 8px 16px 16px;
5060
5104
  }
5105
+
5106
+ /* mobile-only "Account Layer" sheet (TopBar) — full-width panel under the header, per the Figma
5107
+ "03 | Account Layer" mockups. .show-mobile forces display:flex (see the ≤600px override
5108
+ below) — flex-direction:column comes from the inline style on the element itself. */
5109
+ .md3 .amenu-mobile {
5110
+ /* NOT full-width — measured against the real Figma export: the panel is anchored to the
5111
+ RIGHT edge, leaving ~22% of the viewport showing the dimmed page on the left (both the
5112
+ logged-out and logged-in mockups measured close to this, not identical px-for-px, so this
5113
+ is the representative middle value rather than either exact reading). */
5114
+ position: absolute;
5115
+ top: 100%;
5116
+ left: 22%;
5117
+ right: 0;
5118
+ background: var(--md-surface);
5119
+ box-shadow: var(--md-elev-2);
5120
+ z-index: 60;
5121
+ max-height: calc(100vh - 64px);
5122
+ overflow-y: auto;
5123
+ /* only the LEFT corners are visually rounded (top-left, bottom-left) — the right edge sits
5124
+ flush against the viewport's own right edge, so rounding there would be invisible anyway.
5125
+ (This element is already show-mobile-gated, so no extra media query needed here.) */
5126
+ border-radius: 20px 0 0 20px;
5127
+ }
5128
+ .md3 .amenu-mobile-row:active {
5129
+ background: color-mix(in srgb, var(--md-primary) 6%, transparent);
5130
+ }
5131
+ /* desktop "Konto" pill collapses to a plain icon-only button on mobile — the account layer above
5132
+ replaces the small popover the pill normally opens on wider screens. */
5133
+ @media (max-width: 600px) {
5134
+ .md3 .topbar-account-btn {
5135
+ background: transparent;
5136
+ border: 0;
5137
+ padding: 0;
5138
+ width: 40px;
5139
+ height: 40px;
5140
+ min-width: 0;
5141
+ border-radius: 50%;
5142
+ }
5143
+ .md3 .topbar-account-btn:hover {
5144
+ background: color-mix(in srgb, var(--md-on-surface) 8%, transparent);
5145
+ }
5146
+ /* Mobile header icons measured against the real Figma export read visibly smaller than the
5147
+ reference (the shared .icon-btn/.mbtn rules force 20px/18px everywhere in the app — fine
5148
+ elsewhere, but the Figma mobile header specifically wants ~24px/22px). Scoped to icon
5149
+ buttons INSIDE the TopBar's own <header> so it doesn't touch icon-btn/mbtn usage on other
5150
+ pages (VDP gallery controls, financing modal, etc.). */
5151
+ .md3 header .icon-btn .lucide {
5152
+ width: 24px;
5153
+ height: 24px;
5154
+ }
5155
+ .md3 header .topbar-account-btn .lucide {
5156
+ width: 22px;
5157
+ height: 22px;
5158
+ }
5159
+ }
5061
5160
  /* language segmented DE|EN — active tab filled, inactive ghost */
5062
5161
  .md3 .lang-seg {
5063
5162
  display: inline-flex;
@@ -5188,9 +5287,10 @@ line-height: 31px;
5188
5287
  }
5189
5288
  .md3 .hiw-section {
5190
5289
  background: var(--md-sc-lowest);
5191
- border: 1px solid var(--fig-border);
5290
+ border: 1px solid #D4DDE7;
5192
5291
  border-radius: 18px;
5193
5292
  padding: 30px 24px;
5293
+ box-shadow: none;
5194
5294
  }
5195
5295
  .md3 .hiw-heading {
5196
5296
  text-align: left;
@@ -5777,10 +5877,10 @@ line-height: 31px;
5777
5877
  grid-template-columns: 1fr 1fr;
5778
5878
  gap: 10px;
5779
5879
  background: var(--md-sc-lowest);
5780
- border: 1px solid var(--fig-border);
5880
+ border: 1px solid #D4DDE7;
5781
5881
  border-radius: 18px;
5782
5882
  padding: 18px 16px;
5783
- box-shadow: 0 2px 3px rgba(14, 34, 56, 0.07);
5883
+ box-shadow: none;
5784
5884
  }
5785
5885
  .md3 .trust-strip > div {
5786
5886
  display: flex;
@@ -5944,6 +6044,7 @@ line-height: 31px;
5944
6044
  }
5945
6045
  .md3 .vdp-push-badges {
5946
6046
  gap: 6px;
6047
+ margin-bottom: 13px;
5947
6048
  }
5948
6049
  }
5949
6050
 
@@ -6067,21 +6168,37 @@ color: #005CFF;
6067
6168
  }
6068
6169
 
6069
6170
  @media (max-width: 600px) {
6070
- /* full-bleed gallery: edge-to-edge, app-like. Fixed 200px height on phone (overrides
6071
- the 980px breakpoint's aspect-ratio: 4/3, which made the box too tall here). */
6171
+ /* full-bleed gallery: edge-to-edge, app-like. Extra height (vs. the plain photo height)
6172
+ reserves room at the bottom for the controls row, now overlaid on the image itself
6173
+ (round 15 — supersedes the earlier "no overlay" layout). */
6072
6174
  .md3 .vdp-gallerybox {
6073
6175
  margin: 0 -24px;
6074
6176
  border-radius: 0;
6075
6177
  border: 0;
6076
6178
  box-shadow: none;
6077
- height: 200px;
6179
+ height: 230px;
6078
6180
  aspect-ratio: auto;
6079
6181
  }
6080
- /* controls row sits BELOW the image in normal flow (per user: no overlay) */
6182
+ /* .vdp-gallery is the positioning context: .gallery-controls-row is its own sibling of
6183
+ .vdp-gallerybox in the DOM, so it needs a relative ancestor to overlay against. */
6184
+ .md3 .vdp-gallery {
6185
+ position: relative;
6186
+ }
6187
+ /* controls row now sits ON the image as an overlay, bottom-aligned (round 15) */
6081
6188
  .md3 .gallery-controls-row {
6082
- padding: 0 2px;
6189
+ position: absolute;
6190
+ left: 14px;
6191
+ right: 14px;
6192
+ bottom: 12px;
6193
+ z-index: 3;
6194
+ padding: 0;
6195
+ }
6196
+ /* share/save icon buttons: scoped to THIS row (not the base .icon-btn--filled, which is
6197
+ also the opaque vdp-back + desktop overlay buttons) — translucent so photo shows through. */
6198
+ .md3 .gallery-controls-row .icon-btn--filled {
6199
+ background-color: hsla(0, 0%, 100%, .75);
6083
6200
  }
6084
- /* the carousel's own overlay dots move into the below-image controls row (page-rendered;
6201
+ /* the carousel's own overlay dots move into the on-image controls row (page-rendered;
6085
6202
  the dots div carries an inline display:flex — hence !important) */
6086
6203
  .md3 .vdp-gallerybox .cardcar-dots {
6087
6204
  display: none !important;
@@ -6451,11 +6568,19 @@ color: #005CFF;
6451
6568
  .md3 .vdp-similar {
6452
6569
  margin-top: 14px !important;
6453
6570
  }
6571
+ .md3 #vdp-kontakt {
6572
+ border: 1px solid #D4DDE7;
6573
+ box-shadow: none;
6574
+ }
6454
6575
  .md3 #vdp-bewertungen {
6455
6576
  padding: 28px 24px !important;
6577
+ border: 1px solid #D4DDE7;
6578
+ box-shadow: none;
6456
6579
  }
6457
6580
  .md3 #vdp-preis {
6458
6581
  padding: 0 !important;
6582
+ border: 1px solid #D4DDE7;
6583
+ box-shadow: none;
6459
6584
  }
6460
6585
  .md3 #vdp-preis h2 {
6461
6586
  padding: 22px 22px 0 !important;
@@ -6694,6 +6819,8 @@ color: #005CFF;
6694
6819
  margin-bottom: 20px !important;
6695
6820
  padding: 18px !important;
6696
6821
  box-sizing: border-box;
6822
+ border: 1px solid #D4DDE7;
6823
+ box-shadow: none;
6697
6824
  display: flex;
6698
6825
  flex-direction: column;
6699
6826
  }
@@ -6823,6 +6950,10 @@ color: #005CFF;
6823
6950
  text-align: center;
6824
6951
  color: #ffffff;
6825
6952
  }
6953
+ .md3 #vdp-eco {
6954
+ border: 1px solid #D4DDE7;
6955
+ box-shadow: none;
6956
+ }
6826
6957
  .md3 #vdp-eco .md-label-sm {
6827
6958
  font-family: Arial, Helvetica, sans-serif;
6828
6959
  font-style: normal;
@@ -6833,6 +6964,8 @@ color: #005CFF;
6833
6964
  }
6834
6965
  .md3 #vdp-daten {
6835
6966
  padding: 26px 24px 34px 24px;
6967
+ border: 1px solid #D4DDE7;
6968
+ box-shadow: none;
6836
6969
  }
6837
6970
  .md3 #vdp-daten .mbtn--text {
6838
6971
  font-family: Arial, Helvetica, sans-serif;
@@ -6855,6 +6988,8 @@ color: #005CFF;
6855
6988
  }
6856
6989
  .md3 #vdp-ausstattung {
6857
6990
  padding: 28px 24px 19px 24px;
6991
+ border: 1px solid #D4DDE7;
6992
+ box-shadow: none;
6858
6993
  }
6859
6994
  .md3 #vdp-ausstattung h2 {
6860
6995
  margin: 0 0 30px;
@@ -7018,6 +7153,8 @@ color: #005CFF;
7018
7153
  }
7019
7154
  .md3 .enq-country {
7020
7155
  width: 70px;
7156
+ }
7157
+ .md3 .enq-country .mselect-label {
7021
7158
  font-family: Arial, Helvetica, sans-serif;
7022
7159
  font-style: normal;
7023
7160
  font-weight: 400;
@@ -8931,3 +9068,160 @@ color: #005CFF;
8931
9068
  background: var(--md-sc-lowest);
8932
9069
  box-shadow: none;
8933
9070
  }
9071
+
9072
+ /* ==========================================================================
9073
+ VdpViewer "all photos" bottom sheet (libs/packages/fe-ui-core-autobahn/vdp/VdpViewer.tsx)
9074
+ --------------------------------------------------------------------------
9075
+ New selectors only, own `.vdp-photosheet*` prefix — nothing above this block
9076
+ is touched. Static values live here; only per-state dynamics (slide
9077
+ transform/transition while open/dragging, backdrop opacity/pointer-events)
9078
+ stay inline in the component. 3 grid columns is the default (portrait,
9079
+ <=693px); the 693-860px media query below is the ONLY landscape-specific
9080
+ override, matching the one visual difference the spec calls for.
9081
+ ========================================================================== */
9082
+ .md3 .vdp-photosheet-backdrop {
9083
+ position: absolute;
9084
+ inset: 0;
9085
+ background: rgba(0, 0, 0, 0.5);
9086
+ z-index: 210;
9087
+ }
9088
+ .md3 .vdp-photosheet {
9089
+ position: absolute;
9090
+ left: 0;
9091
+ right: 0;
9092
+ bottom: 0;
9093
+ height: 100vh;
9094
+ z-index: 211;
9095
+ display: flex;
9096
+ flex-direction: column;
9097
+ /* same dark color as the viewer's own header bar (rgba(6,11,18,0.8)), but fully opaque: the
9098
+ header uses 0.8 alpha OVER a photo, whereas this panel sits over the dimmed backdrop + viewer
9099
+ behind it — at 0.8 alpha, high-contrast content back there (e.g. the blue CTA button) bled
9100
+ through visibly during testing, so opacity is dropped without changing the hue itself. */
9101
+ background: rgb(6, 11, 18);
9102
+ border-radius: 16px 16px 0 0;
9103
+ overflow: hidden;
9104
+ }
9105
+ .md3 .vdp-photosheet__header {
9106
+ position: relative;
9107
+ flex: none;
9108
+ display: flex;
9109
+ align-items: center;
9110
+ justify-content: center;
9111
+ min-height: 88px;
9112
+ padding-top: 10px;
9113
+ touch-action: none;
9114
+ }
9115
+ .md3 .vdp-photosheet__handle {
9116
+ position: absolute;
9117
+ top: 8px;
9118
+ left: 50%;
9119
+ transform: translateX(-50%);
9120
+ width: 36px;
9121
+ height: 4px;
9122
+ border-radius: 2px;
9123
+ background: rgba(255, 255, 255, 0.28);
9124
+ }
9125
+ .md3 .vdp-photosheet__close {
9126
+ position: absolute;
9127
+ left: 12px;
9128
+ top: 50%;
9129
+ transform: translateY(-50%);
9130
+ }
9131
+ .md3 .vdp-photosheet__logo {
9132
+ display: flex;
9133
+ align-items: center;
9134
+ justify-content: center;
9135
+ }
9136
+ .md3 .vdp-photosheet__logo--portrait {
9137
+ transform: scale(0.75);
9138
+ }
9139
+ .md3 .vdp-photosheet__titles {
9140
+ flex: none;
9141
+ padding: 0 16px 12px;
9142
+ text-align: left;
9143
+ }
9144
+ .md3 .vdp-photosheet__title {
9145
+ margin: 0;
9146
+ font-family: 'Inter', var(--md-font);
9147
+ font-size: 18px;
9148
+ font-weight: 700;
9149
+ line-height: 24px;
9150
+ color: #ffffff;
9151
+ }
9152
+ .md3 .vdp-photosheet__subtitle {
9153
+ margin: 4px 0 0;
9154
+ font-family: 'Inter', var(--md-font);
9155
+ font-size: 13px;
9156
+ font-weight: 400;
9157
+ line-height: 18px;
9158
+ color: rgba(255, 255, 255, 0.56);
9159
+ }
9160
+ .md3 .vdp-photosheet__scroll {
9161
+ flex: 1;
9162
+ min-height: 0;
9163
+ overflow-y: auto;
9164
+ padding: 0 12px 12px;
9165
+ }
9166
+ .md3 .vdp-photosheet__grid {
9167
+ display: grid;
9168
+ grid-template-columns: repeat(3, 1fr);
9169
+ gap: 8px;
9170
+ }
9171
+ .md3 .vdp-photosheet__tile {
9172
+ position: relative;
9173
+ aspect-ratio: 4 / 3;
9174
+ box-sizing: border-box;
9175
+ border-radius: 10px;
9176
+ overflow: hidden;
9177
+ border: 1px solid rgba(255, 255, 255, 0.16);
9178
+ padding: 0;
9179
+ cursor: pointer;
9180
+ background: rgba(255, 255, 255, 0.06);
9181
+ }
9182
+ .md3 .vdp-photosheet__tile img {
9183
+ width: 100%;
9184
+ height: 100%;
9185
+ object-fit: cover;
9186
+ display: block;
9187
+ }
9188
+ .md3 .vdp-photosheet__tile--active {
9189
+ border: 3px solid #0b63f6;
9190
+ }
9191
+ .md3 .vdp-photosheet__badge {
9192
+ position: absolute;
9193
+ top: 6px;
9194
+ right: 6px;
9195
+ width: 20px;
9196
+ height: 20px;
9197
+ border-radius: 50%;
9198
+ background: #0b63f6;
9199
+ display: flex;
9200
+ align-items: center;
9201
+ justify-content: center;
9202
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
9203
+ }
9204
+ .md3 .vdp-photosheet__footer {
9205
+ flex: none;
9206
+ display: flex;
9207
+ align-items: center;
9208
+ justify-content: flex-start;
9209
+ gap: 6px;
9210
+ height: 44px;
9211
+ padding: 0 16px;
9212
+ /* rgba(0,0,0,0.5) was invisible here — it darkens whatever's behind it, and the sheet's own
9213
+ panel (rgb(6,11,18)) is already almost black, so a 50%-black overlay on top of near-black
9214
+ reads as no change at all. #1c222c is lighter/bluer and already used elsewhere in this same
9215
+ file (landscape's "Anrufen" CTA background), so it's a real, visible band without a new tone. */
9216
+ background: #1c222c;
9217
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
9218
+ color: rgba(255, 255, 255, 0.72);
9219
+ font-family: 'Inter', var(--md-font);
9220
+ font-size: 13px;
9221
+ font-weight: 500;
9222
+ }
9223
+ @media (min-width: 693px) and (max-width: 860px) {
9224
+ .md3 .vdp-photosheet__grid {
9225
+ grid-template-columns: repeat(4, 1fr);
9226
+ }
9227
+ }
@@ -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
+ }