acinguiux-preact-components 0.0.1

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 (313) hide show
  1. package/package.json +56 -0
  2. package/src/content/themes/theme-acinguiux-amg/theme-acinguiux-amg.css +23 -0
  3. package/src/content/themes/theme-acinguiux-cafe/theme-acinguiux-cafe.css +47 -0
  4. package/src/content/themes/theme-acinguiux-energy/theme-acinguiux-energy.css +45 -0
  5. package/src/content/themes/theme-acinguiux-livewire/theme-acinguiux-livewire.css +22 -0
  6. package/src/content/themes/theme-acinguiux-livewire-italy/theme-acinguiux-livewire-italy.css +22 -0
  7. package/src/content/themes/theme-acinguiux-recharge/theme-acinguiux-recharge.css +49 -0
  8. package/src/content/themes/theme-allon/theme-allon.css +25 -0
  9. package/src/content/themes/theme-atlas/theme-atlas.css +31 -0
  10. package/src/content/themes/theme-aurvana/resources/favicon/apple-touch-icon.png +0 -0
  11. package/src/content/themes/theme-aurvana/resources/favicon/favico.ico +0 -0
  12. package/src/content/themes/theme-aurvana/resources/favicon/favicon-96x96.png +0 -0
  13. package/src/content/themes/theme-aurvana/resources/favicon/favicon.ico +0 -0
  14. package/src/content/themes/theme-aurvana/resources/favicon/favicon.png +0 -0
  15. package/src/content/themes/theme-aurvana/resources/favicon/favicon.svg +13 -0
  16. package/src/content/themes/theme-aurvana/resources/favicon/google-touch-icon.png +0 -0
  17. package/src/content/themes/theme-aurvana/resources/favicon/manifest.json +14 -0
  18. package/src/content/themes/theme-aurvana/resources/favicon/site.webmanifest +21 -0
  19. package/src/content/themes/theme-aurvana/resources/favicon/web-app-manifest-192x192.png +0 -0
  20. package/src/content/themes/theme-aurvana/resources/favicon/web-app-manifest-512x512.png +0 -0
  21. package/src/content/themes/theme-aurvana/theme-aurvana.css +49 -0
  22. package/src/content/themes/theme-base/theme-base.css +49 -0
  23. package/src/content/themes/theme-base2/resources/favicon/android-chrome-192x192.png +0 -0
  24. package/src/content/themes/theme-base2/resources/favicon/android-chrome-512x512.png +0 -0
  25. package/src/content/themes/theme-base2/resources/favicon/apple-touch-icon.png +0 -0
  26. package/src/content/themes/theme-base2/resources/favicon/favico.ico +0 -0
  27. package/src/content/themes/theme-base2/resources/favicon/favicon-16x16.png +0 -0
  28. package/src/content/themes/theme-base2/resources/favicon/favicon-32x32.png +0 -0
  29. package/src/content/themes/theme-base2/resources/favicon/favicon-96x96.png +0 -0
  30. package/src/content/themes/theme-base2/resources/favicon/favicon.ico +0 -0
  31. package/src/content/themes/theme-base2/resources/favicon/favicon.png +0 -0
  32. package/src/content/themes/theme-base2/resources/favicon/favicon.svg +9 -0
  33. package/src/content/themes/theme-base2/resources/favicon/google-touch-icon.png +0 -0
  34. package/src/content/themes/theme-base2/resources/favicon/manifest.json +14 -0
  35. package/src/content/themes/theme-base2/resources/favicon/site.webmanifest +1 -0
  36. package/src/content/themes/theme-base2/resources/favicon/web-app-manifest-192x192.png +0 -0
  37. package/src/content/themes/theme-base2/resources/favicon/web-app-manifest-512x512.png +0 -0
  38. package/src/content/themes/theme-base2/resources/fonts/acinguiux-typeface-la-heavy-221208.woff2 +0 -0
  39. package/src/content/themes/theme-base2/theme-base2.css +47 -0
  40. package/src/content/themes/theme-eco-marathon/theme-eco-marathon.css +22 -0
  41. package/src/content/themes/theme-energy-transition-campus-amsterdam/theme-energy-transition-campus-amsterdam.css +26 -0
  42. package/src/content/themes/theme-evpass/theme-evpass.css +46 -0
  43. package/src/content/themes/theme-nam-2025/resources/favicon/apple-touch-icon.png +0 -0
  44. package/src/content/themes/theme-nam-2025/resources/favicon/favico.ico +0 -0
  45. package/src/content/themes/theme-nam-2025/resources/favicon/favicon-96x96.png +0 -0
  46. package/src/content/themes/theme-nam-2025/resources/favicon/favicon.ico +0 -0
  47. package/src/content/themes/theme-nam-2025/resources/favicon/favicon.png +0 -0
  48. package/src/content/themes/theme-nam-2025/resources/favicon/favicon.svg +9 -0
  49. package/src/content/themes/theme-nam-2025/resources/favicon/google-touch-icon.png +0 -0
  50. package/src/content/themes/theme-nam-2025/resources/favicon/manifest.json +14 -0
  51. package/src/content/themes/theme-nam-2025/resources/favicon/site.webmanifest +21 -0
  52. package/src/content/themes/theme-nam-2025/resources/favicon/web-app-manifest-192x192.png +0 -0
  53. package/src/content/themes/theme-nam-2025/resources/favicon/web-app-manifest-512x512.png +0 -0
  54. package/src/content/themes/theme-nam-2025/theme-nam-2025.css +47 -0
  55. package/src/content/themes/theme-pennzoil/theme-pennzoil.css +36 -0
  56. package/src/content/themes/theme-quaker-state/theme-quaker-state.css +63 -0
  57. package/src/content/themes/theme-tafawoq/theme-tafawoq.css +26 -0
  58. package/src/content/themes/theme-vegetable/resources/favicon/apple-touch-icon.png +0 -0
  59. package/src/content/themes/theme-vegetable/resources/favicon/favico.ico +0 -0
  60. package/src/content/themes/theme-vegetable/resources/favicon/favicon-96x96.png +0 -0
  61. package/src/content/themes/theme-vegetable/resources/favicon/favicon.ico +0 -0
  62. package/src/content/themes/theme-vegetable/resources/favicon/favicon.png +0 -0
  63. package/src/content/themes/theme-vegetable/resources/favicon/favicon.svg +13 -0
  64. package/src/content/themes/theme-vegetable/resources/favicon/google-touch-icon.png +0 -0
  65. package/src/content/themes/theme-vegetable/resources/favicon/manifest.json +14 -0
  66. package/src/content/themes/theme-vegetable/resources/favicon/site.webmanifest +21 -0
  67. package/src/content/themes/theme-vegetable/resources/favicon/web-app-manifest-192x192.png +0 -0
  68. package/src/content/themes/theme-vegetable/resources/favicon/web-app-manifest-512x512.png +0 -0
  69. package/src/content/themes/theme-vegetable/theme-vegetable.css +49 -0
  70. package/src/content/themes/theme-zeolyst/resources/fonts/type-ar-medium.woff2 +0 -0
  71. package/src/content/themes/theme-zeolyst/theme-zeolyst.css +29 -0
  72. package/src/main/atoms/audio.js +16 -0
  73. package/src/main/atoms/box.js +5 -0
  74. package/src/main/atoms/button.js +40 -0
  75. package/src/main/atoms/card.js +22 -0
  76. package/src/main/atoms/form.js +30 -0
  77. package/src/main/atoms/heading.js +17 -0
  78. package/src/main/atoms/icon.js +24 -0
  79. package/src/main/atoms/img.js +131 -0
  80. package/src/main/atoms/input.js +55 -0
  81. package/src/main/atoms/link-text.js +21 -0
  82. package/src/main/atoms/link.js +60 -0
  83. package/src/main/atoms/list.js +12 -0
  84. package/src/main/atoms/logo.js +9 -0
  85. package/src/main/atoms/menu.js +10 -0
  86. package/src/main/atoms/message.js +5 -0
  87. package/src/main/atoms/nav-link.js +49 -0
  88. package/src/main/atoms/popup.js +47 -0
  89. package/src/main/atoms/rich-text.js +128 -0
  90. package/src/main/atoms/scroller.js +224 -0
  91. package/src/main/atoms/svg.js +65 -0
  92. package/src/main/atoms/table.js +32 -0
  93. package/src/main/atoms/textarea.js +10 -0
  94. package/src/main/atoms/time.js +12 -0
  95. package/src/main/atoms/video.js +100 -0
  96. package/src/main/export-main.js +12 -0
  97. package/src/main/export-matter.js +86 -0
  98. package/src/main/export-preact-hooks.js +1 -0
  99. package/src/main/export-preact.js +1 -0
  100. package/src/main/index.js +13 -0
  101. package/src/main/molecules/asset.js +23 -0
  102. package/src/main/molecules/glossary.js +44 -0
  103. package/src/main/molecules/links.js +23 -0
  104. package/src/main/molecules/promo-text.js +27 -0
  105. package/src/main/molecules/tags.js +15 -0
  106. package/src/main/molecules/tree.js +51 -0
  107. package/src/main/organisms/accordion-item.js +106 -0
  108. package/src/main/organisms/author.js +29 -0
  109. package/src/main/organisms/breadcrumb.js +69 -0
  110. package/src/main/organisms/call-to-action.js +24 -0
  111. package/src/main/organisms/carousel.js +178 -0
  112. package/src/main/organisms/cart-item.js +156 -0
  113. package/src/main/organisms/cart.js +162 -0
  114. package/src/main/organisms/contact-form.js +141 -0
  115. package/src/main/organisms/container/ab-test.js +47 -0
  116. package/src/main/organisms/container/default.js +6 -0
  117. package/src/main/organisms/container/filtered-section.js +293 -0
  118. package/src/main/organisms/container/footer.js +12 -0
  119. package/src/main/organisms/container/grid.js +44 -0
  120. package/src/main/organisms/container/header.js +13 -0
  121. package/src/main/organisms/container/list.js +7 -0
  122. package/src/main/organisms/container/main.js +6 -0
  123. package/src/main/organisms/container/raw.js +7 -0
  124. package/src/main/organisms/container/section.js +28 -0
  125. package/src/main/organisms/container.js +29 -0
  126. package/src/main/organisms/content-owner.js +15 -0
  127. package/src/main/organisms/date-entry.js +56 -0
  128. package/src/main/organisms/external-search.js +73 -0
  129. package/src/main/organisms/filtered-item.js +163 -0
  130. package/src/main/organisms/footer-item.js +17 -0
  131. package/src/main/organisms/image-gallery.js +164 -0
  132. package/src/main/organisms/last-modified.js +20 -0
  133. package/src/main/organisms/legal-footer.js +16 -0
  134. package/src/main/organisms/list-item.js +48 -0
  135. package/src/main/organisms/metadata.js +11 -0
  136. package/src/main/organisms/navigation.js +232 -0
  137. package/src/main/organisms/notification.js +87 -0
  138. package/src/main/organisms/order-tracker.js +203 -0
  139. package/src/main/organisms/page-header-banner.js +26 -0
  140. package/src/main/organisms/page-header.js +33 -0
  141. package/src/main/organisms/page-tags.js +14 -0
  142. package/src/main/organisms/page.js +260 -0
  143. package/src/main/organisms/press-release.js +24 -0
  144. package/src/main/organisms/product-admin.js +204 -0
  145. package/src/main/organisms/promo-banner.js +28 -0
  146. package/src/main/organisms/promo-bottom.js +23 -0
  147. package/src/main/organisms/promo-button.js +8 -0
  148. package/src/main/organisms/promo-card-cover.js +35 -0
  149. package/src/main/organisms/promo-card.js +33 -0
  150. package/src/main/organisms/promo-full.js +20 -0
  151. package/src/main/organisms/promo-image.js +22 -0
  152. package/src/main/organisms/promo-lure.js +22 -0
  153. package/src/main/organisms/promo-product-card.js +187 -0
  154. package/src/main/organisms/promo-product-full.js +293 -0
  155. package/src/main/organisms/promo-simple.js +23 -0
  156. package/src/main/organisms/quote.js +21 -0
  157. package/src/main/organisms/search-form.js +42 -0
  158. package/src/main/organisms/search-nav.js +66 -0
  159. package/src/main/organisms/search-result.js +53 -0
  160. package/src/main/organisms/slider.js +26 -0
  161. package/src/main/organisms/standalone-asset.js +22 -0
  162. package/src/main/organisms/tabs.js +277 -0
  163. package/src/main/organisms/topbar.js +83 -0
  164. package/src/main/organisms/web-component.js +53 -0
  165. package/src/main/routing/annotation.js +9 -0
  166. package/src/main/routing/component.js +138 -0
  167. package/src/main/routing/empty.js +5 -0
  168. package/src/main/routing/error-handler.js +64 -0
  169. package/src/main/routing/placeholder-image.svg +5 -0
  170. package/src/main/routing/router.js +219 -0
  171. package/src/main/shared/analytics.js +677 -0
  172. package/src/main/shared/bubble-event.js +11 -0
  173. package/src/main/shared/custom-element.js +21 -0
  174. package/src/main/shared/deep-selector.js +28 -0
  175. package/src/main/shared/disable-transparency.js +10 -0
  176. package/src/main/shared/format-time.js +8 -0
  177. package/src/main/shared/get-id.js +5 -0
  178. package/src/main/shared/get-meta.js +3 -0
  179. package/src/main/shared/get-size-class.js +3 -0
  180. package/src/main/shared/get-size.js +11 -0
  181. package/src/main/shared/h.js +88 -0
  182. package/src/main/shared/hash-jump.js +33 -0
  183. package/src/main/shared/icons/arrow-back.svg +1 -0
  184. package/src/main/shared/icons/arrow-down.svg +1 -0
  185. package/src/main/shared/icons/arrow-next.svg +1 -0
  186. package/src/main/shared/icons/arrow-tail-right.svg +1 -0
  187. package/src/main/shared/icons/arrow-tail-up.svg +1 -0
  188. package/src/main/shared/icons/arrow-up.svg +1 -0
  189. package/src/main/shared/icons/asset-download.svg +1 -0
  190. package/src/main/shared/icons/logo.svg +5 -0
  191. package/src/main/shared/icons/low-carbon-placeholder.svg +9 -0
  192. package/src/main/shared/icons/media-pause.svg +1 -0
  193. package/src/main/shared/icons/media-play.svg +1 -0
  194. package/src/main/shared/icons/navigation-burger.svg +1 -0
  195. package/src/main/shared/icons/navigation-close.svg +1 -0
  196. package/src/main/shared/icons/navigation-link.svg +1 -0
  197. package/src/main/shared/icons/navigation-refresh.svg +1 -0
  198. package/src/main/shared/icons/navigation-search.svg +1 -0
  199. package/src/main/shared/icons/navigation-share.svg +1 -0
  200. package/src/main/shared/icons/toggle-newwindow.svg +1 -0
  201. package/src/main/shared/icons.js +18 -0
  202. package/src/main/shared/id-from-string.js +5 -0
  203. package/src/main/shared/mark-selection.js +19 -0
  204. package/src/main/shared/register.js +26 -0
  205. package/src/main/shared/renderer.js +43 -0
  206. package/src/main/shared/simple-consent-api.js +70 -0
  207. package/src/main/shared/split-links.js +11 -0
  208. package/src/main/shared/t.js +60 -0
  209. package/src/main/shared/twind.js +837 -0
  210. package/src/main/shared/update-head.js +34 -0
  211. package/src/main/shared/update-scrollbar-width.js +30 -0
  212. package/src/main/shared/use-link.js +151 -0
  213. package/src/main/shared/use-persistent-state.js +42 -0
  214. package/src/main/shared/wait-for-dom-ready.js +6 -0
  215. package/src/main/shared/wcm-mode.js +4 -0
  216. package/src/wcs/components/acinguiux-preact-doc/acinguiux-preact-doc.js +207 -0
  217. package/src/wcs/components/admin-dashboard/admin-dashboard.js +487 -0
  218. package/src/wcs/components/admin-login/admin-login.js +91 -0
  219. package/src/wcs/components/bazaar-voice/bazaar-voice.js +56 -0
  220. package/src/wcs/components/chatbot-koreai/chatbot-koreai.js +176 -0
  221. package/src/wcs/components/chatbot-koreai/koreai-transport.js +217 -0
  222. package/src/wcs/components/chatbot-ms/chatbot-ms.js +210 -0
  223. package/src/wcs/components/chatbot-test/chatbot-test.js +44 -0
  224. package/src/wcs/components/comparison-chart/comparison-chart.js +111 -0
  225. package/src/wcs/components/consent-banner/consent-banner.js +248 -0
  226. package/src/wcs/components/consent-banner/icons/ccpa.svg +6 -0
  227. package/src/wcs/components/consent-banner/icons/info.svg +1 -0
  228. package/src/wcs/components/consent-banner/provider-onetrust.js +131 -0
  229. package/src/wcs/components/decision-tree/arrow-back.svg +3 -0
  230. package/src/wcs/components/decision-tree/badges.js +37 -0
  231. package/src/wcs/components/decision-tree/decision-tree.js +162 -0
  232. package/src/wcs/components/dynamic-contact-details/dynamic-contact-details.js +111 -0
  233. package/src/wcs/components/example-accordion/example-accordion.js +10 -0
  234. package/src/wcs/components/example-asset/example-asset.js +12 -0
  235. package/src/wcs/components/example-form/example-form.js +59 -0
  236. package/src/wcs/components/example-nested/example-nested.js +10 -0
  237. package/src/wcs/components/example-routing/example-routing.js +51 -0
  238. package/src/wcs/components/example-rtl/example-rtl.js +28 -0
  239. package/src/wcs/components/example-tabs/example-tabs.js +12 -0
  240. package/src/wcs/components/example-web-component/example-web-component.js +34 -0
  241. package/src/wcs/components/floating-button/floating-button.js +17 -0
  242. package/src/wcs/components/formstack-form/fields/address.js +38 -0
  243. package/src/wcs/components/formstack-form/fields/checkbox.js +42 -0
  244. package/src/wcs/components/formstack-form/fields/date.js +22 -0
  245. package/src/wcs/components/formstack-form/fields/description.js +8 -0
  246. package/src/wcs/components/formstack-form/fields/input.js +8 -0
  247. package/src/wcs/components/formstack-form/fields/name.js +39 -0
  248. package/src/wcs/components/formstack-form/fields/radio.js +24 -0
  249. package/src/wcs/components/formstack-form/fields/rating.js +53 -0
  250. package/src/wcs/components/formstack-form/fields/section.js +8 -0
  251. package/src/wcs/components/formstack-form/fields/select.js +10 -0
  252. package/src/wcs/components/formstack-form/fields/textarea.js +8 -0
  253. package/src/wcs/components/formstack-form/fields/wrapper.js +11 -0
  254. package/src/wcs/components/formstack-form/formstack-form.js +280 -0
  255. package/src/wcs/components/fuel-prices/fuel-prices.js +45 -0
  256. package/src/wcs/components/furniture-overview/furniture-overview.js +115 -0
  257. package/src/wcs/components/gauge-value/gauge-value.js +65 -0
  258. package/src/wcs/components/help-centre/api.js +150 -0
  259. package/src/wcs/components/help-centre/help-centre.js +400 -0
  260. package/src/wcs/components/help-centre/icon-search.svg +1 -0
  261. package/src/wcs/components/image-gen/admin-panel.js +248 -0
  262. package/src/wcs/components/image-gen/image-gen.js +385 -0
  263. package/src/wcs/components/image-gen/labels.js +37 -0
  264. package/src/wcs/components/image-gen/use-api.js +392 -0
  265. package/src/wcs/components/inspired-gallery/inspired-gallery.js +118 -0
  266. package/src/wcs/components/launch-container/launch-container.js +95 -0
  267. package/src/wcs/components/launch-container/ledger.js +140 -0
  268. package/src/wcs/components/lng-map/lng-map.js +44 -0
  269. package/src/wcs/components/mouseflow-analytics/mouseflow-analytics.js +39 -0
  270. package/src/wcs/components/msds-search/msds-search.js +127 -0
  271. package/src/wcs/components/msds-search/navigation-search.svg +3 -0
  272. package/src/wcs/components/product-catalogue/icon-back.svg +3 -0
  273. package/src/wcs/components/product-catalogue/icon-cart.svg +3 -0
  274. package/src/wcs/components/product-catalogue/icon-close.svg +3 -0
  275. package/src/wcs/components/product-catalogue/product-catalogue.js +215 -0
  276. package/src/wcs/components/product-links/icon-cart.svg +3 -0
  277. package/src/wcs/components/product-links/product-links.js +43 -0
  278. package/src/wcs/components/rio-iframe/rio-iframe.js +137 -0
  279. package/src/wcs/components/salsify-products/filter-tools.js +60 -0
  280. package/src/wcs/components/salsify-products/icon-cart.svg +3 -0
  281. package/src/wcs/components/salsify-products/process-products.js +53 -0
  282. package/src/wcs/components/salsify-products/route-tools.js +54 -0
  283. package/src/wcs/components/salsify-products/salsify-products.js +281 -0
  284. package/src/wcs/components/shout-out/shout-out.js +51 -0
  285. package/src/wcs/components/simple-chart/simple-chart.js +53 -0
  286. package/src/wcs/components/single-stat/single-stat.js +85 -0
  287. package/src/wcs/components/site-feedback/site-feedback.js +56 -0
  288. package/src/wcs/components/skds-search/navigation-search.svg +3 -0
  289. package/src/wcs/components/skds-search/skds-search.js +103 -0
  290. package/src/wcs/components/smart-banner/smart-banner.js +104 -0
  291. package/src/wcs/components/standalone-table/arrow-up-down.svg +3 -0
  292. package/src/wcs/components/standalone-table/arrow-up.svg +3 -0
  293. package/src/wcs/components/standalone-table/standalone-table.js +440 -0
  294. package/src/wcs/components/station-locator/station-locator.js +49 -0
  295. package/src/wcs/components/store-badges/badges.js +60 -0
  296. package/src/wcs/components/store-badges/store-badges.js +93 -0
  297. package/src/wcs/components/topbar-button/person.svg +1 -0
  298. package/src/wcs/components/topbar-button/topbar-button.js +22 -0
  299. package/src/wcs/components/universal-gallery/universal-gallery.js +308 -0
  300. package/src/wcs/components/zendesk-chat/zendesk-chat.js +133 -0
  301. package/src/wcs/shared/chat-bot/README.md +61 -0
  302. package/src/wcs/shared/chat-bot/chat-bot.js +216 -0
  303. package/src/wcs/shared/chat-bot/resources/arrow-next.svg +1 -0
  304. package/src/wcs/shared/chat-bot/resources/navigation-close.svg +1 -0
  305. package/src/wcs/shared/chat-bot/resources/person.svg +1 -0
  306. package/src/wcs/shared/chat-bot/resources/upload.svg +1 -0
  307. package/src/wcs/shared/filtered-data/README.md +52 -0
  308. package/src/wcs/shared/filtered-data/fetch-data.js +33 -0
  309. package/src/wcs/shared/filtered-data/filtered-data.js +337 -0
  310. package/src/wcs/shared/promo-with-popup/icon-close.svg +3 -0
  311. package/src/wcs/shared/promo-with-popup/icon-next.svg +3 -0
  312. package/src/wcs/shared/promo-with-popup/icon-prev.svg +3 -0
  313. package/src/wcs/shared/promo-with-popup/promo-with-popup.js +93 -0
@@ -0,0 +1,162 @@
1
+ import { useState, useEffect } from 'preact/hooks'
2
+ import { Img } from '../atoms/img.js'
3
+ import { Link } from '../atoms/link.js'
4
+ import h from '../shared/h.js'
5
+
6
+ export function Cart ({ _model, ...props }) {
7
+ const { items = [], currency = '\u20b9', deliveryCharge = 0, discount = 0 } = _model || {}
8
+
9
+ const [cartItems, setCartItems] = useState(() =>
10
+ items.map((item, idx) => ({
11
+ ...item,
12
+ _key: item.id || idx,
13
+ quantity: Math.max(1, Number.parseInt(item.quantity, 10) || 1)
14
+ }))
15
+ )
16
+
17
+ // Sync when external items prop changes (e.g. from DB/API)
18
+ useEffect(() => {
19
+ setCartItems(items.map((item, idx) => ({
20
+ ...item,
21
+ _key: item.id || idx,
22
+ quantity: Math.max(1, Number.parseInt(item.quantity, 10) || 1)
23
+ })))
24
+ }, [JSON.stringify(items)])
25
+
26
+ const updateQuantity = (idx, delta) => {
27
+ setCartItems(prev => prev.map((item, i) => {
28
+ if (i !== idx) return item
29
+ const maxQty = Number.parseInt(item.maxQuantity, 10)
30
+ const newQty = item.quantity + delta
31
+ if (newQty < 1) return item
32
+ if (Number.isFinite(maxQty) && newQty > maxQty) return item
33
+ return { ...item, quantity: newQty }
34
+ }))
35
+ }
36
+
37
+ const removeItem = (idx) => {
38
+ setCartItems(prev => prev.filter((_, i) => i !== idx))
39
+ }
40
+
41
+ const subtotal = cartItems.reduce((sum, item) => sum + (Number(item.price) || 0) * item.quantity, 0)
42
+ const totalSavings = cartItems.reduce((sum, item) => {
43
+ if (item.oldPrice) return sum + ((Number(item.oldPrice) - Number(item.price)) * item.quantity)
44
+ return sum
45
+ }, 0)
46
+ const grandTotal = subtotal - Number(discount) + Number(deliveryCharge)
47
+ const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0)
48
+
49
+ return h('div', {
50
+ ...props,
51
+ className: 'bg-bga text-txa border border-txa/10 rounded-xl w-full overflow-hidden',
52
+ 'data-cart-total': grandTotal,
53
+ 'data-cart-items': totalItems
54
+ },
55
+
56
+ // Header
57
+ h('div', { className: 'flex items-center justify-between p-3 lg:p-4 border-b border-txa/10' },
58
+ h('h2', { className: 'text-sm lg:text-base font-bold' }, 'Your Cart'),
59
+ h('span', { className: 'text-xs text-txa/60' }, `${totalItems} item${totalItems !== 1 ? 's' : ''}`)
60
+ ),
61
+
62
+ // Cart items list
63
+ cartItems.length > 0
64
+ ? h('ul', { className: 'divide-y divide-txa/10' },
65
+ cartItems.map((item, idx) =>
66
+ h('li', { key: item._key, className: 'p-3 lg:p-4' },
67
+ h('div', { className: 'grid grid-cols-12 gap-x-2 lg:gap-x-4 items-center' },
68
+
69
+ // Image
70
+ h('div', { className: 'col-span-2' },
71
+ item.image?.src
72
+ ? h(Img, {
73
+ _model: item.image,
74
+ _fit: 'contain',
75
+ className: 'w-full rounded-md bg-bgb/5',
76
+ style: 'aspect-ratio: 1 / 1;'
77
+ })
78
+ : h('div', { className: 'w-full rounded-md bg-txa/5', style: 'aspect-ratio: 1 / 1;' })
79
+ ),
80
+
81
+ // Info
82
+ h('div', { className: 'col-span-4 flex flex-col gap-0.5' },
83
+ h('span', { className: 'text-xs lg:text-sm font-semibold leading-tight' }, item.title),
84
+ h('div', { className: 'flex items-center gap-1' },
85
+ h('span', { className: 'text-xs font-bold' }, formatPrice(item.price, currency)),
86
+ item.oldPrice && h('span', { className: 'text-[10px] line-through opacity-60' }, formatPrice(item.oldPrice, currency))
87
+ ),
88
+ item.unitLabel && h('span', { className: 'text-[10px] text-txa/60' }, item.unitLabel)
89
+ ),
90
+
91
+ // Quantity controls
92
+ h('div', { className: 'col-span-3 flex items-center justify-center' },
93
+ h('div', { className: 'inline-flex items-center rounded-md border border-bgb overflow-hidden' },
94
+ h('button', {
95
+ type: 'button',
96
+ className: 'clickable bg-bgb text-txb w-6 h-6 lg:w-7 lg:h-7 text-sm font-bold',
97
+ onClick: () => updateQuantity(idx, -1),
98
+ 'aria-label': 'Decrease quantity'
99
+ }, '\u2212'),
100
+ h('span', { className: 'px-2 min-w-6 text-center text-xs font-semibold' }, item.quantity),
101
+ h('button', {
102
+ type: 'button',
103
+ className: 'clickable bg-bgb text-txb w-6 h-6 lg:w-7 lg:h-7 text-sm font-bold',
104
+ onClick: () => updateQuantity(idx, 1),
105
+ 'aria-label': 'Increase quantity'
106
+ }, '+')
107
+ )
108
+ ),
109
+
110
+ // Subtotal + remove
111
+ h('div', { className: 'col-span-3 flex flex-col items-end gap-1' },
112
+ h('span', { className: 'text-xs lg:text-sm font-bold' }, formatPrice((Number(item.price) || 0) * item.quantity, currency)),
113
+ h('button', {
114
+ type: 'button',
115
+ className: 'clickable text-[10px] text-txa/50 hover:text-txa',
116
+ onClick: () => removeItem(idx),
117
+ 'aria-label': `Remove ${item.title}`
118
+ }, 'Remove')
119
+ )
120
+ )
121
+ )
122
+ )
123
+ )
124
+ : h('div', { className: 'p-6 text-center text-sm text-txa/50' }, 'Your cart is empty'),
125
+
126
+ // Grand total footer
127
+ cartItems.length > 0 && h('div', { className: 'border-t border-txa/10 p-3 lg:p-4 space-y-2 bg-txa/3' },
128
+ h('div', { className: 'flex justify-between text-xs lg:text-sm' },
129
+ h('span', { className: 'text-txa/70' }, 'Subtotal'),
130
+ h('span', { className: 'font-semibold' }, formatPrice(subtotal, currency))
131
+ ),
132
+ totalSavings > 0 && h('div', { className: 'flex justify-between text-xs lg:text-sm' },
133
+ h('span', { className: 'text-bgb' }, 'Savings'),
134
+ h('span', { className: 'font-semibold text-bgb' }, `-${formatPrice(totalSavings, currency)}`)
135
+ ),
136
+ discount > 0 && h('div', { className: 'flex justify-between text-xs lg:text-sm' },
137
+ h('span', { className: 'text-txa/70' }, 'Discount'),
138
+ h('span', { className: 'font-semibold text-bgb' }, `-${formatPrice(discount, currency)}`)
139
+ ),
140
+ deliveryCharge > 0 && h('div', { className: 'flex justify-between text-xs lg:text-sm' },
141
+ h('span', { className: 'text-txa/70' }, 'Delivery'),
142
+ h('span', { className: 'font-semibold' }, formatPrice(deliveryCharge, currency))
143
+ ),
144
+ h('div', { className: 'flex justify-between text-sm lg:text-base font-bold pt-2 border-t border-txa/10' },
145
+ h('span', null, 'Grand Total'),
146
+ h('span', null, formatPrice(grandTotal, currency))
147
+ ),
148
+ h('button', {
149
+ type: 'button',
150
+ className: 'clickable w-full mt-2 bg-bgb text-txb py-2.5 lg:py-3 rounded-lg text-sm lg:text-base font-bold hover:bg-txb hover:text-bgb transition',
151
+ 'aria-label': 'Proceed to checkout',
152
+ 'data-cart': JSON.stringify(cartItems.map(({ _key, ...rest }) => rest))
153
+ }, `Checkout \u2022 ${formatPrice(grandTotal, currency)}`)
154
+ )
155
+ )
156
+ }
157
+
158
+ function formatPrice (value, currency) {
159
+ const num = Number(value)
160
+ if (Number.isFinite(num)) return `${currency}${num % 1 === 0 ? num : num.toFixed(2)}`
161
+ return `${currency}0`
162
+ }
@@ -0,0 +1,141 @@
1
+ import { Button } from '../atoms/button.js'
2
+ import { Form } from '../atoms/form.js'
3
+ import { Heading } from '../atoms/heading.js'
4
+ import { Input } from '../atoms/input.js'
5
+ import { RichText } from '../atoms/rich-text.js'
6
+ import getId from '../shared/get-id.js'
7
+ import h from '../shared/h.js'
8
+
9
+ const DEFAULT_FIELDS = [
10
+ { name: 'name', label: 'Full name', required: true, placeholder: 'Your name' },
11
+ { name: 'email', label: 'Email', type: 'email', required: true, placeholder: 'you@email.com' },
12
+ { name: 'phone', label: 'Phone', type: 'tel', required: true, placeholder: '+91 9XXXXXXXXX' },
13
+ { name: 'city', label: 'City', required: true, placeholder: 'Bengaluru / Seegehalli' },
14
+ { name: 'budget', label: 'Budget range', placeholder: '₹8L - ₹18L' },
15
+ { name: 'message', label: 'Tell us about your home', type: 'textarea', required: true, placeholder: 'Rooms, layout, timelines, and style goals' }
16
+ ]
17
+
18
+ function getFields (fields) {
19
+ if (!Array.isArray(fields) || fields.length === 0) {
20
+ return DEFAULT_FIELDS
21
+ }
22
+
23
+ return fields
24
+ .filter(field => field?.name)
25
+ .map(field => ({
26
+ type: 'text',
27
+ required: false,
28
+ ...field
29
+ }))
30
+ }
31
+
32
+ function getValueMap (data) {
33
+ const map = {}
34
+ for (const [key, value] of data.entries()) {
35
+ map[key] = value || ''
36
+ }
37
+ return map
38
+ }
39
+
40
+ export function ContactForm ({ _model, ...props }) {
41
+ const {
42
+ title,
43
+ text,
44
+ email,
45
+ to,
46
+ subject,
47
+ endpoint = '/api/contact',
48
+ fields,
49
+ submitLabel = 'Submit enquiry',
50
+ submittingLabel = 'Sending...',
51
+ successMessage = 'Enquiry sent successfully.',
52
+ errorMessage = 'Unable to send enquiry. Please try again.'
53
+ } = _model
54
+
55
+ const resolvedFields = getFields(fields)
56
+
57
+ const baseId = getId()
58
+ const ids = Object.fromEntries(resolvedFields.map(field => [field.name, `${baseId}-${field.name}`]))
59
+
60
+ const onSubmit = async event => {
61
+ event.preventDefault()
62
+ const form = event.currentTarget
63
+ const submitButton = form.querySelector('button[type="submit"]')
64
+ if (submitButton?.disabled) {
65
+ return
66
+ }
67
+
68
+ const data = new FormData(event.currentTarget)
69
+ const values = getValueMap(data)
70
+ const payload = {
71
+ to: to || email,
72
+ subject: subject || 'Interior design enquiry',
73
+ replyTo: values.email || '',
74
+ formName: title || 'Contact form',
75
+ fields: values
76
+ }
77
+
78
+ const originalLabel = submitButton?.textContent
79
+
80
+ try {
81
+ if (submitButton) {
82
+ submitButton.disabled = true
83
+ submitButton.textContent = submittingLabel
84
+ }
85
+
86
+ const response = await fetch(endpoint, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify(payload)
90
+ })
91
+
92
+ const result = await response.json().catch(() => ({}))
93
+ if (!response.ok) {
94
+ throw new Error(result.message || 'Unable to send enquiry')
95
+ }
96
+
97
+ form.reset()
98
+ globalThis.alert(successMessage)
99
+ } catch (error) {
100
+ globalThis.alert(error.message || errorMessage)
101
+ } finally {
102
+ if (submitButton) {
103
+ submitButton.disabled = false
104
+ submitButton.textContent = originalLabel || submitLabel
105
+ }
106
+ }
107
+ }
108
+
109
+ return h('section', { ...props, className: 'bg-bga text-txa rounded-2xl p-6 space-y-5' },
110
+ title && h(Heading, { _level: 2, _size: '6' }, title),
111
+ text && h(RichText, text),
112
+ h(Form, { onSubmit, 'aria-label': title ?? 'Contact form' },
113
+ h('div', { className: 'grid gap-4' },
114
+ ...resolvedFields.map(field => {
115
+ if (field.type === 'hidden') {
116
+ return h(Input, {
117
+ type: 'hidden',
118
+ name: field.name,
119
+ value: field.value ?? ''
120
+ })
121
+ }
122
+
123
+ return h('div', { className: 'grid gap-2' },
124
+ h('label', { for: ids[field.name], className: 'text-sm font-bold' }, field.label || field.name),
125
+ h(Input, {
126
+ id: ids[field.name],
127
+ name: field.name,
128
+ type: field.type,
129
+ required: field.required,
130
+ placeholder: field.placeholder,
131
+ value: field.value
132
+ })
133
+ )
134
+ }),
135
+ h('div', { className: 'flex flex-wrap gap-3 items-center' },
136
+ h(Button, { _model: { name: submitLabel }, type: 'submit' }),
137
+ )
138
+ )
139
+ )
140
+ )
141
+ }
@@ -0,0 +1,47 @@
1
+ import { useLayoutEffect } from 'preact/hooks'
2
+ import { ValidationError } from '#acinguiux-preact/main/routing/error-handler.js'
3
+ import h from '#acinguiux-preact/main/shared/h.js'
4
+ import usePersistentState from '#acinguiux-preact/main/shared/use-persistent-state.js'
5
+
6
+ // 100 days in milliseconds.
7
+ const EXPERIMENT_RETENTION = 8_640_000_000
8
+ const EXPERIMENTS_KEY = 'experiments'
9
+
10
+ // Randomly show one of the children.
11
+ export function ABTest ({ _model, children, ...props }) {
12
+ const { title } = _model
13
+ if (!title) {
14
+ throw new ValidationError()
15
+ }
16
+
17
+ const [experiments, setExperiments] = usePersistentState(EXPERIMENTS_KEY, {})
18
+
19
+ useLayoutEffect(() => {
20
+ setExperiments(data => {
21
+ // Clean expired experiments.
22
+ for (const key in data) {
23
+ if (new Date(data[key].date).getTime() + EXPERIMENT_RETENTION < Date.now()) {
24
+ delete data[key]
25
+ }
26
+ }
27
+
28
+ // Initialize new experiment.
29
+ data[title] ??= {
30
+ // In this case, Math.random() is appropriate and safe as it not relevant for security
31
+ // eslint-disable-next-line sonarjs/pseudo-random
32
+ index: Math.floor(Math.random() * (children?.length ?? 0)),
33
+ date: new Date().toISOString()
34
+ }
35
+
36
+ return data
37
+ })
38
+ }, [title])
39
+
40
+ const experiment = experiments[title]
41
+ return experiment && h('div', {
42
+ ...props,
43
+ 'data-experiment': `${title} | ${experiment.index + 1}`,
44
+ className: 'h-full',
45
+ children: children[experiment.index]
46
+ })
47
+ }
@@ -0,0 +1,6 @@
1
+ import h from '#acinguiux-preact/main/shared/h.js'
2
+ import { Grid } from './grid.js'
3
+
4
+ export function Default (props) {
5
+ return h(Grid, { ...props, _gap: true, _color: undefined })
6
+ }
@@ -0,0 +1,293 @@
1
+ import { Button } from '#acinguiux-preact/main/atoms/button.js'
2
+ import { Input } from '#acinguiux-preact/main/atoms/input.js'
3
+ import { Link } from '#acinguiux-preact/main/atoms/link.js'
4
+ import getId from '#acinguiux-preact/main/shared/get-id.js'
5
+ import h from '#acinguiux-preact/main/shared/h.js'
6
+ import t from '#acinguiux-preact/main/shared/t.js'
7
+ import { useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'
8
+ import { Section } from './section.js'
9
+
10
+ const LOAD_MORE_LIMIT = 12
11
+ const SEARCH_DEBOUNCE = 300
12
+ const SEARCH_PARAM = 'q'
13
+
14
+ export function FilteredSection (props) {
15
+ return h(SearchableSection, { ...props, _hideSearch: true })
16
+ }
17
+
18
+ export function SearchableSection ({ _model, _hideSearch, _wrap, children = [], ...props }) {
19
+ const { tags } = _model
20
+ const [items, allLabels] = useMemo(() => getItemsAndLabels(children, _wrap), [children, _wrap])
21
+
22
+ // Each filter is an object: { name, values: [] }
23
+ const allFilters = useMemo(() => tags
24
+ ?.filter?.(tag => allLabels[tag.name])
25
+ ?.map?.(({ name }) => ({
26
+ name,
27
+ values: [...allLabels[name]].toSorted((a, b) => {
28
+ if (isNumber(a) && isNumber(b)) {
29
+ return Number.parseFloat(a) - Number.parseFloat(b)
30
+ }
31
+ return a.localeCompare(b)
32
+ })
33
+ })),
34
+ [tags])
35
+
36
+ const [limit, setLimit] = useState(LOAD_MORE_LIMIT)
37
+ const [selection, setSelection] = useState({})
38
+ const [search, setSearch] = useState(null)
39
+ const [id] = useState(() => getId())
40
+
41
+ const updateFiltersAndSearchFromUrl = () => {
42
+ const params = new URLSearchParams(location.hash.substring(1))
43
+ const selected = {}
44
+
45
+ for (const [name, value] of params) {
46
+ if (allLabels[name] && value) {
47
+ selected[name] = value
48
+ }
49
+ }
50
+
51
+ setSelection(selected)
52
+ setSearch(params.get(SEARCH_PARAM))
53
+ setLimit(LOAD_MORE_LIMIT)
54
+ }
55
+
56
+ useLayoutEffect(() => {
57
+ updateFiltersAndSearchFromUrl()
58
+ globalThis.addEventListener('hashchange', updateFiltersAndSearchFromUrl)
59
+ return () => globalThis.removeEventListener('hashchange', updateFiltersAndSearchFromUrl)
60
+ }, [])
61
+
62
+ // Get filtered and limited children.
63
+ const [filteredItems, limitedItems] = useMemo(() => {
64
+ const fc = filterItems(selection, search, items)
65
+ const lc = fc.slice(0, limit)
66
+ return [fc, lc]
67
+ }, [items, selection, search, limit])
68
+
69
+ // Get filter instance.
70
+ const filterInstance = useMemo(() => h(Filters, { _hideSearch, allFilters, selection, search, items, id }),
71
+ [allFilters, selection, search, items])
72
+
73
+ //
74
+ // Resolver section combined children.
75
+ //
76
+
77
+ const combinedWrap = []
78
+ const combinedChildren = []
79
+
80
+ // Add filters.
81
+ if (filterInstance) {
82
+ combinedWrap.push({ colspan: 12, key: 'filters' })
83
+ combinedChildren.push(filterInstance)
84
+ }
85
+
86
+ // Add items.
87
+ for (const item of limitedItems) {
88
+ combinedWrap.push(item.wrap)
89
+ combinedChildren.push(h('div', { className: 'animate-fade h-full' }, item.child))
90
+ }
91
+
92
+ // Load more button.
93
+ if (filteredItems.length > limit) {
94
+ combinedWrap.push({ colspan: 12, key: 'load-more' })
95
+ combinedChildren.push(h('div', { className: 'flex justify-center animate-fade' },
96
+ h(Button, {
97
+ 'aria-controls': id,
98
+ onClick: () => {
99
+ setLimit(prev => prev + LOAD_MORE_LIMIT)
100
+ },
101
+ _model: { name: t('Load more') }
102
+ })
103
+ ))
104
+ }
105
+
106
+ // Add no results message.
107
+ if (filteredItems.length === 0) {
108
+ combinedWrap.push({ colspan: 12, key: 'no-results' })
109
+ combinedChildren.push(h('p', { className: 'animate-fade' }, t('No results found.')))
110
+ }
111
+
112
+ return h(Section, {
113
+ ...props,
114
+ id,
115
+ ariaLive: 'polite',
116
+ _model: { ..._model, tags: null },
117
+ _wrap: combinedWrap,
118
+ children: combinedChildren
119
+ })
120
+ }
121
+
122
+ function setFilterRoute (params) {
123
+ const paramHash = params ? `#${params.toString()}` : ''
124
+ globalThis.history.replaceState({}, '', `${globalThis.location.href.split('#')[0]}${paramHash}`)
125
+ globalThis.dispatchEvent(new globalThis.HashChangeEvent('hashchange'))
126
+ }
127
+
128
+ function Filters ({ _hideSearch, allFilters, selection, search, items, id }) {
129
+ const searchTimeoutRef = useRef(null)
130
+
131
+ if (Object.keys(allFilters).length === 0 && _hideSearch) {
132
+ return null
133
+ }
134
+
135
+ return h('div', { className: 'space-y-5' },
136
+ h('div', { className: 'flex gap-5 sm:flex-col' },
137
+ _hideSearch || h('label', { className: 'flex-1 block' },
138
+ h('span', { className: 'font-bold' }, t('Search')),
139
+ h(Input, {
140
+ 'aria-controls': id,
141
+ type: 'search',
142
+ name: t('Search'),
143
+ value: search || '',
144
+ onInput: event => {
145
+ // Avoid flooding updates when user is typing.
146
+ clearTimeout(searchTimeoutRef.current)
147
+ searchTimeoutRef.current = setTimeout(() => {
148
+ const query = event.target.value
149
+ const params = new URLSearchParams(location.hash.substring(1))
150
+ query ? params.set(SEARCH_PARAM, query) : params.delete(SEARCH_PARAM)
151
+ setFilterRoute(params)
152
+ }, SEARCH_DEBOUNCE)
153
+ }
154
+ })
155
+ ),
156
+ allFilters.map(({ name, values }) => h(Filter, {
157
+ id,
158
+ name,
159
+ values,
160
+ selectedValue: selection[name],
161
+ selection,
162
+ search,
163
+ items,
164
+ onChange: value => {
165
+ const params = new URLSearchParams(location.hash.substring(1))
166
+ value ? params.set(name, value) : params.delete(name)
167
+ setFilterRoute(params)
168
+ }
169
+ }))
170
+ ),
171
+ // Reset filters.
172
+ h(Link, {
173
+ 'aria-controls': id,
174
+ _variant: 'underline',
175
+ _model: {
176
+ name: t('Reset filters'),
177
+ value: '#'
178
+ },
179
+ role: 'button',
180
+ onClick: e => {
181
+ e.preventDefault()
182
+ // Route to avoid scroll to top.
183
+ setFilterRoute()
184
+ }
185
+ })
186
+ )
187
+ }
188
+
189
+ function Filter ({ name, values, selectedValue, selection, search, items, onChange, id }) {
190
+ // Remove current selection from the list to calculate counts.
191
+ const adjustedSelection = { ...selection }
192
+ delete adjustedSelection[name]
193
+
194
+ // Get counts for the current filter based on partially filtered children
195
+ const filteredItems = filterItems(adjustedSelection, search, items)
196
+ const count = getCountForFilter(name, filteredItems)
197
+
198
+ return h('label', { className: 'block flex-1' },
199
+ h('span', { className: 'font-bold' }, name),
200
+ h(Input, {
201
+ 'aria-controls': id,
202
+ name,
203
+ type: 'select',
204
+ onChange: event => onChange(event.target.value),
205
+ children: [
206
+ h('option'),
207
+ values.map(value => h('option', { value, selected: value === selectedValue },
208
+ `${value} (${count[value] ?? 0})`
209
+ ))
210
+ ]
211
+ })
212
+ )
213
+ }
214
+
215
+ // -----------------------------------------------------------------------------
216
+ // Helpers
217
+ // -----------------------------------------------------------------------------
218
+
219
+ function getItemsAndLabels (children, _wrap) {
220
+ const items = []
221
+ const labels = {}
222
+
223
+ for (const [ii, child] of children.entries()) {
224
+ const model = child.props._model
225
+ const wrap = { colspan: _wrap[ii]?.colspan }
226
+ items.push({ child, model, wrap })
227
+
228
+ for (const tag of model.tags) {
229
+ labels[tag.name] ||= new Set()
230
+ labels[tag.name].add(tag.value)
231
+ }
232
+ }
233
+
234
+ return [items, labels]
235
+ }
236
+
237
+ function filterItems (selection, search, items) {
238
+ const itemMatchesSelection = item => Object.entries(selection).every(([name, value]) => {
239
+ const tags = item.model.tags
240
+ return tags.some(tag => tag.name === name && tag.value === value)
241
+ })
242
+
243
+ // Skip filtering if no criteria.
244
+ if (Object.keys(selection).length === 0 && !search) {
245
+ return items
246
+ }
247
+
248
+ const filteredItems = []
249
+
250
+ for (const item of items) {
251
+ // Match search.
252
+ if (search) {
253
+ const searchIndex = item.model.tags.map(tag => tag.value.toLowerCase())
254
+
255
+ const title = item.model.title?.toLowerCase()
256
+ title && searchIndex.push(title)
257
+ const text = item.model.text?.toLowerCase()
258
+ text && searchIndex.push(text)
259
+
260
+ if (!searchIndex.some(s => s.includes(search.trim().toLowerCase()))) {
261
+ continue
262
+ }
263
+ }
264
+
265
+ // Match selection.
266
+ if (!itemMatchesSelection(item)) {
267
+ continue
268
+ }
269
+
270
+ filteredItems.push(item)
271
+ }
272
+
273
+ return filteredItems
274
+ }
275
+
276
+ function getCountForFilter (name, items) {
277
+ const count = {}
278
+
279
+ for (const item of items) {
280
+ for (const tag of item.model.tags) {
281
+ if (name === tag.name) {
282
+ count[tag.value] ||= 0
283
+ count[tag.value] += 1
284
+ }
285
+ }
286
+ }
287
+
288
+ return count
289
+ }
290
+
291
+ function isNumber (value) {
292
+ return !Number.isNaN(Number.parseFloat(value))
293
+ }
@@ -0,0 +1,12 @@
1
+ import h from '#acinguiux-preact/main/shared/h.js'
2
+ import { Grid } from './grid.js'
3
+
4
+ export function Footer (props) {
5
+ return h('div', {
6
+ ...props,
7
+ className: 'text-txa bg-bga border-txa/20 border-t pt-12 pb-12',
8
+ children: h('div', { className: 'max-w-page m-auto px-3' },
9
+ h(Grid, { ...props, _tag: 'footer' })
10
+ )
11
+ })
12
+ }