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,3132 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo, useRef, useCallback } from "react";
|
|
2
|
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useRoute, useLocation } from "wouter";
|
|
4
|
+
import { useForm, useFieldArray, useWatch } from "react-hook-form";
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import mapboxgl from "mapbox-gl";
|
|
8
|
+
import { ArrowLeft, Loader2, ChevronDown, ChevronUp, Plus, Trash2, TrendingUp, CalendarIcon, Clock, Lock, Monitor, MapPin, RefreshCw, Edit2, Globe, Check, FileText, BarChart3, AlertCircle, DollarSign, Users, Target, Zap, Layers, Copy, Receipt, Server, Building, Layout, CircleDollarSign, CalendarRange, Gauge, Settings } from "lucide-react";
|
|
9
|
+
import { format, parse } from "date-fns";
|
|
10
|
+
import { Calendar } from "@/components/ui/calendar";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
14
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
15
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
16
|
+
import {
|
|
17
|
+
Collapsible,
|
|
18
|
+
CollapsibleContent,
|
|
19
|
+
CollapsibleTrigger,
|
|
20
|
+
} from "@/components/ui/collapsible";
|
|
21
|
+
import {
|
|
22
|
+
Sheet,
|
|
23
|
+
SheetContent,
|
|
24
|
+
SheetDescription,
|
|
25
|
+
SheetHeader,
|
|
26
|
+
SheetTitle,
|
|
27
|
+
} from "@/components/ui/sheet";
|
|
28
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
29
|
+
import "mapbox-gl/dist/mapbox-gl.css";
|
|
30
|
+
|
|
31
|
+
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_PUBLIC_KEY || "";
|
|
32
|
+
import {
|
|
33
|
+
Popover,
|
|
34
|
+
PopoverContent,
|
|
35
|
+
PopoverTrigger,
|
|
36
|
+
} from "@/components/ui/popover";
|
|
37
|
+
import {
|
|
38
|
+
Form,
|
|
39
|
+
FormControl,
|
|
40
|
+
FormField,
|
|
41
|
+
FormItem,
|
|
42
|
+
FormLabel,
|
|
43
|
+
FormMessage,
|
|
44
|
+
FormDescription,
|
|
45
|
+
} from "@/components/ui/form";
|
|
46
|
+
import {
|
|
47
|
+
Select,
|
|
48
|
+
SelectContent,
|
|
49
|
+
SelectItem,
|
|
50
|
+
SelectTrigger,
|
|
51
|
+
SelectValue,
|
|
52
|
+
} from "@/components/ui/select";
|
|
53
|
+
import { Slider } from "@/components/ui/slider";
|
|
54
|
+
import { PageHeader } from "@/components/page-header";
|
|
55
|
+
import { SearchableCombobox, ComboboxOption } from "@/components/searchable-combobox";
|
|
56
|
+
import { TrafficSlider } from "@/components/traffic-slider";
|
|
57
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
58
|
+
import { Badge } from "@/components/ui/badge";
|
|
59
|
+
import { useToast } from "@/hooks/use-toast";
|
|
60
|
+
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
61
|
+
import { FormInsightsPanel } from "@/components/form-insights-panel";
|
|
62
|
+
import type { CampaignGoalType } from "@shared/schema";
|
|
63
|
+
import { VenueTypeTrigger } from "@/components/venue-type-selector";
|
|
64
|
+
import { InventorySelector } from "@/components/inventory-selector";
|
|
65
|
+
import { MediaOwnerDrawer } from "@/components/media-owner-drawer";
|
|
66
|
+
import { InventoryFormatDrawer } from "@/components/inventory-format-drawer";
|
|
67
|
+
import { POITargetingDrawer } from "@/components/poi-targeting-drawer";
|
|
68
|
+
import { AdvancedMapDrawer } from "@/components/advanced-map-drawer";
|
|
69
|
+
import { VenueTypeDrawer } from "@/components/venue-type-drawer";
|
|
70
|
+
import { InventoryAvailabilitySection } from "@/components/inventory-availability-section";
|
|
71
|
+
import { AvailabilityDrawer } from "@/components/availability-drawer";
|
|
72
|
+
import { AIRecommendationPanel } from "@/components/ai-recommendation-panel";
|
|
73
|
+
import { ManualInventoryDrawer } from "@/components/manual-inventory-drawer";
|
|
74
|
+
import type { LineItem, Deal, Screen, DspPartner, Signal } from "@shared/schema";
|
|
75
|
+
import { Switch } from "@/components/ui/switch";
|
|
76
|
+
import {
|
|
77
|
+
CREATIVE_TYPES,
|
|
78
|
+
PACING_OPTIONS,
|
|
79
|
+
INCOME_BRACKETS,
|
|
80
|
+
AGE_GROUPS,
|
|
81
|
+
GENDERS,
|
|
82
|
+
BEHAVIORS,
|
|
83
|
+
INTERESTS,
|
|
84
|
+
INVENTORY_TYPES,
|
|
85
|
+
INVENTORY_CLASSIFICATIONS,
|
|
86
|
+
INVENTORY_FORMATS,
|
|
87
|
+
INVENTORY_FORMATS_BY_TYPE,
|
|
88
|
+
SCREEN_RESOLUTIONS,
|
|
89
|
+
COUNTRIES,
|
|
90
|
+
} from "@shared/schema";
|
|
91
|
+
|
|
92
|
+
// Mock inventory rates - In production, these come from Inventory Management API
|
|
93
|
+
const INVENTORY_RATES: Record<string, { cpm: number; cps: number }> = {
|
|
94
|
+
// By classification
|
|
95
|
+
"Digital": { cpm: 15.00, cps: 1.50 },
|
|
96
|
+
// By type
|
|
97
|
+
"OOH": { cpm: 12.00, cps: 1.20 },
|
|
98
|
+
"Transit": { cpm: 10.00, cps: 1.00 },
|
|
99
|
+
"Retail": { cpm: 14.00, cps: 1.40 },
|
|
100
|
+
// By format
|
|
101
|
+
"Static Billboards": { cpm: 6.00, cps: 0.60 },
|
|
102
|
+
"Digital LED Billboards": { cpm: 18.00, cps: 1.80 },
|
|
103
|
+
"Street Furniture / Transit Shelters": { cpm: 9.00, cps: 0.90 },
|
|
104
|
+
"Mall / Retail Screens": { cpm: 16.00, cps: 1.60 },
|
|
105
|
+
"Cinema Pre-Show / Lobby": { cpm: 22.00, cps: 2.20 },
|
|
106
|
+
"Airport Terminal Screens": { cpm: 25.00, cps: 2.50 },
|
|
107
|
+
"Elevator / Lobby Screens": { cpm: 11.00, cps: 1.10 },
|
|
108
|
+
"Digital Video Walls": { cpm: 20.00, cps: 2.00 },
|
|
109
|
+
"Digital Kiosk": { cpm: 13.00, cps: 1.30 },
|
|
110
|
+
"Programmatic DOOH": { cpm: 17.00, cps: 1.70 },
|
|
111
|
+
// By size category
|
|
112
|
+
"XS": { cpm: 5.00, cps: 0.50 },
|
|
113
|
+
"S": { cpm: 8.00, cps: 0.80 },
|
|
114
|
+
"M": { cpm: 12.00, cps: 1.20 },
|
|
115
|
+
"L": { cpm: 18.00, cps: 1.80 },
|
|
116
|
+
"XL": { cpm: 25.00, cps: 2.50 },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const LINE_ITEM_INSIGHTS = [
|
|
120
|
+
{ text: "Targeting determines audience reach - broader targeting increases potential impressions but may reduce relevance" },
|
|
121
|
+
{ text: "Inventory selection affects available impressions - more formats and classifications expand your reach" },
|
|
122
|
+
{ text: "Max bid impacts win rate in programmatic - higher bids increase win rate but may affect budget efficiency" },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const LINE_ITEM_TIPS = [
|
|
126
|
+
{ text: "Start with broader targeting and narrow based on performance data" },
|
|
127
|
+
{ text: "Use suggested max bid based on inventory selection for optimal results" },
|
|
128
|
+
{ text: "Monitor pacing to optimize delivery and prevent early budget exhaustion" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const SECTION_INSIGHTS = [
|
|
132
|
+
{
|
|
133
|
+
section: "Basic Details",
|
|
134
|
+
insights: [
|
|
135
|
+
{ text: "The line item name helps identify this campaign in reports and dashboards" },
|
|
136
|
+
{ text: "Creative type determines which creative formats can be assigned" },
|
|
137
|
+
],
|
|
138
|
+
tips: [
|
|
139
|
+
{ text: "Use descriptive names that include campaign objective and date range" },
|
|
140
|
+
{ text: "Copy settings from existing line items to save time on similar campaigns" },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
section: "Targeting",
|
|
145
|
+
insights: [
|
|
146
|
+
{ text: "Demographic targeting helps reach specific audience segments" },
|
|
147
|
+
{ text: "POI targeting places ads near relevant points of interest" },
|
|
148
|
+
],
|
|
149
|
+
tips: [
|
|
150
|
+
{ text: "Combine age and gender targeting for more precise audience reach" },
|
|
151
|
+
{ text: "Use POI categories for broader reach or specific POIs for precision" },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
section: "Inventory",
|
|
156
|
+
insights: [
|
|
157
|
+
{ text: "Media owner selection affects available screen inventory" },
|
|
158
|
+
{ text: "Format selection determines compatible creative specifications" },
|
|
159
|
+
],
|
|
160
|
+
tips: [
|
|
161
|
+
{ text: "Use AI recommendations to find optimal inventory for your goals" },
|
|
162
|
+
{ text: "Balance format variety with creative production capabilities" },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
section: "Schedule",
|
|
167
|
+
insights: [
|
|
168
|
+
{ text: "Flight dates determine the campaign delivery window" },
|
|
169
|
+
{ text: "Day-parting optimizes delivery during peak audience times" },
|
|
170
|
+
],
|
|
171
|
+
tips: [
|
|
172
|
+
{ text: "Schedule ads during peak traffic hours for maximum visibility" },
|
|
173
|
+
{ text: "Use dayparting to avoid off-hours when audience is minimal" },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
section: "Budget",
|
|
178
|
+
insights: [
|
|
179
|
+
{ text: "Budget pacing controls how quickly your budget is spent" },
|
|
180
|
+
{ text: "Traffic allocation distributes impressions across line items" },
|
|
181
|
+
],
|
|
182
|
+
tips: [
|
|
183
|
+
{ text: "Even pacing ensures consistent delivery throughout the campaign" },
|
|
184
|
+
{ text: "Front-loaded pacing is useful for time-sensitive promotions" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const formatNumber = (num: number): string => {
|
|
190
|
+
return new Intl.NumberFormat('en-US').format(num);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const formatCurrency = (num: number): string => {
|
|
194
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const customFeeSchema = z.object({
|
|
198
|
+
name: z.string().min(1, "Fee name is required"),
|
|
199
|
+
amount: z.number().min(0, "Amount must be positive"),
|
|
200
|
+
type: z.enum(["fixed", "percentage"]),
|
|
201
|
+
hidden: z.boolean().default(false),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const lineItemFormSchema = z.object({
|
|
205
|
+
name: z.string().min(1, "Line item name is required"),
|
|
206
|
+
dealId: z.string().min(1, "Please select a deal"),
|
|
207
|
+
status: z.enum(["active", "paused", "draft"]).default("draft"),
|
|
208
|
+
copiedFromId: z.string().optional(),
|
|
209
|
+
copyFromLineItemId: z.string().optional(),
|
|
210
|
+
creativeType: z.enum(["display", "video", "audio"]).default("display"),
|
|
211
|
+
priority: z.number().min(1).max(10).default(5),
|
|
212
|
+
demographics: z.array(z.string()).default([]),
|
|
213
|
+
geography: z.array(z.string()).default([]),
|
|
214
|
+
selectedPOIs: z.array(z.string()).default([]),
|
|
215
|
+
incomeBrackets: z.array(z.string()).default([]),
|
|
216
|
+
behaviors: z.array(z.string()).default([]),
|
|
217
|
+
interests: z.array(z.string()).default([]),
|
|
218
|
+
inventoryClassification: z.array(z.string()).default([]),
|
|
219
|
+
inventoryType: z.array(z.string()).default([]),
|
|
220
|
+
inventoryFormat: z.array(z.string()).default([]),
|
|
221
|
+
selectedScreens: z.array(z.string()).default([]),
|
|
222
|
+
startDate: z.string().optional(),
|
|
223
|
+
endDate: z.string().optional(),
|
|
224
|
+
schedules: z.array(z.object({
|
|
225
|
+
days: z.array(z.number()),
|
|
226
|
+
startHour: z.number().min(0).max(23),
|
|
227
|
+
endHour: z.number().min(0).max(23),
|
|
228
|
+
})).default([]),
|
|
229
|
+
budget: z.string().optional(),
|
|
230
|
+
pacing: z.enum(["asap", "even", "front_loaded"]).default("even"),
|
|
231
|
+
trafficAllocation: z.number().min(0).max(100).default(100),
|
|
232
|
+
customFees: z.array(customFeeSchema).default([]),
|
|
233
|
+
frequencyCapImpressions: z.string().optional(),
|
|
234
|
+
frequencyCapPeriod: z.enum(["hour", "day", "week", "month", "lifetime"]).optional(),
|
|
235
|
+
dspId: z.string().optional(),
|
|
236
|
+
dspSeatId: z.string().optional(),
|
|
237
|
+
pushToDsp: z.boolean().default(false),
|
|
238
|
+
creativeDuration: z.number().min(1).default(10),
|
|
239
|
+
triggerEnabled: z.boolean().default(false),
|
|
240
|
+
triggerId: z.string().optional(),
|
|
241
|
+
resolution: z.string().optional().default(""),
|
|
242
|
+
venueTypes: z.array(z.string()).default([]),
|
|
243
|
+
adResolutions: z.array(z.string()).default([]),
|
|
244
|
+
adDurations: z.array(z.number()).default([]),
|
|
245
|
+
billable: z.boolean().default(true),
|
|
246
|
+
unlimitedBudget: z.boolean().default(false),
|
|
247
|
+
budgetConsumption: z.enum(["daily", "weekly", "monthly", "lifetime"]).default("daily"),
|
|
248
|
+
dailyBudget: z.string().optional(),
|
|
249
|
+
currency: z.string().default("USD"),
|
|
250
|
+
automatedBidding: z.boolean().default(false),
|
|
251
|
+
maxBid: z.string().optional(),
|
|
252
|
+
bidType: z.enum(["cpm", "cps"]).default("cpm"),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const DURATION_OPTIONS = [5, 10, 15, 20, 30];
|
|
256
|
+
|
|
257
|
+
const COUNTRY_CENTERS: Record<string, { lng: number; lat: number; zoom: number }> = {
|
|
258
|
+
Malaysia: { lng: 101.9758, lat: 4.2105, zoom: 5 },
|
|
259
|
+
Singapore: { lng: 103.8198, lat: 1.3521, zoom: 10 },
|
|
260
|
+
Thailand: { lng: 100.5018, lat: 13.7563, zoom: 5 },
|
|
261
|
+
Indonesia: { lng: 106.8456, lat: -6.2088, zoom: 4 },
|
|
262
|
+
Philippines: { lng: 121.774, lat: 12.8797, zoom: 5 },
|
|
263
|
+
"United States": { lng: -98.5795, lat: 39.8283, zoom: 3 },
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
type LineItemFormData = z.infer<typeof lineItemFormSchema>;
|
|
267
|
+
|
|
268
|
+
interface MultiSelectFieldProps {
|
|
269
|
+
label: string;
|
|
270
|
+
options: string[];
|
|
271
|
+
value: string[];
|
|
272
|
+
onChange: (value: string[]) => void;
|
|
273
|
+
testIdPrefix: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function MultiSelectField({ label, options, value, onChange, testIdPrefix }: MultiSelectFieldProps) {
|
|
277
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
278
|
+
|
|
279
|
+
const handleToggle = (option: string, checked: boolean) => {
|
|
280
|
+
if (checked) {
|
|
281
|
+
onChange([...value, option]);
|
|
282
|
+
} else {
|
|
283
|
+
onChange(value.filter((v) => v !== option));
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<FormItem>
|
|
289
|
+
<FormLabel>{label}</FormLabel>
|
|
290
|
+
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
291
|
+
<PopoverTrigger asChild>
|
|
292
|
+
<Button
|
|
293
|
+
type="button"
|
|
294
|
+
variant="outline"
|
|
295
|
+
className="w-full justify-between font-normal"
|
|
296
|
+
data-testid={`${testIdPrefix}-trigger`}
|
|
297
|
+
>
|
|
298
|
+
<span className="text-muted-foreground">
|
|
299
|
+
{value.length > 0 ? `${value.length} selected` : `Select ${label.toLowerCase()}...`}
|
|
300
|
+
</span>
|
|
301
|
+
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
302
|
+
</Button>
|
|
303
|
+
</PopoverTrigger>
|
|
304
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
305
|
+
<div className="p-3 max-h-48 overflow-y-auto space-y-2">
|
|
306
|
+
{options.map((option) => (
|
|
307
|
+
<label
|
|
308
|
+
key={option}
|
|
309
|
+
className="flex items-center gap-2 cursor-pointer hover-elevate p-1 rounded"
|
|
310
|
+
>
|
|
311
|
+
<Checkbox
|
|
312
|
+
checked={value.includes(option)}
|
|
313
|
+
onCheckedChange={(checked) => handleToggle(option, checked as boolean)}
|
|
314
|
+
data-testid={`${testIdPrefix}-${option.toLowerCase().replace(/\s+/g, "-")}`}
|
|
315
|
+
/>
|
|
316
|
+
<span className="text-sm">{option}</span>
|
|
317
|
+
</label>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
</PopoverContent>
|
|
321
|
+
</Popover>
|
|
322
|
+
{value.length > 0 && (
|
|
323
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
324
|
+
{value.map((v) => (
|
|
325
|
+
<Badge key={v} variant="secondary" className="text-xs">
|
|
326
|
+
{v}
|
|
327
|
+
</Badge>
|
|
328
|
+
))}
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
</FormItem>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const DAYS_OF_WEEK = [
|
|
336
|
+
{ value: 0, label: "Sun" },
|
|
337
|
+
{ value: 1, label: "Mon" },
|
|
338
|
+
{ value: 2, label: "Tue" },
|
|
339
|
+
{ value: 3, label: "Wed" },
|
|
340
|
+
{ value: 4, label: "Thu" },
|
|
341
|
+
{ value: 5, label: "Fri" },
|
|
342
|
+
{ value: 6, label: "Sat" },
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
const HOURS = Array.from({ length: 24 }, (_, i) => ({
|
|
346
|
+
value: i,
|
|
347
|
+
label: i.toString().padStart(2, "0") + ":00",
|
|
348
|
+
}));
|
|
349
|
+
|
|
350
|
+
interface Schedule {
|
|
351
|
+
days: number[];
|
|
352
|
+
startHour: number;
|
|
353
|
+
endHour: number;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface ScheduleEditorProps {
|
|
357
|
+
value: Schedule[];
|
|
358
|
+
onChange: (value: Schedule[]) => void;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ScheduleEditor({ value, onChange }: ScheduleEditorProps) {
|
|
362
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
363
|
+
|
|
364
|
+
const addSchedule = () => {
|
|
365
|
+
onChange([...value, { days: [1, 2, 3, 4, 5], startHour: 9, endHour: 17 }]);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const removeSchedule = (index: number) => {
|
|
369
|
+
onChange(value.filter((_, i) => i !== index));
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const updateSchedule = (index: number, updates: Partial<Schedule>) => {
|
|
373
|
+
const newSchedules = [...value];
|
|
374
|
+
newSchedules[index] = { ...newSchedules[index], ...updates };
|
|
375
|
+
onChange(newSchedules);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const toggleDay = (scheduleIndex: number, day: number) => {
|
|
379
|
+
const schedule = value[scheduleIndex];
|
|
380
|
+
const newDays = schedule.days.includes(day)
|
|
381
|
+
? schedule.days.filter(d => d !== day)
|
|
382
|
+
: [...schedule.days, day].sort((a, b) => a - b);
|
|
383
|
+
updateSchedule(scheduleIndex, { days: newDays });
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const formatScheduleSummary = (schedule: Schedule) => {
|
|
387
|
+
const dayNames = schedule.days.map(d => DAYS_OF_WEEK.find(dw => dw.value === d)?.label).join(", ");
|
|
388
|
+
return `${dayNames} ${schedule.startHour.toString().padStart(2, "0")}:00-${schedule.endHour.toString().padStart(2, "0")}:00`;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<FormItem>
|
|
393
|
+
<FormLabel>Schedules</FormLabel>
|
|
394
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
395
|
+
<CollapsibleTrigger asChild>
|
|
396
|
+
<Button
|
|
397
|
+
type="button"
|
|
398
|
+
variant="outline"
|
|
399
|
+
className="w-full justify-between font-normal"
|
|
400
|
+
data-testid="schedules-trigger"
|
|
401
|
+
>
|
|
402
|
+
<span className="text-muted-foreground flex items-center gap-2">
|
|
403
|
+
<Clock className="h-4 w-4" />
|
|
404
|
+
{value.length > 0 ? `${value.length} schedule${value.length !== 1 ? "s" : ""} configured` : "No schedules (runs all day)"}
|
|
405
|
+
</span>
|
|
406
|
+
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
407
|
+
</Button>
|
|
408
|
+
</CollapsibleTrigger>
|
|
409
|
+
<CollapsibleContent className="mt-2 space-y-3">
|
|
410
|
+
{value.map((schedule, index) => (
|
|
411
|
+
<Card key={index} className="p-3">
|
|
412
|
+
<div className="space-y-3">
|
|
413
|
+
<div className="flex items-center justify-between">
|
|
414
|
+
<span className="text-sm font-medium">Schedule {index + 1}</span>
|
|
415
|
+
<Button
|
|
416
|
+
type="button"
|
|
417
|
+
variant="ghost"
|
|
418
|
+
size="sm"
|
|
419
|
+
onClick={() => removeSchedule(index)}
|
|
420
|
+
className="text-destructive hover:text-destructive"
|
|
421
|
+
data-testid={`button-remove-schedule-${index}`}
|
|
422
|
+
>
|
|
423
|
+
<Trash2 className="h-4 w-4" />
|
|
424
|
+
</Button>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div>
|
|
428
|
+
<label className="text-xs text-muted-foreground mb-1 block">Days</label>
|
|
429
|
+
<div className="flex gap-1">
|
|
430
|
+
{DAYS_OF_WEEK.map(day => (
|
|
431
|
+
<Button
|
|
432
|
+
key={day.value}
|
|
433
|
+
type="button"
|
|
434
|
+
variant={schedule.days.includes(day.value) ? "default" : "outline"}
|
|
435
|
+
size="sm"
|
|
436
|
+
className="px-2 py-1 h-8 text-xs"
|
|
437
|
+
onClick={() => toggleDay(index, day.value)}
|
|
438
|
+
data-testid={`button-day-${index}-${day.value}`}
|
|
439
|
+
>
|
|
440
|
+
{day.label}
|
|
441
|
+
</Button>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div className="grid grid-cols-2 gap-3">
|
|
447
|
+
<div>
|
|
448
|
+
<label className="text-xs text-muted-foreground mb-1 block">Start Hour</label>
|
|
449
|
+
<Select
|
|
450
|
+
value={schedule.startHour.toString()}
|
|
451
|
+
onValueChange={(v) => updateSchedule(index, { startHour: parseInt(v) })}
|
|
452
|
+
>
|
|
453
|
+
<SelectTrigger data-testid={`select-start-hour-${index}`}>
|
|
454
|
+
<SelectValue />
|
|
455
|
+
</SelectTrigger>
|
|
456
|
+
<SelectContent>
|
|
457
|
+
{HOURS.map(h => (
|
|
458
|
+
<SelectItem key={h.value} value={h.value.toString()}>
|
|
459
|
+
{h.label}
|
|
460
|
+
</SelectItem>
|
|
461
|
+
))}
|
|
462
|
+
</SelectContent>
|
|
463
|
+
</Select>
|
|
464
|
+
</div>
|
|
465
|
+
<div>
|
|
466
|
+
<label className="text-xs text-muted-foreground mb-1 block">End Hour</label>
|
|
467
|
+
<Select
|
|
468
|
+
value={schedule.endHour.toString()}
|
|
469
|
+
onValueChange={(v) => updateSchedule(index, { endHour: parseInt(v) })}
|
|
470
|
+
>
|
|
471
|
+
<SelectTrigger data-testid={`select-end-hour-${index}`}>
|
|
472
|
+
<SelectValue />
|
|
473
|
+
</SelectTrigger>
|
|
474
|
+
<SelectContent>
|
|
475
|
+
{HOURS.map(h => (
|
|
476
|
+
<SelectItem key={h.value} value={h.value.toString()}>
|
|
477
|
+
{h.label}
|
|
478
|
+
</SelectItem>
|
|
479
|
+
))}
|
|
480
|
+
</SelectContent>
|
|
481
|
+
</Select>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</Card>
|
|
486
|
+
))}
|
|
487
|
+
|
|
488
|
+
<Button
|
|
489
|
+
type="button"
|
|
490
|
+
variant="outline"
|
|
491
|
+
size="sm"
|
|
492
|
+
onClick={addSchedule}
|
|
493
|
+
className="w-full"
|
|
494
|
+
data-testid="button-add-schedule"
|
|
495
|
+
>
|
|
496
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
497
|
+
Add Schedule
|
|
498
|
+
</Button>
|
|
499
|
+
</CollapsibleContent>
|
|
500
|
+
</Collapsible>
|
|
501
|
+
|
|
502
|
+
{value.length > 0 && !isOpen && (
|
|
503
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
504
|
+
{value.map((schedule, i) => (
|
|
505
|
+
<Badge key={i} variant="secondary" className="text-xs">
|
|
506
|
+
{formatScheduleSummary(schedule)}
|
|
507
|
+
</Badge>
|
|
508
|
+
))}
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
</FormItem>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
interface DatePickerFieldProps {
|
|
516
|
+
value: string | undefined;
|
|
517
|
+
onChange: (value: string) => void;
|
|
518
|
+
label: string;
|
|
519
|
+
testId: string;
|
|
520
|
+
required?: boolean;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function DatePickerField({ value, onChange, label, testId, required }: DatePickerFieldProps) {
|
|
524
|
+
const [open, setOpen] = useState(false);
|
|
525
|
+
|
|
526
|
+
const selectedDate = value ? parse(value, "yyyy-MM-dd", new Date()) : undefined;
|
|
527
|
+
|
|
528
|
+
const handleSelect = (date: Date | undefined) => {
|
|
529
|
+
if (date) {
|
|
530
|
+
onChange(format(date, "yyyy-MM-dd"));
|
|
531
|
+
setOpen(false);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<FormItem>
|
|
537
|
+
<FormLabel>{label} {required && <span className="text-destructive">*</span>}</FormLabel>
|
|
538
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
539
|
+
<PopoverTrigger asChild>
|
|
540
|
+
<Button
|
|
541
|
+
type="button"
|
|
542
|
+
variant="outline"
|
|
543
|
+
className="w-full justify-start text-left font-normal"
|
|
544
|
+
data-testid={testId}
|
|
545
|
+
>
|
|
546
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
547
|
+
{selectedDate ? format(selectedDate, "MMM dd, yyyy") : <span className="text-muted-foreground">Select date...</span>}
|
|
548
|
+
</Button>
|
|
549
|
+
</PopoverTrigger>
|
|
550
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
551
|
+
<Calendar
|
|
552
|
+
mode="single"
|
|
553
|
+
selected={selectedDate}
|
|
554
|
+
onSelect={handleSelect}
|
|
555
|
+
initialFocus
|
|
556
|
+
/>
|
|
557
|
+
</PopoverContent>
|
|
558
|
+
</Popover>
|
|
559
|
+
</FormItem>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function InventoryMapComponent({
|
|
564
|
+
center,
|
|
565
|
+
zoom,
|
|
566
|
+
screens,
|
|
567
|
+
selectedIds,
|
|
568
|
+
onScreenClick,
|
|
569
|
+
}: {
|
|
570
|
+
center: { lng: number; lat: number };
|
|
571
|
+
zoom: number;
|
|
572
|
+
screens: Screen[];
|
|
573
|
+
selectedIds: Set<string>;
|
|
574
|
+
onScreenClick: (id: string) => void;
|
|
575
|
+
}) {
|
|
576
|
+
const mapContainer = useRef<HTMLDivElement>(null);
|
|
577
|
+
const map = useRef<mapboxgl.Map | null>(null);
|
|
578
|
+
const markersRef = useRef<mapboxgl.Marker[]>([]);
|
|
579
|
+
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (!mapContainer.current || !MAPBOX_TOKEN) return;
|
|
582
|
+
|
|
583
|
+
map.current = new mapboxgl.Map({
|
|
584
|
+
container: mapContainer.current,
|
|
585
|
+
style: "mapbox://styles/mapbox/streets-v12",
|
|
586
|
+
center: [center.lng, center.lat],
|
|
587
|
+
zoom: zoom,
|
|
588
|
+
accessToken: MAPBOX_TOKEN,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
map.current.addControl(new mapboxgl.NavigationControl(), "top-right");
|
|
592
|
+
|
|
593
|
+
return () => {
|
|
594
|
+
map.current?.remove();
|
|
595
|
+
};
|
|
596
|
+
}, []);
|
|
597
|
+
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
if (!map.current) return;
|
|
600
|
+
|
|
601
|
+
map.current.flyTo({
|
|
602
|
+
center: [center.lng, center.lat],
|
|
603
|
+
zoom: zoom,
|
|
604
|
+
});
|
|
605
|
+
}, [center, zoom]);
|
|
606
|
+
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
if (!map.current) return;
|
|
609
|
+
|
|
610
|
+
markersRef.current.forEach((marker) => marker.remove());
|
|
611
|
+
markersRef.current = [];
|
|
612
|
+
|
|
613
|
+
screens.forEach((screen) => {
|
|
614
|
+
if (screen.latitude && screen.longitude) {
|
|
615
|
+
const lat = parseFloat(screen.latitude);
|
|
616
|
+
const lng = parseFloat(screen.longitude);
|
|
617
|
+
|
|
618
|
+
if (!isNaN(lat) && !isNaN(lng)) {
|
|
619
|
+
const isSelected = selectedIds.has(screen.id);
|
|
620
|
+
const el = document.createElement("div");
|
|
621
|
+
el.className = `flex items-center justify-center w-7 h-7 rounded-full shadow-lg cursor-pointer transition-all ${
|
|
622
|
+
isSelected
|
|
623
|
+
? "bg-primary text-primary-foreground scale-100"
|
|
624
|
+
: "bg-muted text-muted-foreground scale-90 opacity-70"
|
|
625
|
+
}`;
|
|
626
|
+
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>';
|
|
627
|
+
el.onclick = () => onScreenClick(screen.id);
|
|
628
|
+
|
|
629
|
+
const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(`
|
|
630
|
+
<div class="p-2">
|
|
631
|
+
<div class="font-medium text-sm">${screen.name}</div>
|
|
632
|
+
<div class="text-xs text-gray-500">${screen.city || ''}, ${screen.country || ''}</div>
|
|
633
|
+
<div class="text-xs mt-1">CPM: $${screen.cpm || '0.00'}</div>
|
|
634
|
+
</div>
|
|
635
|
+
`);
|
|
636
|
+
|
|
637
|
+
const marker = new mapboxgl.Marker(el)
|
|
638
|
+
.setLngLat([lng, lat])
|
|
639
|
+
.setPopup(popup)
|
|
640
|
+
.addTo(map.current!);
|
|
641
|
+
|
|
642
|
+
markersRef.current.push(marker);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}, [screens, selectedIds, onScreenClick]);
|
|
647
|
+
|
|
648
|
+
if (!MAPBOX_TOKEN) {
|
|
649
|
+
return (
|
|
650
|
+
<div className="h-full flex items-center justify-center bg-muted">
|
|
651
|
+
<div className="text-center text-muted-foreground">
|
|
652
|
+
<Globe className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
|
653
|
+
<p>Map unavailable</p>
|
|
654
|
+
<p className="text-sm">Mapbox token not configured</p>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return <div ref={mapContainer} className="h-full w-full" />;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export default function LineItemForm() {
|
|
664
|
+
const [, setLocation] = useLocation();
|
|
665
|
+
const [matchNew, newParams] = useRoute("/deals/:dealId/line-items/new");
|
|
666
|
+
const [matchEdit, editParams] = useRoute("/deals/:dealId/line-items/:lineItemId/edit");
|
|
667
|
+
const { toast } = useToast();
|
|
668
|
+
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
669
|
+
const [inventoryLoading, setInventoryLoading] = useState(false);
|
|
670
|
+
const [fetchedInventoryCount, setFetchedInventoryCount] = useState<number | null>(null);
|
|
671
|
+
const [inventoryDrawerOpen, setInventoryDrawerOpen] = useState(false);
|
|
672
|
+
const [fetchedScreens, setFetchedScreens] = useState<Screen[]>([]);
|
|
673
|
+
|
|
674
|
+
const [advancedMapDrawerOpen, setAdvancedMapDrawerOpen] = useState(false);
|
|
675
|
+
const [venueTypeDrawerOpen, setVenueTypeDrawerOpen] = useState(false);
|
|
676
|
+
const [availabilityDrawerOpen, setAvailabilityDrawerOpen] = useState(false);
|
|
677
|
+
const [poiDrawerOpen, setPoiDrawerOpen] = useState(false);
|
|
678
|
+
const [mediaOwnerDrawerOpen, setMediaOwnerDrawerOpen] = useState(false);
|
|
679
|
+
const [inventoryFormatDrawerOpen, setInventoryFormatDrawerOpen] = useState(false);
|
|
680
|
+
const [manualInventoryDrawerOpen, setManualInventoryDrawerOpen] = useState(false);
|
|
681
|
+
const [activeTargetingSection, setActiveTargetingSection] = useState<string | null>(null);
|
|
682
|
+
const [selectedPOICategories, setSelectedPOICategories] = useState<string[]>([]);
|
|
683
|
+
const [selectedMediaOwners, setSelectedMediaOwners] = useState<string[]>([]);
|
|
684
|
+
const [currentFormSection, setCurrentFormSection] = useState<string>("Basic Details");
|
|
685
|
+
|
|
686
|
+
const targetingSectionRef = useRef<HTMLDivElement>(null);
|
|
687
|
+
const inventorySectionRef = useRef<HTMLDivElement>(null);
|
|
688
|
+
const basicSectionRef = useRef<HTMLDivElement>(null);
|
|
689
|
+
const scheduleSectionRef = useRef<HTMLDivElement>(null);
|
|
690
|
+
const budgetSectionRef = useRef<HTMLDivElement>(null);
|
|
691
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
692
|
+
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
695
|
+
if (activeTargetingSection) {
|
|
696
|
+
const target = event.target as HTMLElement;
|
|
697
|
+
const clickedCollapsible = target.closest('[data-collapsible-section]');
|
|
698
|
+
const clickedSectionId = clickedCollapsible?.getAttribute('data-collapsible-section');
|
|
699
|
+
|
|
700
|
+
if (!clickedSectionId || clickedSectionId !== activeTargetingSection) {
|
|
701
|
+
setActiveTargetingSection(null);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
707
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
708
|
+
}, [activeTargetingSection]);
|
|
709
|
+
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
const scrollContainer = scrollContainerRef.current;
|
|
712
|
+
if (!scrollContainer) return;
|
|
713
|
+
|
|
714
|
+
const handleScroll = () => {
|
|
715
|
+
const sections = [
|
|
716
|
+
{ ref: basicSectionRef, name: "Basic Details" },
|
|
717
|
+
{ ref: targetingSectionRef, name: "Targeting" },
|
|
718
|
+
{ ref: inventorySectionRef, name: "Inventory" },
|
|
719
|
+
{ ref: scheduleSectionRef, name: "Schedule" },
|
|
720
|
+
{ ref: budgetSectionRef, name: "Budget" },
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
const containerRect = scrollContainer.getBoundingClientRect();
|
|
724
|
+
const viewportCenter = containerRect.top + containerRect.height / 3;
|
|
725
|
+
|
|
726
|
+
for (const { ref, name } of sections) {
|
|
727
|
+
if (ref.current) {
|
|
728
|
+
const rect = ref.current.getBoundingClientRect();
|
|
729
|
+
if (rect.top <= viewportCenter && rect.bottom >= viewportCenter) {
|
|
730
|
+
setCurrentFormSection(name);
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
scrollContainer.addEventListener("scroll", handleScroll);
|
|
738
|
+
handleScroll();
|
|
739
|
+
|
|
740
|
+
return () => scrollContainer.removeEventListener("scroll", handleScroll);
|
|
741
|
+
}, []);
|
|
742
|
+
|
|
743
|
+
const isEditing = Boolean(matchEdit);
|
|
744
|
+
const dealId = isEditing ? editParams?.dealId : newParams?.dealId;
|
|
745
|
+
const lineItemId = editParams?.lineItemId;
|
|
746
|
+
const hasDealFromRoute = Boolean(dealId);
|
|
747
|
+
|
|
748
|
+
const { data: allDeals, isLoading: dealsLoading } = useQuery<Deal[]>({
|
|
749
|
+
queryKey: ["/api/deals"],
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const { data: allLineItems, isLoading: lineItemsLoading } = useQuery<LineItem[]>({
|
|
753
|
+
queryKey: ["/api/line-items"],
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const { data: existingLineItem, isLoading: lineItemLoading } = useQuery<LineItem>({
|
|
757
|
+
queryKey: ["/api/line-items", lineItemId],
|
|
758
|
+
enabled: isEditing && Boolean(lineItemId),
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const { data: currentDeal } = useQuery<Deal>({
|
|
762
|
+
queryKey: ["/api/deals", dealId],
|
|
763
|
+
enabled: Boolean(dealId),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const { data: allScreens = [] } = useQuery<Screen[]>({
|
|
767
|
+
queryKey: ["/api/screens"],
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const { data: dspPartners = [] } = useQuery<DspPartner[]>({
|
|
771
|
+
queryKey: ["/api/dsp-partners"],
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const { data: signals = [] } = useQuery<Signal[]>({
|
|
775
|
+
queryKey: ["/api/signals"],
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const isPGDeal = currentDeal?.dealType === "pg";
|
|
779
|
+
const isProgrammaticDeal = currentDeal?.dealType !== "traditional";
|
|
780
|
+
const isTraditionalDeal = currentDeal?.dealType === "traditional";
|
|
781
|
+
const dealBudget = currentDeal?.budget ? parseFloat(currentDeal.budget) : 0;
|
|
782
|
+
|
|
783
|
+
// Line items available for copying (exclude current line item if editing)
|
|
784
|
+
const lineItemsForCopy = useMemo(() => {
|
|
785
|
+
if (!allLineItems) return [];
|
|
786
|
+
return allLineItems.filter((li) => li.id !== lineItemId);
|
|
787
|
+
}, [allLineItems, lineItemId]);
|
|
788
|
+
|
|
789
|
+
const auctionType = useMemo(() => {
|
|
790
|
+
if (!currentDeal) return null;
|
|
791
|
+
const dealType = currentDeal.dealType;
|
|
792
|
+
if (dealType === 'pg' || dealType === 'pd') return 'Fixed Price';
|
|
793
|
+
if (dealType === 'traditional') return 'Not Applicable';
|
|
794
|
+
if (dealType === 'always_on' || dealType === 'pmp') return 'First Price Auction';
|
|
795
|
+
return null;
|
|
796
|
+
}, [currentDeal]);
|
|
797
|
+
|
|
798
|
+
const dealOptions: ComboboxOption[] = (allDeals ?? []).map((deal) => ({
|
|
799
|
+
value: deal.id,
|
|
800
|
+
label: deal.name,
|
|
801
|
+
}));
|
|
802
|
+
|
|
803
|
+
const existingLineItemOptions: ComboboxOption[] = (allLineItems ?? [])
|
|
804
|
+
.filter((li) => li.id !== lineItemId)
|
|
805
|
+
.map((li) => ({
|
|
806
|
+
value: li.id,
|
|
807
|
+
label: li.name,
|
|
808
|
+
}));
|
|
809
|
+
|
|
810
|
+
const form = useForm<LineItemFormData>({
|
|
811
|
+
resolver: zodResolver(lineItemFormSchema),
|
|
812
|
+
defaultValues: {
|
|
813
|
+
name: "",
|
|
814
|
+
dealId: "",
|
|
815
|
+
status: "draft",
|
|
816
|
+
copiedFromId: "",
|
|
817
|
+
copyFromLineItemId: "",
|
|
818
|
+
creativeType: "display",
|
|
819
|
+
priority: 5,
|
|
820
|
+
demographics: [],
|
|
821
|
+
geography: [],
|
|
822
|
+
selectedPOIs: [],
|
|
823
|
+
incomeBrackets: [],
|
|
824
|
+
behaviors: [],
|
|
825
|
+
interests: [],
|
|
826
|
+
inventoryClassification: [],
|
|
827
|
+
inventoryType: [],
|
|
828
|
+
inventoryFormat: [],
|
|
829
|
+
selectedScreens: [],
|
|
830
|
+
startDate: "",
|
|
831
|
+
endDate: "",
|
|
832
|
+
schedules: [],
|
|
833
|
+
budget: "",
|
|
834
|
+
pacing: "even",
|
|
835
|
+
trafficAllocation: 100,
|
|
836
|
+
customFees: [],
|
|
837
|
+
frequencyCapImpressions: "",
|
|
838
|
+
frequencyCapPeriod: undefined,
|
|
839
|
+
dspId: "",
|
|
840
|
+
dspSeatId: "",
|
|
841
|
+
pushToDsp: false,
|
|
842
|
+
creativeDuration: 10,
|
|
843
|
+
triggerEnabled: false,
|
|
844
|
+
triggerId: "",
|
|
845
|
+
resolution: "",
|
|
846
|
+
venueTypes: [],
|
|
847
|
+
adResolutions: [],
|
|
848
|
+
adDurations: [],
|
|
849
|
+
billable: true,
|
|
850
|
+
unlimitedBudget: false,
|
|
851
|
+
budgetConsumption: "daily",
|
|
852
|
+
dailyBudget: "",
|
|
853
|
+
currency: "USD",
|
|
854
|
+
automatedBidding: false,
|
|
855
|
+
maxBid: "",
|
|
856
|
+
bidType: "cpm",
|
|
857
|
+
},
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const watchedSelectedPOIs = useWatch({ control: form.control, name: "selectedPOIs" });
|
|
861
|
+
|
|
862
|
+
const handleFetchInventory = useCallback(async () => {
|
|
863
|
+
setInventoryLoading(true);
|
|
864
|
+
setFetchedInventoryCount(null);
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
const params = new URLSearchParams();
|
|
868
|
+
const countries = currentDeal?.countries;
|
|
869
|
+
if (countries && countries.length > 0) {
|
|
870
|
+
params.set("country", countries[0]);
|
|
871
|
+
}
|
|
872
|
+
if (currentDeal?.sspId) {
|
|
873
|
+
params.set("sspId", currentDeal.sspId);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const url = params.toString() ? `/api/screens?${params.toString()}` : "/api/screens";
|
|
877
|
+
const response = await fetch(url);
|
|
878
|
+
const screens: Screen[] = await response.json();
|
|
879
|
+
|
|
880
|
+
setFetchedScreens(screens);
|
|
881
|
+
setFetchedInventoryCount(screens.length);
|
|
882
|
+
form.setValue("selectedScreens", screens.map(s => s.id));
|
|
883
|
+
|
|
884
|
+
toast({
|
|
885
|
+
title: "Inventory fetched",
|
|
886
|
+
description: `Found ${screens.length} available screens`,
|
|
887
|
+
});
|
|
888
|
+
} catch (error) {
|
|
889
|
+
toast({
|
|
890
|
+
title: "Error",
|
|
891
|
+
description: "Failed to fetch inventory. Please try again.",
|
|
892
|
+
variant: "destructive",
|
|
893
|
+
});
|
|
894
|
+
} finally {
|
|
895
|
+
setInventoryLoading(false);
|
|
896
|
+
}
|
|
897
|
+
}, [currentDeal, form, toast]);
|
|
898
|
+
|
|
899
|
+
const handleToggleScreen = useCallback((screenId: string) => {
|
|
900
|
+
const current = form.getValues("selectedScreens") || [];
|
|
901
|
+
if (current.includes(screenId)) {
|
|
902
|
+
form.setValue("selectedScreens", current.filter(id => id !== screenId));
|
|
903
|
+
} else {
|
|
904
|
+
form.setValue("selectedScreens", [...current, screenId]);
|
|
905
|
+
}
|
|
906
|
+
}, [form]);
|
|
907
|
+
|
|
908
|
+
// Watch inventory selections and floor rate type for auto-calculation
|
|
909
|
+
const watchedInventoryClassification = useWatch({ control: form.control, name: "inventoryClassification" });
|
|
910
|
+
const watchedInventoryType = useWatch({ control: form.control, name: "inventoryType" });
|
|
911
|
+
const watchedInventoryFormat = useWatch({ control: form.control, name: "inventoryFormat" });
|
|
912
|
+
const watchedBidType = useWatch({ control: form.control, name: "bidType" });
|
|
913
|
+
const watchedSelectedScreens = useWatch({ control: form.control, name: "selectedScreens" });
|
|
914
|
+
const watchedCreativeDuration = useWatch({ control: form.control, name: "creativeDuration" });
|
|
915
|
+
|
|
916
|
+
const selectedScreenIds = useMemo(() => new Set(watchedSelectedScreens || []), [watchedSelectedScreens]);
|
|
917
|
+
|
|
918
|
+
const primaryCountry = (currentDeal?.countries && currentDeal.countries.length > 0) ? currentDeal.countries[0] : "Malaysia";
|
|
919
|
+
const countryName = COUNTRIES.find(c => c.code === primaryCountry)?.name || "Malaysia";
|
|
920
|
+
const mapCenter = COUNTRY_CENTERS[countryName] || COUNTRY_CENTERS["Malaysia"];
|
|
921
|
+
|
|
922
|
+
const { fields: feeFields, append: appendFee, remove: removeFee } = useFieldArray({
|
|
923
|
+
control: form.control,
|
|
924
|
+
name: "customFees",
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Watch additional values for forecast calculations
|
|
928
|
+
const watchedStartDate = useWatch({ control: form.control, name: "startDate" });
|
|
929
|
+
const watchedEndDate = useWatch({ control: form.control, name: "endDate" });
|
|
930
|
+
const watchedMaxBid = useWatch({ control: form.control, name: "maxBid" });
|
|
931
|
+
const watchedBudget = useWatch({ control: form.control, name: "budget" });
|
|
932
|
+
const watchedCustomFees = useWatch({ control: form.control, name: "customFees" });
|
|
933
|
+
const watchedBillable = useWatch({ control: form.control, name: "billable" });
|
|
934
|
+
const watchedUnlimitedBudget = useWatch({ control: form.control, name: "unlimitedBudget" });
|
|
935
|
+
const watchedAutomatedBidding = useWatch({ control: form.control, name: "automatedBidding" });
|
|
936
|
+
const watchedGeography = useWatch({ control: form.control, name: "geography" });
|
|
937
|
+
const watchedDemographics = useWatch({ control: form.control, name: "demographics" });
|
|
938
|
+
const watchedVenueTypes = useWatch({ control: form.control, name: "venueTypes" });
|
|
939
|
+
|
|
940
|
+
// Calculate suggested max bid based on selected screens' CPM values
|
|
941
|
+
const suggestedMaxBid = useMemo(() => {
|
|
942
|
+
const selectedScreenIds = watchedSelectedScreens || [];
|
|
943
|
+
const bidType = watchedBidType || "cpm";
|
|
944
|
+
|
|
945
|
+
if (selectedScreenIds.length === 0) {
|
|
946
|
+
// Fallback to inventory category rates if no screens selected
|
|
947
|
+
const allSelections = [
|
|
948
|
+
...(watchedInventoryClassification || []),
|
|
949
|
+
...(watchedInventoryType || []),
|
|
950
|
+
...(watchedInventoryFormat || []),
|
|
951
|
+
];
|
|
952
|
+
|
|
953
|
+
if (allSelections.length === 0) return null;
|
|
954
|
+
|
|
955
|
+
const rates: number[] = [];
|
|
956
|
+
allSelections.forEach((selection) => {
|
|
957
|
+
const rate = INVENTORY_RATES[selection];
|
|
958
|
+
if (rate) {
|
|
959
|
+
rates.push(bidType === "cpm" ? rate.cpm : rate.cps);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
if (rates.length === 0) return null;
|
|
964
|
+
const average = rates.reduce((sum, r) => sum + r, 0) / rates.length;
|
|
965
|
+
return average.toFixed(2);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Calculate average CPM from selected screens
|
|
969
|
+
const selectedScreens = allScreens.filter(s => selectedScreenIds.includes(s.id));
|
|
970
|
+
if (selectedScreens.length === 0) return null;
|
|
971
|
+
|
|
972
|
+
const validCpms = selectedScreens
|
|
973
|
+
.map(s => s.cpm ? parseFloat(s.cpm) : null)
|
|
974
|
+
.filter((cpm): cpm is number => cpm !== null && !isNaN(cpm));
|
|
975
|
+
|
|
976
|
+
if (validCpms.length === 0) return null;
|
|
977
|
+
|
|
978
|
+
const avgCpm = validCpms.reduce((sum, cpm) => sum + cpm, 0) / validCpms.length;
|
|
979
|
+
|
|
980
|
+
if (bidType === "cpm") {
|
|
981
|
+
return avgCpm.toFixed(2);
|
|
982
|
+
} else {
|
|
983
|
+
// CPS: derive from CPM (CPM / 1000 / avg_duration)
|
|
984
|
+
const duration = watchedCreativeDuration || 10;
|
|
985
|
+
const cps = avgCpm / 1000 / duration;
|
|
986
|
+
return cps.toFixed(4);
|
|
987
|
+
}
|
|
988
|
+
}, [watchedSelectedScreens, allScreens, watchedInventoryClassification, watchedInventoryType, watchedInventoryFormat, watchedBidType, watchedCreativeDuration]);
|
|
989
|
+
|
|
990
|
+
// Check if current max bid differs from suggestion (using numeric comparison)
|
|
991
|
+
const maxBidDiffersFromSuggestion = useMemo(() => {
|
|
992
|
+
if (!suggestedMaxBid || !watchedMaxBid) return false;
|
|
993
|
+
const currentValue = parseFloat(String(watchedMaxBid));
|
|
994
|
+
const suggestedValue = parseFloat(suggestedMaxBid);
|
|
995
|
+
if (isNaN(currentValue) || isNaN(suggestedValue)) return false;
|
|
996
|
+
// Use a small tolerance for floating point comparison
|
|
997
|
+
return Math.abs(currentValue - suggestedValue) > 0.001;
|
|
998
|
+
}, [suggestedMaxBid, watchedMaxBid]);
|
|
999
|
+
|
|
1000
|
+
const applyMaxBidSuggestion = () => {
|
|
1001
|
+
if (suggestedMaxBid) {
|
|
1002
|
+
form.setValue("maxBid", suggestedMaxBid);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Calculate forecast based on form values
|
|
1007
|
+
const forecast = useMemo(() => {
|
|
1008
|
+
const allInventorySelections = [
|
|
1009
|
+
...(watchedInventoryClassification || []),
|
|
1010
|
+
...(watchedInventoryType || []),
|
|
1011
|
+
...(watchedInventoryFormat || []),
|
|
1012
|
+
];
|
|
1013
|
+
const selectedScreenIds = watchedSelectedScreens || [];
|
|
1014
|
+
|
|
1015
|
+
// Use selected screens count if available, otherwise use inventory selections
|
|
1016
|
+
const inventoryCount = selectedScreenIds.length > 0
|
|
1017
|
+
? selectedScreenIds.length
|
|
1018
|
+
: allInventorySelections.length;
|
|
1019
|
+
const startDate = watchedStartDate ? new Date(watchedStartDate) : null;
|
|
1020
|
+
const endDate = watchedEndDate ? new Date(watchedEndDate) : null;
|
|
1021
|
+
const maxBid = watchedMaxBid ? parseFloat(watchedMaxBid) : 0;
|
|
1022
|
+
const lineItemBudget = watchedBudget ? parseFloat(watchedBudget) : 0;
|
|
1023
|
+
const creativeDuration = watchedCreativeDuration || 10;
|
|
1024
|
+
const bidType = watchedBidType || "cpm";
|
|
1025
|
+
|
|
1026
|
+
// Check if we have enough data to calculate forecast
|
|
1027
|
+
if (inventoryCount === 0 || !startDate || !endDate || startDate > endDate) {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Calculate days in range
|
|
1032
|
+
const timeDiff = endDate.getTime() - startDate.getTime();
|
|
1033
|
+
const daysInRange = Math.max(1, Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1);
|
|
1034
|
+
|
|
1035
|
+
// Mock logic: estimated impressions = inventory selections × 10000 × days
|
|
1036
|
+
const estimatedImpressions = inventoryCount * 10000 * daysInRange;
|
|
1037
|
+
|
|
1038
|
+
// Calculate total cost based on bid type
|
|
1039
|
+
let totalCost = 0;
|
|
1040
|
+
if (maxBid > 0) {
|
|
1041
|
+
if (bidType === "cpm") {
|
|
1042
|
+
// CPM: cost = (impressions / 1000) * max_bid
|
|
1043
|
+
totalCost = (estimatedImpressions / 1000) * maxBid;
|
|
1044
|
+
} else {
|
|
1045
|
+
// CPS: cost = impressions * creative_duration * max_bid
|
|
1046
|
+
totalCost = estimatedImpressions * creativeDuration * maxBid;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Media Cost is the base cost from floor rate
|
|
1051
|
+
const mediaCost = totalCost;
|
|
1052
|
+
|
|
1053
|
+
// Calculate custom fees total
|
|
1054
|
+
let customFeesTotal = 0;
|
|
1055
|
+
const customFeesList = watchedCustomFees || [];
|
|
1056
|
+
customFeesList.forEach((fee: { amount: number; type: string }) => {
|
|
1057
|
+
if (fee.type === "fixed") {
|
|
1058
|
+
customFeesTotal += fee.amount;
|
|
1059
|
+
} else if (fee.type === "percentage") {
|
|
1060
|
+
customFeesTotal += mediaCost * (fee.amount / 100);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// Platform fee (5% of media cost as example)
|
|
1065
|
+
const platformFeePercent = 5;
|
|
1066
|
+
const platformFee = mediaCost * (platformFeePercent / 100);
|
|
1067
|
+
|
|
1068
|
+
// Grand total = Media Cost + Custom Fees + Platform Fee
|
|
1069
|
+
const grandTotal = mediaCost + customFeesTotal + platformFee;
|
|
1070
|
+
|
|
1071
|
+
// Calculate cost efficiency (effective rate)
|
|
1072
|
+
const effectiveCPM = estimatedImpressions > 0 && totalCost > 0
|
|
1073
|
+
? (totalCost / estimatedImpressions) * 1000
|
|
1074
|
+
: 0;
|
|
1075
|
+
const effectiveCPS = estimatedImpressions > 0 && totalCost > 0
|
|
1076
|
+
? totalCost / estimatedImpressions
|
|
1077
|
+
: 0;
|
|
1078
|
+
|
|
1079
|
+
// Calculate budget utilization if budget is set
|
|
1080
|
+
const budgetUtilization = lineItemBudget > 0 && grandTotal > 0
|
|
1081
|
+
? Math.min(100, (grandTotal / lineItemBudget) * 100)
|
|
1082
|
+
: null;
|
|
1083
|
+
|
|
1084
|
+
// Check if cost exceeds deal budget
|
|
1085
|
+
const exceedsDealBudget = dealBudget > 0 && grandTotal > dealBudget;
|
|
1086
|
+
const exceedsLineItemBudget = lineItemBudget > 0 && grandTotal > lineItemBudget;
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
estimatedImpressions,
|
|
1090
|
+
estimatedSpend: grandTotal,
|
|
1091
|
+
totalCost: grandTotal,
|
|
1092
|
+
mediaCost,
|
|
1093
|
+
customFeesTotal,
|
|
1094
|
+
platformFee,
|
|
1095
|
+
platformFeePercent,
|
|
1096
|
+
hasCustomFees: customFeesList.length > 0 && customFeesTotal > 0,
|
|
1097
|
+
effectiveCPM,
|
|
1098
|
+
effectiveCPS,
|
|
1099
|
+
daysInRange,
|
|
1100
|
+
budgetUtilization,
|
|
1101
|
+
hasBudget: lineItemBudget > 0,
|
|
1102
|
+
hasMaxBid: maxBid > 0,
|
|
1103
|
+
dealBudget,
|
|
1104
|
+
lineItemBudget,
|
|
1105
|
+
exceedsDealBudget,
|
|
1106
|
+
exceedsLineItemBudget,
|
|
1107
|
+
creativeDuration,
|
|
1108
|
+
};
|
|
1109
|
+
}, [
|
|
1110
|
+
watchedInventoryClassification,
|
|
1111
|
+
watchedInventoryType,
|
|
1112
|
+
watchedInventoryFormat,
|
|
1113
|
+
watchedSelectedScreens,
|
|
1114
|
+
watchedStartDate,
|
|
1115
|
+
watchedEndDate,
|
|
1116
|
+
watchedMaxBid,
|
|
1117
|
+
watchedBidType,
|
|
1118
|
+
watchedBudget,
|
|
1119
|
+
watchedCreativeDuration,
|
|
1120
|
+
watchedCustomFees,
|
|
1121
|
+
dealBudget,
|
|
1122
|
+
]);
|
|
1123
|
+
|
|
1124
|
+
const handleCopyLineItem = (lineItemId: string) => {
|
|
1125
|
+
const sourceLineItem = allLineItems?.find((li) => li.id === lineItemId);
|
|
1126
|
+
if (sourceLineItem) {
|
|
1127
|
+
const targeting = sourceLineItem.targeting as Record<string, string[]> | null;
|
|
1128
|
+
const inventoryFilters = sourceLineItem.inventoryFilters as Record<string, string[]> | null;
|
|
1129
|
+
const customFees = sourceLineItem.customFees as Array<{ name: string; amount: number; type: "fixed" | "percentage"; hidden?: boolean }> | null;
|
|
1130
|
+
const frequencyCap = sourceLineItem.frequencyCap as { impressions: number; period: string } | null;
|
|
1131
|
+
|
|
1132
|
+
form.reset({
|
|
1133
|
+
name: form.getValues("name"),
|
|
1134
|
+
dealId: form.getValues("dealId"),
|
|
1135
|
+
status: (sourceLineItem.status as "active" | "paused" | "draft") ?? "draft",
|
|
1136
|
+
copiedFromId: lineItemId,
|
|
1137
|
+
copyFromLineItemId: lineItemId,
|
|
1138
|
+
creativeType: (sourceLineItem.creativeType as "display" | "video" | "audio") ?? "display",
|
|
1139
|
+
priority: sourceLineItem.priority ?? 5,
|
|
1140
|
+
demographics: targeting?.demographics ?? [],
|
|
1141
|
+
geography: targeting?.geography ?? [],
|
|
1142
|
+
selectedPOIs: targeting?.selectedPOIs ?? [],
|
|
1143
|
+
incomeBrackets: targeting?.incomeBrackets ?? [],
|
|
1144
|
+
behaviors: targeting?.behaviors ?? [],
|
|
1145
|
+
interests: targeting?.interests ?? [],
|
|
1146
|
+
inventoryClassification: inventoryFilters?.classification ?? [],
|
|
1147
|
+
inventoryType: inventoryFilters?.type ?? [],
|
|
1148
|
+
inventoryFormat: inventoryFilters?.format ?? [],
|
|
1149
|
+
startDate: sourceLineItem.startDate ?? "",
|
|
1150
|
+
endDate: sourceLineItem.endDate ?? "",
|
|
1151
|
+
schedules: (sourceLineItem.schedules as Schedule[]) ?? [],
|
|
1152
|
+
budget: sourceLineItem.budget ?? "",
|
|
1153
|
+
pacing: (sourceLineItem.pacing as "asap" | "even" | "front_loaded") ?? "even",
|
|
1154
|
+
trafficAllocation: sourceLineItem.trafficAllocation ?? 100,
|
|
1155
|
+
customFees: customFees ?? [],
|
|
1156
|
+
frequencyCapImpressions: frequencyCap?.impressions?.toString() ?? "",
|
|
1157
|
+
frequencyCapPeriod: frequencyCap?.period as "hour" | "day" | "week" | "month" | "lifetime" | undefined,
|
|
1158
|
+
triggerEnabled: sourceLineItem.triggerEnabled ?? false,
|
|
1159
|
+
triggerId: sourceLineItem.triggerId ?? "",
|
|
1160
|
+
resolution: sourceLineItem.resolution ?? "",
|
|
1161
|
+
venueTypes: form.getValues("venueTypes") ?? [],
|
|
1162
|
+
adResolutions: form.getValues("adResolutions") ?? [],
|
|
1163
|
+
adDurations: form.getValues("adDurations") ?? [],
|
|
1164
|
+
billable: form.getValues("billable") ?? true,
|
|
1165
|
+
unlimitedBudget: form.getValues("unlimitedBudget") ?? false,
|
|
1166
|
+
budgetConsumption: form.getValues("budgetConsumption") ?? "daily",
|
|
1167
|
+
dailyBudget: form.getValues("dailyBudget") ?? "",
|
|
1168
|
+
currency: currentDeal?.currency || "USD",
|
|
1169
|
+
automatedBidding: form.getValues("automatedBidding") ?? false,
|
|
1170
|
+
maxBid: form.getValues("maxBid") ?? "",
|
|
1171
|
+
bidType: form.getValues("bidType") ?? "cpm",
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
toast({
|
|
1175
|
+
title: "Line item copied",
|
|
1176
|
+
description: `Settings copied from "${sourceLineItem.name}"`,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
if (existingLineItem) {
|
|
1183
|
+
const targeting = existingLineItem.targeting as Record<string, string[]> | null;
|
|
1184
|
+
const inventoryFilters = existingLineItem.inventoryFilters as Record<string, string[]> | null;
|
|
1185
|
+
const customFees = existingLineItem.customFees as Array<{ name: string; amount: number; type: "fixed" | "percentage"; hidden?: boolean }> | null;
|
|
1186
|
+
const frequencyCap = existingLineItem.frequencyCap as { impressions: number; period: string } | null;
|
|
1187
|
+
|
|
1188
|
+
form.reset({
|
|
1189
|
+
name: existingLineItem.name,
|
|
1190
|
+
dealId: existingLineItem.dealId ?? "",
|
|
1191
|
+
status: (existingLineItem.status as "active" | "paused" | "draft") ?? "draft",
|
|
1192
|
+
copiedFromId: existingLineItem.copiedFromId ?? "",
|
|
1193
|
+
creativeType: (existingLineItem.creativeType as "display" | "video" | "audio") ?? "display",
|
|
1194
|
+
priority: existingLineItem.priority ?? 5,
|
|
1195
|
+
demographics: targeting?.demographics ?? [],
|
|
1196
|
+
geography: targeting?.geography ?? [],
|
|
1197
|
+
selectedPOIs: targeting?.selectedPOIs ?? [],
|
|
1198
|
+
incomeBrackets: targeting?.incomeBrackets ?? [],
|
|
1199
|
+
behaviors: targeting?.behaviors ?? [],
|
|
1200
|
+
interests: targeting?.interests ?? [],
|
|
1201
|
+
inventoryClassification: inventoryFilters?.classification ?? [],
|
|
1202
|
+
inventoryType: inventoryFilters?.type ?? [],
|
|
1203
|
+
inventoryFormat: inventoryFilters?.format ?? [],
|
|
1204
|
+
startDate: existingLineItem.startDate ?? "",
|
|
1205
|
+
endDate: existingLineItem.endDate ?? "",
|
|
1206
|
+
schedules: (existingLineItem.schedules as Schedule[]) ?? [],
|
|
1207
|
+
budget: existingLineItem.budget ?? "",
|
|
1208
|
+
pacing: (existingLineItem.pacing as "asap" | "even" | "front_loaded") ?? "even",
|
|
1209
|
+
trafficAllocation: existingLineItem.trafficAllocation ?? 100,
|
|
1210
|
+
customFees: customFees ?? [],
|
|
1211
|
+
frequencyCapImpressions: frequencyCap?.impressions?.toString() ?? "",
|
|
1212
|
+
frequencyCapPeriod: frequencyCap?.period as "hour" | "day" | "week" | "month" | "lifetime" | undefined,
|
|
1213
|
+
dspId: existingLineItem.dspId ?? "",
|
|
1214
|
+
dspSeatId: existingLineItem.dspSeatId ?? "",
|
|
1215
|
+
pushToDsp: existingLineItem.pushToDsp ?? false,
|
|
1216
|
+
creativeDuration: existingLineItem.creativeDuration ?? 10,
|
|
1217
|
+
triggerEnabled: existingLineItem.triggerEnabled ?? false,
|
|
1218
|
+
triggerId: existingLineItem.triggerId ?? "",
|
|
1219
|
+
resolution: existingLineItem.resolution ?? "",
|
|
1220
|
+
});
|
|
1221
|
+
} else if (dealId && !isEditing) {
|
|
1222
|
+
form.setValue("dealId", dealId);
|
|
1223
|
+
// Set pushToDsp to true by default for programmatic deals
|
|
1224
|
+
if (isProgrammaticDeal) {
|
|
1225
|
+
form.setValue("pushToDsp", true);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}, [existingLineItem, form, dealId, isEditing, isProgrammaticDeal]);
|
|
1229
|
+
|
|
1230
|
+
const createMutation = useMutation({
|
|
1231
|
+
mutationFn: async (data: LineItemFormData) => {
|
|
1232
|
+
return apiRequest("POST", "/api/line-items", {
|
|
1233
|
+
name: data.name,
|
|
1234
|
+
dealId: data.dealId,
|
|
1235
|
+
status: data.status,
|
|
1236
|
+
copiedFromId: data.copiedFromId || undefined,
|
|
1237
|
+
creativeType: data.creativeType,
|
|
1238
|
+
priority: data.priority,
|
|
1239
|
+
targeting: {
|
|
1240
|
+
demographics: data.demographics,
|
|
1241
|
+
geography: data.geography,
|
|
1242
|
+
selectedPOIs: data.selectedPOIs,
|
|
1243
|
+
incomeBrackets: data.incomeBrackets,
|
|
1244
|
+
behaviors: data.behaviors,
|
|
1245
|
+
interests: data.interests,
|
|
1246
|
+
},
|
|
1247
|
+
inventoryFilters: {
|
|
1248
|
+
classification: data.inventoryClassification,
|
|
1249
|
+
type: data.inventoryType,
|
|
1250
|
+
format: data.inventoryFormat,
|
|
1251
|
+
},
|
|
1252
|
+
selectedScreens: data.selectedScreens,
|
|
1253
|
+
startDate: data.startDate || undefined,
|
|
1254
|
+
endDate: data.endDate || undefined,
|
|
1255
|
+
schedules: data.schedules.length > 0 ? data.schedules : undefined,
|
|
1256
|
+
budget: data.budget || undefined,
|
|
1257
|
+
pacing: data.pacing,
|
|
1258
|
+
trafficAllocation: data.trafficAllocation,
|
|
1259
|
+
customFees: data.customFees.length > 0 ? data.customFees : undefined,
|
|
1260
|
+
frequencyCap: data.frequencyCapImpressions && data.frequencyCapPeriod
|
|
1261
|
+
? { impressions: parseInt(data.frequencyCapImpressions), period: data.frequencyCapPeriod }
|
|
1262
|
+
: undefined,
|
|
1263
|
+
dspId: data.dspId || undefined,
|
|
1264
|
+
dspSeatId: data.dspSeatId || undefined,
|
|
1265
|
+
pushToDsp: data.pushToDsp,
|
|
1266
|
+
creativeDuration: data.creativeDuration,
|
|
1267
|
+
triggerEnabled: data.triggerEnabled,
|
|
1268
|
+
triggerId: data.triggerId || undefined,
|
|
1269
|
+
resolution: data.resolution,
|
|
1270
|
+
});
|
|
1271
|
+
},
|
|
1272
|
+
onSuccess: () => {
|
|
1273
|
+
queryClient.invalidateQueries({ queryKey: ["/api/line-items"] });
|
|
1274
|
+
toast({
|
|
1275
|
+
title: "Line item created",
|
|
1276
|
+
description: "Your line item has been created successfully.",
|
|
1277
|
+
});
|
|
1278
|
+
setLocation(`/deals/${dealId}`);
|
|
1279
|
+
},
|
|
1280
|
+
onError: () => {
|
|
1281
|
+
toast({
|
|
1282
|
+
title: "Error",
|
|
1283
|
+
description: "Failed to create line item. Please try again.",
|
|
1284
|
+
variant: "destructive",
|
|
1285
|
+
});
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
const updateMutation = useMutation({
|
|
1290
|
+
mutationFn: async (data: LineItemFormData) => {
|
|
1291
|
+
return apiRequest("PATCH", `/api/line-items/${lineItemId}`, {
|
|
1292
|
+
name: data.name,
|
|
1293
|
+
dealId: data.dealId,
|
|
1294
|
+
status: data.status,
|
|
1295
|
+
copiedFromId: data.copiedFromId || undefined,
|
|
1296
|
+
creativeType: data.creativeType,
|
|
1297
|
+
priority: data.priority,
|
|
1298
|
+
targeting: {
|
|
1299
|
+
demographics: data.demographics,
|
|
1300
|
+
geography: data.geography,
|
|
1301
|
+
selectedPOIs: data.selectedPOIs,
|
|
1302
|
+
incomeBrackets: data.incomeBrackets,
|
|
1303
|
+
behaviors: data.behaviors,
|
|
1304
|
+
interests: data.interests,
|
|
1305
|
+
},
|
|
1306
|
+
inventoryFilters: {
|
|
1307
|
+
classification: data.inventoryClassification,
|
|
1308
|
+
type: data.inventoryType,
|
|
1309
|
+
format: data.inventoryFormat,
|
|
1310
|
+
},
|
|
1311
|
+
selectedScreens: data.selectedScreens,
|
|
1312
|
+
startDate: data.startDate || undefined,
|
|
1313
|
+
endDate: data.endDate || undefined,
|
|
1314
|
+
schedules: data.schedules.length > 0 ? data.schedules : undefined,
|
|
1315
|
+
budget: data.budget || undefined,
|
|
1316
|
+
pacing: data.pacing,
|
|
1317
|
+
trafficAllocation: data.trafficAllocation,
|
|
1318
|
+
customFees: data.customFees.length > 0 ? data.customFees : undefined,
|
|
1319
|
+
frequencyCap: data.frequencyCapImpressions && data.frequencyCapPeriod
|
|
1320
|
+
? { impressions: parseInt(data.frequencyCapImpressions), period: data.frequencyCapPeriod }
|
|
1321
|
+
: undefined,
|
|
1322
|
+
dspId: data.dspId || undefined,
|
|
1323
|
+
dspSeatId: data.dspSeatId || undefined,
|
|
1324
|
+
pushToDsp: data.pushToDsp,
|
|
1325
|
+
creativeDuration: data.creativeDuration,
|
|
1326
|
+
triggerEnabled: data.triggerEnabled,
|
|
1327
|
+
triggerId: data.triggerId || undefined,
|
|
1328
|
+
resolution: data.resolution,
|
|
1329
|
+
});
|
|
1330
|
+
},
|
|
1331
|
+
onSuccess: () => {
|
|
1332
|
+
queryClient.invalidateQueries({ queryKey: ["/api/line-items"] });
|
|
1333
|
+
queryClient.invalidateQueries({ queryKey: ["/api/line-items", lineItemId] });
|
|
1334
|
+
toast({
|
|
1335
|
+
title: "Line item updated",
|
|
1336
|
+
description: "Your line item has been updated successfully.",
|
|
1337
|
+
});
|
|
1338
|
+
setLocation(`/deals/${dealId}`);
|
|
1339
|
+
},
|
|
1340
|
+
onError: () => {
|
|
1341
|
+
toast({
|
|
1342
|
+
title: "Error",
|
|
1343
|
+
description: "Failed to update line item. Please try again.",
|
|
1344
|
+
variant: "destructive",
|
|
1345
|
+
});
|
|
1346
|
+
},
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
const onSubmit = (data: LineItemFormData) => {
|
|
1350
|
+
if (isEditing) {
|
|
1351
|
+
updateMutation.mutate(data);
|
|
1352
|
+
} else {
|
|
1353
|
+
createMutation.mutate(data);
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
const handleCancel = () => {
|
|
1358
|
+
setLocation(`/deals/${dealId}`);
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
|
1362
|
+
const isLoadingData = isEditing && lineItemLoading;
|
|
1363
|
+
|
|
1364
|
+
if (isLoadingData) {
|
|
1365
|
+
return (
|
|
1366
|
+
<div className="flex flex-col gap-6 p-6">
|
|
1367
|
+
<PageHeader
|
|
1368
|
+
title={isEditing ? "Edit Line Item" : "New Line Item"}
|
|
1369
|
+
description={isEditing ? "Update line item details" : "Create a new line item"}
|
|
1370
|
+
/>
|
|
1371
|
+
<Card>
|
|
1372
|
+
<CardContent className="pt-6 space-y-6">
|
|
1373
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-name" />
|
|
1374
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-status" />
|
|
1375
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-creative-type" />
|
|
1376
|
+
<Skeleton className="h-16 w-full" data-testid="skeleton-priority" />
|
|
1377
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1378
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-start-date" />
|
|
1379
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-end-date" />
|
|
1380
|
+
</div>
|
|
1381
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-budget" />
|
|
1382
|
+
</CardContent>
|
|
1383
|
+
</Card>
|
|
1384
|
+
</div>
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const incomeBracketLabels = INCOME_BRACKETS.map((b) => b.label);
|
|
1389
|
+
const behaviorLabels = BEHAVIORS.map((b) => b.label);
|
|
1390
|
+
const demographicOptions = [...AGE_GROUPS, ...GENDERS];
|
|
1391
|
+
|
|
1392
|
+
// Targeting summary row component
|
|
1393
|
+
const TargetingSummaryRow = ({
|
|
1394
|
+
label,
|
|
1395
|
+
count,
|
|
1396
|
+
onClick,
|
|
1397
|
+
icon: Icon
|
|
1398
|
+
}: {
|
|
1399
|
+
label: string;
|
|
1400
|
+
count: number;
|
|
1401
|
+
onClick: () => void;
|
|
1402
|
+
icon: React.ElementType;
|
|
1403
|
+
}) => (
|
|
1404
|
+
<div
|
|
1405
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
1406
|
+
onClick={onClick}
|
|
1407
|
+
data-testid={`targeting-row-${label.toLowerCase().replace(/\s+/g, '-')}`}
|
|
1408
|
+
>
|
|
1409
|
+
<div className="flex items-center gap-3">
|
|
1410
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
1411
|
+
<span className="text-sm">{label}</span>
|
|
1412
|
+
</div>
|
|
1413
|
+
<div className="flex items-center gap-2">
|
|
1414
|
+
<span className="text-sm text-muted-foreground">
|
|
1415
|
+
{count > 0 ? `${count} ${label} are selected` : `0 ${label} are selected`}
|
|
1416
|
+
</span>
|
|
1417
|
+
<Check className={`h-4 w-4 ${count > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
return (
|
|
1423
|
+
<div className="relative flex flex-col h-full overflow-hidden">
|
|
1424
|
+
<div ref={scrollContainerRef} className="flex-1 overflow-auto p-6 pb-20">
|
|
1425
|
+
<div className="flex flex-col gap-6">
|
|
1426
|
+
<div className="flex items-center justify-between">
|
|
1427
|
+
<div className="flex items-center gap-4">
|
|
1428
|
+
<h1 className="text-2xl font-bold" data-testid="text-page-title">
|
|
1429
|
+
{isEditing ? "Edit Line Item" : "New Line Item"}
|
|
1430
|
+
</h1>
|
|
1431
|
+
</div>
|
|
1432
|
+
<Button
|
|
1433
|
+
variant="outline"
|
|
1434
|
+
onClick={handleCancel}
|
|
1435
|
+
data-testid="button-back-deal"
|
|
1436
|
+
>
|
|
1437
|
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
1438
|
+
Back
|
|
1439
|
+
</Button>
|
|
1440
|
+
</div>
|
|
1441
|
+
|
|
1442
|
+
<div className="mt-6">
|
|
1443
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
1444
|
+
<div className="lg:col-span-2">
|
|
1445
|
+
<Form {...form}>
|
|
1446
|
+
<form id="line-item-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
1447
|
+
|
|
1448
|
+
{/* Line Item Name and Status */}
|
|
1449
|
+
<Card ref={basicSectionRef as any}>
|
|
1450
|
+
<CardContent className="pt-6">
|
|
1451
|
+
<div className="flex items-center gap-4 flex-wrap">
|
|
1452
|
+
<FormField
|
|
1453
|
+
control={form.control}
|
|
1454
|
+
name="name"
|
|
1455
|
+
render={({ field }) => (
|
|
1456
|
+
<FormItem className="flex-1 min-w-[200px]">
|
|
1457
|
+
<FormLabel>Line Item Name <span className="text-destructive">*</span></FormLabel>
|
|
1458
|
+
<FormControl>
|
|
1459
|
+
<Input
|
|
1460
|
+
placeholder="Enter line item name"
|
|
1461
|
+
{...field}
|
|
1462
|
+
data-testid="input-line-item-name"
|
|
1463
|
+
/>
|
|
1464
|
+
</FormControl>
|
|
1465
|
+
<FormMessage />
|
|
1466
|
+
</FormItem>
|
|
1467
|
+
)}
|
|
1468
|
+
/>
|
|
1469
|
+
|
|
1470
|
+
<FormField
|
|
1471
|
+
control={form.control}
|
|
1472
|
+
name="status"
|
|
1473
|
+
render={({ field }) => (
|
|
1474
|
+
<FormItem className="w-32">
|
|
1475
|
+
<FormLabel>Status</FormLabel>
|
|
1476
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1477
|
+
<FormControl>
|
|
1478
|
+
<SelectTrigger data-testid="select-status">
|
|
1479
|
+
<SelectValue placeholder="Select status" />
|
|
1480
|
+
</SelectTrigger>
|
|
1481
|
+
</FormControl>
|
|
1482
|
+
<SelectContent>
|
|
1483
|
+
<SelectItem value="active">Active</SelectItem>
|
|
1484
|
+
<SelectItem value="paused">Paused</SelectItem>
|
|
1485
|
+
<SelectItem value="draft">Draft</SelectItem>
|
|
1486
|
+
</SelectContent>
|
|
1487
|
+
</Select>
|
|
1488
|
+
<FormMessage />
|
|
1489
|
+
</FormItem>
|
|
1490
|
+
)}
|
|
1491
|
+
/>
|
|
1492
|
+
|
|
1493
|
+
{!isPGDeal && (
|
|
1494
|
+
<FormField
|
|
1495
|
+
control={form.control}
|
|
1496
|
+
name="copyFromLineItemId"
|
|
1497
|
+
render={({ field }) => (
|
|
1498
|
+
<FormItem className="min-w-[200px]">
|
|
1499
|
+
<FormLabel>Copy from Line Item</FormLabel>
|
|
1500
|
+
<FormControl>
|
|
1501
|
+
<SearchableCombobox
|
|
1502
|
+
options={lineItemsForCopy.map((li: LineItem) => ({
|
|
1503
|
+
value: li.id,
|
|
1504
|
+
label: li.name,
|
|
1505
|
+
description: `Priority: ${li.priority}`,
|
|
1506
|
+
}))}
|
|
1507
|
+
value={field.value || ""}
|
|
1508
|
+
onValueChange={(value) => {
|
|
1509
|
+
field.onChange(value);
|
|
1510
|
+
if (value) {
|
|
1511
|
+
handleCopyLineItem(value);
|
|
1512
|
+
}
|
|
1513
|
+
}}
|
|
1514
|
+
placeholder="Select to copy..."
|
|
1515
|
+
searchPlaceholder="Search line items..."
|
|
1516
|
+
emptyMessage="No line items found."
|
|
1517
|
+
data-testid="combobox-copy-line-item"
|
|
1518
|
+
/>
|
|
1519
|
+
</FormControl>
|
|
1520
|
+
</FormItem>
|
|
1521
|
+
)}
|
|
1522
|
+
/>
|
|
1523
|
+
)}
|
|
1524
|
+
</div>
|
|
1525
|
+
|
|
1526
|
+
{!hasDealFromRoute && (
|
|
1527
|
+
<FormField
|
|
1528
|
+
control={form.control}
|
|
1529
|
+
name="dealId"
|
|
1530
|
+
render={({ field }) => (
|
|
1531
|
+
<FormItem className="mt-4">
|
|
1532
|
+
<FormLabel>Deal</FormLabel>
|
|
1533
|
+
<FormControl>
|
|
1534
|
+
{dealsLoading ? (
|
|
1535
|
+
<Skeleton className="h-10 w-full" data-testid="skeleton-deal-loading" />
|
|
1536
|
+
) : (
|
|
1537
|
+
<SearchableCombobox
|
|
1538
|
+
options={dealOptions}
|
|
1539
|
+
value={field.value}
|
|
1540
|
+
onValueChange={field.onChange}
|
|
1541
|
+
placeholder="Select a deal..."
|
|
1542
|
+
searchPlaceholder="Search deals..."
|
|
1543
|
+
emptyMessage="No deals found."
|
|
1544
|
+
data-testid="combobox-deal"
|
|
1545
|
+
/>
|
|
1546
|
+
)}
|
|
1547
|
+
</FormControl>
|
|
1548
|
+
<FormMessage />
|
|
1549
|
+
</FormItem>
|
|
1550
|
+
)}
|
|
1551
|
+
/>
|
|
1552
|
+
)}
|
|
1553
|
+
</CardContent>
|
|
1554
|
+
</Card>
|
|
1555
|
+
|
|
1556
|
+
<Card>
|
|
1557
|
+
<CardHeader>
|
|
1558
|
+
<CardTitle>Creative</CardTitle>
|
|
1559
|
+
</CardHeader>
|
|
1560
|
+
<CardContent className="space-y-4">
|
|
1561
|
+
<FormField
|
|
1562
|
+
control={form.control}
|
|
1563
|
+
name="creativeType"
|
|
1564
|
+
render={({ field }) => (
|
|
1565
|
+
<FormItem>
|
|
1566
|
+
<FormLabel>Creative Type <span className="text-destructive">*</span></FormLabel>
|
|
1567
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1568
|
+
<FormControl>
|
|
1569
|
+
<SelectTrigger data-testid="select-creative-type">
|
|
1570
|
+
<SelectValue placeholder="Select creative type" />
|
|
1571
|
+
</SelectTrigger>
|
|
1572
|
+
</FormControl>
|
|
1573
|
+
<SelectContent>
|
|
1574
|
+
{CREATIVE_TYPES.map((type) => (
|
|
1575
|
+
<SelectItem key={type.value} value={type.value}>
|
|
1576
|
+
{type.label}
|
|
1577
|
+
</SelectItem>
|
|
1578
|
+
))}
|
|
1579
|
+
</SelectContent>
|
|
1580
|
+
</Select>
|
|
1581
|
+
<FormMessage />
|
|
1582
|
+
</FormItem>
|
|
1583
|
+
)}
|
|
1584
|
+
/>
|
|
1585
|
+
|
|
1586
|
+
<FormField
|
|
1587
|
+
control={form.control}
|
|
1588
|
+
name="priority"
|
|
1589
|
+
render={({ field }) => (
|
|
1590
|
+
<FormItem>
|
|
1591
|
+
<div className="flex items-center justify-between">
|
|
1592
|
+
<FormLabel>Priority</FormLabel>
|
|
1593
|
+
<span className="text-sm font-medium">
|
|
1594
|
+
{field.value} {field.value === 1 ? "(Highest)" : field.value === 10 ? "(Lowest)" : ""}
|
|
1595
|
+
</span>
|
|
1596
|
+
</div>
|
|
1597
|
+
<FormControl>
|
|
1598
|
+
<Slider
|
|
1599
|
+
value={[field.value]}
|
|
1600
|
+
onValueChange={([val]) => field.onChange(val)}
|
|
1601
|
+
min={1}
|
|
1602
|
+
max={10}
|
|
1603
|
+
step={1}
|
|
1604
|
+
className="w-full"
|
|
1605
|
+
data-testid="slider-priority"
|
|
1606
|
+
/>
|
|
1607
|
+
</FormControl>
|
|
1608
|
+
<div className="flex justify-between text-xs text-muted-foreground">
|
|
1609
|
+
<span>1 (Highest)</span>
|
|
1610
|
+
<span>5</span>
|
|
1611
|
+
<span>10 (Lowest)</span>
|
|
1612
|
+
</div>
|
|
1613
|
+
<FormDescription>
|
|
1614
|
+
Lower number means higher priority for ad serving
|
|
1615
|
+
</FormDescription>
|
|
1616
|
+
<FormMessage />
|
|
1617
|
+
</FormItem>
|
|
1618
|
+
)}
|
|
1619
|
+
/>
|
|
1620
|
+
</CardContent>
|
|
1621
|
+
</Card>
|
|
1622
|
+
|
|
1623
|
+
{/* Flight Dates Section - Before Targeting */}
|
|
1624
|
+
<Card ref={scheduleSectionRef as any}>
|
|
1625
|
+
<CardHeader className="pb-3">
|
|
1626
|
+
<div className="flex items-center gap-2">
|
|
1627
|
+
<CalendarRange className="h-4 w-4 text-muted-foreground" />
|
|
1628
|
+
<CardTitle className="text-base">Flight Dates</CardTitle>
|
|
1629
|
+
</div>
|
|
1630
|
+
</CardHeader>
|
|
1631
|
+
<CardContent className="space-y-4">
|
|
1632
|
+
{/* Quick Date Range Selection */}
|
|
1633
|
+
<div className="flex flex-wrap gap-2">
|
|
1634
|
+
<span className="text-sm text-muted-foreground mr-2 flex items-center">Quick select:</span>
|
|
1635
|
+
{[
|
|
1636
|
+
{ label: "7 days", days: 7 },
|
|
1637
|
+
{ label: "28 days", days: 28 },
|
|
1638
|
+
{ label: "30 days", days: 30 },
|
|
1639
|
+
{ label: "45 days", days: 45 },
|
|
1640
|
+
{ label: "60 days", days: 60 },
|
|
1641
|
+
].map(({ label, days }) => (
|
|
1642
|
+
<Button
|
|
1643
|
+
key={days}
|
|
1644
|
+
type="button"
|
|
1645
|
+
variant="outline"
|
|
1646
|
+
size="sm"
|
|
1647
|
+
onClick={() => {
|
|
1648
|
+
const today = new Date();
|
|
1649
|
+
const endDate = new Date(today);
|
|
1650
|
+
endDate.setDate(today.getDate() + days);
|
|
1651
|
+
form.setValue("startDate", format(today, "yyyy-MM-dd"));
|
|
1652
|
+
form.setValue("endDate", format(endDate, "yyyy-MM-dd"));
|
|
1653
|
+
}}
|
|
1654
|
+
data-testid={`button-quick-${days}-days`}
|
|
1655
|
+
>
|
|
1656
|
+
Next {label}
|
|
1657
|
+
</Button>
|
|
1658
|
+
))}
|
|
1659
|
+
</div>
|
|
1660
|
+
|
|
1661
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
1662
|
+
<FormField
|
|
1663
|
+
control={form.control}
|
|
1664
|
+
name="startDate"
|
|
1665
|
+
render={({ field }) => (
|
|
1666
|
+
<DatePickerField
|
|
1667
|
+
value={field.value}
|
|
1668
|
+
onChange={field.onChange}
|
|
1669
|
+
label="Start Date"
|
|
1670
|
+
testId="input-start-date"
|
|
1671
|
+
required
|
|
1672
|
+
/>
|
|
1673
|
+
)}
|
|
1674
|
+
/>
|
|
1675
|
+
|
|
1676
|
+
<FormField
|
|
1677
|
+
control={form.control}
|
|
1678
|
+
name="endDate"
|
|
1679
|
+
render={({ field }) => (
|
|
1680
|
+
<DatePickerField
|
|
1681
|
+
value={field.value}
|
|
1682
|
+
onChange={field.onChange}
|
|
1683
|
+
label="End Date"
|
|
1684
|
+
testId="input-end-date"
|
|
1685
|
+
required
|
|
1686
|
+
/>
|
|
1687
|
+
)}
|
|
1688
|
+
/>
|
|
1689
|
+
</div>
|
|
1690
|
+
</CardContent>
|
|
1691
|
+
</Card>
|
|
1692
|
+
|
|
1693
|
+
{/* Budget Options Section */}
|
|
1694
|
+
<Card ref={budgetSectionRef as any}>
|
|
1695
|
+
<CardHeader className="pb-3">
|
|
1696
|
+
<div className="flex items-center gap-2">
|
|
1697
|
+
<CircleDollarSign className="h-4 w-4 text-muted-foreground" />
|
|
1698
|
+
<CardTitle className="text-base">Budget Options</CardTitle>
|
|
1699
|
+
</div>
|
|
1700
|
+
</CardHeader>
|
|
1701
|
+
<CardContent className="space-y-4">
|
|
1702
|
+
<FormField
|
|
1703
|
+
control={form.control}
|
|
1704
|
+
name="unlimitedBudget"
|
|
1705
|
+
render={({ field }) => (
|
|
1706
|
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
|
1707
|
+
<div className="space-y-0.5">
|
|
1708
|
+
<FormLabel>Unlimited Budget</FormLabel>
|
|
1709
|
+
<FormDescription>
|
|
1710
|
+
When enabled, line item will not have a budget cap
|
|
1711
|
+
</FormDescription>
|
|
1712
|
+
</div>
|
|
1713
|
+
<FormControl>
|
|
1714
|
+
<Switch
|
|
1715
|
+
checked={field.value || false}
|
|
1716
|
+
onCheckedChange={field.onChange}
|
|
1717
|
+
data-testid="switch-unlimited-budget"
|
|
1718
|
+
/>
|
|
1719
|
+
</FormControl>
|
|
1720
|
+
</FormItem>
|
|
1721
|
+
)}
|
|
1722
|
+
/>
|
|
1723
|
+
|
|
1724
|
+
{/* Budget field - Only show when unlimited budget is OFF */}
|
|
1725
|
+
{!watchedUnlimitedBudget && (
|
|
1726
|
+
<FormField
|
|
1727
|
+
control={form.control}
|
|
1728
|
+
name="budget"
|
|
1729
|
+
render={({ field }) => (
|
|
1730
|
+
<FormItem>
|
|
1731
|
+
<FormLabel>Budget <span className="text-destructive">*</span></FormLabel>
|
|
1732
|
+
<div className="flex gap-2">
|
|
1733
|
+
<FormControl>
|
|
1734
|
+
<Input
|
|
1735
|
+
type="number"
|
|
1736
|
+
placeholder="0.00"
|
|
1737
|
+
{...field}
|
|
1738
|
+
data-testid="input-budget"
|
|
1739
|
+
/>
|
|
1740
|
+
</FormControl>
|
|
1741
|
+
<div className="flex h-9 items-center rounded-md border bg-muted/50 px-3 text-sm font-medium">
|
|
1742
|
+
<Lock className="h-3 w-3 mr-1.5 text-muted-foreground" />
|
|
1743
|
+
{currentDeal?.currency || "USD"}
|
|
1744
|
+
</div>
|
|
1745
|
+
</div>
|
|
1746
|
+
<FormDescription className="text-xs">
|
|
1747
|
+
Currency inherited from deal
|
|
1748
|
+
</FormDescription>
|
|
1749
|
+
<FormMessage />
|
|
1750
|
+
</FormItem>
|
|
1751
|
+
)}
|
|
1752
|
+
/>
|
|
1753
|
+
)}
|
|
1754
|
+
|
|
1755
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
1756
|
+
<FormField
|
|
1757
|
+
control={form.control}
|
|
1758
|
+
name="budgetConsumption"
|
|
1759
|
+
render={({ field }) => (
|
|
1760
|
+
<FormItem>
|
|
1761
|
+
<FormLabel>Budget Consumption</FormLabel>
|
|
1762
|
+
<Select onValueChange={field.onChange} value={field.value || "daily"}>
|
|
1763
|
+
<FormControl>
|
|
1764
|
+
<SelectTrigger data-testid="select-budget-consumption">
|
|
1765
|
+
<SelectValue placeholder="Select consumption" />
|
|
1766
|
+
</SelectTrigger>
|
|
1767
|
+
</FormControl>
|
|
1768
|
+
<SelectContent>
|
|
1769
|
+
<SelectItem value="daily">Daily</SelectItem>
|
|
1770
|
+
<SelectItem value="weekly">Weekly</SelectItem>
|
|
1771
|
+
<SelectItem value="monthly">Monthly</SelectItem>
|
|
1772
|
+
<SelectItem value="lifetime">Lifetime</SelectItem>
|
|
1773
|
+
</SelectContent>
|
|
1774
|
+
</Select>
|
|
1775
|
+
<FormMessage />
|
|
1776
|
+
</FormItem>
|
|
1777
|
+
)}
|
|
1778
|
+
/>
|
|
1779
|
+
|
|
1780
|
+
<FormField
|
|
1781
|
+
control={form.control}
|
|
1782
|
+
name="dailyBudget"
|
|
1783
|
+
render={({ field }) => (
|
|
1784
|
+
<FormItem>
|
|
1785
|
+
<FormLabel>Daily Budget ({currentDeal?.currency || "USD"})</FormLabel>
|
|
1786
|
+
<FormControl>
|
|
1787
|
+
<Input
|
|
1788
|
+
type="number"
|
|
1789
|
+
placeholder="0.00"
|
|
1790
|
+
{...field}
|
|
1791
|
+
value={field.value || ""}
|
|
1792
|
+
onChange={(e) => field.onChange(e.target.value)}
|
|
1793
|
+
data-testid="input-daily-budget"
|
|
1794
|
+
/>
|
|
1795
|
+
</FormControl>
|
|
1796
|
+
<FormMessage />
|
|
1797
|
+
</FormItem>
|
|
1798
|
+
)}
|
|
1799
|
+
/>
|
|
1800
|
+
</div>
|
|
1801
|
+
</CardContent>
|
|
1802
|
+
</Card>
|
|
1803
|
+
|
|
1804
|
+
{/* Pacing & Traffic Allocation Section - Only for programmatic deals */}
|
|
1805
|
+
{!isTraditionalDeal && (
|
|
1806
|
+
<Card>
|
|
1807
|
+
<CardHeader className="pb-3">
|
|
1808
|
+
<div className="flex items-center gap-2">
|
|
1809
|
+
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
1810
|
+
<CardTitle className="text-base">Pacing & Traffic Allocation</CardTitle>
|
|
1811
|
+
</div>
|
|
1812
|
+
</CardHeader>
|
|
1813
|
+
<CardContent className="space-y-4">
|
|
1814
|
+
<FormField
|
|
1815
|
+
control={form.control}
|
|
1816
|
+
name="pacing"
|
|
1817
|
+
render={({ field }) => (
|
|
1818
|
+
<FormItem>
|
|
1819
|
+
<FormLabel>Pacing</FormLabel>
|
|
1820
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1821
|
+
<FormControl>
|
|
1822
|
+
<SelectTrigger data-testid="select-pacing">
|
|
1823
|
+
<SelectValue placeholder="Select pacing" />
|
|
1824
|
+
</SelectTrigger>
|
|
1825
|
+
</FormControl>
|
|
1826
|
+
<SelectContent>
|
|
1827
|
+
{PACING_OPTIONS.map((option) => (
|
|
1828
|
+
<SelectItem key={option.value} value={option.value}>
|
|
1829
|
+
{option.label}
|
|
1830
|
+
</SelectItem>
|
|
1831
|
+
))}
|
|
1832
|
+
</SelectContent>
|
|
1833
|
+
</Select>
|
|
1834
|
+
<FormDescription>
|
|
1835
|
+
Controls how the budget is spent over the deal duration
|
|
1836
|
+
</FormDescription>
|
|
1837
|
+
<FormMessage />
|
|
1838
|
+
</FormItem>
|
|
1839
|
+
)}
|
|
1840
|
+
/>
|
|
1841
|
+
|
|
1842
|
+
<FormField
|
|
1843
|
+
control={form.control}
|
|
1844
|
+
name="trafficAllocation"
|
|
1845
|
+
render={({ field }) => (
|
|
1846
|
+
<FormItem>
|
|
1847
|
+
<FormControl>
|
|
1848
|
+
<TrafficSlider
|
|
1849
|
+
value={field.value}
|
|
1850
|
+
onChange={field.onChange}
|
|
1851
|
+
data-testid="slider-traffic-allocation"
|
|
1852
|
+
/>
|
|
1853
|
+
</FormControl>
|
|
1854
|
+
<FormDescription>
|
|
1855
|
+
Controls what percentage of eligible impressions this line item receives
|
|
1856
|
+
</FormDescription>
|
|
1857
|
+
<FormMessage />
|
|
1858
|
+
</FormItem>
|
|
1859
|
+
)}
|
|
1860
|
+
/>
|
|
1861
|
+
</CardContent>
|
|
1862
|
+
</Card>
|
|
1863
|
+
)}
|
|
1864
|
+
|
|
1865
|
+
{/* Targeting Section - Summary Row Format */}
|
|
1866
|
+
<Card ref={targetingSectionRef as any}>
|
|
1867
|
+
<CardHeader className="pb-3">
|
|
1868
|
+
<div className="flex items-center gap-2">
|
|
1869
|
+
<Target className="h-4 w-4 text-muted-foreground" />
|
|
1870
|
+
<CardTitle className="text-base">Targeting</CardTitle>
|
|
1871
|
+
</div>
|
|
1872
|
+
</CardHeader>
|
|
1873
|
+
<CardContent className="space-y-2">
|
|
1874
|
+
{/* Media Type Row */}
|
|
1875
|
+
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
|
1876
|
+
<div className="flex items-center gap-3">
|
|
1877
|
+
<Monitor className="h-4 w-4 text-muted-foreground" />
|
|
1878
|
+
<span className="text-sm">Media Type</span>
|
|
1879
|
+
</div>
|
|
1880
|
+
<div className="flex items-center gap-2">
|
|
1881
|
+
<span className="text-sm font-medium">DOOH</span>
|
|
1882
|
+
<Check className="h-4 w-4 text-primary" />
|
|
1883
|
+
</div>
|
|
1884
|
+
</div>
|
|
1885
|
+
|
|
1886
|
+
{/* Geography Row */}
|
|
1887
|
+
<FormField
|
|
1888
|
+
control={form.control}
|
|
1889
|
+
name="geography"
|
|
1890
|
+
render={({ field }) => (
|
|
1891
|
+
<FormItem className="space-y-0">
|
|
1892
|
+
<div
|
|
1893
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
1894
|
+
onClick={() => setAdvancedMapDrawerOpen(true)}
|
|
1895
|
+
data-testid="targeting-row-geography"
|
|
1896
|
+
>
|
|
1897
|
+
<div className="flex items-center gap-3">
|
|
1898
|
+
<MapPin className="h-4 w-4 text-muted-foreground" />
|
|
1899
|
+
<span className="text-sm">Geography</span>
|
|
1900
|
+
</div>
|
|
1901
|
+
<div className="flex items-center gap-2">
|
|
1902
|
+
<span className="text-sm text-muted-foreground">
|
|
1903
|
+
{field.value?.length || 0} locations selected
|
|
1904
|
+
</span>
|
|
1905
|
+
<Check className={`h-4 w-4 ${(field.value?.length || 0) > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
1906
|
+
</div>
|
|
1907
|
+
</div>
|
|
1908
|
+
</FormItem>
|
|
1909
|
+
)}
|
|
1910
|
+
/>
|
|
1911
|
+
|
|
1912
|
+
{/* Age Targeting Row */}
|
|
1913
|
+
<FormField
|
|
1914
|
+
control={form.control}
|
|
1915
|
+
name="demographics"
|
|
1916
|
+
render={({ field }) => {
|
|
1917
|
+
const selectedAges = field.value.filter((v: string) => AGE_GROUPS.includes(v));
|
|
1918
|
+
const isOpen = activeTargetingSection === "age";
|
|
1919
|
+
return (
|
|
1920
|
+
<FormItem className="space-y-0">
|
|
1921
|
+
<div data-collapsible-section="age">
|
|
1922
|
+
<Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "age" : null)}>
|
|
1923
|
+
<CollapsibleTrigger asChild>
|
|
1924
|
+
<div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
1925
|
+
data-testid="targeting-row-age"
|
|
1926
|
+
>
|
|
1927
|
+
<div className="flex items-center gap-3">
|
|
1928
|
+
<Users className="h-4 w-4 text-muted-foreground" />
|
|
1929
|
+
<span className="text-sm">Age Groups</span>
|
|
1930
|
+
</div>
|
|
1931
|
+
<div className="flex items-center gap-2">
|
|
1932
|
+
<span className="text-sm text-muted-foreground">
|
|
1933
|
+
{selectedAges.length} age groups selected
|
|
1934
|
+
</span>
|
|
1935
|
+
<Check className={`h-4 w-4 ${selectedAges.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
1936
|
+
</div>
|
|
1937
|
+
</div>
|
|
1938
|
+
</CollapsibleTrigger>
|
|
1939
|
+
<CollapsibleContent className="pt-2 pl-10">
|
|
1940
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 p-3 bg-muted/30 rounded-md">
|
|
1941
|
+
{AGE_GROUPS.map((age) => (
|
|
1942
|
+
<label key={age} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
1943
|
+
<Checkbox
|
|
1944
|
+
checked={field.value.includes(age)}
|
|
1945
|
+
onCheckedChange={(checked) => {
|
|
1946
|
+
if (checked) {
|
|
1947
|
+
field.onChange([...field.value, age]);
|
|
1948
|
+
} else {
|
|
1949
|
+
field.onChange(field.value.filter((v: string) => v !== age));
|
|
1950
|
+
}
|
|
1951
|
+
}}
|
|
1952
|
+
/>
|
|
1953
|
+
{age}
|
|
1954
|
+
</label>
|
|
1955
|
+
))}
|
|
1956
|
+
</div>
|
|
1957
|
+
</CollapsibleContent>
|
|
1958
|
+
</Collapsible>
|
|
1959
|
+
</div>
|
|
1960
|
+
</FormItem>
|
|
1961
|
+
);
|
|
1962
|
+
}}
|
|
1963
|
+
/>
|
|
1964
|
+
|
|
1965
|
+
{/* Gender Targeting Row */}
|
|
1966
|
+
<FormField
|
|
1967
|
+
control={form.control}
|
|
1968
|
+
name="demographics"
|
|
1969
|
+
render={({ field }) => {
|
|
1970
|
+
const selectedGenders = field.value.filter((v: string) => GENDERS.includes(v));
|
|
1971
|
+
const isOpen = activeTargetingSection === "gender";
|
|
1972
|
+
return (
|
|
1973
|
+
<FormItem className="space-y-0">
|
|
1974
|
+
<div data-collapsible-section="gender">
|
|
1975
|
+
<Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "gender" : null)}>
|
|
1976
|
+
<CollapsibleTrigger asChild>
|
|
1977
|
+
<div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
1978
|
+
data-testid="targeting-row-gender"
|
|
1979
|
+
>
|
|
1980
|
+
<div className="flex items-center gap-3">
|
|
1981
|
+
<Users className="h-4 w-4 text-muted-foreground" />
|
|
1982
|
+
<span className="text-sm">Gender</span>
|
|
1983
|
+
</div>
|
|
1984
|
+
<div className="flex items-center gap-2">
|
|
1985
|
+
<span className="text-sm text-muted-foreground">
|
|
1986
|
+
{selectedGenders.length} genders selected
|
|
1987
|
+
</span>
|
|
1988
|
+
<Check className={`h-4 w-4 ${selectedGenders.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
1989
|
+
</div>
|
|
1990
|
+
</div>
|
|
1991
|
+
</CollapsibleTrigger>
|
|
1992
|
+
<CollapsibleContent className="pt-2 pl-10">
|
|
1993
|
+
<div className="grid grid-cols-2 gap-2 p-3 bg-muted/30 rounded-md">
|
|
1994
|
+
{GENDERS.map((gender) => (
|
|
1995
|
+
<label key={gender} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
1996
|
+
<Checkbox
|
|
1997
|
+
checked={field.value.includes(gender)}
|
|
1998
|
+
onCheckedChange={(checked) => {
|
|
1999
|
+
if (checked) {
|
|
2000
|
+
field.onChange([...field.value, gender]);
|
|
2001
|
+
} else {
|
|
2002
|
+
field.onChange(field.value.filter((v: string) => v !== gender));
|
|
2003
|
+
}
|
|
2004
|
+
}}
|
|
2005
|
+
/>
|
|
2006
|
+
{gender}
|
|
2007
|
+
</label>
|
|
2008
|
+
))}
|
|
2009
|
+
</div>
|
|
2010
|
+
</CollapsibleContent>
|
|
2011
|
+
</Collapsible>
|
|
2012
|
+
</div>
|
|
2013
|
+
</FormItem>
|
|
2014
|
+
);
|
|
2015
|
+
}}
|
|
2016
|
+
/>
|
|
2017
|
+
|
|
2018
|
+
{/* POI Targeting Row */}
|
|
2019
|
+
<FormField
|
|
2020
|
+
control={form.control}
|
|
2021
|
+
name="selectedPOIs"
|
|
2022
|
+
render={({ field }) => (
|
|
2023
|
+
<FormItem className="space-y-0">
|
|
2024
|
+
<div
|
|
2025
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2026
|
+
onClick={() => setPoiDrawerOpen(true)}
|
|
2027
|
+
data-testid="targeting-row-poi"
|
|
2028
|
+
>
|
|
2029
|
+
<div className="flex items-center gap-3">
|
|
2030
|
+
<MapPin className="h-4 w-4 text-muted-foreground" />
|
|
2031
|
+
<span className="text-sm">POI Targeting</span>
|
|
2032
|
+
</div>
|
|
2033
|
+
<div className="flex items-center gap-2">
|
|
2034
|
+
<span className="text-sm text-muted-foreground">
|
|
2035
|
+
{field.value.length} POIs, {selectedPOICategories.length} categories
|
|
2036
|
+
</span>
|
|
2037
|
+
<Check className={`h-4 w-4 ${field.value.length > 0 || selectedPOICategories.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2038
|
+
</div>
|
|
2039
|
+
</div>
|
|
2040
|
+
</FormItem>
|
|
2041
|
+
)}
|
|
2042
|
+
/>
|
|
2043
|
+
|
|
2044
|
+
{/* Venue Type Row - Opens Drawer */}
|
|
2045
|
+
<FormField
|
|
2046
|
+
control={form.control}
|
|
2047
|
+
name="venueTypes"
|
|
2048
|
+
render={({ field }) => (
|
|
2049
|
+
<FormItem className="space-y-0">
|
|
2050
|
+
<div
|
|
2051
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2052
|
+
onClick={() => setVenueTypeDrawerOpen(true)}
|
|
2053
|
+
data-testid="targeting-row-venue-type"
|
|
2054
|
+
>
|
|
2055
|
+
<div className="flex items-center gap-3">
|
|
2056
|
+
<Layers className="h-4 w-4 text-muted-foreground" />
|
|
2057
|
+
<span className="text-sm">Venue Type</span>
|
|
2058
|
+
</div>
|
|
2059
|
+
<div className="flex items-center gap-2">
|
|
2060
|
+
<span className="text-sm text-muted-foreground">
|
|
2061
|
+
{field.value.length} venue type{field.value.length !== 1 ? 's' : ''} selected
|
|
2062
|
+
</span>
|
|
2063
|
+
<Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2064
|
+
</div>
|
|
2065
|
+
</div>
|
|
2066
|
+
</FormItem>
|
|
2067
|
+
)}
|
|
2068
|
+
/>
|
|
2069
|
+
|
|
2070
|
+
{/* Ad Resolution Row */}
|
|
2071
|
+
<FormField
|
|
2072
|
+
control={form.control}
|
|
2073
|
+
name="resolution"
|
|
2074
|
+
render={({ field }) => (
|
|
2075
|
+
<FormItem className="space-y-0">
|
|
2076
|
+
<Collapsible>
|
|
2077
|
+
<CollapsibleTrigger asChild>
|
|
2078
|
+
<div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2079
|
+
data-testid="targeting-row-resolution"
|
|
2080
|
+
>
|
|
2081
|
+
<div className="flex items-center gap-3">
|
|
2082
|
+
<Monitor className="h-4 w-4 text-muted-foreground" />
|
|
2083
|
+
<span className="text-sm">Ad Resolution</span>
|
|
2084
|
+
</div>
|
|
2085
|
+
<div className="flex items-center gap-2">
|
|
2086
|
+
<span className="text-sm text-muted-foreground">
|
|
2087
|
+
{field.value ? `${field.value} selected` : "0 Ad Resolutions are selected"}
|
|
2088
|
+
</span>
|
|
2089
|
+
<Check className={`h-4 w-4 ${field.value ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2090
|
+
</div>
|
|
2091
|
+
</div>
|
|
2092
|
+
</CollapsibleTrigger>
|
|
2093
|
+
<CollapsibleContent className="pt-2 pl-10">
|
|
2094
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 p-3 bg-muted/30 rounded-md">
|
|
2095
|
+
{SCREEN_RESOLUTIONS.map((res) => (
|
|
2096
|
+
<label key={res} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
2097
|
+
<Checkbox
|
|
2098
|
+
checked={field.value === res}
|
|
2099
|
+
onCheckedChange={(checked) => {
|
|
2100
|
+
field.onChange(checked ? res : "");
|
|
2101
|
+
}}
|
|
2102
|
+
/>
|
|
2103
|
+
{res}
|
|
2104
|
+
</label>
|
|
2105
|
+
))}
|
|
2106
|
+
</div>
|
|
2107
|
+
</CollapsibleContent>
|
|
2108
|
+
</Collapsible>
|
|
2109
|
+
</FormItem>
|
|
2110
|
+
)}
|
|
2111
|
+
/>
|
|
2112
|
+
|
|
2113
|
+
{/* Ad Duration Row */}
|
|
2114
|
+
<FormField
|
|
2115
|
+
control={form.control}
|
|
2116
|
+
name="creativeDuration"
|
|
2117
|
+
render={({ field }) => (
|
|
2118
|
+
<FormItem className="space-y-0">
|
|
2119
|
+
<Collapsible>
|
|
2120
|
+
<CollapsibleTrigger asChild>
|
|
2121
|
+
<div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2122
|
+
data-testid="targeting-row-duration"
|
|
2123
|
+
>
|
|
2124
|
+
<div className="flex items-center gap-3">
|
|
2125
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
2126
|
+
<span className="text-sm">Ad Duration</span>
|
|
2127
|
+
</div>
|
|
2128
|
+
<div className="flex items-center gap-2">
|
|
2129
|
+
<span className="text-sm text-muted-foreground">
|
|
2130
|
+
{field.value}s selected
|
|
2131
|
+
</span>
|
|
2132
|
+
<Check className={`h-4 w-4 ${field.value ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2133
|
+
</div>
|
|
2134
|
+
</div>
|
|
2135
|
+
</CollapsibleTrigger>
|
|
2136
|
+
<CollapsibleContent className="pt-2 pl-10">
|
|
2137
|
+
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-md">
|
|
2138
|
+
{DURATION_OPTIONS.map((duration) => (
|
|
2139
|
+
<Button
|
|
2140
|
+
key={duration}
|
|
2141
|
+
type="button"
|
|
2142
|
+
variant={field.value === duration ? "default" : "outline"}
|
|
2143
|
+
size="sm"
|
|
2144
|
+
onClick={() => field.onChange(duration)}
|
|
2145
|
+
>
|
|
2146
|
+
{duration}s
|
|
2147
|
+
</Button>
|
|
2148
|
+
))}
|
|
2149
|
+
<div className="flex items-center gap-2 ml-4">
|
|
2150
|
+
<span className="text-xs text-muted-foreground">Custom:</span>
|
|
2151
|
+
<Input
|
|
2152
|
+
type="number"
|
|
2153
|
+
className="w-20 h-8"
|
|
2154
|
+
min={1}
|
|
2155
|
+
value={field.value}
|
|
2156
|
+
onChange={(e) => field.onChange(parseInt(e.target.value) || 10)}
|
|
2157
|
+
/>
|
|
2158
|
+
</div>
|
|
2159
|
+
</div>
|
|
2160
|
+
</CollapsibleContent>
|
|
2161
|
+
</Collapsible>
|
|
2162
|
+
</FormItem>
|
|
2163
|
+
)}
|
|
2164
|
+
/>
|
|
2165
|
+
</CardContent>
|
|
2166
|
+
</Card>
|
|
2167
|
+
|
|
2168
|
+
{/* DOOH Inventory Type Section */}
|
|
2169
|
+
<Card ref={inventorySectionRef as any}>
|
|
2170
|
+
<CardHeader className="pb-3">
|
|
2171
|
+
<div className="flex items-center gap-2">
|
|
2172
|
+
<Monitor className="h-4 w-4 text-muted-foreground" />
|
|
2173
|
+
<CardTitle className="text-base">DOOH Inventory Type</CardTitle>
|
|
2174
|
+
</div>
|
|
2175
|
+
</CardHeader>
|
|
2176
|
+
<CardContent className="space-y-2">
|
|
2177
|
+
{/* SSP / Exchange Row */}
|
|
2178
|
+
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
|
2179
|
+
<div className="flex items-center gap-3">
|
|
2180
|
+
<Server className="h-4 w-4 text-muted-foreground" />
|
|
2181
|
+
<span className="text-sm">SSP / Exchange</span>
|
|
2182
|
+
</div>
|
|
2183
|
+
<div className="flex items-center gap-2">
|
|
2184
|
+
<span className="text-sm font-medium">Influence SSP</span>
|
|
2185
|
+
<Check className="h-4 w-4 text-primary" />
|
|
2186
|
+
</div>
|
|
2187
|
+
</div>
|
|
2188
|
+
|
|
2189
|
+
{/* Media Owner Row */}
|
|
2190
|
+
<div
|
|
2191
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2192
|
+
onClick={() => setMediaOwnerDrawerOpen(true)}
|
|
2193
|
+
data-testid="inventory-row-media-owner"
|
|
2194
|
+
>
|
|
2195
|
+
<div className="flex items-center gap-3">
|
|
2196
|
+
<Building className="h-4 w-4 text-muted-foreground" />
|
|
2197
|
+
<span className="text-sm">Media Owner</span>
|
|
2198
|
+
</div>
|
|
2199
|
+
<div className="flex items-center gap-2">
|
|
2200
|
+
<span className="text-sm text-muted-foreground">
|
|
2201
|
+
{selectedMediaOwners.length} Media Owners selected
|
|
2202
|
+
</span>
|
|
2203
|
+
<Check className={`h-4 w-4 ${selectedMediaOwners.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2204
|
+
</div>
|
|
2205
|
+
</div>
|
|
2206
|
+
|
|
2207
|
+
{/* DOOH Inventory Type Row */}
|
|
2208
|
+
<FormField
|
|
2209
|
+
control={form.control}
|
|
2210
|
+
name="inventoryType"
|
|
2211
|
+
render={({ field }) => {
|
|
2212
|
+
const isOpen = activeTargetingSection === "inventoryType";
|
|
2213
|
+
return (
|
|
2214
|
+
<FormItem className="space-y-0">
|
|
2215
|
+
<div data-collapsible-section="inventoryType">
|
|
2216
|
+
<Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "inventoryType" : null)}>
|
|
2217
|
+
<CollapsibleTrigger asChild>
|
|
2218
|
+
<div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2219
|
+
data-testid="inventory-row-dooh"
|
|
2220
|
+
>
|
|
2221
|
+
<div className="flex items-center gap-3">
|
|
2222
|
+
<Monitor className="h-4 w-4 text-muted-foreground" />
|
|
2223
|
+
<span className="text-sm">Inventory Type</span>
|
|
2224
|
+
</div>
|
|
2225
|
+
<div className="flex items-center gap-2">
|
|
2226
|
+
<span className="text-sm text-muted-foreground">
|
|
2227
|
+
{field.value.length} types selected
|
|
2228
|
+
</span>
|
|
2229
|
+
<Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2230
|
+
</div>
|
|
2231
|
+
</div>
|
|
2232
|
+
</CollapsibleTrigger>
|
|
2233
|
+
<CollapsibleContent className="pt-2 pl-10">
|
|
2234
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-muted/30 rounded-md">
|
|
2235
|
+
{INVENTORY_TYPES.map((option) => (
|
|
2236
|
+
<label key={option} className="flex items-center gap-2 cursor-pointer text-sm">
|
|
2237
|
+
<Checkbox
|
|
2238
|
+
checked={field.value.includes(option)}
|
|
2239
|
+
onCheckedChange={(checked) => {
|
|
2240
|
+
if (checked) {
|
|
2241
|
+
field.onChange([...field.value, option]);
|
|
2242
|
+
} else {
|
|
2243
|
+
field.onChange(field.value.filter((v: string) => v !== option));
|
|
2244
|
+
}
|
|
2245
|
+
}}
|
|
2246
|
+
/>
|
|
2247
|
+
{option}
|
|
2248
|
+
</label>
|
|
2249
|
+
))}
|
|
2250
|
+
</div>
|
|
2251
|
+
</CollapsibleContent>
|
|
2252
|
+
</Collapsible>
|
|
2253
|
+
</div>
|
|
2254
|
+
</FormItem>
|
|
2255
|
+
);
|
|
2256
|
+
}}
|
|
2257
|
+
/>
|
|
2258
|
+
|
|
2259
|
+
{/* Inventory Format Row */}
|
|
2260
|
+
<FormField
|
|
2261
|
+
control={form.control}
|
|
2262
|
+
name="inventoryFormat"
|
|
2263
|
+
render={({ field }) => (
|
|
2264
|
+
<FormItem className="space-y-0">
|
|
2265
|
+
<div
|
|
2266
|
+
className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
|
|
2267
|
+
onClick={() => setInventoryFormatDrawerOpen(true)}
|
|
2268
|
+
data-testid="inventory-row-format"
|
|
2269
|
+
>
|
|
2270
|
+
<div className="flex items-center gap-3">
|
|
2271
|
+
<Layout className="h-4 w-4 text-muted-foreground" />
|
|
2272
|
+
<span className="text-sm">Inventory Format</span>
|
|
2273
|
+
</div>
|
|
2274
|
+
<div className="flex items-center gap-2">
|
|
2275
|
+
<span className="text-sm text-muted-foreground">
|
|
2276
|
+
{field.value.length} formats selected
|
|
2277
|
+
</span>
|
|
2278
|
+
<Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
|
|
2279
|
+
</div>
|
|
2280
|
+
</div>
|
|
2281
|
+
</FormItem>
|
|
2282
|
+
)}
|
|
2283
|
+
/>
|
|
2284
|
+
</CardContent>
|
|
2285
|
+
</Card>
|
|
2286
|
+
|
|
2287
|
+
{/* AI Inventory Recommendations Section */}
|
|
2288
|
+
<AIRecommendationPanel
|
|
2289
|
+
dealId={dealId || ""}
|
|
2290
|
+
startDate={watchedStartDate}
|
|
2291
|
+
endDate={watchedEndDate}
|
|
2292
|
+
budget={watchedBudget ? parseFloat(watchedBudget) : undefined}
|
|
2293
|
+
country={currentDeal?.countries?.[0]}
|
|
2294
|
+
selectedScreens={watchedSelectedScreens || []}
|
|
2295
|
+
onAddScreens={(screenIds) => {
|
|
2296
|
+
const current = form.getValues("selectedScreens") || [];
|
|
2297
|
+
const newScreens = Array.from(new Set([...current, ...screenIds]));
|
|
2298
|
+
form.setValue("selectedScreens", newScreens);
|
|
2299
|
+
}}
|
|
2300
|
+
onOpenManualEdit={() => setManualInventoryDrawerOpen(true)}
|
|
2301
|
+
/>
|
|
2302
|
+
|
|
2303
|
+
<Sheet open={inventoryDrawerOpen} onOpenChange={setInventoryDrawerOpen}>
|
|
2304
|
+
<SheetContent className="w-full sm:max-w-2xl overflow-hidden flex flex-col">
|
|
2305
|
+
<SheetHeader>
|
|
2306
|
+
<SheetTitle>Edit Inventory Selection</SheetTitle>
|
|
2307
|
+
<SheetDescription>
|
|
2308
|
+
Select or deselect screens to include in your line item. Click markers on the map or use checkboxes.
|
|
2309
|
+
</SheetDescription>
|
|
2310
|
+
</SheetHeader>
|
|
2311
|
+
<div className="flex-1 overflow-hidden grid grid-cols-1 gap-4 mt-4">
|
|
2312
|
+
<div className="h-64 rounded-md overflow-hidden border">
|
|
2313
|
+
<InventoryMapComponent
|
|
2314
|
+
center={mapCenter}
|
|
2315
|
+
zoom={mapCenter.zoom}
|
|
2316
|
+
screens={fetchedScreens}
|
|
2317
|
+
selectedIds={selectedScreenIds}
|
|
2318
|
+
onScreenClick={handleToggleScreen}
|
|
2319
|
+
/>
|
|
2320
|
+
</div>
|
|
2321
|
+
<div className="flex-1 overflow-hidden">
|
|
2322
|
+
<div className="flex items-center justify-between mb-2">
|
|
2323
|
+
<span className="text-sm font-medium">
|
|
2324
|
+
{(watchedSelectedScreens || []).length} of {fetchedScreens.length} screens selected
|
|
2325
|
+
</span>
|
|
2326
|
+
<div className="flex gap-2">
|
|
2327
|
+
<Button
|
|
2328
|
+
type="button"
|
|
2329
|
+
variant="outline"
|
|
2330
|
+
size="sm"
|
|
2331
|
+
onClick={() => form.setValue("selectedScreens", fetchedScreens.map(s => s.id))}
|
|
2332
|
+
data-testid="button-select-all-screens"
|
|
2333
|
+
>
|
|
2334
|
+
Select All
|
|
2335
|
+
</Button>
|
|
2336
|
+
<Button
|
|
2337
|
+
type="button"
|
|
2338
|
+
variant="outline"
|
|
2339
|
+
size="sm"
|
|
2340
|
+
onClick={() => form.setValue("selectedScreens", [])}
|
|
2341
|
+
data-testid="button-deselect-all-screens"
|
|
2342
|
+
>
|
|
2343
|
+
Deselect All
|
|
2344
|
+
</Button>
|
|
2345
|
+
</div>
|
|
2346
|
+
</div>
|
|
2347
|
+
<ScrollArea className="h-[300px] border rounded-md">
|
|
2348
|
+
<div className="p-3 space-y-2">
|
|
2349
|
+
{fetchedScreens.map((screen) => (
|
|
2350
|
+
<label
|
|
2351
|
+
key={screen.id}
|
|
2352
|
+
className="flex items-center gap-3 p-2 rounded-md hover-elevate cursor-pointer"
|
|
2353
|
+
>
|
|
2354
|
+
<Checkbox
|
|
2355
|
+
checked={selectedScreenIds.has(screen.id)}
|
|
2356
|
+
onCheckedChange={() => handleToggleScreen(screen.id)}
|
|
2357
|
+
data-testid={`checkbox-screen-${screen.id}`}
|
|
2358
|
+
/>
|
|
2359
|
+
<div className="flex-1 min-w-0">
|
|
2360
|
+
<div className="font-medium text-sm truncate">{screen.name}</div>
|
|
2361
|
+
<div className="text-xs text-muted-foreground">
|
|
2362
|
+
{screen.city || ''}{screen.city && screen.country ? ', ' : ''}{screen.country || ''} • CPM: ${screen.cpm || '0.00'}
|
|
2363
|
+
</div>
|
|
2364
|
+
</div>
|
|
2365
|
+
<Monitor className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
2366
|
+
</label>
|
|
2367
|
+
))}
|
|
2368
|
+
{fetchedScreens.length === 0 && (
|
|
2369
|
+
<p className="text-sm text-muted-foreground text-center py-4">
|
|
2370
|
+
No screens available. Fetch inventory first.
|
|
2371
|
+
</p>
|
|
2372
|
+
)}
|
|
2373
|
+
</div>
|
|
2374
|
+
</ScrollArea>
|
|
2375
|
+
</div>
|
|
2376
|
+
</div>
|
|
2377
|
+
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
|
|
2378
|
+
<Button
|
|
2379
|
+
type="button"
|
|
2380
|
+
variant="outline"
|
|
2381
|
+
onClick={() => setInventoryDrawerOpen(false)}
|
|
2382
|
+
data-testid="button-cancel-inventory"
|
|
2383
|
+
>
|
|
2384
|
+
Cancel
|
|
2385
|
+
</Button>
|
|
2386
|
+
<Button
|
|
2387
|
+
type="button"
|
|
2388
|
+
onClick={() => setInventoryDrawerOpen(false)}
|
|
2389
|
+
data-testid="button-apply-inventory"
|
|
2390
|
+
>
|
|
2391
|
+
Apply Selection
|
|
2392
|
+
</Button>
|
|
2393
|
+
</div>
|
|
2394
|
+
</SheetContent>
|
|
2395
|
+
</Sheet>
|
|
2396
|
+
|
|
2397
|
+
{/* Inventory Availability Section - Shows when screens are selected */}
|
|
2398
|
+
{(watchedSelectedScreens?.length ?? 0) > 0 && (
|
|
2399
|
+
<InventoryAvailabilitySection
|
|
2400
|
+
selectedScreenIds={watchedSelectedScreens || []}
|
|
2401
|
+
startDate={watchedStartDate}
|
|
2402
|
+
endDate={watchedEndDate}
|
|
2403
|
+
onViewDetails={() => setAvailabilityDrawerOpen(true)}
|
|
2404
|
+
/>
|
|
2405
|
+
)}
|
|
2406
|
+
|
|
2407
|
+
{/* Day Parting Section */}
|
|
2408
|
+
<Card>
|
|
2409
|
+
<CardHeader className="pb-3">
|
|
2410
|
+
<div className="flex items-center gap-2">
|
|
2411
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
2412
|
+
<CardTitle className="text-base">Day Parting</CardTitle>
|
|
2413
|
+
</div>
|
|
2414
|
+
<p className="text-sm text-muted-foreground">
|
|
2415
|
+
Set specific days and hours when ads should run
|
|
2416
|
+
</p>
|
|
2417
|
+
</CardHeader>
|
|
2418
|
+
<CardContent>
|
|
2419
|
+
<FormField
|
|
2420
|
+
control={form.control}
|
|
2421
|
+
name="schedules"
|
|
2422
|
+
render={({ field }) => (
|
|
2423
|
+
<ScheduleEditor
|
|
2424
|
+
value={field.value || []}
|
|
2425
|
+
onChange={field.onChange}
|
|
2426
|
+
/>
|
|
2427
|
+
)}
|
|
2428
|
+
/>
|
|
2429
|
+
</CardContent>
|
|
2430
|
+
</Card>
|
|
2431
|
+
|
|
2432
|
+
{/* Billing Section */}
|
|
2433
|
+
<Card>
|
|
2434
|
+
<CardHeader className="pb-3">
|
|
2435
|
+
<div className="flex items-center gap-2">
|
|
2436
|
+
<Receipt className="h-4 w-4 text-muted-foreground" />
|
|
2437
|
+
<CardTitle className="text-base">Billing</CardTitle>
|
|
2438
|
+
</div>
|
|
2439
|
+
</CardHeader>
|
|
2440
|
+
<CardContent className="space-y-4">
|
|
2441
|
+
<FormField
|
|
2442
|
+
control={form.control}
|
|
2443
|
+
name="billable"
|
|
2444
|
+
render={({ field }) => (
|
|
2445
|
+
<FormItem className="space-y-3">
|
|
2446
|
+
<FormControl>
|
|
2447
|
+
<RadioGroup
|
|
2448
|
+
onValueChange={(value) => field.onChange(value === "billable")}
|
|
2449
|
+
value={field.value ? "billable" : "non_billable"}
|
|
2450
|
+
className="flex flex-col gap-3"
|
|
2451
|
+
>
|
|
2452
|
+
<div className="flex items-center space-x-3 p-3 rounded-md border hover-elevate cursor-pointer">
|
|
2453
|
+
<RadioGroupItem value="non_billable" id="non_billable" data-testid="radio-non-billable" />
|
|
2454
|
+
<label htmlFor="non_billable" className="flex-1 cursor-pointer">
|
|
2455
|
+
<div className="text-sm font-medium">Non Billable</div>
|
|
2456
|
+
<div className="text-xs text-muted-foreground">This line item will not be billed</div>
|
|
2457
|
+
</label>
|
|
2458
|
+
</div>
|
|
2459
|
+
<div className="flex items-center space-x-3 p-3 rounded-md border hover-elevate cursor-pointer">
|
|
2460
|
+
<RadioGroupItem value="billable" id="billable" data-testid="radio-billable" />
|
|
2461
|
+
<label htmlFor="billable" className="flex-1 cursor-pointer">
|
|
2462
|
+
<div className="text-sm font-medium">Billable</div>
|
|
2463
|
+
<div className="text-xs text-muted-foreground">This line item will be included in billing</div>
|
|
2464
|
+
</label>
|
|
2465
|
+
</div>
|
|
2466
|
+
</RadioGroup>
|
|
2467
|
+
</FormControl>
|
|
2468
|
+
</FormItem>
|
|
2469
|
+
)}
|
|
2470
|
+
/>
|
|
2471
|
+
</CardContent>
|
|
2472
|
+
</Card>
|
|
2473
|
+
|
|
2474
|
+
{/* Bid Strategy Section */}
|
|
2475
|
+
<Card>
|
|
2476
|
+
<CardHeader className="pb-3">
|
|
2477
|
+
<div className="flex items-center gap-2">
|
|
2478
|
+
<Gauge className="h-4 w-4 text-muted-foreground" />
|
|
2479
|
+
<CardTitle className="text-base">Bid Strategy</CardTitle>
|
|
2480
|
+
</div>
|
|
2481
|
+
</CardHeader>
|
|
2482
|
+
<CardContent className="space-y-4">
|
|
2483
|
+
<FormField
|
|
2484
|
+
control={form.control}
|
|
2485
|
+
name="automatedBidding"
|
|
2486
|
+
render={({ field }) => (
|
|
2487
|
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
|
2488
|
+
<div className="space-y-0.5">
|
|
2489
|
+
<FormLabel>Automated Bidding</FormLabel>
|
|
2490
|
+
<FormDescription>
|
|
2491
|
+
Let the system automatically optimize bid amounts
|
|
2492
|
+
</FormDescription>
|
|
2493
|
+
</div>
|
|
2494
|
+
<FormControl>
|
|
2495
|
+
<Switch
|
|
2496
|
+
checked={field.value || false}
|
|
2497
|
+
onCheckedChange={field.onChange}
|
|
2498
|
+
data-testid="switch-automated-bidding"
|
|
2499
|
+
/>
|
|
2500
|
+
</FormControl>
|
|
2501
|
+
</FormItem>
|
|
2502
|
+
)}
|
|
2503
|
+
/>
|
|
2504
|
+
|
|
2505
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
2506
|
+
<FormField
|
|
2507
|
+
control={form.control}
|
|
2508
|
+
name="maxBid"
|
|
2509
|
+
render={({ field }) => (
|
|
2510
|
+
<FormItem>
|
|
2511
|
+
<FormLabel>Max Bid ($)</FormLabel>
|
|
2512
|
+
<div className="space-y-2">
|
|
2513
|
+
<FormControl>
|
|
2514
|
+
<Input
|
|
2515
|
+
type="number"
|
|
2516
|
+
step="0.01"
|
|
2517
|
+
placeholder="0.00"
|
|
2518
|
+
{...field}
|
|
2519
|
+
data-testid="input-max-bid"
|
|
2520
|
+
/>
|
|
2521
|
+
</FormControl>
|
|
2522
|
+
{suggestedMaxBid && !maxBidDiffersFromSuggestion && (
|
|
2523
|
+
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50 border">
|
|
2524
|
+
<span className="text-sm text-muted-foreground">
|
|
2525
|
+
Suggested: <span className="font-medium text-foreground">${suggestedMaxBid}</span>
|
|
2526
|
+
<span className="text-xs ml-1">
|
|
2527
|
+
({(watchedSelectedScreens?.length || 0) > 0 ? "avg of selected screens" : "based on inventory filters"})
|
|
2528
|
+
</span>
|
|
2529
|
+
</span>
|
|
2530
|
+
<Button
|
|
2531
|
+
type="button"
|
|
2532
|
+
variant="outline"
|
|
2533
|
+
size="sm"
|
|
2534
|
+
onClick={applyMaxBidSuggestion}
|
|
2535
|
+
data-testid="button-apply-max-bid"
|
|
2536
|
+
>
|
|
2537
|
+
Apply
|
|
2538
|
+
</Button>
|
|
2539
|
+
</div>
|
|
2540
|
+
)}
|
|
2541
|
+
{maxBidDiffersFromSuggestion && (
|
|
2542
|
+
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
|
2543
|
+
<span className="text-sm text-muted-foreground flex-1">
|
|
2544
|
+
Suggested: <span className="font-medium text-foreground">${suggestedMaxBid}</span>
|
|
2545
|
+
<span className="text-xs ml-1 text-amber-600 dark:text-amber-400">
|
|
2546
|
+
(differs from current)
|
|
2547
|
+
</span>
|
|
2548
|
+
</span>
|
|
2549
|
+
<Button
|
|
2550
|
+
type="button"
|
|
2551
|
+
variant="outline"
|
|
2552
|
+
size="sm"
|
|
2553
|
+
onClick={applyMaxBidSuggestion}
|
|
2554
|
+
data-testid="button-use-suggested-bid"
|
|
2555
|
+
>
|
|
2556
|
+
Use Suggested
|
|
2557
|
+
</Button>
|
|
2558
|
+
</div>
|
|
2559
|
+
)}
|
|
2560
|
+
</div>
|
|
2561
|
+
<FormDescription>
|
|
2562
|
+
Maximum bid amount. {!suggestedMaxBid && "Select inventory or screens to see a suggested rate."}
|
|
2563
|
+
</FormDescription>
|
|
2564
|
+
<FormMessage />
|
|
2565
|
+
</FormItem>
|
|
2566
|
+
)}
|
|
2567
|
+
/>
|
|
2568
|
+
|
|
2569
|
+
<FormField
|
|
2570
|
+
control={form.control}
|
|
2571
|
+
name="bidType"
|
|
2572
|
+
render={({ field }) => (
|
|
2573
|
+
<FormItem>
|
|
2574
|
+
<FormLabel>Bid Type</FormLabel>
|
|
2575
|
+
<Select onValueChange={field.onChange} value={field.value || "cpm"}>
|
|
2576
|
+
<FormControl>
|
|
2577
|
+
<SelectTrigger data-testid="select-bid-type">
|
|
2578
|
+
<SelectValue placeholder="Select bid type" />
|
|
2579
|
+
</SelectTrigger>
|
|
2580
|
+
</FormControl>
|
|
2581
|
+
<SelectContent>
|
|
2582
|
+
<SelectItem value="cpm">CPM</SelectItem>
|
|
2583
|
+
<SelectItem value="cps">CPS</SelectItem>
|
|
2584
|
+
</SelectContent>
|
|
2585
|
+
</Select>
|
|
2586
|
+
<FormMessage />
|
|
2587
|
+
</FormItem>
|
|
2588
|
+
)}
|
|
2589
|
+
/>
|
|
2590
|
+
</div>
|
|
2591
|
+
|
|
2592
|
+
{auctionType && (
|
|
2593
|
+
<FormItem>
|
|
2594
|
+
<FormLabel className="flex items-center gap-2">
|
|
2595
|
+
Auction Type
|
|
2596
|
+
<Badge variant="outline" className="text-xs font-normal">
|
|
2597
|
+
<Lock className="h-3 w-3 mr-1" />
|
|
2598
|
+
Auto
|
|
2599
|
+
</Badge>
|
|
2600
|
+
</FormLabel>
|
|
2601
|
+
<Input
|
|
2602
|
+
value={auctionType}
|
|
2603
|
+
disabled
|
|
2604
|
+
className="bg-muted"
|
|
2605
|
+
data-testid="input-auction-type"
|
|
2606
|
+
/>
|
|
2607
|
+
<FormDescription>
|
|
2608
|
+
Determined by the deal type
|
|
2609
|
+
</FormDescription>
|
|
2610
|
+
</FormItem>
|
|
2611
|
+
)}
|
|
2612
|
+
|
|
2613
|
+
{isPGDeal && currentDeal && (
|
|
2614
|
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
|
2615
|
+
<div className="space-y-0.5">
|
|
2616
|
+
<div className="flex items-center gap-2">
|
|
2617
|
+
<FormLabel>Hard Stop</FormLabel>
|
|
2618
|
+
<Badge variant="outline" className="text-xs">From Deal</Badge>
|
|
2619
|
+
</div>
|
|
2620
|
+
<FormDescription>
|
|
2621
|
+
{currentDeal.hardStop
|
|
2622
|
+
? "Enabled - Delivery stops when budget is exhausted"
|
|
2623
|
+
: "Disabled - Delivery continues past budget"}
|
|
2624
|
+
</FormDescription>
|
|
2625
|
+
</div>
|
|
2626
|
+
<Checkbox
|
|
2627
|
+
checked={currentDeal.hardStop ?? false}
|
|
2628
|
+
disabled
|
|
2629
|
+
data-testid="checkbox-hard-stop"
|
|
2630
|
+
/>
|
|
2631
|
+
</FormItem>
|
|
2632
|
+
)}
|
|
2633
|
+
</CardContent>
|
|
2634
|
+
</Card>
|
|
2635
|
+
|
|
2636
|
+
{/* Custom Fees Section - Table Format */}
|
|
2637
|
+
<Card>
|
|
2638
|
+
<CardHeader className="pb-3">
|
|
2639
|
+
<div className="flex items-center justify-between">
|
|
2640
|
+
<div className="flex items-center gap-2">
|
|
2641
|
+
<Receipt className="h-4 w-4 text-muted-foreground" />
|
|
2642
|
+
<CardTitle className="text-base">Custom Fees</CardTitle>
|
|
2643
|
+
</div>
|
|
2644
|
+
<Button
|
|
2645
|
+
type="button"
|
|
2646
|
+
variant="outline"
|
|
2647
|
+
size="sm"
|
|
2648
|
+
onClick={() => appendFee({ name: "", amount: 0, type: "fixed", hidden: false })}
|
|
2649
|
+
data-testid="button-add-fee"
|
|
2650
|
+
>
|
|
2651
|
+
<Plus className="h-4 w-4 mr-1" />
|
|
2652
|
+
Add Fee
|
|
2653
|
+
</Button>
|
|
2654
|
+
</div>
|
|
2655
|
+
</CardHeader>
|
|
2656
|
+
<CardContent>
|
|
2657
|
+
{feeFields.length === 0 ? (
|
|
2658
|
+
<p className="text-sm text-muted-foreground text-center py-4">No custom fees added</p>
|
|
2659
|
+
) : (
|
|
2660
|
+
<div className="border rounded-md overflow-hidden">
|
|
2661
|
+
<table className="w-full text-sm">
|
|
2662
|
+
<thead className="bg-muted/50">
|
|
2663
|
+
<tr>
|
|
2664
|
+
<th className="text-left p-3 font-medium">Name</th>
|
|
2665
|
+
<th className="text-left p-3 font-medium">Amount</th>
|
|
2666
|
+
<th className="text-left p-3 font-medium">Type</th>
|
|
2667
|
+
<th className="text-center p-3 font-medium">Invoiced</th>
|
|
2668
|
+
<th className="text-center p-3 font-medium w-12">Action</th>
|
|
2669
|
+
</tr>
|
|
2670
|
+
</thead>
|
|
2671
|
+
<tbody className="divide-y">
|
|
2672
|
+
{feeFields.map((feeField, index) => (
|
|
2673
|
+
<tr key={feeField.id} className="hover-elevate">
|
|
2674
|
+
<td className="p-2">
|
|
2675
|
+
<FormField
|
|
2676
|
+
control={form.control}
|
|
2677
|
+
name={`customFees.${index}.name`}
|
|
2678
|
+
render={({ field }) => (
|
|
2679
|
+
<Input
|
|
2680
|
+
placeholder="Fee name"
|
|
2681
|
+
className="h-8"
|
|
2682
|
+
{...field}
|
|
2683
|
+
data-testid={`input-fee-name-${index}`}
|
|
2684
|
+
/>
|
|
2685
|
+
)}
|
|
2686
|
+
/>
|
|
2687
|
+
</td>
|
|
2688
|
+
<td className="p-2">
|
|
2689
|
+
<FormField
|
|
2690
|
+
control={form.control}
|
|
2691
|
+
name={`customFees.${index}.amount`}
|
|
2692
|
+
render={({ field }) => (
|
|
2693
|
+
<Input
|
|
2694
|
+
type="number"
|
|
2695
|
+
step="0.01"
|
|
2696
|
+
placeholder="0.00"
|
|
2697
|
+
className="h-8 w-24"
|
|
2698
|
+
{...field}
|
|
2699
|
+
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
|
2700
|
+
data-testid={`input-fee-amount-${index}`}
|
|
2701
|
+
/>
|
|
2702
|
+
)}
|
|
2703
|
+
/>
|
|
2704
|
+
</td>
|
|
2705
|
+
<td className="p-2">
|
|
2706
|
+
<FormField
|
|
2707
|
+
control={form.control}
|
|
2708
|
+
name={`customFees.${index}.type`}
|
|
2709
|
+
render={({ field }) => (
|
|
2710
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
2711
|
+
<SelectTrigger className="h-8 w-28" data-testid={`select-fee-type-${index}`}>
|
|
2712
|
+
<SelectValue />
|
|
2713
|
+
</SelectTrigger>
|
|
2714
|
+
<SelectContent>
|
|
2715
|
+
<SelectItem value="fixed">Fixed</SelectItem>
|
|
2716
|
+
<SelectItem value="percentage">Percentage</SelectItem>
|
|
2717
|
+
</SelectContent>
|
|
2718
|
+
</Select>
|
|
2719
|
+
)}
|
|
2720
|
+
/>
|
|
2721
|
+
</td>
|
|
2722
|
+
<td className="p-2 text-center">
|
|
2723
|
+
<FormField
|
|
2724
|
+
control={form.control}
|
|
2725
|
+
name={`customFees.${index}.hidden`}
|
|
2726
|
+
render={({ field }) => (
|
|
2727
|
+
<Checkbox
|
|
2728
|
+
checked={!field.value}
|
|
2729
|
+
onCheckedChange={(checked) => field.onChange(!checked)}
|
|
2730
|
+
data-testid={`checkbox-fee-invoiced-${index}`}
|
|
2731
|
+
/>
|
|
2732
|
+
)}
|
|
2733
|
+
/>
|
|
2734
|
+
</td>
|
|
2735
|
+
<td className="p-2 text-center">
|
|
2736
|
+
<Button
|
|
2737
|
+
type="button"
|
|
2738
|
+
variant="ghost"
|
|
2739
|
+
size="icon"
|
|
2740
|
+
className="h-8 w-8"
|
|
2741
|
+
onClick={() => removeFee(index)}
|
|
2742
|
+
data-testid={`button-remove-fee-${index}`}
|
|
2743
|
+
>
|
|
2744
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
2745
|
+
</Button>
|
|
2746
|
+
</td>
|
|
2747
|
+
</tr>
|
|
2748
|
+
))}
|
|
2749
|
+
</tbody>
|
|
2750
|
+
</table>
|
|
2751
|
+
</div>
|
|
2752
|
+
)}
|
|
2753
|
+
</CardContent>
|
|
2754
|
+
</Card>
|
|
2755
|
+
|
|
2756
|
+
{/* Advanced Options */}
|
|
2757
|
+
<Card>
|
|
2758
|
+
<CardHeader>
|
|
2759
|
+
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
2760
|
+
<CollapsibleTrigger asChild>
|
|
2761
|
+
<Button variant="ghost" className="w-full justify-between p-0 h-auto hover:bg-transparent">
|
|
2762
|
+
<div className="flex items-center gap-2">
|
|
2763
|
+
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
2764
|
+
<CardTitle className="text-base">Advanced Options</CardTitle>
|
|
2765
|
+
</div>
|
|
2766
|
+
{advancedOpen ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
|
|
2767
|
+
</Button>
|
|
2768
|
+
</CollapsibleTrigger>
|
|
2769
|
+
</Collapsible>
|
|
2770
|
+
</CardHeader>
|
|
2771
|
+
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
2772
|
+
<CollapsibleContent>
|
|
2773
|
+
<CardContent className="space-y-6">
|
|
2774
|
+
{/* Frequency Cap - only for programmatic deals (not PG or Traditional) */}
|
|
2775
|
+
{!isPGDeal && !isTraditionalDeal && (
|
|
2776
|
+
<div className="space-y-4">
|
|
2777
|
+
<FormLabel className="text-base font-medium">Frequency Cap</FormLabel>
|
|
2778
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
2779
|
+
<FormField
|
|
2780
|
+
control={form.control}
|
|
2781
|
+
name="frequencyCapImpressions"
|
|
2782
|
+
render={({ field }) => (
|
|
2783
|
+
<FormItem>
|
|
2784
|
+
<FormLabel>Ad Plays</FormLabel>
|
|
2785
|
+
<FormControl>
|
|
2786
|
+
<Input
|
|
2787
|
+
type="number"
|
|
2788
|
+
placeholder="e.g., 5"
|
|
2789
|
+
{...field}
|
|
2790
|
+
data-testid="input-frequency-impressions"
|
|
2791
|
+
/>
|
|
2792
|
+
</FormControl>
|
|
2793
|
+
<FormDescription>
|
|
2794
|
+
Maximum ad plays per inventory per period
|
|
2795
|
+
</FormDescription>
|
|
2796
|
+
<FormMessage />
|
|
2797
|
+
</FormItem>
|
|
2798
|
+
)}
|
|
2799
|
+
/>
|
|
2800
|
+
|
|
2801
|
+
<FormField
|
|
2802
|
+
control={form.control}
|
|
2803
|
+
name="frequencyCapPeriod"
|
|
2804
|
+
render={({ field }) => (
|
|
2805
|
+
<FormItem>
|
|
2806
|
+
<FormLabel>Period</FormLabel>
|
|
2807
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
2808
|
+
<FormControl>
|
|
2809
|
+
<SelectTrigger data-testid="select-frequency-period">
|
|
2810
|
+
<SelectValue placeholder="Select period" />
|
|
2811
|
+
</SelectTrigger>
|
|
2812
|
+
</FormControl>
|
|
2813
|
+
<SelectContent>
|
|
2814
|
+
<SelectItem value="hour">Hour</SelectItem>
|
|
2815
|
+
<SelectItem value="day">Day</SelectItem>
|
|
2816
|
+
<SelectItem value="week">Week</SelectItem>
|
|
2817
|
+
<SelectItem value="month">Month</SelectItem>
|
|
2818
|
+
<SelectItem value="lifetime">Lifetime</SelectItem>
|
|
2819
|
+
</SelectContent>
|
|
2820
|
+
</Select>
|
|
2821
|
+
<FormMessage />
|
|
2822
|
+
</FormItem>
|
|
2823
|
+
)}
|
|
2824
|
+
/>
|
|
2825
|
+
</div>
|
|
2826
|
+
</div>
|
|
2827
|
+
)}
|
|
2828
|
+
</CardContent>
|
|
2829
|
+
</CollapsibleContent>
|
|
2830
|
+
</Collapsible>
|
|
2831
|
+
</Card>
|
|
2832
|
+
|
|
2833
|
+
{/* Signals Section */}
|
|
2834
|
+
<Card>
|
|
2835
|
+
<CardHeader className="pb-3">
|
|
2836
|
+
<div className="flex items-center gap-2">
|
|
2837
|
+
<Zap className="h-4 w-4 text-muted-foreground" />
|
|
2838
|
+
<CardTitle className="text-base">Signals</CardTitle>
|
|
2839
|
+
</div>
|
|
2840
|
+
</CardHeader>
|
|
2841
|
+
<CardContent className="space-y-4">
|
|
2842
|
+
<FormField
|
|
2843
|
+
control={form.control}
|
|
2844
|
+
name="triggerEnabled"
|
|
2845
|
+
render={({ field }) => (
|
|
2846
|
+
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
|
2847
|
+
<div className="space-y-0.5">
|
|
2848
|
+
<FormLabel>Auto-activate Signal</FormLabel>
|
|
2849
|
+
<FormDescription>
|
|
2850
|
+
Automatically activate line item when signal triggers
|
|
2851
|
+
</FormDescription>
|
|
2852
|
+
</div>
|
|
2853
|
+
<FormControl>
|
|
2854
|
+
<Switch
|
|
2855
|
+
checked={field.value}
|
|
2856
|
+
onCheckedChange={field.onChange}
|
|
2857
|
+
data-testid="toggle-signal-auto-activate"
|
|
2858
|
+
/>
|
|
2859
|
+
</FormControl>
|
|
2860
|
+
</FormItem>
|
|
2861
|
+
)}
|
|
2862
|
+
/>
|
|
2863
|
+
|
|
2864
|
+
<FormField
|
|
2865
|
+
control={form.control}
|
|
2866
|
+
name="triggerId"
|
|
2867
|
+
render={({ field }) => (
|
|
2868
|
+
<FormItem>
|
|
2869
|
+
<FormLabel>Select Signal</FormLabel>
|
|
2870
|
+
<FormControl>
|
|
2871
|
+
<SearchableCombobox
|
|
2872
|
+
options={signals.map((signal) => ({
|
|
2873
|
+
value: signal.id,
|
|
2874
|
+
label: signal.name,
|
|
2875
|
+
description: `${signal.signalType} • ${signal.status}`,
|
|
2876
|
+
}))}
|
|
2877
|
+
value={field.value}
|
|
2878
|
+
onValueChange={field.onChange}
|
|
2879
|
+
placeholder="Select a signal..."
|
|
2880
|
+
searchPlaceholder="Search signals..."
|
|
2881
|
+
emptyMessage="No signals found."
|
|
2882
|
+
disabled={!form.watch("triggerEnabled")}
|
|
2883
|
+
data-testid="select-signal"
|
|
2884
|
+
/>
|
|
2885
|
+
</FormControl>
|
|
2886
|
+
<FormDescription>
|
|
2887
|
+
Choose a signal to control when this line item activates
|
|
2888
|
+
</FormDescription>
|
|
2889
|
+
<FormMessage />
|
|
2890
|
+
</FormItem>
|
|
2891
|
+
)}
|
|
2892
|
+
/>
|
|
2893
|
+
</CardContent>
|
|
2894
|
+
</Card>
|
|
2895
|
+
</form>
|
|
2896
|
+
</Form>
|
|
2897
|
+
|
|
2898
|
+
</div>
|
|
2899
|
+
|
|
2900
|
+
<div className="lg:col-span-1 space-y-4">
|
|
2901
|
+
<div className="sticky top-6 space-y-4">
|
|
2902
|
+
{!forecast && (
|
|
2903
|
+
<FormInsightsPanel
|
|
2904
|
+
insights={LINE_ITEM_INSIGHTS}
|
|
2905
|
+
tips={LINE_ITEM_TIPS}
|
|
2906
|
+
currentSection={currentFormSection}
|
|
2907
|
+
sectionInsights={SECTION_INSIGHTS}
|
|
2908
|
+
/>
|
|
2909
|
+
)}
|
|
2910
|
+
|
|
2911
|
+
<Card data-testid="panel-forecast">
|
|
2912
|
+
<CardHeader className="pb-3">
|
|
2913
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
2914
|
+
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
2915
|
+
Forecast
|
|
2916
|
+
</CardTitle>
|
|
2917
|
+
</CardHeader>
|
|
2918
|
+
<CardContent className="pt-0">
|
|
2919
|
+
{forecast ? (
|
|
2920
|
+
<div className="space-y-3">
|
|
2921
|
+
<div className="space-y-1">
|
|
2922
|
+
<div className="text-xs text-muted-foreground">Estimated Impressions</div>
|
|
2923
|
+
<div className="text-lg font-semibold" data-testid="text-estimated-impressions">
|
|
2924
|
+
{formatNumber(forecast.estimatedImpressions)}
|
|
2925
|
+
</div>
|
|
2926
|
+
<div className="text-xs text-muted-foreground">
|
|
2927
|
+
Over {forecast.daysInRange} {forecast.daysInRange === 1 ? 'day' : 'days'}
|
|
2928
|
+
</div>
|
|
2929
|
+
</div>
|
|
2930
|
+
|
|
2931
|
+
{forecast.hasMaxBid && (
|
|
2932
|
+
<div className="space-y-2 pt-2 border-t">
|
|
2933
|
+
<div className="text-xs text-muted-foreground font-medium">Cost Breakdown</div>
|
|
2934
|
+
<div className="p-3 rounded-md bg-muted/50 space-y-2" data-testid="panel-cost-breakdown">
|
|
2935
|
+
<div className="flex items-center justify-between text-xs">
|
|
2936
|
+
<span>Media Cost</span>
|
|
2937
|
+
<span className="font-medium">{formatCurrency(forecast.mediaCost)}</span>
|
|
2938
|
+
</div>
|
|
2939
|
+
{forecast.hasCustomFees && (
|
|
2940
|
+
<div className="flex items-center justify-between text-xs">
|
|
2941
|
+
<span>+ Custom Fees</span>
|
|
2942
|
+
<span className="font-medium">{formatCurrency(forecast.customFeesTotal)}</span>
|
|
2943
|
+
</div>
|
|
2944
|
+
)}
|
|
2945
|
+
<div className="flex items-center justify-between text-xs">
|
|
2946
|
+
<span>+ Platform Fee ({forecast.platformFeePercent}%)</span>
|
|
2947
|
+
<span className="font-medium">{formatCurrency(forecast.platformFee)}</span>
|
|
2948
|
+
</div>
|
|
2949
|
+
<div className="flex items-center justify-between text-sm border-t pt-2 mt-2">
|
|
2950
|
+
<span className="font-semibold">Total</span>
|
|
2951
|
+
<span className={`font-bold ${
|
|
2952
|
+
(forecast.exceedsDealBudget || forecast.exceedsLineItemBudget)
|
|
2953
|
+
? 'text-destructive'
|
|
2954
|
+
: 'text-green-600'
|
|
2955
|
+
}`} data-testid="text-total-cost">
|
|
2956
|
+
{formatCurrency(forecast.totalCost)}
|
|
2957
|
+
</span>
|
|
2958
|
+
</div>
|
|
2959
|
+
</div>
|
|
2960
|
+
</div>
|
|
2961
|
+
)}
|
|
2962
|
+
|
|
2963
|
+
{!forecast.hasMaxBid && (
|
|
2964
|
+
<div className="text-xs text-muted-foreground pt-2 border-t">
|
|
2965
|
+
Set a floor rate to see cost breakdown
|
|
2966
|
+
</div>
|
|
2967
|
+
)}
|
|
2968
|
+
|
|
2969
|
+
{forecast.hasMaxBid && (forecast.dealBudget > 0 || forecast.lineItemBudget > 0) && (
|
|
2970
|
+
<div className="space-y-2 pt-2 border-t">
|
|
2971
|
+
<div className="text-xs text-muted-foreground">Budget Comparison</div>
|
|
2972
|
+
<div className="p-2 rounded-md bg-muted/30 space-y-1" data-testid="panel-budget-comparison">
|
|
2973
|
+
{forecast.dealBudget > 0 && (
|
|
2974
|
+
<div className="flex items-center justify-between text-xs">
|
|
2975
|
+
<span>Deal Budget:</span>
|
|
2976
|
+
<span className="font-medium">{formatCurrency(forecast.dealBudget)}</span>
|
|
2977
|
+
</div>
|
|
2978
|
+
)}
|
|
2979
|
+
{forecast.lineItemBudget > 0 && (
|
|
2980
|
+
<div className="flex items-center justify-between text-xs">
|
|
2981
|
+
<span>Line Item Budget:</span>
|
|
2982
|
+
<span className="font-medium">{formatCurrency(forecast.lineItemBudget)}</span>
|
|
2983
|
+
</div>
|
|
2984
|
+
)}
|
|
2985
|
+
</div>
|
|
2986
|
+
{(forecast.exceedsDealBudget || forecast.exceedsLineItemBudget) && (
|
|
2987
|
+
<div className="flex items-center gap-1 text-xs text-destructive" data-testid="alert-budget-exceeded">
|
|
2988
|
+
<span className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
|
2989
|
+
Cost exceeds {forecast.exceedsDealBudget ? "deal" : "line item"} budget
|
|
2990
|
+
</div>
|
|
2991
|
+
)}
|
|
2992
|
+
</div>
|
|
2993
|
+
)}
|
|
2994
|
+
|
|
2995
|
+
{forecast.hasMaxBid && (
|
|
2996
|
+
<div className="space-y-1 pt-2 border-t">
|
|
2997
|
+
<div className="text-xs text-muted-foreground">Cost Efficiency</div>
|
|
2998
|
+
<div className="flex flex-wrap gap-2" data-testid="text-cost-efficiency">
|
|
2999
|
+
<Badge variant="secondary" className="text-xs">
|
|
3000
|
+
eCPM: {formatCurrency(forecast.effectiveCPM)}
|
|
3001
|
+
</Badge>
|
|
3002
|
+
<Badge variant="secondary" className="text-xs">
|
|
3003
|
+
eCPS: {formatCurrency(forecast.effectiveCPS)}
|
|
3004
|
+
</Badge>
|
|
3005
|
+
</div>
|
|
3006
|
+
</div>
|
|
3007
|
+
)}
|
|
3008
|
+
|
|
3009
|
+
{forecast.budgetUtilization !== null && (
|
|
3010
|
+
<div className="space-y-1 pt-2 border-t">
|
|
3011
|
+
<div className="text-xs text-muted-foreground">Budget Utilization</div>
|
|
3012
|
+
<div className="flex items-center gap-2">
|
|
3013
|
+
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
3014
|
+
<div
|
|
3015
|
+
className={`h-full rounded-full ${
|
|
3016
|
+
forecast.budgetUtilization > 100
|
|
3017
|
+
? 'bg-destructive'
|
|
3018
|
+
: forecast.budgetUtilization > 80
|
|
3019
|
+
? 'bg-amber-500'
|
|
3020
|
+
: 'bg-green-500'
|
|
3021
|
+
}`}
|
|
3022
|
+
style={{ width: `${Math.min(100, forecast.budgetUtilization)}%` }}
|
|
3023
|
+
/>
|
|
3024
|
+
</div>
|
|
3025
|
+
<span className="text-xs font-medium" data-testid="text-budget-utilization">
|
|
3026
|
+
{forecast.budgetUtilization.toFixed(0)}%
|
|
3027
|
+
</span>
|
|
3028
|
+
</div>
|
|
3029
|
+
{forecast.budgetUtilization > 100 && (
|
|
3030
|
+
<div className="text-xs text-destructive">
|
|
3031
|
+
Estimated spend exceeds budget
|
|
3032
|
+
</div>
|
|
3033
|
+
)}
|
|
3034
|
+
</div>
|
|
3035
|
+
)}
|
|
3036
|
+
</div>
|
|
3037
|
+
) : (
|
|
3038
|
+
<p className="text-sm text-muted-foreground" data-testid="text-forecast-empty">
|
|
3039
|
+
Configure inventory and dates to see forecast
|
|
3040
|
+
</p>
|
|
3041
|
+
)}
|
|
3042
|
+
</CardContent>
|
|
3043
|
+
</Card>
|
|
3044
|
+
</div>
|
|
3045
|
+
</div>
|
|
3046
|
+
</div>
|
|
3047
|
+
</div>
|
|
3048
|
+
</div>
|
|
3049
|
+
</div>
|
|
3050
|
+
|
|
3051
|
+
<div className="absolute bottom-0 left-0 right-0 border-t bg-background p-4 z-10">
|
|
3052
|
+
<div className="flex justify-end gap-4">
|
|
3053
|
+
<Button
|
|
3054
|
+
type="button"
|
|
3055
|
+
variant="outline"
|
|
3056
|
+
onClick={handleCancel}
|
|
3057
|
+
disabled={isSubmitting}
|
|
3058
|
+
data-testid="button-cancel"
|
|
3059
|
+
>
|
|
3060
|
+
Cancel
|
|
3061
|
+
</Button>
|
|
3062
|
+
<Button
|
|
3063
|
+
type="submit"
|
|
3064
|
+
form="line-item-form"
|
|
3065
|
+
disabled={isSubmitting}
|
|
3066
|
+
data-testid="button-submit"
|
|
3067
|
+
>
|
|
3068
|
+
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
3069
|
+
{isEditing ? "Update Line Item" : "Create Line Item"}
|
|
3070
|
+
</Button>
|
|
3071
|
+
</div>
|
|
3072
|
+
</div>
|
|
3073
|
+
|
|
3074
|
+
<MediaOwnerDrawer
|
|
3075
|
+
open={mediaOwnerDrawerOpen}
|
|
3076
|
+
onOpenChange={setMediaOwnerDrawerOpen}
|
|
3077
|
+
selectedMediaOwners={selectedMediaOwners}
|
|
3078
|
+
onSelectionChange={setSelectedMediaOwners}
|
|
3079
|
+
/>
|
|
3080
|
+
|
|
3081
|
+
<InventoryFormatDrawer
|
|
3082
|
+
open={inventoryFormatDrawerOpen}
|
|
3083
|
+
onOpenChange={setInventoryFormatDrawerOpen}
|
|
3084
|
+
selectedFormats={form.watch("inventoryFormat")}
|
|
3085
|
+
onSelectionChange={(formats) => form.setValue("inventoryFormat", formats)}
|
|
3086
|
+
selectedTypes={form.watch("inventoryType")}
|
|
3087
|
+
/>
|
|
3088
|
+
|
|
3089
|
+
<POITargetingDrawer
|
|
3090
|
+
open={poiDrawerOpen}
|
|
3091
|
+
onOpenChange={setPoiDrawerOpen}
|
|
3092
|
+
selectedPOIs={form.watch("selectedPOIs")}
|
|
3093
|
+
onPOISelectionChange={(pois) => form.setValue("selectedPOIs", pois)}
|
|
3094
|
+
selectedCategories={selectedPOICategories}
|
|
3095
|
+
onCategorySelectionChange={setSelectedPOICategories}
|
|
3096
|
+
mapCenter={{ lng: 103.8198, lat: 1.3521, zoom: 11 }}
|
|
3097
|
+
/>
|
|
3098
|
+
|
|
3099
|
+
<AdvancedMapDrawer
|
|
3100
|
+
open={advancedMapDrawerOpen}
|
|
3101
|
+
onOpenChange={setAdvancedMapDrawerOpen}
|
|
3102
|
+
selectedLocations={form.watch("geography")}
|
|
3103
|
+
onSelectionChange={(locations) => form.setValue("geography", locations)}
|
|
3104
|
+
/>
|
|
3105
|
+
|
|
3106
|
+
<ManualInventoryDrawer
|
|
3107
|
+
open={manualInventoryDrawerOpen}
|
|
3108
|
+
onOpenChange={setManualInventoryDrawerOpen}
|
|
3109
|
+
screens={fetchedScreens.length > 0 ? fetchedScreens : allScreens}
|
|
3110
|
+
selectedScreens={form.watch("selectedScreens")}
|
|
3111
|
+
onSelectionChange={(screens) => form.setValue("selectedScreens", screens)}
|
|
3112
|
+
mapCenter={{ lng: 103.8198, lat: 1.3521, zoom: 11 }}
|
|
3113
|
+
/>
|
|
3114
|
+
|
|
3115
|
+
<VenueTypeDrawer
|
|
3116
|
+
open={venueTypeDrawerOpen}
|
|
3117
|
+
onOpenChange={setVenueTypeDrawerOpen}
|
|
3118
|
+
selectedVenues={form.watch("venueTypes")}
|
|
3119
|
+
onApply={(venues) => form.setValue("venueTypes", venues)}
|
|
3120
|
+
/>
|
|
3121
|
+
|
|
3122
|
+
<AvailabilityDrawer
|
|
3123
|
+
open={availabilityDrawerOpen}
|
|
3124
|
+
onOpenChange={setAvailabilityDrawerOpen}
|
|
3125
|
+
screens={allScreens}
|
|
3126
|
+
selectedScreenIds={form.watch("selectedScreens") || []}
|
|
3127
|
+
lineItemStartDate={watchedStartDate}
|
|
3128
|
+
lineItemEndDate={watchedEndDate}
|
|
3129
|
+
/>
|
|
3130
|
+
</div>
|
|
3131
|
+
);
|
|
3132
|
+
}
|