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,280 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pagination E2E Tests
|
|
5
|
+
* Tests the pagination functionality matching MW Planner design
|
|
6
|
+
*/
|
|
7
|
+
test.describe('Pagination Functionality', () => {
|
|
8
|
+
test.beforeEach(async ({ page }) => {
|
|
9
|
+
// Navigate to the Direct Campaigns page
|
|
10
|
+
await page.goto('/direct-campaigns');
|
|
11
|
+
await page.waitForLoadState('networkidle');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.describe('Pagination Visibility', () => {
|
|
15
|
+
test('should display pagination controls when campaigns are loaded', async ({ page }) => {
|
|
16
|
+
// Wait for campaigns to load
|
|
17
|
+
await page.waitForSelector('table tbody tr');
|
|
18
|
+
|
|
19
|
+
// Check for pagination section
|
|
20
|
+
const paginationSection = page.locator('.bg-white.dark\\:bg-mw-neutral-900').filter({ hasText: /Page \d+ of \d+/ });
|
|
21
|
+
await expect(paginationSection).toBeVisible();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('should display item count (X to Y of Z)', async ({ page }) => {
|
|
25
|
+
// Wait for campaigns to load
|
|
26
|
+
await page.waitForSelector('table tbody tr');
|
|
27
|
+
|
|
28
|
+
// Check for item count text (e.g., "1 to 12 of 28")
|
|
29
|
+
const itemCountText = page.getByText(/\d+ to \d+ of \d+/);
|
|
30
|
+
await expect(itemCountText).toBeVisible();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('should display all pagination buttons', async ({ page }) => {
|
|
34
|
+
// Wait for campaigns to load
|
|
35
|
+
await page.waitForSelector('table tbody tr');
|
|
36
|
+
|
|
37
|
+
// Check for First button (ChevronFirst icon)
|
|
38
|
+
const firstButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-first') }).first();
|
|
39
|
+
await expect(firstButton).toBeVisible();
|
|
40
|
+
|
|
41
|
+
// Check for Previous button (ChevronLeft icon)
|
|
42
|
+
const prevButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-left') }).first();
|
|
43
|
+
await expect(prevButton).toBeVisible();
|
|
44
|
+
|
|
45
|
+
// Check for page info
|
|
46
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
47
|
+
await expect(pageInfo).toBeVisible();
|
|
48
|
+
|
|
49
|
+
// Check for Next button (ChevronRight icon)
|
|
50
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
51
|
+
await expect(nextButton).toBeVisible();
|
|
52
|
+
|
|
53
|
+
// Check for Last button (ChevronLast icon)
|
|
54
|
+
const lastButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-last') }).first();
|
|
55
|
+
await expect(lastButton).toBeVisible();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('should not display pagination when no campaigns', async ({ page }) => {
|
|
59
|
+
// Navigate with search that returns no results
|
|
60
|
+
const searchInput = page.getByPlaceholder('Search here...');
|
|
61
|
+
await searchInput.fill('XYZ_NONEXISTENT_CAMPAIGN_999');
|
|
62
|
+
await page.waitForTimeout(600);
|
|
63
|
+
await page.waitForLoadState('networkidle');
|
|
64
|
+
|
|
65
|
+
// Pagination should not be visible
|
|
66
|
+
const paginationSection = page.locator('.bg-white.dark\\:bg-mw-neutral-900').filter({ hasText: /Page \d+ of \d+/ });
|
|
67
|
+
await expect(paginationSection).not.toBeVisible();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test.describe('Pagination Navigation', () => {
|
|
72
|
+
test('should disable First and Previous buttons on first page', async ({ page }) => {
|
|
73
|
+
// Wait for campaigns to load
|
|
74
|
+
await page.waitForSelector('table tbody tr');
|
|
75
|
+
|
|
76
|
+
// First and Previous buttons should be disabled on page 1
|
|
77
|
+
const firstButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-first') }).first();
|
|
78
|
+
const prevButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-left') }).first();
|
|
79
|
+
|
|
80
|
+
await expect(firstButton).toBeDisabled();
|
|
81
|
+
await expect(prevButton).toBeDisabled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should navigate to next page when clicking Next button', async ({ page }) => {
|
|
85
|
+
// Wait for campaigns to load
|
|
86
|
+
await page.waitForSelector('table tbody tr');
|
|
87
|
+
|
|
88
|
+
// Get current page number
|
|
89
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
90
|
+
const currentPageText = await pageInfo.textContent();
|
|
91
|
+
const currentPage = parseInt(currentPageText?.match(/Page (\d+)/)?.[1] || '1');
|
|
92
|
+
|
|
93
|
+
// Click Next button
|
|
94
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
95
|
+
|
|
96
|
+
// Only test if not on last page
|
|
97
|
+
if (!(await nextButton.isDisabled())) {
|
|
98
|
+
await nextButton.click();
|
|
99
|
+
|
|
100
|
+
// Wait for new data to load
|
|
101
|
+
await page.waitForLoadState('networkidle');
|
|
102
|
+
|
|
103
|
+
// Verify page number increased
|
|
104
|
+
const newPageText = await pageInfo.textContent();
|
|
105
|
+
const newPage = parseInt(newPageText?.match(/Page (\d+)/)?.[1] || '1');
|
|
106
|
+
expect(newPage).toBe(currentPage + 1);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('should navigate to previous page when clicking Previous button', async ({ page }) => {
|
|
111
|
+
// Wait for campaigns to load
|
|
112
|
+
await page.waitForSelector('table tbody tr');
|
|
113
|
+
|
|
114
|
+
// Click Next to go to page 2
|
|
115
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
116
|
+
|
|
117
|
+
if (!(await nextButton.isDisabled())) {
|
|
118
|
+
await nextButton.click();
|
|
119
|
+
await page.waitForLoadState('networkidle');
|
|
120
|
+
|
|
121
|
+
// Now click Previous to go back
|
|
122
|
+
const prevButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-left') }).first();
|
|
123
|
+
await prevButton.click();
|
|
124
|
+
await page.waitForLoadState('networkidle');
|
|
125
|
+
|
|
126
|
+
// Should be back on page 1
|
|
127
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
128
|
+
await expect(pageInfo).toContainText('Page 1 of');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('should navigate to first page when clicking First button', async ({ page }) => {
|
|
133
|
+
// Wait for campaigns to load
|
|
134
|
+
await page.waitForSelector('table tbody tr');
|
|
135
|
+
|
|
136
|
+
// Navigate to a later page
|
|
137
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
138
|
+
|
|
139
|
+
if (!(await nextButton.isDisabled())) {
|
|
140
|
+
// Click next a few times
|
|
141
|
+
await nextButton.click();
|
|
142
|
+
await page.waitForLoadState('networkidle');
|
|
143
|
+
await nextButton.click();
|
|
144
|
+
await page.waitForLoadState('networkidle');
|
|
145
|
+
|
|
146
|
+
// Click First button
|
|
147
|
+
const firstButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-first') }).first();
|
|
148
|
+
await firstButton.click();
|
|
149
|
+
await page.waitForLoadState('networkidle');
|
|
150
|
+
|
|
151
|
+
// Should be on page 1
|
|
152
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
153
|
+
await expect(pageInfo).toContainText('Page 1 of');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should navigate to last page when clicking Last button', async ({ page }) => {
|
|
158
|
+
// Wait for campaigns to load
|
|
159
|
+
await page.waitForSelector('table tbody tr');
|
|
160
|
+
|
|
161
|
+
// Get total pages
|
|
162
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
163
|
+
const pageText = await pageInfo.textContent();
|
|
164
|
+
const totalPages = parseInt(pageText?.match(/of (\d+)/)?.[1] || '1');
|
|
165
|
+
|
|
166
|
+
// Only test if there are multiple pages
|
|
167
|
+
if (totalPages > 1) {
|
|
168
|
+
// Click Last button
|
|
169
|
+
const lastButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-last') }).first();
|
|
170
|
+
await lastButton.click();
|
|
171
|
+
await page.waitForLoadState('networkidle');
|
|
172
|
+
|
|
173
|
+
// Should be on last page
|
|
174
|
+
await expect(pageInfo).toContainText(`Page ${totalPages} of ${totalPages}`);
|
|
175
|
+
|
|
176
|
+
// Last and Next buttons should be disabled
|
|
177
|
+
await expect(lastButton).toBeDisabled();
|
|
178
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
179
|
+
await expect(nextButton).toBeDisabled();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test.describe('Pagination Data Accuracy', () => {
|
|
185
|
+
test('should display correct item range on first page', async ({ page }) => {
|
|
186
|
+
// Wait for campaigns to load
|
|
187
|
+
await page.waitForSelector('table tbody tr');
|
|
188
|
+
|
|
189
|
+
// Get item count text
|
|
190
|
+
const itemCountText = page.getByText(/\d+ to \d+ of \d+/);
|
|
191
|
+
const text = await itemCountText.textContent();
|
|
192
|
+
|
|
193
|
+
// Should start from 1
|
|
194
|
+
expect(text).toMatch(/^1 to/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('should update item range when navigating pages', async ({ page }) => {
|
|
198
|
+
// Wait for campaigns to load
|
|
199
|
+
await page.waitForSelector('table tbody tr');
|
|
200
|
+
|
|
201
|
+
// Get item count on first page
|
|
202
|
+
const itemCountText = page.getByText(/\d+ to \d+ of \d+/);
|
|
203
|
+
const firstPageText = await itemCountText.textContent();
|
|
204
|
+
const endItem = parseInt(firstPageText?.match(/to (\d+)/)?.[1] || '0');
|
|
205
|
+
|
|
206
|
+
// Click next page
|
|
207
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
208
|
+
|
|
209
|
+
if (!(await nextButton.isDisabled())) {
|
|
210
|
+
await nextButton.click();
|
|
211
|
+
await page.waitForLoadState('networkidle');
|
|
212
|
+
|
|
213
|
+
// Get new item count
|
|
214
|
+
const secondPageText = await itemCountText.textContent();
|
|
215
|
+
const newStartItem = parseInt(secondPageText?.match(/^(\d+) to/)?.[1] || '0');
|
|
216
|
+
|
|
217
|
+
// New start should be previous end + 1
|
|
218
|
+
expect(newStartItem).toBe(endItem + 1);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('should maintain correct total count across pages', async ({ page }) => {
|
|
223
|
+
// Wait for campaigns to load
|
|
224
|
+
await page.waitForSelector('table tbody tr');
|
|
225
|
+
|
|
226
|
+
// Get total from first page
|
|
227
|
+
const itemCountText = page.getByText(/\d+ to \d+ of \d+/);
|
|
228
|
+
const firstPageText = await itemCountText.textContent();
|
|
229
|
+
const totalFirst = parseInt(firstPageText?.match(/of (\d+)/)?.[1] || '0');
|
|
230
|
+
|
|
231
|
+
// Navigate to next page
|
|
232
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
233
|
+
|
|
234
|
+
if (!(await nextButton.isDisabled())) {
|
|
235
|
+
await nextButton.click();
|
|
236
|
+
await page.waitForLoadState('networkidle');
|
|
237
|
+
|
|
238
|
+
// Get total from second page
|
|
239
|
+
const secondPageText = await itemCountText.textContent();
|
|
240
|
+
const totalSecond = parseInt(secondPageText?.match(/of (\d+)/)?.[1] || '0');
|
|
241
|
+
|
|
242
|
+
// Total should remain the same
|
|
243
|
+
expect(totalSecond).toBe(totalFirst);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test.describe('Mobile Pagination', () => {
|
|
249
|
+
test.use({ viewport: { width: 375, height: 667 } });
|
|
250
|
+
|
|
251
|
+
test('should display mobile pagination controls', async ({ page }) => {
|
|
252
|
+
// Navigate to the Direct Campaigns page
|
|
253
|
+
await page.goto('/direct-campaigns');
|
|
254
|
+
await page.waitForLoadState('networkidle');
|
|
255
|
+
|
|
256
|
+
// Wait for campaigns to load
|
|
257
|
+
await page.waitForSelector('table tbody tr');
|
|
258
|
+
|
|
259
|
+
// Mobile should show Previous and Next buttons
|
|
260
|
+
const previousButton = page.getByRole('button', { name: 'Previous' });
|
|
261
|
+
const nextButton = page.getByRole('button', { name: 'Next' });
|
|
262
|
+
|
|
263
|
+
await expect(previousButton).toBeVisible();
|
|
264
|
+
await expect(nextButton).toBeVisible();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('should not display desktop pagination on mobile', async ({ page }) => {
|
|
268
|
+
// Navigate to the Direct Campaigns page
|
|
269
|
+
await page.goto('/direct-campaigns');
|
|
270
|
+
await page.waitForLoadState('networkidle');
|
|
271
|
+
|
|
272
|
+
// Wait for campaigns to load
|
|
273
|
+
await page.waitForSelector('table tbody tr');
|
|
274
|
+
|
|
275
|
+
// Desktop pagination with icons should not be visible
|
|
276
|
+
const firstButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-first') });
|
|
277
|
+
await expect(firstButton).not.toBeVisible();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* View Toggle E2E Tests
|
|
5
|
+
* Tests switching between List and Grid views
|
|
6
|
+
*/
|
|
7
|
+
test.describe('View Toggle Functionality', () => {
|
|
8
|
+
test.beforeEach(async ({ page }) => {
|
|
9
|
+
// Navigate to the Direct Campaigns page
|
|
10
|
+
await page.goto('/direct-campaigns');
|
|
11
|
+
await page.waitForLoadState('networkidle');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.describe('View Toggle Buttons', () => {
|
|
15
|
+
test('should display both view toggle buttons', async ({ page }) => {
|
|
16
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
17
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
18
|
+
|
|
19
|
+
await expect(listViewButton).toBeVisible();
|
|
20
|
+
await expect(gridViewButton).toBeVisible();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('should have grid view selected by default', async ({ page }) => {
|
|
24
|
+
// Grid view should be active by default (based on code)
|
|
25
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
26
|
+
|
|
27
|
+
// Check if button has active classes
|
|
28
|
+
await expect(gridViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('should highlight active view button', async ({ page }) => {
|
|
32
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
33
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
34
|
+
|
|
35
|
+
// Grid should be active initially
|
|
36
|
+
await expect(gridViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
37
|
+
|
|
38
|
+
// Click list view
|
|
39
|
+
await listViewButton.click();
|
|
40
|
+
await page.waitForTimeout(300);
|
|
41
|
+
|
|
42
|
+
// List should now be active
|
|
43
|
+
await expect(listViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
44
|
+
await expect(gridViewButton).not.toHaveClass(/bg-mw-neutral-200/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test.describe('Grid View', () => {
|
|
49
|
+
test('should display campaigns in grid layout', async ({ page }) => {
|
|
50
|
+
// Ensure we're in grid view
|
|
51
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
52
|
+
await gridViewButton.click();
|
|
53
|
+
await page.waitForTimeout(300);
|
|
54
|
+
|
|
55
|
+
// Wait for campaigns to load
|
|
56
|
+
await page.waitForSelector('[class*="grid"]', { timeout: 10000 });
|
|
57
|
+
|
|
58
|
+
// Check for grid container
|
|
59
|
+
const gridContainer = page.locator('.grid.grid-cols-1');
|
|
60
|
+
await expect(gridContainer).toBeVisible();
|
|
61
|
+
|
|
62
|
+
// Check for campaign cards
|
|
63
|
+
const campaignCards = page.locator('[class*="rounded-lg"][class*="border"]');
|
|
64
|
+
await expect(campaignCards.first()).toBeVisible();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should display campaign cards with correct information', async ({ page }) => {
|
|
68
|
+
// Ensure we're in grid view
|
|
69
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
70
|
+
await gridViewButton.click();
|
|
71
|
+
await page.waitForTimeout(300);
|
|
72
|
+
|
|
73
|
+
// Wait for campaign cards
|
|
74
|
+
await page.waitForSelector('.grid.grid-cols-1');
|
|
75
|
+
|
|
76
|
+
// Get first campaign card
|
|
77
|
+
const firstCard = page.locator('[class*="rounded-lg"][class*="border"]').first();
|
|
78
|
+
await expect(firstCard).toBeVisible();
|
|
79
|
+
|
|
80
|
+
// Card should contain campaign name, brand, dates, status
|
|
81
|
+
// (Based on CampaignCard component structure)
|
|
82
|
+
await expect(firstCard).toContainText(/.+/); // Should have some text content
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('should display grid in responsive columns', async ({ page }) => {
|
|
86
|
+
// Desktop: should have lg:grid-cols-3
|
|
87
|
+
const gridContainer = page.locator('.grid.grid-cols-1');
|
|
88
|
+
await expect(gridContainer).toHaveClass(/lg:grid-cols-3/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.describe('List View', () => {
|
|
93
|
+
test('should display campaigns in table layout', async ({ page }) => {
|
|
94
|
+
// Click list view
|
|
95
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
96
|
+
await listViewButton.click();
|
|
97
|
+
await page.waitForTimeout(300);
|
|
98
|
+
|
|
99
|
+
// Wait for table to be visible
|
|
100
|
+
await page.waitForSelector('table', { timeout: 10000 });
|
|
101
|
+
|
|
102
|
+
// Check for table
|
|
103
|
+
const table = page.locator('table');
|
|
104
|
+
await expect(table).toBeVisible();
|
|
105
|
+
|
|
106
|
+
// Check for table rows
|
|
107
|
+
const rows = page.locator('table tbody tr');
|
|
108
|
+
await expect(rows.first()).toBeVisible();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('should display all table columns in list view', async ({ page }) => {
|
|
112
|
+
// Click list view
|
|
113
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
114
|
+
await listViewButton.click();
|
|
115
|
+
await page.waitForTimeout(300);
|
|
116
|
+
|
|
117
|
+
// Wait for table
|
|
118
|
+
await page.waitForSelector('table');
|
|
119
|
+
|
|
120
|
+
// Check for all column headers
|
|
121
|
+
await expect(page.getByRole('columnheader', { name: 'Campaign Name' })).toBeVisible();
|
|
122
|
+
await expect(page.getByRole('columnheader', { name: 'Brand' })).toBeVisible();
|
|
123
|
+
await expect(page.getByRole('columnheader', { name: 'Flight Dates' })).toBeVisible();
|
|
124
|
+
await expect(page.getByRole('columnheader', { name: 'Client Type' })).toBeVisible();
|
|
125
|
+
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible();
|
|
126
|
+
await expect(page.getByRole('columnheader', { name: 'Actions' })).toBeVisible();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('should show checkbox column in list view', async ({ page }) => {
|
|
130
|
+
// Click list view
|
|
131
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
132
|
+
await listViewButton.click();
|
|
133
|
+
await page.waitForTimeout(300);
|
|
134
|
+
|
|
135
|
+
// Wait for table
|
|
136
|
+
await page.waitForSelector('table tbody tr');
|
|
137
|
+
|
|
138
|
+
// Check for checkbox in header
|
|
139
|
+
const headerCheckbox = page.locator('#table-select-all-checkbox');
|
|
140
|
+
await expect(headerCheckbox).toBeVisible();
|
|
141
|
+
|
|
142
|
+
// Check for checkbox in first row
|
|
143
|
+
const firstRowCheckbox = page.locator('table tbody tr').first().locator('input[type="checkbox"]');
|
|
144
|
+
await expect(firstRowCheckbox).toBeVisible();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test.describe('View Switch Behavior', () => {
|
|
149
|
+
test('should switch from grid to list view', async ({ page }) => {
|
|
150
|
+
// Start in grid view
|
|
151
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
152
|
+
await gridViewButton.click();
|
|
153
|
+
await page.waitForTimeout(300);
|
|
154
|
+
|
|
155
|
+
// Verify grid is visible
|
|
156
|
+
await expect(page.locator('.grid.grid-cols-1')).toBeVisible();
|
|
157
|
+
|
|
158
|
+
// Switch to list view
|
|
159
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
160
|
+
await listViewButton.click();
|
|
161
|
+
await page.waitForTimeout(300);
|
|
162
|
+
|
|
163
|
+
// Grid should be hidden, table should be visible
|
|
164
|
+
await expect(page.locator('table')).toBeVisible();
|
|
165
|
+
await expect(page.locator('.grid.grid-cols-1')).not.toBeVisible();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('should switch from list to grid view', async ({ page }) => {
|
|
169
|
+
// Start in list view
|
|
170
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
171
|
+
await listViewButton.click();
|
|
172
|
+
await page.waitForTimeout(300);
|
|
173
|
+
|
|
174
|
+
// Verify table is visible
|
|
175
|
+
await expect(page.locator('table')).toBeVisible();
|
|
176
|
+
|
|
177
|
+
// Switch to grid view
|
|
178
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
179
|
+
await gridViewButton.click();
|
|
180
|
+
await page.waitForTimeout(300);
|
|
181
|
+
|
|
182
|
+
// Table should be hidden, grid should be visible
|
|
183
|
+
await expect(page.locator('.grid.grid-cols-1')).toBeVisible();
|
|
184
|
+
await expect(page.locator('table')).not.toBeVisible();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('should persist view selection in localStorage', async ({ page }) => {
|
|
188
|
+
// Switch to list view
|
|
189
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
190
|
+
await listViewButton.click();
|
|
191
|
+
await page.waitForTimeout(300);
|
|
192
|
+
|
|
193
|
+
// Reload the page
|
|
194
|
+
await page.reload();
|
|
195
|
+
await page.waitForLoadState('networkidle');
|
|
196
|
+
|
|
197
|
+
// Should still be in list view
|
|
198
|
+
await expect(page.locator('table')).toBeVisible();
|
|
199
|
+
await expect(listViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('should clear selected rows when switching views', async ({ page }) => {
|
|
203
|
+
// Start in list view and select a row
|
|
204
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
205
|
+
await listViewButton.click();
|
|
206
|
+
await page.waitForTimeout(300);
|
|
207
|
+
|
|
208
|
+
// Wait for table
|
|
209
|
+
await page.waitForSelector('table tbody tr');
|
|
210
|
+
|
|
211
|
+
// Select first row
|
|
212
|
+
const firstRowCheckbox = page.locator('table tbody tr').first().locator('input[type="checkbox"]');
|
|
213
|
+
await firstRowCheckbox.check();
|
|
214
|
+
await expect(firstRowCheckbox).toBeChecked();
|
|
215
|
+
|
|
216
|
+
// Switch to grid view
|
|
217
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
218
|
+
await gridViewButton.click();
|
|
219
|
+
await page.waitForTimeout(300);
|
|
220
|
+
|
|
221
|
+
// Switch back to list view
|
|
222
|
+
await listViewButton.click();
|
|
223
|
+
await page.waitForTimeout(300);
|
|
224
|
+
|
|
225
|
+
// Selection should be cleared
|
|
226
|
+
await expect(firstRowCheckbox).not.toBeChecked();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test.describe('View Persistence', () => {
|
|
231
|
+
test('should remember last selected view after page reload', async ({ page }) => {
|
|
232
|
+
// Select list view
|
|
233
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
234
|
+
await listViewButton.click();
|
|
235
|
+
await page.waitForTimeout(300);
|
|
236
|
+
|
|
237
|
+
// Get current URL
|
|
238
|
+
const url = page.url();
|
|
239
|
+
|
|
240
|
+
// Reload page
|
|
241
|
+
await page.goto(url);
|
|
242
|
+
await page.waitForLoadState('networkidle');
|
|
243
|
+
|
|
244
|
+
// Should still show list view
|
|
245
|
+
await expect(page.locator('table')).toBeVisible();
|
|
246
|
+
await expect(listViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('should remember last selected view after navigation', async ({ page }) => {
|
|
250
|
+
// Select list view
|
|
251
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
252
|
+
await listViewButton.click();
|
|
253
|
+
await page.waitForTimeout(300);
|
|
254
|
+
|
|
255
|
+
// Navigate away
|
|
256
|
+
await page.goto('/');
|
|
257
|
+
await page.waitForLoadState('networkidle');
|
|
258
|
+
|
|
259
|
+
// Navigate back
|
|
260
|
+
await page.goto('/direct-campaigns');
|
|
261
|
+
await page.waitForLoadState('networkidle');
|
|
262
|
+
|
|
263
|
+
// Should still show list view
|
|
264
|
+
await expect(page.locator('table')).toBeVisible();
|
|
265
|
+
await expect(listViewButton).toHaveClass(/bg-mw-neutral-200/);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test.describe('Pagination with View Toggle', () => {
|
|
270
|
+
test('should show pagination in both grid and list views', async ({ page }) => {
|
|
271
|
+
// Check pagination in grid view
|
|
272
|
+
const gridViewButton = page.locator('button[title="Grid View"]');
|
|
273
|
+
await gridViewButton.click();
|
|
274
|
+
await page.waitForTimeout(300);
|
|
275
|
+
|
|
276
|
+
await page.waitForSelector('.grid.grid-cols-1');
|
|
277
|
+
const paginationInGrid = page.getByText(/Page \d+ of \d+/);
|
|
278
|
+
await expect(paginationInGrid).toBeVisible();
|
|
279
|
+
|
|
280
|
+
// Switch to list view
|
|
281
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
282
|
+
await listViewButton.click();
|
|
283
|
+
await page.waitForTimeout(300);
|
|
284
|
+
|
|
285
|
+
await page.waitForSelector('table');
|
|
286
|
+
const paginationInList = page.getByText(/Page \d+ of \d+/);
|
|
287
|
+
await expect(paginationInList).toBeVisible();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('should maintain page number when switching views', async ({ page }) => {
|
|
291
|
+
// Navigate to page 2 in grid view
|
|
292
|
+
const nextButton = page.locator('button').filter({ has: page.locator('svg.lucide-chevron-right') }).first();
|
|
293
|
+
|
|
294
|
+
if (!(await nextButton.isDisabled())) {
|
|
295
|
+
await nextButton.click();
|
|
296
|
+
await page.waitForLoadState('networkidle');
|
|
297
|
+
|
|
298
|
+
// Verify on page 2
|
|
299
|
+
const pageInfo = page.getByText(/Page \d+ of \d+/);
|
|
300
|
+
await expect(pageInfo).toContainText('Page 2');
|
|
301
|
+
|
|
302
|
+
// Switch to list view
|
|
303
|
+
const listViewButton = page.locator('button[title="List View"]');
|
|
304
|
+
await listViewButton.click();
|
|
305
|
+
await page.waitForTimeout(300);
|
|
306
|
+
|
|
307
|
+
// Should still be on page 2
|
|
308
|
+
await expect(pageInfo).toContainText('Page 2');
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
Binary file
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
input: [
|
|
5
|
+
'client/src/**/*.{js,jsx,ts,tsx}',
|
|
6
|
+
'!client/src/**/*.test.{js,jsx,ts,tsx}',
|
|
7
|
+
'!client/src/**/*.spec.{js,jsx,ts,tsx}',
|
|
8
|
+
'!**/node_modules/**',
|
|
9
|
+
],
|
|
10
|
+
output: './client/public/locales',
|
|
11
|
+
options: {
|
|
12
|
+
debug: true,
|
|
13
|
+
removeUnusedKeys: false,
|
|
14
|
+
sort: true,
|
|
15
|
+
func: {
|
|
16
|
+
list: ['t', 'i18next.t', 'i18n.t'],
|
|
17
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
18
|
+
},
|
|
19
|
+
trans: {
|
|
20
|
+
component: 'Trans',
|
|
21
|
+
i18nKey: 'i18nKey',
|
|
22
|
+
defaultsKey: 'defaults',
|
|
23
|
+
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
24
|
+
fallbackKey: function(ns, value) {
|
|
25
|
+
return value;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
lngs: ['en', 'zh', 'ja', 'ar'],
|
|
29
|
+
ns: ['common', 'deals', 'creatives', 'navigation', 'contentHub', 'wizard', 'campaigns'],
|
|
30
|
+
defaultLng: 'en',
|
|
31
|
+
defaultNs: 'common',
|
|
32
|
+
defaultValue: '__NOT_TRANSLATED__',
|
|
33
|
+
resource: {
|
|
34
|
+
loadPath: 'client/public/locales/{{lng}}/{{ns}}.json',
|
|
35
|
+
savePath: '{{lng}}/{{ns}}.json',
|
|
36
|
+
jsonIndent: 2,
|
|
37
|
+
lineEnding: '\n',
|
|
38
|
+
},
|
|
39
|
+
nsSeparator: ':',
|
|
40
|
+
keySeparator: '.',
|
|
41
|
+
interpolation: {
|
|
42
|
+
prefix: '{{',
|
|
43
|
+
suffix: '}}',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|