adserver-dashboard 1.0.0

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 (525) hide show
  1. package/.ci/staging.yml +191 -0
  2. package/.dockerignore +117 -0
  3. package/.env +40 -0
  4. package/.env.staging +38 -0
  5. package/.gitlab-ci.yml +16 -0
  6. package/DEMO_STATUS.md +579 -0
  7. package/Dockerfile +61 -0
  8. package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
  9. package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
  10. package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
  11. package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
  12. package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
  13. package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
  14. package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
  15. package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
  16. package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
  17. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
  18. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
  19. package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
  20. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
  21. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
  22. package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
  23. package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
  24. package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
  25. package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
  26. package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
  27. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
  28. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
  29. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
  30. package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
  31. package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
  32. package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
  33. package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
  34. package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
  35. package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
  36. package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
  37. package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
  38. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
  39. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
  40. package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
  41. package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
  42. package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
  43. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
  44. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
  45. package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
  46. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
  47. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
  48. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
  49. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
  50. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
  51. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
  52. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
  53. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
  54. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
  55. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
  56. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
  57. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
  58. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
  59. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
  60. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
  61. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
  62. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
  63. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
  64. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
  65. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
  66. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
  67. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
  68. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
  69. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
  70. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
  71. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
  72. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
  73. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
  74. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
  75. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
  76. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
  77. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
  78. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
  79. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
  80. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
  81. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
  82. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
  83. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
  84. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
  85. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
  86. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
  87. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
  88. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
  89. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
  90. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
  91. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
  92. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
  93. package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
  94. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
  95. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
  96. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
  97. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
  98. package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
  99. package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
  100. package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
  101. package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
  102. package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
  103. package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
  104. package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
  105. package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
  106. package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
  107. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
  108. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
  109. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
  110. package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
  111. package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
  112. package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
  113. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
  114. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
  115. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
  116. package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
  117. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
  118. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
  119. package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
  120. package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
  121. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
  122. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
  123. package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
  124. package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
  125. package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
  126. package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
  127. package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
  128. package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
  129. package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
  130. package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
  131. package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
  132. package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
  133. package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
  134. package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
  135. package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
  136. package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
  137. package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
  138. package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
  139. package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
  140. package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
  141. package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
  142. package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
  143. package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
  144. package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
  145. package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
  146. package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
  147. package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
  148. package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
  149. package/PCM/.env.example +92 -0
  150. package/PCM/README.md +558 -0
  151. package/PCM/docs/TEST_CASES.md +422 -0
  152. package/PCM/index.js +106 -0
  153. package/PCM/package-lock.json +3282 -0
  154. package/PCM/package.json +32 -0
  155. package/PCM/replit.md +64 -0
  156. package/PCM/schema.sql +495 -0
  157. package/PCM/scripts/export-schema.js +183 -0
  158. package/PCM/scripts/seed-comprehensive.js +631 -0
  159. package/PCM/scripts/seed-production.js +477 -0
  160. package/PCM/src/config/db.js +56 -0
  161. package/PCM/src/config/swagger.js +5975 -0
  162. package/PCM/src/dto/EmailRequestDTO.js +166 -0
  163. package/PCM/src/middleware/errorHandler.js +52 -0
  164. package/PCM/src/middleware/logger.js +26 -0
  165. package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
  166. package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
  167. package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
  168. package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
  169. package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
  170. package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
  171. package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
  172. package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
  173. package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
  174. package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
  175. package/PCM/src/migrations/011_add_planning_field.sql +21 -0
  176. package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
  177. package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
  178. package/PCM/src/migrations/014_create_change_history.sql +38 -0
  179. package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
  180. package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
  181. package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
  182. package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
  183. package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
  184. package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
  185. package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
  186. package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
  187. package/PCM/src/migrations/run-migrations.js +46 -0
  188. package/PCM/src/models/ApprovalOTP.js +51 -0
  189. package/PCM/src/models/ApprovalToken.js +79 -0
  190. package/PCM/src/models/ChangeHistory.js +107 -0
  191. package/PCM/src/models/Deal.js +186 -0
  192. package/PCM/src/models/DealIdCounter.js +28 -0
  193. package/PCM/src/models/LineItem.js +227 -0
  194. package/PCM/src/models/LineItemCreative.js +89 -0
  195. package/PCM/src/models/LineItemInventory.js +115 -0
  196. package/PCM/src/models/PublisherInsertionOrder.js +93 -0
  197. package/PCM/src/models/TransactionHistory.js +34 -0
  198. package/PCM/src/models/associations.js +81 -0
  199. package/PCM/src/routes/approval.js +321 -0
  200. package/PCM/src/routes/creatives.js +437 -0
  201. package/PCM/src/routes/deals.js +1638 -0
  202. package/PCM/src/routes/digitalSignage.js +242 -0
  203. package/PCM/src/routes/insertionOrders.js +380 -0
  204. package/PCM/src/routes/lineItems.js +926 -0
  205. package/PCM/src/routes/system.js +384 -0
  206. package/PCM/src/services/ApprovalService.js +885 -0
  207. package/PCM/src/services/CampaignImportConverter.js +631 -0
  208. package/PCM/src/services/CampaignModeService.js +273 -0
  209. package/PCM/src/services/CampaignStatusService.js +395 -0
  210. package/PCM/src/services/ChangeHistoryService.js +316 -0
  211. package/PCM/src/services/DealIdService.js +94 -0
  212. package/PCM/src/services/DealResponseFormatter.js +90 -0
  213. package/PCM/src/services/EmailNotificationService.js +315 -0
  214. package/PCM/src/services/LineItemResponseFormatter.js +122 -0
  215. package/PCM/src/services/LineItemStatusService.js +380 -0
  216. package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
  217. package/PCM/src/tests/comprehensiveTests.js +1277 -0
  218. package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
  219. package/PCM/src/tests/testRunner.js +248 -0
  220. package/PCM/src/utils/caseConverter.js +92 -0
  221. package/PCM/src/utils/dealCalculations.js +206 -0
  222. package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
  223. package/PCM/src/utils/payloadNormalizer.js +34 -0
  224. package/PCM/src/utils/sourceNormalizer.js +56 -0
  225. package/PCM/src/validators/creativeValidator.js +27 -0
  226. package/PCM/src/validators/dealValidator.js +203 -0
  227. package/PCM/src/validators/lineItemValidator.js +489 -0
  228. package/PCM/tests/approval-flows.test.js +238 -0
  229. package/PCM/tests/approval-workflow.test.js +291 -0
  230. package/PCM/tests/campaign-import-converter.test.js +543 -0
  231. package/PCM/tests/campaign-import-e2e.test.js +520 -0
  232. package/PCM/tests/campaign-status.test.js +539 -0
  233. package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
  234. package/PCM/tests/e2e/digital-signage.test.js +145 -0
  235. package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
  236. package/PCM/tests/e2e-comprehensive.test.js +3446 -0
  237. package/PCM/tests/edge-cases.test.js +340 -0
  238. package/PCM/tests/line-item-status.test.js +340 -0
  239. package/PCM/tests/seller-account-external-ids.test.js +877 -0
  240. package/PCM/tests/source-validation.test.js +324 -0
  241. package/PRD.md +3373 -0
  242. package/README.md +186 -0
  243. package/client/index.html +35 -0
  244. package/client/public/DEMO_STATUS.md +579 -0
  245. package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
  246. package/client/public/locales/ar/approval.json +144 -0
  247. package/client/public/locales/ar/buyer.json +61 -0
  248. package/client/public/locales/ar/campaigns.json +1 -0
  249. package/client/public/locales/ar/common.json +218 -0
  250. package/client/public/locales/ar/contentHub.json +266 -0
  251. package/client/public/locales/ar/creatives.json +79 -0
  252. package/client/public/locales/ar/dashboard.json +57 -0
  253. package/client/public/locales/ar/deals.json +886 -0
  254. package/client/public/locales/ar/dsp.json +131 -0
  255. package/client/public/locales/ar/inventory.json +201 -0
  256. package/client/public/locales/ar/lineItems.json +553 -0
  257. package/client/public/locales/ar/navigation.json +48 -0
  258. package/client/public/locales/ar/wizard.json +1 -0
  259. package/client/public/locales/en/approval.json +144 -0
  260. package/client/public/locales/en/buyer.json +65 -0
  261. package/client/public/locales/en/campaigns.json +1 -0
  262. package/client/public/locales/en/common.json +218 -0
  263. package/client/public/locales/en/contentHub.json +266 -0
  264. package/client/public/locales/en/creatives.json +79 -0
  265. package/client/public/locales/en/dashboard.json +57 -0
  266. package/client/public/locales/en/deals.json +886 -0
  267. package/client/public/locales/en/dsp.json +131 -0
  268. package/client/public/locales/en/inventory.json +201 -0
  269. package/client/public/locales/en/lineItems.json +659 -0
  270. package/client/public/locales/en/navigation.json +48 -0
  271. package/client/public/locales/en/wizard.json +1 -0
  272. package/client/public/locales/ja/approval.json +144 -0
  273. package/client/public/locales/ja/buyer.json +61 -0
  274. package/client/public/locales/ja/campaigns.json +1 -0
  275. package/client/public/locales/ja/common.json +218 -0
  276. package/client/public/locales/ja/contentHub.json +266 -0
  277. package/client/public/locales/ja/creatives.json +79 -0
  278. package/client/public/locales/ja/dashboard.json +57 -0
  279. package/client/public/locales/ja/deals.json +886 -0
  280. package/client/public/locales/ja/dsp.json +131 -0
  281. package/client/public/locales/ja/inventory.json +201 -0
  282. package/client/public/locales/ja/lineItems.json +553 -0
  283. package/client/public/locales/ja/navigation.json +48 -0
  284. package/client/public/locales/ja/wizard.json +1 -0
  285. package/client/public/locales/zh/approval.json +144 -0
  286. package/client/public/locales/zh/buyer.json +61 -0
  287. package/client/public/locales/zh/campaigns.json +1 -0
  288. package/client/public/locales/zh/common.json +218 -0
  289. package/client/public/locales/zh/contentHub.json +266 -0
  290. package/client/public/locales/zh/creatives.json +79 -0
  291. package/client/public/locales/zh/dashboard.json +57 -0
  292. package/client/public/locales/zh/deals.json +886 -0
  293. package/client/public/locales/zh/dsp.json +131 -0
  294. package/client/public/locales/zh/inventory.json +201 -0
  295. package/client/public/locales/zh/lineItems.json +553 -0
  296. package/client/public/locales/zh/navigation.json +48 -0
  297. package/client/public/locales/zh/wizard.json +1 -0
  298. package/client/public/manifest.json +36 -0
  299. package/client/src/App.tsx +464 -0
  300. package/client/src/components/app-sidebar.tsx +312 -0
  301. package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
  302. package/client/src/components/approval/approval-decision-form.tsx +326 -0
  303. package/client/src/components/approval/approval-sheet.tsx +631 -0
  304. package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
  305. package/client/src/components/approval/otp-verification.test.tsx +337 -0
  306. package/client/src/components/approval/otp-verification.tsx +180 -0
  307. package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
  308. package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
  309. package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
  310. package/client/src/components/content-hub/creative-card.tsx +456 -0
  311. package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
  312. package/client/src/components/content-hub/creative-filters.tsx +457 -0
  313. package/client/src/components/content-hub/creative-grid.tsx +329 -0
  314. package/client/src/components/content-hub/creative-selector.tsx +415 -0
  315. package/client/src/components/content-hub/creative-upload.tsx +547 -0
  316. package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
  317. package/client/src/components/content-hub/folder-list.tsx +280 -0
  318. package/client/src/components/content-hub/review-dialogs.tsx +268 -0
  319. package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
  320. package/client/src/components/creative-library/creative-details-view.tsx +446 -0
  321. package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
  322. package/client/src/components/creative-library/creative-list.tsx +360 -0
  323. package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
  324. package/client/src/components/creative-library/folder-card.tsx +78 -0
  325. package/client/src/components/creative-library/index.ts +27 -0
  326. package/client/src/components/creative-library/new-creative-card.tsx +211 -0
  327. package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
  328. package/client/src/components/dashboard-overview.tsx +109 -0
  329. package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
  330. package/client/src/components/deals/approval-history-panel.tsx +156 -0
  331. package/client/src/components/deals/deal-status-badge.tsx +92 -0
  332. package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
  333. package/client/src/components/deals/market-insights-panel.tsx +237 -0
  334. package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
  335. package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
  336. package/client/src/components/deals/request-approval-sheet.tsx +136 -0
  337. package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
  338. package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
  339. package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
  340. package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
  341. package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
  342. package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
  343. package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
  344. package/client/src/components/inventory/inventory-map-view.tsx +596 -0
  345. package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
  346. package/client/src/components/language-switcher.tsx +53 -0
  347. package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
  348. package/client/src/components/line-items/form-insights.tsx +89 -0
  349. package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
  350. package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
  351. package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
  352. package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
  353. package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
  354. package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
  355. package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
  356. package/client/src/components/line-items/geofencing-map.tsx +1270 -0
  357. package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
  358. package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
  359. package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
  360. package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
  361. package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
  362. package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
  363. package/client/src/components/line-items/schedule-rule-types.ts +122 -0
  364. package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
  365. package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
  366. package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
  367. package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
  368. package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
  369. package/client/src/components/line-items/steps/summary-step.tsx +680 -0
  370. package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
  371. package/client/src/components/product-switcher.tsx +105 -0
  372. package/client/src/components/protected-route.tsx +49 -0
  373. package/client/src/components/skip-link.tsx +22 -0
  374. package/client/src/components/stat-card.tsx +53 -0
  375. package/client/src/components/status-badge.tsx +96 -0
  376. package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
  377. package/client/src/components/ui/toaster.tsx +111 -0
  378. package/client/src/contexts/auth-context.tsx +181 -0
  379. package/client/src/contexts/sidebar-state.tsx +50 -0
  380. package/client/src/contexts/theme-context.tsx +66 -0
  381. package/client/src/data/campaign-data.json +107 -0
  382. package/client/src/data/countries.json +22 -0
  383. package/client/src/hooks/use-approval.ts +366 -0
  384. package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
  385. package/client/src/hooks/use-media-query.ts +46 -0
  386. package/client/src/hooks/use-mobile.tsx +19 -0
  387. package/client/src/hooks/use-page-title.ts +21 -0
  388. package/client/src/hooks/use-toast.ts +195 -0
  389. package/client/src/index.css +694 -0
  390. package/client/src/lib/__tests__/accessibility.test.ts +104 -0
  391. package/client/src/lib/__tests__/date-utils.test.ts +199 -0
  392. package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
  393. package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
  394. package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
  395. package/client/src/lib/__tests__/utils.test.ts +57 -0
  396. package/client/src/lib/accessibility.ts +141 -0
  397. package/client/src/lib/api-config.ts +9 -0
  398. package/client/src/lib/auth-service.ts +209 -0
  399. package/client/src/lib/campaign-creative-api.ts +82 -0
  400. package/client/src/lib/company-api.ts +61 -0
  401. package/client/src/lib/content-hub-api.ts +407 -0
  402. package/client/src/lib/creative-mapper.ts +61 -0
  403. package/client/src/lib/date-utils.ts +119 -0
  404. package/client/src/lib/deal-helpers.ts +220 -0
  405. package/client/src/lib/dsp-buyer-api.ts +196 -0
  406. package/client/src/lib/geo-import-api.ts +151 -0
  407. package/client/src/lib/google-poi-api.ts +305 -0
  408. package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
  409. package/client/src/lib/i18n/formatting.ts +130 -0
  410. package/client/src/lib/i18n/index.ts +8 -0
  411. package/client/src/lib/i18n-compat.ts +76 -0
  412. package/client/src/lib/influence-deals-api.ts +896 -0
  413. package/client/src/lib/inventory-api.ts +399 -0
  414. package/client/src/lib/oauth-service.ts +678 -0
  415. package/client/src/lib/poi-types.ts +75 -0
  416. package/client/src/lib/queryClient.ts +144 -0
  417. package/client/src/lib/recommendation-api.ts +380 -0
  418. package/client/src/lib/storage-utils.ts +104 -0
  419. package/client/src/lib/tolgee.ts +85 -0
  420. package/client/src/lib/utils.ts +0 -0
  421. package/client/src/main.tsx +67 -0
  422. package/client/src/mapbox-draw-modes.d.ts +32 -0
  423. package/client/src/pages/all-folders.tsx +203 -0
  424. package/client/src/pages/auth-callback.tsx +115 -0
  425. package/client/src/pages/buyer-form.tsx +339 -0
  426. package/client/src/pages/buyer-list.tsx +622 -0
  427. package/client/src/pages/content-hub.tsx +1358 -0
  428. package/client/src/pages/create-deal.tsx +2093 -0
  429. package/client/src/pages/creative-assignment-page.tsx +548 -0
  430. package/client/src/pages/creatives.tsx +5 -0
  431. package/client/src/pages/custom-pois.tsx +425 -0
  432. package/client/src/pages/dashboard.tsx +615 -0
  433. package/client/src/pages/deal-history.tsx +434 -0
  434. package/client/src/pages/deal-line-items.tsx +1703 -0
  435. package/client/src/pages/demo-status.tsx +113 -0
  436. package/client/src/pages/direct-campaign-details.tsx +361 -0
  437. package/client/src/pages/direct-campaigns-new.tsx +824 -0
  438. package/client/src/pages/dsp-form.tsx +803 -0
  439. package/client/src/pages/dsp-list.tsx +239 -0
  440. package/client/src/pages/folder-content.tsx +336 -0
  441. package/client/src/pages/integrations.tsx +429 -0
  442. package/client/src/pages/line-item-creatives.tsx +789 -0
  443. package/client/src/pages/line-item-detail-page.tsx +684 -0
  444. package/client/src/pages/line-item-form-page.tsx +3261 -0
  445. package/client/src/pages/line-item-wizard.tsx +1207 -0
  446. package/client/src/pages/login.tsx +154 -0
  447. package/client/src/pages/not-found.tsx +23 -0
  448. package/client/src/pages/proof-of-play.tsx +397 -0
  449. package/client/src/pages/public-approval.tsx +551 -0
  450. package/client/src/pages/reports.tsx +231 -0
  451. package/client/src/pages/settings.tsx +760 -0
  452. package/client/src/pages/signals.tsx +389 -0
  453. package/client/src/pages/tags.tsx +318 -0
  454. package/client/src/pages/test-results.tsx +328 -0
  455. package/client/src/store/hooks.ts +5 -0
  456. package/client/src/store/index.ts +15 -0
  457. package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
  458. package/client/src/styles/design-tokens.css +324 -0
  459. package/client/src/test/setup.ts +261 -0
  460. package/client/src/test/test-utils.tsx +40 -0
  461. package/client/src/types/approval.ts +221 -0
  462. package/client/src/types/content-hub.ts +209 -0
  463. package/client/src/types/geofencing.ts +67 -0
  464. package/client/src/types/transcoding.ts +140 -0
  465. package/client/src/vite-env.d.ts +18 -0
  466. package/components.json +20 -0
  467. package/creative-api.json +1 -0
  468. package/docs/AI_REFERENCE.md +459 -0
  469. package/docs/MWDesign-Prompt.md +132 -0
  470. package/docs/MWDesign-System.md +344 -0
  471. package/docs/test-plan.md +277 -0
  472. package/e2e/AUTONOMOUS-TESTING.md +406 -0
  473. package/e2e/README.md +219 -0
  474. package/e2e/autonomous-flow.spec.ts +308 -0
  475. package/e2e/debug-sso.spec.ts +163 -0
  476. package/e2e/direct-campaigns.spec.ts +219 -0
  477. package/e2e/explore-sso.spec.ts +149 -0
  478. package/e2e/fixtures/auth.ts +26 -0
  479. package/e2e/fixtures/enhanced-test.ts +331 -0
  480. package/e2e/pagination.spec.ts +280 -0
  481. package/e2e/view-toggle.spec.ts +312 -0
  482. package/generated-icon.png +0 -0
  483. package/i18next-scanner.config.cjs +46 -0
  484. package/package.json +141 -0
  485. package/playwright.config.ts +93 -0
  486. package/postcss.config.js +6 -0
  487. package/replit.md +196 -0
  488. package/screenshot-after-login.png +0 -0
  489. package/screenshot-contenthub-grid.png +0 -0
  490. package/screenshot-contenthub-list-fixed.png +0 -0
  491. package/screenshot-contenthub-list.png +0 -0
  492. package/screenshot-create-deal.png +0 -0
  493. package/screenshot-dashboard.png +0 -0
  494. package/screenshot-deals.png +0 -0
  495. package/screenshot-login-filled.png +0 -0
  496. package/screenshot-login.png +0 -0
  497. package/screenshot.mjs +24 -0
  498. package/scripts/deploy-stg.sh +185 -0
  499. package/shared/direct-io-schema.ts +383 -0
  500. package/shared/schema.ts +439 -0
  501. package/shared/screen-types.ts +149 -0
  502. package/springdocDefault.json +1 -0
  503. package/swagger-ui-bundle.js +2 -0
  504. package/swagger-ui-init.js +10316 -0
  505. package/tailwind.config.ts +282 -0
  506. package/terraform/README.md +306 -0
  507. package/terraform/cloudfront.tf +289 -0
  508. package/terraform/ecs.tf +727 -0
  509. package/terraform/environments/dev.tfvars +59 -0
  510. package/terraform/environments/production.tfvars +60 -0
  511. package/terraform/main.tf +47 -0
  512. package/terraform/outputs.tf +145 -0
  513. package/terraform/s3.tf +192 -0
  514. package/terraform/variables.tf +226 -0
  515. package/terraform/waf.tf +165 -0
  516. package/terraform-frontend/.terraform.lock.hcl +25 -0
  517. package/terraform-frontend/README.md +85 -0
  518. package/terraform-frontend/cloudfront.tf +125 -0
  519. package/terraform-frontend/main.tf +31 -0
  520. package/terraform-frontend/outputs.tf +24 -0
  521. package/terraform-frontend/terraform.tfvars +12 -0
  522. package/terraform-frontend/variables.tf +53 -0
  523. package/tsconfig.json +23 -0
  524. package/vite.config.ts +226 -0
  525. package/vitest.config.ts +56 -0
@@ -0,0 +1,3261 @@
1
+ import { useState, useEffect, useMemo, useCallback, useRef } from "react";
2
+ import { useParams, useLocation } from "wouter";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useForm, useFieldArray } from "react-hook-form";
5
+ import { zodResolver } from "@hookform/resolvers/zod";
6
+ import { z } from "zod";
7
+ import { Button, cn } from "@moving-walls/design-system";
8
+ import { Input } from "@moving-walls/design-system";
9
+ import { Card, CardContent, CardHeader, CardTitle } from "@moving-walls/design-system";
10
+ import { Badge } from "@moving-walls/design-system";
11
+ import { Label } from "@moving-walls/design-system";
12
+ import { Switch } from "@moving-walls/design-system";
13
+ import { Slider } from "@moving-walls/design-system";
14
+ import { Checkbox } from "@moving-walls/design-system";
15
+ import {
16
+ SelectRoot,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from "@moving-walls/design-system";
22
+ import {
23
+ Collapsible,
24
+ CollapsibleContent,
25
+ CollapsibleTrigger,
26
+ } from "@moving-walls/design-system";
27
+ import { FormInsights } from "@/components/line-items/form-insights";
28
+ import { CampaignForecastPanel } from "@/components/line-items/campaign-forecast-panel";
29
+ import { DateRangePicker } from "@moving-walls/design-system";
30
+ import {
31
+ ArrowLeft,
32
+ ChevronDown,
33
+ ChevronUp,
34
+ FileText,
35
+ Palette,
36
+ Target,
37
+ Server,
38
+ Monitor,
39
+ Calendar,
40
+ Receipt,
41
+ DollarSign,
42
+ TrendingUp,
43
+ Plus,
44
+ Trash2,
45
+ Settings,
46
+ MapPin,
47
+ Users,
48
+ Layers,
49
+ Clock,
50
+ Loader2,
51
+ Map,
52
+ Pencil,
53
+ X,
54
+ Cloud,
55
+ } from "lucide-react";
56
+ import { useToast } from "@/hooks/use-toast";
57
+ import { usePageTitle } from "@/hooks/use-page-title";
58
+ import { useTranslation } from "@/lib/i18n";
59
+ import {
60
+ InfluenceDealsAPI,
61
+ influenceDealsRequest,
62
+ generateExternalId,
63
+ formatAPIErrorForToast,
64
+ mapInventoryForAPI,
65
+ mapLineItemForAPI,
66
+ type Deal,
67
+ } from "@/lib/influence-deals-api";
68
+ import { getDsp } from "@/lib/dsp-buyer-api";
69
+ import { getTodayDateString, toAPIDateString, fromAPIDateString } from "@/lib/date-utils";
70
+ import {
71
+ submitRecommendation,
72
+ pollUntilCompleted,
73
+ getRecommendationResults,
74
+ type RecommendationRequest,
75
+ type RecommendedInventory,
76
+ type PollingProgress,
77
+ } from "@/lib/recommendation-api";
78
+ import { useAuth } from "@/contexts/auth-context";
79
+ import {
80
+ Sheet,
81
+ SheetContent,
82
+ SheetHeader,
83
+ SheetFooter,
84
+ SheetTitle,
85
+ SheetDescription,
86
+ } from "@moving-walls/design-system";
87
+ import {
88
+ Tabs,
89
+ TabsContent,
90
+ TabsList,
91
+ TabsTrigger,
92
+ } from "@moving-walls/design-system";
93
+ import { ScrollArea } from "@moving-walls/design-system";
94
+ import { PlannerInventoryCard } from "@/components/line-items/planner-inventory-card";
95
+ import { GeofencingMap } from "@/components/line-items/geofencing-map";
96
+ import { ScheduleRuleEditor } from "@/components/line-items/schedule-rule-editor";
97
+ import {
98
+ ScheduleRule,
99
+ createDefaultSchedule,
100
+ formatScheduleHours,
101
+ formatScheduleDays,
102
+ getScheduleDisplayName,
103
+ } from "@/components/line-items/schedule-rule-types";
104
+ import { Search, Upload, Check, Eye, Sparkles } from "lucide-react";
105
+ import { searchInventories, fetchVenueTypes, fetchCompanies, fetchInventoryTypes, fetchInventoryDisplayFormats, fetchPanelResolutions, fetchPriceDurations, type InventoryItem, type VenueType, type Company, type InventoryTypeItem, type InventoryDisplayFormat } from "@/lib/inventory-api";
106
+ import { ManualInventoryDrawer } from "@/components/line-items/manual-inventory-drawer";
107
+ import { InventoryAvailabilitySection } from "@/components/line-items/inventory-availability-section";
108
+
109
+ const customFeeSchema = z.object({
110
+ name: z.string().min(1, "Fee name is required"),
111
+ amount: z.number().min(0, "Amount must be positive"),
112
+ type: z.enum(["fixed", "percentage"]),
113
+ invoiced: z.boolean().default(true),
114
+ }).refine((data) => {
115
+ if (data.type === "percentage" && data.amount > 100) {
116
+ return false;
117
+ }
118
+ return true;
119
+ }, { message: "Percentage cannot exceed 100%", path: ["amount"] });
120
+
121
+ const frequencyCapSchema = z.object({
122
+ period: z.enum(["hour", "day", "week", "month", "lifetime"]),
123
+ target: z.number().min(0, "Target must be positive"),
124
+ }).optional();
125
+
126
+ const lineItemFormSchema = z.object({
127
+ name: z.string().min(1, "Line item name is required").max(200, "Line item name must not exceed 200 characters"),
128
+ status: z.enum(["DRAFT", "GENERATED", "ACTIVE", "PAUSED"]).default("DRAFT"),
129
+ copyFromLineItemId: z.string().optional(),
130
+ creativeType: z.enum(["DISPLAY", "VIDEO", "AUDIO"]).default("VIDEO"),
131
+ priority: z.number().min(1).max(10).default(5),
132
+ startDate: z.string().min(1, "Start date is required"),
133
+ endDate: z.string().min(1, "End date is required"),
134
+ currency: z.string().default("USD"),
135
+ mediaType: z.string().default("DOOH"),
136
+ geography: z.array(z.string()).default([]),
137
+ demographics: z.object({
138
+ ageGroups: z.array(z.string()).default([]),
139
+ genders: z.array(z.string()).default([]),
140
+ }).default({}),
141
+ venueTypes: z.array(z.string()).default([]),
142
+ selectedPOIs: z.array(z.string()).default([]),
143
+ adResolution: z.string().optional(),
144
+ adResolutions: z.array(z.string()).default([]),
145
+ adDuration: z.number().default(10),
146
+ sspExchange: z.string().optional(),
147
+ mediaOwner: z.string().optional(),
148
+ inventoryFormat: z.string().optional(),
149
+ selectedInventories: z.array(z.string()).default([]),
150
+ schedules: z.array(z.object({
151
+ id: z.string(),
152
+ type: z.enum(["DEFAULT", "WEEKDAY", "WEEKEND", "CUSTOM"]),
153
+ validity: z.object({
154
+ startDate: z.string(),
155
+ endDate: z.string(),
156
+ }),
157
+ hours: z.array(z.object({
158
+ start: z.number(),
159
+ end: z.number(),
160
+ })),
161
+ priority: z.number().optional(),
162
+ daysOfWeek: z.array(z.number()).optional(),
163
+ date: z.string().optional(),
164
+ name: z.string().optional(),
165
+ })).default([]),
166
+ billable: z.boolean().default(true),
167
+ totalBudget: z.number().min(0, "Total budget must be positive").optional(),
168
+ budgetConsumption: z.enum(["daily", "weekly", "monthly", "lifetime"]).default("daily"),
169
+ dailyBudget: z.number().min(0, "Daily budget must be positive").optional(),
170
+ pacing: z.enum(["even", "asap", "front-loaded"]).default("even"),
171
+ trafficAllocation: z.number().min(0).max(100).default(100),
172
+ sov: z.number().min(0).max(100).default(10),
173
+ maxBid: z.number().min(0, "Max bid must be positive").optional(),
174
+ bidType: z.enum(["cpm", "cps"]).default("cpm"),
175
+ auctionType: z.string().optional(),
176
+ customFees: z.array(customFeeSchema).default([]),
177
+ bidFloorBase: z.number().min(0, "Bid floor base must be positive").optional(),
178
+ cpmBase: z.number().min(0, "CPM base must be positive").optional(),
179
+ estimatedCostBase: z.number().min(0, "Estimated cost base must be positive").optional(),
180
+ frequencyCap: z.object({
181
+ period: z.enum(["hour", "day", "week", "month", "lifetime"]),
182
+ target: z.number().min(0),
183
+ }).optional(),
184
+ doohInventories: z.array(z.string()).default([]),
185
+ inventoryFormats: z.array(z.string()).default([]),
186
+ inventorySource: z.string().optional(),
187
+ weatherSignalEnabled: z.boolean().default(false),
188
+ weatherConditions: z.array(z.string()).default([]),
189
+ weatherTempEnabled: z.boolean().default(false),
190
+ weatherTempMin: z.number().optional(),
191
+ weatherTempMax: z.number().optional(),
192
+ weatherTempUnit: z.enum(["celsius", "fahrenheit"]).default("celsius"),
193
+ });
194
+
195
+ type LineItemFormData = z.infer<typeof lineItemFormSchema>;
196
+
197
+ const AGE_GROUPS = ["18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
198
+ const GENDERS = ["Male", "Female", "Other"];
199
+ const POI_OPTIONS = [
200
+ { value: "accounting", label: "Accounting" },
201
+ { value: "airport", label: "Airport" },
202
+ { value: "amusement_park", label: "Amusement Park" },
203
+ { value: "aquarium", label: "Aquarium" },
204
+ { value: "atm", label: "ATM" },
205
+ { value: "bank", label: "Bank" },
206
+ { value: "bar", label: "Bar" },
207
+ { value: "beauty_salon", label: "Beauty Salon" },
208
+ { value: "cafe", label: "Cafe" },
209
+ { value: "hospital", label: "Hospital" },
210
+ { value: "mall", label: "Shopping Mall" },
211
+ { value: "restaurant", label: "Restaurant" },
212
+ { value: "school", label: "School" },
213
+ { value: "university", label: "University" },
214
+ ];
215
+ const FALLBACK_RESOLUTIONS = ["1920x1080", "1280x720", "3840x2160", "4096x2160", "1080x1920"];
216
+ const FALLBACK_DURATIONS = [5, 10, 15, 20, 30];
217
+
218
+ const WEATHER_CONDITIONS = [
219
+ { value: "sunny", label: "Sunny" },
220
+ { value: "partly_cloudy", label: "Partly Cloudy" },
221
+ { value: "cloudy", label: "Cloudy" },
222
+ { value: "rainy", label: "Rainy" },
223
+ { value: "stormy", label: "Stormy" },
224
+ { value: "snowy", label: "Snowy" },
225
+ { value: "windy", label: "Windy" },
226
+ { value: "foggy", label: "Foggy" },
227
+ { value: "hazy", label: "Hazy" },
228
+ ];
229
+
230
+ const MAX_POIS_PER_LOCATION = 5;
231
+
232
+ const COUNTRIES = [
233
+ { value: "Malaysia", code: "MY", currency: "MYR" },
234
+ { value: "Singapore", code: "SG", currency: "SGD" },
235
+ { value: "Japan", code: "JP", currency: "JPY" },
236
+ { value: "Indonesia", code: "ID", currency: "IDR" },
237
+ { value: "Thailand", code: "TH", currency: "THB" },
238
+ { value: "Philippines", code: "PH", currency: "PHP" },
239
+ { value: "Australia", code: "AU", currency: "AUD" },
240
+ { value: "United States", code: "US", currency: "USD" },
241
+ { value: "United Kingdom", code: "GB", currency: "GBP" },
242
+ { value: "Germany", code: "DE", currency: "EUR" },
243
+ { value: "France", code: "FR", currency: "EUR" },
244
+ { value: "India", code: "IN", currency: "INR" },
245
+ { value: "China", code: "CN", currency: "CNY" },
246
+ { value: "South Korea", code: "KR", currency: "KRW" },
247
+ { value: "UAE", code: "AE", currency: "AED" },
248
+ ];
249
+
250
+ const getCountryCode = (countryName: string): string => {
251
+ const country = COUNTRIES.find((c) => c.value === countryName || c.code === countryName);
252
+ return country?.code || countryName;
253
+ };
254
+
255
+ interface AccordionSectionProps {
256
+ id: string;
257
+ title: string;
258
+ icon: React.ReactNode;
259
+ isOpen: boolean;
260
+ onToggle: () => void;
261
+ children: React.ReactNode;
262
+ badge?: string;
263
+ }
264
+
265
+ function AccordionSection({ id, title, icon, isOpen, onToggle, children, badge }: AccordionSectionProps) {
266
+ return (
267
+ <Collapsible open={isOpen} onOpenChange={onToggle}>
268
+ <CollapsibleTrigger asChild>
269
+ <div
270
+ className="flex items-center justify-between p-4 bg-mw-neutral-50 dark:bg-mw-neutral-800 rounded-lg cursor-pointer hover:bg-mw-neutral-100 dark:hover:bg-mw-neutral-700 transition-colors"
271
+ data-testid={`section-trigger-${id}`}
272
+ >
273
+ <div className="flex items-center gap-3">
274
+ <span className="text-mw-primary-500">{icon}</span>
275
+ <span className="font-semibold text-sm text-mw-neutral-900 dark:text-white">{title}</span>
276
+ {badge && (
277
+ <Badge variant="secondary" className="text-xs">
278
+ {badge}
279
+ </Badge>
280
+ )}
281
+ </div>
282
+ {isOpen ? (
283
+ <ChevronUp className="h-4 w-4 text-mw-neutral-500" />
284
+ ) : (
285
+ <ChevronDown className="h-4 w-4 text-mw-neutral-500" />
286
+ )}
287
+ </div>
288
+ </CollapsibleTrigger>
289
+ <CollapsibleContent>
290
+ <div className="p-4 border border-t-0 border-mw-neutral-200 dark:border-mw-neutral-700 rounded-b-lg bg-white dark:bg-mw-neutral-900">
291
+ {children}
292
+ </div>
293
+ </CollapsibleContent>
294
+ </Collapsible>
295
+ );
296
+ }
297
+
298
+ function PlainSection({ title, icon, children, badge }: { title: string; icon: React.ReactNode; children: React.ReactNode; badge?: string }) {
299
+ return (
300
+ <div className="bg-white dark:bg-mw-neutral-900 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg">
301
+ <div className="flex items-center gap-3 p-4 border-b border-mw-neutral-100 dark:border-mw-neutral-800">
302
+ <span className="text-mw-primary-500">{icon}</span>
303
+ <span className="font-semibold text-sm text-mw-neutral-900 dark:text-white">{title}</span>
304
+ {badge && (
305
+ <Badge variant="secondary" className="text-xs">
306
+ {badge}
307
+ </Badge>
308
+ )}
309
+ </div>
310
+ <div className="p-4">
311
+ {children}
312
+ </div>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ export default function LineItemFormPage() {
318
+ usePageTitle("Line Item Form");
319
+ const { t } = useTranslation("lineItems");
320
+ const { dealId, lineItemId } = useParams<{ dealId: string; lineItemId?: string }>();
321
+ const [, setLocation] = useLocation();
322
+ const queryClient = useQueryClient();
323
+ const { toast } = useToast();
324
+ const { user } = useAuth();
325
+
326
+ const isEditMode = !!lineItemId;
327
+
328
+ const [openSections, setOpenSections] = useState<Record<string, boolean>>({
329
+ details: true,
330
+ creative: true,
331
+ targeting: true,
332
+ inventorySource: true,
333
+ inventorySelection: false,
334
+ schedule: false,
335
+ billing: false,
336
+ budget: false,
337
+ bidStrategy: false,
338
+ customFees: false,
339
+ pacing: false,
340
+ advanced: false,
341
+ signals: false,
342
+ });
343
+
344
+ const [isSaving, setIsSaving] = useState(false);
345
+ const [fetchedInventoryCount, setFetchedInventoryCount] = useState<number | null>(null);
346
+ const [isInventoryLoading, setIsInventoryLoading] = useState(false);
347
+ const [pollingProgress, setPollingProgress] = useState<PollingProgress | null>(null);
348
+ const [recommendedInventories, setRecommendedInventories] = useState<RecommendedInventory[]>([]);
349
+ const [recommendationRunId, setRecommendationRunId] = useState<string | null>(null);
350
+ const [isGeoMapSheetOpen, setIsGeoMapSheetOpen] = useState(false);
351
+ const [inventorySearchQuery, setInventorySearchQuery] = useState("");
352
+ const [geoLocations, setGeoLocations] = useState<any[]>([]);
353
+ const [locationSearchQuery, setLocationSearchQuery] = useState("");
354
+ const [enableAllLocations, setEnableAllLocations] = useState(true);
355
+ const [isScheduleEditorOpen, setIsScheduleEditorOpen] = useState(false);
356
+ const [editingSchedule, setEditingSchedule] = useState<ScheduleRule | null>(null);
357
+ const [editingScheduleIndex, setEditingScheduleIndex] = useState<number | null>(null);
358
+ const [addPOIMode, setAddPOIMode] = useState(false);
359
+ const [selectedLocationIndex, setSelectedLocationIndex] = useState<number | null>(null);
360
+ const [shouldCenterOnLocation, setShouldCenterOnLocation] = useState(false);
361
+ const [isManualEditOpen, setIsManualEditOpen] = useState(false);
362
+ const [manualEditScreens, setManualEditScreens] = useState<InventoryItem[]>([]);
363
+ const [manualEditLoading, setManualEditLoading] = useState(false);
364
+ const [manualEditSelection, setManualEditSelection] = useState<string[]>([]);
365
+ const [isMediaOwnerSheetOpen, setIsMediaOwnerSheetOpen] = useState(false);
366
+ const [mediaOwnerSearch, setMediaOwnerSearch] = useState("");
367
+ const [selectedMediaOwners, setSelectedMediaOwners] = useState<string[]>([]);
368
+ const [mediaOwnerTypeFilter, setMediaOwnerTypeFilter] = useState("all");
369
+ const [isSSPSheetOpen, setIsSSPSheetOpen] = useState(false);
370
+ const [isInventoryTypeSheetOpen, setIsInventoryTypeSheetOpen] = useState(false);
371
+ const [isInventoryFormatSheetOpen, setIsInventoryFormatSheetOpen] = useState(false);
372
+ const [isPOIDrawerOpen, setIsPOIDrawerOpen] = useState(false);
373
+ const [selectedPOIs, setSelectedPOIs] = useState<string[]>([]);
374
+ const [poiSearchQuery, setPOISearchQuery] = useState("");
375
+ const [localPOIs, setLocalPOIs] = useState<string[]>([]);
376
+ const [isVenueDrawerOpen, setIsVenueDrawerOpen] = useState(false);
377
+ const [venueSearchQuery, setVenueSearchQuery] = useState("");
378
+ const [localVenueTypes, setLocalVenueTypes] = useState<string[]>([]);
379
+ const [expandedVenueParents, setExpandedVenueParents] = useState<Set<string>>(new Set());
380
+ const [selectedInventoryTypePaths, setSelectedInventoryTypePaths] = useState<string[]>([]);
381
+
382
+ useEffect(() => {
383
+ form.setValue("doohInventories", selectedInventoryTypePaths);
384
+ }, [selectedInventoryTypePaths]);
385
+
386
+ const toggleSection = (sectionId: string) => {
387
+ setOpenSections((prev) => ({
388
+ ...prev,
389
+ [sectionId]: !prev[sectionId],
390
+ }));
391
+ };
392
+
393
+ const handleAddSchedule = () => {
394
+ const startDate = form.watch("startDate") || getTodayDateString();
395
+ const endDate = form.watch("endDate") || startDate;
396
+ const newSchedule = createDefaultSchedule(startDate, endDate);
397
+ setEditingSchedule(newSchedule);
398
+ setEditingScheduleIndex(null);
399
+ setIsScheduleEditorOpen(true);
400
+ };
401
+
402
+ const handleEditSchedule = (schedule: ScheduleRule, index: number) => {
403
+ setEditingSchedule({ ...schedule });
404
+ setEditingScheduleIndex(index);
405
+ setIsScheduleEditorOpen(true);
406
+ };
407
+
408
+ const handleSaveSchedule = (updatedSchedule: ScheduleRule) => {
409
+ const currentSchedules = form.watch("schedules") || [];
410
+ if (editingScheduleIndex !== null) {
411
+ const newSchedules = [...currentSchedules];
412
+ newSchedules[editingScheduleIndex] = updatedSchedule;
413
+ form.setValue("schedules", newSchedules);
414
+ } else {
415
+ form.setValue("schedules", [...currentSchedules, updatedSchedule]);
416
+ }
417
+ setIsScheduleEditorOpen(false);
418
+ setEditingSchedule(null);
419
+ setEditingScheduleIndex(null);
420
+ };
421
+
422
+ const handleCancelScheduleEdit = () => {
423
+ setIsScheduleEditorOpen(false);
424
+ setEditingSchedule(null);
425
+ setEditingScheduleIndex(null);
426
+ };
427
+
428
+ const handleDeleteSchedule = (index: number) => {
429
+ const currentSchedules = form.watch("schedules") || [];
430
+ form.setValue("schedules", currentSchedules.filter((_: any, i: number) => i !== index));
431
+ };
432
+
433
+ const removePoiFromLocation = (locationIndex: number, poiType: string) => {
434
+ setGeoLocations((prev) =>
435
+ prev.map((loc, idx) => {
436
+ if (idx === locationIndex) {
437
+ const updatedPois = (loc.properties?.pois || []).filter(
438
+ (p: { type: string }) => p.type !== poiType
439
+ );
440
+ return {
441
+ ...loc,
442
+ poi: updatedPois.map((p: { type: string }) => p.type),
443
+ properties: {
444
+ ...loc.properties,
445
+ pois: updatedPois,
446
+ },
447
+ };
448
+ }
449
+ return loc;
450
+ })
451
+ );
452
+ };
453
+
454
+ const { data: dealData, isLoading: isLoadingDeal } = useQuery({
455
+ queryKey: ["deal", dealId],
456
+ queryFn: async () => {
457
+ const response = await influenceDealsRequest<Deal>(
458
+ InfluenceDealsAPI.deals.get(dealId!)
459
+ );
460
+ return response;
461
+ },
462
+ enabled: !!dealId,
463
+ });
464
+
465
+ const { data: existingLineItem, isLoading: isLoadingLineItem } = useQuery({
466
+ queryKey: ["lineItem", dealId, lineItemId],
467
+ queryFn: async () => {
468
+ const response = await influenceDealsRequest<any>(
469
+ InfluenceDealsAPI.lineItems.get(dealId!, lineItemId!)
470
+ );
471
+ return response;
472
+ },
473
+ enabled: !!dealId && !!lineItemId,
474
+ });
475
+
476
+ const { data: existingLineItems } = useQuery({
477
+ queryKey: ["lineItems", dealId],
478
+ queryFn: async () => {
479
+ const response = await influenceDealsRequest<any>(
480
+ InfluenceDealsAPI.lineItems.list(dealId!)
481
+ );
482
+ return response?.data || [];
483
+ },
484
+ enabled: !!dealId,
485
+ });
486
+
487
+ const { data: venueTypesData = [], isLoading: isLoadingVenueTypes } = useQuery({
488
+ queryKey: ["venueTypes"],
489
+ queryFn: fetchVenueTypes,
490
+ staleTime: 0,
491
+ gcTime: 0,
492
+ enabled: isVenueDrawerOpen,
493
+ });
494
+
495
+ const { data: panelResolutions = [] } = useQuery({
496
+ queryKey: ["panelResolutions"],
497
+ queryFn: fetchPanelResolutions,
498
+ staleTime: 5 * 60 * 1000,
499
+ });
500
+
501
+ const { data: priceDurations = [] } = useQuery({
502
+ queryKey: ["priceDurations"],
503
+ queryFn: fetchPriceDurations,
504
+ staleTime: 5 * 60 * 1000,
505
+ });
506
+
507
+ const { data: companiesData = [], isLoading: isLoadingCompanies } = useQuery({
508
+ queryKey: ["companies-ssp"],
509
+ queryFn: fetchCompanies,
510
+ staleTime: 0,
511
+ gcTime: 0,
512
+ enabled: isMediaOwnerSheetOpen,
513
+ });
514
+
515
+ const { data: parentInventoryTypes = [], isLoading: isLoadingInventoryTypes } = useQuery({
516
+ queryKey: ["inventoryTypes"],
517
+ queryFn: () => fetchInventoryTypes(),
518
+ staleTime: 1000 * 60 * 10,
519
+ });
520
+
521
+ const { data: inventoryTypeChildren = {} } = useQuery({
522
+ queryKey: ["inventoryTypeChildren", parentInventoryTypes.map(p => p.path)],
523
+ queryFn: async () => {
524
+ const result: Record<string, InventoryTypeItem[]> = {};
525
+ await Promise.all(
526
+ parentInventoryTypes.map(async (parent) => {
527
+ const children = await fetchInventoryTypes(parent.path);
528
+ result[parent.path] = children;
529
+ })
530
+ );
531
+ return result;
532
+ },
533
+ enabled: parentInventoryTypes.length > 0,
534
+ staleTime: 1000 * 60 * 10,
535
+ });
536
+
537
+ const { data: inventoryDisplayFormats = [], isLoading: isLoadingDisplayFormats } = useQuery({
538
+ queryKey: ["inventoryDisplayFormats"],
539
+ queryFn: fetchInventoryDisplayFormats,
540
+ staleTime: 0,
541
+ gcTime: 0,
542
+ enabled: isInventoryFormatSheetOpen,
543
+ });
544
+
545
+ interface VenueTreeNode extends VenueType {
546
+ children: VenueTreeNode[];
547
+ }
548
+
549
+ const venueTypeTree = useMemo((): VenueTreeNode[] => {
550
+ if (!venueTypesData.length) return [];
551
+ const nodeMap: Record<string, VenueTreeNode> = {};
552
+ venueTypesData.forEach((vt) => {
553
+ nodeMap[vt.taxonomyId] = { ...vt, children: [] };
554
+ });
555
+ const roots: VenueTreeNode[] = [];
556
+ venueTypesData.forEach((vt) => {
557
+ const node = nodeMap[vt.taxonomyId];
558
+ if (vt.parentId && nodeMap[vt.parentId]) {
559
+ nodeMap[vt.parentId].children.push(node);
560
+ } else if (!vt.parentId) {
561
+ roots.push(node);
562
+ }
563
+ });
564
+ return roots;
565
+ }, [venueTypesData]);
566
+
567
+ const formatGroups = useMemo(() => {
568
+ const groups: Record<string, { groupName: string; formats: InventoryDisplayFormat[] }> = {};
569
+ inventoryDisplayFormats.forEach(fmt => {
570
+ if (!groups[fmt.inventoryTypePath]) {
571
+ let groupName = fmt.inventoryTypePath;
572
+ for (const parent of parentInventoryTypes) {
573
+ const children = inventoryTypeChildren[parent.path] || [];
574
+ const child = children.find(c => c.path === fmt.inventoryTypePath);
575
+ if (child) {
576
+ groupName = `${parent.name} - ${child.name}`;
577
+ break;
578
+ }
579
+ }
580
+ groups[fmt.inventoryTypePath] = { groupName, formats: [] };
581
+ }
582
+ groups[fmt.inventoryTypePath].formats.push(fmt);
583
+ });
584
+ return groups;
585
+ }, [inventoryDisplayFormats, parentInventoryTypes, inventoryTypeChildren]);
586
+
587
+ const dealMode = dealData?.mode || "DIRECT";
588
+ const dealCurrency = dealData?.currency || "USD";
589
+
590
+ // Fetch DSP data for PROGRAMMATIC deals to determine allowed creative types
591
+ const dspIds = useMemo(() => {
592
+ if (dealMode !== "PROGRAMMATIC") return [];
593
+ return [...new Set((dealData?.programmatic?.buyers || []).map((b) => b.dsp).filter(Boolean))];
594
+ }, [dealMode, dealData]);
595
+
596
+ const { data: dspDataList = [] } = useQuery({
597
+ queryKey: ["dsps-for-deal", dspIds],
598
+ queryFn: () => Promise.all(dspIds.map((id) => getDsp(id))),
599
+ enabled: dspIds.length > 0,
600
+ });
601
+
602
+ const allowedCreativeTypes = useMemo<string[]>(() => {
603
+ const all = ["DISPLAY", "VIDEO", "AUDIO"];
604
+ if (dealMode !== "PROGRAMMATIC" || dspDataList.length === 0) return all;
605
+ const union = new Set<string>();
606
+ dspDataList.forEach((dsp) => (dsp.creativeType || []).forEach((t) => union.add(t.toUpperCase())));
607
+ return all.filter((t) => union.has(t));
608
+ }, [dealMode, dspDataList]);
609
+
610
+ const form = useForm<LineItemFormData>({
611
+ resolver: zodResolver(lineItemFormSchema),
612
+ defaultValues: {
613
+ name: "",
614
+ status: "DRAFT",
615
+ creativeType: "VIDEO",
616
+ priority: 5,
617
+ startDate: getTodayDateString(),
618
+ endDate: getTodayDateString(),
619
+ currency: dealCurrency,
620
+ mediaType: "DOOH",
621
+ geography: [],
622
+ demographics: { ageGroups: [], genders: [] },
623
+ venueTypes: [],
624
+ selectedPOIs: [],
625
+ adResolutions: [],
626
+ adDuration: 10,
627
+ selectedInventories: [],
628
+ schedules: [],
629
+ billable: true,
630
+ budgetConsumption: "daily",
631
+ pacing: "even",
632
+ trafficAllocation: 100,
633
+ sov: 10,
634
+ bidType: "cpm",
635
+ customFees: [],
636
+ bidFloorBase: undefined,
637
+ cpmBase: undefined,
638
+ estimatedCostBase: undefined,
639
+ },
640
+ });
641
+
642
+ const { fields: feeFields, append: appendFee, remove: removeFee } = useFieldArray({
643
+ control: form.control,
644
+ name: "customFees",
645
+ });
646
+
647
+ useEffect(() => {
648
+ if (dealData?.currency) {
649
+ form.setValue("currency", dealData.currency);
650
+ }
651
+ }, [dealData, form]);
652
+
653
+ useEffect(() => {
654
+ if (existingLineItem && isEditMode) {
655
+ const li = existingLineItem as any;
656
+ form.reset({
657
+ name: li.name || "",
658
+ status: li.status || "DRAFT",
659
+ creativeType: li.creativeType || "VIDEO",
660
+ priority: li.priority || 5,
661
+ startDate: li.startDate || getTodayDateString(),
662
+ endDate: li.endDate || getTodayDateString(),
663
+ currency: li.currency || dealCurrency,
664
+ mediaType: "DOOH",
665
+ geography: li.targeting?.geofencing?.locations?.map((l: any) => l.name) || [],
666
+ demographics: li.targeting?.demographics || { ageGroups: [], genders: [] },
667
+ venueTypes: li.targeting?.venueTypes || [],
668
+ selectedPOIs: li.targeting?.selectedPOIs || [],
669
+ adDuration: li.duration || 10,
670
+ selectedInventories: li.inventories?.map((inv: any) => inv.id) || [],
671
+ schedules: li.schedule || [],
672
+ billable: li.billable !== false,
673
+ totalBudget: li.direct?.budgetSetup?.budgetAmount,
674
+ budgetConsumption: "daily",
675
+ dailyBudget: li.direct?.pacing?.dailyCap,
676
+ pacing: li.pacing?.type || "even",
677
+ trafficAllocation: 100,
678
+ sov: li.planning?.allocation?.sov || 10,
679
+ maxBid: li.programmatic?.bidFloor,
680
+ bidFloorBase: li.programmatic?.bidFloorBase,
681
+ cpmBase: li.pricing?.cpmBase,
682
+ estimatedCostBase: li.pricing?.estimatedCostBase,
683
+ bidType: "cpm",
684
+ customFees: [],
685
+ weatherSignalEnabled: !!(li.deliveryTargeting?.signals?.weather),
686
+ weatherConditions: li.deliveryTargeting?.signals?.weather?.conditions || [],
687
+ weatherTempEnabled: !!(li.deliveryTargeting?.signals?.weather?.temperature),
688
+ weatherTempMin: li.deliveryTargeting?.signals?.weather?.temperature?.min,
689
+ weatherTempMax: li.deliveryTargeting?.signals?.weather?.temperature?.max,
690
+ weatherTempUnit: li.deliveryTargeting?.signals?.weather?.temperature?.unit || "celsius",
691
+ });
692
+
693
+ // Initialize selectedPOIs state from form when loading existing line item
694
+ setSelectedPOIs(li.targeting?.selectedPOIs || []);
695
+
696
+ // Initialize geoLocations state from existing geofencing data
697
+ if (li.targeting?.geofencing?.locations?.length > 0) {
698
+ const existingGeoLocations = li.targeting.geofencing.locations.map((loc: any, index: number) => ({
699
+ id: `existing-${index}`,
700
+ type: "Feature",
701
+ geometry: loc.lat && loc.lng ? {
702
+ type: "Point",
703
+ coordinates: [loc.lng, loc.lat],
704
+ } : undefined,
705
+ properties: {
706
+ name: loc.name || "Location",
707
+ center: loc.lat && loc.lng ? [loc.lng, loc.lat] : undefined,
708
+ included: true,
709
+ ...(loc.radius ? { radius: loc.radius } : {}),
710
+ },
711
+ }));
712
+ setGeoLocations(existingGeoLocations);
713
+ }
714
+ }
715
+ }, [existingLineItem, isEditMode, dealCurrency, form]);
716
+
717
+ const watchedValues = form.watch();
718
+
719
+ const forecast = useMemo(() => {
720
+ const selectedIds = watchedValues.selectedInventories || [];
721
+ const inventoryCount = selectedIds.length;
722
+ const startDate = watchedValues.startDate ? new Date(watchedValues.startDate) : null;
723
+ const endDate = watchedValues.endDate ? new Date(watchedValues.endDate) : null;
724
+
725
+ if (inventoryCount === 0 || !startDate || !endDate || startDate > endDate) {
726
+ return {
727
+ impressions: 0,
728
+ reach: 0,
729
+ frequency: 0,
730
+ adPlays: 0,
731
+ sov: watchedValues.sov || 10,
732
+ cpm: 0,
733
+ ecpm: 0,
734
+ totalCost: 0,
735
+ sot: 0,
736
+ };
737
+ }
738
+
739
+ const days = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
740
+
741
+ // Use real inventory data when available
742
+ const selectedInvs = recommendedInventories.filter(inv => selectedIds.includes(inv.inventoryId));
743
+
744
+ let estimatedImpressions: number;
745
+ let avgCpm: number;
746
+ let totalCost: number;
747
+
748
+ if (selectedInvs.length > 0) {
749
+ // Calculate from actual inventory data
750
+ const totalDailyImpressions = selectedInvs.reduce(
751
+ (sum, inv) => sum + (inv.forecast?.estimatedImpressions || 10000), 0
752
+ );
753
+ estimatedImpressions = totalDailyImpressions * days;
754
+
755
+ const cpmValues = selectedInvs
756
+ .map(inv => inv.cost?.estimatedCost
757
+ ? (inv.cost.estimatedCost / Math.max(1, (inv.forecast?.estimatedImpressions || 1000) / 1000))
758
+ : null)
759
+ .filter((v): v is number => v !== null && v > 0);
760
+
761
+ avgCpm = cpmValues.length > 0
762
+ ? Math.round((cpmValues.reduce((a, b) => a + b, 0) / cpmValues.length) * 100) / 100
763
+ : 15;
764
+
765
+ const totalInventoryCost = selectedInvs.reduce(
766
+ (sum, inv) => sum + (inv.cost?.estimatedCost || 0), 0
767
+ );
768
+ totalCost = totalInventoryCost > 0 ? totalInventoryCost * days : (estimatedImpressions / 1000) * avgCpm;
769
+ } else {
770
+ // Fallback to estimate
771
+ estimatedImpressions = Math.max(inventoryCount, 1) * 10000 * days;
772
+ avgCpm = 15;
773
+ totalCost = (estimatedImpressions / 1000) * avgCpm;
774
+ }
775
+
776
+ const estimatedReach = Math.floor(estimatedImpressions * 0.6);
777
+
778
+ return {
779
+ impressions: estimatedImpressions,
780
+ reach: estimatedReach,
781
+ frequency: estimatedReach > 0 ? estimatedImpressions / estimatedReach : 0,
782
+ adPlays: Math.max(inventoryCount, 1) * days * 100,
783
+ sov: watchedValues.sov || 10,
784
+ cpm: avgCpm,
785
+ ecpm: Math.round(avgCpm * 1.1 * 100) / 100,
786
+ totalCost,
787
+ sot: Math.min(100, (watchedValues.trafficAllocation || 100)),
788
+ };
789
+ }, [watchedValues, recommendedInventories]);
790
+
791
+ const suggestedMaxBid = useMemo(() => {
792
+ const selectedIds = watchedValues.selectedInventories || [];
793
+ if (selectedIds.length === 0 || recommendedInventories.length === 0) return null;
794
+ const selectedInvs = recommendedInventories.filter(inv => selectedIds.includes(inv.inventoryId));
795
+ if (selectedInvs.length === 0) return null;
796
+ const cpmValues = selectedInvs
797
+ .map(inv => inv.cost?.estimatedCost ? (inv.cost.estimatedCost / Math.max(1, (inv.forecast?.estimatedImpressions || 1000) / 1000)) : null)
798
+ .filter((v): v is number => v !== null && v > 0);
799
+ if (cpmValues.length === 0) return 15.00;
800
+ const avg = cpmValues.reduce((a, b) => a + b, 0) / cpmValues.length;
801
+ return Math.round(avg * 100) / 100;
802
+ }, [watchedValues.selectedInventories, recommendedInventories]);
803
+
804
+ const handleFetchInventory = useCallback(async () => {
805
+ const formValues = form.getValues();
806
+
807
+ if (!formValues.startDate || !formValues.endDate) {
808
+ toast({
809
+ title: "Date Required",
810
+ description: "Please select both start date and end date before fetching inventory",
811
+ variant: "destructive",
812
+ });
813
+ return;
814
+ }
815
+
816
+ setIsInventoryLoading(true);
817
+ setPollingProgress(null);
818
+ try {
819
+ const country = dealData?.country || "Japan";
820
+
821
+ const recommendationRequest: RecommendationRequest = {
822
+ country,
823
+ startDate: formValues.startDate,
824
+ endDate: formValues.endDate,
825
+ budget: formValues.totalBudget,
826
+ goal: "IMPRESSIONS",
827
+ goalValue: forecast.impressions,
828
+ mediaOwnerIds: user?.company_id ? [user.company_id] : undefined,
829
+ geographyTargeting: formValues.geography.length > 0 ? {
830
+ cities: formValues.geography,
831
+ } : undefined,
832
+ audienceTargeting: (formValues.demographics?.ageGroups?.length || formValues.demographics?.genders?.length) ? {
833
+ demographics: {
834
+ ageGroups: formValues.demographics.ageGroups || [],
835
+ genders: (formValues.demographics.genders || []).map((g: string) => g.toLowerCase()),
836
+ },
837
+ } : undefined,
838
+ };
839
+
840
+ const campaignId = lineItemId || dealId || "new";
841
+ const statusResponse = await submitRecommendation(campaignId, recommendationRequest);
842
+
843
+ let finalStatus = statusResponse;
844
+ if (statusResponse.status === "IN_PROGRESS") {
845
+ setPollingProgress({
846
+ status: 'IN_PROGRESS',
847
+ completionPercentage: statusResponse.completionPercentage ?? 0,
848
+ attempt: 0,
849
+ maxAttempts: 150,
850
+ elapsedSeconds: 0,
851
+ });
852
+ finalStatus = await pollUntilCompleted(
853
+ campaignId,
854
+ recommendationRequest,
855
+ 150,
856
+ 2000,
857
+ (progress) => setPollingProgress(progress)
858
+ );
859
+ }
860
+
861
+ const results = await getRecommendationResults(finalStatus.runId, 0, 50);
862
+
863
+ setRecommendationRunId(finalStatus.runId);
864
+ setRecommendedInventories(results.recommendations || []);
865
+ setFetchedInventoryCount(results.pagination?.totalElements || results.recommendations?.length || 0);
866
+
867
+ toast({
868
+ title: "Inventory Fetched",
869
+ description: `Found ${results.pagination?.totalElements || results.recommendations?.length || 0} available screens matching your criteria`,
870
+ });
871
+ } catch (error) {
872
+ console.error("Failed to fetch inventory:", error);
873
+ toast({
874
+ title: "Error",
875
+ description: error instanceof Error ? error.message : "Failed to fetch inventory",
876
+ variant: "destructive",
877
+ });
878
+ } finally {
879
+ setIsInventoryLoading(false);
880
+ setPollingProgress(null);
881
+ }
882
+ }, [toast, form, dealData, lineItemId, dealId, forecast.impressions]);
883
+
884
+ // Keep a stable ref to handleFetchInventory so the date-watch effect
885
+ // always calls the latest version without needing it in the dep array
886
+ const handleFetchInventoryRef = useRef(handleFetchInventory);
887
+ useEffect(() => {
888
+ handleFetchInventoryRef.current = handleFetchInventory;
889
+ }, [handleFetchInventory]);
890
+
891
+ // Auto-fetch recommendations when both dates are set/changed.
892
+ // After fetch, inventories already in form.selectedInventories are
893
+ // automatically shown as selected because PlannerInventoryCard reads
894
+ // from watchedValues.selectedInventories.
895
+ useEffect(() => {
896
+ const startDate = watchedValues.startDate;
897
+ const endDate = watchedValues.endDate;
898
+ if (!startDate || !endDate) return;
899
+
900
+ const timer = setTimeout(() => {
901
+ handleFetchInventoryRef.current();
902
+ }, 500);
903
+
904
+ return () => clearTimeout(timer);
905
+ // eslint-disable-next-line react-hooks/exhaustive-deps
906
+ }, [watchedValues.startDate, watchedValues.endDate]);
907
+
908
+ const handleOpenManualEdit = useCallback(async () => {
909
+ setIsManualEditOpen(true);
910
+ setManualEditSelection(form.getValues("selectedInventories") || []);
911
+ setManualEditLoading(true);
912
+ try {
913
+ const result = await searchInventories({}, 1, 100);
914
+ setManualEditScreens(result.data || []);
915
+ // Total count tracked internally by ManualInventoryDrawer
916
+ } catch (error) {
917
+ console.error("Failed to load screens:", error);
918
+ toast({
919
+ title: "Error",
920
+ description: "Failed to load available screens",
921
+ variant: "destructive",
922
+ });
923
+ } finally {
924
+ setManualEditLoading(false);
925
+ }
926
+ }, [form, toast]);
927
+
928
+ const onSubmit = async (data: LineItemFormData) => {
929
+ setIsSaving(true);
930
+ try {
931
+ const targeting = {
932
+ demographics: data.demographics ? {
933
+ ageGroups: data.demographics.ageGroups || [],
934
+ genders: (data.demographics.genders || []).map((g: string) => g.toLowerCase()),
935
+ } : undefined,
936
+ venueTypes: data.venueTypes,
937
+ geofencing: geoLocations.length > 0 ? {
938
+ locations: geoLocations
939
+ .filter((loc: any) => loc.properties?.included !== false)
940
+ .map((loc: any) => {
941
+ let lat: number | undefined;
942
+ let lng: number | undefined;
943
+
944
+ if (loc.properties?.center) {
945
+ lng = loc.properties.center[0];
946
+ lat = loc.properties.center[1];
947
+ } else if (loc.geometry?.type === 'Point' && loc.geometry?.coordinates) {
948
+ lng = loc.geometry.coordinates[0];
949
+ lat = loc.geometry.coordinates[1];
950
+ } else if (loc.geometry?.type === 'Polygon' && loc.geometry?.coordinates?.[0]) {
951
+ const ring = loc.geometry.coordinates[0] as [number, number][];
952
+ const sumLng = ring.reduce((s: number, c: [number, number]) => s + c[0], 0);
953
+ const sumLat = ring.reduce((s: number, c: [number, number]) => s + c[1], 0);
954
+ lng = sumLng / ring.length;
955
+ lat = sumLat / ring.length;
956
+ }
957
+
958
+ return {
959
+ name: loc.properties?.name || "Location",
960
+ lat: lat ?? 0,
961
+ lng: lng ?? 0,
962
+ ...(loc.properties?.radius ? { radius: loc.properties.radius } : {}),
963
+ };
964
+ }),
965
+ } : undefined,
966
+ };
967
+
968
+ const schedulePayload = (data.schedules || []).map((s: any) => ({
969
+ type: s.type,
970
+ hours: s.hours,
971
+ date: s.date || s.validity?.startDate || data.startDate,
972
+ ...(s.priority !== undefined && { priority: s.priority }),
973
+ ...(s.daysOfWeek?.length && { daysOfWeek: s.daysOfWeek }),
974
+ ...(s.validity && { validity: s.validity }),
975
+ }));
976
+
977
+ const resolutions = data.adResolutions?.length > 0
978
+ ? data.adResolutions
979
+ : data.adResolution ? [data.adResolution] : [];
980
+
981
+ const lineItemPayload: any = {
982
+ name: data.name,
983
+ status: data.status,
984
+ startDate: data.startDate,
985
+ endDate: data.endDate,
986
+ priority: data.priority || 5,
987
+ currency: data.currency || dealCurrency,
988
+ creativeType: data.creativeType,
989
+ duration: data.adDuration,
990
+ creativeSource: "PUBLISHER",
991
+ timezoneId: dealData?.timezoneId || Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Tokyo",
992
+ publisherId: user?.company_id || undefined,
993
+ targeting,
994
+ ...(resolutions.length > 0 && { resolutions }),
995
+ ...(schedulePayload.length > 0 && { schedule: schedulePayload }),
996
+ ...(data.customFees?.length > 0 && { customFees: data.customFees }),
997
+ ...(data.frequencyCap?.target && data.frequencyCap?.period && {
998
+ frequencyCap: {
999
+ period: data.frequencyCap.period,
1000
+ target: data.frequencyCap.target
1001
+ }
1002
+ }),
1003
+ ...(data.trafficAllocation !== undefined && data.trafficAllocation !== 100 && { trafficAllocation: data.trafficAllocation }),
1004
+ ...(data.inventorySource && { inventorySource: data.inventorySource }),
1005
+ };
1006
+
1007
+ if (dealMode === "DIRECT") {
1008
+ // Calculate cpmBase and estimatedCostBase from selected inventories
1009
+ const selectedIds = data.selectedInventories || [];
1010
+ const selectedInvs = recommendedInventories.filter(inv => selectedIds.includes(inv.inventoryId));
1011
+
1012
+ let computedCpmBase = data.cpmBase;
1013
+ let computedEstimatedCostBase = data.estimatedCostBase;
1014
+
1015
+ if (selectedInvs.length > 0) {
1016
+ // Calculate average CPM from inventory cost data
1017
+ const cpmValues = selectedInvs
1018
+ .map(inv => inv.cost?.estimatedCost
1019
+ ? (inv.cost.estimatedCost / Math.max(1, (inv.forecast?.estimatedImpressions || 1000) / 1000))
1020
+ : null)
1021
+ .filter((v): v is number => v !== null && v > 0);
1022
+
1023
+ if (cpmValues.length > 0 && !computedCpmBase) {
1024
+ computedCpmBase = Math.round((cpmValues.reduce((a, b) => a + b, 0) / cpmValues.length) * 100) / 100;
1025
+ }
1026
+
1027
+ // Calculate total estimated cost from inventories
1028
+ const totalEstimatedCost = selectedInvs
1029
+ .reduce((sum, inv) => sum + (inv.cost?.estimatedCost || 0), 0);
1030
+
1031
+ if (totalEstimatedCost > 0 && !computedEstimatedCostBase) {
1032
+ computedEstimatedCostBase = Math.round(totalEstimatedCost * 100) / 100;
1033
+ }
1034
+ }
1035
+
1036
+ lineItemPayload.direct = {
1037
+ budgetSetup: {
1038
+ budgetType: "TOTAL",
1039
+ budgetAmount: data.totalBudget,
1040
+ currency: data.currency || dealCurrency,
1041
+ },
1042
+ campaignGoal: {
1043
+ type: "IMPRESSIONS",
1044
+ targetValue: forecast.impressions,
1045
+ },
1046
+ pacing: {
1047
+ type: data.pacing,
1048
+ dailyCap: data.dailyBudget,
1049
+ },
1050
+ pricing: {
1051
+ cpmBase: computedCpmBase || undefined,
1052
+ estimatedCostBase: computedEstimatedCostBase || undefined,
1053
+ },
1054
+ };
1055
+
1056
+ if (data.weatherSignalEnabled && (data.weatherConditions?.length > 0 || data.weatherTempEnabled)) {
1057
+ lineItemPayload.deliveryTargeting = {
1058
+ signals: {
1059
+ weather: {
1060
+ ...(data.weatherConditions?.length > 0 && { conditions: data.weatherConditions }),
1061
+ ...(data.weatherTempEnabled && (data.weatherTempMin !== undefined || data.weatherTempMax !== undefined) && {
1062
+ temperature: {
1063
+ ...(data.weatherTempMin !== undefined && { min: data.weatherTempMin }),
1064
+ ...(data.weatherTempMax !== undefined && { max: data.weatherTempMax }),
1065
+ unit: data.weatherTempUnit || "celsius",
1066
+ },
1067
+ }),
1068
+ },
1069
+ },
1070
+ };
1071
+ }
1072
+
1073
+ } else {
1074
+ const dealAuctionType = dealData?.programmatic?.auctionType;
1075
+ const formAuctionType = data.auctionType ? parseInt(data.auctionType, 10) : undefined;
1076
+
1077
+ lineItemPayload.pacing = { type: data.pacing.toLowerCase() };
1078
+
1079
+ lineItemPayload.programmatic = {
1080
+ auctionType: formAuctionType || dealAuctionType || 3,
1081
+ bidFloor: data.maxBid || 0,
1082
+ bidFloorBase: data.bidFloorBase || data.maxBid || 0,
1083
+ netCost: data.totalBudget || 0,
1084
+ impressions: forecast.impressions,
1085
+ impMultiplier: { type: "ALL_TIME" },
1086
+ };
1087
+
1088
+ if (dealData?.dealType === "GUARANTEED") {
1089
+ const totalDays = Math.max(1, Math.ceil((new Date(data.endDate).getTime() - new Date(data.startDate).getTime()) / (1000 * 60 * 60 * 24)));
1090
+ const dailyThreshold = Math.ceil((forecast.impressions || 0) / totalDays) || 50;
1091
+ lineItemPayload.programmatic.thresholdCountPerDay = dailyThreshold;
1092
+ }
1093
+
1094
+ }
1095
+
1096
+ const mappedPayload = mapLineItemForAPI(lineItemPayload, dealMode, dealData?.dealType);
1097
+
1098
+ // Build inventories array from selected inventories and recommended inventories
1099
+ const selectedIds = data.selectedInventories || [];
1100
+ const inventoriesPayload = selectedIds.length > 0
1101
+ ? selectedIds.map(id => {
1102
+ // Find the full inventory data from recommended inventories
1103
+ const fullInventory = recommendedInventories.find(inv => inv.inventoryId === id);
1104
+ if (fullInventory) {
1105
+ return {
1106
+ id: fullInventory.inventoryId,
1107
+ name: fullInventory.name,
1108
+ size: fullInventory.inventoryDetails?.sizes?.[0],
1109
+ publisher: fullInventory.inventoryDetails?.mediaOwnerId ? {
1110
+ id: fullInventory.inventoryDetails.mediaOwnerId,
1111
+ name: fullInventory.inventoryDetails.mediaOwnerName,
1112
+ } : undefined,
1113
+ venueType: fullInventory.inventoryDetails?.venueTypes?.[0],
1114
+ latitude: fullInventory.inventoryDetails?.location?.locationCoordinates?.latitude,
1115
+ longitude: fullInventory.inventoryDetails?.location?.locationCoordinates?.longitude,
1116
+ };
1117
+ }
1118
+ // Fallback to just ID if not found in recommendations
1119
+ return { id };
1120
+ })
1121
+ : [];
1122
+
1123
+ if (isEditMode && lineItemId) {
1124
+ // For existing line items: update line item first, then update inventories separately
1125
+ await influenceDealsRequest(
1126
+ InfluenceDealsAPI.lineItems.update(dealId!, lineItemId),
1127
+ "PUT",
1128
+ mappedPayload
1129
+ );
1130
+
1131
+ // Update inventories via separate endpoint if any selected
1132
+ if (inventoriesPayload.length > 0) {
1133
+ await influenceDealsRequest(
1134
+ InfluenceDealsAPI.lineItems.updateInventories(dealId!, lineItemId),
1135
+ "PUT",
1136
+ { inventories: inventoriesPayload }
1137
+ );
1138
+ }
1139
+
1140
+ toast({
1141
+ title: "Success",
1142
+ description: `Line item "${data.name}" updated successfully`,
1143
+ });
1144
+ } else {
1145
+ // For new line items: include inventories directly in the POST body
1146
+ const createPayload = {
1147
+ ...mappedPayload,
1148
+ ...(inventoriesPayload.length > 0 && { inventories: inventoriesPayload }),
1149
+ };
1150
+
1151
+ await influenceDealsRequest(
1152
+ InfluenceDealsAPI.lineItems.create(dealId!),
1153
+ "POST",
1154
+ createPayload
1155
+ );
1156
+ toast({
1157
+ title: "Success",
1158
+ description: `Line item "${data.name}" created successfully`,
1159
+ });
1160
+ }
1161
+
1162
+ queryClient.invalidateQueries({ queryKey: ["lineItems", dealId] });
1163
+ queryClient.invalidateQueries({ queryKey: ["deal-line-items", dealId] });
1164
+ setLocation(`/deals/${dealId}/line-items`);
1165
+ } catch (error) {
1166
+ console.error("Failed to save line item:", error);
1167
+ toast({
1168
+ title: "Error",
1169
+ description: formatAPIErrorForToast(error),
1170
+ variant: "destructive",
1171
+ });
1172
+ } finally {
1173
+ setIsSaving(false);
1174
+ }
1175
+ };
1176
+
1177
+ const handleCancel = () => {
1178
+ setLocation(`/deals/${dealId}/line-items`);
1179
+ };
1180
+
1181
+ const insights = useMemo(() => {
1182
+ const items = [];
1183
+ if (!watchedValues.name) {
1184
+ items.push({ text: t("lineItems:form.messages.noName"), color: "orange" as const });
1185
+ }
1186
+ if (watchedValues.selectedInventories?.length === 0) {
1187
+ items.push({ text: t("lineItems:form.messages.noInventory"), color: "blue" as const });
1188
+ }
1189
+ if (watchedValues.venueTypes?.length === 0) {
1190
+ items.push({ text: t("lineItems:form.messages.noVenue"), color: "blue" as const });
1191
+ }
1192
+ if (items.length === 0) {
1193
+ items.push({ text: t("lineItems:form.messages.configLooks"), color: "green" as const });
1194
+ }
1195
+ return items;
1196
+ }, [watchedValues, t]);
1197
+
1198
+ const quickTips = [
1199
+ { text: t("lineItems:form.tips.startBroad"), color: "blue" as const },
1200
+ { text: t("lineItems:form.tips.usePacing"), color: "green" as const },
1201
+ { text: t("lineItems:form.tips.monitorSOV"), color: "orange" as const },
1202
+ ];
1203
+
1204
+ if (isLoadingDeal || (isEditMode && isLoadingLineItem)) {
1205
+ return (
1206
+ <div className="flex items-center justify-center h-full">
1207
+ <Loader2 className="h-8 w-8 animate-spin text-mw-primary-500" />
1208
+ </div>
1209
+ );
1210
+ }
1211
+
1212
+ const lineItemsForCopy = (existingLineItems || []).filter((li: any) => li.id !== lineItemId);
1213
+
1214
+ return (
1215
+ <div className="flex flex-col h-full">
1216
+ <div className="flex items-center px-6 py-4 border-b border-mw-neutral-200 dark:border-mw-neutral-700 bg-white dark:bg-mw-neutral-900">
1217
+ <div className="flex items-center gap-4">
1218
+ <Button variant="ghost" size="sm" onClick={handleCancel}>
1219
+ <ArrowLeft className="h-4 w-4" />
1220
+ </Button>
1221
+ <div>
1222
+ <h1 className="text-xl font-semibold text-mw-neutral-900 dark:text-white">
1223
+ {isEditMode ? t("lineItems:form.editLineItem") : t("lineItems:form.newLineItem")}
1224
+ </h1>
1225
+ <p className="text-sm text-mw-neutral-500">
1226
+ {dealData?.name || "Deal"}
1227
+ </p>
1228
+ </div>
1229
+ </div>
1230
+ </div>
1231
+
1232
+ <div className="flex-1 overflow-auto min-h-0">
1233
+ <div className="p-6">
1234
+ <div className="flex gap-6 items-start">
1235
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 min-w-0 space-y-4">
1236
+
1237
+ <div className="bg-white dark:bg-mw-neutral-900 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg p-4 space-y-4">
1238
+ <div className="grid grid-cols-1 lg:grid-cols-[1fr_140px] gap-4 items-start">
1239
+ <div className="space-y-2">
1240
+ <Label htmlFor="name">{t("lineItems:form.fields.name")} *</Label>
1241
+ <Input
1242
+ id="name"
1243
+ placeholder="Enter line item name"
1244
+ maxLength={200}
1245
+ {...form.register("name")}
1246
+ data-testid="input-name"
1247
+ />
1248
+ {form.formState.errors.name && (
1249
+ <p className="text-xs text-red-500">{form.formState.errors.name.message}</p>
1250
+ )}
1251
+ </div>
1252
+ </div>
1253
+ </div>
1254
+
1255
+ <div className="bg-white dark:bg-mw-neutral-900 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg">
1256
+ <div className="flex items-center gap-3 p-4 border-b border-mw-neutral-100 dark:border-mw-neutral-800">
1257
+ <Palette className="h-4 w-4 text-mw-primary-500" />
1258
+ <span className="font-semibold text-sm text-mw-neutral-900 dark:text-white">{t("lineItems:form.sections.creative")}</span>
1259
+ </div>
1260
+ <div className="p-4 space-y-4">
1261
+ <div className="space-y-2">
1262
+ <Label>{t("lineItems:form.fields.creativeType")} *</Label>
1263
+ <SelectRoot
1264
+ value={form.watch("creativeType")}
1265
+ onValueChange={(value) => form.setValue("creativeType", value as any)}
1266
+ >
1267
+ <SelectTrigger data-testid="select-creative-type">
1268
+ <SelectValue />
1269
+ </SelectTrigger>
1270
+ <SelectContent>
1271
+ {allowedCreativeTypes.includes("DISPLAY") && (
1272
+ <SelectItem value="DISPLAY">{t("lineItems:form.labels.displayType")}</SelectItem>
1273
+ )}
1274
+ {allowedCreativeTypes.includes("VIDEO") && (
1275
+ <SelectItem value="VIDEO">{t("lineItems:form.labels.videoType")}</SelectItem>
1276
+ )}
1277
+ {allowedCreativeTypes.includes("AUDIO") && (
1278
+ <SelectItem value="AUDIO">{t("lineItems:form.labels.audioType")}</SelectItem>
1279
+ )}
1280
+ </SelectContent>
1281
+ </SelectRoot>
1282
+ </div>
1283
+ {dealMode !== "DIRECT" && (
1284
+ <div className="space-y-2">
1285
+ <div className="flex items-center justify-between">
1286
+ <Label>{t("lineItems:form.fields.priority")}</Label>
1287
+ <span className="text-sm font-medium text-mw-neutral-700 dark:text-mw-neutral-300">{form.watch("priority")}</span>
1288
+ </div>
1289
+ <Slider
1290
+ value={form.watch("priority")}
1291
+ onChange={(val: number) => form.setValue("priority", val)}
1292
+ min={1}
1293
+ max={10}
1294
+ step={1}
1295
+ data-testid="slider-priority"
1296
+ />
1297
+ <div className="flex justify-between text-xs text-mw-neutral-500">
1298
+ <span>1 (Highest)</span>
1299
+ <span className="text-mw-neutral-400">5</span>
1300
+ <span>10 (Lowest)</span>
1301
+ </div>
1302
+ <p className="text-xs text-mw-neutral-500">Lower number means higher priority for ad serving</p>
1303
+ </div>
1304
+ )}
1305
+ </div>
1306
+ </div>
1307
+
1308
+ <div className="bg-white dark:bg-mw-neutral-900 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg">
1309
+ <div className="flex items-center gap-3 p-4 border-b border-mw-neutral-100 dark:border-mw-neutral-800">
1310
+ <Calendar className="h-4 w-4 text-mw-primary-500" />
1311
+ <span className="font-semibold text-sm text-mw-neutral-900 dark:text-white">Flight Dates</span>
1312
+ </div>
1313
+ <div className="p-4 space-y-4">
1314
+ <div className="flex items-center gap-2 flex-wrap">
1315
+ <span className="text-sm text-mw-neutral-500">Quick select:</span>
1316
+ {[
1317
+ { label: "Next 7 days", days: 7 },
1318
+ { label: "Next 28 days", days: 28 },
1319
+ { label: "Next 30 days", days: 30 },
1320
+ { label: "Next 45 days", days: 45 },
1321
+ { label: "Next 60 days", days: 60 },
1322
+ ].map(({ label, days }) => (
1323
+ <Button
1324
+ key={days}
1325
+ type="button"
1326
+ variant="outline"
1327
+ size="sm"
1328
+ className="text-xs"
1329
+ onClick={() => {
1330
+ const start = new Date();
1331
+ const end = new Date();
1332
+ end.setDate(end.getDate() + days);
1333
+ form.setValue("startDate", start.toISOString().split("T")[0]);
1334
+ form.setValue("endDate", end.toISOString().split("T")[0]);
1335
+ }}
1336
+ >
1337
+ {label}
1338
+ </Button>
1339
+ ))}
1340
+ </div>
1341
+ <div className="grid grid-cols-2 gap-4">
1342
+ <div className="space-y-2">
1343
+ <Label>{t("lineItems:form.fields.startDate")} *</Label>
1344
+ <Input
1345
+ type="date"
1346
+ {...form.register("startDate")}
1347
+ data-testid="input-start-date"
1348
+ />
1349
+ </div>
1350
+ <div className="space-y-2">
1351
+ <Label>{t("lineItems:form.fields.endDate")} *</Label>
1352
+ <Input
1353
+ type="date"
1354
+ {...form.register("endDate")}
1355
+ data-testid="input-end-date"
1356
+ />
1357
+ </div>
1358
+ </div>
1359
+ </div>
1360
+ </div>
1361
+
1362
+ <PlainSection
1363
+ title="Budget Options"
1364
+ icon={<DollarSign className="h-4 w-4" />}
1365
+ >
1366
+ <div className="space-y-4">
1367
+ <div className="grid grid-cols-2 gap-4">
1368
+ <div className="space-y-2">
1369
+ <Label>{t("lineItems:form.fields.totalBudget")} *</Label>
1370
+ <Input
1371
+ type="number"
1372
+ min={0}
1373
+ placeholder="0.00"
1374
+ {...form.register("totalBudget", { valueAsNumber: true })}
1375
+ data-testid="input-total-budget"
1376
+ />
1377
+ </div>
1378
+ <div className="space-y-2">
1379
+ <div className="flex items-center gap-2">
1380
+ <Label>{t("lineItems:form.fields.currency")}</Label>
1381
+ <Badge variant="secondary" className="text-xs">From Deal</Badge>
1382
+ </div>
1383
+ <div className="relative">
1384
+ <div className="absolute left-3 top-1/2 -translate-y-1/2 text-mw-neutral-400">
1385
+ <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1386
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
1387
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
1388
+ </svg>
1389
+ </div>
1390
+ <Input
1391
+ value={form.watch("currency") || "USD"}
1392
+ disabled
1393
+ className="bg-mw-neutral-100 dark:bg-mw-neutral-800 pl-10"
1394
+ />
1395
+ </div>
1396
+ <p className="text-xs text-mw-primary-500">Currency is inherited from the parent deal</p>
1397
+ </div>
1398
+ </div>
1399
+ <div className="grid grid-cols-2 gap-4">
1400
+ <div className="space-y-2">
1401
+ <Label>{t("lineItems:form.fields.budgetConsumption")}</Label>
1402
+ <SelectRoot
1403
+ value={form.watch("budgetConsumption")}
1404
+ onValueChange={(value) => form.setValue("budgetConsumption", value as any)}
1405
+ >
1406
+ <SelectTrigger>
1407
+ <SelectValue />
1408
+ </SelectTrigger>
1409
+ <SelectContent>
1410
+ <SelectItem value="daily">Daily</SelectItem>
1411
+ <SelectItem value="weekly">Weekly</SelectItem>
1412
+ <SelectItem value="monthly">Monthly</SelectItem>
1413
+ <SelectItem value="lifetime">Lifetime</SelectItem>
1414
+ </SelectContent>
1415
+ </SelectRoot>
1416
+ </div>
1417
+ <div className="space-y-2">
1418
+ <Label>{t("lineItems:form.fields.dailyBudget")}</Label>
1419
+ <Input
1420
+ type="number"
1421
+ min={0}
1422
+ placeholder="0.00"
1423
+ {...form.register("dailyBudget", { valueAsNumber: true })}
1424
+ />
1425
+ </div>
1426
+ </div>
1427
+ </div>
1428
+ </PlainSection>
1429
+
1430
+ {dealMode === "PROGRAMMATIC" && (
1431
+ <PlainSection
1432
+ title="Pacing & Traffic Allocation"
1433
+ icon={<TrendingUp className="h-4 w-4" />}
1434
+ >
1435
+ <div className="space-y-4">
1436
+ <div className="space-y-2">
1437
+ <Label>Pacing</Label>
1438
+ <SelectRoot
1439
+ value={form.watch("pacing")}
1440
+ onValueChange={(value) => form.setValue("pacing", value as any)}
1441
+ >
1442
+ <SelectTrigger>
1443
+ <SelectValue />
1444
+ </SelectTrigger>
1445
+ <SelectContent>
1446
+ <SelectItem value="even">Even</SelectItem>
1447
+ <SelectItem value="asap">ASAP</SelectItem>
1448
+ <SelectItem value="front-loaded">Front-loaded</SelectItem>
1449
+ </SelectContent>
1450
+ </SelectRoot>
1451
+ <p className="text-xs text-mw-neutral-500">Controls how the budget is spent over the deal duration</p>
1452
+ </div>
1453
+ <div className="space-y-2">
1454
+ <div className="flex items-center justify-between">
1455
+ <Label>Traffic Allocation</Label>
1456
+ <span className="text-sm font-medium text-mw-primary-500">{form.watch("trafficAllocation")}%</span>
1457
+ </div>
1458
+ <Slider
1459
+ value={form.watch("trafficAllocation")}
1460
+ onChange={(val: number) => form.setValue("trafficAllocation", val)}
1461
+ min={0}
1462
+ max={100}
1463
+ step={5}
1464
+ />
1465
+ <div className="flex justify-between text-xs text-mw-neutral-400">
1466
+ <span>0%</span>
1467
+ <span>50%</span>
1468
+ <span>100%</span>
1469
+ </div>
1470
+ <p className="text-xs text-mw-neutral-500">Controls what percentage of eligible impressions this line item receives</p>
1471
+ </div>
1472
+ </div>
1473
+ </PlainSection>
1474
+ )}
1475
+
1476
+ <PlainSection
1477
+ title={t("lineItems:form.sections.targeting")}
1478
+ icon={<Target className="h-4 w-4" />}
1479
+ badge={`${(watchedValues.venueTypes?.length || 0) + (watchedValues.demographics?.ageGroups?.length || 0) + (watchedValues.demographics?.genders?.length || 0)} selected`}
1480
+ >
1481
+ <div className="space-y-1">
1482
+ <div className="flex items-center justify-between p-3 bg-mw-neutral-50 dark:bg-mw-neutral-800 rounded-lg">
1483
+ <div className="flex items-center gap-2">
1484
+ <Monitor className="h-4 w-4 text-mw-neutral-500" />
1485
+ <span className="text-sm">{t("lineItems:form.fields.mediaType")}</span>
1486
+ </div>
1487
+ <div className="flex items-center gap-2">
1488
+ <span className="text-sm text-mw-neutral-700 dark:text-mw-neutral-300">DOOH</span>
1489
+ <Check className="h-4 w-4 text-mw-primary-500" />
1490
+ </div>
1491
+ </div>
1492
+
1493
+ <div
1494
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1495
+ onClick={() => setIsGeoMapSheetOpen(true)}
1496
+ >
1497
+ <div className="flex items-center gap-2">
1498
+ <MapPin className="h-4 w-4 text-mw-neutral-500" />
1499
+ <span className="text-sm">{t("lineItems:form.fields.geography")}</span>
1500
+ </div>
1501
+ <div className="flex items-center gap-2">
1502
+ <span className="text-sm text-mw-neutral-500">{form.watch("geography")?.length || 0} locations selected</span>
1503
+ <Check className="h-4 w-4 text-mw-primary-500" />
1504
+ </div>
1505
+ </div>
1506
+
1507
+ <Collapsible>
1508
+ <CollapsibleTrigger asChild>
1509
+ <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors">
1510
+ <div className="flex items-center gap-2">
1511
+ <Users className="h-4 w-4 text-mw-neutral-500" />
1512
+ <span className="text-sm">Age Groups</span>
1513
+ </div>
1514
+ <div className="flex items-center gap-2">
1515
+ <span className="text-sm text-mw-neutral-500">{form.watch("demographics")?.ageGroups?.length || 0} selected</span>
1516
+ <Check className="h-4 w-4 text-mw-primary-500" />
1517
+ </div>
1518
+ </div>
1519
+ </CollapsibleTrigger>
1520
+ <CollapsibleContent>
1521
+ <div className="px-3 pb-3">
1522
+ <div className="grid grid-cols-3 gap-x-6 gap-y-2">
1523
+ {AGE_GROUPS.map((age) => (
1524
+ <div key={age} className="flex items-center gap-2">
1525
+ <Checkbox
1526
+ id={`age-${age}`}
1527
+ checked={form.watch("demographics")?.ageGroups?.includes(age) || false}
1528
+ onChange={() => {
1529
+ const current = form.watch("demographics")?.ageGroups || [];
1530
+ if (current.includes(age)) {
1531
+ form.setValue("demographics.ageGroups", current.filter((a) => a !== age));
1532
+ } else {
1533
+ form.setValue("demographics.ageGroups", [...current, age]);
1534
+ }
1535
+ }}
1536
+ />
1537
+ <Label htmlFor={`age-${age}`} className="text-sm font-normal cursor-pointer">
1538
+ {age}
1539
+ </Label>
1540
+ </div>
1541
+ ))}
1542
+ </div>
1543
+ </div>
1544
+ </CollapsibleContent>
1545
+ </Collapsible>
1546
+
1547
+ <Collapsible>
1548
+ <CollapsibleTrigger asChild>
1549
+ <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors">
1550
+ <div className="flex items-center gap-2">
1551
+ <Users className="h-4 w-4 text-mw-neutral-500" />
1552
+ <span className="text-sm">Gender</span>
1553
+ </div>
1554
+ <div className="flex items-center gap-2">
1555
+ <span className="text-sm text-mw-neutral-500">{form.watch("demographics")?.genders?.length || 0} selected</span>
1556
+ <Check className="h-4 w-4 text-mw-primary-500" />
1557
+ </div>
1558
+ </div>
1559
+ </CollapsibleTrigger>
1560
+ <CollapsibleContent>
1561
+ <div className="px-3 pb-3">
1562
+ <div className="grid grid-cols-3 gap-x-6 gap-y-2">
1563
+ {GENDERS.map((gender) => (
1564
+ <div key={gender} className="flex items-center gap-2">
1565
+ <Checkbox
1566
+ id={`gender-${gender}`}
1567
+ checked={form.watch("demographics")?.genders?.includes(gender) || false}
1568
+ onChange={() => {
1569
+ const current = form.watch("demographics")?.genders || [];
1570
+ if (current.includes(gender)) {
1571
+ form.setValue("demographics.genders", current.filter((g) => g !== gender));
1572
+ } else {
1573
+ form.setValue("demographics.genders", [...current, gender]);
1574
+ }
1575
+ }}
1576
+ />
1577
+ <Label htmlFor={`gender-${gender}`} className="text-sm font-normal cursor-pointer">
1578
+ {gender}
1579
+ </Label>
1580
+ </div>
1581
+ ))}
1582
+ </div>
1583
+ </div>
1584
+ </CollapsibleContent>
1585
+ </Collapsible>
1586
+
1587
+ <div
1588
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1589
+ onClick={() => {
1590
+ setLocalPOIs([...(form.watch("selectedPOIs") || [])]);
1591
+ setPOISearchQuery("");
1592
+ setIsPOIDrawerOpen(true);
1593
+ }}
1594
+ >
1595
+ <div className="flex items-center gap-2">
1596
+ <MapPin className="h-4 w-4 text-mw-neutral-500" />
1597
+ <span className="text-sm">POI Targeting</span>
1598
+ </div>
1599
+ <div className="flex items-center gap-2">
1600
+ <span className="text-sm text-mw-neutral-500">{form.watch("selectedPOIs")?.length || 0} POIs selected</span>
1601
+ <ChevronDown className="h-4 w-4 text-mw-neutral-400" />
1602
+ </div>
1603
+ </div>
1604
+
1605
+ <div
1606
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1607
+ onClick={() => {
1608
+ setLocalVenueTypes([...(form.watch("venueTypes") || [])]);
1609
+ setVenueSearchQuery("");
1610
+ setIsVenueDrawerOpen(true);
1611
+ }}
1612
+ >
1613
+ <div className="flex items-center gap-2">
1614
+ <Layers className="h-4 w-4 text-mw-neutral-500" />
1615
+ <span className="text-sm">{t("lineItems:form.fields.venueType")}</span>
1616
+ </div>
1617
+ <div className="flex items-center gap-2">
1618
+ <span className="text-sm text-mw-neutral-500">{form.watch("venueTypes")?.length || 0} venue types selected</span>
1619
+ <ChevronDown className="h-4 w-4 text-mw-neutral-400" />
1620
+ </div>
1621
+ </div>
1622
+
1623
+ <Collapsible>
1624
+ <CollapsibleTrigger asChild>
1625
+ <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors">
1626
+ <div className="flex items-center gap-2">
1627
+ <Monitor className="h-4 w-4 text-mw-neutral-500" />
1628
+ <span className="text-sm">{t("lineItems:form.fields.adResolution")}</span>
1629
+ </div>
1630
+ <div className="flex items-center gap-2">
1631
+ <span className="text-sm text-mw-neutral-500">{form.watch("adResolutions")?.length || 0} Ad Resolutions are selected</span>
1632
+ <Check className="h-4 w-4 text-mw-primary-500" />
1633
+ </div>
1634
+ </div>
1635
+ </CollapsibleTrigger>
1636
+ <CollapsibleContent>
1637
+ <div className="px-3 pb-3">
1638
+ <div className="grid grid-cols-5 gap-x-4 gap-y-2">
1639
+ {(panelResolutions.length > 0 ? panelResolutions : FALLBACK_RESOLUTIONS).map((res) => (
1640
+ <div key={res} className="flex items-center gap-2">
1641
+ <Checkbox
1642
+ id={`res-${res}`}
1643
+ checked={form.watch("adResolutions")?.includes(res) || form.watch("adResolution") === res}
1644
+ onChange={() => {
1645
+ const current = form.watch("adResolutions") || [];
1646
+ if (current.includes(res)) {
1647
+ form.setValue("adResolutions", current.filter((r: string) => r !== res));
1648
+ } else {
1649
+ form.setValue("adResolutions", [...current, res]);
1650
+ }
1651
+ const updated = current.includes(res) ? current.filter((r: string) => r !== res) : [...current, res];
1652
+ form.setValue("adResolution", updated[0] || "");
1653
+ }}
1654
+ />
1655
+ <Label htmlFor={`res-${res}`} className="text-sm font-normal cursor-pointer">
1656
+ {res}
1657
+ </Label>
1658
+ </div>
1659
+ ))}
1660
+ </div>
1661
+ </div>
1662
+ </CollapsibleContent>
1663
+ </Collapsible>
1664
+
1665
+ <Collapsible>
1666
+ <CollapsibleTrigger asChild>
1667
+ <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors">
1668
+ <div className="flex items-center gap-2">
1669
+ <Clock className="h-4 w-4 text-mw-neutral-500" />
1670
+ <span className="text-sm">{t("lineItems:form.fields.adDuration")}</span>
1671
+ </div>
1672
+ <div className="flex items-center gap-2">
1673
+ <span className="text-sm text-mw-neutral-500">{form.watch("adDuration")}s selected</span>
1674
+ <Check className="h-4 w-4 text-mw-primary-500" />
1675
+ </div>
1676
+ </div>
1677
+ </CollapsibleTrigger>
1678
+ <CollapsibleContent>
1679
+ <div className="px-3 pb-3">
1680
+ <div className="flex items-center gap-2 flex-wrap">
1681
+ {(priceDurations.length > 0 ? priceDurations : FALLBACK_DURATIONS).map((d) => (
1682
+ <Button
1683
+ key={d}
1684
+ type="button"
1685
+ variant={form.watch("adDuration") === d ? "primary" : "outline"}
1686
+ size="sm"
1687
+ onClick={() => form.setValue("adDuration", d)}
1688
+ >
1689
+ {d}s
1690
+ </Button>
1691
+ ))}
1692
+ <span className="text-sm text-mw-neutral-500 ml-2">Custom:</span>
1693
+ <Input
1694
+ type="number"
1695
+ min={1}
1696
+ className="w-16 h-8 text-sm"
1697
+ value={!(priceDurations.length > 0 ? priceDurations : FALLBACK_DURATIONS).includes(form.watch("adDuration")) ? form.watch("adDuration") : ""}
1698
+ onChange={(e) => {
1699
+ const val = parseInt(e.target.value);
1700
+ if (val > 0) form.setValue("adDuration", val);
1701
+ }}
1702
+ placeholder="10"
1703
+ />
1704
+ </div>
1705
+ </div>
1706
+ </CollapsibleContent>
1707
+ </Collapsible>
1708
+ </div>
1709
+ </PlainSection>
1710
+
1711
+ <PlainSection
1712
+ title="DOOH Inventory Type"
1713
+ icon={<Monitor className="h-4 w-4" />}
1714
+ >
1715
+ <div className="space-y-1">
1716
+ <div
1717
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1718
+ onClick={() => setIsSSPSheetOpen(true)}
1719
+ >
1720
+ <div className="flex items-center gap-2">
1721
+ <Server className="h-4 w-4 text-mw-neutral-500" />
1722
+ <span className="text-sm">SSP / Exchange</span>
1723
+ </div>
1724
+ <div className="flex items-center gap-2">
1725
+ <span className="text-sm text-mw-neutral-500">Influence SSP</span>
1726
+ <Check className="h-4 w-4 text-mw-primary-500" />
1727
+ </div>
1728
+ </div>
1729
+
1730
+ <div
1731
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1732
+ onClick={() => setIsMediaOwnerSheetOpen(true)}
1733
+ >
1734
+ <div className="flex items-center gap-2">
1735
+ <Monitor className="h-4 w-4 text-mw-neutral-500" />
1736
+ <span className="text-sm">Media Owner</span>
1737
+ </div>
1738
+ <div className="flex items-center gap-2">
1739
+ <span className="text-sm text-mw-neutral-500">{selectedMediaOwners.length} Media Owners selected</span>
1740
+ <ChevronDown className="h-4 w-4 text-mw-neutral-400" />
1741
+ </div>
1742
+ </div>
1743
+
1744
+ <div
1745
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1746
+ onClick={() => setIsInventoryTypeSheetOpen(true)}
1747
+ >
1748
+ <div className="flex items-center gap-2">
1749
+ <Monitor className="h-4 w-4 text-mw-neutral-500" />
1750
+ <span className="text-sm">Inventory Type</span>
1751
+ </div>
1752
+ <div className="flex items-center gap-2">
1753
+ <span className="text-sm text-mw-neutral-500">{selectedInventoryTypePaths.length} types selected</span>
1754
+ <ChevronDown className="h-4 w-4 text-mw-neutral-400" />
1755
+ </div>
1756
+ </div>
1757
+
1758
+ <div
1759
+ className="flex items-center justify-between p-3 cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 rounded-lg transition-colors"
1760
+ onClick={() => setIsInventoryFormatSheetOpen(true)}
1761
+ >
1762
+ <div className="flex items-center gap-2">
1763
+ <Layers className="h-4 w-4 text-mw-neutral-500" />
1764
+ <span className="text-sm">Inventory Format</span>
1765
+ </div>
1766
+ <div className="flex items-center gap-2">
1767
+ <span className="text-sm text-mw-neutral-500">{form.watch("inventoryFormats")?.length || 0} formats selected</span>
1768
+ <ChevronDown className="h-4 w-4 text-mw-neutral-400" />
1769
+ </div>
1770
+ </div>
1771
+ </div>
1772
+ </PlainSection>
1773
+
1774
+ <PlainSection
1775
+ title="AI Inventory Recommendations"
1776
+ icon={<Sparkles className="h-4 w-4" />}
1777
+ badge={fetchedInventoryCount !== null ? `${watchedValues.selectedInventories?.length || 0}/${fetchedInventoryCount} selected` : undefined}
1778
+ >
1779
+ <div className="space-y-4">
1780
+ <div className="flex items-center justify-between">
1781
+ <p className="text-sm text-mw-neutral-500">Get AI-powered inventory suggestions based on your campaign parameters</p>
1782
+ <Button
1783
+ type="button"
1784
+ variant="outline"
1785
+ size="sm"
1786
+ onClick={handleOpenManualEdit}
1787
+ className="gap-2"
1788
+ >
1789
+ <Pencil className="h-4 w-4" />
1790
+ Manual Edit
1791
+ </Button>
1792
+ </div>
1793
+
1794
+ {(!watchedValues.startDate || !watchedValues.endDate) && (
1795
+ <div className="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
1796
+ <Calendar className="h-5 w-5 text-amber-500 flex-shrink-0" />
1797
+ <div>
1798
+ <p className="text-sm font-medium text-amber-700 dark:text-amber-400">Start & End dates required</p>
1799
+ <p className="text-xs text-amber-600 dark:text-amber-500 mt-0.5">Please set both start and end dates in the Flight Dates section before fetching inventory recommendations.</p>
1800
+ </div>
1801
+ </div>
1802
+ )}
1803
+
1804
+ <div className="flex items-center gap-3">
1805
+ <Button
1806
+ type="button"
1807
+ onClick={handleFetchInventory}
1808
+ disabled={isInventoryLoading || !watchedValues.startDate || !watchedValues.endDate}
1809
+ variant="primary"
1810
+ className="bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
1811
+ >
1812
+ {isInventoryLoading && !pollingProgress ? (
1813
+ <>
1814
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
1815
+ Submitting...
1816
+ </>
1817
+ ) : !isInventoryLoading ? (
1818
+ <>
1819
+ <Monitor className="h-4 w-4 mr-2" />
1820
+ Fetch Available Inventory
1821
+ </>
1822
+ ) : (
1823
+ <>
1824
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
1825
+ {pollingProgress?.completionPercentage ?? 0}%
1826
+ </>
1827
+ )}
1828
+ </Button>
1829
+ {fetchedInventoryCount !== null && !isInventoryLoading && (
1830
+ <span className="text-sm text-mw-neutral-500">
1831
+ {watchedValues.selectedInventories?.length || 0} of {fetchedInventoryCount} screens selected
1832
+ </span>
1833
+ )}
1834
+ </div>
1835
+
1836
+ {isInventoryLoading && pollingProgress && (
1837
+ <div className="space-y-2">
1838
+ <div className="flex items-center justify-between text-xs text-mw-neutral-500">
1839
+ <span className="flex items-center gap-2">
1840
+ <Loader2 className="h-3 w-3 animate-spin text-emerald-500" />
1841
+ Analyzing inventory...
1842
+ </span>
1843
+ <span>{pollingProgress.completionPercentage}% complete</span>
1844
+ </div>
1845
+ <div className="w-full h-2 bg-mw-neutral-100 dark:bg-mw-neutral-800 rounded-full overflow-hidden">
1846
+ <div
1847
+ className="h-full bg-emerald-500 rounded-full transition-all duration-500 ease-out"
1848
+ style={{ width: `${pollingProgress.completionPercentage}%` }}
1849
+ />
1850
+ </div>
1851
+ <p className="text-xs text-mw-neutral-400">
1852
+ {pollingProgress.elapsedSeconds > 0
1853
+ ? `Elapsed: ${pollingProgress.elapsedSeconds}s`
1854
+ : 'Starting recommendation engine...'}
1855
+ </p>
1856
+ </div>
1857
+ )}
1858
+
1859
+ {recommendedInventories.length > 0 && (
1860
+ <div className="border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg overflow-hidden">
1861
+ <div className="flex items-center gap-3 p-3 bg-mw-neutral-50 dark:bg-mw-neutral-800 border-b border-mw-neutral-200 dark:border-mw-neutral-700">
1862
+ <span className="text-sm text-mw-neutral-500 whitespace-nowrap">{recommendedInventories.length} recommendations</span>
1863
+ <div className="relative flex-1">
1864
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-mw-neutral-400" />
1865
+ <Input
1866
+ placeholder="Search inventories..."
1867
+ value={inventorySearchQuery}
1868
+ onChange={(e) => setInventorySearchQuery(e.target.value)}
1869
+ className="pl-9 h-9"
1870
+ />
1871
+ </div>
1872
+ <Button
1873
+ type="button"
1874
+ variant="outline"
1875
+ size="sm"
1876
+ onClick={handleFetchInventory}
1877
+ disabled={isInventoryLoading}
1878
+ >
1879
+ Refresh
1880
+ </Button>
1881
+ <Button
1882
+ type="button"
1883
+ variant="primary"
1884
+ size="sm"
1885
+ onClick={() => {
1886
+ const allIds = recommendedInventories.map(inv => inv.inventoryId);
1887
+ form.setValue("selectedInventories", allIds);
1888
+ }}
1889
+ className="gap-1 bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
1890
+ >
1891
+ <Plus className="h-3 w-3" />
1892
+ Add All
1893
+ </Button>
1894
+ </div>
1895
+
1896
+ <div className="max-h-[500px] overflow-y-auto p-3 space-y-3">
1897
+ {recommendedInventories
1898
+ .filter(inv => {
1899
+ if (!inventorySearchQuery) return true;
1900
+ const query = inventorySearchQuery.toLowerCase();
1901
+ return (
1902
+ inv.name?.toLowerCase().includes(query) ||
1903
+ inv.inventoryId?.toLowerCase().includes(query) ||
1904
+ inv.inventoryDetails?.address?.toLowerCase().includes(query) ||
1905
+ inv.inventoryDetails?.mediaOwnerName?.toLowerCase().includes(query) ||
1906
+ inv.referenceId?.toLowerCase().includes(query)
1907
+ );
1908
+ })
1909
+ .map((inventory) => (
1910
+ <PlannerInventoryCard
1911
+ key={inventory.inventoryId}
1912
+ id={inventory.inventoryId}
1913
+ name={inventory.name}
1914
+ referenceId={inventory.referenceId}
1915
+ location={inventory.inventoryDetails?.address}
1916
+ owner={inventory.inventoryDetails?.mediaOwnerName}
1917
+ publisherName={inventory.inventoryDetails?.mediaOwnerName}
1918
+ isSelected={watchedValues.selectedInventories?.includes(inventory.inventoryId) || false}
1919
+ onToggleSelect={() => {
1920
+ const current = form.getValues("selectedInventories") || [];
1921
+ if (current.includes(inventory.inventoryId)) {
1922
+ form.setValue("selectedInventories", current.filter(id => id !== inventory.inventoryId));
1923
+ } else {
1924
+ form.setValue("selectedInventories", [...current, inventory.inventoryId]);
1925
+ }
1926
+ }}
1927
+ finalScore={inventory.finalScore}
1928
+ componentScores={inventory.componentScores ? {
1929
+ geoFit: inventory.componentScores.geoFit,
1930
+ audienceFit: inventory.componentScores.audienceFit,
1931
+ availability: inventory.componentScores.availability,
1932
+ budgetFit: inventory.componentScores.budgetFit,
1933
+ brandFit: inventory.componentScores.brandFit,
1934
+ qualityFit: inventory.componentScores.qualityFit,
1935
+ timeFit: inventory.componentScores.timeFit,
1936
+ } : undefined}
1937
+ availability={inventory.availability}
1938
+ why={inventory.why}
1939
+ impressions={inventory.forecast?.estimatedImpressions}
1940
+ reach={inventory.forecast?.estimatedReach}
1941
+ estimatedCost={inventory.cost?.estimatedCost}
1942
+ currency={inventory.cost?.currency || watchedValues.currency || "USD"}
1943
+ tags={inventory.inventoryDetails?.venueTypes?.slice(0, 3).map(type => ({
1944
+ label: type,
1945
+ variant: "outline" as const,
1946
+ })) || []}
1947
+ showPlanningData={false}
1948
+ />
1949
+ ))}
1950
+ </div>
1951
+
1952
+ <div className="flex items-center justify-between p-3 bg-mw-neutral-50 dark:bg-mw-neutral-800 border-t border-mw-neutral-200 dark:border-mw-neutral-700">
1953
+ <p className="text-sm text-mw-neutral-600 dark:text-mw-neutral-400">
1954
+ {watchedValues.selectedInventories?.length || 0} screens selected
1955
+ {recommendationRunId && <span className="text-xs ml-2 text-mw-neutral-400">(Run: {recommendationRunId.slice(0, 8)}...)</span>}
1956
+ </p>
1957
+ </div>
1958
+ </div>
1959
+ )}
1960
+
1961
+ {recommendedInventories.length === 0 && fetchedInventoryCount === null && (
1962
+ <div className="text-center py-8 border border-dashed border-mw-neutral-300 dark:border-mw-neutral-600 rounded-lg">
1963
+ <Monitor className="h-10 w-10 mx-auto mb-3 text-mw-neutral-400" />
1964
+ <p className="text-sm text-mw-neutral-500">No inventory fetched yet</p>
1965
+ <p className="text-xs text-mw-neutral-400 mt-1">Configure your targeting criteria and click "Fetch Available Inventory"</p>
1966
+ </div>
1967
+ )}
1968
+
1969
+ {recommendedInventories.length === 0 && fetchedInventoryCount !== null && fetchedInventoryCount === 0 && (
1970
+ <div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
1971
+ <p className="text-sm text-amber-700 dark:text-amber-400">
1972
+ No screens found matching your criteria. Try adjusting your targeting settings.
1973
+ </p>
1974
+ </div>
1975
+ )}
1976
+
1977
+ {fetchedInventoryCount !== null && fetchedInventoryCount > 0 && recommendedInventories.length === 0 && (
1978
+ <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
1979
+ <p className="text-sm text-green-700 dark:text-green-400">
1980
+ Found <strong>{fetchedInventoryCount}</strong> matching screens
1981
+ </p>
1982
+ </div>
1983
+ )}
1984
+ </div>
1985
+ </PlainSection>
1986
+
1987
+ {(watchedValues.selectedInventories?.length || 0) > 0 && (
1988
+ <InventoryAvailabilitySection
1989
+ selectedScreenIds={watchedValues.selectedInventories || []}
1990
+ startDate={watchedValues.startDate}
1991
+ endDate={watchedValues.endDate}
1992
+ />
1993
+ )}
1994
+
1995
+ <PlainSection
1996
+ title="Day Parting"
1997
+ icon={<Clock className="h-4 w-4" />}
1998
+ >
1999
+ <div className="space-y-4">
2000
+ <p className="text-sm text-mw-neutral-500">Set specific days and hours when ads should run</p>
2001
+
2002
+ <Collapsible defaultOpen>
2003
+ <CollapsibleTrigger asChild>
2004
+ <div className="flex items-center justify-between p-3 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg cursor-pointer hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800">
2005
+ <div className="flex items-center gap-2">
2006
+ <Clock className="h-4 w-4 text-mw-neutral-500" />
2007
+ <span className="text-sm font-medium">{t("lineItems:form.fields.schedules")}</span>
2008
+ </div>
2009
+ <span className="text-sm text-mw-neutral-500">
2010
+ {form.watch("schedules")?.length || 0} {t("lineItems:form.fields.configured")}
2011
+ </span>
2012
+ </div>
2013
+ </CollapsibleTrigger>
2014
+ <CollapsibleContent>
2015
+ <div className="p-4 border border-t-0 border-mw-neutral-200 dark:border-mw-neutral-700 rounded-b-lg -mt-1 space-y-3">
2016
+ {(!form.watch("schedules") || form.watch("schedules")?.length === 0) ? (
2017
+ <p className="text-sm text-mw-neutral-500">
2018
+ No schedules configured. Line item will run all day.
2019
+ </p>
2020
+ ) : (
2021
+ <div className="space-y-2">
2022
+ {form.watch("schedules")?.map((schedule: ScheduleRule, index: number) => (
2023
+ <div key={schedule.id || index} className="flex items-center justify-between p-3 bg-mw-neutral-50 dark:bg-mw-neutral-800 rounded-lg">
2024
+ <div className="flex items-center gap-4">
2025
+ <span className="text-sm font-medium">{getScheduleDisplayName(schedule, index)}</span>
2026
+ <span className="text-sm text-mw-neutral-500">
2027
+ {formatScheduleDays(schedule)} • {formatScheduleHours(schedule)}
2028
+ </span>
2029
+ </div>
2030
+ <div className="flex items-center gap-1">
2031
+ <Button
2032
+ type="button"
2033
+ variant="ghost"
2034
+ size="sm"
2035
+ onClick={() => handleEditSchedule(schedule, index)}
2036
+ >
2037
+ <Pencil className="h-4 w-4 text-mw-neutral-500" />
2038
+ </Button>
2039
+ <Button
2040
+ type="button"
2041
+ variant="ghost"
2042
+ size="sm"
2043
+ onClick={() => handleDeleteSchedule(index)}
2044
+ >
2045
+ <Trash2 className="h-4 w-4 text-red-500" />
2046
+ </Button>
2047
+ </div>
2048
+ </div>
2049
+ ))}
2050
+ </div>
2051
+ )}
2052
+ <Button
2053
+ type="button"
2054
+ variant="outline"
2055
+ size="sm"
2056
+ onClick={handleAddSchedule}
2057
+ className="w-full"
2058
+ >
2059
+ <Plus className="h-4 w-4 mr-2" />
2060
+ Add Schedule
2061
+ </Button>
2062
+ </div>
2063
+ </CollapsibleContent>
2064
+ </Collapsible>
2065
+ </div>
2066
+ </PlainSection>
2067
+
2068
+
2069
+ {dealMode === "PROGRAMMATIC" && (
2070
+ <PlainSection
2071
+ title={t("lineItems:form.sections.bidStrategy")}
2072
+ icon={<TrendingUp className="h-4 w-4" />}
2073
+ >
2074
+ <div className="space-y-4">
2075
+ <div className="grid grid-cols-2 gap-4">
2076
+ <div className="space-y-2">
2077
+ <Label>Max Bid ($)</Label>
2078
+ <Input
2079
+ type="number"
2080
+ min={0}
2081
+ placeholder="0.00"
2082
+ {...form.register("maxBid", { valueAsNumber: true })}
2083
+ />
2084
+ {suggestedMaxBid !== null ? (
2085
+ <div className="flex items-center gap-2 p-2 rounded-md bg-mw-neutral-50 dark:bg-mw-neutral-800 border border-mw-neutral-200 dark:border-mw-neutral-700">
2086
+ <span className="text-sm text-mw-neutral-600 dark:text-mw-neutral-400 flex-1">
2087
+ Suggested: <span className="font-medium text-mw-neutral-900 dark:text-white">${suggestedMaxBid.toFixed(2)}</span>
2088
+ <span className="text-xs ml-1">({watchedValues.selectedInventories?.length || 0} selected screens avg)</span>
2089
+ </span>
2090
+ <Button
2091
+ type="button"
2092
+ variant="outline"
2093
+ size="sm"
2094
+ onClick={() => form.setValue("maxBid", suggestedMaxBid)}
2095
+ >
2096
+ Apply
2097
+ </Button>
2098
+ </div>
2099
+ ) : (
2100
+ <p className="text-xs text-mw-primary-500">Maximum bid amount. Select inventory or screens to see a suggested rate.</p>
2101
+ )}
2102
+ </div>
2103
+ <div className="space-y-2">
2104
+ <Label>{t("lineItems:form.fields.bidType")}</Label>
2105
+ <SelectRoot
2106
+ value={form.watch("bidType")}
2107
+ onValueChange={(value) => form.setValue("bidType", value as any)}
2108
+ >
2109
+ <SelectTrigger>
2110
+ <SelectValue />
2111
+ </SelectTrigger>
2112
+ <SelectContent>
2113
+ <SelectItem value="cpm">CPM</SelectItem>
2114
+ <SelectItem value="cps">CPS</SelectItem>
2115
+ </SelectContent>
2116
+ </SelectRoot>
2117
+ </div>
2118
+ </div>
2119
+ <div className="grid grid-cols-2 gap-4 items-start">
2120
+ <div className="space-y-2">
2121
+ <div className="flex items-center gap-2">
2122
+ <Label>{t("lineItems:form.fields.auctionType")}</Label>
2123
+ <Badge variant="secondary" className="text-xs flex items-center gap-1">
2124
+ <svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2125
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
2126
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
2127
+ </svg>
2128
+ Auto
2129
+ </Badge>
2130
+ </div>
2131
+ <Input
2132
+ value={dealData?.dealType === "GUARANTEED" ? "Fixed Price" : "First Price Auction"}
2133
+ disabled
2134
+ className="bg-mw-neutral-100 dark:bg-mw-neutral-800"
2135
+ />
2136
+ <p className="text-xs text-mw-primary-500">Determined by the deal type</p>
2137
+ </div>
2138
+ </div>
2139
+ </div>
2140
+ </PlainSection>
2141
+ )}
2142
+
2143
+ <PlainSection
2144
+ title={t("lineItems:form.sections.customFees")}
2145
+ icon={<DollarSign className="h-4 w-4" />}
2146
+ badge={feeFields.length > 0 ? `${feeFields.length} fees` : undefined}
2147
+ >
2148
+ <div className="space-y-4">
2149
+ <Button
2150
+ type="button"
2151
+ variant="outline"
2152
+ size="sm"
2153
+ onClick={() => appendFee({ name: "", amount: 0, type: "fixed", invoiced: true })}
2154
+ className="gap-2"
2155
+ >
2156
+ <Plus className="h-4 w-4" />
2157
+ {t("lineItems:form.fields.addFee")}
2158
+ </Button>
2159
+ {feeFields.map((field, index) => (
2160
+ <div key={field.id} className="grid grid-cols-5 gap-2 items-end">
2161
+ <div className="space-y-1">
2162
+ <Label className="text-xs">Name</Label>
2163
+ <Input
2164
+ placeholder="Fee name"
2165
+ {...form.register(`customFees.${index}.name`)}
2166
+ />
2167
+ </div>
2168
+ <div className="space-y-1">
2169
+ <Label className="text-xs">Amount</Label>
2170
+ <Input
2171
+ type="number"
2172
+ min={0}
2173
+ placeholder="0"
2174
+ {...form.register(`customFees.${index}.amount`, { valueAsNumber: true })}
2175
+ />
2176
+ {form.watch(`customFees.${index}.type`) === "percentage" && (
2177
+ <p className="text-xs text-mw-neutral-400">Max 100%</p>
2178
+ )}
2179
+ </div>
2180
+ <div className="space-y-1">
2181
+ <Label className="text-xs">Type</Label>
2182
+ <SelectRoot
2183
+ value={form.watch(`customFees.${index}.type`)}
2184
+ onValueChange={(value) => form.setValue(`customFees.${index}.type`, value as any)}
2185
+ >
2186
+ <SelectTrigger>
2187
+ <SelectValue />
2188
+ </SelectTrigger>
2189
+ <SelectContent>
2190
+ <SelectItem value="fixed">Fixed</SelectItem>
2191
+ <SelectItem value="percentage">Percentage</SelectItem>
2192
+ </SelectContent>
2193
+ </SelectRoot>
2194
+ </div>
2195
+ <div className="flex items-center space-x-2 pb-2">
2196
+ <Checkbox
2197
+ checked={form.watch(`customFees.${index}.invoiced`)}
2198
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => form.setValue(`customFees.${index}.invoiced`, e.target.checked)}
2199
+ />
2200
+ <Label className="text-xs">Invoiced</Label>
2201
+ </div>
2202
+ <Button
2203
+ type="button"
2204
+ variant="ghost"
2205
+ size="sm"
2206
+ onClick={() => removeFee(index)}
2207
+ >
2208
+ <Trash2 className="h-4 w-4 text-red-500" />
2209
+ </Button>
2210
+ </div>
2211
+ ))}
2212
+ </div>
2213
+ </PlainSection>
2214
+
2215
+ <PlainSection
2216
+ title={t("lineItems:form.sections.advanced")}
2217
+ icon={<Settings className="h-4 w-4" />}
2218
+ >
2219
+ <div className="space-y-4">
2220
+ <div>
2221
+ <Label className="text-sm font-medium mb-3 block">Frequency Cap</Label>
2222
+ <div className="grid grid-cols-2 gap-4">
2223
+ <div className="space-y-2">
2224
+ <Label className="text-sm text-mw-neutral-500">Ad Plays</Label>
2225
+ <Input
2226
+ type="number"
2227
+ min={0}
2228
+ placeholder="e.g., 10"
2229
+ value={form.watch("frequencyCap.target") || ""}
2230
+ onChange={(e) => {
2231
+ const value = e.target.value ? Number(e.target.value) : undefined;
2232
+ const currentPeriod = form.watch("frequencyCap.period") || "day";
2233
+ if (value !== undefined) {
2234
+ form.setValue("frequencyCap", { period: currentPeriod, target: value });
2235
+ } else {
2236
+ form.setValue("frequencyCap", undefined);
2237
+ }
2238
+ }}
2239
+ />
2240
+ <p className="text-xs text-mw-primary-500">Maximum ad plays per inventory per period</p>
2241
+ </div>
2242
+ <div className="space-y-2">
2243
+ <Label className="text-sm text-mw-neutral-500">Period</Label>
2244
+ <SelectRoot
2245
+ value={form.watch("frequencyCap.period") || ""}
2246
+ onValueChange={(value) => {
2247
+ const currentTarget = form.watch("frequencyCap.target") || 0;
2248
+ form.setValue("frequencyCap", { period: value as any, target: currentTarget });
2249
+ }}
2250
+ >
2251
+ <SelectTrigger>
2252
+ <SelectValue placeholder="Select period" />
2253
+ </SelectTrigger>
2254
+ <SelectContent>
2255
+ <SelectItem value="hour">Hour</SelectItem>
2256
+ <SelectItem value="day">Day</SelectItem>
2257
+ <SelectItem value="week">Week</SelectItem>
2258
+ <SelectItem value="month">Month</SelectItem>
2259
+ <SelectItem value="lifetime">Lifetime</SelectItem>
2260
+ </SelectContent>
2261
+ </SelectRoot>
2262
+ </div>
2263
+ </div>
2264
+ </div>
2265
+ </div>
2266
+ </PlainSection>
2267
+
2268
+
2269
+ {dealMode === "DIRECT" && <PlainSection
2270
+ title="Delivery Signals"
2271
+ icon={<Cloud className="h-4 w-4" />}
2272
+ >
2273
+ <div className="space-y-4">
2274
+ <div className="flex items-center justify-between p-4 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg">
2275
+ <div>
2276
+ <p className="text-sm font-medium">Weather Targeting</p>
2277
+ <p className="text-xs text-mw-neutral-500">Trigger ads based on weather conditions</p>
2278
+ </div>
2279
+ <Button
2280
+ type="button"
2281
+ variant="ghost"
2282
+ size="sm"
2283
+ role="switch"
2284
+ aria-checked={!!form.watch("weatherSignalEnabled")}
2285
+ onClick={() => {
2286
+ const newVal = !form.watch("weatherSignalEnabled");
2287
+ form.setValue("weatherSignalEnabled", newVal);
2288
+ if (!newVal) {
2289
+ form.setValue("weatherConditions", []);
2290
+ form.setValue("weatherTempEnabled", false);
2291
+ form.setValue("weatherTempMin", undefined);
2292
+ form.setValue("weatherTempMax", undefined);
2293
+ }
2294
+ }}
2295
+ className={cn(
2296
+ "relative inline-flex h-6 w-11 items-center rounded-full",
2297
+ form.watch("weatherSignalEnabled") ? "bg-mw-primary-500" : "bg-mw-neutral-300"
2298
+ )}
2299
+ >
2300
+ <span
2301
+ className={cn(
2302
+ "inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
2303
+ form.watch("weatherSignalEnabled") ? "translate-x-6" : "translate-x-1"
2304
+ )}
2305
+ />
2306
+ </Button>
2307
+ </div>
2308
+
2309
+ {form.watch("weatherSignalEnabled") && (
2310
+ <>
2311
+ <div className="space-y-2">
2312
+ <Label>Weather Conditions</Label>
2313
+ <div className="grid grid-cols-3 gap-2">
2314
+ {WEATHER_CONDITIONS.map((condition) => {
2315
+ const selected = (form.watch("weatherConditions") || []).includes(condition.value);
2316
+ return (
2317
+ <Button
2318
+ key={condition.value}
2319
+ type="button"
2320
+ variant={selected ? "primary" : "outline"}
2321
+ size="sm"
2322
+ onClick={() => {
2323
+ const current = form.watch("weatherConditions") || [];
2324
+ if (selected) {
2325
+ form.setValue("weatherConditions", current.filter((c: string) => c !== condition.value));
2326
+ } else {
2327
+ form.setValue("weatherConditions", [...current, condition.value]);
2328
+ }
2329
+ }}
2330
+ >
2331
+ {condition.label}
2332
+ </Button>
2333
+ );
2334
+ })}
2335
+ </div>
2336
+ </div>
2337
+
2338
+ <div className="space-y-3 p-4 border border-mw-neutral-200 dark:border-mw-neutral-700 rounded-lg">
2339
+ <div className="flex items-center justify-between">
2340
+ <Label>Temperature Range</Label>
2341
+ <Button
2342
+ type="button"
2343
+ variant="ghost"
2344
+ size="sm"
2345
+ role="switch"
2346
+ aria-checked={!!form.watch("weatherTempEnabled")}
2347
+ onClick={() => {
2348
+ const newVal = !form.watch("weatherTempEnabled");
2349
+ form.setValue("weatherTempEnabled", newVal);
2350
+ if (!newVal) {
2351
+ form.setValue("weatherTempMin", undefined);
2352
+ form.setValue("weatherTempMax", undefined);
2353
+ }
2354
+ }}
2355
+ className={cn(
2356
+ "relative inline-flex h-5 w-9 items-center rounded-full",
2357
+ form.watch("weatherTempEnabled") ? "bg-mw-primary-500" : "bg-mw-neutral-300"
2358
+ )}
2359
+ >
2360
+ <span
2361
+ className={cn(
2362
+ "inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform",
2363
+ form.watch("weatherTempEnabled") ? "translate-x-4" : "translate-x-0.5"
2364
+ )}
2365
+ />
2366
+ </Button>
2367
+ </div>
2368
+ {form.watch("weatherTempEnabled") && (
2369
+ <div className="grid grid-cols-3 gap-3">
2370
+ <div className="space-y-1">
2371
+ <Label className="text-xs">Min</Label>
2372
+ <Input
2373
+ type="number"
2374
+ placeholder="e.g. 25"
2375
+ {...form.register("weatherTempMin", { valueAsNumber: true })}
2376
+ />
2377
+ </div>
2378
+ <div className="space-y-1">
2379
+ <Label className="text-xs">Max</Label>
2380
+ <Input
2381
+ type="number"
2382
+ placeholder="e.g. 35"
2383
+ {...form.register("weatherTempMax", { valueAsNumber: true })}
2384
+ />
2385
+ </div>
2386
+ <div className="space-y-1">
2387
+ <Label className="text-xs">Unit</Label>
2388
+ <SelectRoot
2389
+ value={form.watch("weatherTempUnit") || "celsius"}
2390
+ onValueChange={(value) => form.setValue("weatherTempUnit", value as "celsius" | "fahrenheit")}
2391
+ >
2392
+ <SelectTrigger>
2393
+ <SelectValue />
2394
+ </SelectTrigger>
2395
+ <SelectContent>
2396
+ <SelectItem value="celsius">Celsius</SelectItem>
2397
+ <SelectItem value="fahrenheit">Fahrenheit</SelectItem>
2398
+ </SelectContent>
2399
+ </SelectRoot>
2400
+ </div>
2401
+ </div>
2402
+ )}
2403
+ </div>
2404
+ </>
2405
+ )}
2406
+ </div>
2407
+ </PlainSection>}
2408
+
2409
+ </form>
2410
+
2411
+ <div className="hidden lg:block w-[280px] flex-shrink-0 self-start sticky top-6 space-y-4">
2412
+ <FormInsights
2413
+ title={t("lineItems:form.labels.formInsights")}
2414
+ insights={insights}
2415
+ className="w-full"
2416
+ />
2417
+ <Card className="w-full">
2418
+ <CardHeader className="pb-2">
2419
+ <CardTitle className="text-sm font-semibold flex items-center gap-2">
2420
+ <Sparkles className="h-4 w-4 text-mw-primary-500" />
2421
+ Quick Tips
2422
+ </CardTitle>
2423
+ </CardHeader>
2424
+ <CardContent className="pt-0">
2425
+ <ul className="space-y-2">
2426
+ {quickTips.map((tip, idx) => (
2427
+ <li key={idx} className="flex items-start gap-2 text-xs text-mw-neutral-600 dark:text-mw-neutral-400">
2428
+ <span className={cn(
2429
+ "mt-1 h-1.5 w-1.5 rounded-full flex-shrink-0",
2430
+ tip.color === "blue" ? "bg-blue-500" :
2431
+ tip.color === "green" ? "bg-green-500" :
2432
+ "bg-orange-500"
2433
+ )} />
2434
+ {tip.text}
2435
+ </li>
2436
+ ))}
2437
+ </ul>
2438
+ </CardContent>
2439
+ </Card>
2440
+ <CampaignForecastPanel
2441
+ forecast={forecast}
2442
+ currency={dealCurrency}
2443
+ />
2444
+ </div>
2445
+ </div>
2446
+ </div>
2447
+ </div>
2448
+
2449
+ <div className="flex-shrink-0 bg-white dark:bg-mw-neutral-900 border-t border-mw-neutral-200 dark:border-mw-neutral-700 px-6 py-3">
2450
+ <div className="flex items-center justify-end gap-3">
2451
+ <Button type="button" variant="outline" onClick={handleCancel}>
2452
+ {t("common:actions.cancel")}
2453
+ </Button>
2454
+ <Button type="button" onClick={form.handleSubmit(onSubmit, (errors) => {
2455
+ console.error("Form validation errors:", errors);
2456
+ const firstError = Object.entries(errors)[0];
2457
+ if (firstError) {
2458
+ const [field, error] = firstError;
2459
+ toast({
2460
+ title: "Validation Error",
2461
+ description: `${field}: ${(error as any)?.message || "Invalid value"}`,
2462
+ variant: "destructive",
2463
+ });
2464
+ }
2465
+ })} disabled={isSaving}>
2466
+ {isSaving ? (
2467
+ <>
2468
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
2469
+ {t("lineItems:form.saving")}
2470
+ </>
2471
+ ) : isEditMode ? (
2472
+ t("lineItems:form.saveChanges")
2473
+ ) : (
2474
+ t("lineItems:form.createLineItem")
2475
+ )}
2476
+ </Button>
2477
+ </div>
2478
+ </div>
2479
+
2480
+ {/* Geography Map Sheet */}
2481
+ <Sheet open={isGeoMapSheetOpen} onOpenChange={setIsGeoMapSheetOpen}>
2482
+ <SheetContent side="right" className="w-full sm:max-w-[90vw] lg:max-w-[80vw] overflow-hidden flex flex-col p-0">
2483
+ <div className="flex h-full">
2484
+ {/* Map Section - Left Side */}
2485
+ <div className="flex-1 relative">
2486
+ <GeofencingMap
2487
+ onLocationsChange={(locations) => {
2488
+ setGeoLocations(locations);
2489
+ const locationNames = locations
2490
+ .filter((loc: any) => loc.properties?.included !== false)
2491
+ .map((loc: any) => loc.properties?.name || "Location")
2492
+ .filter(Boolean);
2493
+ form.setValue("geography", locationNames);
2494
+ }}
2495
+ initialLocations={geoLocations}
2496
+ country={dealData?.country || "US"}
2497
+ hasLocations={geoLocations.length > 0}
2498
+ addPOI={addPOIMode}
2499
+ selectedLocation={
2500
+ selectedLocationIndex !== null
2501
+ ? geoLocations[selectedLocationIndex]
2502
+ : null
2503
+ }
2504
+ onCenterOnLocation={shouldCenterOnLocation}
2505
+ onAddPOIChange={(mode) => {
2506
+ setAddPOIMode(mode);
2507
+ if (!mode) setSelectedLocationIndex(null);
2508
+ }}
2509
+ onUpdateLocationPOIMetadata={(id, metadata, pois) => {
2510
+ setGeoLocations((prev) =>
2511
+ prev.map((loc) => {
2512
+ if (loc.id === id) {
2513
+ return {
2514
+ ...loc,
2515
+ poi: pois,
2516
+ metadata: { ...loc.metadata, ...metadata },
2517
+ properties: {
2518
+ ...loc.properties,
2519
+ pois: pois.map((p) => ({
2520
+ type: p,
2521
+ label: p,
2522
+ })),
2523
+ },
2524
+ };
2525
+ }
2526
+ return loc;
2527
+ })
2528
+ );
2529
+ setAddPOIMode(false);
2530
+ setSelectedLocationIndex(null);
2531
+ }}
2532
+ />
2533
+ </div>
2534
+
2535
+ {/* Selected Locations Sidebar - Right Side */}
2536
+ <div className="w-80 border-l border-mw-neutral-200 dark:border-mw-neutral-700 bg-white dark:bg-mw-neutral-900 flex flex-col">
2537
+ <div className="p-4 border-b border-mw-neutral-200 dark:border-mw-neutral-700">
2538
+ <h3 className="font-semibold text-lg text-mw-neutral-900 dark:text-white">Selected Locations</h3>
2539
+ <p className="text-sm text-mw-neutral-500 mt-1">
2540
+ Click on the map or search to add locations
2541
+ </p>
2542
+ </div>
2543
+
2544
+ {/* Search Input */}
2545
+ <div className="p-4 border-b border-mw-neutral-200 dark:border-mw-neutral-700">
2546
+ <div className="relative">
2547
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-mw-neutral-400" />
2548
+ <Input
2549
+ placeholder="Search added locations here..."
2550
+ value={locationSearchQuery}
2551
+ onChange={(e) => setLocationSearchQuery(e.target.value)}
2552
+ className="pl-9"
2553
+ />
2554
+ </div>
2555
+ <div className="flex items-center gap-2 mt-3">
2556
+ <Checkbox
2557
+ checked={enableAllLocations}
2558
+ onChange={() => {
2559
+ setEnableAllLocations(!enableAllLocations);
2560
+ // Toggle all locations' included status
2561
+ const updatedLocations = geoLocations.map((loc: any) => ({
2562
+ ...loc,
2563
+ properties: {
2564
+ ...loc.properties,
2565
+ included: !enableAllLocations,
2566
+ },
2567
+ }));
2568
+ setGeoLocations(updatedLocations);
2569
+ }}
2570
+ />
2571
+ <Label className="text-sm cursor-pointer">Enable all</Label>
2572
+ </div>
2573
+ </div>
2574
+
2575
+ {/* Locations List */}
2576
+ <ScrollArea className="flex-1">
2577
+ <div className="p-4 space-y-2">
2578
+ {geoLocations.length === 0 ? (
2579
+ <div className="text-center py-8 text-mw-neutral-500">
2580
+ <MapPin className="h-12 w-12 mx-auto mb-3 opacity-50" />
2581
+ <p>No locations added yet</p>
2582
+ </div>
2583
+ ) : (
2584
+ geoLocations
2585
+ .filter((loc: any) => {
2586
+ if (!locationSearchQuery) return true;
2587
+ const name = loc.properties?.name || "";
2588
+ return name.toLowerCase().includes(locationSearchQuery.toLowerCase());
2589
+ })
2590
+ .map((location: any, index: number) => {
2591
+ const locationPois = location.properties?.pois || [];
2592
+ const canAddMorePois = locationPois.length < MAX_POIS_PER_LOCATION;
2593
+ const isSelected = selectedLocationIndex === index;
2594
+
2595
+ const handleLocationCardClick = () => {
2596
+ setSelectedLocationIndex(index);
2597
+ setShouldCenterOnLocation(true);
2598
+ setTimeout(() => setShouldCenterOnLocation(false), 100);
2599
+ };
2600
+
2601
+ return (
2602
+ <div
2603
+ key={location.id || index}
2604
+ className={`border rounded-lg p-3 cursor-pointer transition-colors ${
2605
+ isSelected
2606
+ ? "border-mw-primary-500 bg-mw-primary-50 dark:bg-mw-primary-900/20"
2607
+ : "border-mw-neutral-200 dark:border-mw-neutral-700 hover:border-mw-primary-300"
2608
+ }`}
2609
+ onClick={handleLocationCardClick}
2610
+ >
2611
+ <div className="flex items-start justify-between mb-2">
2612
+ <div className="flex items-start gap-2">
2613
+ <Checkbox
2614
+ checked={location.properties?.included !== false}
2615
+ onChange={(e) => {
2616
+ e.stopPropagation();
2617
+ const updatedLocations = geoLocations.map((loc: any, i: number) =>
2618
+ i === index
2619
+ ? {
2620
+ ...loc,
2621
+ properties: {
2622
+ ...loc.properties,
2623
+ included: loc.properties?.included === false,
2624
+ },
2625
+ }
2626
+ : loc
2627
+ );
2628
+ setGeoLocations(updatedLocations);
2629
+ }}
2630
+ onClick={(e) => e.stopPropagation()}
2631
+ className="mt-0.5"
2632
+ />
2633
+ <div>
2634
+ <span className="text-sm font-medium block text-mw-neutral-900 dark:text-white">
2635
+ {location.properties?.name || `Location ${index + 1}`}
2636
+ </span>
2637
+ <span className="text-xs text-mw-neutral-500">
2638
+ {location.geometry?.type || "Point"} - Drawn on map
2639
+ </span>
2640
+ </div>
2641
+ </div>
2642
+ <Button
2643
+ variant="ghost"
2644
+ size="sm"
2645
+ className="h-8 w-8 p-0"
2646
+ onClick={(e) => {
2647
+ e.stopPropagation();
2648
+ const updatedLocations = geoLocations.filter((_, i) => i !== index);
2649
+ setGeoLocations(updatedLocations);
2650
+ }}
2651
+ >
2652
+ <Trash2 className="h-4 w-4 text-mw-neutral-400 hover:text-red-500" />
2653
+ </Button>
2654
+ </div>
2655
+
2656
+ {/* POI Badges */}
2657
+ {locationPois.length > 0 && (
2658
+ <div className="flex flex-wrap gap-1 mt-2 mb-2" onClick={(e) => e.stopPropagation()}>
2659
+ {locationPois.map((poi: { type: string; label: string }) => (
2660
+ <Badge key={poi.type} variant="secondary" className="text-xs h-5 px-2 pr-1 flex items-center gap-1">
2661
+ {poi.label}
2662
+ <Button
2663
+ type="button"
2664
+ variant="ghost"
2665
+ size="sm"
2666
+ isIconOnly
2667
+ onClick={(e) => {
2668
+ e.stopPropagation();
2669
+ removePoiFromLocation(index, poi.type);
2670
+ }}
2671
+ >
2672
+ <X className="h-3 w-3" />
2673
+ </Button>
2674
+ </Badge>
2675
+ ))}
2676
+ </div>
2677
+ )}
2678
+
2679
+ {/* Add POI Section */}
2680
+ <div className="flex items-center justify-between border-t border-mw-neutral-100 dark:border-mw-neutral-700 pt-2 mt-2" onClick={(e) => e.stopPropagation()}>
2681
+ <span className="text-xs text-mw-neutral-500">
2682
+ {locationPois.length >= MAX_POIS_PER_LOCATION
2683
+ ? "Limit reached"
2684
+ : `You can add up to ${MAX_POIS_PER_LOCATION} POIs`}
2685
+ </span>
2686
+ <Button
2687
+ type="button"
2688
+ variant="outline"
2689
+ size="sm"
2690
+ className="h-7 text-xs text-mw-primary-500 border-mw-primary-300"
2691
+ onClick={(e) => {
2692
+ e.stopPropagation();
2693
+ setSelectedLocationIndex(index);
2694
+ setAddPOIMode(true);
2695
+ }}
2696
+ disabled={!canAddMorePois}
2697
+ >
2698
+ <Plus className="h-3 w-3 mr-1" />
2699
+ Add POI
2700
+ </Button>
2701
+ </div>
2702
+ </div>
2703
+ );
2704
+ })
2705
+ )}
2706
+ </div>
2707
+ </ScrollArea>
2708
+
2709
+ </div>
2710
+ </div>
2711
+ </SheetContent>
2712
+ </Sheet>
2713
+
2714
+
2715
+ {/* Schedule Rule Editor */}
2716
+ {isScheduleEditorOpen && editingSchedule && (
2717
+ <ScheduleRuleEditor
2718
+ schedule={editingSchedule}
2719
+ lineItemStartDate={form.watch("startDate") || getTodayDateString()}
2720
+ lineItemEndDate={form.watch("endDate") || form.watch("startDate") || getTodayDateString()}
2721
+ onSave={handleSaveSchedule}
2722
+ onCancel={handleCancelScheduleEdit}
2723
+ isOpen={isScheduleEditorOpen}
2724
+ />
2725
+ )}
2726
+
2727
+ <Sheet open={isMediaOwnerSheetOpen} onOpenChange={setIsMediaOwnerSheetOpen}>
2728
+ <SheetContent side="right" className="w-full sm:max-w-md flex flex-col p-0">
2729
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
2730
+ <SheetTitle className="flex items-center gap-2">
2731
+ <Monitor className="h-5 w-5" />
2732
+ Select Media Owners
2733
+ </SheetTitle>
2734
+ <SheetDescription>
2735
+ Search and filter media owners by type
2736
+ </SheetDescription>
2737
+ </SheetHeader>
2738
+ <div className="flex-1 flex flex-col gap-4 px-6 pt-4 min-h-0 overflow-hidden">
2739
+ <div className="relative">
2740
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-mw-neutral-400" />
2741
+ <Input
2742
+ placeholder="Search media owners..."
2743
+ value={mediaOwnerSearch}
2744
+ onChange={(e) => setMediaOwnerSearch(e.target.value)}
2745
+ className="pl-9"
2746
+ />
2747
+ </div>
2748
+ <div>
2749
+ <SelectRoot value={mediaOwnerTypeFilter} onValueChange={setMediaOwnerTypeFilter}>
2750
+ <SelectTrigger className="h-9">
2751
+ <SelectValue placeholder="All Types" />
2752
+ </SelectTrigger>
2753
+ <SelectContent>
2754
+ <SelectItem value="all">All Types</SelectItem>
2755
+ {Array.from(new Set(companiesData.map((c: Company) => c.company_type?.name).filter(Boolean))).map((typeName) => (
2756
+ <SelectItem key={typeName as string} value={typeName as string}>{typeName as string}</SelectItem>
2757
+ ))}
2758
+ </SelectContent>
2759
+ </SelectRoot>
2760
+ </div>
2761
+ {isLoadingCompanies ? (
2762
+ <div className="flex items-center justify-center py-8">
2763
+ <Loader2 className="h-6 w-6 animate-spin text-mw-primary-500" />
2764
+ <span className="ml-2 text-sm text-mw-neutral-500">Loading media owners...</span>
2765
+ </div>
2766
+ ) : (
2767
+ <ScrollArea className="flex-1">
2768
+ <div className="space-y-1">
2769
+ {companiesData
2770
+ .filter((company: Company) => {
2771
+ const matchesSearch = !mediaOwnerSearch || company.name.toLowerCase().includes(mediaOwnerSearch.toLowerCase());
2772
+ const matchesType = mediaOwnerTypeFilter === "all" || company.company_type?.name === mediaOwnerTypeFilter;
2773
+ return matchesSearch && matchesType;
2774
+ })
2775
+ .map((company: Company) => (
2776
+ <div
2777
+ key={company.id}
2778
+ className="flex items-center gap-3 p-3 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 cursor-pointer"
2779
+ onClick={() => {
2780
+ setSelectedMediaOwners(prev =>
2781
+ prev.includes(company.id)
2782
+ ? prev.filter(id => id !== company.id)
2783
+ : [...prev, company.id]
2784
+ );
2785
+ }}
2786
+ >
2787
+ <Checkbox
2788
+ checked={selectedMediaOwners.includes(company.id)}
2789
+ onChange={() => {}}
2790
+ />
2791
+ <div>
2792
+ <p className="text-sm font-medium">{company.name}</p>
2793
+ <p className="text-xs text-mw-neutral-500">{company.company_type?.name || "Unknown Type"}</p>
2794
+ </div>
2795
+ </div>
2796
+ ))}
2797
+ </div>
2798
+ </ScrollArea>
2799
+ )}
2800
+ </div>
2801
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0">
2802
+ <div className="flex items-center justify-between w-full">
2803
+ <span className="text-sm text-mw-neutral-500">{selectedMediaOwners.length} selected</span>
2804
+ <div className="flex gap-2">
2805
+ <Button variant="outline" onClick={() => setIsMediaOwnerSheetOpen(false)}>Cancel</Button>
2806
+ <Button onClick={() => setIsMediaOwnerSheetOpen(false)}>Apply Selection</Button>
2807
+ </div>
2808
+ </div>
2809
+ </SheetFooter>
2810
+ </SheetContent>
2811
+ </Sheet>
2812
+
2813
+ <Sheet open={isSSPSheetOpen} onOpenChange={setIsSSPSheetOpen}>
2814
+ <SheetContent side="right" className="w-full sm:max-w-md flex flex-col p-0">
2815
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
2816
+ <SheetTitle className="flex items-center gap-2">
2817
+ <Server className="h-5 w-5" />
2818
+ Select SSP / Exchange
2819
+ </SheetTitle>
2820
+ <SheetDescription>
2821
+ Choose the SSP or exchange for inventory sourcing
2822
+ </SheetDescription>
2823
+ </SheetHeader>
2824
+ <div className="flex-1 px-6 pt-4">
2825
+ <div className="space-y-1">
2826
+ <div
2827
+ className="flex items-center gap-3 p-3 rounded-lg border border-mw-primary-500 bg-mw-primary-50 dark:bg-mw-primary-900/20"
2828
+ >
2829
+ <div className="w-5 h-5 rounded-full border-2 border-mw-primary-500 flex items-center justify-center">
2830
+ <div className="w-2.5 h-2.5 rounded-full bg-mw-primary-500" />
2831
+ </div>
2832
+ <div>
2833
+ <p className="text-sm font-medium">Influence SSP</p>
2834
+ <p className="text-xs text-mw-neutral-500">Default supply-side platform</p>
2835
+ </div>
2836
+ </div>
2837
+ </div>
2838
+ </div>
2839
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0 flex justify-end">
2840
+ <Button onClick={() => setIsSSPSheetOpen(false)}>Done</Button>
2841
+ </SheetFooter>
2842
+ </SheetContent>
2843
+ </Sheet>
2844
+
2845
+ <Sheet open={isInventoryTypeSheetOpen} onOpenChange={setIsInventoryTypeSheetOpen}>
2846
+ <SheetContent side="right" className="w-full sm:max-w-md flex flex-col p-0">
2847
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
2848
+ <SheetTitle className="flex items-center gap-2">
2849
+ <Monitor className="h-5 w-5" />
2850
+ Select Inventory Types
2851
+ </SheetTitle>
2852
+ <SheetDescription>
2853
+ Choose inventory types for your line item
2854
+ </SheetDescription>
2855
+ </SheetHeader>
2856
+ <div className="flex-1 min-h-0 overflow-hidden flex flex-col px-6 pt-4">
2857
+ <ScrollArea className="flex-1">
2858
+ <div className="space-y-1">
2859
+ {isLoadingInventoryTypes && (
2860
+ <p className="text-sm text-mw-neutral-500 py-4 text-center">Loading inventory types...</p>
2861
+ )}
2862
+ {parentInventoryTypes.map((parent) => {
2863
+ const children = inventoryTypeChildren[parent.path] || [];
2864
+ const isParentSelected = selectedInventoryTypePaths.includes(parent.path);
2865
+ const allChildrenSelected = children.length > 0 && children.every(c => selectedInventoryTypePaths.includes(c.path));
2866
+ return (
2867
+ <Collapsible key={parent.path}>
2868
+ <div className="flex items-center gap-2 p-2 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800">
2869
+ {children.length > 0 && (
2870
+ <CollapsibleTrigger asChild>
2871
+ <Button type="button" variant="ghost" size="sm" isIconOnly>
2872
+ <ChevronDown className="h-4 w-4 text-mw-neutral-500" />
2873
+ </Button>
2874
+ </CollapsibleTrigger>
2875
+ )}
2876
+ {children.length === 0 && <span className="w-5" />}
2877
+ <Checkbox
2878
+ id={`invtype-sheet-${parent.path}`}
2879
+ checked={isParentSelected || allChildrenSelected}
2880
+ onChange={() => {
2881
+ const shouldCheck = !(isParentSelected || allChildrenSelected);
2882
+ setSelectedInventoryTypePaths(prev => {
2883
+ let next = shouldCheck
2884
+ ? [...prev, parent.path]
2885
+ : prev.filter(p => p !== parent.path);
2886
+ if (shouldCheck) {
2887
+ children.forEach(c => { if (!next.includes(c.path)) next.push(c.path); });
2888
+ } else {
2889
+ next = next.filter(p => !children.some(c => c.path === p));
2890
+ }
2891
+ return next;
2892
+ });
2893
+ }}
2894
+ />
2895
+ <Label htmlFor={`invtype-sheet-${parent.path}`} className="text-sm font-medium cursor-pointer flex-1">
2896
+ {parent.name}
2897
+ </Label>
2898
+ </div>
2899
+ {children.length > 0 && (
2900
+ <CollapsibleContent>
2901
+ <div className="ml-7 space-y-0.5">
2902
+ {children.map(child => (
2903
+ <div key={child.path} className="flex items-center gap-2 p-2 pl-4 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800">
2904
+ <Checkbox
2905
+ id={`invtype-sheet-${child.path}`}
2906
+ checked={selectedInventoryTypePaths.includes(child.path)}
2907
+ onChange={() => {
2908
+ setSelectedInventoryTypePaths(prev =>
2909
+ prev.includes(child.path)
2910
+ ? prev.filter(p => p !== child.path)
2911
+ : [...prev, child.path]
2912
+ );
2913
+ }}
2914
+ />
2915
+ <Label htmlFor={`invtype-sheet-${child.path}`} className="text-sm font-normal cursor-pointer flex-1">
2916
+ {child.name}
2917
+ </Label>
2918
+ </div>
2919
+ ))}
2920
+ </div>
2921
+ </CollapsibleContent>
2922
+ )}
2923
+ </Collapsible>
2924
+ );
2925
+ })}
2926
+ </div>
2927
+ </ScrollArea>
2928
+ </div>
2929
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0">
2930
+ <div className="flex items-center justify-between w-full">
2931
+ <span className="text-sm text-mw-neutral-500">{selectedInventoryTypePaths.length} types selected</span>
2932
+ <div className="flex gap-2">
2933
+ <Button variant="outline" onClick={() => setIsInventoryTypeSheetOpen(false)}>Cancel</Button>
2934
+ <Button onClick={() => setIsInventoryTypeSheetOpen(false)}>Apply Selection</Button>
2935
+ </div>
2936
+ </div>
2937
+ </SheetFooter>
2938
+ </SheetContent>
2939
+ </Sheet>
2940
+
2941
+ <Sheet open={isInventoryFormatSheetOpen} onOpenChange={setIsInventoryFormatSheetOpen}>
2942
+ <SheetContent side="right" className="w-full sm:max-w-md flex flex-col p-0">
2943
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
2944
+ <SheetTitle className="flex items-center gap-2">
2945
+ <Layers className="h-5 w-5" />
2946
+ Select Inventory Formats
2947
+ </SheetTitle>
2948
+ <SheetDescription>
2949
+ Choose inventory formats grouped by DOOH type
2950
+ </SheetDescription>
2951
+ </SheetHeader>
2952
+ <div className="flex-1 min-h-0 overflow-hidden flex flex-col px-6 pt-4">
2953
+ <ScrollArea className="flex-1">
2954
+ <div className="space-y-6">
2955
+ {isLoadingDisplayFormats && (
2956
+ <p className="text-sm text-mw-neutral-500 py-4 text-center">Loading inventory formats...</p>
2957
+ )}
2958
+ {Object.entries(formatGroups).map(([typePath, { groupName, formats }]) => {
2959
+ const formatIds = formats.map(f => String(f.id));
2960
+ const selectedInGroup = formatIds.filter(id => form.watch("inventoryFormats")?.includes(id));
2961
+ return (
2962
+ <div key={typePath}>
2963
+ <div className="flex items-center justify-between mb-3">
2964
+ <div className="flex items-center gap-2">
2965
+ <span className="text-sm font-semibold">{groupName}</span>
2966
+ <span className="text-xs text-mw-neutral-500">{selectedInGroup.length}/{formats.length}</span>
2967
+ </div>
2968
+ <Button
2969
+ type="button"
2970
+ variant="ghost"
2971
+ size="sm"
2972
+ onClick={() => {
2973
+ const current = form.watch("inventoryFormats") || [];
2974
+ if (selectedInGroup.length === formats.length) {
2975
+ form.setValue("inventoryFormats", current.filter((f: string) => !formatIds.includes(f)));
2976
+ } else {
2977
+ const newFormats = Array.from(new Set([...current, ...formatIds]));
2978
+ form.setValue("inventoryFormats", newFormats);
2979
+ }
2980
+ }}
2981
+ >
2982
+ Select All
2983
+ </Button>
2984
+ </div>
2985
+ <div className="space-y-1">
2986
+ {formats.map((format) => {
2987
+ const fId = String(format.id);
2988
+ return (
2989
+ <div
2990
+ key={format.id}
2991
+ className="flex items-center gap-3 p-2 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 cursor-pointer"
2992
+ onClick={() => {
2993
+ const current = form.watch("inventoryFormats") || [];
2994
+ if (current.includes(fId)) {
2995
+ form.setValue("inventoryFormats", current.filter((f: string) => f !== fId));
2996
+ } else {
2997
+ form.setValue("inventoryFormats", [...current, fId]);
2998
+ }
2999
+ }}
3000
+ >
3001
+ <Checkbox
3002
+ checked={form.watch("inventoryFormats")?.includes(fId) || false}
3003
+ onChange={() => {}}
3004
+ />
3005
+ <span className="text-sm">{format.name}</span>
3006
+ </div>
3007
+ );
3008
+ })}
3009
+ </div>
3010
+ </div>
3011
+ );
3012
+ })}
3013
+ </div>
3014
+ </ScrollArea>
3015
+ </div>
3016
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0">
3017
+ <div className="flex items-center justify-between w-full">
3018
+ <span className="text-sm text-mw-neutral-500">{form.watch("inventoryFormats")?.length || 0} formats selected</span>
3019
+ <div className="flex gap-2">
3020
+ <Button variant="outline" onClick={() => setIsInventoryFormatSheetOpen(false)}>Cancel</Button>
3021
+ <Button onClick={() => setIsInventoryFormatSheetOpen(false)}>Apply Selection</Button>
3022
+ </div>
3023
+ </div>
3024
+ </SheetFooter>
3025
+ </SheetContent>
3026
+ </Sheet>
3027
+
3028
+ <Sheet open={isPOIDrawerOpen} onOpenChange={setIsPOIDrawerOpen}>
3029
+ <SheetContent className="w-[400px] sm:max-w-[400px] flex flex-col p-0">
3030
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
3031
+ <SheetTitle>POI Targeting</SheetTitle>
3032
+ <SheetDescription>Select points of interest to target</SheetDescription>
3033
+ </SheetHeader>
3034
+ <div className="flex-1 min-h-0 overflow-hidden flex flex-col px-6 pt-4">
3035
+ <Tabs defaultValue="pois" className="flex-1 flex flex-col min-h-0">
3036
+ <TabsList className="w-full flex-shrink-0">
3037
+ <TabsTrigger value="pois" className="flex-1">POIs</TabsTrigger>
3038
+ <TabsTrigger value="categories" className="flex-1">Categories</TabsTrigger>
3039
+ </TabsList>
3040
+ <TabsContent value="pois" className="flex-1 flex flex-col mt-4 min-h-0">
3041
+ <div className="relative mb-3 flex-shrink-0">
3042
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-mw-neutral-400" />
3043
+ <Input
3044
+ placeholder="Search POIs..."
3045
+ value={poiSearchQuery}
3046
+ onChange={(e) => setPOISearchQuery(e.target.value)}
3047
+ className="pl-9"
3048
+ />
3049
+ </div>
3050
+ <ScrollArea className="flex-1">
3051
+ <div className="space-y-1">
3052
+ {POI_OPTIONS.filter((poi) =>
3053
+ poi.label.toLowerCase().includes(poiSearchQuery.toLowerCase())
3054
+ ).map((poi) => (
3055
+ <div
3056
+ key={poi.value}
3057
+ className="flex items-center gap-3 p-2 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 transition-colors"
3058
+ >
3059
+ <Checkbox
3060
+ id={`poi-drawer-${poi.value}`}
3061
+ checked={localPOIs.includes(poi.value)}
3062
+ onChange={() => {
3063
+ setLocalPOIs((prev) =>
3064
+ prev.includes(poi.value)
3065
+ ? prev.filter((p) => p !== poi.value)
3066
+ : [...prev, poi.value]
3067
+ );
3068
+ }}
3069
+ />
3070
+ <Label htmlFor={`poi-drawer-${poi.value}`} className="text-sm font-normal cursor-pointer flex-1">
3071
+ {poi.label}
3072
+ </Label>
3073
+ </div>
3074
+ ))}
3075
+ </div>
3076
+ </ScrollArea>
3077
+ </TabsContent>
3078
+ <TabsContent value="categories" className="flex-1 mt-4">
3079
+ <div className="flex items-center justify-center h-32 text-mw-neutral-400 text-sm">
3080
+ POI category selection coming soon
3081
+ </div>
3082
+ </TabsContent>
3083
+ </Tabs>
3084
+ </div>
3085
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0">
3086
+ <div className="flex items-center justify-between w-full">
3087
+ <span className="text-sm text-mw-neutral-500">{localPOIs.length} POIs selected</span>
3088
+ <div className="flex gap-2">
3089
+ <Button variant="outline" onClick={() => setIsPOIDrawerOpen(false)}>Cancel</Button>
3090
+ <Button onClick={() => {
3091
+ setSelectedPOIs([...localPOIs]);
3092
+ form.setValue("selectedPOIs", [...localPOIs]);
3093
+ setIsPOIDrawerOpen(false);
3094
+ }}>Apply</Button>
3095
+ </div>
3096
+ </div>
3097
+ </SheetFooter>
3098
+ </SheetContent>
3099
+ </Sheet>
3100
+
3101
+ <Sheet open={isVenueDrawerOpen} onOpenChange={setIsVenueDrawerOpen}>
3102
+ <SheetContent className="w-[400px] sm:max-w-[400px] flex flex-col p-0">
3103
+ <SheetHeader className="px-6 py-5 border-b border-mw-neutral-200 flex-shrink-0">
3104
+ <SheetTitle>Venue Types</SheetTitle>
3105
+ <SheetDescription>Select venue types to target</SheetDescription>
3106
+ </SheetHeader>
3107
+ <div className="flex-1 min-h-0 overflow-hidden flex flex-col px-6 pt-4">
3108
+ <div className="relative mb-3 flex-shrink-0">
3109
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-mw-neutral-400" />
3110
+ <Input
3111
+ placeholder="Search venue types..."
3112
+ value={venueSearchQuery}
3113
+ onChange={(e) => setVenueSearchQuery(e.target.value)}
3114
+ className="pl-9"
3115
+ />
3116
+ </div>
3117
+ <ScrollArea className="flex-1">
3118
+ <div className="space-y-1">
3119
+ {isLoadingVenueTypes && (
3120
+ <div className="flex items-center justify-center py-4">
3121
+ <span className="text-sm text-mw-neutral-500">Loading venue types...</span>
3122
+ </div>
3123
+ )}
3124
+ {(() => {
3125
+ const getAllDescendantPaths = (node: typeof venueTypeTree[0]): string[] => {
3126
+ let paths: string[] = [];
3127
+ node.children.forEach((child) => {
3128
+ paths.push(child.path);
3129
+ paths = paths.concat(getAllDescendantPaths(child));
3130
+ });
3131
+ return paths;
3132
+ };
3133
+
3134
+ const matchesSearch = (node: typeof venueTypeTree[0], query: string): boolean => {
3135
+ if (node.name.toLowerCase().includes(query)) return true;
3136
+ return node.children.some((child) => matchesSearch(child, query));
3137
+ };
3138
+
3139
+ const renderNode = (node: typeof venueTypeTree[0], depth: number = 0): React.ReactNode => {
3140
+ const query = venueSearchQuery.toLowerCase();
3141
+ if (query && !matchesSearch(node, query)) return null;
3142
+
3143
+ const isExpanded = expandedVenueParents.has(node.taxonomyId);
3144
+ const hasChildren = node.children.length > 0;
3145
+ const isChecked = localVenueTypes.includes(node.path);
3146
+ const descendantPaths = getAllDescendantPaths(node);
3147
+ const allDescendantsSelected = descendantPaths.length > 0 && descendantPaths.every((p) => localVenueTypes.includes(p));
3148
+
3149
+ return (
3150
+ <div key={node.taxonomyId}>
3151
+ <div
3152
+ className="flex items-center gap-2 p-2 rounded-lg hover:bg-mw-neutral-50 dark:hover:bg-mw-neutral-800 transition-colors"
3153
+ style={{ paddingLeft: `${depth * 20 + 8}px` }}
3154
+ >
3155
+ {hasChildren ? (
3156
+ <Button
3157
+ type="button"
3158
+ variant="ghost"
3159
+ size="sm"
3160
+ isIconOnly
3161
+ className="flex-shrink-0"
3162
+ onClick={() => {
3163
+ setExpandedVenueParents((prev) => {
3164
+ const next = new Set(prev);
3165
+ if (next.has(node.taxonomyId)) next.delete(node.taxonomyId);
3166
+ else next.add(node.taxonomyId);
3167
+ return next;
3168
+ });
3169
+ }}
3170
+ >
3171
+ <ChevronDown className={`h-4 w-4 text-mw-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`} />
3172
+ </Button>
3173
+ ) : (
3174
+ <span className="w-5 flex-shrink-0" />
3175
+ )}
3176
+ <Checkbox
3177
+ id={`venue-${node.taxonomyId}`}
3178
+ checked={isChecked || allDescendantsSelected}
3179
+ onChange={() => {
3180
+ const shouldCheck = !(isChecked || allDescendantsSelected);
3181
+ setLocalVenueTypes((prev) => {
3182
+ let next = shouldCheck
3183
+ ? [...prev, node.path]
3184
+ : prev.filter((p) => p !== node.path);
3185
+ if (shouldCheck) {
3186
+ descendantPaths.forEach((dp) => {
3187
+ if (!next.includes(dp)) next.push(dp);
3188
+ });
3189
+ } else {
3190
+ next = next.filter((p) => !descendantPaths.includes(p));
3191
+ }
3192
+ return next;
3193
+ });
3194
+ }}
3195
+ />
3196
+ <Label
3197
+ htmlFor={`venue-${node.taxonomyId}`}
3198
+ className={`text-sm cursor-pointer flex-1 ${hasChildren ? "font-medium" : "font-normal"}`}
3199
+ >
3200
+ {node.name}
3201
+ </Label>
3202
+ </div>
3203
+ {hasChildren && (isExpanded || venueSearchQuery) && (
3204
+ <div>
3205
+ {node.children.map((child) => renderNode(child, depth + 1))}
3206
+ </div>
3207
+ )}
3208
+ </div>
3209
+ );
3210
+ };
3211
+
3212
+ return venueTypeTree.map((root) => renderNode(root, 0));
3213
+ })()}
3214
+ </div>
3215
+ </ScrollArea>
3216
+ </div>
3217
+ <SheetFooter className="px-6 py-4 border-t border-mw-neutral-200 flex-shrink-0">
3218
+ <div className="flex items-center justify-between w-full">
3219
+ <span className="text-sm text-mw-neutral-500">{localVenueTypes.length} venue types selected</span>
3220
+ <div className="flex gap-2">
3221
+ <Button variant="outline" onClick={() => setIsVenueDrawerOpen(false)}>Cancel</Button>
3222
+ <Button onClick={() => {
3223
+ form.setValue("venueTypes", [...localVenueTypes]);
3224
+ setIsVenueDrawerOpen(false);
3225
+ }}>Apply</Button>
3226
+ </div>
3227
+ </div>
3228
+ </SheetFooter>
3229
+ </SheetContent>
3230
+ </Sheet>
3231
+
3232
+ <ManualInventoryDrawer
3233
+ open={isManualEditOpen}
3234
+ onOpenChange={setIsManualEditOpen}
3235
+ screens={manualEditScreens.map(s => {
3236
+ const resParts = s.resolution?.split('x');
3237
+ return {
3238
+ id: s.id,
3239
+ name: s.name || s.id,
3240
+ city: s.address?.split(',')[0] || undefined,
3241
+ country: s.countryIso2 || undefined,
3242
+ latitude: s.latitude ?? null,
3243
+ longitude: s.longitude ?? null,
3244
+ cpm: s.planning?.pricing?.cpm ? s.planning.pricing.cpm / 100 : null,
3245
+ dailyImpressions: s.planning?.estimates?.impressions || null,
3246
+ width: resParts && resParts.length === 2 ? parseInt(resParts[0]) : null,
3247
+ height: resParts && resParts.length === 2 ? parseInt(resParts[1]) : null,
3248
+ screenType: s.type || null,
3249
+ mediaOwnerId: s.publisherId || s.mediaOwnerId || null,
3250
+ mediaOwnerName: s.publisherName || s.mediaOwnerName || undefined,
3251
+ };
3252
+ })}
3253
+ selectedScreens={manualEditSelection}
3254
+ onSelectionChange={(ids) => {
3255
+ form.setValue("selectedInventories", ids);
3256
+ }}
3257
+ mapCenter={{ lng: 103.8198, lat: 1.3521, zoom: 11 }}
3258
+ />
3259
+ </div>
3260
+ );
3261
+ }