@bytebrand/fe-ui-core-autobahn 1.0.1 → 1.0.3

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.
Files changed (621) hide show
  1. package/assets/autode-logo.svg +6 -6
  2. package/cards/CarCard.tsx +174 -174
  3. package/cards/CarRow.tsx +186 -182
  4. package/cards/cardParts.tsx +529 -489
  5. package/cards/index.ts +37 -35
  6. package/cards/types.ts +124 -114
  7. package/chrome/Footer.tsx +189 -189
  8. package/chrome/TopBar.tsx +405 -405
  9. package/chrome/index.ts +20 -20
  10. package/chrome/svg.d.ts +11 -11
  11. package/compat/AccardionSection/AccardionSection.styl +45 -45
  12. package/compat/AccardionSection/AccardionSection.tsx +56 -56
  13. package/compat/AccordionWidget/AccordionWidget.styl +220 -220
  14. package/compat/AccordionWidget/AccordionWidget.tsx +257 -257
  15. package/compat/AccordionWrapper/AccordionItem/AccordionItem.styl +34 -34
  16. package/compat/AccordionWrapper/AccordionItem/AccordionItem.tsx +64 -64
  17. package/compat/AccordionWrapper/AccordionWrapper.tsx +34 -34
  18. package/compat/AdvantageItem/AdvantageItem.styl +47 -47
  19. package/compat/AdvantageItem/AdvantageItem.tsx +26 -26
  20. package/compat/Alternative/DealerInfo.styl +116 -116
  21. package/compat/Alternative/DealerInfo.tsx +171 -171
  22. package/compat/Badge/Badge.styl +44 -44
  23. package/compat/Badge/Badge.tsx +25 -25
  24. package/compat/BodyTypeIcons/Audi.tsx +9 -9
  25. package/compat/BodyTypeIcons/Bmw.tsx +860 -860
  26. package/compat/BodyTypeIcons/Ford.tsx +31 -31
  27. package/compat/BodyTypeIcons/IconsConfig.tsx +44 -44
  28. package/compat/BodyTypeIcons/IconsConfigCompact.tsx +30 -30
  29. package/compat/BodyTypeIcons/Mazda.tsx +44 -44
  30. package/compat/BodyTypeIcons/MercedesBenz.tsx +38 -38
  31. package/compat/BodyTypeIcons/Opel.tsx +19 -19
  32. package/compat/BodyTypeIcons/Search.tsx +19 -19
  33. package/compat/BodyTypeIcons/SelectorCategoryCombi.tsx +68 -68
  34. package/compat/BodyTypeIcons/SelectorCategoryCompact.tsx +20 -20
  35. package/compat/BodyTypeIcons/SelectorCategoryConvertible.tsx +68 -68
  36. package/compat/BodyTypeIcons/SelectorCategoryCoupe.tsx +60 -60
  37. package/compat/BodyTypeIcons/SelectorCategoryMinibus.tsx +64 -64
  38. package/compat/BodyTypeIcons/SelectorCategorySedan.tsx +69 -69
  39. package/compat/BodyTypeIcons/SelectorCategorySuv.tsx +77 -77
  40. package/compat/BodyTypeIcons/SelectorCategoryTransporter.tsx +68 -68
  41. package/compat/BodyTypeIcons/SelectorCategoryVan.tsx +72 -72
  42. package/compat/BodyTypeIcons/Volkswagen.tsx +330 -330
  43. package/compat/BodyTypeIcons/index.ts +17 -17
  44. package/compat/Button/Button.tsx +65 -65
  45. package/compat/ButtonOld/Button.styl +65 -65
  46. package/compat/ButtonOld/Button.tsx +84 -84
  47. package/compat/Checkbox/Checkbox.styl +119 -119
  48. package/compat/Checkbox/Checkbox.tsx +151 -151
  49. package/compat/Checkbox/FormCheckbox.styl +22 -22
  50. package/compat/Checkbox/FormCheckbox.tsx +86 -86
  51. package/compat/Co2Widget/CO2Efficiency.styl +219 -219
  52. package/compat/Co2Widget/CO2Efficiency.tsx +93 -93
  53. package/compat/ContactInfo/ContactInfo.styl +22 -22
  54. package/compat/ContactInfo/ContactInfo.tsx +161 -161
  55. package/compat/ContactInfo/TemporaryTranslation.ts +11 -11
  56. package/compat/CookieModal/CookieModal.tsx +49 -49
  57. package/compat/CookieModal/Modal.tsx +43 -43
  58. package/compat/CookieModal/ModalsConfig.tsx +23 -23
  59. package/compat/CookieModal/modals/ManageCookieModal/ManageCookieModal.styl +86 -86
  60. package/compat/CookieModal/modals/ManageCookieModal/ManageCookieModal.tsx +184 -184
  61. package/compat/CookieModal/modals/PreviewCookieModal/PreviewCookieModal.styl +66 -66
  62. package/compat/CookieModal/modals/PreviewCookieModal/PreviewCookieModal.tsx +51 -51
  63. package/compat/CookieModal/modals/TestModal.tsx +7 -7
  64. package/compat/CookieModal/useModal.ts +13 -13
  65. package/compat/CustomerQuote/CustomerQuote.styl +79 -79
  66. package/compat/CustomerQuote/CustomerQuote.tsx +68 -68
  67. package/compat/CustomerQuotesSlider/CustomerQuotesSlider.styl +46 -46
  68. package/compat/CustomerQuotesSlider/CustomerQuotesSlider.tsx +79 -79
  69. package/compat/CustomerQuotesSlider/SliderArrow.tsx +20 -20
  70. package/compat/CustomerQuotesSlider/slider.global.styl +2 -2
  71. package/compat/ExpansionPanel/ExpansionPanel.tsx +68 -68
  72. package/compat/FirstInfoBlock/FirstInfoBlock.styl +33 -33
  73. package/compat/FirstInfoBlock/FirstInfoBlock.tsx +49 -49
  74. package/compat/FirstInfoBlock/FirstInfoBlockItem/FirstInfoBlockItem.styl +66 -66
  75. package/compat/FirstInfoBlock/FirstInfoBlockItem/FirstInfoBlockItem.tsx +21 -21
  76. package/compat/FormattedNumber/FormattedNumber.styl +1 -1
  77. package/compat/FormattedNumber/FormattedNumber.tsx +90 -90
  78. package/compat/IconContainer/IconContainer.styl +23 -23
  79. package/compat/IconContainer/IconContainer.tsx +34 -34
  80. package/compat/Image/Image.styl +109 -109
  81. package/compat/Image/Image.tsx +302 -302
  82. package/compat/ImageSettings/EditableOptionsTable/CustomCheckbox/CustomCheckbox.styl +7 -7
  83. package/compat/ImageSettings/EditableOptionsTable/CustomCheckbox/CustomCheckbox.tsx +30 -30
  84. package/compat/ImageSettings/EditableOptionsTable/EditableOptionsTable.styl +122 -122
  85. package/compat/ImageSettings/EditableOptionsTable/EditableOptionsTable.tsx +199 -199
  86. package/compat/ImageSettings/EditableOptionsTable/ImageIcon/ImageIcon.styl +3 -3
  87. package/compat/ImageSettings/EditableOptionsTable/ImageIcon/ImageIcon.tsx +15 -15
  88. package/compat/ImageSettings/OptionsTable/OptionsTable.styl +47 -47
  89. package/compat/ImageSettings/OptionsTable/OptionsTable.tsx +122 -122
  90. package/compat/ImageSettings/PricePlan/PricePlan.styl +63 -63
  91. package/compat/ImageSettings/PricePlan/PricePlan.tsx +32 -32
  92. package/compat/ImageSettings/index.ts +42 -42
  93. package/compat/ImagesSet/ImagesSet.styl +27 -27
  94. package/compat/ImagesSet/ImagesSet.tsx +76 -76
  95. package/compat/InfoBlockWrapper/InfoBlockWrapper.styl +41 -41
  96. package/compat/InfoBlockWrapper/InfoBlockWrapper.tsx +46 -46
  97. package/compat/Loader/Loader.styl +39 -39
  98. package/compat/Loader/Loader.tsx +32 -32
  99. package/compat/MagnifyGlass/MagnifyGlass.styl +33 -33
  100. package/compat/MagnifyGlass/MagnifyGlass.tsx +213 -213
  101. package/compat/MdAccordionGroup/MdAccordionGroup.tsx +53 -53
  102. package/compat/MdCheckbox/MdCheckbox.tsx +69 -69
  103. package/compat/MdField/MdField.tsx +100 -100
  104. package/compat/MdSelect/MdSelect.tsx +103 -103
  105. package/compat/MdSwitch/MdSwitch.tsx +29 -29
  106. package/compat/MdTabs/MdTabs.tsx +47 -47
  107. package/compat/MdTooltip/MdTooltip.tsx +31 -31
  108. package/compat/OfferDetailedSection/OfferDetailedSection.styl +31 -31
  109. package/compat/OfferDetailedSection/OfferDetailedSection.tsx +100 -100
  110. package/compat/OfferDetailedSection/partials/PanelConfig.styl +131 -131
  111. package/compat/OfferDetailedSection/partials/PanelConfig.tsx +121 -121
  112. package/compat/OfferPanel/AboAccordion/AboAccordion.styl +24 -24
  113. package/compat/OfferPanel/AboAccordion/AboAccordion.tsx +128 -128
  114. package/compat/OfferPanel/AboAccordion/AboColorOptions/AboColorOptions.styl +11 -11
  115. package/compat/OfferPanel/AboAccordion/AboColorOptions/AboColorOptions.tsx +35 -35
  116. package/compat/OfferPanel/AboAccordion/AboFaq/AboFaq.styl +20 -20
  117. package/compat/OfferPanel/AboAccordion/AboFaq/AboFaq.tsx +25 -25
  118. package/compat/OfferPanel/AboAccordion/AboIncludedServices/AboIncludedServices.styl +13 -13
  119. package/compat/OfferPanel/AboAccordion/AboIncludedServices/AboIncludedServices.tsx +39 -39
  120. package/compat/OfferPanel/MuiOfferPeriod/MuiOfferPeriod.tsx +42 -42
  121. package/compat/OfferPanel/OfferCheckboxGroup/CheckboxContainer.tsx +140 -140
  122. package/compat/OfferPanel/OfferCheckboxGroup/OfferCheckboxGroup.styl +144 -144
  123. package/compat/OfferPanel/OfferCheckboxGroup/OfferCheckboxGroup.tsx +23 -23
  124. package/compat/OfferPanel/OfferPanel.styl +193 -193
  125. package/compat/OfferPanel/OfferPanel.tsx +81 -81
  126. package/compat/OfferPanel/OfferPeriod/OfferPeriod.styl +24 -24
  127. package/compat/OfferPanel/OfferPeriod/OfferPeriod.tsx +45 -45
  128. package/compat/OfferPanel/RangeGroup/RangeGroup.tsx +39 -39
  129. package/compat/OfferRequestButtonWrapper/OfferRequestButtonWrapper.styl +119 -119
  130. package/compat/OfferRequestButtonWrapper/OfferRequestButtonWrapper.styled.tsx +33 -33
  131. package/compat/OfferRequestButtonWrapper/OfferRequestButtonWrapper.tsx +122 -122
  132. package/compat/PriceRating/PriceRating.styl +47 -47
  133. package/compat/PriceRating/PriceRating.tsx +64 -64
  134. package/compat/PriceRatingDetailed/PriceRatingDetailed.styl +120 -120
  135. package/compat/PriceRatingDetailed/PriceRatingDetailed.tsx +191 -191
  136. package/compat/PricingToolBlock/PricingTool.styl +6 -6
  137. package/compat/PricingToolBlock/PricingTool.tsx +29 -29
  138. package/compat/Radio/Radio.styl +115 -115
  139. package/compat/Radio/Radio.tsx +141 -141
  140. package/compat/Radio/RadioGroup.styl +5 -5
  141. package/compat/Radio/RadioGroup.tsx +65 -65
  142. package/compat/Range/Range.tsx +79 -79
  143. package/compat/RateSearchValue/RateSearchValue.styl +22 -22
  144. package/compat/RateSearchValue/RateSearchValue.tsx +55 -55
  145. package/compat/RateSearchWidget/RateSearchWidget.styl +106 -106
  146. package/compat/RateSearchWidget/RateSearchWidget.tsx +163 -163
  147. package/compat/ServiceBlockItem/ServiceBlockItem.styl +47 -47
  148. package/compat/ServiceBlockItem/ServiceBlockItem.tsx +25 -25
  149. package/compat/SkeletonItem/SkeletonItem.styl +80 -80
  150. package/compat/SkeletonItem/SkeletonItem.tsx +80 -80
  151. package/compat/StarButton/StarButton.styl +39 -39
  152. package/compat/StarButton/StarButton.tsx +31 -31
  153. package/compat/TabPanel/TabPanel.tsx +28 -28
  154. package/compat/TimePicker/TimePicker.styl +22 -22
  155. package/compat/TimePicker/TimePicker.tsx +156 -156
  156. package/compat/Tooltip/Tooltip.styl +117 -117
  157. package/compat/Tooltip/Tooltip.tsx +63 -63
  158. package/compat/Usp/Usp.styl +78 -78
  159. package/compat/Usp/Usp.tsx +30 -30
  160. package/compat/Usp/uspTranslate.ts +12 -12
  161. package/compat/VehicleDetailedSidebar/VehicleDetailedSidebar.styl +36 -36
  162. package/compat/VehicleDetailedSidebar/VehicleDetailedSidebar.tsx +178 -178
  163. package/compat/VehicleDetailedSidebar/partials/Price.styl +27 -27
  164. package/compat/VehicleDetailedSidebar/partials/Price.tsx +41 -41
  165. package/compat/VehicleDetailedSidebar/partials/PriceContent.styl +123 -123
  166. package/compat/VehicleDetailedSidebar/partials/PriceContent.tsx +128 -128
  167. package/compat/VehicleDetailedSidebar/partials/Properties.styl +58 -58
  168. package/compat/VehicleDetailedSidebar/partials/Properties.tsx +155 -155
  169. package/compat/VehicleDetailedSidebar/partials/Title.styl +35 -35
  170. package/compat/VehicleDetailedSidebar/partials/Title.tsx +33 -33
  171. package/compat/VehicleDetailedSlider/VehicleDetailedSlider.styl +146 -146
  172. package/compat/VehicleDetailedSlider/VehicleDetailedSlider.tsx +382 -382
  173. package/compat/VehicleDetailedSlider/partials/MobileStast.tsx +61 -61
  174. package/compat/VehicleDetailedSlider/partials/MobileStats.styl +71 -71
  175. package/compat/VehicleDetailedSlider/partials/PriceData.styl +50 -50
  176. package/compat/VehicleDetailedSlider/partials/PriceData.tsx +67 -67
  177. package/compat/VehicleDetailedSlider/partials/Stats.styl +80 -80
  178. package/compat/VehicleDetailedSlider/partials/Stats.tsx +62 -62
  179. package/compat/VehicleDetailedSlider/partials/Title.styl +44 -44
  180. package/compat/VehicleDetailedSlider/partials/Title.tsx +36 -36
  181. package/compat/VehicleFormattedPrice/VehicleFormattedPrice.styl +148 -148
  182. package/compat/VehicleFormattedPrice/VehicleFormattedPrice.tsx +163 -163
  183. package/compat/VehicleSlider/VehicleSlider.styl +62 -62
  184. package/compat/VehicleSlider/VehicleSlider.tsx +125 -125
  185. package/compat/VehicleSlider/VehicleSliderForSRL.tsx +175 -175
  186. package/compat/VehicleSmallCard/SeoText/SeoText.styl +8 -8
  187. package/compat/VehicleSmallCard/SeoText/SeoText.tsx +16 -16
  188. package/compat/VehicleSmallCard/VehicleData/VechiclePriceItem/VechiclePriceItem.styl +204 -204
  189. package/compat/VehicleSmallCard/VehicleData/VechiclePriceItem/VechiclePriceItem.tsx +199 -199
  190. package/compat/VehicleSmallCard/VehicleData/VehicleInfo/VehicleInfo.styl +273 -273
  191. package/compat/VehicleSmallCard/VehicleData/VehicleInfo/VehicleInfo.tsx +179 -179
  192. package/compat/VehicleSmallCard/VehicleData/VehiclePrice/VehiclePrice.styl +165 -165
  193. package/compat/VehicleSmallCard/VehicleData/VehiclePrice/VehiclePrice.tsx +299 -299
  194. package/compat/VehicleSmallCard/VehicleData/VehicleProperty/VehicleProperty.styl +32 -32
  195. package/compat/VehicleSmallCard/VehicleData/VehicleProperty/VehicleProperty.tsx +35 -35
  196. package/compat/VehicleSmallCard/VehicleData/VehicleTitle/VehicleTitle.styl +135 -135
  197. package/compat/VehicleSmallCard/VehicleData/VehicleTitle/VehicleTitle.tsx +91 -91
  198. package/compat/VehicleSmallCard/VehicleSmallCard.styl +309 -309
  199. package/compat/VehicleSmallCard/VehicleSmallCard.tsx +448 -448
  200. package/compat/index.ts +77 -77
  201. package/compat/legacy-theme/mixins.styl +93 -93
  202. package/compat/legacy-theme/priceReviewColors.styl +12 -12
  203. package/compat/legacy-theme/slider.global.styl +5 -5
  204. package/compat/legacy-theme/theme.styl +136 -136
  205. package/compat/withLabel/withLabel.styl +33 -33
  206. package/compat/withLabel/withLabel.tsx +44 -44
  207. package/compat/withStats/withStats.styl +174 -174
  208. package/compat/withStats/withStats.tsx +238 -238
  209. package/constants/RateSearchWidget.ts +15 -15
  210. package/constants/Search.ts +290 -290
  211. package/constants/SearchWidget.ts +108 -108
  212. package/constants/app.ts +223 -223
  213. package/constants/common.ts +127 -127
  214. package/constants/core.ts +131 -131
  215. package/constants/highlights.ts +165 -165
  216. package/constants/price.ts +9 -9
  217. package/constants/selectors.ts +38 -38
  218. package/constants.ts +91 -91
  219. package/data/DataTransformers.ts +88 -88
  220. package/data/fieldMapping.ts +364 -342
  221. package/data/toAutobahnCar.ts +455 -442
  222. package/iconsvg/IconSVG.tsx +30 -30
  223. package/iconsvg/IconSVGConfig.tsx +768 -768
  224. package/iconsvg/SVG/AboPartner.tsx +16 -16
  225. package/iconsvg/SVG/ArrowQuotes.tsx +22 -22
  226. package/iconsvg/SVG/CloseIcon.tsx +17 -17
  227. package/iconsvg/SVG/CloseIconSelect.tsx +18 -18
  228. package/iconsvg/SVG/ClosedLockIcon.tsx +18 -18
  229. package/iconsvg/SVG/DealerCheckIcon.tsx +18 -18
  230. package/iconsvg/SVG/DealerPopoverMail.tsx +21 -21
  231. package/iconsvg/SVG/DealerPopoverPhone.tsx +21 -21
  232. package/iconsvg/SVG/GrayPhoneIcon.tsx +22 -22
  233. package/iconsvg/SVG/InternetIcon.tsx +18 -18
  234. package/iconsvg/SVG/WhiteEnvelopeIcon.tsx +21 -21
  235. package/iconsvg/SVG/bodyType/Cabrio.tsx +23 -23
  236. package/iconsvg/SVG/bodyType/Coupe.tsx +22 -22
  237. package/iconsvg/SVG/bodyType/Minibus.tsx +22 -22
  238. package/iconsvg/SVG/bodyType/Other.tsx +25 -25
  239. package/iconsvg/SVG/bodyType/Sedan.tsx +22 -22
  240. package/iconsvg/SVG/bodyType/SelectorCategoryCombi.tsx +68 -68
  241. package/iconsvg/SVG/bodyType/SelectorCategoryCompact.tsx +20 -20
  242. package/iconsvg/SVG/bodyType/SelectorCategoryConvertible.tsx +68 -68
  243. package/iconsvg/SVG/bodyType/SelectorCategoryCoupe.tsx +60 -60
  244. package/iconsvg/SVG/bodyType/SelectorCategoryMinibus.tsx +64 -64
  245. package/iconsvg/SVG/bodyType/SelectorCategoryOther.tsx +60 -60
  246. package/iconsvg/SVG/bodyType/SelectorCategorySedan.tsx +69 -69
  247. package/iconsvg/SVG/bodyType/SelectorCategorySuv.tsx +77 -77
  248. package/iconsvg/SVG/bodyType/SelectorCategoryTransporter.tsx +68 -68
  249. package/iconsvg/SVG/bodyType/SelectorCategoryVan.tsx +72 -72
  250. package/iconsvg/SVG/bodyType/Small.tsx +23 -23
  251. package/iconsvg/SVG/bodyType/Suv.tsx +23 -23
  252. package/iconsvg/SVG/bodyType/Van.tsx +23 -23
  253. package/iconsvg/SVG/bodyType/Wagon.tsx +23 -23
  254. package/iconsvg/SVG/common/AddIcon.tsx +16 -16
  255. package/iconsvg/SVG/common/AddPlus.tsx +16 -16
  256. package/iconsvg/SVG/common/AddressDashboard.tsx +29 -29
  257. package/iconsvg/SVG/common/AddressIcon.tsx +20 -20
  258. package/iconsvg/SVG/common/ArrowDetailedAccordionBlue.tsx +26 -26
  259. package/iconsvg/SVG/common/ArrowDownBlue.tsx +21 -21
  260. package/iconsvg/SVG/common/ArrowSelect.tsx +23 -23
  261. package/iconsvg/SVG/common/ArrowStepDown.tsx +18 -18
  262. package/iconsvg/SVG/common/Autocheck.tsx +19 -19
  263. package/iconsvg/SVG/common/AutodeLogo.tsx +36 -36
  264. package/iconsvg/SVG/common/Avatar.tsx +17 -17
  265. package/iconsvg/SVG/common/BellGray.tsx +19 -19
  266. package/iconsvg/SVG/common/BigInfoTransparent.tsx +13 -13
  267. package/iconsvg/SVG/common/Browse.tsx +20 -20
  268. package/iconsvg/SVG/common/Calendar.tsx +24 -24
  269. package/iconsvg/SVG/common/CallbackDashboard.tsx +30 -30
  270. package/iconsvg/SVG/common/Camera.tsx +17 -17
  271. package/iconsvg/SVG/common/Car.tsx +16 -16
  272. package/iconsvg/SVG/common/CarBody.tsx +19 -19
  273. package/iconsvg/SVG/common/CarFront.tsx +43 -43
  274. package/iconsvg/SVG/common/CarInspectionCheck.tsx +21 -21
  275. package/iconsvg/SVG/common/CarInspectionScheme0.tsx +32 -32
  276. package/iconsvg/SVG/common/CarInspectionScheme1.tsx +53 -53
  277. package/iconsvg/SVG/common/CarInspectionScheme2.tsx +52 -52
  278. package/iconsvg/SVG/common/CarInspectionScheme3.tsx +32 -32
  279. package/iconsvg/SVG/common/CarInspectionScheme4.tsx +32 -32
  280. package/iconsvg/SVG/common/CarInspectionScheme5.tsx +32 -32
  281. package/iconsvg/SVG/common/CarInspectionScheme6.tsx +32 -32
  282. package/iconsvg/SVG/common/CarInspectionSchemeGeneral copy.tsx +24 -24
  283. package/iconsvg/SVG/common/CarInspectionSchemeGeneral.tsx +24 -24
  284. package/iconsvg/SVG/common/CheckBlue.tsx +18 -18
  285. package/iconsvg/SVG/common/CheckGreen.tsx +22 -22
  286. package/iconsvg/SVG/common/CheckMark.tsx +16 -16
  287. package/iconsvg/SVG/common/CheckWhite.tsx +19 -19
  288. package/iconsvg/SVG/common/Chevron.tsx +17 -17
  289. package/iconsvg/SVG/common/CircledCheckmark.tsx +25 -25
  290. package/iconsvg/SVG/common/Clock.tsx +18 -18
  291. package/iconsvg/SVG/common/Close.tsx +19 -19
  292. package/iconsvg/SVG/common/CloseGrey.tsx +22 -22
  293. package/iconsvg/SVG/common/ContactEmail.tsx +21 -21
  294. package/iconsvg/SVG/common/ContactPhone.tsx +28 -28
  295. package/iconsvg/SVG/common/CopyIcon copy.tsx +22 -22
  296. package/iconsvg/SVG/common/CopyIcon.tsx +22 -22
  297. package/iconsvg/SVG/common/CopyIconForDealer.tsx +21 -21
  298. package/iconsvg/SVG/common/DashboardIcon.tsx +20 -20
  299. package/iconsvg/SVG/common/DashboardQuestionMark.tsx +20 -20
  300. package/iconsvg/SVG/common/DealersIcon.tsx +20 -20
  301. package/iconsvg/SVG/common/DeleteCircle.tsx +31 -31
  302. package/iconsvg/SVG/common/DeleteV1Icon.tsx +18 -18
  303. package/iconsvg/SVG/common/DeliveryDashboard.tsx +25 -25
  304. package/iconsvg/SVG/common/DeliveryMail.tsx +24 -24
  305. package/iconsvg/SVG/common/Discount100De.tsx +18 -18
  306. package/iconsvg/SVG/common/Discount100En.tsx +19 -19
  307. package/iconsvg/SVG/common/Discount66De.tsx +28 -28
  308. package/iconsvg/SVG/common/Discount66En.tsx +20 -20
  309. package/iconsvg/SVG/common/DoneIcon.tsx +18 -18
  310. package/iconsvg/SVG/common/EditCircle.tsx +32 -32
  311. package/iconsvg/SVG/common/EditPriceIcon.tsx +20 -20
  312. package/iconsvg/SVG/common/EuroCurrency.tsx +18 -18
  313. package/iconsvg/SVG/common/Export.tsx +20 -20
  314. package/iconsvg/SVG/common/Eye.tsx +17 -17
  315. package/iconsvg/SVG/common/EyeCatch.tsx +19 -19
  316. package/iconsvg/SVG/common/FavoritesIcon.tsx +20 -20
  317. package/iconsvg/SVG/common/FilterRange.tsx +16 -16
  318. package/iconsvg/SVG/common/FilterToolBlack.tsx +23 -23
  319. package/iconsvg/SVG/common/FlagDe.tsx +21 -21
  320. package/iconsvg/SVG/common/FlagEn.tsx +43 -43
  321. package/iconsvg/SVG/common/GrayEnvelopeIcon.tsx +19 -19
  322. package/iconsvg/SVG/common/HamburgerMenuIcon.tsx +18 -18
  323. package/iconsvg/SVG/common/HandingOverDashboard.tsx +32 -32
  324. package/iconsvg/SVG/common/ImageSettingsIcon.tsx +20 -20
  325. package/iconsvg/SVG/common/Info.tsx +16 -16
  326. package/iconsvg/SVG/common/InfoIcon.tsx +17 -17
  327. package/iconsvg/SVG/common/InfoTransparent.tsx +18 -18
  328. package/iconsvg/SVG/common/LogoutIcon.tsx +20 -20
  329. package/iconsvg/SVG/common/MagnifyingGlass.tsx +31 -31
  330. package/iconsvg/SVG/common/MenuArrow.tsx +16 -16
  331. package/iconsvg/SVG/common/MenuGrey.tsx +22 -22
  332. package/iconsvg/SVG/common/MobileDeLogo.tsx +108 -108
  333. package/iconsvg/SVG/common/MyOrderIcon.tsx +20 -20
  334. package/iconsvg/SVG/common/MyVehiclesIcon.tsx +20 -20
  335. package/iconsvg/SVG/common/NewFlagDe.tsx +25 -25
  336. package/iconsvg/SVG/common/NewFlagEn.tsx +34 -34
  337. package/iconsvg/SVG/common/OrderReceivedDashboard.tsx +27 -27
  338. package/iconsvg/SVG/common/Package.tsx +30 -30
  339. package/iconsvg/SVG/common/Phone.tsx +18 -18
  340. package/iconsvg/SVG/common/PhoneGrey.tsx +22 -22
  341. package/iconsvg/SVG/common/PickupDashboard.tsx +30 -30
  342. package/iconsvg/SVG/common/Picture.tsx +24 -24
  343. package/iconsvg/SVG/common/Pictures.tsx +32 -32
  344. package/iconsvg/SVG/common/Plus.tsx +16 -16
  345. package/iconsvg/SVG/common/PlusNew.tsx +16 -16
  346. package/iconsvg/SVG/common/PreparationDashboard.tsx +29 -29
  347. package/iconsvg/SVG/common/ProfileDashboard.tsx +24 -24
  348. package/iconsvg/SVG/common/ProfileIcon.tsx +17 -17
  349. package/iconsvg/SVG/common/Question.tsx +19 -19
  350. package/iconsvg/SVG/common/QuestionBold.tsx +25 -25
  351. package/iconsvg/SVG/common/RegistrationDashboard.tsx +31 -31
  352. package/iconsvg/SVG/common/SavedSearchsIcon.tsx +20 -20
  353. package/iconsvg/SVG/common/Search.tsx +19 -19
  354. package/iconsvg/SVG/common/SearchGrey.tsx +22 -22
  355. package/iconsvg/SVG/common/SettingPenIcon.tsx +20 -20
  356. package/iconsvg/SVG/common/Share.tsx +16 -16
  357. package/iconsvg/SVG/common/ShoppingCart.tsx +18 -18
  358. package/iconsvg/SVG/common/SliderArrow.tsx +21 -21
  359. package/iconsvg/SVG/common/SliderEye.tsx +17 -17
  360. package/iconsvg/SVG/common/SortUpDownArrows.tsx +18 -18
  361. package/iconsvg/SVG/common/Speedometer.tsx +23 -23
  362. package/iconsvg/SVG/common/Star.tsx +20 -20
  363. package/iconsvg/SVG/common/StarNew.tsx +16 -16
  364. package/iconsvg/SVG/common/StarOutline.tsx +20 -20
  365. package/iconsvg/SVG/common/StarRoundedCorners.tsx +18 -18
  366. package/iconsvg/SVG/common/Stats.tsx +17 -17
  367. package/iconsvg/SVG/common/Step1.tsx +21 -21
  368. package/iconsvg/SVG/common/Step2.tsx +20 -20
  369. package/iconsvg/SVG/common/Step3.tsx +21 -21
  370. package/iconsvg/SVG/common/SupportCallbackIcon.tsx +20 -20
  371. package/iconsvg/SVG/common/Telephone.tsx +22 -22
  372. package/iconsvg/SVG/common/Time.tsx +17 -17
  373. package/iconsvg/SVG/common/Timetable.tsx +18 -18
  374. package/iconsvg/SVG/common/Trash.tsx +22 -22
  375. package/iconsvg/SVG/common/TrashRed.tsx +23 -23
  376. package/iconsvg/SVG/common/Upload.tsx +17 -17
  377. package/iconsvg/SVG/common/UserGrey.tsx +22 -22
  378. package/iconsvg/SVG/common/UserProfileIcon.tsx +20 -20
  379. package/iconsvg/SVG/common/ValidateFailure.tsx +21 -21
  380. package/iconsvg/SVG/common/ValidateSuccess.tsx +21 -21
  381. package/iconsvg/SVG/common/WarningTriangle.tsx +20 -20
  382. package/iconsvg/SVG/common/WhiteArrowUp.tsx +21 -21
  383. package/iconsvg/SVG/common/WhiteKey.tsx +21 -21
  384. package/iconsvg/SVG/common/WhitePeople.tsx +23 -23
  385. package/iconsvg/SVG/common/WhiteTick.tsx +19 -19
  386. package/iconsvg/SVG/flags/AT.tsx +19 -19
  387. package/iconsvg/SVG/flags/BE.tsx +20 -20
  388. package/iconsvg/SVG/flags/BG.tsx +20 -20
  389. package/iconsvg/SVG/flags/CH.tsx +22 -22
  390. package/iconsvg/SVG/flags/CZ.tsx +18 -18
  391. package/iconsvg/SVG/flags/DE.tsx +18 -18
  392. package/iconsvg/SVG/flags/DK.tsx +18 -18
  393. package/iconsvg/SVG/flags/ES.tsx +2585 -2585
  394. package/iconsvg/SVG/flags/FI.tsx +18 -18
  395. package/iconsvg/SVG/flags/FR.tsx +20 -20
  396. package/iconsvg/SVG/flags/GB.tsx +26 -26
  397. package/iconsvg/SVG/flags/HR.tsx +135 -135
  398. package/iconsvg/SVG/flags/HU.tsx +20 -20
  399. package/iconsvg/SVG/flags/IT.tsx +20 -20
  400. package/iconsvg/SVG/flags/LI.tsx +106 -106
  401. package/iconsvg/SVG/flags/LU.tsx +18 -18
  402. package/iconsvg/SVG/flags/NL.tsx +18 -18
  403. package/iconsvg/SVG/flags/NO.tsx +20 -20
  404. package/iconsvg/SVG/flags/PL.tsx +19 -19
  405. package/iconsvg/SVG/flags/RO.tsx +20 -20
  406. package/iconsvg/SVG/flags/RU.tsx +20 -20
  407. package/iconsvg/SVG/flags/SE.tsx +20 -20
  408. package/iconsvg/SVG/flags/SI.tsx +54 -54
  409. package/iconsvg/SVG/flags/SK.tsx +34 -34
  410. package/iconsvg/SVG/flags/UA.tsx +19 -19
  411. package/iconsvg/SVG/infoBlocks/14DaysReturnPolicy.tsx +18 -18
  412. package/iconsvg/SVG/infoBlocks/AlertInfo.tsx +19 -19
  413. package/iconsvg/SVG/infoBlocks/Autocheck.tsx +19 -19
  414. package/iconsvg/SVG/infoBlocks/CheckTheStory.tsx +22 -22
  415. package/iconsvg/SVG/infoBlocks/CustomerServiceWhite.tsx +16 -16
  416. package/iconsvg/SVG/infoBlocks/Guarantee.tsx +16 -16
  417. package/iconsvg/SVG/infoBlocks/OrderWhite.tsx +13 -13
  418. package/iconsvg/SVG/infoBlocks/PriceGuarantee.tsx +19 -19
  419. package/iconsvg/SVG/infoBlocks/PriceGuaranteeWhite.tsx +17 -17
  420. package/iconsvg/SVG/infoBlocks/TransportAndDelivery.tsx +21 -21
  421. package/iconsvg/SVG/infoBlocks/Warranty.tsx +23 -23
  422. package/iconsvg/SVG/popower/Mail.tsx +18 -18
  423. package/iconsvg/SVG/popower/Phone.tsx +19 -19
  424. package/iconsvg/SVG/popower/Schedule.tsx +18 -18
  425. package/iconsvg/SVG/searchWidgets/drive/AAllWheelDrive.tsx +26 -26
  426. package/iconsvg/SVG/searchWidgets/drive/AllWheelDrive.tsx +26 -26
  427. package/iconsvg/SVG/searchWidgets/drive/FrontWheelDrive.tsx +25 -25
  428. package/iconsvg/SVG/searchWidgets/drive/OtherWheelDrive.tsx +26 -26
  429. package/iconsvg/SVG/searchWidgets/drive/RearWheelDrive.tsx +25 -25
  430. package/iconsvg/SVG/searchWidgets/fuel/FuelDiesel.tsx +18 -18
  431. package/iconsvg/SVG/searchWidgets/fuel/FuelElectrical.tsx +19 -19
  432. package/iconsvg/SVG/searchWidgets/fuel/FuelElectricalDiesel.tsx +34 -34
  433. package/iconsvg/SVG/searchWidgets/fuel/FuelElectricalPetrol.tsx +20 -20
  434. package/iconsvg/SVG/searchWidgets/fuel/FuelEthanol.tsx +53 -53
  435. package/iconsvg/SVG/searchWidgets/fuel/FuelHydrogen.tsx +24 -24
  436. package/iconsvg/SVG/searchWidgets/fuel/FuelLpg.tsx +24 -24
  437. package/iconsvg/SVG/searchWidgets/fuel/FuelNaturalGas.tsx +23 -23
  438. package/iconsvg/SVG/searchWidgets/fuel/FuelOther.tsx +28 -28
  439. package/iconsvg/SVG/searchWidgets/fuel/FuelPetrol.tsx +18 -18
  440. package/iconsvg/SVG/settings/Account.tsx +17 -17
  441. package/iconsvg/SVG/settings/DataUsage.tsx +17 -17
  442. package/iconsvg/SVG/settings/MarketResearch.tsx +17 -17
  443. package/iconsvg/SVG/settings/Newsletter.tsx +17 -17
  444. package/iconsvg/SVG/settings/RemoveEye.tsx +17 -17
  445. package/iconsvg/SVG/settings/RemoveEyeOff.tsx +17 -17
  446. package/iconsvg/SVG/settings/Security.tsx +17 -17
  447. package/iconsvg/SVG/settings/Warning.tsx +17 -17
  448. package/iconsvg/SVG/slider/360.tsx +23 -23
  449. package/iconsvg/SVG/slider/360New.tsx +20 -20
  450. package/iconsvg/SVG/slider/Checked.tsx +19 -19
  451. package/iconsvg/SVG/slider/DiscountRibbon.tsx +51 -51
  452. package/iconsvg/SVG/slider/DiscountRibbonMobile.tsx +47 -47
  453. package/iconsvg/SVG/slider/DiscountRibbonNew.tsx +29 -29
  454. package/iconsvg/SVG/slider/Easy.tsx +19 -19
  455. package/iconsvg/SVG/slider/Fullscreen.tsx +16 -16
  456. package/iconsvg/SVG/slider/FullscreenWindow.tsx +23 -23
  457. package/iconsvg/SVG/slider/Safe.tsx +19 -19
  458. package/iconsvg/SVG/slider/ToBuy.tsx +19 -19
  459. package/iconsvg/SVG/slider/YoutubeButton.tsx +26 -26
  460. package/iconsvg/SVG/slider/ZoomIn.tsx +21 -21
  461. package/iconsvg/SVG/slider/ZoomOut.tsx +21 -21
  462. package/iconsvg/SVG/social/AndroidLogo.tsx +18 -18
  463. package/iconsvg/SVG/social/Apple.tsx +19 -19
  464. package/iconsvg/SVG/social/FacebookF.tsx +19 -19
  465. package/iconsvg/SVG/social/GooglePlusG.tsx +19 -19
  466. package/iconsvg/SVG/social/Instagram.tsx +17 -17
  467. package/iconsvg/SVG/social/Pinterest.tsx +19 -19
  468. package/iconsvg/SVG/social/SharingInterface.tsx +21 -21
  469. package/iconsvg/SVG/social/Twitter.tsx +19 -19
  470. package/iconsvg/SVG/social/Youtube.tsx +19 -19
  471. package/iconsvg/SVG/vehicle/AboIcon.tsx +11 -11
  472. package/iconsvg/SVG/vehicle/Abs.tsx +24 -24
  473. package/iconsvg/SVG/vehicle/AirCond.tsx +34 -34
  474. package/iconsvg/SVG/vehicle/Airbag.tsx +21 -21
  475. package/iconsvg/SVG/vehicle/AuxiliaryHeating.tsx +23 -23
  476. package/iconsvg/SVG/vehicle/Bluetooth.tsx +22 -22
  477. package/iconsvg/SVG/vehicle/BuyIcon.tsx +15 -15
  478. package/iconsvg/SVG/vehicle/CarAvailability.tsx +22 -22
  479. package/iconsvg/SVG/vehicle/CarInspectionCheck.tsx +21 -21
  480. package/iconsvg/SVG/vehicle/CarInspectionCross.tsx +21 -21
  481. package/iconsvg/SVG/vehicle/CheckoutChecked.tsx +15 -15
  482. package/iconsvg/SVG/vehicle/Co2.tsx +24 -24
  483. package/iconsvg/SVG/vehicle/CruiseControl.tsx +25 -25
  484. package/iconsvg/SVG/vehicle/Date.tsx +21 -21
  485. package/iconsvg/SVG/vehicle/Delivery.tsx +12 -12
  486. package/iconsvg/SVG/vehicle/Diesel.tsx +28 -28
  487. package/iconsvg/SVG/vehicle/Display.tsx +25 -25
  488. package/iconsvg/SVG/vehicle/Done.tsx +11 -11
  489. package/iconsvg/SVG/vehicle/Door.tsx +19 -19
  490. package/iconsvg/SVG/vehicle/DoubleDone.tsx +18 -18
  491. package/iconsvg/SVG/vehicle/EditIcon.tsx +11 -11
  492. package/iconsvg/SVG/vehicle/EmergencyBrakeAssistant.tsx +27 -27
  493. package/iconsvg/SVG/vehicle/Eps.tsx +24 -24
  494. package/iconsvg/SVG/vehicle/Esp.tsx +24 -24
  495. package/iconsvg/SVG/vehicle/FinancingIcon.tsx +20 -20
  496. package/iconsvg/SVG/vehicle/FuelConsumption.tsx +20 -20
  497. package/iconsvg/SVG/vehicle/Fueling.tsx +22 -22
  498. package/iconsvg/SVG/vehicle/FullDrive.tsx +24 -24
  499. package/iconsvg/SVG/vehicle/GasElectric.tsx +28 -28
  500. package/iconsvg/SVG/vehicle/HandsFreePhoneSystem.tsx +24 -24
  501. package/iconsvg/SVG/vehicle/Kw.tsx +23 -23
  502. package/iconsvg/SVG/vehicle/LastCrawledAt.tsx +22 -22
  503. package/iconsvg/SVG/vehicle/LeasingIcon.tsx +14 -14
  504. package/iconsvg/SVG/vehicle/Led.tsx +24 -24
  505. package/iconsvg/SVG/vehicle/LightSensor.tsx +22 -22
  506. package/iconsvg/SVG/vehicle/LinkAlternative.tsx +15 -15
  507. package/iconsvg/SVG/vehicle/Location.tsx +17 -17
  508. package/iconsvg/SVG/vehicle/MinusIcon.tsx +11 -11
  509. package/iconsvg/SVG/vehicle/Mirror.tsx +19 -19
  510. package/iconsvg/SVG/vehicle/Navigation.tsx +27 -27
  511. package/iconsvg/SVG/vehicle/NewCar.tsx +21 -21
  512. package/iconsvg/SVG/vehicle/NoImage.tsx +17 -17
  513. package/iconsvg/SVG/vehicle/Owner.tsx +18 -18
  514. package/iconsvg/SVG/vehicle/PanoramicRoof.tsx +23 -23
  515. package/iconsvg/SVG/vehicle/ParkingAssistants.tsx +24 -24
  516. package/iconsvg/SVG/vehicle/PickYourself.tsx +18 -18
  517. package/iconsvg/SVG/vehicle/PlusIcon.tsx +11 -11
  518. package/iconsvg/SVG/vehicle/Power.tsx +21 -21
  519. package/iconsvg/SVG/vehicle/Ps.tsx +19 -19
  520. package/iconsvg/SVG/vehicle/Question.tsx +24 -24
  521. package/iconsvg/SVG/vehicle/Seats.tsx +21 -21
  522. package/iconsvg/SVG/vehicle/SeatsHeating.tsx +26 -26
  523. package/iconsvg/SVG/vehicle/SelectorGearboxAutomatic.tsx +24 -24
  524. package/iconsvg/SVG/vehicle/SelectorGearboxManualShift.tsx +19 -19
  525. package/iconsvg/SVG/vehicle/SelectorGearboxOther.tsx +24 -24
  526. package/iconsvg/SVG/vehicle/SelectorGearboxSemiAutomatic.tsx +30 -30
  527. package/iconsvg/SVG/vehicle/SelectorInteriorTypeAlcantara.tsx +225 -225
  528. package/iconsvg/SVG/vehicle/SelectorInteriorTypeFabric.tsx +29 -29
  529. package/iconsvg/SVG/vehicle/SelectorInteriorTypeFullLeather.tsx +29 -29
  530. package/iconsvg/SVG/vehicle/SelectorInteriorTypeOther.tsx +26 -26
  531. package/iconsvg/SVG/vehicle/SelectorInteriorTypePartLeather.tsx +142 -142
  532. package/iconsvg/SVG/vehicle/SelectorInteriorTypeSyntheticLeather.tsx +27 -27
  533. package/iconsvg/SVG/vehicle/SelectorInteriorTypeVelours.tsx +39 -39
  534. package/iconsvg/SVG/vehicle/Shield.tsx +39 -39
  535. package/iconsvg/SVG/vehicle/Speedometer.tsx +17 -17
  536. package/iconsvg/SVG/vehicle/StartStopSystem.tsx +30 -30
  537. package/iconsvg/SVG/vehicle/Stop.tsx +25 -25
  538. package/iconsvg/SVG/vehicle/TickMark.tsx +21 -21
  539. package/iconsvg/SVG/vehicle/TrafficSignRecognition.tsx +29 -29
  540. package/iconsvg/SVG/vehicle/TrailerCoupling.tsx +24 -24
  541. package/iconsvg/SVG/vehicle/Transmission.tsx +27 -27
  542. package/iconsvg/SVG/vehicle/Wheel.tsx +43 -43
  543. package/iconsvg/SVG/vehicle/Wipers.tsx +22 -22
  544. package/iconsvg/SVG/vehicle/Wrench.tsx +17 -17
  545. package/iconsvg/index.ts +7 -7
  546. package/index.ts +18 -18
  547. package/locales/de/autobahn.json +676 -671
  548. package/locales/en/autobahn.json +676 -671
  549. package/locales/legacy/SearchDetailedPage.ts +205 -205
  550. package/locales/legacy/cbd.ts +719 -719
  551. package/locales/legacy/common.ts +109 -109
  552. package/locales/legacy/data.ts +1118 -1118
  553. package/locales/legacy/priceRating.ts +10 -10
  554. package/locales/legacy/sortingOptions.ts +21 -21
  555. package/media/images/raiting-stars-min.svg +7 -7
  556. package/media/images/refresh-button.svg +49 -49
  557. package/media/images/user-icon.svg +16 -16
  558. package/media/svg/infoBlocks/whyAutodeIconSet-F.svg +9 -9
  559. package/media/svg/infoBlocks/whyAutodeIconSet-G.svg +8 -8
  560. package/media/svg/infoBlocks/whyAutodeIconSet-Q.svg +6 -6
  561. package/media/svg/infoBlocks/whyAutodeIconSet-R.svg +9 -9
  562. package/media/svg/usp/icon1.svg +16 -16
  563. package/media/svg/usp/icon2.svg +13 -13
  564. package/media/svg/usp/icon3.svg +10 -10
  565. package/media/svg/usp/icon4.svg +14 -14
  566. package/package.json +30 -30
  567. package/schemes/AggregationScheme.ts +73 -73
  568. package/searchFilters/filterOptions.ts +119 -119
  569. package/searchFilters/filterProps.ts +35 -35
  570. package/searchFilters/index.ts +15 -15
  571. package/theme/autobahn.css +25 -25
  572. package/theme/colors_and_type.css +113 -113
  573. package/theme/md3.css +2045 -2034
  574. package/types/types.ts +373 -373
  575. package/types.ts +8 -8
  576. package/ui/Accordion.tsx +134 -134
  577. package/ui/Button.tsx +62 -62
  578. package/ui/CardCarousel.tsx +133 -133
  579. package/ui/Chip.tsx +29 -29
  580. package/ui/DatePicker.tsx +145 -145
  581. package/ui/Dropdown.tsx +205 -205
  582. package/ui/FileUpload.tsx +154 -154
  583. package/ui/FormCheckbox.tsx +79 -79
  584. package/ui/FormDropdown.tsx +327 -327
  585. package/ui/FormField.tsx +88 -88
  586. package/ui/FormRadio.tsx +93 -93
  587. package/ui/FormRadioGroup.tsx +106 -106
  588. package/ui/Icon.tsx +391 -391
  589. package/ui/IconButton.tsx +44 -44
  590. package/ui/Modal.tsx +187 -187
  591. package/ui/Pagination.tsx +140 -140
  592. package/ui/Price.tsx +25 -25
  593. package/ui/PriceEvalBar.tsx +48 -48
  594. package/ui/SaveChip.tsx +34 -34
  595. package/ui/Segmented.tsx +48 -48
  596. package/ui/Skeleton.tsx +75 -75
  597. package/ui/Spec.tsx +24 -24
  598. package/ui/Stars.tsx +29 -29
  599. package/ui/StatChip.tsx +21 -21
  600. package/ui/Stepper.tsx +106 -106
  601. package/ui/Switch.tsx +65 -65
  602. package/ui/Table.tsx +156 -156
  603. package/ui/Textarea.tsx +58 -58
  604. package/ui/index.ts +88 -88
  605. package/utils/CommonUtils.ts +648 -648
  606. package/utils/DateUtils.ts +101 -101
  607. package/utils/index.ts +74 -74
  608. package/utils/power.ts +11 -11
  609. package/utils/query.ts +21 -21
  610. package/vdp/FinanceCalculator.tsx +465 -465
  611. package/vdp/VdpModules.tsx +1490 -1490
  612. package/vdp/VdpViewer.tsx +209 -209
  613. package/vdp/index.ts +75 -75
  614. package/vdp/types.ts +197 -197
  615. package/vehiclesProps/cbdSelectors.ts +117 -117
  616. package/vehiclesProps/decoratedLightProps.tsx +572 -572
  617. package/vehiclesProps/decoratedProps.tsx +1746 -1746
  618. package/vehiclesProps/equipment.ts +129 -129
  619. package/vehiclesProps/vehicleDetails.ts +428 -428
  620. package/widgets/SearchBox.tsx +256 -256
  621. package/widgets/index.ts +5 -5
@@ -1,1490 +1,1490 @@
1
- // Autobahn (auto.de redesign) — Vehicle-Detail (VDP) presentational modules.
2
- //
3
- // Typed React 18 + TS port of the remaining VDP pieces from the design kit
4
- // (docs/redesign/design-source/ui_kits/website/CdpModules.jsx + ScreenVDP.jsx).
5
- // Each is a pure / presentational ES named export with an explicit props
6
- // interface; none touches a store, the API or `window`. The page wiring that
7
- // feeds these from the real stores is a SEPARATE later step (W3b).
8
- //
9
- // Replaces the design kit's globals:
10
- // - `window.CO2_CLASSES` / `window.CO2_COLORS` → ../constants
11
- // - `equipFor(id)` / `car.priceHistory` / market band → typed props on VehicleDetail
12
- // - `assetUrl(...)` → pre-resolved image URLs (caller's job)
13
- // - `data-lucide` icons → the ui kit's <Icon />
14
- // The DealerBlock from CdpModules is intentionally NOT ported (auto.de is the
15
- // seller, not a dealer marketplace).
16
- //
17
- // NEEDS-DATA discipline (the point of the ticket): wherever the design showed a
18
- // figure we have no real source for, these render the NEEDS_DATA_LABEL
19
- // ('Daten folgt') marker rather than inventing a value — see PriceHistory (whole
20
- // card) and its market band.
21
- //
22
- // All components render correctly only inside a `<div className="md3">` subtree.
23
-
24
- import React, { useState } from 'react';
25
- import { TranslationFunction } from '../types';
26
-
27
- import { Button, Icon, Spec, StatChip, Stars } from '../ui';
28
- import { CO2_CLASSES, CO2_COLORS } from '../constants';
29
- import { defaultVdpSpecs, EquipmentGroups, VehicleDetail, VdpSpec, VdpReview } from './types';
30
- import { Slider, TermChips } from './FinanceCalculator';
31
-
32
- const eur = (n: number): string => n.toLocaleString('de-DE');
33
-
34
- /** mileage → 'X.XXX km' (de-DE grouping). */
35
- function fmtKm(n: number): string {
36
- return `${n.toLocaleString('de-DE')} km`;
37
- }
38
-
39
- /** strip a trailing parenthetical, e.g. '110 kW (150 PS)' → '110 kW'. */
40
- function stripParen(s: string): string {
41
- return s.replace(/\s*\(.*\)/, '');
42
- }
43
-
44
- // ---------------------------------------------------------------------------
45
- // Header card — title, fact line, highlight chips, condition tiles
46
- // ---------------------------------------------------------------------------
47
-
48
- export interface VdpHeaderCardProps {
49
- vehicle: VehicleDetail;
50
- /** i18n function (drilled from the page). */
51
- t: TranslationFunction;
52
- }
53
-
54
- /**
55
- * One container holding the entire vehicle identity: headline + trim/power, the
56
- * dot-separated fact line, the hand-picked highlight chips, and the condition
57
- * (zustand) tiles footer. The gallery itself is composed by the page (W3b) — this
58
- * is just the textual header card that sits beneath it.
59
- */
60
- export const VdpHeaderCard: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
61
- const { condition } = vehicle;
62
- const factLine: ReadonlyArray<readonly [string, string]> = [
63
- ['gauge', fmtKm(vehicle.km)],
64
- ['calendar', vehicle.reg],
65
- ['cog', vehicle.gear],
66
- ['fuel', stripParen(vehicle.fuel)],
67
- ['user', t('autobahn:header.owners', { owners: vehicle.owners, count: vehicle.owners })],
68
- ];
69
- // condition.hu === 'Neu' compares the RAW backend data value, not a display string — left as-is.
70
- const tiles: ReadonlyArray<readonly [string, string, string]> = [
71
- ['wrench', t('autobahn:header.huLabel'), condition.hu === 'Neu' ? t('autobahn:header.huNew') : t('autobahn:header.huUntil', { date: condition.hu })],
72
- ['shield-check', t('autobahn:header.accidentFree'), condition.unfallfrei ? t('autobahn:header.yes') : t('autobahn:header.no')],
73
- ['clipboard-check', t('autobahn:header.serviceHistory'), condition.scheckheft ? t('autobahn:header.yes') : t('autobahn:header.no')],
74
- ['award', t('autobahn:header.warranty'), condition.garantie],
75
- ];
76
- return (
77
- <div className="mcard" style={{ marginTop: 16, padding: '22px 24px 18px' }}>
78
- <div className="vdp-headrow">
79
- <div style={{ minWidth: 0 }}>
80
- <h1 className="md-headline" style={{ margin: '0 0 4px' }}>
81
- {vehicle.make} {vehicle.model}
82
- </h1>
83
- <p className="md-body" style={{ margin: 0, fontSize: 15 }}>
84
- {vehicle.trim} · {vehicle.power}
85
- </p>
86
- {/* auto.de-ID lives in the offer card (next to Finanzierungsdetails), not here */}
87
- </div>
88
- {/* BL-08: no chip at all when there's no real rating (empty/broken price) — never a
89
- fabricated "Top Preis"-shaped badge for `rating === ''`. */}
90
- {vehicle.rating && (
91
- <StatChip
92
- tone={vehicle.rating === 'fair' ? 'warn' : 'good'}
93
- icon={vehicle.rating === 'fair' ? 'minus' : 'check'}
94
- >
95
- {t('autobahn:tier.' + vehicle.rating)}
96
- </StatChip>
97
- )}
98
- </div>
99
-
100
- {/* fact line — plain text with dot separators, not pills */}
101
- <div className="vdp-meta">
102
- {factLine.map(([ic, v]) => (
103
- <span key={ic}>
104
- <Icon name={ic} size={16} color="var(--md-on-surface-variant)" stroke={1.8} />
105
- {v}
106
- </span>
107
- ))}
108
- </div>
109
-
110
- {/* hand-picked highlights — the single accent row of the header */}
111
- <div
112
- className="vdp-highlights"
113
- style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginTop: 14 }}
114
- >
115
- {vehicle.highlights.slice(0, 6).map((f) => (
116
- <span key={f} className="chip chip--filter chip--selected" style={{ cursor: 'default' }}>
117
- <Icon name="check" size={14} stroke={2.4} />
118
- {f}
119
- </span>
120
- ))}
121
- </div>
122
-
123
- {/* condition — footer row of the header card, divided by a hairline */}
124
- <div className="zustand-grid">
125
- {tiles.map(([ic, l, v]) => (
126
- <div key={l} className="zustand-tile">
127
- <Icon name={ic} size={19} color="var(--md-primary)" stroke={1.9} style={{ flex: 'none' }} />
128
- <span style={{ minWidth: 0 }}>
129
- <span
130
- className="md-label-sm"
131
- style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
132
- >
133
- {l}
134
- </span>
135
- <span className="md-title-sm" style={{ display: 'block' }}>
136
- {v}
137
- </span>
138
- </span>
139
- </div>
140
- ))}
141
- </div>
142
- </div>
143
- );
144
- };
145
-
146
- // ---------------------------------------------------------------------------
147
- // Fahrzeugdaten spec table (mobile clamp + "Mehr anzeigen")
148
- // ---------------------------------------------------------------------------
149
-
150
- export interface VdpSpecTableProps {
151
- vehicle: VehicleDetail;
152
- /** i18n function (drilled from the page). */
153
- t: TranslationFunction;
154
- }
155
-
156
- /**
157
- * Fahrzeugdaten grid of Spec items. Uses the vehicle's explicit `specs` when
158
- * present, else derives the design's twelve-row layout via `defaultVdpSpecs`. On
159
- * phones the table clamps to a few rows (`vdp-specs--clamp`) with a "Mehr
160
- * anzeigen" toggle (pure view state).
161
- */
162
- export const VdpSpecTable: React.FunctionComponent<VdpSpecTableProps> = ({ vehicle, t }) => {
163
- const [allSpecs, setAllSpecs] = useState(false);
164
- const specs: VdpSpec[] = vehicle.specs && vehicle.specs.length ? vehicle.specs : defaultVdpSpecs(vehicle);
165
- return (
166
- <div className="mcard" id="vdp-daten" style={{ padding: 22 }}>
167
- <h2 className="md-headline-sm" style={{ marginBottom: 16 }}>
168
- {t('autobahn:specTable.title')}
169
- </h2>
170
- <div className={'vdp-specs' + (allSpecs ? '' : ' vdp-specs--clamp')}>
171
- {specs.map(([ic, l, v]) => (
172
- <Spec key={l} icon={ic} label={l}>
173
- {v}
174
- </Spec>
175
- ))}
176
- </div>
177
- {/* phone only: clamp button only meaningful when there are more than 6 specs */}
178
- {specs.length > 6 && (
179
- <button
180
- className="mbtn mbtn--text show-mobile"
181
- style={{ marginTop: 12, padding: 0 }}
182
- onClick={() => setAllSpecs((a) => !a)}
183
- >
184
- {allSpecs ? t('autobahn:vdp.specs.showLess') : t('autobahn:specTable.showAll', { count: specs.length })}
185
- <Icon name={allSpecs ? 'chevron-up' : 'chevron-down'} size={16} />
186
- </button>
187
- )}
188
- </div>
189
- );
190
- };
191
-
192
- // ---------------------------------------------------------------------------
193
- // Equipment list (grouped + searchable)
194
- // ---------------------------------------------------------------------------
195
-
196
- export interface EquipmentListProps {
197
- /** grouped equipment, e.g. { Komfort: [...], Sicherheit: [...] }. */
198
- groups: EquipmentGroups;
199
- /** i18n function (drilled from the page). */
200
- t: TranslationFunction;
201
- }
202
-
203
- /** Grouped + searchable equipment list (Autohero / AutoScout pattern). */
204
- export const EquipmentList: React.FunctionComponent<EquipmentListProps> = ({ groups, t }) => {
205
- const [q, setQ] = useState('');
206
- const [openAll, setOpenAll] = useState(false);
207
- const ql = q.trim().toLowerCase();
208
- const entries = Object.entries(groups)
209
- .map(([g, items]): [string, string[]] => [g, items.filter((it) => !ql || it.toLowerCase().includes(ql))])
210
- .filter(([, items]) => items.length);
211
- const total = Object.values(groups).reduce((s, a) => s + a.length, 0);
212
- return (
213
- <div className="mcard" id="vdp-ausstattung" style={{ padding: 22 }}>
214
- <div
215
- style={{
216
- display: 'flex',
217
- alignItems: 'center',
218
- justifyContent: 'space-between',
219
- gap: 12,
220
- marginBottom: 14,
221
- flexWrap: 'wrap',
222
- }}
223
- >
224
- <h2 className="md-headline-sm">
225
- {t('autobahn:equipment.title')}{' '}
226
- <span style={{ color: 'var(--md-on-surface-variant)', fontWeight: 600 }}>({total})</span>
227
- </h2>
228
- <label className="mfield" style={{ height: 40, maxWidth: 240, flex: 1, minWidth: 170 }}>
229
- <Icon name="search" size={18} />
230
- <input placeholder={t('autobahn:equipment.search.placeholder')} value={q} onChange={(e) => setQ(e.target.value)} />
231
- {q && (
232
- <button className="icon-btn" style={{ width: 28, height: 28 }} onClick={() => setQ('')}>
233
- <Icon name="x" size={15} />
234
- </button>
235
- )}
236
- </label>
237
- </div>
238
- <div className="eq-cols">
239
- {entries.map(([g, items]) => (
240
- <div key={g} style={{ breakInside: 'avoid', marginBottom: 18 }}>
241
- <div className="md-title-sm" style={{ color: 'var(--md-primary)', marginBottom: 8 }}>
242
- {g}
243
- </div>
244
- <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
245
- {(openAll || ql ? items : items.slice(0, 4)).map((it) => (
246
- <li
247
- key={it}
248
- style={{
249
- display: 'flex',
250
- gap: 9,
251
- alignItems: 'flex-start',
252
- fontSize: 13.5,
253
- color: 'var(--md-on-surface-variant)',
254
- }}
255
- >
256
- <Icon name="check" size={16} color="var(--md-good)" stroke={2.4} style={{ flex: 'none', marginTop: 1 }} />
257
- {it}
258
- </li>
259
- ))}
260
- </ul>
261
- </div>
262
- ))}
263
- </div>
264
- {!ql && !openAll && (
265
- <button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setOpenAll(true)}>
266
- {t('autobahn:equipment.showAll', { total })}
267
- <Icon name="chevron-down" size={16} />
268
- </button>
269
- )}
270
- {entries.length === 0 && <p className="md-body-sm">{t('autobahn:equipment.noResults', { q })}</p>}
271
- </div>
272
- );
273
- };
274
-
275
- // ---------------------------------------------------------------------------
276
- // CO₂ / energy-efficiency label
277
- // ---------------------------------------------------------------------------
278
-
279
- export interface Co2LabelProps {
280
- vehicle: VehicleDetail;
281
- /** i18n function (drilled from the page). */
282
- t: TranslationFunction;
283
- }
284
-
285
- /** CO₂ / energy-efficiency label (EU-style stepped colour band). Handles PHEV. */
286
- export const Co2Label: React.FunctionComponent<Co2LabelProps> = ({ vehicle, t }) => {
287
- const p = vehicle.phev;
288
- const idx = Math.max(0, CO2_CLASSES.indexOf((p ? p.co2classW : vehicle.co2class) as (typeof CO2_CLASSES)[number]));
289
- const stats: ReadonlyArray<readonly [string, string]> = p
290
- ? [
291
- [t('autobahn:eco.stat.powerConsumptionWeighted'), p.consEl],
292
- [t('autobahn:eco.stat.fuelConsumptionWeighted'), p.consFuel],
293
- [t('autobahn:eco.stat.fuelConsumptionDischarged'), p.consDis],
294
- [t('autobahn:eco.stat.co2WeightedCombined'), p.co2w],
295
- ]
296
- : [
297
- [t('autobahn:eco.stat.consumptionCombined'), vehicle.cons],
298
- [t('autobahn:eco.stat.co2Emissions'), vehicle.co2],
299
- [t('autobahn:eco.stat.co2Class'), vehicle.co2class],
300
- ];
301
- return (
302
- <div className="mcard" style={{ padding: 22 }}>
303
- <h2 className="md-headline-sm" style={{ marginBottom: 14 }}>
304
- {t('autobahn:eco.heading')}
305
- </h2>
306
- <div className="co2-stats">
307
- {stats.map(([l, v]) => (
308
- <div key={l}>
309
- <div className="md-label-sm">{l}</div>
310
- <div className="md-title-sm" style={{ fontSize: 16 }}>
311
- {v}
312
- </div>
313
- </div>
314
- ))}
315
- </div>
316
- <div className="co2-scale">
317
- {CO2_CLASSES.map((c, i) => (
318
- <div key={c} className={'co2-seg' + (i === idx ? ' co2-seg--on' : '')} style={{ background: CO2_COLORS[i] }}>
319
- {c}
320
- </div>
321
- ))}
322
- </div>
323
- {p ? (
324
- <p className="md-label-sm" style={{ margin: '10px 0 0' }}>
325
- {t('autobahn:eco.co2ClassPrefix')} <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classW}</b>{' '}
326
- {t('autobahn:eco.weightedCombined')} · <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classD}</b>{' '}
327
- {t('autobahn:eco.dischargedCombined')} · {t('autobahn:eco.energyClassWltpSuffix')}
328
- </p>
329
- ) : (
330
- <p className="md-label-sm" style={{ margin: '10px 0 0' }}>
331
- {t('autobahn:eco.energyClass')}
332
- </p>
333
- )}
334
- </div>
335
- );
336
- };
337
-
338
- // ---------------------------------------------------------------------------
339
- // Free-text vehicle description (Fahrzeugbeschreibung)
340
- // ---------------------------------------------------------------------------
341
-
342
- export interface VehicleDescriptionProps {
343
- vehicle: VehicleDetail;
344
- /**
345
- * Pre-composed description text. When omitted a sensible default is built from
346
- * the vehicle's typed fields (matching the design's template sentence).
347
- */
348
- text?: string;
349
- /** i18n function (drilled from the page). */
350
- t: TranslationFunction;
351
- }
352
-
353
- function defaultDescription(v: VehicleDetail, t: TranslationFunction): string {
354
- return t('autobahn:description.defaultBody', {
355
- make: v.make,
356
- model: v.model,
357
- trim: v.trim,
358
- power: v.power,
359
- gear: v.gear,
360
- owners: v.owners,
361
- reg: v.reg,
362
- km: v.km.toLocaleString('de-DE'),
363
- color: v.color,
364
- });
365
- }
366
-
367
- /** Free-text vehicle description with a "Mehr anzeigen" clamp (pure view state). */
368
- export const VehicleDescription: React.FunctionComponent<VehicleDescriptionProps> = ({ vehicle, text, t }) => {
369
- const [open, setOpen] = useState(false);
370
- const body = text ?? defaultDescription(vehicle, t);
371
- return (
372
- <div className="mcard" style={{ padding: 22 }}>
373
- <h2 className="md-headline-sm" style={{ marginBottom: 12 }}>
374
- {t('autobahn:description.heading')}
375
- </h2>
376
- <p
377
- className="md-body"
378
- style={{ margin: 0, maxHeight: open ? 'none' : 96, overflow: 'hidden', position: 'relative' }}
379
- >
380
- {body}
381
- </p>
382
- <button className="mbtn mbtn--text" style={{ marginTop: 8, padding: 0 }} onClick={() => setOpen((o) => !o)}>
383
- {open ? t('autobahn:description.showLess') : t('autobahn:description.showMore')}
384
- <Icon name={open ? 'chevron-up' : 'chevron-down'} size={16} />
385
- </button>
386
- </div>
387
- );
388
- };
389
-
390
- // ---------------------------------------------------------------------------
391
- // Trust strip
392
- // ---------------------------------------------------------------------------
393
-
394
- /** [kebab-case lucide icon, label]. */
395
- export type TrustItem = readonly [icon: string, label: string];
396
-
397
- export interface TrustStripProps {
398
- /** trust badges; defaults to auto.de's four standard promises. */
399
- items?: ReadonlyArray<TrustItem>;
400
- /** i18n function (drilled from the page). */
401
- t: TranslationFunction;
402
- }
403
-
404
- /** auto.de's four standard promises — [icon, i18n label key]. */
405
- const DEFAULT_TRUST: ReadonlyArray<TrustItem> = [
406
- ['shield-check', 'autobahn:trust.warranty'],
407
- ['undo-2', 'autobahn:trust.return'],
408
- ['truck', 'autobahn:trust.delivery'],
409
- ['badge-check', 'autobahn:trust.inspection'],
410
- ];
411
-
412
- /** Horizontal trust-signal strip. */
413
- export const TrustStrip: React.FunctionComponent<TrustStripProps> = ({ items, t }) => (
414
- <div className="trust-strip">
415
- {(items ?? DEFAULT_TRUST.map(([ic, key]) => [ic, t(key)] as TrustItem)).map(([ic, label]) => (
416
- <div key={label}>
417
- <Icon name={ic} size={17} color="var(--md-primary)" stroke={2} style={{ flex: 'none' }} />
418
- {label}
419
- </div>
420
- ))}
421
- </div>
422
- );
423
-
424
- // ---------------------------------------------------------------------------
425
- // Price history + market band (NEEDS-DATA by default)
426
- // ---------------------------------------------------------------------------
427
-
428
- export interface PriceHistoryProps {
429
- vehicle: VehicleDetail;
430
- /** i18n function (drilled from the page). */
431
- t: TranslationFunction;
432
- }
433
-
434
- /**
435
- * Price-history sparkline + market-value position bar.
436
- *
437
- * NEEDS-DATA: the sparkline needs `priceHistory` (with at least two points) and
438
- * the position bar needs the full market band (`marketLow` / `marketAvg` /
439
- * `marketHigh`). Either section that lacks real data renders the
440
- * NEEDS_DATA_LABEL ('Daten folgt') marker — we never draw a fake trend or
441
- * position. Both are needs-data by default, so until W3b supplies them this card
442
- * shows the markers.
443
- */
444
- export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehicle, t }) => {
445
- const pts = vehicle.priceHistory;
446
- const hasHistory = !!pts && pts.length >= 2;
447
- const hasMarket =
448
- vehicle.marketLow != null &&
449
- vehicle.marketAvg != null &&
450
- vehicle.marketHigh != null &&
451
- vehicle.marketHigh > vehicle.marketLow;
452
-
453
- // Nothing real to show yet — hide the whole card instead of rendering stubs.
454
- if (!hasHistory && !hasMarket) return null;
455
-
456
- // --- history sparkline (only when real points exist) ---
457
- let historyBlock: React.ReactNode;
458
- if (hasHistory) {
459
- const prices = pts!.map((p) => p.price);
460
- const lo = Math.min(...prices);
461
- const hi = Math.max(...prices);
462
- const W = 280;
463
- const H = 70;
464
- const x = (i: number) => (i / (pts!.length - 1)) * W;
465
- const y = (p: number) => H - ((p - lo) / (hi - lo || 1)) * H;
466
- const last = pts![pts!.length - 1];
467
- const line = pts!.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.price).toFixed(1)}`).join(' ');
468
- const area = `${line} L ${W} ${H} L 0 ${H} Z`;
469
- const drop = vehicle.listPrice != null ? vehicle.listPrice - vehicle.price : null;
470
- historyBlock = (
471
- <div>
472
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
473
- {drop != null && drop > 0 ? (
474
- <>
475
- <StatChip tone="good" icon="trending-down">
476
- {t('autobahn:priceHistory.dropAmount', { amount: eur(drop) })}
477
- </StatChip>
478
- <span className="md-label-sm">{t('autobahn:priceHistory.sinceListing')}</span>
479
- </>
480
- ) : (
481
- <span className="md-label-sm">{t('autobahn:priceHistory.label')}</span>
482
- )}
483
- </div>
484
- <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="78" preserveAspectRatio="none" style={{ overflow: 'visible' }}>
485
- <defs>
486
- <linearGradient id="phg" x1="0" y1="0" x2="0" y2="1">
487
- <stop offset="0%" stopColor="var(--md-primary)" stopOpacity=".22" />
488
- <stop offset="100%" stopColor="var(--md-primary)" stopOpacity="0" />
489
- </linearGradient>
490
- </defs>
491
- <path d={area} fill="url(#phg)" />
492
- <path d={line} fill="none" stroke="var(--md-primary)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
493
- <circle cx={x(pts!.length - 1)} cy={y(last.price)} r="4.5" fill="var(--md-primary)" stroke="#fff" strokeWidth="2" />
494
- </svg>
495
- <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
496
- <span className="md-label-sm">{pts![0].label}</span>
497
- <span className="md-label-sm">{last.label}</span>
498
- </div>
499
- </div>
500
- );
501
- } else {
502
- historyBlock = (
503
- <div>
504
- <div className="md-title-sm" style={{ marginBottom: 12 }}>
505
- {t('autobahn:priceHistory.title')}
506
- </div>
507
- <p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
508
- {t('autobahn:common.needsData')}
509
- </p>
510
- </div>
511
- );
512
- }
513
-
514
- // --- market-value position (only when the full band exists) ---
515
- let marketBlock: React.ReactNode;
516
- if (hasMarket) {
517
- const mpos = Math.max(
518
- 0,
519
- Math.min(1, (vehicle.price - vehicle.marketLow!) / (vehicle.marketHigh! - vehicle.marketLow!)),
520
- );
521
- const delta = vehicle.marketAvg! - vehicle.price;
522
- marketBlock = (
523
- <div>
524
- <div className="md-title-sm" style={{ marginBottom: 12 }}>
525
- {t('autobahn:marketValue.heading')}
526
- </div>
527
- <div
528
- style={{
529
- position: 'relative',
530
- height: 10,
531
- borderRadius: 'var(--md-r-full)',
532
- background: 'linear-gradient(90deg,#0F7B43,#C7B53A 55%,#D4502B)',
533
- }}
534
- >
535
- <span
536
- style={{
537
- position: 'absolute',
538
- top: '50%',
539
- left: `calc(${mpos * 100}% - 9px)`,
540
- transform: 'translateY(-50%)',
541
- width: 18,
542
- height: 18,
543
- borderRadius: '50%',
544
- background: '#fff',
545
- boxShadow: 'var(--md-e2), 0 0 0 2px var(--md-primary)',
546
- }}
547
- />
548
- </div>
549
- <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
550
- <span className="md-label-sm">{eur(vehicle.marketLow!)} €</span>
551
- <span className="md-label-sm">{eur(vehicle.marketHigh!)} €</span>
552
- </div>
553
- {delta > 0 && (
554
- <div
555
- style={{
556
- display: 'flex',
557
- alignItems: 'center',
558
- gap: 8,
559
- marginTop: 12,
560
- padding: '10px 12px',
561
- borderRadius: 'var(--md-r-sm)',
562
- background: 'var(--md-good-container)',
563
- }}
564
- >
565
- <Icon name="badge-check" size={18} color="var(--md-on-good-container)" />
566
- <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-on-good-container)' }}>
567
- {t('autobahn:marketValue.belowAverage', { rating: t('autobahn:tier.' + vehicle.rating), amount: eur(delta) })}
568
- </span>
569
- </div>
570
- )}
571
- </div>
572
- );
573
- } else {
574
- marketBlock = (
575
- <div>
576
- <div className="md-title-sm" style={{ marginBottom: 12 }}>
577
- {t('autobahn:marketValue.heading')}
578
- </div>
579
- <p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
580
- {t('autobahn:common.needsData')}
581
- </p>
582
- </div>
583
- );
584
- }
585
-
586
- return (
587
- <div className="mcard" id="vdp-preis" style={{ padding: 22 }}>
588
- <h2 className="md-headline-sm" style={{ marginBottom: 4 }}>
589
- {t('autobahn:priceMarket.heading')}
590
- </h2>
591
- <p className="md-body-sm" style={{ marginBottom: 18 }}>
592
- {t('autobahn:priceMarket.description')}
593
- </p>
594
- <div className="pmv-grid">
595
- {historyBlock}
596
- {marketBlock}
597
- </div>
598
- </div>
599
- );
600
- };
601
-
602
- // ---------------------------------------------------------------------------
603
- // Protection products (CNP Santander upsell) — optional, non-functional UI
604
- // ---------------------------------------------------------------------------
605
-
606
- /** [kebab-case lucide icon, name, description, monthly price EUR]. */
607
- export type ProtectionProduct = readonly [icon: string, name: string, desc: string, price: number];
608
-
609
- export interface ProtectionProductsProps {
610
- /** the upsell products; defaults to the design's four CNP Santander packages. */
611
- items?: ReadonlyArray<ProtectionProduct>;
612
- /** i18n function (drilled from the page). */
613
- t: TranslationFunction;
614
- }
615
-
616
- /**
617
- * The design's four CNP Santander packages — [icon, product name (proper noun,
618
- * also the selection key), i18n description key, monthly example price EUR].
619
- */
620
- const DEFAULT_PROTECTION: ReadonlyArray<ProtectionProduct> = [
621
- ['shield-check', 'Safe Plus', 'autobahn:protection.items.safePlus.desc', 9],
622
- ['life-buoy', 'Drive Pro', 'autobahn:protection.items.drivePro.desc', 7],
623
- ['wrench', 'Smart Repair', 'autobahn:protection.items.smartRepair.desc', 12],
624
- ['rotate-ccw', 'Smart Return', 'autobahn:protection.items.smartReturn.desc', 10],
625
- ];
626
-
627
- /**
628
- * Partner attach products (CNP Santander) sold alongside the financing. Kept as a
629
- * presentational upsell demo: the selectable rows are pure VIEW state (no store /
630
- * order side effects, the prices are example values), and the catalogue is a prop
631
- * with sane defaults. Functional ordering is out of scope for W3a.
632
- */
633
- export const ProtectionProducts: React.FunctionComponent<ProtectionProductsProps> = ({ items, t }) => {
634
- const products: ReadonlyArray<ProtectionProduct> =
635
- items ?? DEFAULT_PROTECTION.map(([ic, name, descKey, price]) => [ic, name, t(descKey), price] as ProtectionProduct);
636
- const [sel, setSel] = useState<Record<string, boolean>>({});
637
- const chosen = products.filter(([, n]) => sel[n]);
638
- const sum = chosen.reduce((a, [, , , p]) => a + p, 0);
639
- return (
640
- <div className="mcard" style={{ padding: 20 }}>
641
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
642
- <span
643
- style={{
644
- width: 34,
645
- height: 34,
646
- borderRadius: 'var(--md-r-full)',
647
- background: 'var(--md-sc)',
648
- display: 'flex',
649
- alignItems: 'center',
650
- justifyContent: 'center',
651
- flex: 'none',
652
- }}
653
- >
654
- <Icon name="umbrella" size={18} color="var(--md-on-surface-variant)" />
655
- </span>
656
- <div>
657
- <div className="md-title-sm">{t('autobahn:protection.heading')}</div>
658
- <div className="md-label-sm">{t('autobahn:protection.subtitle')}</div>
659
- </div>
660
- </div>
661
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
662
- {products.map(([ic, name, desc, price]) => (
663
- <button
664
- key={name}
665
- onClick={() => setSel((s) => ({ ...s, [name]: !s[name] }))}
666
- style={{
667
- display: 'flex',
668
- alignItems: 'center',
669
- gap: 12,
670
- width: '100%',
671
- textAlign: 'left',
672
- cursor: 'pointer',
673
- padding: '10px 12px',
674
- borderRadius: 'var(--md-r-md)',
675
- background: sel[name] ? 'var(--md-primary-container)' : 'var(--md-sc-low)',
676
- border: '1px solid ' + (sel[name] ? 'var(--md-primary)' : 'transparent'),
677
- transition: 'background var(--dur-1), border-color var(--dur-1)',
678
- }}
679
- >
680
- <Icon
681
- name={ic}
682
- size={19}
683
- stroke={1.8}
684
- color={sel[name] ? 'var(--md-on-primary-container)' : 'var(--md-on-surface-variant)'}
685
- style={{ flex: 'none' }}
686
- />
687
- <span style={{ flex: 1, minWidth: 0 }}>
688
- <span className="md-title-sm" style={{ display: 'block', fontSize: 13.5 }}>
689
- {name}
690
- </span>
691
- <span className="md-label-sm" style={{ display: 'block', fontSize: 11.5 }}>
692
- {desc}
693
- </span>
694
- </span>
695
- <span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
696
- <span className="num md-label-sm" style={{ fontWeight: 600 }}>
697
- +{price} €/mtl.
698
- </span>
699
- <span
700
- style={{
701
- width: 20,
702
- height: 20,
703
- borderRadius: '50%',
704
- display: 'flex',
705
- alignItems: 'center',
706
- justifyContent: 'center',
707
- background: sel[name] ? 'var(--md-primary)' : 'transparent',
708
- border: sel[name] ? 0 : '1.5px solid var(--md-outline)',
709
- }}
710
- >
711
- {sel[name] && <Icon name="check" size={13} stroke={3} color="#fff" />}
712
- </span>
713
- </span>
714
- </button>
715
- ))}
716
- </div>
717
- {sum > 0 && (
718
- <div
719
- style={{
720
- display: 'flex',
721
- justifyContent: 'space-between',
722
- alignItems: 'baseline',
723
- marginTop: 12,
724
- paddingTop: 10,
725
- borderTop: '1px solid var(--md-outline-variant)',
726
- }}
727
- >
728
- <span className="md-label-sm">
729
- {t('autobahn:protection.packagesSelected', { count: chosen.length })}
730
- </span>
731
- <span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
732
- +{sum} €/mtl.
733
- </span>
734
- </div>
735
- )}
736
- <div className="md-label-sm" style={{ marginTop: 10, fontSize: 11.5 }}>
737
- {t('autobahn:protection.exampleValues', { partner: 'CNP Santander Insurance' })}
738
- </div>
739
- </div>
740
- );
741
- };
742
-
743
- // ---------------------------------------------------------------------------
744
- // In-page anchor nav (mobile)
745
- // ---------------------------------------------------------------------------
746
-
747
- /** [target element id, label]. */
748
- export type VdpAnchor = readonly [id: string, label: string];
749
-
750
- export interface VdpAnchorsProps {
751
- /** anchor chips; defaults to the four VDP sections. */
752
- items?: ReadonlyArray<VdpAnchor>;
753
- /**
754
- * Optional navigation handler. When provided it OWNS the scroll (e.g. the page
755
- * in W3b). When omitted the component falls back to scrolling the page's own
756
- * element with that id into view — intrinsic anchor-nav behaviour on the
757
- * document, not a data source.
758
- */
759
- onNavigate?: (id: string) => void;
760
- /** i18n function (drilled from the page). */
761
- t: TranslationFunction;
762
- }
763
-
764
- /** the four VDP sections — [target element id, i18n label key]. */
765
- const DEFAULT_ANCHORS: ReadonlyArray<VdpAnchor> = [
766
- ['vdp-daten', 'autobahn:anchors.data'],
767
- ['vdp-ausstattung', 'autobahn:anchors.equipment'],
768
- ['vdp-preis', 'autobahn:anchors.priceMarket'],
769
- ['vdp-check', 'autobahn:anchors.inspection'],
770
- ];
771
-
772
- /** Sticky in-page anchor nav (mobile) — quiet chips. */
773
- export const VdpAnchors: React.FunctionComponent<VdpAnchorsProps> = ({ items, onNavigate, t }) => {
774
- const anchors: ReadonlyArray<VdpAnchor> =
775
- items ?? DEFAULT_ANCHORS.map(([id, key]) => [id, t(key)] as VdpAnchor);
776
- const go = (id: string) => {
777
- if (onNavigate) {
778
- onNavigate(id);
779
- return;
780
- }
781
- if (typeof document === 'undefined') return;
782
- const el = document.getElementById(id);
783
- if (!el) return;
784
- let r = el.getBoundingClientRect();
785
- if (!r.height && el.firstElementChild) r = el.firstElementChild.getBoundingClientRect();
786
- window.scrollTo({ top: r.top + window.scrollY - 60, behavior: 'smooth' });
787
- };
788
- return (
789
- <div className="vdp-anchors show-mobile" style={{ background: 'var(--md-sc-low)' }}>
790
- {anchors.map(([id, l]) => (
791
- <button key={id} className="chip" onClick={() => go(id)} style={{ flex: 'none' }}>
792
- {l}
793
- </button>
794
- ))}
795
- </div>
796
- );
797
- };
798
-
799
- // ---------------------------------------------------------------------------
800
- // Desktop sticky summary bar
801
- // ---------------------------------------------------------------------------
802
-
803
- export interface VdpStickyBarProps {
804
- vehicle: VehicleDetail;
805
- /** thumbnail image URL (already resolved) for the docked bar. */
806
- image: string;
807
- /**
808
- * Whether the bar is docked/visible. Controlled by the page (W3b owns the
809
- * scroll position → show toggle), keeping this component pure — no window
810
- * scroll listener here.
811
- */
812
- show: boolean;
813
- /** primary CTA click. */
814
- onSecure?: () => void;
815
- /** i18n function (drilled from the page). */
816
- t: TranslationFunction;
817
- }
818
-
819
- /** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
820
- export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
821
- <div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
822
- <div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
823
- <img
824
- src={image}
825
- alt=""
826
- style={{
827
- width: 64,
828
- height: 44,
829
- objectFit: 'cover',
830
- borderRadius: 'var(--md-r-sm)',
831
- background: '#fff',
832
- border: '1px solid var(--md-outline-variant)',
833
- flex: 'none',
834
- }}
835
- />
836
- <div style={{ minWidth: 0, flex: 1 }}>
837
- <div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
838
- {vehicle.make} {vehicle.model} · {vehicle.trim}
839
- </div>
840
- <div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
841
- {fmtKm(vehicle.km)} · {vehicle.reg} · {vehicle.gear}
842
- </div>
843
- </div>
844
- <div style={{ textAlign: 'right', flex: 'none' }}>
845
- <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
846
- <span className="md-label-sm">{t('autobahn:sticky.from')}</span>
847
- <span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
848
- {vehicle.monthly} €
849
- </span>
850
- <span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
851
- </div>
852
- <div className="md-label-sm">{t('autobahn:sticky.purchasePrice', { price: eur(vehicle.price) })}</div>
853
- </div>
854
- <Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
855
- {t('autobahn:sticky.secureOffer')}
856
- </Button>
857
- </div>
858
- </div>
859
- );
860
-
861
- // ---------------------------------------------------------------------------
862
- // Mobile sticky CTA bar
863
- // ---------------------------------------------------------------------------
864
-
865
- export interface VdpBottomBarProps {
866
- vehicle: VehicleDetail;
867
- /** primary CTA click. */
868
- onSecure?: () => void;
869
- /** i18n function (drilled from the page). */
870
- t: TranslationFunction;
871
- }
872
-
873
- /** Mobile sticky bottom CTA bar — rate + Kaufpreis + "Angebot sichern". */
874
- export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({ vehicle, onSecure, t }) => (
875
- <div className="vdp-bottom-bar show-mobile">
876
- <div style={{ flex: 1, minWidth: 0 }}>
877
- <div style={{ display: 'flex', alignItems: 'baseline', gap: 5, whiteSpace: 'nowrap' }}>
878
- <span className="md-label-sm">{t('autobahn:bottom.from')}</span>
879
- <span className="num" style={{ fontWeight: 700, fontSize: 22, color: 'var(--md-primary)', letterSpacing: '-.5px' }}>
880
- {vehicle.monthly} €
881
- </span>
882
- <span className="md-label-sm">{t('autobahn:bottom.perMonth')}</span>
883
- </div>
884
- <div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
885
- {t('autobahn:bottom.purchasePrice', { price: eur(vehicle.price) })}
886
- </div>
887
- </div>
888
- <Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
889
- {t('autobahn:bottom.secureOffer')}
890
- </Button>
891
- </div>
892
- );
893
-
894
- // ---------------------------------------------------------------------------
895
- // 130-Punkte-Check block
896
- // ---------------------------------------------------------------------------
897
-
898
- export interface InspectionBlockProps {
899
- /** pass categories; e.g. ['Karosserie & Lack', 'Motor & Antrieb', …]. */
900
- categories: string[];
901
- /** i18n function (drilled from the page). */
902
- t: TranslationFunction;
903
- }
904
-
905
- /** 130-Punkte-Check block — the auto.de inspection promise, all categories pass. */
906
- export const InspectionBlock: React.FunctionComponent<InspectionBlockProps> = ({ categories, t }) => (
907
- <div className="mcard mcard--filled" id="vdp-check" style={{ padding: 22 }}>
908
- <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
909
- <Icon name="badge-check" size={24} color="var(--md-primary)" />
910
- <h2 className="md-headline-sm">{t('autobahn:inspection.passed')}</h2>
911
- </div>
912
- <div className="eq-cols">
913
- {categories.map((c) => (
914
- <div
915
- key={c}
916
- style={{
917
- display: 'flex',
918
- alignItems: 'center',
919
- gap: 9,
920
- fontSize: 13.5,
921
- color: 'var(--md-on-surface-variant)',
922
- marginBottom: 10,
923
- }}
924
- >
925
- <Icon name="check-circle" size={18} color="var(--md-good)" stroke={2} style={{ flex: 'none' }} />
926
- {c}
927
- </div>
928
- ))}
929
- </div>
930
- </div>
931
- );
932
-
933
- // ---------------------------------------------------------------------------
934
- // Finanzierungs-/Leasingdetails popup (desktop modal / mobile bottom-sheet)
935
- // ---------------------------------------------------------------------------
936
-
937
- export interface FinanceDetailsModalProps {
938
- /** open flag (controlled by the page). */
939
- open: boolean;
940
- /** active payment mode — drives labels + which legal example shows. */
941
- mode: 'fin' | 'leasing' | 'cash';
942
- price: number;
943
- monthly: number;
944
- apr: number;
945
- down: number;
946
- term: number;
947
- /** leasing annual mileage (km/yr), shown only in leasing mode. */
948
- annualMileage?: number;
949
- /** REAL figures from the financing config (NOT computed — balloon/Schlussrate makes monthly·term wrong). */
950
- netLoanAmount?: number;
951
- /** Schlussrate (balloon final installment); shown only when > 0. */
952
- finalInstallment?: number;
953
- /** Gesamt(rückzahlungs)betrag from the config; shown only when > 0 (never the wrong monthly·term). */
954
- grossAmount?: number;
955
- /** Sollzinssatz p.a. (%); shown only when > 0. */
956
- nominalRate?: number;
957
- /** slider bounds — when the change handlers are supplied, the modal shows adjustment sliders. */
958
- downMin?: number;
959
- downMax?: number;
960
- downStep?: number;
961
- termMin?: number;
962
- termMax?: number;
963
- termStep?: number;
964
- /** discrete selectable terms (months) for the Laufzeit chips. */
965
- termOptions?: number[];
966
- annualMileageMin?: number;
967
- annualMileageMax?: number;
968
- annualMileageStep?: number;
969
- /** slider change handlers (same as the calculator); they recompute the rate via the page. */
970
- onDownChange?: (down: number) => void;
971
- onTermChange?: (term: number) => void;
972
- onAnnualMileageChange?: (km: number) => void;
973
- onClose: () => void;
974
- /** i18n function (drilled from the page). */
975
- t: TranslationFunction;
976
- }
977
-
978
- /**
979
- * Full payment breakdown + legal example, opened from the calculator's
980
- * "Finanzierungsdetails" / "Leasingdetails" link (matches production, which uses a popup).
981
- * Desktop = centered modal, mobile = bottom-sheet (CSS). All figures are derived from the
982
- * live calculator props (no fabricated Sollzins/Schlussrate — only what we can compute).
983
- */
984
- export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalProps> = ({
985
- open,
986
- mode,
987
- price,
988
- monthly,
989
- apr,
990
- down,
991
- term,
992
- annualMileage,
993
- netLoanAmount,
994
- finalInstallment,
995
- grossAmount,
996
- nominalRate,
997
- downMin,
998
- downMax,
999
- downStep = 100,
1000
- termMin,
1001
- termMax,
1002
- termStep = 6,
1003
- termOptions,
1004
- annualMileageMin = 5000,
1005
- annualMileageMax = 60000,
1006
- annualMileageStep = 5000,
1007
- onDownChange,
1008
- onTermChange,
1009
- onAnnualMileageChange,
1010
- onClose,
1011
- t,
1012
- }) => {
1013
- if (!open) return null;
1014
- const isLease = mode === 'leasing';
1015
- const title = isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails');
1016
- // adjustable only for fin/leasing, and only when the page supplies the change handlers + bounds.
1017
- const adjustable = mode !== 'cash' && !!onDownChange && downMin != null && downMax != null && termMin != null && termMax != null;
1018
- // discrete term chip values (supplied periods, else derived from the bounds).
1019
- const termVals =
1020
- termOptions && termOptions.length
1021
- ? termOptions
1022
- : (() => {
1023
- const out: number[] = [];
1024
- for (let v = termMin as number; v <= (termMax as number); v += termStep || 12) out.push(v);
1025
- return out.length ? out : [term];
1026
- })();
1027
- const pct = (n: number) => `${n.toFixed(2).replace('.', ',')} %`;
1028
- // Only REAL figures (from the financing config). Balloon financing means monthly·term is NOT the
1029
- // total, so Schlussrate + Gesamtbetrag come from the config and are omitted when unavailable —
1030
- // never a fabricated/misleading total.
1031
- const netto = netLoanAmount != null ? netLoanAmount : Math.max(0, price - down);
1032
- const rows: Array<[string, string]> = [
1033
- [t('autobahn:modal.vehiclePrice'), `${eur(price)} €`],
1034
- [isLease ? t('autobahn:modal.leasingAmount') : t('autobahn:modal.netLoanAmount'), `${eur(netto)} €`],
1035
- [t('autobahn:modal.downPayment'), `${eur(down)} €`],
1036
- ...(isLease && annualMileage != null ? ([[t('autobahn:modal.annualMileage'), `${eur(annualMileage)} km`]] as Array<[string, string]>) : []),
1037
- [t('autobahn:modal.term'), t('autobahn:modal.months', { months: term, count: term })],
1038
- ...(nominalRate != null && nominalRate > 0 ? ([[t('autobahn:modal.nominalRate'), pct(nominalRate)]] as Array<[string, string]>) : []),
1039
- [t('autobahn:modal.effectiveRate'), pct(apr)],
1040
- ...(finalInstallment != null && finalInstallment > 0 ? ([[t('autobahn:modal.finalInstallment'), `${eur(finalInstallment)} €`]] as Array<[string, string]>) : []),
1041
- [isLease ? t('autobahn:modal.leasingRate') : t('autobahn:modal.monthlyRate'), t('autobahn:modal.ratePerMonth', { amount: eur(monthly) })],
1042
- ...(grossAmount != null && grossAmount > 0 ? ([[isLease ? t('autobahn:modal.totalAmount') : t('autobahn:modal.totalRepayment'), `${eur(grossAmount)} €`]] as Array<[string, string]>) : []),
1043
- ];
1044
- return (
1045
- <div className="fin-modal-scrim" onClick={onClose}>
1046
- <div className="fin-modal" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
1047
- <div className="fin-modal-head">
1048
- <h2 className="md-headline-sm" style={{ margin: 0 }}>
1049
- {title}
1050
- </h2>
1051
- <button className="icon-btn" onClick={onClose} aria-label={t('autobahn:modal.close')}>
1052
- <Icon name="x" size={20} />
1053
- </button>
1054
- </div>
1055
- <div className="fin-modal-body">
1056
- <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 14 }}>
1057
- <span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
1058
- {eur(monthly)} €
1059
- </span>
1060
- <span className="md-label-sm">{t('autobahn:finance.perMonth')}</span>
1061
- </div>
1062
- {/* adjustment sliders — change a value and every figure below recomputes live (via the page) */}
1063
- {adjustable && (
1064
- <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginBottom: 18 }}>
1065
- <div>
1066
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
1067
- <span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
1068
- <span className="num" style={{ fontWeight: 700 }}>
1069
- {eur(down)} €
1070
- </span>
1071
- </div>
1072
- <Slider
1073
- min={downMin as number}
1074
- max={downMax as number}
1075
- step={downStep}
1076
- value={down}
1077
- onChange={onDownChange as (n: number) => void}
1078
- fmt={(v) => `${eur(Number(v))} €`}
1079
- />
1080
- </div>
1081
- {isLease && annualMileage != null && onAnnualMileageChange && (
1082
- <div>
1083
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
1084
- <span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
1085
- <span className="num" style={{ fontWeight: 700 }}>
1086
- {eur(annualMileage)} km
1087
- </span>
1088
- </div>
1089
- <Slider
1090
- min={annualMileageMin}
1091
- max={annualMileageMax}
1092
- step={annualMileageStep}
1093
- value={annualMileage}
1094
- onChange={onAnnualMileageChange}
1095
- fmt={(v) => `${eur(Number(v))} km`}
1096
- />
1097
- </div>
1098
- )}
1099
- <div>
1100
- <div className="md-title-sm" style={{ marginBottom: 8 }}>
1101
- {t('autobahn:finance.termSelection')}
1102
- </div>
1103
- <TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
1104
- </div>
1105
- </div>
1106
- )}
1107
- {rows.map(([l, v], i) => (
1108
- <div
1109
- key={l}
1110
- style={{
1111
- display: 'flex',
1112
- justifyContent: 'space-between',
1113
- gap: 12,
1114
- padding: '9px 0',
1115
- borderBottom: i < rows.length - 1 ? '1px solid var(--md-outline-variant)' : 0,
1116
- }}
1117
- >
1118
- <span className="md-label-sm">{l}</span>
1119
- <span className="num" style={{ fontSize: 13.5, fontWeight: 600 }}>
1120
- {v}
1121
- </span>
1122
- </div>
1123
- ))}
1124
- <p className="md-label-sm" style={{ marginTop: 16, lineHeight: 1.55 }}>
1125
- {isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
1126
- </p>
1127
- </div>
1128
- </div>
1129
- </div>
1130
- );
1131
- };
1132
-
1133
- // ---------------------------------------------------------------------------
1134
- // Customer ratings (auto.de Google reviews) — REAL data, loaded by the page
1135
- // ---------------------------------------------------------------------------
1136
-
1137
- export interface CustomerRatingsProps {
1138
- /** the loaded reviews (REAL — from the WP json-quotes source). */
1139
- reviews: VdpReview[];
1140
- /** i18n function (drilled from the page). */
1141
- t: TranslationFunction;
1142
- }
1143
-
1144
- /**
1145
- * Customer-rating section (the legacy VDP's "Kundenmeinungen", rebuilt on the
1146
- * Autobahn card). Shows the auto.de average (DERIVED from the loaded reviews — not
1147
- * invented) + a grid of review cards (avatar, name, stars, quote). The reviews are
1148
- * the SAME real source the legacy CustomerQuotes uses (auto.de Google reviews via
1149
- * the WordPress json-quotes feed); the page loads them and passes them in. When no
1150
- * reviews load, the page renders nothing (no fabricated content).
1151
- */
1152
- export const CustomerRatings: React.FunctionComponent<CustomerRatingsProps> = ({ reviews, t }) => {
1153
- const [showAll, setShowAll] = useState(false);
1154
- if (!reviews || !reviews.length) return null;
1155
- const count = reviews.length;
1156
- const avg = reviews.reduce((s, r) => s + (r.rating || 0), 0) / count;
1157
- const shown = showAll ? reviews : reviews.slice(0, 4);
1158
- return (
1159
- <section className="mcard" id="vdp-bewertungen" style={{ padding: 22, marginTop: 44 }}>
1160
- <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
1161
- <h2 className="md-headline-sm" style={{ margin: 0 }}>
1162
- {t('autobahn:reviews.title')}
1163
- </h2>
1164
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
1165
- <span className="num" style={{ fontWeight: 800, fontSize: 26, color: 'var(--md-on-surface)', letterSpacing: '-.5px' }}>
1166
- {avg.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
1167
- </span>
1168
- <span>
1169
- <Stars value={avg} size={16} />
1170
- <span className="md-label-sm" style={{ display: 'block' }}>
1171
- {t('autobahn:reviews.ratingCount', { count })} · via Google
1172
- </span>
1173
- </span>
1174
- </div>
1175
- </div>
1176
- <div className="eq-cols">
1177
- {shown.map((r) => (
1178
- <div
1179
- key={r.id}
1180
- className="mcard mcard--filled"
1181
- style={{ breakInside: 'avoid', marginBottom: 16, padding: 16 }}
1182
- >
1183
- <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
1184
- {r.image ? (
1185
- <img
1186
- src={r.image}
1187
- alt=""
1188
- loading="lazy"
1189
- style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', flex: 'none', background: 'var(--md-sc)' }}
1190
- />
1191
- ) : (
1192
- <span
1193
- style={{
1194
- width: 40,
1195
- height: 40,
1196
- borderRadius: '50%',
1197
- flex: 'none',
1198
- background: 'var(--md-primary-container)',
1199
- color: 'var(--md-on-primary-container)',
1200
- display: 'flex',
1201
- alignItems: 'center',
1202
- justifyContent: 'center',
1203
- fontWeight: 700,
1204
- }}
1205
- >
1206
- {(r.name || '?').charAt(0).toUpperCase()}
1207
- </span>
1208
- )}
1209
- <div style={{ minWidth: 0 }}>
1210
- <div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
1211
- {r.name}
1212
- </div>
1213
- <Stars value={r.rating} />
1214
- </div>
1215
- </div>
1216
- <p className="md-body-sm" style={{ margin: 0, color: 'var(--md-on-surface-variant)' }}>
1217
- {r.review}
1218
- </p>
1219
- </div>
1220
- ))}
1221
- </div>
1222
- {reviews.length > 4 && (
1223
- <button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setShowAll((a) => !a)}>
1224
- {showAll ? t('autobahn:ratings.showLess') : t('autobahn:ratings.showMore', { count })}
1225
- <Icon name={showAll ? 'chevron-up' : 'chevron-down'} size={16} />
1226
- </button>
1227
- )}
1228
- </section>
1229
- );
1230
- };
1231
-
1232
- // ---------------------------------------------------------------------------
1233
- // Enquiry block — low-friction "Frage zum Fahrzeug" lead form
1234
- // ---------------------------------------------------------------------------
1235
-
1236
- export interface VdpEnquiryProps {
1237
- /** car title for the prefilled message + heading context. */
1238
- carTitle: string;
1239
- /** auto.de-ID reference, woven into the prefilled message when present. */
1240
- autoDeId?: string;
1241
- /** sales hotline shown in the aside (tap-to-call). */
1242
- salesPhone?: string;
1243
- /**
1244
- * Submit handler — the page wires this to the REAL contact channel
1245
- * (carStore.submitContactForm). Returns a promise; the form shows a thank-you on
1246
- * resolve and an inline error on reject.
1247
- */
1248
- onSubmit: (data: { name: string; email: string; phone: string; message: string }) => Promise<void> | void;
1249
- /** i18n function (drilled from the page). */
1250
- t: TranslationFunction;
1251
- }
1252
-
1253
- const enquiryInput: React.CSSProperties = {
1254
- width: '100%',
1255
- height: 46,
1256
- padding: '0 14px',
1257
- borderRadius: 'var(--md-r-sm)',
1258
- border: '1px solid var(--md-outline-variant)',
1259
- background: 'var(--md-sc-lowest)',
1260
- color: 'var(--md-on-surface)',
1261
- font: 'inherit',
1262
- fontSize: 14,
1263
- };
1264
-
1265
- /** field with a small floating-style label above the input. */
1266
- const EnquiryField: React.FunctionComponent<{
1267
- label: string;
1268
- children: React.ReactNode;
1269
- }> = ({ label, children }) => (
1270
- <label style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
1271
- <span className="md-label-sm" style={{ fontWeight: 600 }}>
1272
- {label}
1273
- </span>
1274
- {children}
1275
- </label>
1276
- );
1277
-
1278
- /** [icon, i18n label key] — resolved against the drilled t in the component. */
1279
- const ENQUIRY_BENEFITS: ReadonlyArray<[string, string]> = [
1280
- ['clock', 'autobahn:enquiry.benefits.fastResponse'],
1281
- ['shield-check', 'autobahn:enquiry.benefits.freeAndNonBinding'],
1282
- ['hand-coins', 'autobahn:enquiry.benefits.financingAdvice'],
1283
- ];
1284
-
1285
- /**
1286
- * Vehicle enquiry — an enriched two-pane lead block: a left trust/benefits aside (persönliche
1287
- * Beratung, response time, hotline) and a right structured form (preferred-contact toggle, named
1288
- * fields, editable prefilled message), wired to the REAL submit (carStore.submitContactForm).
1289
- */
1290
- export const VdpEnquiry: React.FunctionComponent<VdpEnquiryProps> = ({
1291
- carTitle,
1292
- autoDeId,
1293
- salesPhone = '0800 40 30 182',
1294
- onSubmit,
1295
- t,
1296
- }) => {
1297
- const [name, setName] = useState('');
1298
- const [email, setEmail] = useState('');
1299
- const [phone, setPhone] = useState('');
1300
- const [pref, setPref] = useState<'email' | 'phone'>('email');
1301
- const [message, setMessage] = useState(
1302
- autoDeId
1303
- ? t('autobahn:enquiry.prefilledMessageWithId', { carTitle, autoDeId })
1304
- : t('autobahn:enquiry.prefilledMessage', { carTitle })
1305
- );
1306
- const [sending, setSending] = useState(false);
1307
- const [sent, setSent] = useState(false);
1308
- const [error, setError] = useState('');
1309
-
1310
- const emailOk = /.+@.+\..+/.test(email);
1311
- const phoneOk = phone.replace(/\D/g, '').length >= 6;
1312
- // the preferred channel is required; the other stays optional.
1313
- const valid = name.trim().length > 1 && (pref === 'email' ? emailOk : phoneOk);
1314
-
1315
- const submit = async (): Promise<void> => {
1316
- if (!valid || sending) return;
1317
- setSending(true);
1318
- setError('');
1319
- const note = t('autobahn:enquiry.preferredContact', {
1320
- channel: pref === 'email' ? t('autobahn:enquiry.contactMethod.email') : t('autobahn:enquiry.contactMethod.phone'),
1321
- });
1322
- try {
1323
- await onSubmit({ name: name.trim(), email: email.trim(), phone: phone.trim(), message: message.trim() + note });
1324
- setSent(true);
1325
- } catch (e) {
1326
- setError(t('autobahn:enquiry.form.sendError'));
1327
- } finally {
1328
- setSending(false);
1329
- }
1330
- };
1331
-
1332
- if (sent) {
1333
- return (
1334
- <section className="mcard mcard--filled" id="vdp-kontakt" style={{ padding: 22, marginTop: 24 }}>
1335
- <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1336
- <Icon name="check-circle" size={24} color="var(--md-good)" />
1337
- <div>
1338
- <div className="md-title-sm">{t('autobahn:enquiry.success.title')}</div>
1339
- <div className="md-body-sm">{t('autobahn:enquiry.success.message')}</div>
1340
- </div>
1341
- </div>
1342
- </section>
1343
- );
1344
- }
1345
-
1346
- return (
1347
- <section className="mcard" id="vdp-kontakt" style={{ padding: 0, overflow: 'hidden', marginTop: 24 }}>
1348
- <div className="enquiry-grid">
1349
- {/* trust / benefits aside */}
1350
- <aside className="enquiry-aside">
1351
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
1352
- <span className="enquiry-ico">
1353
- <Icon name="headset" size={20} color="var(--md-on-primary-container)" />
1354
- </span>
1355
- <h2 className="md-title" style={{ margin: 0, fontSize: 19 }}>
1356
- {t('autobahn:enquiry.personalConsultation')}
1357
- </h2>
1358
- </div>
1359
- <p className="md-body-sm" style={{ marginBottom: 16 }}>
1360
- {t('autobahn:enquiry.aside.description')}
1361
- </p>
1362
- <ul className="enquiry-benefits">
1363
- {ENQUIRY_BENEFITS.map(([ic, key]) => (
1364
- <li key={key}>
1365
- <Icon name={ic} size={17} color="var(--md-primary)" stroke={2} />
1366
- {t(key)}
1367
- </li>
1368
- ))}
1369
- </ul>
1370
- <a className="enquiry-phone" href={`tel:${salesPhone.replace(/\s/g, '')}`}>
1371
- <Icon name="phone" size={16} />
1372
- {salesPhone}
1373
- </a>
1374
- </aside>
1375
-
1376
- {/* form */}
1377
- <div className="enquiry-form">
1378
- <div style={{ marginBottom: 14 }}>
1379
- <div className="md-label-sm" style={{ fontWeight: 600, marginBottom: 7 }}>
1380
- {t('autobahn:enquiry.contactMethod.heading')}
1381
- </div>
1382
- <div style={{ display: 'flex', gap: 8 }}>
1383
- <button
1384
- type="button"
1385
- className={'chip' + (pref === 'email' ? ' chip--filter chip--selected' : '')}
1386
- onClick={() => setPref('email')}
1387
- >
1388
- <Icon name="mail" size={15} />
1389
- {t('autobahn:enquiry.contactMethod.email')}
1390
- </button>
1391
- <button
1392
- type="button"
1393
- className={'chip' + (pref === 'phone' ? ' chip--filter chip--selected' : '')}
1394
- onClick={() => setPref('phone')}
1395
- >
1396
- <Icon name="phone" size={15} />
1397
- {t('autobahn:enquiry.contactMethod.phone')}
1398
- </button>
1399
- </div>
1400
- </div>
1401
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: 12 }}>
1402
- <EnquiryField label={t('autobahn:enquiry.form.nameLabel')}>
1403
- <input style={enquiryInput} placeholder={t('autobahn:enquiry.form.namePlaceholder')} value={name} onChange={(e) => setName(e.target.value)} aria-label={t('autobahn:enquiry.form.nameAria')} />
1404
- </EnquiryField>
1405
- <EnquiryField label={pref === 'email' ? t('autobahn:enquiry.form.emailLabelRequired') : t('autobahn:enquiry.form.emailLabel')}>
1406
- <input style={enquiryInput} type="email" placeholder={t('autobahn:enquiry.form.emailPlaceholder')} value={email} onChange={(e) => setEmail(e.target.value)} aria-label={t('autobahn:enquiry.contactMethod.email')} />
1407
- </EnquiryField>
1408
- <EnquiryField label={pref === 'phone' ? t('autobahn:enquiry.form.phoneLabelRequired') : t('autobahn:enquiry.form.phoneLabel')}>
1409
- <input style={enquiryInput} type="tel" placeholder={t('autobahn:enquiry.form.phonePlaceholder')} value={phone} onChange={(e) => setPhone(e.target.value)} aria-label={t('autobahn:enquiry.contactMethod.phone')} />
1410
- </EnquiryField>
1411
- </div>
1412
- <div style={{ marginTop: 12 }}>
1413
- <EnquiryField label={t('autobahn:enquiry.form.messageLabel')}>
1414
- <textarea
1415
- style={{ ...enquiryInput, height: 92, padding: '10px 14px', resize: 'vertical' }}
1416
- value={message}
1417
- onChange={(e) => setMessage(e.target.value)}
1418
- aria-label={t('autobahn:enquiry.form.messageAria')}
1419
- />
1420
- </EnquiryField>
1421
- </div>
1422
- {error && (
1423
- <div className="md-label-sm" style={{ color: 'var(--md-error)', marginTop: 8 }}>
1424
- {error}
1425
- </div>
1426
- )}
1427
- <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginTop: 16, flexWrap: 'wrap' }}>
1428
- <Button variant="filled" size="lg" icon="send" disabled={!valid || sending} onClick={submit}>
1429
- {sending ? t('autobahn:enquiry.form.sending') : t('autobahn:enquiry.sendButton')}
1430
- </Button>
1431
- <span className="md-label-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
1432
- <Icon name="lock" size={13} />
1433
- {t('autobahn:enquiry.security')}
1434
- </span>
1435
- </div>
1436
- </div>
1437
- </div>
1438
- </section>
1439
- );
1440
- };
1441
-
1442
- // ---------------------------------------------------------------------------
1443
- // "So einfach geht's" steps
1444
- // ---------------------------------------------------------------------------
1445
-
1446
- /** [title, description]. */
1447
- export type HowItWorksStep = readonly [title: string, desc: string];
1448
-
1449
- export interface HowItWorksProps {
1450
- /** the numbered steps; defaults to the design's four-step flow. */
1451
- steps?: ReadonlyArray<HowItWorksStep>;
1452
- /** i18n function (drilled from the page). */
1453
- t: TranslationFunction;
1454
- }
1455
-
1456
- /** Lucide icon per step index: Angebot / Beratung / Bezahlung / Übergabe. */
1457
- const STEP_ICONS = ['bookmark', 'message-circle', 'credit-card', 'truck'] as const;
1458
-
1459
- /** the design's four-step flow — [title key, description key]. */
1460
- const DEFAULT_STEPS: ReadonlyArray<HowItWorksStep> = [
1461
- ['autobahn:steps.step1.title', 'autobahn:steps.step1.description'],
1462
- ['autobahn:steps.step2.title', 'autobahn:steps.step2.description'],
1463
- ['autobahn:steps.step3.title', 'autobahn:steps.step3.description'],
1464
- ['autobahn:steps.step4.title', 'autobahn:steps.step4.description'],
1465
- ];
1466
-
1467
- /** "So einfach geht's" — icon-step cards with connector line (professional redesign). */
1468
- export const HowItWorks: React.FunctionComponent<HowItWorksProps> = ({ steps, t }) => {
1469
- const items: ReadonlyArray<HowItWorksStep> =
1470
- steps ?? DEFAULT_STEPS.map(([titleKey, descKey]) => [t(titleKey), t(descKey)] as HowItWorksStep);
1471
- return (
1472
- <section className="hiw-section" style={{ marginTop: 44 }}>
1473
- <h2 className="md-headline-sm" style={{ marginBottom: 28, textAlign: 'center' }}>
1474
- {t('autobahn:howItWorks.title')}
1475
- </h2>
1476
- <div className="steps-4 hiw-grid">
1477
- {items.map(([title, desc], i) => (
1478
- <div key={title} className="hiw-step">
1479
- <div className="hiw-icon-wrap">
1480
- <Icon name={STEP_ICONS[i] || 'star'} size={24} color="#fff" stroke={1.8} />
1481
- <span className="hiw-num">{i + 1}</span>
1482
- </div>
1483
- <div className="hiw-title">{title}</div>
1484
- <p className="hiw-desc">{desc}</p>
1485
- </div>
1486
- ))}
1487
- </div>
1488
- </section>
1489
- );
1490
- };
1
+ // Autobahn (auto.de redesign) — Vehicle-Detail (VDP) presentational modules.
2
+ //
3
+ // Typed React 18 + TS port of the remaining VDP pieces from the design kit
4
+ // (docs/redesign/design-source/ui_kits/website/CdpModules.jsx + ScreenVDP.jsx).
5
+ // Each is a pure / presentational ES named export with an explicit props
6
+ // interface; none touches a store, the API or `window`. The page wiring that
7
+ // feeds these from the real stores is a SEPARATE later step (W3b).
8
+ //
9
+ // Replaces the design kit's globals:
10
+ // - `window.CO2_CLASSES` / `window.CO2_COLORS` → ../constants
11
+ // - `equipFor(id)` / `car.priceHistory` / market band → typed props on VehicleDetail
12
+ // - `assetUrl(...)` → pre-resolved image URLs (caller's job)
13
+ // - `data-lucide` icons → the ui kit's <Icon />
14
+ // The DealerBlock from CdpModules is intentionally NOT ported (auto.de is the
15
+ // seller, not a dealer marketplace).
16
+ //
17
+ // NEEDS-DATA discipline (the point of the ticket): wherever the design showed a
18
+ // figure we have no real source for, these render the NEEDS_DATA_LABEL
19
+ // ('Daten folgt') marker rather than inventing a value — see PriceHistory (whole
20
+ // card) and its market band.
21
+ //
22
+ // All components render correctly only inside a `<div className="md3">` subtree.
23
+
24
+ import React, { useState } from 'react';
25
+ import { TranslationFunction } from '../types';
26
+
27
+ import { Button, Icon, Spec, StatChip, Stars } from '../ui';
28
+ import { CO2_CLASSES, CO2_COLORS } from '../constants';
29
+ import { defaultVdpSpecs, EquipmentGroups, VehicleDetail, VdpSpec, VdpReview } from './types';
30
+ import { Slider, TermChips } from './FinanceCalculator';
31
+
32
+ const eur = (n: number): string => n.toLocaleString('de-DE');
33
+
34
+ /** mileage → 'X.XXX km' (de-DE grouping). */
35
+ function fmtKm(n: number): string {
36
+ return `${n.toLocaleString('de-DE')} km`;
37
+ }
38
+
39
+ /** strip a trailing parenthetical, e.g. '110 kW (150 PS)' → '110 kW'. */
40
+ function stripParen(s: string): string {
41
+ return s.replace(/\s*\(.*\)/, '');
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Header card — title, fact line, highlight chips, condition tiles
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface VdpHeaderCardProps {
49
+ vehicle: VehicleDetail;
50
+ /** i18n function (drilled from the page). */
51
+ t: TranslationFunction;
52
+ }
53
+
54
+ /**
55
+ * One container holding the entire vehicle identity: headline + trim/power, the
56
+ * dot-separated fact line, the hand-picked highlight chips, and the condition
57
+ * (zustand) tiles footer. The gallery itself is composed by the page (W3b) — this
58
+ * is just the textual header card that sits beneath it.
59
+ */
60
+ export const VdpHeaderCard: React.FunctionComponent<VdpHeaderCardProps> = ({ vehicle, t }) => {
61
+ const { condition } = vehicle;
62
+ const factLine: ReadonlyArray<readonly [string, string]> = [
63
+ ['gauge', fmtKm(vehicle.km)],
64
+ ['calendar', vehicle.reg],
65
+ ['cog', vehicle.gear],
66
+ ['fuel', stripParen(vehicle.fuel)],
67
+ ['user', t('autobahn:header.owners', { owners: vehicle.owners, count: vehicle.owners })],
68
+ ];
69
+ // condition.hu === 'Neu' compares the RAW backend data value, not a display string — left as-is.
70
+ const tiles: ReadonlyArray<readonly [string, string, string]> = [
71
+ ['wrench', t('autobahn:header.huLabel'), condition.hu === 'Neu' ? t('autobahn:header.huNew') : t('autobahn:header.huUntil', { date: condition.hu })],
72
+ ['shield-check', t('autobahn:header.accidentFree'), condition.unfallfrei ? t('autobahn:header.yes') : t('autobahn:header.no')],
73
+ ['clipboard-check', t('autobahn:header.serviceHistory'), condition.scheckheft ? t('autobahn:header.yes') : t('autobahn:header.no')],
74
+ ['award', t('autobahn:header.warranty'), condition.garantie],
75
+ ];
76
+ return (
77
+ <div className="mcard" style={{ marginTop: 16, padding: '22px 24px 18px' }}>
78
+ <div className="vdp-headrow">
79
+ <div style={{ minWidth: 0 }}>
80
+ <h1 className="md-headline" style={{ margin: '0 0 4px' }}>
81
+ {vehicle.make} {vehicle.model}
82
+ </h1>
83
+ <p className="md-body" style={{ margin: 0, fontSize: 15 }}>
84
+ {vehicle.trim} · {vehicle.power}
85
+ </p>
86
+ {/* auto.de-ID lives in the offer card (next to Finanzierungsdetails), not here */}
87
+ </div>
88
+ {/* BL-08: no chip at all when there's no real rating (empty/broken price) — never a
89
+ fabricated "Top Preis"-shaped badge for `rating === ''`. */}
90
+ {vehicle.rating && (
91
+ <StatChip
92
+ tone={vehicle.rating === 'fair' ? 'warn' : 'good'}
93
+ icon={vehicle.rating === 'fair' ? 'minus' : 'check'}
94
+ >
95
+ {t('autobahn:tier.' + vehicle.rating)}
96
+ </StatChip>
97
+ )}
98
+ </div>
99
+
100
+ {/* fact line — plain text with dot separators, not pills */}
101
+ <div className="vdp-meta">
102
+ {factLine.map(([ic, v]) => (
103
+ <span key={ic}>
104
+ <Icon name={ic} size={16} color="var(--md-on-surface-variant)" stroke={1.8} />
105
+ {v}
106
+ </span>
107
+ ))}
108
+ </div>
109
+
110
+ {/* hand-picked highlights — the single accent row of the header */}
111
+ <div
112
+ className="vdp-highlights"
113
+ style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginTop: 14 }}
114
+ >
115
+ {vehicle.highlights.slice(0, 6).map((f) => (
116
+ <span key={f} className="chip chip--filter chip--selected" style={{ cursor: 'default' }}>
117
+ <Icon name="check" size={14} stroke={2.4} />
118
+ {f}
119
+ </span>
120
+ ))}
121
+ </div>
122
+
123
+ {/* condition — footer row of the header card, divided by a hairline */}
124
+ <div className="zustand-grid">
125
+ {tiles.map(([ic, l, v]) => (
126
+ <div key={l} className="zustand-tile">
127
+ <Icon name={ic} size={19} color="var(--md-primary)" stroke={1.9} style={{ flex: 'none' }} />
128
+ <span style={{ minWidth: 0 }}>
129
+ <span
130
+ className="md-label-sm"
131
+ style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
132
+ >
133
+ {l}
134
+ </span>
135
+ <span className="md-title-sm" style={{ display: 'block' }}>
136
+ {v}
137
+ </span>
138
+ </span>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ );
144
+ };
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Fahrzeugdaten spec table (mobile clamp + "Mehr anzeigen")
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export interface VdpSpecTableProps {
151
+ vehicle: VehicleDetail;
152
+ /** i18n function (drilled from the page). */
153
+ t: TranslationFunction;
154
+ }
155
+
156
+ /**
157
+ * Fahrzeugdaten grid of Spec items. Uses the vehicle's explicit `specs` when
158
+ * present, else derives the design's twelve-row layout via `defaultVdpSpecs`. On
159
+ * phones the table clamps to a few rows (`vdp-specs--clamp`) with a "Mehr
160
+ * anzeigen" toggle (pure view state).
161
+ */
162
+ export const VdpSpecTable: React.FunctionComponent<VdpSpecTableProps> = ({ vehicle, t }) => {
163
+ const [allSpecs, setAllSpecs] = useState(false);
164
+ const specs: VdpSpec[] = vehicle.specs && vehicle.specs.length ? vehicle.specs : defaultVdpSpecs(vehicle);
165
+ return (
166
+ <div className="mcard" id="vdp-daten" style={{ padding: 22 }}>
167
+ <h2 className="md-headline-sm" style={{ marginBottom: 16 }}>
168
+ {t('autobahn:specTable.title')}
169
+ </h2>
170
+ <div className={'vdp-specs' + (allSpecs ? '' : ' vdp-specs--clamp')}>
171
+ {specs.map(([ic, l, v]) => (
172
+ <Spec key={l} icon={ic} label={l}>
173
+ {v}
174
+ </Spec>
175
+ ))}
176
+ </div>
177
+ {/* phone only: clamp button only meaningful when there are more than 6 specs */}
178
+ {specs.length > 6 && (
179
+ <button
180
+ className="mbtn mbtn--text show-mobile"
181
+ style={{ marginTop: 12, padding: 0 }}
182
+ onClick={() => setAllSpecs((a) => !a)}
183
+ >
184
+ {allSpecs ? t('autobahn:vdp.specs.showLess') : t('autobahn:specTable.showAll', { count: specs.length })}
185
+ <Icon name={allSpecs ? 'chevron-up' : 'chevron-down'} size={16} />
186
+ </button>
187
+ )}
188
+ </div>
189
+ );
190
+ };
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Equipment list (grouped + searchable)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ export interface EquipmentListProps {
197
+ /** grouped equipment, e.g. { Komfort: [...], Sicherheit: [...] }. */
198
+ groups: EquipmentGroups;
199
+ /** i18n function (drilled from the page). */
200
+ t: TranslationFunction;
201
+ }
202
+
203
+ /** Grouped + searchable equipment list (Autohero / AutoScout pattern). */
204
+ export const EquipmentList: React.FunctionComponent<EquipmentListProps> = ({ groups, t }) => {
205
+ const [q, setQ] = useState('');
206
+ const [openAll, setOpenAll] = useState(false);
207
+ const ql = q.trim().toLowerCase();
208
+ const entries = Object.entries(groups)
209
+ .map(([g, items]): [string, string[]] => [g, items.filter((it) => !ql || it.toLowerCase().includes(ql))])
210
+ .filter(([, items]) => items.length);
211
+ const total = Object.values(groups).reduce((s, a) => s + a.length, 0);
212
+ return (
213
+ <div className="mcard" id="vdp-ausstattung" style={{ padding: 22 }}>
214
+ <div
215
+ style={{
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ justifyContent: 'space-between',
219
+ gap: 12,
220
+ marginBottom: 14,
221
+ flexWrap: 'wrap',
222
+ }}
223
+ >
224
+ <h2 className="md-headline-sm">
225
+ {t('autobahn:equipment.title')}{' '}
226
+ <span style={{ color: 'var(--md-on-surface-variant)', fontWeight: 600 }}>({total})</span>
227
+ </h2>
228
+ <label className="mfield" style={{ height: 40, maxWidth: 240, flex: 1, minWidth: 170 }}>
229
+ <Icon name="search" size={18} />
230
+ <input placeholder={t('autobahn:equipment.search.placeholder')} value={q} onChange={(e) => setQ(e.target.value)} />
231
+ {q && (
232
+ <button className="icon-btn" style={{ width: 28, height: 28 }} onClick={() => setQ('')}>
233
+ <Icon name="x" size={15} />
234
+ </button>
235
+ )}
236
+ </label>
237
+ </div>
238
+ <div className="eq-cols">
239
+ {entries.map(([g, items]) => (
240
+ <div key={g} style={{ breakInside: 'avoid', marginBottom: 18 }}>
241
+ <div className="md-title-sm" style={{ color: 'var(--md-primary)', marginBottom: 8 }}>
242
+ {g}
243
+ </div>
244
+ <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
245
+ {(openAll || ql ? items : items.slice(0, 4)).map((it) => (
246
+ <li
247
+ key={it}
248
+ style={{
249
+ display: 'flex',
250
+ gap: 9,
251
+ alignItems: 'flex-start',
252
+ fontSize: 13.5,
253
+ color: 'var(--md-on-surface-variant)',
254
+ }}
255
+ >
256
+ <Icon name="check" size={16} color="var(--md-good)" stroke={2.4} style={{ flex: 'none', marginTop: 1 }} />
257
+ {it}
258
+ </li>
259
+ ))}
260
+ </ul>
261
+ </div>
262
+ ))}
263
+ </div>
264
+ {!ql && !openAll && (
265
+ <button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setOpenAll(true)}>
266
+ {t('autobahn:equipment.showAll', { total })}
267
+ <Icon name="chevron-down" size={16} />
268
+ </button>
269
+ )}
270
+ {entries.length === 0 && <p className="md-body-sm">{t('autobahn:equipment.noResults', { q })}</p>}
271
+ </div>
272
+ );
273
+ };
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // CO₂ / energy-efficiency label
277
+ // ---------------------------------------------------------------------------
278
+
279
+ export interface Co2LabelProps {
280
+ vehicle: VehicleDetail;
281
+ /** i18n function (drilled from the page). */
282
+ t: TranslationFunction;
283
+ }
284
+
285
+ /** CO₂ / energy-efficiency label (EU-style stepped colour band). Handles PHEV. */
286
+ export const Co2Label: React.FunctionComponent<Co2LabelProps> = ({ vehicle, t }) => {
287
+ const p = vehicle.phev;
288
+ const idx = Math.max(0, CO2_CLASSES.indexOf((p ? p.co2classW : vehicle.co2class) as (typeof CO2_CLASSES)[number]));
289
+ const stats: ReadonlyArray<readonly [string, string]> = p
290
+ ? [
291
+ [t('autobahn:eco.stat.powerConsumptionWeighted'), p.consEl],
292
+ [t('autobahn:eco.stat.fuelConsumptionWeighted'), p.consFuel],
293
+ [t('autobahn:eco.stat.fuelConsumptionDischarged'), p.consDis],
294
+ [t('autobahn:eco.stat.co2WeightedCombined'), p.co2w],
295
+ ]
296
+ : [
297
+ [t('autobahn:eco.stat.consumptionCombined'), vehicle.cons],
298
+ [t('autobahn:eco.stat.co2Emissions'), vehicle.co2],
299
+ [t('autobahn:eco.stat.co2Class'), vehicle.co2class],
300
+ ];
301
+ return (
302
+ <div className="mcard" style={{ padding: 22 }}>
303
+ <h2 className="md-headline-sm" style={{ marginBottom: 14 }}>
304
+ {t('autobahn:eco.heading')}
305
+ </h2>
306
+ <div className="co2-stats">
307
+ {stats.map(([l, v]) => (
308
+ <div key={l}>
309
+ <div className="md-label-sm">{l}</div>
310
+ <div className="md-title-sm" style={{ fontSize: 16 }}>
311
+ {v}
312
+ </div>
313
+ </div>
314
+ ))}
315
+ </div>
316
+ <div className="co2-scale">
317
+ {CO2_CLASSES.map((c, i) => (
318
+ <div key={c} className={'co2-seg' + (i === idx ? ' co2-seg--on' : '')} style={{ background: CO2_COLORS[i] }}>
319
+ {c}
320
+ </div>
321
+ ))}
322
+ </div>
323
+ {p ? (
324
+ <p className="md-label-sm" style={{ margin: '10px 0 0' }}>
325
+ {t('autobahn:eco.co2ClassPrefix')} <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classW}</b>{' '}
326
+ {t('autobahn:eco.weightedCombined')} · <b style={{ color: 'var(--md-on-surface)' }}>{p.co2classD}</b>{' '}
327
+ {t('autobahn:eco.dischargedCombined')} · {t('autobahn:eco.energyClassWltpSuffix')}
328
+ </p>
329
+ ) : (
330
+ <p className="md-label-sm" style={{ margin: '10px 0 0' }}>
331
+ {t('autobahn:eco.energyClass')}
332
+ </p>
333
+ )}
334
+ </div>
335
+ );
336
+ };
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Free-text vehicle description (Fahrzeugbeschreibung)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ export interface VehicleDescriptionProps {
343
+ vehicle: VehicleDetail;
344
+ /**
345
+ * Pre-composed description text. When omitted a sensible default is built from
346
+ * the vehicle's typed fields (matching the design's template sentence).
347
+ */
348
+ text?: string;
349
+ /** i18n function (drilled from the page). */
350
+ t: TranslationFunction;
351
+ }
352
+
353
+ function defaultDescription(v: VehicleDetail, t: TranslationFunction): string {
354
+ return t('autobahn:description.defaultBody', {
355
+ make: v.make,
356
+ model: v.model,
357
+ trim: v.trim,
358
+ power: v.power,
359
+ gear: v.gear,
360
+ owners: v.owners,
361
+ reg: v.reg,
362
+ km: v.km.toLocaleString('de-DE'),
363
+ color: v.color,
364
+ });
365
+ }
366
+
367
+ /** Free-text vehicle description with a "Mehr anzeigen" clamp (pure view state). */
368
+ export const VehicleDescription: React.FunctionComponent<VehicleDescriptionProps> = ({ vehicle, text, t }) => {
369
+ const [open, setOpen] = useState(false);
370
+ const body = text ?? defaultDescription(vehicle, t);
371
+ return (
372
+ <div className="mcard" style={{ padding: 22 }}>
373
+ <h2 className="md-headline-sm" style={{ marginBottom: 12 }}>
374
+ {t('autobahn:description.heading')}
375
+ </h2>
376
+ <p
377
+ className="md-body"
378
+ style={{ margin: 0, maxHeight: open ? 'none' : 96, overflow: 'hidden', position: 'relative' }}
379
+ >
380
+ {body}
381
+ </p>
382
+ <button className="mbtn mbtn--text" style={{ marginTop: 8, padding: 0 }} onClick={() => setOpen((o) => !o)}>
383
+ {open ? t('autobahn:description.showLess') : t('autobahn:description.showMore')}
384
+ <Icon name={open ? 'chevron-up' : 'chevron-down'} size={16} />
385
+ </button>
386
+ </div>
387
+ );
388
+ };
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Trust strip
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /** [kebab-case lucide icon, label]. */
395
+ export type TrustItem = readonly [icon: string, label: string];
396
+
397
+ export interface TrustStripProps {
398
+ /** trust badges; defaults to auto.de's four standard promises. */
399
+ items?: ReadonlyArray<TrustItem>;
400
+ /** i18n function (drilled from the page). */
401
+ t: TranslationFunction;
402
+ }
403
+
404
+ /** auto.de's four standard promises — [icon, i18n label key]. */
405
+ const DEFAULT_TRUST: ReadonlyArray<TrustItem> = [
406
+ ['shield-check', 'autobahn:trust.warranty'],
407
+ ['undo-2', 'autobahn:trust.return'],
408
+ ['truck', 'autobahn:trust.delivery'],
409
+ ['badge-check', 'autobahn:trust.inspection'],
410
+ ];
411
+
412
+ /** Horizontal trust-signal strip. */
413
+ export const TrustStrip: React.FunctionComponent<TrustStripProps> = ({ items, t }) => (
414
+ <div className="trust-strip">
415
+ {(items ?? DEFAULT_TRUST.map(([ic, key]) => [ic, t(key)] as TrustItem)).map(([ic, label]) => (
416
+ <div key={label}>
417
+ <Icon name={ic} size={17} color="var(--md-primary)" stroke={2} style={{ flex: 'none' }} />
418
+ {label}
419
+ </div>
420
+ ))}
421
+ </div>
422
+ );
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Price history + market band (NEEDS-DATA by default)
426
+ // ---------------------------------------------------------------------------
427
+
428
+ export interface PriceHistoryProps {
429
+ vehicle: VehicleDetail;
430
+ /** i18n function (drilled from the page). */
431
+ t: TranslationFunction;
432
+ }
433
+
434
+ /**
435
+ * Price-history sparkline + market-value position bar.
436
+ *
437
+ * NEEDS-DATA: the sparkline needs `priceHistory` (with at least two points) and
438
+ * the position bar needs the full market band (`marketLow` / `marketAvg` /
439
+ * `marketHigh`). Either section that lacks real data renders the
440
+ * NEEDS_DATA_LABEL ('Daten folgt') marker — we never draw a fake trend or
441
+ * position. Both are needs-data by default, so until W3b supplies them this card
442
+ * shows the markers.
443
+ */
444
+ export const PriceHistory: React.FunctionComponent<PriceHistoryProps> = ({ vehicle, t }) => {
445
+ const pts = vehicle.priceHistory;
446
+ const hasHistory = !!pts && pts.length >= 2;
447
+ const hasMarket =
448
+ vehicle.marketLow != null &&
449
+ vehicle.marketAvg != null &&
450
+ vehicle.marketHigh != null &&
451
+ vehicle.marketHigh > vehicle.marketLow;
452
+
453
+ // Nothing real to show yet — hide the whole card instead of rendering stubs.
454
+ if (!hasHistory && !hasMarket) return null;
455
+
456
+ // --- history sparkline (only when real points exist) ---
457
+ let historyBlock: React.ReactNode;
458
+ if (hasHistory) {
459
+ const prices = pts!.map((p) => p.price);
460
+ const lo = Math.min(...prices);
461
+ const hi = Math.max(...prices);
462
+ const W = 280;
463
+ const H = 70;
464
+ const x = (i: number) => (i / (pts!.length - 1)) * W;
465
+ const y = (p: number) => H - ((p - lo) / (hi - lo || 1)) * H;
466
+ const last = pts![pts!.length - 1];
467
+ const line = pts!.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.price).toFixed(1)}`).join(' ');
468
+ const area = `${line} L ${W} ${H} L 0 ${H} Z`;
469
+ const drop = vehicle.listPrice != null ? vehicle.listPrice - vehicle.price : null;
470
+ historyBlock = (
471
+ <div>
472
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
473
+ {drop != null && drop > 0 ? (
474
+ <>
475
+ <StatChip tone="good" icon="trending-down">
476
+ {t('autobahn:priceHistory.dropAmount', { amount: eur(drop) })}
477
+ </StatChip>
478
+ <span className="md-label-sm">{t('autobahn:priceHistory.sinceListing')}</span>
479
+ </>
480
+ ) : (
481
+ <span className="md-label-sm">{t('autobahn:priceHistory.label')}</span>
482
+ )}
483
+ </div>
484
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height="78" preserveAspectRatio="none" style={{ overflow: 'visible' }}>
485
+ <defs>
486
+ <linearGradient id="phg" x1="0" y1="0" x2="0" y2="1">
487
+ <stop offset="0%" stopColor="var(--md-primary)" stopOpacity=".22" />
488
+ <stop offset="100%" stopColor="var(--md-primary)" stopOpacity="0" />
489
+ </linearGradient>
490
+ </defs>
491
+ <path d={area} fill="url(#phg)" />
492
+ <path d={line} fill="none" stroke="var(--md-primary)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
493
+ <circle cx={x(pts!.length - 1)} cy={y(last.price)} r="4.5" fill="var(--md-primary)" stroke="#fff" strokeWidth="2" />
494
+ </svg>
495
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
496
+ <span className="md-label-sm">{pts![0].label}</span>
497
+ <span className="md-label-sm">{last.label}</span>
498
+ </div>
499
+ </div>
500
+ );
501
+ } else {
502
+ historyBlock = (
503
+ <div>
504
+ <div className="md-title-sm" style={{ marginBottom: 12 }}>
505
+ {t('autobahn:priceHistory.title')}
506
+ </div>
507
+ <p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
508
+ {t('autobahn:common.needsData')}
509
+ </p>
510
+ </div>
511
+ );
512
+ }
513
+
514
+ // --- market-value position (only when the full band exists) ---
515
+ let marketBlock: React.ReactNode;
516
+ if (hasMarket) {
517
+ const mpos = Math.max(
518
+ 0,
519
+ Math.min(1, (vehicle.price - vehicle.marketLow!) / (vehicle.marketHigh! - vehicle.marketLow!)),
520
+ );
521
+ const delta = vehicle.marketAvg! - vehicle.price;
522
+ marketBlock = (
523
+ <div>
524
+ <div className="md-title-sm" style={{ marginBottom: 12 }}>
525
+ {t('autobahn:marketValue.heading')}
526
+ </div>
527
+ <div
528
+ style={{
529
+ position: 'relative',
530
+ height: 10,
531
+ borderRadius: 'var(--md-r-full)',
532
+ background: 'linear-gradient(90deg,#0F7B43,#C7B53A 55%,#D4502B)',
533
+ }}
534
+ >
535
+ <span
536
+ style={{
537
+ position: 'absolute',
538
+ top: '50%',
539
+ left: `calc(${mpos * 100}% - 9px)`,
540
+ transform: 'translateY(-50%)',
541
+ width: 18,
542
+ height: 18,
543
+ borderRadius: '50%',
544
+ background: '#fff',
545
+ boxShadow: 'var(--md-e2), 0 0 0 2px var(--md-primary)',
546
+ }}
547
+ />
548
+ </div>
549
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
550
+ <span className="md-label-sm">{eur(vehicle.marketLow!)} €</span>
551
+ <span className="md-label-sm">{eur(vehicle.marketHigh!)} €</span>
552
+ </div>
553
+ {delta > 0 && (
554
+ <div
555
+ style={{
556
+ display: 'flex',
557
+ alignItems: 'center',
558
+ gap: 8,
559
+ marginTop: 12,
560
+ padding: '10px 12px',
561
+ borderRadius: 'var(--md-r-sm)',
562
+ background: 'var(--md-good-container)',
563
+ }}
564
+ >
565
+ <Icon name="badge-check" size={18} color="var(--md-on-good-container)" />
566
+ <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-on-good-container)' }}>
567
+ {t('autobahn:marketValue.belowAverage', { rating: t('autobahn:tier.' + vehicle.rating), amount: eur(delta) })}
568
+ </span>
569
+ </div>
570
+ )}
571
+ </div>
572
+ );
573
+ } else {
574
+ marketBlock = (
575
+ <div>
576
+ <div className="md-title-sm" style={{ marginBottom: 12 }}>
577
+ {t('autobahn:marketValue.heading')}
578
+ </div>
579
+ <p className="md-body-sm" style={{ margin: 0, fontStyle: 'italic', color: 'var(--md-on-surface-variant)' }}>
580
+ {t('autobahn:common.needsData')}
581
+ </p>
582
+ </div>
583
+ );
584
+ }
585
+
586
+ return (
587
+ <div className="mcard" id="vdp-preis" style={{ padding: 22 }}>
588
+ <h2 className="md-headline-sm" style={{ marginBottom: 4 }}>
589
+ {t('autobahn:priceMarket.heading')}
590
+ </h2>
591
+ <p className="md-body-sm" style={{ marginBottom: 18 }}>
592
+ {t('autobahn:priceMarket.description')}
593
+ </p>
594
+ <div className="pmv-grid">
595
+ {historyBlock}
596
+ {marketBlock}
597
+ </div>
598
+ </div>
599
+ );
600
+ };
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Protection products (CNP Santander upsell) — optional, non-functional UI
604
+ // ---------------------------------------------------------------------------
605
+
606
+ /** [kebab-case lucide icon, name, description, monthly price EUR]. */
607
+ export type ProtectionProduct = readonly [icon: string, name: string, desc: string, price: number];
608
+
609
+ export interface ProtectionProductsProps {
610
+ /** the upsell products; defaults to the design's four CNP Santander packages. */
611
+ items?: ReadonlyArray<ProtectionProduct>;
612
+ /** i18n function (drilled from the page). */
613
+ t: TranslationFunction;
614
+ }
615
+
616
+ /**
617
+ * The design's four CNP Santander packages — [icon, product name (proper noun,
618
+ * also the selection key), i18n description key, monthly example price EUR].
619
+ */
620
+ const DEFAULT_PROTECTION: ReadonlyArray<ProtectionProduct> = [
621
+ ['shield-check', 'Safe Plus', 'autobahn:protection.items.safePlus.desc', 9],
622
+ ['life-buoy', 'Drive Pro', 'autobahn:protection.items.drivePro.desc', 7],
623
+ ['wrench', 'Smart Repair', 'autobahn:protection.items.smartRepair.desc', 12],
624
+ ['rotate-ccw', 'Smart Return', 'autobahn:protection.items.smartReturn.desc', 10],
625
+ ];
626
+
627
+ /**
628
+ * Partner attach products (CNP Santander) sold alongside the financing. Kept as a
629
+ * presentational upsell demo: the selectable rows are pure VIEW state (no store /
630
+ * order side effects, the prices are example values), and the catalogue is a prop
631
+ * with sane defaults. Functional ordering is out of scope for W3a.
632
+ */
633
+ export const ProtectionProducts: React.FunctionComponent<ProtectionProductsProps> = ({ items, t }) => {
634
+ const products: ReadonlyArray<ProtectionProduct> =
635
+ items ?? DEFAULT_PROTECTION.map(([ic, name, descKey, price]) => [ic, name, t(descKey), price] as ProtectionProduct);
636
+ const [sel, setSel] = useState<Record<string, boolean>>({});
637
+ const chosen = products.filter(([, n]) => sel[n]);
638
+ const sum = chosen.reduce((a, [, , , p]) => a + p, 0);
639
+ return (
640
+ <div className="mcard" style={{ padding: 20 }}>
641
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
642
+ <span
643
+ style={{
644
+ width: 34,
645
+ height: 34,
646
+ borderRadius: 'var(--md-r-full)',
647
+ background: 'var(--md-sc)',
648
+ display: 'flex',
649
+ alignItems: 'center',
650
+ justifyContent: 'center',
651
+ flex: 'none',
652
+ }}
653
+ >
654
+ <Icon name="umbrella" size={18} color="var(--md-on-surface-variant)" />
655
+ </span>
656
+ <div>
657
+ <div className="md-title-sm">{t('autobahn:protection.heading')}</div>
658
+ <div className="md-label-sm">{t('autobahn:protection.subtitle')}</div>
659
+ </div>
660
+ </div>
661
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
662
+ {products.map(([ic, name, desc, price]) => (
663
+ <button
664
+ key={name}
665
+ onClick={() => setSel((s) => ({ ...s, [name]: !s[name] }))}
666
+ style={{
667
+ display: 'flex',
668
+ alignItems: 'center',
669
+ gap: 12,
670
+ width: '100%',
671
+ textAlign: 'left',
672
+ cursor: 'pointer',
673
+ padding: '10px 12px',
674
+ borderRadius: 'var(--md-r-md)',
675
+ background: sel[name] ? 'var(--md-primary-container)' : 'var(--md-sc-low)',
676
+ border: '1px solid ' + (sel[name] ? 'var(--md-primary)' : 'transparent'),
677
+ transition: 'background var(--dur-1), border-color var(--dur-1)',
678
+ }}
679
+ >
680
+ <Icon
681
+ name={ic}
682
+ size={19}
683
+ stroke={1.8}
684
+ color={sel[name] ? 'var(--md-on-primary-container)' : 'var(--md-on-surface-variant)'}
685
+ style={{ flex: 'none' }}
686
+ />
687
+ <span style={{ flex: 1, minWidth: 0 }}>
688
+ <span className="md-title-sm" style={{ display: 'block', fontSize: 13.5 }}>
689
+ {name}
690
+ </span>
691
+ <span className="md-label-sm" style={{ display: 'block', fontSize: 11.5 }}>
692
+ {desc}
693
+ </span>
694
+ </span>
695
+ <span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
696
+ <span className="num md-label-sm" style={{ fontWeight: 600 }}>
697
+ +{price} €/mtl.
698
+ </span>
699
+ <span
700
+ style={{
701
+ width: 20,
702
+ height: 20,
703
+ borderRadius: '50%',
704
+ display: 'flex',
705
+ alignItems: 'center',
706
+ justifyContent: 'center',
707
+ background: sel[name] ? 'var(--md-primary)' : 'transparent',
708
+ border: sel[name] ? 0 : '1.5px solid var(--md-outline)',
709
+ }}
710
+ >
711
+ {sel[name] && <Icon name="check" size={13} stroke={3} color="#fff" />}
712
+ </span>
713
+ </span>
714
+ </button>
715
+ ))}
716
+ </div>
717
+ {sum > 0 && (
718
+ <div
719
+ style={{
720
+ display: 'flex',
721
+ justifyContent: 'space-between',
722
+ alignItems: 'baseline',
723
+ marginTop: 12,
724
+ paddingTop: 10,
725
+ borderTop: '1px solid var(--md-outline-variant)',
726
+ }}
727
+ >
728
+ <span className="md-label-sm">
729
+ {t('autobahn:protection.packagesSelected', { count: chosen.length })}
730
+ </span>
731
+ <span className="num" style={{ fontWeight: 700, color: 'var(--md-primary)' }}>
732
+ +{sum} €/mtl.
733
+ </span>
734
+ </div>
735
+ )}
736
+ <div className="md-label-sm" style={{ marginTop: 10, fontSize: 11.5 }}>
737
+ {t('autobahn:protection.exampleValues', { partner: 'CNP Santander Insurance' })}
738
+ </div>
739
+ </div>
740
+ );
741
+ };
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // In-page anchor nav (mobile)
745
+ // ---------------------------------------------------------------------------
746
+
747
+ /** [target element id, label]. */
748
+ export type VdpAnchor = readonly [id: string, label: string];
749
+
750
+ export interface VdpAnchorsProps {
751
+ /** anchor chips; defaults to the four VDP sections. */
752
+ items?: ReadonlyArray<VdpAnchor>;
753
+ /**
754
+ * Optional navigation handler. When provided it OWNS the scroll (e.g. the page
755
+ * in W3b). When omitted the component falls back to scrolling the page's own
756
+ * element with that id into view — intrinsic anchor-nav behaviour on the
757
+ * document, not a data source.
758
+ */
759
+ onNavigate?: (id: string) => void;
760
+ /** i18n function (drilled from the page). */
761
+ t: TranslationFunction;
762
+ }
763
+
764
+ /** the four VDP sections — [target element id, i18n label key]. */
765
+ const DEFAULT_ANCHORS: ReadonlyArray<VdpAnchor> = [
766
+ ['vdp-daten', 'autobahn:anchors.data'],
767
+ ['vdp-ausstattung', 'autobahn:anchors.equipment'],
768
+ ['vdp-preis', 'autobahn:anchors.priceMarket'],
769
+ ['vdp-check', 'autobahn:anchors.inspection'],
770
+ ];
771
+
772
+ /** Sticky in-page anchor nav (mobile) — quiet chips. */
773
+ export const VdpAnchors: React.FunctionComponent<VdpAnchorsProps> = ({ items, onNavigate, t }) => {
774
+ const anchors: ReadonlyArray<VdpAnchor> =
775
+ items ?? DEFAULT_ANCHORS.map(([id, key]) => [id, t(key)] as VdpAnchor);
776
+ const go = (id: string) => {
777
+ if (onNavigate) {
778
+ onNavigate(id);
779
+ return;
780
+ }
781
+ if (typeof document === 'undefined') return;
782
+ const el = document.getElementById(id);
783
+ if (!el) return;
784
+ let r = el.getBoundingClientRect();
785
+ if (!r.height && el.firstElementChild) r = el.firstElementChild.getBoundingClientRect();
786
+ window.scrollTo({ top: r.top + window.scrollY - 60, behavior: 'smooth' });
787
+ };
788
+ return (
789
+ <div className="vdp-anchors show-mobile" style={{ background: 'var(--md-sc-low)' }}>
790
+ {anchors.map(([id, l]) => (
791
+ <button key={id} className="chip" onClick={() => go(id)} style={{ flex: 'none' }}>
792
+ {l}
793
+ </button>
794
+ ))}
795
+ </div>
796
+ );
797
+ };
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // Desktop sticky summary bar
801
+ // ---------------------------------------------------------------------------
802
+
803
+ export interface VdpStickyBarProps {
804
+ vehicle: VehicleDetail;
805
+ /** thumbnail image URL (already resolved) for the docked bar. */
806
+ image: string;
807
+ /**
808
+ * Whether the bar is docked/visible. Controlled by the page (W3b owns the
809
+ * scroll position → show toggle), keeping this component pure — no window
810
+ * scroll listener here.
811
+ */
812
+ show: boolean;
813
+ /** primary CTA click. */
814
+ onSecure?: () => void;
815
+ /** i18n function (drilled from the page). */
816
+ t: TranslationFunction;
817
+ }
818
+
819
+ /** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
820
+ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
821
+ <div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
822
+ <div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
823
+ <img
824
+ src={image}
825
+ alt=""
826
+ style={{
827
+ width: 64,
828
+ height: 44,
829
+ objectFit: 'cover',
830
+ borderRadius: 'var(--md-r-sm)',
831
+ background: '#fff',
832
+ border: '1px solid var(--md-outline-variant)',
833
+ flex: 'none',
834
+ }}
835
+ />
836
+ <div style={{ minWidth: 0, flex: 1 }}>
837
+ <div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
838
+ {vehicle.make} {vehicle.model} · {vehicle.trim}
839
+ </div>
840
+ <div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
841
+ {fmtKm(vehicle.km)} · {vehicle.reg} · {vehicle.gear}
842
+ </div>
843
+ </div>
844
+ <div style={{ textAlign: 'right', flex: 'none' }}>
845
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
846
+ <span className="md-label-sm">{t('autobahn:sticky.from')}</span>
847
+ <span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
848
+ {vehicle.monthly} €
849
+ </span>
850
+ <span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
851
+ </div>
852
+ <div className="md-label-sm">{t('autobahn:sticky.purchasePrice', { price: eur(vehicle.price) })}</div>
853
+ </div>
854
+ <Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
855
+ {t('autobahn:sticky.secureOffer')}
856
+ </Button>
857
+ </div>
858
+ </div>
859
+ );
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Mobile sticky CTA bar
863
+ // ---------------------------------------------------------------------------
864
+
865
+ export interface VdpBottomBarProps {
866
+ vehicle: VehicleDetail;
867
+ /** primary CTA click. */
868
+ onSecure?: () => void;
869
+ /** i18n function (drilled from the page). */
870
+ t: TranslationFunction;
871
+ }
872
+
873
+ /** Mobile sticky bottom CTA bar — rate + Kaufpreis + "Angebot sichern". */
874
+ export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({ vehicle, onSecure, t }) => (
875
+ <div className="vdp-bottom-bar show-mobile">
876
+ <div style={{ flex: 1, minWidth: 0 }}>
877
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 5, whiteSpace: 'nowrap' }}>
878
+ <span className="md-label-sm">{t('autobahn:bottom.from')}</span>
879
+ <span className="num" style={{ fontWeight: 700, fontSize: 22, color: 'var(--md-primary)', letterSpacing: '-.5px' }}>
880
+ {vehicle.monthly} €
881
+ </span>
882
+ <span className="md-label-sm">{t('autobahn:bottom.perMonth')}</span>
883
+ </div>
884
+ <div className="md-label-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
885
+ {t('autobahn:bottom.purchasePrice', { price: eur(vehicle.price) })}
886
+ </div>
887
+ </div>
888
+ <Button variant="filled" icon="shield-check" style={{ flex: 'none' }} onClick={onSecure}>
889
+ {t('autobahn:bottom.secureOffer')}
890
+ </Button>
891
+ </div>
892
+ );
893
+
894
+ // ---------------------------------------------------------------------------
895
+ // 130-Punkte-Check block
896
+ // ---------------------------------------------------------------------------
897
+
898
+ export interface InspectionBlockProps {
899
+ /** pass categories; e.g. ['Karosserie & Lack', 'Motor & Antrieb', …]. */
900
+ categories: string[];
901
+ /** i18n function (drilled from the page). */
902
+ t: TranslationFunction;
903
+ }
904
+
905
+ /** 130-Punkte-Check block — the auto.de inspection promise, all categories pass. */
906
+ export const InspectionBlock: React.FunctionComponent<InspectionBlockProps> = ({ categories, t }) => (
907
+ <div className="mcard mcard--filled" id="vdp-check" style={{ padding: 22 }}>
908
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
909
+ <Icon name="badge-check" size={24} color="var(--md-primary)" />
910
+ <h2 className="md-headline-sm">{t('autobahn:inspection.passed')}</h2>
911
+ </div>
912
+ <div className="eq-cols">
913
+ {categories.map((c) => (
914
+ <div
915
+ key={c}
916
+ style={{
917
+ display: 'flex',
918
+ alignItems: 'center',
919
+ gap: 9,
920
+ fontSize: 13.5,
921
+ color: 'var(--md-on-surface-variant)',
922
+ marginBottom: 10,
923
+ }}
924
+ >
925
+ <Icon name="check-circle" size={18} color="var(--md-good)" stroke={2} style={{ flex: 'none' }} />
926
+ {c}
927
+ </div>
928
+ ))}
929
+ </div>
930
+ </div>
931
+ );
932
+
933
+ // ---------------------------------------------------------------------------
934
+ // Finanzierungs-/Leasingdetails popup (desktop modal / mobile bottom-sheet)
935
+ // ---------------------------------------------------------------------------
936
+
937
+ export interface FinanceDetailsModalProps {
938
+ /** open flag (controlled by the page). */
939
+ open: boolean;
940
+ /** active payment mode — drives labels + which legal example shows. */
941
+ mode: 'fin' | 'leasing' | 'cash';
942
+ price: number;
943
+ monthly: number;
944
+ apr: number;
945
+ down: number;
946
+ term: number;
947
+ /** leasing annual mileage (km/yr), shown only in leasing mode. */
948
+ annualMileage?: number;
949
+ /** REAL figures from the financing config (NOT computed — balloon/Schlussrate makes monthly·term wrong). */
950
+ netLoanAmount?: number;
951
+ /** Schlussrate (balloon final installment); shown only when > 0. */
952
+ finalInstallment?: number;
953
+ /** Gesamt(rückzahlungs)betrag from the config; shown only when > 0 (never the wrong monthly·term). */
954
+ grossAmount?: number;
955
+ /** Sollzinssatz p.a. (%); shown only when > 0. */
956
+ nominalRate?: number;
957
+ /** slider bounds — when the change handlers are supplied, the modal shows adjustment sliders. */
958
+ downMin?: number;
959
+ downMax?: number;
960
+ downStep?: number;
961
+ termMin?: number;
962
+ termMax?: number;
963
+ termStep?: number;
964
+ /** discrete selectable terms (months) for the Laufzeit chips. */
965
+ termOptions?: number[];
966
+ annualMileageMin?: number;
967
+ annualMileageMax?: number;
968
+ annualMileageStep?: number;
969
+ /** slider change handlers (same as the calculator); they recompute the rate via the page. */
970
+ onDownChange?: (down: number) => void;
971
+ onTermChange?: (term: number) => void;
972
+ onAnnualMileageChange?: (km: number) => void;
973
+ onClose: () => void;
974
+ /** i18n function (drilled from the page). */
975
+ t: TranslationFunction;
976
+ }
977
+
978
+ /**
979
+ * Full payment breakdown + legal example, opened from the calculator's
980
+ * "Finanzierungsdetails" / "Leasingdetails" link (matches production, which uses a popup).
981
+ * Desktop = centered modal, mobile = bottom-sheet (CSS). All figures are derived from the
982
+ * live calculator props (no fabricated Sollzins/Schlussrate — only what we can compute).
983
+ */
984
+ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalProps> = ({
985
+ open,
986
+ mode,
987
+ price,
988
+ monthly,
989
+ apr,
990
+ down,
991
+ term,
992
+ annualMileage,
993
+ netLoanAmount,
994
+ finalInstallment,
995
+ grossAmount,
996
+ nominalRate,
997
+ downMin,
998
+ downMax,
999
+ downStep = 100,
1000
+ termMin,
1001
+ termMax,
1002
+ termStep = 6,
1003
+ termOptions,
1004
+ annualMileageMin = 5000,
1005
+ annualMileageMax = 60000,
1006
+ annualMileageStep = 5000,
1007
+ onDownChange,
1008
+ onTermChange,
1009
+ onAnnualMileageChange,
1010
+ onClose,
1011
+ t,
1012
+ }) => {
1013
+ if (!open) return null;
1014
+ const isLease = mode === 'leasing';
1015
+ const title = isLease ? t('autobahn:modal.leasingDetails') : t('autobahn:modal.financingDetails');
1016
+ // adjustable only for fin/leasing, and only when the page supplies the change handlers + bounds.
1017
+ const adjustable = mode !== 'cash' && !!onDownChange && downMin != null && downMax != null && termMin != null && termMax != null;
1018
+ // discrete term chip values (supplied periods, else derived from the bounds).
1019
+ const termVals =
1020
+ termOptions && termOptions.length
1021
+ ? termOptions
1022
+ : (() => {
1023
+ const out: number[] = [];
1024
+ for (let v = termMin as number; v <= (termMax as number); v += termStep || 12) out.push(v);
1025
+ return out.length ? out : [term];
1026
+ })();
1027
+ const pct = (n: number) => `${n.toFixed(2).replace('.', ',')} %`;
1028
+ // Only REAL figures (from the financing config). Balloon financing means monthly·term is NOT the
1029
+ // total, so Schlussrate + Gesamtbetrag come from the config and are omitted when unavailable —
1030
+ // never a fabricated/misleading total.
1031
+ const netto = netLoanAmount != null ? netLoanAmount : Math.max(0, price - down);
1032
+ const rows: Array<[string, string]> = [
1033
+ [t('autobahn:modal.vehiclePrice'), `${eur(price)} €`],
1034
+ [isLease ? t('autobahn:modal.leasingAmount') : t('autobahn:modal.netLoanAmount'), `${eur(netto)} €`],
1035
+ [t('autobahn:modal.downPayment'), `${eur(down)} €`],
1036
+ ...(isLease && annualMileage != null ? ([[t('autobahn:modal.annualMileage'), `${eur(annualMileage)} km`]] as Array<[string, string]>) : []),
1037
+ [t('autobahn:modal.term'), t('autobahn:modal.months', { months: term, count: term })],
1038
+ ...(nominalRate != null && nominalRate > 0 ? ([[t('autobahn:modal.nominalRate'), pct(nominalRate)]] as Array<[string, string]>) : []),
1039
+ [t('autobahn:modal.effectiveRate'), pct(apr)],
1040
+ ...(finalInstallment != null && finalInstallment > 0 ? ([[t('autobahn:modal.finalInstallment'), `${eur(finalInstallment)} €`]] as Array<[string, string]>) : []),
1041
+ [isLease ? t('autobahn:modal.leasingRate') : t('autobahn:modal.monthlyRate'), t('autobahn:modal.ratePerMonth', { amount: eur(monthly) })],
1042
+ ...(grossAmount != null && grossAmount > 0 ? ([[isLease ? t('autobahn:modal.totalAmount') : t('autobahn:modal.totalRepayment'), `${eur(grossAmount)} €`]] as Array<[string, string]>) : []),
1043
+ ];
1044
+ return (
1045
+ <div className="fin-modal-scrim" onClick={onClose}>
1046
+ <div className="fin-modal" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
1047
+ <div className="fin-modal-head">
1048
+ <h2 className="md-headline-sm" style={{ margin: 0 }}>
1049
+ {title}
1050
+ </h2>
1051
+ <button className="icon-btn" onClick={onClose} aria-label={t('autobahn:modal.close')}>
1052
+ <Icon name="x" size={20} />
1053
+ </button>
1054
+ </div>
1055
+ <div className="fin-modal-body">
1056
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 14 }}>
1057
+ <span className="num" style={{ fontSize: 34, fontWeight: 800, color: 'var(--md-primary)', letterSpacing: '-1px' }}>
1058
+ {eur(monthly)} €
1059
+ </span>
1060
+ <span className="md-label-sm">{t('autobahn:finance.perMonth')}</span>
1061
+ </div>
1062
+ {/* adjustment sliders — change a value and every figure below recomputes live (via the page) */}
1063
+ {adjustable && (
1064
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginBottom: 18 }}>
1065
+ <div>
1066
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
1067
+ <span className="md-title-sm">{t('autobahn:finance.downPaymentSliderLabel')}</span>
1068
+ <span className="num" style={{ fontWeight: 700 }}>
1069
+ {eur(down)} €
1070
+ </span>
1071
+ </div>
1072
+ <Slider
1073
+ min={downMin as number}
1074
+ max={downMax as number}
1075
+ step={downStep}
1076
+ value={down}
1077
+ onChange={onDownChange as (n: number) => void}
1078
+ fmt={(v) => `${eur(Number(v))} €`}
1079
+ />
1080
+ </div>
1081
+ {isLease && annualMileage != null && onAnnualMileageChange && (
1082
+ <div>
1083
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
1084
+ <span className="md-title-sm">{t('autobahn:finance.annualMileageLabel')}</span>
1085
+ <span className="num" style={{ fontWeight: 700 }}>
1086
+ {eur(annualMileage)} km
1087
+ </span>
1088
+ </div>
1089
+ <Slider
1090
+ min={annualMileageMin}
1091
+ max={annualMileageMax}
1092
+ step={annualMileageStep}
1093
+ value={annualMileage}
1094
+ onChange={onAnnualMileageChange}
1095
+ fmt={(v) => `${eur(Number(v))} km`}
1096
+ />
1097
+ </div>
1098
+ )}
1099
+ <div>
1100
+ <div className="md-title-sm" style={{ marginBottom: 8 }}>
1101
+ {t('autobahn:finance.termSelection')}
1102
+ </div>
1103
+ <TermChips options={termVals} value={term} onChange={onTermChange as (n: number) => void} t={t} />
1104
+ </div>
1105
+ </div>
1106
+ )}
1107
+ {rows.map(([l, v], i) => (
1108
+ <div
1109
+ key={l}
1110
+ style={{
1111
+ display: 'flex',
1112
+ justifyContent: 'space-between',
1113
+ gap: 12,
1114
+ padding: '9px 0',
1115
+ borderBottom: i < rows.length - 1 ? '1px solid var(--md-outline-variant)' : 0,
1116
+ }}
1117
+ >
1118
+ <span className="md-label-sm">{l}</span>
1119
+ <span className="num" style={{ fontSize: 13.5, fontWeight: 600 }}>
1120
+ {v}
1121
+ </span>
1122
+ </div>
1123
+ ))}
1124
+ <p className="md-label-sm" style={{ marginTop: 16, lineHeight: 1.55 }}>
1125
+ {isLease ? t('autobahn:modal.legalLeasing') : t('autobahn:modal.legalFinancing')}
1126
+ </p>
1127
+ </div>
1128
+ </div>
1129
+ </div>
1130
+ );
1131
+ };
1132
+
1133
+ // ---------------------------------------------------------------------------
1134
+ // Customer ratings (auto.de Google reviews) — REAL data, loaded by the page
1135
+ // ---------------------------------------------------------------------------
1136
+
1137
+ export interface CustomerRatingsProps {
1138
+ /** the loaded reviews (REAL — from the WP json-quotes source). */
1139
+ reviews: VdpReview[];
1140
+ /** i18n function (drilled from the page). */
1141
+ t: TranslationFunction;
1142
+ }
1143
+
1144
+ /**
1145
+ * Customer-rating section (the legacy VDP's "Kundenmeinungen", rebuilt on the
1146
+ * Autobahn card). Shows the auto.de average (DERIVED from the loaded reviews — not
1147
+ * invented) + a grid of review cards (avatar, name, stars, quote). The reviews are
1148
+ * the SAME real source the legacy CustomerQuotes uses (auto.de Google reviews via
1149
+ * the WordPress json-quotes feed); the page loads them and passes them in. When no
1150
+ * reviews load, the page renders nothing (no fabricated content).
1151
+ */
1152
+ export const CustomerRatings: React.FunctionComponent<CustomerRatingsProps> = ({ reviews, t }) => {
1153
+ const [showAll, setShowAll] = useState(false);
1154
+ if (!reviews || !reviews.length) return null;
1155
+ const count = reviews.length;
1156
+ const avg = reviews.reduce((s, r) => s + (r.rating || 0), 0) / count;
1157
+ const shown = showAll ? reviews : reviews.slice(0, 4);
1158
+ return (
1159
+ <section className="mcard" id="vdp-bewertungen" style={{ padding: 22, marginTop: 44 }}>
1160
+ <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
1161
+ <h2 className="md-headline-sm" style={{ margin: 0 }}>
1162
+ {t('autobahn:reviews.title')}
1163
+ </h2>
1164
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' }}>
1165
+ <span className="num" style={{ fontWeight: 800, fontSize: 26, color: 'var(--md-on-surface)', letterSpacing: '-.5px' }}>
1166
+ {avg.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
1167
+ </span>
1168
+ <span>
1169
+ <Stars value={avg} size={16} />
1170
+ <span className="md-label-sm" style={{ display: 'block' }}>
1171
+ {t('autobahn:reviews.ratingCount', { count })} · via Google
1172
+ </span>
1173
+ </span>
1174
+ </div>
1175
+ </div>
1176
+ <div className="eq-cols">
1177
+ {shown.map((r) => (
1178
+ <div
1179
+ key={r.id}
1180
+ className="mcard mcard--filled"
1181
+ style={{ breakInside: 'avoid', marginBottom: 16, padding: 16 }}
1182
+ >
1183
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
1184
+ {r.image ? (
1185
+ <img
1186
+ src={r.image}
1187
+ alt=""
1188
+ loading="lazy"
1189
+ style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', flex: 'none', background: 'var(--md-sc)' }}
1190
+ />
1191
+ ) : (
1192
+ <span
1193
+ style={{
1194
+ width: 40,
1195
+ height: 40,
1196
+ borderRadius: '50%',
1197
+ flex: 'none',
1198
+ background: 'var(--md-primary-container)',
1199
+ color: 'var(--md-on-primary-container)',
1200
+ display: 'flex',
1201
+ alignItems: 'center',
1202
+ justifyContent: 'center',
1203
+ fontWeight: 700,
1204
+ }}
1205
+ >
1206
+ {(r.name || '?').charAt(0).toUpperCase()}
1207
+ </span>
1208
+ )}
1209
+ <div style={{ minWidth: 0 }}>
1210
+ <div className="md-title-sm" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
1211
+ {r.name}
1212
+ </div>
1213
+ <Stars value={r.rating} />
1214
+ </div>
1215
+ </div>
1216
+ <p className="md-body-sm" style={{ margin: 0, color: 'var(--md-on-surface-variant)' }}>
1217
+ {r.review}
1218
+ </p>
1219
+ </div>
1220
+ ))}
1221
+ </div>
1222
+ {reviews.length > 4 && (
1223
+ <button className="mbtn mbtn--text" style={{ marginTop: 4 }} onClick={() => setShowAll((a) => !a)}>
1224
+ {showAll ? t('autobahn:ratings.showLess') : t('autobahn:ratings.showMore', { count })}
1225
+ <Icon name={showAll ? 'chevron-up' : 'chevron-down'} size={16} />
1226
+ </button>
1227
+ )}
1228
+ </section>
1229
+ );
1230
+ };
1231
+
1232
+ // ---------------------------------------------------------------------------
1233
+ // Enquiry block — low-friction "Frage zum Fahrzeug" lead form
1234
+ // ---------------------------------------------------------------------------
1235
+
1236
+ export interface VdpEnquiryProps {
1237
+ /** car title for the prefilled message + heading context. */
1238
+ carTitle: string;
1239
+ /** auto.de-ID reference, woven into the prefilled message when present. */
1240
+ autoDeId?: string;
1241
+ /** sales hotline shown in the aside (tap-to-call). */
1242
+ salesPhone?: string;
1243
+ /**
1244
+ * Submit handler — the page wires this to the REAL contact channel
1245
+ * (carStore.submitContactForm). Returns a promise; the form shows a thank-you on
1246
+ * resolve and an inline error on reject.
1247
+ */
1248
+ onSubmit: (data: { name: string; email: string; phone: string; message: string }) => Promise<void> | void;
1249
+ /** i18n function (drilled from the page). */
1250
+ t: TranslationFunction;
1251
+ }
1252
+
1253
+ const enquiryInput: React.CSSProperties = {
1254
+ width: '100%',
1255
+ height: 46,
1256
+ padding: '0 14px',
1257
+ borderRadius: 'var(--md-r-sm)',
1258
+ border: '1px solid var(--md-outline-variant)',
1259
+ background: 'var(--md-sc-lowest)',
1260
+ color: 'var(--md-on-surface)',
1261
+ font: 'inherit',
1262
+ fontSize: 14,
1263
+ };
1264
+
1265
+ /** field with a small floating-style label above the input. */
1266
+ const EnquiryField: React.FunctionComponent<{
1267
+ label: string;
1268
+ children: React.ReactNode;
1269
+ }> = ({ label, children }) => (
1270
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
1271
+ <span className="md-label-sm" style={{ fontWeight: 600 }}>
1272
+ {label}
1273
+ </span>
1274
+ {children}
1275
+ </label>
1276
+ );
1277
+
1278
+ /** [icon, i18n label key] — resolved against the drilled t in the component. */
1279
+ const ENQUIRY_BENEFITS: ReadonlyArray<[string, string]> = [
1280
+ ['clock', 'autobahn:enquiry.benefits.fastResponse'],
1281
+ ['shield-check', 'autobahn:enquiry.benefits.freeAndNonBinding'],
1282
+ ['hand-coins', 'autobahn:enquiry.benefits.financingAdvice'],
1283
+ ];
1284
+
1285
+ /**
1286
+ * Vehicle enquiry — an enriched two-pane lead block: a left trust/benefits aside (persönliche
1287
+ * Beratung, response time, hotline) and a right structured form (preferred-contact toggle, named
1288
+ * fields, editable prefilled message), wired to the REAL submit (carStore.submitContactForm).
1289
+ */
1290
+ export const VdpEnquiry: React.FunctionComponent<VdpEnquiryProps> = ({
1291
+ carTitle,
1292
+ autoDeId,
1293
+ salesPhone = '0800 40 30 182',
1294
+ onSubmit,
1295
+ t,
1296
+ }) => {
1297
+ const [name, setName] = useState('');
1298
+ const [email, setEmail] = useState('');
1299
+ const [phone, setPhone] = useState('');
1300
+ const [pref, setPref] = useState<'email' | 'phone'>('email');
1301
+ const [message, setMessage] = useState(
1302
+ autoDeId
1303
+ ? t('autobahn:enquiry.prefilledMessageWithId', { carTitle, autoDeId })
1304
+ : t('autobahn:enquiry.prefilledMessage', { carTitle })
1305
+ );
1306
+ const [sending, setSending] = useState(false);
1307
+ const [sent, setSent] = useState(false);
1308
+ const [error, setError] = useState('');
1309
+
1310
+ const emailOk = /.+@.+\..+/.test(email);
1311
+ const phoneOk = phone.replace(/\D/g, '').length >= 6;
1312
+ // the preferred channel is required; the other stays optional.
1313
+ const valid = name.trim().length > 1 && (pref === 'email' ? emailOk : phoneOk);
1314
+
1315
+ const submit = async (): Promise<void> => {
1316
+ if (!valid || sending) return;
1317
+ setSending(true);
1318
+ setError('');
1319
+ const note = t('autobahn:enquiry.preferredContact', {
1320
+ channel: pref === 'email' ? t('autobahn:enquiry.contactMethod.email') : t('autobahn:enquiry.contactMethod.phone'),
1321
+ });
1322
+ try {
1323
+ await onSubmit({ name: name.trim(), email: email.trim(), phone: phone.trim(), message: message.trim() + note });
1324
+ setSent(true);
1325
+ } catch (e) {
1326
+ setError(t('autobahn:enquiry.form.sendError'));
1327
+ } finally {
1328
+ setSending(false);
1329
+ }
1330
+ };
1331
+
1332
+ if (sent) {
1333
+ return (
1334
+ <section className="mcard mcard--filled" id="vdp-kontakt" style={{ padding: 22, marginTop: 24 }}>
1335
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1336
+ <Icon name="check-circle" size={24} color="var(--md-good)" />
1337
+ <div>
1338
+ <div className="md-title-sm">{t('autobahn:enquiry.success.title')}</div>
1339
+ <div className="md-body-sm">{t('autobahn:enquiry.success.message')}</div>
1340
+ </div>
1341
+ </div>
1342
+ </section>
1343
+ );
1344
+ }
1345
+
1346
+ return (
1347
+ <section className="mcard" id="vdp-kontakt" style={{ padding: 0, overflow: 'hidden', marginTop: 24 }}>
1348
+ <div className="enquiry-grid">
1349
+ {/* trust / benefits aside */}
1350
+ <aside className="enquiry-aside">
1351
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
1352
+ <span className="enquiry-ico">
1353
+ <Icon name="headset" size={20} color="var(--md-on-primary-container)" />
1354
+ </span>
1355
+ <h2 className="md-title" style={{ margin: 0, fontSize: 19 }}>
1356
+ {t('autobahn:enquiry.personalConsultation')}
1357
+ </h2>
1358
+ </div>
1359
+ <p className="md-body-sm" style={{ marginBottom: 16 }}>
1360
+ {t('autobahn:enquiry.aside.description')}
1361
+ </p>
1362
+ <ul className="enquiry-benefits">
1363
+ {ENQUIRY_BENEFITS.map(([ic, key]) => (
1364
+ <li key={key}>
1365
+ <Icon name={ic} size={17} color="var(--md-primary)" stroke={2} />
1366
+ {t(key)}
1367
+ </li>
1368
+ ))}
1369
+ </ul>
1370
+ <a className="enquiry-phone" href={`tel:${salesPhone.replace(/\s/g, '')}`}>
1371
+ <Icon name="phone" size={16} />
1372
+ {salesPhone}
1373
+ </a>
1374
+ </aside>
1375
+
1376
+ {/* form */}
1377
+ <div className="enquiry-form">
1378
+ <div style={{ marginBottom: 14 }}>
1379
+ <div className="md-label-sm" style={{ fontWeight: 600, marginBottom: 7 }}>
1380
+ {t('autobahn:enquiry.contactMethod.heading')}
1381
+ </div>
1382
+ <div style={{ display: 'flex', gap: 8 }}>
1383
+ <button
1384
+ type="button"
1385
+ className={'chip' + (pref === 'email' ? ' chip--filter chip--selected' : '')}
1386
+ onClick={() => setPref('email')}
1387
+ >
1388
+ <Icon name="mail" size={15} />
1389
+ {t('autobahn:enquiry.contactMethod.email')}
1390
+ </button>
1391
+ <button
1392
+ type="button"
1393
+ className={'chip' + (pref === 'phone' ? ' chip--filter chip--selected' : '')}
1394
+ onClick={() => setPref('phone')}
1395
+ >
1396
+ <Icon name="phone" size={15} />
1397
+ {t('autobahn:enquiry.contactMethod.phone')}
1398
+ </button>
1399
+ </div>
1400
+ </div>
1401
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: 12 }}>
1402
+ <EnquiryField label={t('autobahn:enquiry.form.nameLabel')}>
1403
+ <input style={enquiryInput} placeholder={t('autobahn:enquiry.form.namePlaceholder')} value={name} onChange={(e) => setName(e.target.value)} aria-label={t('autobahn:enquiry.form.nameAria')} />
1404
+ </EnquiryField>
1405
+ <EnquiryField label={pref === 'email' ? t('autobahn:enquiry.form.emailLabelRequired') : t('autobahn:enquiry.form.emailLabel')}>
1406
+ <input style={enquiryInput} type="email" placeholder={t('autobahn:enquiry.form.emailPlaceholder')} value={email} onChange={(e) => setEmail(e.target.value)} aria-label={t('autobahn:enquiry.contactMethod.email')} />
1407
+ </EnquiryField>
1408
+ <EnquiryField label={pref === 'phone' ? t('autobahn:enquiry.form.phoneLabelRequired') : t('autobahn:enquiry.form.phoneLabel')}>
1409
+ <input style={enquiryInput} type="tel" placeholder={t('autobahn:enquiry.form.phonePlaceholder')} value={phone} onChange={(e) => setPhone(e.target.value)} aria-label={t('autobahn:enquiry.contactMethod.phone')} />
1410
+ </EnquiryField>
1411
+ </div>
1412
+ <div style={{ marginTop: 12 }}>
1413
+ <EnquiryField label={t('autobahn:enquiry.form.messageLabel')}>
1414
+ <textarea
1415
+ style={{ ...enquiryInput, height: 92, padding: '10px 14px', resize: 'vertical' }}
1416
+ value={message}
1417
+ onChange={(e) => setMessage(e.target.value)}
1418
+ aria-label={t('autobahn:enquiry.form.messageAria')}
1419
+ />
1420
+ </EnquiryField>
1421
+ </div>
1422
+ {error && (
1423
+ <div className="md-label-sm" style={{ color: 'var(--md-error)', marginTop: 8 }}>
1424
+ {error}
1425
+ </div>
1426
+ )}
1427
+ <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginTop: 16, flexWrap: 'wrap' }}>
1428
+ <Button variant="filled" size="lg" icon="send" disabled={!valid || sending} onClick={submit}>
1429
+ {sending ? t('autobahn:enquiry.form.sending') : t('autobahn:enquiry.sendButton')}
1430
+ </Button>
1431
+ <span className="md-label-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
1432
+ <Icon name="lock" size={13} />
1433
+ {t('autobahn:enquiry.security')}
1434
+ </span>
1435
+ </div>
1436
+ </div>
1437
+ </div>
1438
+ </section>
1439
+ );
1440
+ };
1441
+
1442
+ // ---------------------------------------------------------------------------
1443
+ // "So einfach geht's" steps
1444
+ // ---------------------------------------------------------------------------
1445
+
1446
+ /** [title, description]. */
1447
+ export type HowItWorksStep = readonly [title: string, desc: string];
1448
+
1449
+ export interface HowItWorksProps {
1450
+ /** the numbered steps; defaults to the design's four-step flow. */
1451
+ steps?: ReadonlyArray<HowItWorksStep>;
1452
+ /** i18n function (drilled from the page). */
1453
+ t: TranslationFunction;
1454
+ }
1455
+
1456
+ /** Lucide icon per step index: Angebot / Beratung / Bezahlung / Übergabe. */
1457
+ const STEP_ICONS = ['bookmark', 'message-circle', 'credit-card', 'truck'] as const;
1458
+
1459
+ /** the design's four-step flow — [title key, description key]. */
1460
+ const DEFAULT_STEPS: ReadonlyArray<HowItWorksStep> = [
1461
+ ['autobahn:steps.step1.title', 'autobahn:steps.step1.description'],
1462
+ ['autobahn:steps.step2.title', 'autobahn:steps.step2.description'],
1463
+ ['autobahn:steps.step3.title', 'autobahn:steps.step3.description'],
1464
+ ['autobahn:steps.step4.title', 'autobahn:steps.step4.description'],
1465
+ ];
1466
+
1467
+ /** "So einfach geht's" — icon-step cards with connector line (professional redesign). */
1468
+ export const HowItWorks: React.FunctionComponent<HowItWorksProps> = ({ steps, t }) => {
1469
+ const items: ReadonlyArray<HowItWorksStep> =
1470
+ steps ?? DEFAULT_STEPS.map(([titleKey, descKey]) => [t(titleKey), t(descKey)] as HowItWorksStep);
1471
+ return (
1472
+ <section className="hiw-section" style={{ marginTop: 44 }}>
1473
+ <h2 className="md-headline-sm" style={{ marginBottom: 28, textAlign: 'center' }}>
1474
+ {t('autobahn:howItWorks.title')}
1475
+ </h2>
1476
+ <div className="steps-4 hiw-grid">
1477
+ {items.map(([title, desc], i) => (
1478
+ <div key={title} className="hiw-step">
1479
+ <div className="hiw-icon-wrap">
1480
+ <Icon name={STEP_ICONS[i] || 'star'} size={24} color="#fff" stroke={1.8} />
1481
+ <span className="hiw-num">{i + 1}</span>
1482
+ </div>
1483
+ <div className="hiw-title">{title}</div>
1484
+ <p className="hiw-desc">{desc}</p>
1485
+ </div>
1486
+ ))}
1487
+ </div>
1488
+ </section>
1489
+ );
1490
+ };