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.
- package/.ci/staging.yml +191 -0
- package/.dockerignore +117 -0
- package/.env +40 -0
- package/.env.staging +38 -0
- package/.gitlab-ci.yml +16 -0
- package/DEMO_STATUS.md +579 -0
- package/Dockerfile +61 -0
- package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
- package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
- package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
- package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
- package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
- package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
- package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
- package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
- package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
- package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
- package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
- package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
- package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
- package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
- package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
- package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
- package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
- package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
- package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
- package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
- package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
- package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
- package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
- package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
- package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
- package/PCM/.env.example +92 -0
- package/PCM/README.md +558 -0
- package/PCM/docs/TEST_CASES.md +422 -0
- package/PCM/index.js +106 -0
- package/PCM/package-lock.json +3282 -0
- package/PCM/package.json +32 -0
- package/PCM/replit.md +64 -0
- package/PCM/schema.sql +495 -0
- package/PCM/scripts/export-schema.js +183 -0
- package/PCM/scripts/seed-comprehensive.js +631 -0
- package/PCM/scripts/seed-production.js +477 -0
- package/PCM/src/config/db.js +56 -0
- package/PCM/src/config/swagger.js +5975 -0
- package/PCM/src/dto/EmailRequestDTO.js +166 -0
- package/PCM/src/middleware/errorHandler.js +52 -0
- package/PCM/src/middleware/logger.js +26 -0
- package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
- package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
- package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
- package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
- package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
- package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
- package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
- package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
- package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
- package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
- package/PCM/src/migrations/011_add_planning_field.sql +21 -0
- package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
- package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
- package/PCM/src/migrations/014_create_change_history.sql +38 -0
- package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
- package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
- package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
- package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
- package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
- package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
- package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
- package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
- package/PCM/src/migrations/run-migrations.js +46 -0
- package/PCM/src/models/ApprovalOTP.js +51 -0
- package/PCM/src/models/ApprovalToken.js +79 -0
- package/PCM/src/models/ChangeHistory.js +107 -0
- package/PCM/src/models/Deal.js +186 -0
- package/PCM/src/models/DealIdCounter.js +28 -0
- package/PCM/src/models/LineItem.js +227 -0
- package/PCM/src/models/LineItemCreative.js +89 -0
- package/PCM/src/models/LineItemInventory.js +115 -0
- package/PCM/src/models/PublisherInsertionOrder.js +93 -0
- package/PCM/src/models/TransactionHistory.js +34 -0
- package/PCM/src/models/associations.js +81 -0
- package/PCM/src/routes/approval.js +321 -0
- package/PCM/src/routes/creatives.js +437 -0
- package/PCM/src/routes/deals.js +1638 -0
- package/PCM/src/routes/digitalSignage.js +242 -0
- package/PCM/src/routes/insertionOrders.js +380 -0
- package/PCM/src/routes/lineItems.js +926 -0
- package/PCM/src/routes/system.js +384 -0
- package/PCM/src/services/ApprovalService.js +885 -0
- package/PCM/src/services/CampaignImportConverter.js +631 -0
- package/PCM/src/services/CampaignModeService.js +273 -0
- package/PCM/src/services/CampaignStatusService.js +395 -0
- package/PCM/src/services/ChangeHistoryService.js +316 -0
- package/PCM/src/services/DealIdService.js +94 -0
- package/PCM/src/services/DealResponseFormatter.js +90 -0
- package/PCM/src/services/EmailNotificationService.js +315 -0
- package/PCM/src/services/LineItemResponseFormatter.js +122 -0
- package/PCM/src/services/LineItemStatusService.js +380 -0
- package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
- package/PCM/src/tests/comprehensiveTests.js +1277 -0
- package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
- package/PCM/src/tests/testRunner.js +248 -0
- package/PCM/src/utils/caseConverter.js +92 -0
- package/PCM/src/utils/dealCalculations.js +206 -0
- package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
- package/PCM/src/utils/payloadNormalizer.js +34 -0
- package/PCM/src/utils/sourceNormalizer.js +56 -0
- package/PCM/src/validators/creativeValidator.js +27 -0
- package/PCM/src/validators/dealValidator.js +203 -0
- package/PCM/src/validators/lineItemValidator.js +489 -0
- package/PCM/tests/approval-flows.test.js +238 -0
- package/PCM/tests/approval-workflow.test.js +291 -0
- package/PCM/tests/campaign-import-converter.test.js +543 -0
- package/PCM/tests/campaign-import-e2e.test.js +520 -0
- package/PCM/tests/campaign-status.test.js +539 -0
- package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
- package/PCM/tests/e2e/digital-signage.test.js +145 -0
- package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
- package/PCM/tests/e2e-comprehensive.test.js +3446 -0
- package/PCM/tests/edge-cases.test.js +340 -0
- package/PCM/tests/line-item-status.test.js +340 -0
- package/PCM/tests/seller-account-external-ids.test.js +877 -0
- package/PCM/tests/source-validation.test.js +324 -0
- package/PRD.md +3373 -0
- package/README.md +186 -0
- package/client/index.html +35 -0
- package/client/public/DEMO_STATUS.md +579 -0
- package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
- package/client/public/locales/ar/approval.json +144 -0
- package/client/public/locales/ar/buyer.json +61 -0
- package/client/public/locales/ar/campaigns.json +1 -0
- package/client/public/locales/ar/common.json +218 -0
- package/client/public/locales/ar/contentHub.json +266 -0
- package/client/public/locales/ar/creatives.json +79 -0
- package/client/public/locales/ar/dashboard.json +57 -0
- package/client/public/locales/ar/deals.json +886 -0
- package/client/public/locales/ar/dsp.json +131 -0
- package/client/public/locales/ar/inventory.json +201 -0
- package/client/public/locales/ar/lineItems.json +553 -0
- package/client/public/locales/ar/navigation.json +48 -0
- package/client/public/locales/ar/wizard.json +1 -0
- package/client/public/locales/en/approval.json +144 -0
- package/client/public/locales/en/buyer.json +65 -0
- package/client/public/locales/en/campaigns.json +1 -0
- package/client/public/locales/en/common.json +218 -0
- package/client/public/locales/en/contentHub.json +266 -0
- package/client/public/locales/en/creatives.json +79 -0
- package/client/public/locales/en/dashboard.json +57 -0
- package/client/public/locales/en/deals.json +886 -0
- package/client/public/locales/en/dsp.json +131 -0
- package/client/public/locales/en/inventory.json +201 -0
- package/client/public/locales/en/lineItems.json +659 -0
- package/client/public/locales/en/navigation.json +48 -0
- package/client/public/locales/en/wizard.json +1 -0
- package/client/public/locales/ja/approval.json +144 -0
- package/client/public/locales/ja/buyer.json +61 -0
- package/client/public/locales/ja/campaigns.json +1 -0
- package/client/public/locales/ja/common.json +218 -0
- package/client/public/locales/ja/contentHub.json +266 -0
- package/client/public/locales/ja/creatives.json +79 -0
- package/client/public/locales/ja/dashboard.json +57 -0
- package/client/public/locales/ja/deals.json +886 -0
- package/client/public/locales/ja/dsp.json +131 -0
- package/client/public/locales/ja/inventory.json +201 -0
- package/client/public/locales/ja/lineItems.json +553 -0
- package/client/public/locales/ja/navigation.json +48 -0
- package/client/public/locales/ja/wizard.json +1 -0
- package/client/public/locales/zh/approval.json +144 -0
- package/client/public/locales/zh/buyer.json +61 -0
- package/client/public/locales/zh/campaigns.json +1 -0
- package/client/public/locales/zh/common.json +218 -0
- package/client/public/locales/zh/contentHub.json +266 -0
- package/client/public/locales/zh/creatives.json +79 -0
- package/client/public/locales/zh/dashboard.json +57 -0
- package/client/public/locales/zh/deals.json +886 -0
- package/client/public/locales/zh/dsp.json +131 -0
- package/client/public/locales/zh/inventory.json +201 -0
- package/client/public/locales/zh/lineItems.json +553 -0
- package/client/public/locales/zh/navigation.json +48 -0
- package/client/public/locales/zh/wizard.json +1 -0
- package/client/public/manifest.json +36 -0
- package/client/src/App.tsx +464 -0
- package/client/src/components/app-sidebar.tsx +312 -0
- package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
- package/client/src/components/approval/approval-decision-form.tsx +326 -0
- package/client/src/components/approval/approval-sheet.tsx +631 -0
- package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
- package/client/src/components/approval/otp-verification.test.tsx +337 -0
- package/client/src/components/approval/otp-verification.tsx +180 -0
- package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
- package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
- package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
- package/client/src/components/content-hub/creative-card.tsx +456 -0
- package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
- package/client/src/components/content-hub/creative-filters.tsx +457 -0
- package/client/src/components/content-hub/creative-grid.tsx +329 -0
- package/client/src/components/content-hub/creative-selector.tsx +415 -0
- package/client/src/components/content-hub/creative-upload.tsx +547 -0
- package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
- package/client/src/components/content-hub/folder-list.tsx +280 -0
- package/client/src/components/content-hub/review-dialogs.tsx +268 -0
- package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
- package/client/src/components/creative-library/creative-details-view.tsx +446 -0
- package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
- package/client/src/components/creative-library/creative-list.tsx +360 -0
- package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
- package/client/src/components/creative-library/folder-card.tsx +78 -0
- package/client/src/components/creative-library/index.ts +27 -0
- package/client/src/components/creative-library/new-creative-card.tsx +211 -0
- package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
- package/client/src/components/dashboard-overview.tsx +109 -0
- package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
- package/client/src/components/deals/approval-history-panel.tsx +156 -0
- package/client/src/components/deals/deal-status-badge.tsx +92 -0
- package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
- package/client/src/components/deals/market-insights-panel.tsx +237 -0
- package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
- package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
- package/client/src/components/deals/request-approval-sheet.tsx +136 -0
- package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
- package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
- package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
- package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
- package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
- package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
- package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
- package/client/src/components/inventory/inventory-map-view.tsx +596 -0
- package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
- package/client/src/components/language-switcher.tsx +53 -0
- package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
- package/client/src/components/line-items/form-insights.tsx +89 -0
- package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
- package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
- package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
- package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
- package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
- package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
- package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
- package/client/src/components/line-items/geofencing-map.tsx +1270 -0
- package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
- package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
- package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
- package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
- package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
- package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
- package/client/src/components/line-items/schedule-rule-types.ts +122 -0
- package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
- package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
- package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
- package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
- package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
- package/client/src/components/line-items/steps/summary-step.tsx +680 -0
- package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
- package/client/src/components/product-switcher.tsx +105 -0
- package/client/src/components/protected-route.tsx +49 -0
- package/client/src/components/skip-link.tsx +22 -0
- package/client/src/components/stat-card.tsx +53 -0
- package/client/src/components/status-badge.tsx +96 -0
- package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
- package/client/src/components/ui/toaster.tsx +111 -0
- package/client/src/contexts/auth-context.tsx +181 -0
- package/client/src/contexts/sidebar-state.tsx +50 -0
- package/client/src/contexts/theme-context.tsx +66 -0
- package/client/src/data/campaign-data.json +107 -0
- package/client/src/data/countries.json +22 -0
- package/client/src/hooks/use-approval.ts +366 -0
- package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
- package/client/src/hooks/use-media-query.ts +46 -0
- package/client/src/hooks/use-mobile.tsx +19 -0
- package/client/src/hooks/use-page-title.ts +21 -0
- package/client/src/hooks/use-toast.ts +195 -0
- package/client/src/index.css +694 -0
- package/client/src/lib/__tests__/accessibility.test.ts +104 -0
- package/client/src/lib/__tests__/date-utils.test.ts +199 -0
- package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
- package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
- package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
- package/client/src/lib/__tests__/utils.test.ts +57 -0
- package/client/src/lib/accessibility.ts +141 -0
- package/client/src/lib/api-config.ts +9 -0
- package/client/src/lib/auth-service.ts +209 -0
- package/client/src/lib/campaign-creative-api.ts +82 -0
- package/client/src/lib/company-api.ts +61 -0
- package/client/src/lib/content-hub-api.ts +407 -0
- package/client/src/lib/creative-mapper.ts +61 -0
- package/client/src/lib/date-utils.ts +119 -0
- package/client/src/lib/deal-helpers.ts +220 -0
- package/client/src/lib/dsp-buyer-api.ts +196 -0
- package/client/src/lib/geo-import-api.ts +151 -0
- package/client/src/lib/google-poi-api.ts +305 -0
- package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
- package/client/src/lib/i18n/formatting.ts +130 -0
- package/client/src/lib/i18n/index.ts +8 -0
- package/client/src/lib/i18n-compat.ts +76 -0
- package/client/src/lib/influence-deals-api.ts +896 -0
- package/client/src/lib/inventory-api.ts +399 -0
- package/client/src/lib/oauth-service.ts +678 -0
- package/client/src/lib/poi-types.ts +75 -0
- package/client/src/lib/queryClient.ts +144 -0
- package/client/src/lib/recommendation-api.ts +380 -0
- package/client/src/lib/storage-utils.ts +104 -0
- package/client/src/lib/tolgee.ts +85 -0
- package/client/src/lib/utils.ts +0 -0
- package/client/src/main.tsx +67 -0
- package/client/src/mapbox-draw-modes.d.ts +32 -0
- package/client/src/pages/all-folders.tsx +203 -0
- package/client/src/pages/auth-callback.tsx +115 -0
- package/client/src/pages/buyer-form.tsx +339 -0
- package/client/src/pages/buyer-list.tsx +622 -0
- package/client/src/pages/content-hub.tsx +1358 -0
- package/client/src/pages/create-deal.tsx +2093 -0
- package/client/src/pages/creative-assignment-page.tsx +548 -0
- package/client/src/pages/creatives.tsx +5 -0
- package/client/src/pages/custom-pois.tsx +425 -0
- package/client/src/pages/dashboard.tsx +615 -0
- package/client/src/pages/deal-history.tsx +434 -0
- package/client/src/pages/deal-line-items.tsx +1703 -0
- package/client/src/pages/demo-status.tsx +113 -0
- package/client/src/pages/direct-campaign-details.tsx +361 -0
- package/client/src/pages/direct-campaigns-new.tsx +824 -0
- package/client/src/pages/dsp-form.tsx +803 -0
- package/client/src/pages/dsp-list.tsx +239 -0
- package/client/src/pages/folder-content.tsx +336 -0
- package/client/src/pages/integrations.tsx +429 -0
- package/client/src/pages/line-item-creatives.tsx +789 -0
- package/client/src/pages/line-item-detail-page.tsx +684 -0
- package/client/src/pages/line-item-form-page.tsx +3261 -0
- package/client/src/pages/line-item-wizard.tsx +1207 -0
- package/client/src/pages/login.tsx +154 -0
- package/client/src/pages/not-found.tsx +23 -0
- package/client/src/pages/proof-of-play.tsx +397 -0
- package/client/src/pages/public-approval.tsx +551 -0
- package/client/src/pages/reports.tsx +231 -0
- package/client/src/pages/settings.tsx +760 -0
- package/client/src/pages/signals.tsx +389 -0
- package/client/src/pages/tags.tsx +318 -0
- package/client/src/pages/test-results.tsx +328 -0
- package/client/src/store/hooks.ts +5 -0
- package/client/src/store/index.ts +15 -0
- package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
- package/client/src/styles/design-tokens.css +324 -0
- package/client/src/test/setup.ts +261 -0
- package/client/src/test/test-utils.tsx +40 -0
- package/client/src/types/approval.ts +221 -0
- package/client/src/types/content-hub.ts +209 -0
- package/client/src/types/geofencing.ts +67 -0
- package/client/src/types/transcoding.ts +140 -0
- package/client/src/vite-env.d.ts +18 -0
- package/components.json +20 -0
- package/creative-api.json +1 -0
- package/docs/AI_REFERENCE.md +459 -0
- package/docs/MWDesign-Prompt.md +132 -0
- package/docs/MWDesign-System.md +344 -0
- package/docs/test-plan.md +277 -0
- package/e2e/AUTONOMOUS-TESTING.md +406 -0
- package/e2e/README.md +219 -0
- package/e2e/autonomous-flow.spec.ts +308 -0
- package/e2e/debug-sso.spec.ts +163 -0
- package/e2e/direct-campaigns.spec.ts +219 -0
- package/e2e/explore-sso.spec.ts +149 -0
- package/e2e/fixtures/auth.ts +26 -0
- package/e2e/fixtures/enhanced-test.ts +331 -0
- package/e2e/pagination.spec.ts +280 -0
- package/e2e/view-toggle.spec.ts +312 -0
- package/generated-icon.png +0 -0
- package/i18next-scanner.config.cjs +46 -0
- package/package.json +141 -0
- package/playwright.config.ts +93 -0
- package/postcss.config.js +6 -0
- package/replit.md +196 -0
- package/screenshot-after-login.png +0 -0
- package/screenshot-contenthub-grid.png +0 -0
- package/screenshot-contenthub-list-fixed.png +0 -0
- package/screenshot-contenthub-list.png +0 -0
- package/screenshot-create-deal.png +0 -0
- package/screenshot-dashboard.png +0 -0
- package/screenshot-deals.png +0 -0
- package/screenshot-login-filled.png +0 -0
- package/screenshot-login.png +0 -0
- package/screenshot.mjs +24 -0
- package/scripts/deploy-stg.sh +185 -0
- package/shared/direct-io-schema.ts +383 -0
- package/shared/schema.ts +439 -0
- package/shared/screen-types.ts +149 -0
- package/springdocDefault.json +1 -0
- package/swagger-ui-bundle.js +2 -0
- package/swagger-ui-init.js +10316 -0
- package/tailwind.config.ts +282 -0
- package/terraform/README.md +306 -0
- package/terraform/cloudfront.tf +289 -0
- package/terraform/ecs.tf +727 -0
- package/terraform/environments/dev.tfvars +59 -0
- package/terraform/environments/production.tfvars +60 -0
- package/terraform/main.tf +47 -0
- package/terraform/outputs.tf +145 -0
- package/terraform/s3.tf +192 -0
- package/terraform/variables.tf +226 -0
- package/terraform/waf.tf +165 -0
- package/terraform-frontend/.terraform.lock.hcl +25 -0
- package/terraform-frontend/README.md +85 -0
- package/terraform-frontend/cloudfront.tf +125 -0
- package/terraform-frontend/main.tf +31 -0
- package/terraform-frontend/outputs.tf +24 -0
- package/terraform-frontend/terraform.tfvars +12 -0
- package/terraform-frontend/variables.tf +53 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +226 -0
- 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
|
+
}
|