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,1570 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
|
2
|
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { useRoute, useLocation } from "wouter";
|
|
4
|
+
import { useForm, useWatch } from "react-hook-form";
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { ArrowLeft, Loader2, User, Tag, Globe, ChevronDown, X, Plus, Lightbulb, Building2, DollarSign, Target, AlertTriangle } from "lucide-react";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
11
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
12
|
+
import { Badge } from "@/components/ui/badge";
|
|
13
|
+
import { Switch } from "@/components/ui/switch";
|
|
14
|
+
import {
|
|
15
|
+
Form,
|
|
16
|
+
FormControl,
|
|
17
|
+
FormField,
|
|
18
|
+
FormItem,
|
|
19
|
+
FormLabel,
|
|
20
|
+
FormMessage,
|
|
21
|
+
FormDescription,
|
|
22
|
+
} from "@/components/ui/form";
|
|
23
|
+
import {
|
|
24
|
+
Popover,
|
|
25
|
+
PopoverContent,
|
|
26
|
+
PopoverTrigger,
|
|
27
|
+
} from "@/components/ui/popover";
|
|
28
|
+
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
29
|
+
import { PageHeader } from "@/components/page-header";
|
|
30
|
+
import { SearchableCombobox, ComboboxOption } from "@/components/searchable-combobox";
|
|
31
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
32
|
+
import { useToast } from "@/hooks/use-toast";
|
|
33
|
+
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
34
|
+
import type { Deal, Brand, Agency, SspPartner, DspPartner } from "@shared/schema";
|
|
35
|
+
import { MEDIA_TYPES, ADPLAY_VERIFICATION_PROVIDERS, DEAL_TYPES, GOAL_TYPES } from "@shared/schema";
|
|
36
|
+
import { BrandInsightsPanel } from "@/components/brand-insights-panel";
|
|
37
|
+
import { MarketInsightsPanel } from "@/components/market-insights-panel";
|
|
38
|
+
import { CreateBrandDrawer } from "@/components/create-brand-drawer";
|
|
39
|
+
import { CreateAgencyDrawer } from "@/components/create-agency-drawer";
|
|
40
|
+
|
|
41
|
+
// Section-specific Quick Tips that change based on scroll position
|
|
42
|
+
type TipSection = "basic" | "ssp" | "brand" | "budget";
|
|
43
|
+
|
|
44
|
+
interface QuickTip {
|
|
45
|
+
title: string;
|
|
46
|
+
tips: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SECTION_TIPS: Record<TipSection, QuickTip> = {
|
|
50
|
+
basic: {
|
|
51
|
+
title: "Deal Basics",
|
|
52
|
+
tips: [
|
|
53
|
+
"Deal Name should be descriptive and unique to easily identify it in lists",
|
|
54
|
+
"Status controls whether the deal is Active (running) or Paused (stopped)",
|
|
55
|
+
"Media Type defines the channel - DOOH (Digital Out-of-Home) is for digital screens",
|
|
56
|
+
"External Deal ID helps sync with external systems like your CRM",
|
|
57
|
+
"Billable flag determines if this deal is included in invoicing",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
ssp: {
|
|
61
|
+
title: "Supply Configuration",
|
|
62
|
+
tips: [
|
|
63
|
+
"SSP Partner (Supply-Side Platform) provides access to inventory networks",
|
|
64
|
+
"Deal Type determines pricing model - Traditional or Programmatic",
|
|
65
|
+
"Programmatic deals enable automated real-time bidding with DSP partners",
|
|
66
|
+
"Media Owner is selected at Line Item level for more granular control",
|
|
67
|
+
"Client Type indicates if this is a direct client or through an agency",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
brand: {
|
|
71
|
+
title: "Brand & Verification",
|
|
72
|
+
tips: [
|
|
73
|
+
"Selecting a brand enables brand-specific insights and recommendations",
|
|
74
|
+
"AdPlay Verification ensures ads are actually played on screens",
|
|
75
|
+
"Countries selected here limit geography targeting in line items",
|
|
76
|
+
"Line items can only target cities/areas within the deal's countries",
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
budget: {
|
|
80
|
+
title: "Budget & Goals",
|
|
81
|
+
tips: [
|
|
82
|
+
"Currency determines the monetary unit for all budget and pricing",
|
|
83
|
+
"Currency margin (3%) applies when inventory uses different currencies",
|
|
84
|
+
"Goal Type sets what you're optimizing for - Impressions, Reach, etc.",
|
|
85
|
+
"Budget and Goal values help with better inventory recommendations in Line Items",
|
|
86
|
+
"Setting budget enables spend tracking and pacing recommendations",
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface CountryOption {
|
|
92
|
+
code: string;
|
|
93
|
+
name: string;
|
|
94
|
+
flag: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const COUNTRIES: CountryOption[] = [
|
|
98
|
+
{ code: "MY", name: "Malaysia", flag: "MY" },
|
|
99
|
+
{ code: "US", name: "United States", flag: "US" },
|
|
100
|
+
{ code: "CN", name: "China", flag: "CN" },
|
|
101
|
+
{ code: "IN", name: "India", flag: "IN" },
|
|
102
|
+
{ code: "GB", name: "United Kingdom", flag: "GB" },
|
|
103
|
+
{ code: "SG", name: "Singapore", flag: "SG" },
|
|
104
|
+
{ code: "TH", name: "Thailand", flag: "TH" },
|
|
105
|
+
{ code: "ID", name: "Indonesia", flag: "ID" },
|
|
106
|
+
{ code: "PH", name: "Philippines", flag: "PH" },
|
|
107
|
+
{ code: "VN", name: "Vietnam", flag: "VN" },
|
|
108
|
+
{ code: "AU", name: "Australia", flag: "AU" },
|
|
109
|
+
{ code: "JP", name: "Japan", flag: "JP" },
|
|
110
|
+
{ code: "KR", name: "South Korea", flag: "KR" },
|
|
111
|
+
{ code: "DE", name: "Germany", flag: "DE" },
|
|
112
|
+
{ code: "FR", name: "France", flag: "FR" },
|
|
113
|
+
{ code: "BR", name: "Brazil", flag: "BR" },
|
|
114
|
+
{ code: "MX", name: "Mexico", flag: "MX" },
|
|
115
|
+
{ code: "CA", name: "Canada", flag: "CA" },
|
|
116
|
+
{ code: "AE", name: "UAE", flag: "AE" },
|
|
117
|
+
{ code: "SA", name: "Saudi Arabia", flag: "SA" },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
// Country to currency mapping
|
|
121
|
+
const COUNTRY_CURRENCY_MAP: Record<string, string> = {
|
|
122
|
+
MY: "MYR",
|
|
123
|
+
US: "USD",
|
|
124
|
+
CN: "CNY",
|
|
125
|
+
IN: "INR",
|
|
126
|
+
GB: "GBP",
|
|
127
|
+
SG: "SGD",
|
|
128
|
+
TH: "USD", // Thailand uses USD for DOOH typically
|
|
129
|
+
ID: "USD", // Indonesia uses USD for DOOH typically
|
|
130
|
+
PH: "USD", // Philippines uses USD for DOOH typically
|
|
131
|
+
VN: "USD", // Vietnam uses USD for DOOH typically
|
|
132
|
+
AU: "AUD",
|
|
133
|
+
JP: "JPY",
|
|
134
|
+
KR: "USD", // South Korea uses USD for DOOH typically
|
|
135
|
+
DE: "EUR",
|
|
136
|
+
FR: "EUR",
|
|
137
|
+
BR: "USD", // Brazil uses USD for DOOH typically
|
|
138
|
+
MX: "USD", // Mexico uses USD for DOOH typically
|
|
139
|
+
CA: "CAD",
|
|
140
|
+
AE: "AED",
|
|
141
|
+
SA: "USD", // Saudi Arabia uses USD for DOOH typically
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const CURRENCIES: { value: string; label: string }[] = [
|
|
145
|
+
{ value: "USD", label: "USD - US Dollar" },
|
|
146
|
+
{ value: "EUR", label: "EUR - Euro" },
|
|
147
|
+
{ value: "GBP", label: "GBP - British Pound" },
|
|
148
|
+
{ value: "MYR", label: "MYR - Malaysian Ringgit" },
|
|
149
|
+
{ value: "SGD", label: "SGD - Singapore Dollar" },
|
|
150
|
+
{ value: "AUD", label: "AUD - Australian Dollar" },
|
|
151
|
+
{ value: "JPY", label: "JPY - Japanese Yen" },
|
|
152
|
+
{ value: "INR", label: "INR - Indian Rupee" },
|
|
153
|
+
{ value: "CNY", label: "CNY - Chinese Yuan" },
|
|
154
|
+
{ value: "AED", label: "AED - UAE Dirham" },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
// Dummy DSP seats for demo - in production this comes from Admin Console
|
|
158
|
+
// Seat IDs are 6-7 digit numbers
|
|
159
|
+
const DUMMY_DSP_SEATS: { dspId: string; seatName: string; seatId: string }[] = [
|
|
160
|
+
{ dspId: "dsp-activate-default", seatName: "Activate Primary", seatId: "123456" },
|
|
161
|
+
{ dspId: "dsp-activate-default", seatName: "Activate Agency Desk", seatId: "234567" },
|
|
162
|
+
{ dspId: "dsp-activate-default", seatName: "Activate Self-Serve", seatId: "3456789" },
|
|
163
|
+
{ dspId: "dsp-1", seatName: "Main Trading Desk", seatId: "456789" },
|
|
164
|
+
{ dspId: "dsp-1", seatName: "Secondary Desk", seatId: "567890" },
|
|
165
|
+
{ dspId: "dsp-2", seatName: "Primary Account", seatId: "6789012" },
|
|
166
|
+
{ dspId: "dsp-2", seatName: "Agency Account", seatId: "789012" },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const dealFormSchema = z.object({
|
|
170
|
+
name: z.string().min(1, "Deal name is required"),
|
|
171
|
+
status: z.enum(["active", "paused"]).default("active"),
|
|
172
|
+
externalDealId: z.string().optional(),
|
|
173
|
+
billable: z.boolean().default(true),
|
|
174
|
+
mediaType: z.enum(["dooh", "mobile", "youtube", "ctv"]).default("dooh"),
|
|
175
|
+
sspId: z.string().min(1, "SSP Partner is required"),
|
|
176
|
+
dealType: z.enum(["traditional", "pg", "preferred_deal", "pmp", "always_on", "open_auction"]).default("traditional"),
|
|
177
|
+
brandId: z.string().optional(),
|
|
178
|
+
clientType: z.enum(["direct", "agency"]).default("direct"),
|
|
179
|
+
agencyId: z.string().optional(),
|
|
180
|
+
dspId: z.string().optional(),
|
|
181
|
+
seatName: z.string().optional(),
|
|
182
|
+
seatId: z.string().optional(),
|
|
183
|
+
adPlayVerificationEnabled: z.boolean().default(false),
|
|
184
|
+
adPlayVerificationProvider: z.string().optional(),
|
|
185
|
+
countries: z.array(z.string()).min(1, "At least one country is required").default(["MY"]),
|
|
186
|
+
currency: z.string().min(1, "Currency is required").default("USD"),
|
|
187
|
+
budget: z.string().optional(),
|
|
188
|
+
goalType: z.string().optional(),
|
|
189
|
+
goalValue: z.string().optional(),
|
|
190
|
+
minimumTargetThreshold: z.string().optional(),
|
|
191
|
+
hardStop: z.boolean().default(false),
|
|
192
|
+
}).refine((data) => {
|
|
193
|
+
if (data.adPlayVerificationEnabled && !data.adPlayVerificationProvider) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
}, {
|
|
198
|
+
message: "Service provider is required when AdPlay Verification is enabled",
|
|
199
|
+
path: ["adPlayVerificationProvider"],
|
|
200
|
+
}).refine((data) => {
|
|
201
|
+
if (data.clientType === "agency" && !data.agencyId) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}, {
|
|
206
|
+
message: "Please select an agency",
|
|
207
|
+
path: ["agencyId"],
|
|
208
|
+
}).refine((data) => {
|
|
209
|
+
if (data.dealType === "pg" && !data.minimumTargetThreshold) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}, {
|
|
214
|
+
message: "Minimum Target Threshold is required for Programmatic Guaranteed deals",
|
|
215
|
+
path: ["minimumTargetThreshold"],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
type DealFormData = z.infer<typeof dealFormSchema>;
|
|
219
|
+
|
|
220
|
+
const statusOptions: ComboboxOption[] = [
|
|
221
|
+
{ value: "active", label: "Active" },
|
|
222
|
+
{ value: "paused", label: "Paused" },
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
const clientTypeOptions: ComboboxOption[] = [
|
|
226
|
+
{ value: "direct", label: "Direct Advertiser" },
|
|
227
|
+
{ value: "agency", label: "Agency" },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const mediaTypeOptions: ComboboxOption[] = MEDIA_TYPES.map((mt) => ({
|
|
231
|
+
value: mt.value,
|
|
232
|
+
label: mt.label,
|
|
233
|
+
disabled: mt.disabled,
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
const dealTypeOptions: ComboboxOption[] = [
|
|
237
|
+
{ value: "traditional", label: "Traditional" },
|
|
238
|
+
{ value: "programmatic", label: "Programmatic" },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const programmaticSubtypeOptions: ComboboxOption[] = [
|
|
242
|
+
{ value: "pg", label: "Programmatic Guaranteed" },
|
|
243
|
+
{ value: "preferred_deal", label: "Preferred Deal" },
|
|
244
|
+
{ value: "pmp", label: "Private Marketplace (PMP)" },
|
|
245
|
+
{ value: "always_on", label: "Always On" },
|
|
246
|
+
{ value: "open_auction", label: "Open Auction" },
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const providerOptions: ComboboxOption[] = ADPLAY_VERIFICATION_PROVIDERS.map((provider) => ({
|
|
250
|
+
value: provider,
|
|
251
|
+
label: provider,
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
// Currency options will be computed dynamically based on selected countries
|
|
255
|
+
|
|
256
|
+
const goalTypeOptions: ComboboxOption[] = GOAL_TYPES.map((gt) => ({
|
|
257
|
+
value: gt.value,
|
|
258
|
+
label: gt.label,
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
export default function DealForm() {
|
|
262
|
+
const [, setLocation] = useLocation();
|
|
263
|
+
const [matchNew] = useRoute("/deals/new");
|
|
264
|
+
const [matchEdit, params] = useRoute("/deals/:id/edit");
|
|
265
|
+
const { toast } = useToast();
|
|
266
|
+
|
|
267
|
+
const isEditing = Boolean(matchEdit);
|
|
268
|
+
const dealId = params?.id;
|
|
269
|
+
|
|
270
|
+
const { data: existingDeal, isLoading: dealLoading } = useQuery<Deal>({
|
|
271
|
+
queryKey: ["/api/deals", dealId],
|
|
272
|
+
enabled: isEditing && Boolean(dealId),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const { data: brands } = useQuery<Brand[]>({
|
|
276
|
+
queryKey: ["/api/brands"],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { data: agencies } = useQuery<Agency[]>({
|
|
280
|
+
queryKey: ["/api/agencies"],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const { data: sspPartners } = useQuery<SspPartner[]>({
|
|
284
|
+
queryKey: ["/api/ssp-partners"],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const { data: dspPartners } = useQuery<DspPartner[]>({
|
|
288
|
+
queryKey: ["/api/dsp-partners"],
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const brandOptions: ComboboxOption[] = useMemo(() => {
|
|
292
|
+
return (brands || []).map((brand) => ({
|
|
293
|
+
value: brand.id,
|
|
294
|
+
label: brand.name,
|
|
295
|
+
}));
|
|
296
|
+
}, [brands]);
|
|
297
|
+
|
|
298
|
+
const agencyOptions: ComboboxOption[] = useMemo(() => {
|
|
299
|
+
return (agencies || []).map((agency) => {
|
|
300
|
+
const countryName = agency.country
|
|
301
|
+
? COUNTRIES.find(c => c.code === agency.country)?.name || agency.country
|
|
302
|
+
: undefined;
|
|
303
|
+
return {
|
|
304
|
+
value: agency.id,
|
|
305
|
+
label: agency.name,
|
|
306
|
+
description: countryName,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}, [agencies]);
|
|
310
|
+
|
|
311
|
+
const sspOptions: ComboboxOption[] = useMemo(() => {
|
|
312
|
+
return (sspPartners || []).map((ssp) => ({
|
|
313
|
+
value: ssp.id,
|
|
314
|
+
label: ssp.name,
|
|
315
|
+
}));
|
|
316
|
+
}, [sspPartners]);
|
|
317
|
+
|
|
318
|
+
const dspOptions: ComboboxOption[] = useMemo(() => {
|
|
319
|
+
return (dspPartners || []).map((dsp) => ({
|
|
320
|
+
value: dsp.id,
|
|
321
|
+
label: dsp.name,
|
|
322
|
+
}));
|
|
323
|
+
}, [dspPartners]);
|
|
324
|
+
|
|
325
|
+
// Generate default deal name with format Deal_Jan_23_26_001
|
|
326
|
+
const generateDealName = () => {
|
|
327
|
+
const now = new Date();
|
|
328
|
+
const month = now.toLocaleDateString("en-US", { month: "short" });
|
|
329
|
+
const day = now.getDate().toString().padStart(2, "0");
|
|
330
|
+
const year = now.getFullYear().toString().slice(-2);
|
|
331
|
+
return `Deal_${month}_${day}_${year}_001`;
|
|
332
|
+
};
|
|
333
|
+
const defaultDealName = isEditing ? "" : generateDealName();
|
|
334
|
+
|
|
335
|
+
const form = useForm<DealFormData>({
|
|
336
|
+
resolver: zodResolver(dealFormSchema),
|
|
337
|
+
defaultValues: {
|
|
338
|
+
name: defaultDealName,
|
|
339
|
+
status: "active",
|
|
340
|
+
externalDealId: "",
|
|
341
|
+
billable: true,
|
|
342
|
+
mediaType: "dooh",
|
|
343
|
+
sspId: "ssp-influence-default",
|
|
344
|
+
dealType: "traditional",
|
|
345
|
+
brandId: "",
|
|
346
|
+
clientType: "direct",
|
|
347
|
+
agencyId: "",
|
|
348
|
+
dspId: "dsp-activate-default",
|
|
349
|
+
seatName: "",
|
|
350
|
+
seatId: "",
|
|
351
|
+
adPlayVerificationEnabled: false,
|
|
352
|
+
adPlayVerificationProvider: "",
|
|
353
|
+
countries: ["MY"],
|
|
354
|
+
currency: "USD",
|
|
355
|
+
budget: "",
|
|
356
|
+
goalType: "",
|
|
357
|
+
goalValue: "",
|
|
358
|
+
minimumTargetThreshold: "",
|
|
359
|
+
hardStop: false,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Watch fields for conditional logic
|
|
364
|
+
const watchedClientType = useWatch({ control: form.control, name: "clientType" });
|
|
365
|
+
const watchedDealType = useWatch({ control: form.control, name: "dealType" });
|
|
366
|
+
const watchedBrandId = useWatch({ control: form.control, name: "brandId" });
|
|
367
|
+
const watchedAgencyId = useWatch({ control: form.control, name: "agencyId" });
|
|
368
|
+
const watchedDspId = useWatch({ control: form.control, name: "dspId" });
|
|
369
|
+
const watchedSspId = useWatch({ control: form.control, name: "sspId" });
|
|
370
|
+
const watchedCountries = useWatch({ control: form.control, name: "countries" });
|
|
371
|
+
const watchedSeatName = useWatch({ control: form.control, name: "seatName" });
|
|
372
|
+
const watchedCurrency = useWatch({ control: form.control, name: "currency" });
|
|
373
|
+
const adPlayVerificationEnabled = useWatch({ control: form.control, name: "adPlayVerificationEnabled" });
|
|
374
|
+
|
|
375
|
+
// Check if deal type is programmatic
|
|
376
|
+
const isProgrammatic = watchedDealType !== "traditional";
|
|
377
|
+
const isPG = watchedDealType === "pg";
|
|
378
|
+
|
|
379
|
+
// Show DSP fields when programmatic AND (agency or brand selected)
|
|
380
|
+
const showDspFields = isProgrammatic && (watchedAgencyId || watchedBrandId);
|
|
381
|
+
|
|
382
|
+
// Get seat options for selected DSP
|
|
383
|
+
const seatOptions: ComboboxOption[] = useMemo(() => {
|
|
384
|
+
if (!watchedDspId) return [];
|
|
385
|
+
return DUMMY_DSP_SEATS
|
|
386
|
+
.filter((seat) => seat.dspId === watchedDspId)
|
|
387
|
+
.map((seat) => ({
|
|
388
|
+
value: seat.seatName,
|
|
389
|
+
label: seat.seatName,
|
|
390
|
+
}));
|
|
391
|
+
}, [watchedDspId]);
|
|
392
|
+
|
|
393
|
+
// Get seat ID for selected seat name
|
|
394
|
+
const selectedSeatId = useMemo(() => {
|
|
395
|
+
if (!watchedDspId || !watchedSeatName) return "";
|
|
396
|
+
const seat = DUMMY_DSP_SEATS.find(
|
|
397
|
+
(s) => s.dspId === watchedDspId && s.seatName === watchedSeatName
|
|
398
|
+
);
|
|
399
|
+
return seat?.seatId || "";
|
|
400
|
+
}, [watchedDspId, watchedSeatName]);
|
|
401
|
+
|
|
402
|
+
// Update seat ID when seat name changes
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
form.setValue("seatId", selectedSeatId);
|
|
405
|
+
}, [selectedSeatId, form]);
|
|
406
|
+
|
|
407
|
+
// Update currency when countries change (use first country's currency)
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (watchedCountries && watchedCountries.length > 0 && !isEditing) {
|
|
410
|
+
const firstCountry = watchedCountries[0];
|
|
411
|
+
const currency = COUNTRY_CURRENCY_MAP[firstCountry] || "USD";
|
|
412
|
+
form.setValue("currency", currency);
|
|
413
|
+
}
|
|
414
|
+
}, [watchedCountries, form, isEditing]);
|
|
415
|
+
|
|
416
|
+
// State for countries popover
|
|
417
|
+
const [countriesOpen, setCountriesOpen] = useState(false);
|
|
418
|
+
|
|
419
|
+
// State for create brand/agency drawer
|
|
420
|
+
const [brandDrawerOpen, setBrandDrawerOpen] = useState(false);
|
|
421
|
+
const [agencyDrawerOpen, setAgencyDrawerOpen] = useState(false);
|
|
422
|
+
|
|
423
|
+
// State for deal type selection
|
|
424
|
+
const [dealTypeCategory, setDealTypeCategory] = useState<"traditional" | "programmatic">("traditional");
|
|
425
|
+
|
|
426
|
+
// State for tracking visible section for Quick Tips
|
|
427
|
+
const [visibleSection, setVisibleSection] = useState<TipSection>("basic");
|
|
428
|
+
|
|
429
|
+
// Refs for section tracking
|
|
430
|
+
const basicSectionRef = useRef<HTMLDivElement>(null);
|
|
431
|
+
const sspSectionRef = useRef<HTMLDivElement>(null);
|
|
432
|
+
const brandSectionRef = useRef<HTMLDivElement>(null);
|
|
433
|
+
const budgetSectionRef = useRef<HTMLDivElement>(null);
|
|
434
|
+
const formContainerRef = useRef<HTMLDivElement>(null);
|
|
435
|
+
|
|
436
|
+
// IntersectionObserver for tracking visible sections
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
const options = {
|
|
439
|
+
root: formContainerRef.current,
|
|
440
|
+
rootMargin: "-20% 0px -60% 0px",
|
|
441
|
+
threshold: 0,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const observer = new IntersectionObserver((entries) => {
|
|
445
|
+
entries.forEach((entry) => {
|
|
446
|
+
if (entry.isIntersecting) {
|
|
447
|
+
const sectionId = entry.target.getAttribute("data-section");
|
|
448
|
+
if (sectionId) {
|
|
449
|
+
setVisibleSection(sectionId as TipSection);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}, options);
|
|
454
|
+
|
|
455
|
+
const sections = [basicSectionRef.current, sspSectionRef.current, brandSectionRef.current, budgetSectionRef.current];
|
|
456
|
+
sections.forEach((section) => {
|
|
457
|
+
if (section) observer.observe(section);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return () => observer.disconnect();
|
|
461
|
+
}, []);
|
|
462
|
+
|
|
463
|
+
// Find the selected brand for insights panel
|
|
464
|
+
const selectedBrand = useMemo(() => {
|
|
465
|
+
if (!watchedBrandId || !brands) return null;
|
|
466
|
+
return brands.find((b) => b.id === watchedBrandId) || null;
|
|
467
|
+
}, [watchedBrandId, brands]);
|
|
468
|
+
|
|
469
|
+
// Dynamic currency options based on selected countries
|
|
470
|
+
const currencyOptions: ComboboxOption[] = useMemo(() => {
|
|
471
|
+
if (!watchedCountries || watchedCountries.length === 0) {
|
|
472
|
+
// Show all currencies when no countries selected
|
|
473
|
+
return CURRENCIES.map((c) => ({ value: c.value, label: c.label }));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Get unique currencies from selected countries
|
|
477
|
+
const countryCurrencies = new Set<string>();
|
|
478
|
+
watchedCountries.forEach((countryCode) => {
|
|
479
|
+
const currency = COUNTRY_CURRENCY_MAP[countryCode];
|
|
480
|
+
if (currency) {
|
|
481
|
+
countryCurrencies.add(currency);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Always include USD as a fallback option
|
|
486
|
+
countryCurrencies.add("USD");
|
|
487
|
+
|
|
488
|
+
// Filter CURRENCIES to only include those from selected countries
|
|
489
|
+
return CURRENCIES
|
|
490
|
+
.filter((c) => countryCurrencies.has(c.value))
|
|
491
|
+
.map((c) => ({ value: c.value, label: c.label }));
|
|
492
|
+
}, [watchedCountries]);
|
|
493
|
+
|
|
494
|
+
// Check if selected currency differs from any selected country's currency (for margin warning)
|
|
495
|
+
const currencyMismatchWarning = useMemo(() => {
|
|
496
|
+
if (!watchedCurrency || !watchedCountries || watchedCountries.length === 0) return null;
|
|
497
|
+
|
|
498
|
+
const mismatchedCountries: string[] = [];
|
|
499
|
+
watchedCountries.forEach((countryCode) => {
|
|
500
|
+
const countryCurrency = COUNTRY_CURRENCY_MAP[countryCode];
|
|
501
|
+
if (countryCurrency && countryCurrency !== watchedCurrency) {
|
|
502
|
+
const country = COUNTRIES.find((c) => c.code === countryCode);
|
|
503
|
+
if (country) {
|
|
504
|
+
mismatchedCountries.push(country.name);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
if (mismatchedCountries.length === 0) return null;
|
|
510
|
+
return mismatchedCountries;
|
|
511
|
+
}, [watchedCurrency, watchedCountries]);
|
|
512
|
+
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
if (existingDeal) {
|
|
515
|
+
const isProgDeal = existingDeal.dealType !== "traditional";
|
|
516
|
+
setDealTypeCategory(isProgDeal ? "programmatic" : "traditional");
|
|
517
|
+
|
|
518
|
+
form.reset({
|
|
519
|
+
name: existingDeal.name,
|
|
520
|
+
status: (existingDeal.status as "active" | "paused") ?? "active",
|
|
521
|
+
externalDealId: existingDeal.externalDealId ?? "",
|
|
522
|
+
billable: existingDeal.billable ?? true,
|
|
523
|
+
mediaType: (existingDeal.mediaType as "dooh" | "mobile" | "youtube" | "ctv") ?? "dooh",
|
|
524
|
+
sspId: existingDeal.sspId ?? "ssp-influence-default",
|
|
525
|
+
dealType: (existingDeal.dealType as any) ?? "traditional",
|
|
526
|
+
brandId: existingDeal.brandId ?? "",
|
|
527
|
+
clientType: (existingDeal.clientType as "direct" | "agency") ?? "direct",
|
|
528
|
+
agencyId: existingDeal.agencyId ?? "",
|
|
529
|
+
dspId: existingDeal.dspId ?? "dsp-activate-default",
|
|
530
|
+
seatName: existingDeal.seatName ?? "",
|
|
531
|
+
seatId: existingDeal.seatId ?? "",
|
|
532
|
+
adPlayVerificationEnabled: existingDeal.adPlayVerificationEnabled ?? false,
|
|
533
|
+
adPlayVerificationProvider: existingDeal.adPlayVerificationProvider ?? "",
|
|
534
|
+
countries: existingDeal.countries ?? ["MY"],
|
|
535
|
+
currency: existingDeal.currency ?? "USD",
|
|
536
|
+
budget: existingDeal.budget ? String(existingDeal.budget) : "",
|
|
537
|
+
goalType: existingDeal.goalType ?? "",
|
|
538
|
+
goalValue: existingDeal.goalValue ? String(existingDeal.goalValue) : "",
|
|
539
|
+
minimumTargetThreshold: existingDeal.minimumTargetThreshold ? String(existingDeal.minimumTargetThreshold) : "",
|
|
540
|
+
hardStop: existingDeal.hardStop ?? false,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}, [existingDeal, form]);
|
|
544
|
+
|
|
545
|
+
const createMutation = useMutation({
|
|
546
|
+
mutationFn: async (data: DealFormData) => {
|
|
547
|
+
return apiRequest("POST", "/api/deals", {
|
|
548
|
+
...data,
|
|
549
|
+
brandId: data.brandId || undefined,
|
|
550
|
+
agencyId: data.agencyId || undefined,
|
|
551
|
+
|
|
552
|
+
dspId: data.dspId || undefined,
|
|
553
|
+
countries: data.countries.length > 0 ? data.countries : undefined,
|
|
554
|
+
budget: data.budget ? parseFloat(data.budget) : undefined,
|
|
555
|
+
goalValue: data.goalValue ? parseInt(data.goalValue) : undefined,
|
|
556
|
+
minimumTargetThreshold: data.minimumTargetThreshold ? parseInt(data.minimumTargetThreshold) : undefined,
|
|
557
|
+
adPlayVerificationProvider: data.adPlayVerificationEnabled ? data.adPlayVerificationProvider : undefined,
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
onSuccess: () => {
|
|
561
|
+
queryClient.invalidateQueries({ queryKey: ["/api/deals"] });
|
|
562
|
+
toast({
|
|
563
|
+
title: "Deal created",
|
|
564
|
+
description: "Your deal has been created successfully.",
|
|
565
|
+
});
|
|
566
|
+
setLocation("/deals");
|
|
567
|
+
},
|
|
568
|
+
onError: () => {
|
|
569
|
+
toast({
|
|
570
|
+
title: "Error",
|
|
571
|
+
description: "Failed to create deal. Please try again.",
|
|
572
|
+
variant: "destructive",
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const updateMutation = useMutation({
|
|
578
|
+
mutationFn: async (data: DealFormData) => {
|
|
579
|
+
return apiRequest("PATCH", `/api/deals/${dealId}`, {
|
|
580
|
+
...data,
|
|
581
|
+
brandId: data.brandId || undefined,
|
|
582
|
+
agencyId: data.agencyId || undefined,
|
|
583
|
+
|
|
584
|
+
dspId: data.dspId || undefined,
|
|
585
|
+
countries: data.countries.length > 0 ? data.countries : undefined,
|
|
586
|
+
budget: data.budget ? parseFloat(data.budget) : undefined,
|
|
587
|
+
goalValue: data.goalValue ? parseInt(data.goalValue) : undefined,
|
|
588
|
+
minimumTargetThreshold: data.minimumTargetThreshold ? parseInt(data.minimumTargetThreshold) : undefined,
|
|
589
|
+
adPlayVerificationProvider: data.adPlayVerificationEnabled ? data.adPlayVerificationProvider : undefined,
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
onSuccess: () => {
|
|
593
|
+
queryClient.invalidateQueries({ queryKey: ["/api/deals"] });
|
|
594
|
+
queryClient.invalidateQueries({ queryKey: ["/api/deals", dealId] });
|
|
595
|
+
toast({
|
|
596
|
+
title: "Deal updated",
|
|
597
|
+
description: "Your deal has been updated successfully.",
|
|
598
|
+
});
|
|
599
|
+
setLocation("/deals");
|
|
600
|
+
},
|
|
601
|
+
onError: () => {
|
|
602
|
+
toast({
|
|
603
|
+
title: "Error",
|
|
604
|
+
description: "Failed to update deal. Please try again.",
|
|
605
|
+
variant: "destructive",
|
|
606
|
+
});
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const onSubmit = (data: DealFormData) => {
|
|
611
|
+
if (isEditing) {
|
|
612
|
+
updateMutation.mutate(data);
|
|
613
|
+
} else {
|
|
614
|
+
createMutation.mutate(data);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const handleCancel = () => {
|
|
619
|
+
setLocation("/deals");
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const isSubmitting = createMutation.isPending || updateMutation.isPending;
|
|
623
|
+
const isLoadingData = isEditing && dealLoading;
|
|
624
|
+
|
|
625
|
+
if (isLoadingData) {
|
|
626
|
+
return (
|
|
627
|
+
<div className="flex flex-col gap-6 p-6">
|
|
628
|
+
<PageHeader
|
|
629
|
+
title={isEditing ? "Edit Deal" : "New Deal"}
|
|
630
|
+
description={isEditing ? "Update deal details" : "Create a new advertising deal"}
|
|
631
|
+
/>
|
|
632
|
+
<Card>
|
|
633
|
+
<CardContent className="pt-6 space-y-6">
|
|
634
|
+
<Skeleton className="h-10 w-full" />
|
|
635
|
+
<Skeleton className="h-10 w-full" />
|
|
636
|
+
<Skeleton className="h-10 w-full" />
|
|
637
|
+
<Skeleton className="h-6 w-48" />
|
|
638
|
+
<Skeleton className="h-10 w-full" />
|
|
639
|
+
</CardContent>
|
|
640
|
+
</Card>
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return (
|
|
646
|
+
<div className="flex flex-col min-h-0 h-full">
|
|
647
|
+
<div ref={formContainerRef} className="flex-1 min-h-0 overflow-auto">
|
|
648
|
+
<div className="flex flex-col gap-6 p-6 pb-6">
|
|
649
|
+
<PageHeader
|
|
650
|
+
title={isEditing ? "Edit Deal" : "New Deal"}
|
|
651
|
+
description={isEditing ? "Update deal details" : "Create a new advertising deal"}
|
|
652
|
+
actions={
|
|
653
|
+
<Button
|
|
654
|
+
variant="outline"
|
|
655
|
+
onClick={handleCancel}
|
|
656
|
+
data-testid="button-back-deals"
|
|
657
|
+
>
|
|
658
|
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
659
|
+
Back to Deals
|
|
660
|
+
</Button>
|
|
661
|
+
}
|
|
662
|
+
/>
|
|
663
|
+
|
|
664
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
665
|
+
<div className="lg:col-span-2">
|
|
666
|
+
<Card>
|
|
667
|
+
<CardContent className="pt-6">
|
|
668
|
+
<div className="flex items-center gap-3 mb-6">
|
|
669
|
+
<div className="p-2 rounded-lg bg-muted">
|
|
670
|
+
<User className="h-5 w-5 text-muted-foreground" />
|
|
671
|
+
</div>
|
|
672
|
+
<div>
|
|
673
|
+
<h3 className="font-semibold">Deal Details</h3>
|
|
674
|
+
<p className="text-sm text-muted-foreground">Set up your deal information</p>
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
<Form {...form}>
|
|
678
|
+
<form id="deal-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
679
|
+
{/* Section: Basic Info (Name, Status, External ID, Billable, Media Type) */}
|
|
680
|
+
<div ref={basicSectionRef} data-section="basic" className="space-y-6">
|
|
681
|
+
{/* Row 1: Name + Status */}
|
|
682
|
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
683
|
+
<FormField
|
|
684
|
+
control={form.control}
|
|
685
|
+
name="name"
|
|
686
|
+
render={({ field }) => (
|
|
687
|
+
<FormItem className="md:col-span-3">
|
|
688
|
+
<FormLabel>Deal Name <span className="text-destructive">*</span></FormLabel>
|
|
689
|
+
<FormControl>
|
|
690
|
+
<Input
|
|
691
|
+
placeholder="Enter deal name"
|
|
692
|
+
{...field}
|
|
693
|
+
data-testid="input-deal-name"
|
|
694
|
+
/>
|
|
695
|
+
</FormControl>
|
|
696
|
+
<FormMessage />
|
|
697
|
+
</FormItem>
|
|
698
|
+
)}
|
|
699
|
+
/>
|
|
700
|
+
|
|
701
|
+
<FormField
|
|
702
|
+
control={form.control}
|
|
703
|
+
name="status"
|
|
704
|
+
render={({ field }) => (
|
|
705
|
+
<FormItem>
|
|
706
|
+
<FormLabel>Status</FormLabel>
|
|
707
|
+
<FormControl>
|
|
708
|
+
<SearchableCombobox
|
|
709
|
+
options={statusOptions}
|
|
710
|
+
value={field.value}
|
|
711
|
+
onValueChange={field.onChange}
|
|
712
|
+
placeholder="Select..."
|
|
713
|
+
searchPlaceholder="Search..."
|
|
714
|
+
emptyMessage="No status found."
|
|
715
|
+
disabled={existingDeal?.reopened ?? false}
|
|
716
|
+
data-testid="combobox-status"
|
|
717
|
+
/>
|
|
718
|
+
</FormControl>
|
|
719
|
+
{existingDeal?.reopened && (
|
|
720
|
+
<p className="text-xs text-muted-foreground">Status cannot be changed after reopening</p>
|
|
721
|
+
)}
|
|
722
|
+
<FormMessage />
|
|
723
|
+
</FormItem>
|
|
724
|
+
)}
|
|
725
|
+
/>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
{/* Row 2: External Deal ID + Billable */}
|
|
729
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
730
|
+
<FormField
|
|
731
|
+
control={form.control}
|
|
732
|
+
name="externalDealId"
|
|
733
|
+
render={({ field }) => (
|
|
734
|
+
<FormItem>
|
|
735
|
+
<FormLabel>External Deal ID</FormLabel>
|
|
736
|
+
<FormControl>
|
|
737
|
+
<Input
|
|
738
|
+
placeholder="Enter external deal ID"
|
|
739
|
+
{...field}
|
|
740
|
+
disabled={existingDeal?.reopened ?? false}
|
|
741
|
+
data-testid="input-external-deal-id"
|
|
742
|
+
/>
|
|
743
|
+
</FormControl>
|
|
744
|
+
<FormMessage />
|
|
745
|
+
</FormItem>
|
|
746
|
+
)}
|
|
747
|
+
/>
|
|
748
|
+
|
|
749
|
+
<FormField
|
|
750
|
+
control={form.control}
|
|
751
|
+
name="billable"
|
|
752
|
+
render={({ field }) => (
|
|
753
|
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
754
|
+
<div className="space-y-0.5">
|
|
755
|
+
<FormLabel className="text-base">Billable</FormLabel>
|
|
756
|
+
<FormDescription>
|
|
757
|
+
Mark this deal as billable
|
|
758
|
+
</FormDescription>
|
|
759
|
+
</div>
|
|
760
|
+
<FormControl>
|
|
761
|
+
<Switch
|
|
762
|
+
checked={field.value}
|
|
763
|
+
onCheckedChange={field.onChange}
|
|
764
|
+
disabled={existingDeal?.reopened ?? false}
|
|
765
|
+
data-testid="switch-billable"
|
|
766
|
+
/>
|
|
767
|
+
</FormControl>
|
|
768
|
+
</FormItem>
|
|
769
|
+
)}
|
|
770
|
+
/>
|
|
771
|
+
</div>
|
|
772
|
+
|
|
773
|
+
{/* Media Type */}
|
|
774
|
+
<FormField
|
|
775
|
+
control={form.control}
|
|
776
|
+
name="mediaType"
|
|
777
|
+
render={({ field }) => (
|
|
778
|
+
<FormItem>
|
|
779
|
+
<FormLabel>Media Type <span className="text-destructive">*</span></FormLabel>
|
|
780
|
+
<FormControl>
|
|
781
|
+
<SearchableCombobox
|
|
782
|
+
options={mediaTypeOptions}
|
|
783
|
+
value={field.value}
|
|
784
|
+
onValueChange={field.onChange}
|
|
785
|
+
placeholder="Select media type..."
|
|
786
|
+
searchPlaceholder="Search media types..."
|
|
787
|
+
emptyMessage="No media types found."
|
|
788
|
+
disabled={existingDeal?.reopened ?? false}
|
|
789
|
+
data-testid="combobox-media-type"
|
|
790
|
+
/>
|
|
791
|
+
</FormControl>
|
|
792
|
+
<FormMessage />
|
|
793
|
+
</FormItem>
|
|
794
|
+
)}
|
|
795
|
+
/>
|
|
796
|
+
|
|
797
|
+
</div>
|
|
798
|
+
{/* End of Basic Section */}
|
|
799
|
+
|
|
800
|
+
{/* Section: SSP and Deal Type */}
|
|
801
|
+
<div ref={sspSectionRef} data-section="ssp" className="space-y-6">
|
|
802
|
+
{/* SSP Partner */}
|
|
803
|
+
<FormField
|
|
804
|
+
control={form.control}
|
|
805
|
+
name="sspId"
|
|
806
|
+
render={({ field }) => (
|
|
807
|
+
<FormItem>
|
|
808
|
+
<FormLabel>SSP Partner <span className="text-destructive">*</span></FormLabel>
|
|
809
|
+
<FormControl>
|
|
810
|
+
<SearchableCombobox
|
|
811
|
+
options={sspOptions}
|
|
812
|
+
value={field.value}
|
|
813
|
+
onValueChange={field.onChange}
|
|
814
|
+
placeholder="Select SSP partner..."
|
|
815
|
+
searchPlaceholder="Search SSP partners..."
|
|
816
|
+
emptyMessage="No SSP partners found."
|
|
817
|
+
disabled={existingDeal?.reopened ?? false}
|
|
818
|
+
data-testid="combobox-ssp"
|
|
819
|
+
/>
|
|
820
|
+
</FormControl>
|
|
821
|
+
<FormMessage />
|
|
822
|
+
</FormItem>
|
|
823
|
+
)}
|
|
824
|
+
/>
|
|
825
|
+
|
|
826
|
+
{/* Deal Type */}
|
|
827
|
+
<div className="space-y-4">
|
|
828
|
+
<FormItem>
|
|
829
|
+
<FormLabel>Deal Type <span className="text-destructive">*</span></FormLabel>
|
|
830
|
+
{existingDeal?.reopened ? (
|
|
831
|
+
<div className="space-y-2">
|
|
832
|
+
<div className="flex items-center gap-2">
|
|
833
|
+
<Badge variant="outline" className="text-sm">
|
|
834
|
+
{dealTypeCategory === "traditional" ? "Traditional" : "Programmatic"}
|
|
835
|
+
{dealTypeCategory === "programmatic" && watchedDealType !== "traditional" && (
|
|
836
|
+
<span className="ml-1">({DEAL_TYPES.find(dt => dt.value === watchedDealType)?.label || watchedDealType})</span>
|
|
837
|
+
)}
|
|
838
|
+
</Badge>
|
|
839
|
+
<Badge variant="secondary" className="text-xs">From Deal</Badge>
|
|
840
|
+
</div>
|
|
841
|
+
<p className="text-sm text-muted-foreground">
|
|
842
|
+
Deal type cannot be changed after the deal has been reopened.
|
|
843
|
+
</p>
|
|
844
|
+
</div>
|
|
845
|
+
) : (
|
|
846
|
+
<div className="flex gap-2">
|
|
847
|
+
<Button
|
|
848
|
+
type="button"
|
|
849
|
+
variant={dealTypeCategory === "traditional" ? "default" : "outline"}
|
|
850
|
+
onClick={() => {
|
|
851
|
+
setDealTypeCategory("traditional");
|
|
852
|
+
form.setValue("dealType", "traditional");
|
|
853
|
+
}}
|
|
854
|
+
data-testid="button-deal-type-traditional"
|
|
855
|
+
>
|
|
856
|
+
Traditional
|
|
857
|
+
</Button>
|
|
858
|
+
<Button
|
|
859
|
+
type="button"
|
|
860
|
+
variant={dealTypeCategory === "programmatic" ? "default" : "outline"}
|
|
861
|
+
onClick={() => {
|
|
862
|
+
setDealTypeCategory("programmatic");
|
|
863
|
+
form.setValue("dealType", "pg");
|
|
864
|
+
}}
|
|
865
|
+
data-testid="button-deal-type-programmatic"
|
|
866
|
+
>
|
|
867
|
+
Programmatic
|
|
868
|
+
</Button>
|
|
869
|
+
</div>
|
|
870
|
+
)}
|
|
871
|
+
</FormItem>
|
|
872
|
+
|
|
873
|
+
{dealTypeCategory === "programmatic" && !existingDeal?.reopened && (
|
|
874
|
+
<FormField
|
|
875
|
+
control={form.control}
|
|
876
|
+
name="dealType"
|
|
877
|
+
render={({ field }) => (
|
|
878
|
+
<FormItem>
|
|
879
|
+
<FormLabel>Programmatic Type</FormLabel>
|
|
880
|
+
<FormControl>
|
|
881
|
+
<SearchableCombobox
|
|
882
|
+
options={programmaticSubtypeOptions}
|
|
883
|
+
value={field.value}
|
|
884
|
+
onValueChange={field.onChange}
|
|
885
|
+
placeholder="Select programmatic type..."
|
|
886
|
+
searchPlaceholder="Search..."
|
|
887
|
+
emptyMessage="No types found."
|
|
888
|
+
data-testid="combobox-programmatic-type"
|
|
889
|
+
/>
|
|
890
|
+
</FormControl>
|
|
891
|
+
<FormMessage />
|
|
892
|
+
</FormItem>
|
|
893
|
+
)}
|
|
894
|
+
/>
|
|
895
|
+
)}
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
{/* End of SSP Section */}
|
|
899
|
+
|
|
900
|
+
{/* Section: Brand and Client */}
|
|
901
|
+
<div ref={brandSectionRef} data-section="brand" className="space-y-6">
|
|
902
|
+
{/* Brand */}
|
|
903
|
+
<FormField
|
|
904
|
+
control={form.control}
|
|
905
|
+
name="brandId"
|
|
906
|
+
render={({ field }) => (
|
|
907
|
+
<FormItem>
|
|
908
|
+
<FormLabel>
|
|
909
|
+
<div className="flex items-center gap-2">
|
|
910
|
+
<Tag className="h-4 w-4" />
|
|
911
|
+
Brand
|
|
912
|
+
</div>
|
|
913
|
+
</FormLabel>
|
|
914
|
+
<FormControl>
|
|
915
|
+
<SearchableCombobox
|
|
916
|
+
options={brandOptions}
|
|
917
|
+
value={field.value}
|
|
918
|
+
onValueChange={field.onChange}
|
|
919
|
+
placeholder="Select brand..."
|
|
920
|
+
searchPlaceholder="Search brands..."
|
|
921
|
+
emptyMessage="No brands found."
|
|
922
|
+
disabled={existingDeal?.reopened ?? false}
|
|
923
|
+
data-testid="combobox-brand"
|
|
924
|
+
footer={
|
|
925
|
+
<Button
|
|
926
|
+
type="button"
|
|
927
|
+
variant="ghost"
|
|
928
|
+
size="sm"
|
|
929
|
+
className="w-full justify-start"
|
|
930
|
+
data-testid="button-create-brand"
|
|
931
|
+
>
|
|
932
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
933
|
+
Create New Brand
|
|
934
|
+
</Button>
|
|
935
|
+
}
|
|
936
|
+
onFooterClick={() => setBrandDrawerOpen(true)}
|
|
937
|
+
/>
|
|
938
|
+
</FormControl>
|
|
939
|
+
<FormMessage />
|
|
940
|
+
</FormItem>
|
|
941
|
+
)}
|
|
942
|
+
/>
|
|
943
|
+
|
|
944
|
+
{/* Client Type */}
|
|
945
|
+
<FormField
|
|
946
|
+
control={form.control}
|
|
947
|
+
name="clientType"
|
|
948
|
+
render={({ field }) => (
|
|
949
|
+
<FormItem>
|
|
950
|
+
<FormLabel>
|
|
951
|
+
<div className="flex items-center gap-2">
|
|
952
|
+
<User className="h-4 w-4" />
|
|
953
|
+
Client <span className="text-destructive">*</span>
|
|
954
|
+
</div>
|
|
955
|
+
</FormLabel>
|
|
956
|
+
<FormControl>
|
|
957
|
+
<SearchableCombobox
|
|
958
|
+
options={clientTypeOptions}
|
|
959
|
+
value={field.value}
|
|
960
|
+
onValueChange={(value) => {
|
|
961
|
+
field.onChange(value);
|
|
962
|
+
if (value === "direct") {
|
|
963
|
+
form.setValue("agencyId", "");
|
|
964
|
+
}
|
|
965
|
+
}}
|
|
966
|
+
placeholder="Select client type..."
|
|
967
|
+
searchPlaceholder="Search..."
|
|
968
|
+
emptyMessage="No options found."
|
|
969
|
+
disabled={existingDeal?.reopened ?? false}
|
|
970
|
+
data-testid="combobox-client-type"
|
|
971
|
+
/>
|
|
972
|
+
</FormControl>
|
|
973
|
+
<FormMessage />
|
|
974
|
+
</FormItem>
|
|
975
|
+
)}
|
|
976
|
+
/>
|
|
977
|
+
|
|
978
|
+
{watchedClientType === "agency" && (
|
|
979
|
+
<FormField
|
|
980
|
+
control={form.control}
|
|
981
|
+
name="agencyId"
|
|
982
|
+
render={({ field }) => (
|
|
983
|
+
<FormItem>
|
|
984
|
+
<FormLabel>
|
|
985
|
+
<div className="flex items-center gap-2">
|
|
986
|
+
<Building2 className="h-4 w-4" />
|
|
987
|
+
Agency
|
|
988
|
+
</div>
|
|
989
|
+
</FormLabel>
|
|
990
|
+
<FormControl>
|
|
991
|
+
<SearchableCombobox
|
|
992
|
+
options={agencyOptions}
|
|
993
|
+
value={field.value}
|
|
994
|
+
onValueChange={field.onChange}
|
|
995
|
+
placeholder="Select agency..."
|
|
996
|
+
searchPlaceholder="Search agencies..."
|
|
997
|
+
emptyMessage="No agencies found."
|
|
998
|
+
disabled={existingDeal?.reopened ?? false}
|
|
999
|
+
data-testid="combobox-agency"
|
|
1000
|
+
footer={
|
|
1001
|
+
<Button
|
|
1002
|
+
type="button"
|
|
1003
|
+
variant="ghost"
|
|
1004
|
+
size="sm"
|
|
1005
|
+
className="w-full justify-start"
|
|
1006
|
+
data-testid="button-create-agency"
|
|
1007
|
+
>
|
|
1008
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
1009
|
+
Add Agency
|
|
1010
|
+
</Button>
|
|
1011
|
+
}
|
|
1012
|
+
onFooterClick={() => setAgencyDrawerOpen(true)}
|
|
1013
|
+
/>
|
|
1014
|
+
</FormControl>
|
|
1015
|
+
<FormMessage />
|
|
1016
|
+
</FormItem>
|
|
1017
|
+
)}
|
|
1018
|
+
/>
|
|
1019
|
+
)}
|
|
1020
|
+
|
|
1021
|
+
{/* DSP Configuration - Show when Programmatic AND (Agency or Brand selected) */}
|
|
1022
|
+
{showDspFields && (
|
|
1023
|
+
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
|
|
1024
|
+
<h4 className="font-medium text-sm">DSP Configuration</h4>
|
|
1025
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
1026
|
+
<FormField
|
|
1027
|
+
control={form.control}
|
|
1028
|
+
name="dspId"
|
|
1029
|
+
render={({ field }) => (
|
|
1030
|
+
<FormItem>
|
|
1031
|
+
<FormLabel>DSP Partner</FormLabel>
|
|
1032
|
+
<FormControl>
|
|
1033
|
+
<SearchableCombobox
|
|
1034
|
+
options={dspOptions}
|
|
1035
|
+
value={field.value}
|
|
1036
|
+
onValueChange={(value) => {
|
|
1037
|
+
field.onChange(value);
|
|
1038
|
+
form.setValue("seatName", "");
|
|
1039
|
+
form.setValue("seatId", "");
|
|
1040
|
+
}}
|
|
1041
|
+
placeholder="Select DSP..."
|
|
1042
|
+
searchPlaceholder="Search DSPs..."
|
|
1043
|
+
emptyMessage="No DSPs found."
|
|
1044
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1045
|
+
data-testid="combobox-dsp"
|
|
1046
|
+
/>
|
|
1047
|
+
</FormControl>
|
|
1048
|
+
<FormMessage />
|
|
1049
|
+
</FormItem>
|
|
1050
|
+
)}
|
|
1051
|
+
/>
|
|
1052
|
+
|
|
1053
|
+
<FormField
|
|
1054
|
+
control={form.control}
|
|
1055
|
+
name="seatName"
|
|
1056
|
+
render={({ field }) => (
|
|
1057
|
+
<FormItem>
|
|
1058
|
+
<FormLabel>Seat Name</FormLabel>
|
|
1059
|
+
<FormControl>
|
|
1060
|
+
<SearchableCombobox
|
|
1061
|
+
options={seatOptions}
|
|
1062
|
+
value={field.value}
|
|
1063
|
+
onValueChange={field.onChange}
|
|
1064
|
+
placeholder="Select seat..."
|
|
1065
|
+
searchPlaceholder="Search seats..."
|
|
1066
|
+
emptyMessage="No seats found."
|
|
1067
|
+
disabled={!watchedDspId || (existingDeal?.reopened ?? false)}
|
|
1068
|
+
data-testid="combobox-seat-name"
|
|
1069
|
+
/>
|
|
1070
|
+
</FormControl>
|
|
1071
|
+
<FormMessage />
|
|
1072
|
+
</FormItem>
|
|
1073
|
+
)}
|
|
1074
|
+
/>
|
|
1075
|
+
|
|
1076
|
+
<FormField
|
|
1077
|
+
control={form.control}
|
|
1078
|
+
name="seatId"
|
|
1079
|
+
render={({ field }) => (
|
|
1080
|
+
<FormItem>
|
|
1081
|
+
<FormLabel>Seat ID</FormLabel>
|
|
1082
|
+
<FormControl>
|
|
1083
|
+
<Input
|
|
1084
|
+
{...field}
|
|
1085
|
+
disabled
|
|
1086
|
+
placeholder="Auto-populated"
|
|
1087
|
+
data-testid="input-seat-id"
|
|
1088
|
+
/>
|
|
1089
|
+
</FormControl>
|
|
1090
|
+
<FormDescription>
|
|
1091
|
+
Fetched from Admin Console
|
|
1092
|
+
</FormDescription>
|
|
1093
|
+
<FormMessage />
|
|
1094
|
+
</FormItem>
|
|
1095
|
+
)}
|
|
1096
|
+
/>
|
|
1097
|
+
</div>
|
|
1098
|
+
</div>
|
|
1099
|
+
)}
|
|
1100
|
+
|
|
1101
|
+
</div>
|
|
1102
|
+
{/* End of Brand Section */}
|
|
1103
|
+
|
|
1104
|
+
{/* Section: Budget, Goals, and Verification */}
|
|
1105
|
+
<div ref={budgetSectionRef} data-section="budget" className="space-y-6">
|
|
1106
|
+
{/* AdPlay Verification */}
|
|
1107
|
+
<FormField
|
|
1108
|
+
control={form.control}
|
|
1109
|
+
name="adPlayVerificationEnabled"
|
|
1110
|
+
render={({ field }) => (
|
|
1111
|
+
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
|
1112
|
+
<FormControl>
|
|
1113
|
+
<Checkbox
|
|
1114
|
+
checked={field.value}
|
|
1115
|
+
onCheckedChange={field.onChange}
|
|
1116
|
+
data-testid="checkbox-adplay-verification"
|
|
1117
|
+
/>
|
|
1118
|
+
</FormControl>
|
|
1119
|
+
<div className="space-y-1 leading-none">
|
|
1120
|
+
<FormLabel>AdPlay Verification Report</FormLabel>
|
|
1121
|
+
</div>
|
|
1122
|
+
</FormItem>
|
|
1123
|
+
)}
|
|
1124
|
+
/>
|
|
1125
|
+
|
|
1126
|
+
{adPlayVerificationEnabled && (
|
|
1127
|
+
<FormField
|
|
1128
|
+
control={form.control}
|
|
1129
|
+
name="adPlayVerificationProvider"
|
|
1130
|
+
render={({ field }) => (
|
|
1131
|
+
<FormItem>
|
|
1132
|
+
<FormLabel>Service Provider</FormLabel>
|
|
1133
|
+
<FormControl>
|
|
1134
|
+
<SearchableCombobox
|
|
1135
|
+
options={providerOptions}
|
|
1136
|
+
value={field.value}
|
|
1137
|
+
onValueChange={field.onChange}
|
|
1138
|
+
placeholder="Select service provider..."
|
|
1139
|
+
searchPlaceholder="Search providers..."
|
|
1140
|
+
emptyMessage="No providers found."
|
|
1141
|
+
data-testid="combobox-service-provider"
|
|
1142
|
+
/>
|
|
1143
|
+
</FormControl>
|
|
1144
|
+
<FormMessage />
|
|
1145
|
+
</FormItem>
|
|
1146
|
+
)}
|
|
1147
|
+
/>
|
|
1148
|
+
)}
|
|
1149
|
+
|
|
1150
|
+
{/* Countries */}
|
|
1151
|
+
<FormField
|
|
1152
|
+
control={form.control}
|
|
1153
|
+
name="countries"
|
|
1154
|
+
render={({ field }) => (
|
|
1155
|
+
<FormItem>
|
|
1156
|
+
<FormLabel>
|
|
1157
|
+
<div className="flex items-center gap-2">
|
|
1158
|
+
<Globe className="h-4 w-4" />
|
|
1159
|
+
Countries <span className="text-destructive">*</span>
|
|
1160
|
+
</div>
|
|
1161
|
+
</FormLabel>
|
|
1162
|
+
<FormDescription>
|
|
1163
|
+
Select countries for this deal. Line items can only target locations within these countries.
|
|
1164
|
+
</FormDescription>
|
|
1165
|
+
<Popover open={countriesOpen} onOpenChange={setCountriesOpen}>
|
|
1166
|
+
<PopoverTrigger asChild>
|
|
1167
|
+
<FormControl>
|
|
1168
|
+
<Button
|
|
1169
|
+
variant="outline"
|
|
1170
|
+
role="combobox"
|
|
1171
|
+
className="w-full justify-between h-auto min-h-10"
|
|
1172
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1173
|
+
data-testid="button-countries-select"
|
|
1174
|
+
>
|
|
1175
|
+
{(field.value || []).length > 0 ? (
|
|
1176
|
+
<div className="flex flex-wrap gap-1">
|
|
1177
|
+
{(field.value || []).slice(0, 3).map((code) => {
|
|
1178
|
+
const country = COUNTRIES.find((c) => c.code === code);
|
|
1179
|
+
return country ? (
|
|
1180
|
+
<Badge
|
|
1181
|
+
key={code}
|
|
1182
|
+
variant="secondary"
|
|
1183
|
+
className="flex items-center gap-1"
|
|
1184
|
+
data-testid={`badge-country-${code}`}
|
|
1185
|
+
>
|
|
1186
|
+
<span>{country.flag}</span>
|
|
1187
|
+
<span>{country.name}</span>
|
|
1188
|
+
<button
|
|
1189
|
+
type="button"
|
|
1190
|
+
className="h-3 w-3 cursor-pointer hover:opacity-70"
|
|
1191
|
+
onClick={(e) => {
|
|
1192
|
+
e.stopPropagation();
|
|
1193
|
+
field.onChange((field.value || []).filter((v) => v !== code));
|
|
1194
|
+
}}
|
|
1195
|
+
data-testid={`button-remove-country-${code}`}
|
|
1196
|
+
>
|
|
1197
|
+
<X className="h-3 w-3" />
|
|
1198
|
+
</button>
|
|
1199
|
+
</Badge>
|
|
1200
|
+
) : null;
|
|
1201
|
+
})}
|
|
1202
|
+
{(field.value || []).length > 3 && (
|
|
1203
|
+
<Badge variant="secondary" data-testid="badge-country-more">
|
|
1204
|
+
+{(field.value || []).length - 3} more
|
|
1205
|
+
</Badge>
|
|
1206
|
+
)}
|
|
1207
|
+
</div>
|
|
1208
|
+
) : (
|
|
1209
|
+
<span className="text-muted-foreground">Select countries...</span>
|
|
1210
|
+
)}
|
|
1211
|
+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
1212
|
+
</Button>
|
|
1213
|
+
</FormControl>
|
|
1214
|
+
</PopoverTrigger>
|
|
1215
|
+
<PopoverContent className="w-[400px] p-0" align="start">
|
|
1216
|
+
<Command>
|
|
1217
|
+
<CommandInput placeholder="Search countries..." data-testid="input-search-countries" />
|
|
1218
|
+
<CommandList>
|
|
1219
|
+
<CommandEmpty>No country found.</CommandEmpty>
|
|
1220
|
+
<CommandGroup>
|
|
1221
|
+
{COUNTRIES.map((country) => {
|
|
1222
|
+
const values = field.value || [];
|
|
1223
|
+
const isSelected = values.includes(country.code);
|
|
1224
|
+
return (
|
|
1225
|
+
<CommandItem
|
|
1226
|
+
key={country.code}
|
|
1227
|
+
value={country.name}
|
|
1228
|
+
onSelect={() => {
|
|
1229
|
+
if (isSelected) {
|
|
1230
|
+
field.onChange(values.filter((v) => v !== country.code));
|
|
1231
|
+
} else {
|
|
1232
|
+
field.onChange([...values, country.code]);
|
|
1233
|
+
}
|
|
1234
|
+
}}
|
|
1235
|
+
data-testid={`option-country-${country.code}`}
|
|
1236
|
+
>
|
|
1237
|
+
<div className="flex items-center gap-2 w-full">
|
|
1238
|
+
<Checkbox checked={isSelected} className="pointer-events-none" />
|
|
1239
|
+
<span>{country.flag}</span>
|
|
1240
|
+
<span>{country.name}</span>
|
|
1241
|
+
</div>
|
|
1242
|
+
</CommandItem>
|
|
1243
|
+
);
|
|
1244
|
+
})}
|
|
1245
|
+
</CommandGroup>
|
|
1246
|
+
</CommandList>
|
|
1247
|
+
</Command>
|
|
1248
|
+
</PopoverContent>
|
|
1249
|
+
</Popover>
|
|
1250
|
+
{(field.value || []).length > 0 && (
|
|
1251
|
+
<Button
|
|
1252
|
+
type="button"
|
|
1253
|
+
variant="ghost"
|
|
1254
|
+
size="sm"
|
|
1255
|
+
onClick={() => field.onChange([])}
|
|
1256
|
+
className="mt-1"
|
|
1257
|
+
data-testid="button-clear-countries"
|
|
1258
|
+
>
|
|
1259
|
+
<X className="h-3 w-3 mr-1" />
|
|
1260
|
+
Clear all
|
|
1261
|
+
</Button>
|
|
1262
|
+
)}
|
|
1263
|
+
<FormMessage />
|
|
1264
|
+
</FormItem>
|
|
1265
|
+
)}
|
|
1266
|
+
/>
|
|
1267
|
+
|
|
1268
|
+
{/* Currency + Budget */}
|
|
1269
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1270
|
+
<FormField
|
|
1271
|
+
control={form.control}
|
|
1272
|
+
name="currency"
|
|
1273
|
+
render={({ field }) => (
|
|
1274
|
+
<FormItem>
|
|
1275
|
+
<FormLabel>
|
|
1276
|
+
<div className="flex items-center gap-2">
|
|
1277
|
+
<DollarSign className="h-4 w-4" />
|
|
1278
|
+
Currency <span className="text-destructive">*</span>
|
|
1279
|
+
</div>
|
|
1280
|
+
</FormLabel>
|
|
1281
|
+
<FormControl>
|
|
1282
|
+
<SearchableCombobox
|
|
1283
|
+
options={currencyOptions}
|
|
1284
|
+
value={field.value}
|
|
1285
|
+
onValueChange={field.onChange}
|
|
1286
|
+
placeholder="Select currency..."
|
|
1287
|
+
searchPlaceholder="Search currencies..."
|
|
1288
|
+
emptyMessage="No currencies found."
|
|
1289
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1290
|
+
data-testid="combobox-currency"
|
|
1291
|
+
/>
|
|
1292
|
+
</FormControl>
|
|
1293
|
+
<FormMessage />
|
|
1294
|
+
{currencyMismatchWarning && currencyMismatchWarning.length > 0 && (
|
|
1295
|
+
<div className="mt-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800" data-testid="currency-margin-warning">
|
|
1296
|
+
<div className="flex items-start gap-2">
|
|
1297
|
+
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
|
1298
|
+
<div className="text-xs">
|
|
1299
|
+
<span className="font-medium text-amber-800 dark:text-amber-300">
|
|
1300
|
+
3% margin applied
|
|
1301
|
+
</span>
|
|
1302
|
+
<span className="text-amber-700 dark:text-amber-400">
|
|
1303
|
+
{" "}for {currencyMismatchWarning.join(", ")} floor rates
|
|
1304
|
+
</span>
|
|
1305
|
+
</div>
|
|
1306
|
+
</div>
|
|
1307
|
+
</div>
|
|
1308
|
+
)}
|
|
1309
|
+
</FormItem>
|
|
1310
|
+
)}
|
|
1311
|
+
/>
|
|
1312
|
+
|
|
1313
|
+
<FormField
|
|
1314
|
+
control={form.control}
|
|
1315
|
+
name="budget"
|
|
1316
|
+
render={({ field }) => (
|
|
1317
|
+
<FormItem>
|
|
1318
|
+
<FormLabel>Budget</FormLabel>
|
|
1319
|
+
<FormControl>
|
|
1320
|
+
<Input
|
|
1321
|
+
type="number"
|
|
1322
|
+
placeholder="Enter budget amount"
|
|
1323
|
+
{...field}
|
|
1324
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1325
|
+
data-testid="input-budget"
|
|
1326
|
+
/>
|
|
1327
|
+
</FormControl>
|
|
1328
|
+
<FormMessage />
|
|
1329
|
+
</FormItem>
|
|
1330
|
+
)}
|
|
1331
|
+
/>
|
|
1332
|
+
</div>
|
|
1333
|
+
|
|
1334
|
+
{/* Goal */}
|
|
1335
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1336
|
+
<FormField
|
|
1337
|
+
control={form.control}
|
|
1338
|
+
name="goalType"
|
|
1339
|
+
render={({ field }) => (
|
|
1340
|
+
<FormItem>
|
|
1341
|
+
<FormLabel>
|
|
1342
|
+
<div className="flex items-center gap-2">
|
|
1343
|
+
<Target className="h-4 w-4" />
|
|
1344
|
+
Goal Type
|
|
1345
|
+
</div>
|
|
1346
|
+
</FormLabel>
|
|
1347
|
+
<FormControl>
|
|
1348
|
+
<SearchableCombobox
|
|
1349
|
+
options={goalTypeOptions}
|
|
1350
|
+
value={field.value}
|
|
1351
|
+
onValueChange={field.onChange}
|
|
1352
|
+
placeholder="Select goal type..."
|
|
1353
|
+
searchPlaceholder="Search goal types..."
|
|
1354
|
+
emptyMessage="No goal types found."
|
|
1355
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1356
|
+
data-testid="combobox-goal-type"
|
|
1357
|
+
/>
|
|
1358
|
+
</FormControl>
|
|
1359
|
+
<FormMessage />
|
|
1360
|
+
</FormItem>
|
|
1361
|
+
)}
|
|
1362
|
+
/>
|
|
1363
|
+
|
|
1364
|
+
<FormField
|
|
1365
|
+
control={form.control}
|
|
1366
|
+
name="goalValue"
|
|
1367
|
+
render={({ field }) => (
|
|
1368
|
+
<FormItem>
|
|
1369
|
+
<FormLabel>Goal Value</FormLabel>
|
|
1370
|
+
<FormControl>
|
|
1371
|
+
<Input
|
|
1372
|
+
type="number"
|
|
1373
|
+
placeholder="Enter goal value"
|
|
1374
|
+
{...field}
|
|
1375
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1376
|
+
data-testid="input-goal-value"
|
|
1377
|
+
/>
|
|
1378
|
+
</FormControl>
|
|
1379
|
+
<FormMessage />
|
|
1380
|
+
</FormItem>
|
|
1381
|
+
)}
|
|
1382
|
+
/>
|
|
1383
|
+
</div>
|
|
1384
|
+
|
|
1385
|
+
{/* PG-only fields: Minimum Target Threshold + Hard Stop */}
|
|
1386
|
+
{isPG && (
|
|
1387
|
+
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
|
|
1388
|
+
<h4 className="font-medium text-sm">Programmatic Guaranteed Settings</h4>
|
|
1389
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1390
|
+
<FormField
|
|
1391
|
+
control={form.control}
|
|
1392
|
+
name="minimumTargetThreshold"
|
|
1393
|
+
render={({ field }) => (
|
|
1394
|
+
<FormItem>
|
|
1395
|
+
<FormLabel>Minimum Target Threshold <span className="text-destructive">*</span></FormLabel>
|
|
1396
|
+
<FormControl>
|
|
1397
|
+
<Input
|
|
1398
|
+
type="number"
|
|
1399
|
+
placeholder="Enter threshold"
|
|
1400
|
+
{...field}
|
|
1401
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1402
|
+
data-testid="input-minimum-target-threshold"
|
|
1403
|
+
/>
|
|
1404
|
+
</FormControl>
|
|
1405
|
+
<FormDescription>
|
|
1406
|
+
Minimum impressions guaranteed (required for PG)
|
|
1407
|
+
</FormDescription>
|
|
1408
|
+
<FormMessage />
|
|
1409
|
+
</FormItem>
|
|
1410
|
+
)}
|
|
1411
|
+
/>
|
|
1412
|
+
|
|
1413
|
+
<FormField
|
|
1414
|
+
control={form.control}
|
|
1415
|
+
name="hardStop"
|
|
1416
|
+
render={({ field }) => (
|
|
1417
|
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 bg-background">
|
|
1418
|
+
<div className="space-y-0.5">
|
|
1419
|
+
<FormLabel className="text-base">Hard Stop</FormLabel>
|
|
1420
|
+
<FormDescription>
|
|
1421
|
+
Stop delivery when goal is reached
|
|
1422
|
+
</FormDescription>
|
|
1423
|
+
</div>
|
|
1424
|
+
<FormControl>
|
|
1425
|
+
<Switch
|
|
1426
|
+
checked={field.value}
|
|
1427
|
+
onCheckedChange={field.onChange}
|
|
1428
|
+
disabled={existingDeal?.reopened ?? false}
|
|
1429
|
+
data-testid="switch-hard-stop"
|
|
1430
|
+
/>
|
|
1431
|
+
</FormControl>
|
|
1432
|
+
</FormItem>
|
|
1433
|
+
)}
|
|
1434
|
+
/>
|
|
1435
|
+
</div>
|
|
1436
|
+
</div>
|
|
1437
|
+
)}
|
|
1438
|
+
</div>
|
|
1439
|
+
{/* End of Budget Section */}
|
|
1440
|
+
</form>
|
|
1441
|
+
</Form>
|
|
1442
|
+
</CardContent>
|
|
1443
|
+
</Card>
|
|
1444
|
+
</div>
|
|
1445
|
+
|
|
1446
|
+
<div className="lg:col-span-1">
|
|
1447
|
+
<div className="sticky top-6 space-y-4">
|
|
1448
|
+
{/* Show Brand Info card when brand is selected and in brand section */}
|
|
1449
|
+
{selectedBrand && visibleSection === "brand" ? (
|
|
1450
|
+
<Card data-testid="card-brand-info">
|
|
1451
|
+
<CardHeader className="pb-3">
|
|
1452
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2" data-testid="title-brand-info">
|
|
1453
|
+
<Tag className="h-4 w-4 text-primary" />
|
|
1454
|
+
Brand Information
|
|
1455
|
+
</CardTitle>
|
|
1456
|
+
</CardHeader>
|
|
1457
|
+
<CardContent className="pt-0 space-y-4">
|
|
1458
|
+
{/* Brand Logo Placeholder */}
|
|
1459
|
+
<div className="flex items-center gap-3">
|
|
1460
|
+
<div className="w-16 h-16 rounded-lg bg-muted flex items-center justify-center border">
|
|
1461
|
+
<span className="text-2xl font-bold text-muted-foreground">
|
|
1462
|
+
{selectedBrand.name.charAt(0).toUpperCase()}
|
|
1463
|
+
</span>
|
|
1464
|
+
</div>
|
|
1465
|
+
<div>
|
|
1466
|
+
<h4 className="font-semibold" data-testid="text-brand-name">{selectedBrand.name}</h4>
|
|
1467
|
+
<p className="text-sm text-muted-foreground" data-testid="text-brand-status">
|
|
1468
|
+
{selectedBrand.status === "active" ? "Active Brand" : "Inactive Brand"}
|
|
1469
|
+
</p>
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
|
|
1473
|
+
{/* IAB Category */}
|
|
1474
|
+
{selectedBrand.iabCategory && (
|
|
1475
|
+
<div className="space-y-1">
|
|
1476
|
+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
1477
|
+
IAB Category
|
|
1478
|
+
</label>
|
|
1479
|
+
<div className="flex items-center gap-2">
|
|
1480
|
+
<Badge variant="secondary" data-testid="badge-iab-category">
|
|
1481
|
+
{selectedBrand.iabCategory}
|
|
1482
|
+
</Badge>
|
|
1483
|
+
</div>
|
|
1484
|
+
<p className="text-xs text-muted-foreground mt-1" data-testid="text-iab-code">
|
|
1485
|
+
IAB Code: {selectedBrand.iabCategory.split(" ").map(word => word.charAt(0)).join("").toUpperCase()}
|
|
1486
|
+
</p>
|
|
1487
|
+
</div>
|
|
1488
|
+
)}
|
|
1489
|
+
|
|
1490
|
+
{/* Brand Insights Link */}
|
|
1491
|
+
<div className="pt-2 border-t">
|
|
1492
|
+
<p className="text-xs text-muted-foreground">
|
|
1493
|
+
Brand selection enables personalized inventory recommendations in line items.
|
|
1494
|
+
</p>
|
|
1495
|
+
</div>
|
|
1496
|
+
</CardContent>
|
|
1497
|
+
</Card>
|
|
1498
|
+
) : (
|
|
1499
|
+
/* Dynamic Quick Tips based on visible section */
|
|
1500
|
+
<Card data-testid="card-quick-tips">
|
|
1501
|
+
<CardHeader className="pb-3">
|
|
1502
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2" data-testid="title-quick-tips">
|
|
1503
|
+
<Lightbulb className="h-4 w-4 text-amber-500" />
|
|
1504
|
+
{SECTION_TIPS[visibleSection].title}
|
|
1505
|
+
</CardTitle>
|
|
1506
|
+
</CardHeader>
|
|
1507
|
+
<CardContent className="pt-0">
|
|
1508
|
+
<ul className="space-y-2 text-sm text-muted-foreground">
|
|
1509
|
+
{SECTION_TIPS[visibleSection].tips.map((tip, index) => (
|
|
1510
|
+
<li key={index} className="flex items-start gap-2" data-testid={`text-tip-${index}`}>
|
|
1511
|
+
<span className="text-amber-500 mt-1 flex-shrink-0">•</span>
|
|
1512
|
+
<span>{tip}</span>
|
|
1513
|
+
</li>
|
|
1514
|
+
))}
|
|
1515
|
+
</ul>
|
|
1516
|
+
</CardContent>
|
|
1517
|
+
</Card>
|
|
1518
|
+
)}
|
|
1519
|
+
|
|
1520
|
+
{/* Market Insights when countries are selected */}
|
|
1521
|
+
{watchedCountries && watchedCountries.length > 0 && (
|
|
1522
|
+
<MarketInsightsPanel selectedCountries={watchedCountries} />
|
|
1523
|
+
)}
|
|
1524
|
+
</div>
|
|
1525
|
+
</div>
|
|
1526
|
+
</div>
|
|
1527
|
+
</div>
|
|
1528
|
+
</div>
|
|
1529
|
+
|
|
1530
|
+
<div className="shrink-0 border-t bg-background p-4">
|
|
1531
|
+
<div className="flex justify-end gap-4">
|
|
1532
|
+
<Button
|
|
1533
|
+
type="button"
|
|
1534
|
+
variant="outline"
|
|
1535
|
+
onClick={handleCancel}
|
|
1536
|
+
disabled={isSubmitting}
|
|
1537
|
+
data-testid="button-cancel"
|
|
1538
|
+
>
|
|
1539
|
+
Cancel
|
|
1540
|
+
</Button>
|
|
1541
|
+
<Button
|
|
1542
|
+
type="submit"
|
|
1543
|
+
form="deal-form"
|
|
1544
|
+
disabled={isSubmitting}
|
|
1545
|
+
data-testid="button-submit"
|
|
1546
|
+
>
|
|
1547
|
+
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
1548
|
+
{isEditing ? "Update Deal" : "Create Deal"}
|
|
1549
|
+
</Button>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
|
|
1553
|
+
<CreateBrandDrawer
|
|
1554
|
+
open={brandDrawerOpen}
|
|
1555
|
+
onOpenChange={setBrandDrawerOpen}
|
|
1556
|
+
onBrandCreated={(brandId) => {
|
|
1557
|
+
form.setValue("brandId", brandId);
|
|
1558
|
+
}}
|
|
1559
|
+
/>
|
|
1560
|
+
|
|
1561
|
+
<CreateAgencyDrawer
|
|
1562
|
+
open={agencyDrawerOpen}
|
|
1563
|
+
onOpenChange={setAgencyDrawerOpen}
|
|
1564
|
+
onAgencyCreated={(agencyId) => {
|
|
1565
|
+
form.setValue("agencyId", agencyId);
|
|
1566
|
+
}}
|
|
1567
|
+
/>
|
|
1568
|
+
</div>
|
|
1569
|
+
);
|
|
1570
|
+
}
|