1 2!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="da6fd120-86a6-583d-8992-c5cd2201c1e6")}catch(e){}}(); 3import { o as __toESM, t as __commonJSMin } from "./rolldown-runtime.D1cXj70v.js"; 4import { X as require_jsx_runtime, dt as require_react, t as require_material } from "./vendor~index~newNavigation~AccountTierManagementPage~AdminContainer~AISystemPromptManager~C~h0mf93xb.B-8UGg7U.js"; 5import { Gn as v4, _r as useParams, fr as Switch$3, gr as useLocation, hr as useHistory, lr as Redirect, ur as Route } from "./vendor~index~newNavigation~AccountTierManagementPage~AdminContainer~AISystemPromptManager~C~hh2h6ti1.CxCdS9_4.js"; 6import { A as require_faTriangleExclamation, D as require_faTrashCan, O as require_faPen, b as require_faBullhorn, m as require_faAngleLeft, t as require_dist, x as require_faAngleRight } from "./vendor~index~newNavigation~AccountTierManagementPage~AdminContainer~AISystemPromptManager~S~d59nl763.C6LdGoX3.js"; 7import { n as Link$6 } from "./react-router-dom.DXk3bR6C.js"; 8import { t as require_dist$1 } from "./dist.DzcutlwI.js"; 9import { Ah as TimeUtils, Fi as PendoService, Q as setInfoNotification, Qg as permissions, Z as setErrorNotification, Zg as isPermitted, gf as STAGE_MODE, o as getService, pf as EDITING_MODE, tt as setSuccessNotification, xa as DownloadUtils } from "./logReportUtils.Ca-hg6bk.js"; 10import { b as init_networkStatus, y as NetworkStatus } from "./vendor~index~newNavigation~AccountTierManagementPage~AdminContainer~AISystemPromptManager~C~lx5ijrxx.Ds2KGt5R.js"; 11import { Cf as useCurrentAccountAdsSpaceCampaignQuery, Df as useCurrentAccountAdsSpaceExperimentsQuery, Ef as useCurrentAccountAdsSpaceEntitiesQuery, Fh as useSaveAdsSpaceServerSettingsMutation, Gr as GqlClientAdsSpaceEntityKind, Hr as GqlClientAdsSpaceCampaignChildTable, Mh as useSaveAdsSpaceEntitiesMutation, Nd as useAdsSpaceAvailableAdsAccountsQuery, Om as useLinkAdsAccountToStudioAccountMutation, Ph as useSaveAdsSpacePixelRulesMutation, Rm as useMoveAdsSpaceCampaignMutation, Uf as useDuplicateAdsSpaceCampaignMutation, Ur as GqlClientAdsSpaceCampaignMode, Wr as GqlClientAdsSpaceDeliveryStatus, Zh as useUpdateAdsSpaceCampaignSectionMutation, bf as useCurrentAccountAdsSpaceAudiencesQuery, df as useCreateAdsSpaceCampaignMutation, jf as useCurrentAccountAdsSpaceServerSettingsQuery, jh as useSaveAdsSpaceAudienceMutation, kf as useCurrentAccountAdsSpacePixelRulesQuery, lf as useCopyAdsSpaceAudienceToAccountMutation, qr as GqlClientAdsSpaceThresholdState, wf as useCurrentAccountAdsSpaceCampaignsQuery } from "./graphqlClient.B8mBnpI6.js"; 12import { N as require_faCircleQuestion } from "./vendor~index~newNavigation~AccountTierManagementPage~AdminContainer~AISystemPromptManager~C~e5v1w7z6.UinWi66V.js"; 13import { i as require_faXmark, n as require_faCircleInfo } from "./faInfoCircle.D2subuiD.js"; 14import { t as require_faAngleDown } from "./faAngleDown.ByAeCpg0.js"; 15import { t as FontAwesomeIcon } from "./dist.D6-c0JAy.js"; 16import { r as require_faChartMixed, t as require_faEllipsisV } from "./faEllipsisV.BfhkYBLO.js"; 17import { n as require_faPlus } from "./faStar.DAIrK_E4.js"; 18import { i as require_faPlug, r as require_faPlay } from "./faShieldCheck.CcsGjb2g.js"; 19import { n as require_faCopy } from "./faArrowsRotate.DLJqrJfV.js"; 20import { n as require_faEarthAmericas, r as require_faDesktop, t as require_faArrowRotateRight } from "./faArrowRotateRight.BILaCnxV.js"; 21import { n as require_faDownload, t as require_faWaveformLines } from "./faWaveformLines.DGYekvi_.js"; 22import { n as require_faFlask } from "./faClockRotateLeft.BDmEd6Qu.js"; 23import { t as require_faLink } from "./faLink.BGvRELs0.js"; 24import { t as require_faUserGroup } from "./faUserGroup.CtbEFl4T.js"; 25import { t as axios } from "./axios.BvSUZrlt.js"; 26import { n as useActiveAccount, o as useUserData, r as useActiveAccountId } from "./model.fbsYc69Q.js"; 27import { t as require_faFilm } from "./faFilm.Dzt69dT8.js"; 28import { G as useDropzone } from "./DataLibraryUtils.Bu8dkwMa.js"; 29import { t as TruffleCircularProgress } from "./TruffleCircularProgress.C8BTwqVq.js"; 30import { n as HelpCenterPopover, r as EditHelpCenterPopover } from "./newNavigation.iV4gAi0z.js";
31import { t as NotificationSnackbar } from "./NotificationSnackbar.BiP7swdL.js"; 32import { n as sundaysky_default, r as useGlobalDragDetector, t as UserMenu } from "./UserMenu.C2degPWb.js"; 33import { w as RowOptionsMenu } from "./UserManagement.IN7bfiVp.js"; 34import { a as stickyLeftSx, i as rowHoverSx, n as bodyCellSx, o as stickyRightSx, r as headCellSx, s as DiscardChangesDialog, t as actionsCellSx } from "./userTableSharedStyles.BCrdfiPn.js"; 35import { t as require_dist$2 } from "./dist.8h5_VYnW.js"; 36import { n as require_faArrowRightArrowLeft, r as require_faLinkSlash, t as require_faFilter } from "./faFilter.BdfO-JKX.js"; 37import { c as AnalyticsChart } from "./InfoTooltipIcon.DpGFNd80.js"; 38import { $ as STRING_LIST_SEPARATORS, A as THRESHOLD_STATE_MEANINGS, B as formatUtcTime, C as ADS_CAMPAIGN_DASHBOARD_SECTION_ID, Ct as adsTableTotalRowSx, D as STATUS_COLORS, E as QUALITY_LABELS, F as formatCount, G as paceSkewChip, H as inverseStatusColor, I as formatDate, K as paceStatusColor, L as formatMoney, M as describePace, N as ecpmStatus, O as STATUS_LABELS, P as formatCompact, Q as AdsSaveBar, R as formatRate, S as AccountAlertsCard, St as adsSurfaceSx, T as BAND_LABELS, U as marginColor, V as formatWholePercent, W as paceColor, X as DeliveryRefreshButton, Y as AdsAuditLogPage, Z as AdsSaveCommentDialog, _ as DeliveryEmptyState, _t as useAdsSpaceAccount, a as AdsSectionPage, at as describeAdsChangeCount, b as AdsMeter, bt as adsIconTileSx, c as useAdsSpaceAccountDelivery, ct as hasFieldChanged, d as todayChartOption, dt as matches, et as addDays, f as trendChartOption, ft as required, g as AccountDecoupledTable, gt as AdsNonProductionBanner, h as AccountCampaignTable, ht as AdsPageHeader, i as useAdsUnsavedChangesGuard, it as countRowChanges, j as campaignStateChip, k as THRESHOLD_STATE_LABELS, l as useAdsSpaceCampaignDelivery, lt as inRange, m as AdsKpiTile, mt as require_faTrash, n as AdsFlightsPage, nt as asText, o as AdsCopyableText, ot as emailEntryProblem, p as volumeDistributionChartOption, pt as validateFields, q as paceTolerance, r as AdsTableSkeleton, rt as countChanges, s as require_faCalendarDays, st as hasErrors, t as AdsUnreachableNotice, tt as asStringList, u as flightChartOption, ut as listSeparatorFor, v as DeliveryFreshness, vt as AdsEmptyState, w as ADS_CAMPAIGN_FLIGHTS_SECTION_ID, x as AdsDashboardCard, xt as adsPageSx, y as DeliverySkeleton, yt as adsFixedPageSx, z as formatRelativeAge } from "./AdsUnreachableNotice.BWm2MGKy.js"; 39//#region client/components/newNav/components/ads/AdsAccountLinkGate.tsx 40var import_dist$1 = require_dist$1(); 41var import_faAngleDown = require_faAngleDown(); 42var import_dist$2 = require_dist(); 43var import_faLink = require_faLink(); 44var import_material = require_material(); 45var import_react = /* @__PURE__ */ __toESM(require_react()); 46var import_jsx_runtime = require_jsx_runtime(); 47/** 48* Nothing in the space can be read before this Studio account is mapped to an ads account, so every route 49* renders through here. The drawer stays around it, so a user who lands on an unlinked account is not trapped. 50*/ 51var AdsAccountLinkGate = ({ children }) => { 52 const { isLinked, hasLoaded, loading } = useAdsSpaceAccount(); 53 if (isLinked) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children }); 54 if (loading && !hasLoaded) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 55 sx: loadingSx$1, 56 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TruffleCircularProgress, { size: 40 }) 57 }); 58 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsAccountLinkScreen, {}); 59}; 60var AdsAccountLinkScreen = () => { 61 const accountId = useActiveAccountId(); 62 const { userRoles } = useUserData(); 63 const canLink = isPermitted(userRoles, permissions.adsEditCampaignSettings); 64 const { data, loading: loadingAccounts } = useAdsSpaceAvailableAdsAccountsQuery({ skip: !canLink }); 65 const [linkAdsAccount, { loading: linking }] = useLinkAdsAccountToStudioAccountMutation(); 66 const [selectedId, setSelectedId] = (0, import_react.useState)(null); 67 const adsAccounts = data?.adsSpaceAvailableAdsAccounts ?? []; 68 const selected = adsAccounts.find((adsAccount) => adsAccount.id === selectedId) ?? null; 69 const link = () => { 70 linkAdsAccount({ variables: { 71 accountId, 72 adsAccountId: selectedId 73 } }).catch(() => setErrorNotification({ message: "Couldn't connect the ads account" })); 74 }; 75 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 76 sx: adsPageSx, 77 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEmptyState, { 78 icon: import_faLink.faLink, 79 heading: "This account is not connected to an ads account yet", 80 subtitle: "The ads space reads campaigns, targeting and settings from an ads account. Pick the one this Studio account belongs to.",
81 children: canLink ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 82 sx: formSx$1, 83 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Autocomplete, { 84 options: adsAccounts, 85 value: selected, 86 loading: loadingAccounts, 87 getOptionLabel: (option) => option.name, 88 isOptionEqualToValue: (option, value) => option.id === value.id, 89 onChange: (_event, option) => setSelectedId(option?.id ?? null), 90 noOptionsText: loadingAccounts ? "Loading ads accounts" : "No ads accounts are reachable", 91 renderInput: (params) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 92 ...params, 93 label: "Ads account", 94 placeholder: "Search ads accounts", 95 inputProps: { 96 ...params.inputProps, 97 "data-tracking-id": "tracking-id-ads-link-account-input" 98 } 99 }) 100 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 101 variant: "contained", 102 size: "large", 103 loading: linking, 104 disabled: !selectedId, 105 onClick: link, 106 "data-tracking-id": "tracking-id-ads-link-account-btn", 107 children: "Connect account" 108 })] 109 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 110 variant: "body1", 111 color: "text.secondary", 112 sx: readOnlySx, 113 children: "Ask someone who can edit campaign settings on this account to connect it." 114 }) 115 }) 116 }); 117}; 118var loadingSx$1 = { 119 display: "flex", 120 alignItems: "center", 121 justifyContent: "center", 122 height: "100%" 123}; 124var formSx$1 = { 125 display: "flex", 126 flexDirection: "column", 127 gap: 2, 128 width: 360, 129 textAlign: "left" 130}; 131var readOnlySx = { maxWidth: 380 }; 132//#endregion 133//#region client/components/newNav/components/ads/AdsAccountLabel.tsx 134/** 135* The scope of everything in the space, at the top of the drawer. The ads account name leads because that is 136* what every write here lands on; the Studio account name and the ads account id are what you quote to someone 137* else. It states where you are and nothing more - switching account lives in the avatar menu at the bottom, 138* the same place it does in every other space. 139*/ 140var AdsAccountLabel = () => { 141 const { displayName } = useActiveAccount(); 142 const { adsAccountName, adsAccountId } = useAdsSpaceAccount(); 143 const headline = adsAccountName ?? displayName; 144 const meta = adsAccountName ? `${displayName} . ads ${adsAccountId}` : ""; 145 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 146 sx: labelSx, 147 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TypographyWithTooltipOnOverflow, { 148 noWrap: true, 149 variant: "h4", 150 color: "text.primary", 151 children: headline 152 }
152), meta && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TypographyWithTooltipOnOverflow, { 153 noWrap: true, 154 variant: "caption", 155 color: "text.secondary", 156 component: "div", 157 children: meta 158 })] 159 }); 160}; 161var labelSx = { 162 padding: "6px 8px", 163 minWidth: 0, 164 width: "100%" 165}; 166//#endregion 167//#region client/components/newNav/components/ads/adsNavModel.ts 168/** 169* Sections rather than a flat list: nineteen rows at once is more than anyone reads, and the first three named 170* zones are the platform's three ways an impression gets served, which is the first thing true about any task 171* here. The dashboard sits above them as a plain row because it is where a session starts rather than a place 172* to configure something. 173*/ 174var adsNavZones = [ 175 { 176 id: "dashboard", 177 items: [{ 178 id: "account-dashboard", 179 label: "Dashboard", 180 to: import_dist$1.newNavAdsDashboardPath, 181 permission: permissions.adsGetCampaignSettings 182 }] 183 }, 184 { 185 id: "programmatic", 186 label: "Programmatic", 187 collapsible: true, 188 items: [ 189 { 190 id: "campaigns", 191 label: "Campaigns", 192 to: import_dist$1.newNavAdsCampaignsPath, 193 permission: permissions.adsGetCampaignSettings 194 }, 195 { 196 id: "targeting", 197 label: "Targeting", 198 to: import_dist$1.newNavAdsTargetingPath, 199 permission: permissions.adsGetCampaignSettings 200 }, 201 { 202 id: "flights", 203 label: "Flights", 204 to: import_dist$1.newNavAdsFlightsPath, 205 permission: permissions.adsGetCampaignSettings 206 }, 207 { 208 id: "experiments", 209 label: "Experiments", 210 to: import_dist$1.newNavAdsExperimentsPath, 211 permission: permissions.adsGetCampaignSettings 212 } 213 ] 214 }, 215 { 216 id: "decoupled", 217 label: "Decoupled", 218 collapsible: true, 219 items: [{ 220 id: "decoupled-campaigns", 221 label: "Decoupled campaigns", 222 to: import_dist$1.newNavAdsDecoupledCampaignsPath, 223 permission: permissions.adsGetCampaignSettings 224 }, { 225 id: "dsps", 226 label: "Demand-side platforms", 227 to: import_dist$1.newNavAdsDspsPath, 228 permission: permissions.adsSystemManagement 229 }] 230 }, 231 { 232 id: "dmp", 233 label: "DMP", 234 collapsible: true, 235 items: [{ 236 id: "dmp-providers", 237 label: "Providers (global)", 238 to: import_dist$1.newNavAdsDmpProvidersPath, 239 permission: permissions.adsSystemManagement 240 }, { 241 id: "dmp-account-mappings", 242 label: "Account mappings (this account)", 243 to: import_dist$1.newNavAdsDmpAccountMappingsPath, 244 permission: permissions.adsGetCampaignSettings 245 }] 246 }, 247 { 248 id: "accounts", 249 label: "Accounts", 250 collapsible: true, 251 items: [ 252 { 253 id: "settings-account", 254 label: "Account", 255 to: ({ accountId }) => (0, import_dist$1.newNavAdsSettingsSectionPath)({ 256 accountId, 257 sectionId: "account" 258 }), 259 permission: permissions.adsEditCampaignSettings 260 }, 261 { 262 id: "settings-tracking", 263 label: "Sampling & sync", 264 to: ({ accountId }) => (0, import_dist$1.newNavAdsSettingsSectionPath)({ 265 accountId, 266 sectionId: "tracking" 267 }), 268 permission: permissions.adsEditCampaignSettings 269 }, 270 { 271 id: "settings-site-actions", 272 label: "Site actions", 273 to: ({ accountId }) => (0, import_dist$1.newNavAdsSettingsSectionPath)({ 274 accountId, 275 sectionId: "site-actions" 276 }), 277 permission: permissions.adsEditCampaignSettings 278 }, 279 { 280 id: "settings-attribution", 281 label: "Attribution reports", 282 to: ({ accountId }) => (0, import_dist$1.newNavAdsSettingsSectionPath)({ 283 accountId, 284 sectionId: "attribution" 285 }), 286 permission: permissions.adsEditCampaignSettings 287 }, 288 { 289 id: "settings-dsp-data", 290 label: "DSP data", 291 to: ({ accountId }) => (0, import_dist$1.newNavAdsSettingsSectionPath)({ 292 accountId, 293 sectionId: "dsp-data" 294 }), 295 permission: permissions.adsEditCampaignSettings 296 } 297 ] 298 }, 299 { 300 id: "general", 301 label: "General", 302 collapsible: true, 303 items: [ 304 { 305 id: "exchanges", 306 label: "Exchanges", 307 to: import_dist$1.newNavAdsExchangesPath, 308 permission: permissions.adsSystemManagement 309 }, 310 { 311 id: "prediction-models", 312 label: "Prediction models", 313 to: import_dist$1.newNavAdsPredictionModelsPath, 314 permission: permissions.adsSystemManagement 315 }, 316 { 317 id: "video-responses", 318 label: "Video responses", 319 to: import_dist$1.newNavAdsVideoResponsesPath, 320 permission: permissions.adsSystemManagement 321 } 322 ] 323 }, 324 { 325 id: "audit", 326 belowDivider: true, 327 items: [{ 328 id: "audit-log", 329 label: "Audit log", 330 to: import_dist$1.newNavAdsAuditLogPath, 331 permission: permissions.adsGetCampaignSettings 332 }, { 333 id: "system", 334 label: "Server configuration", 335 to: import_dist$1.newNavAdsSystemPath, 336 permission: permissions.adsSystemManagement 337 }] 338 }, 339 { 340 id: "utilities", 341 label: "Utilities", 342 collapsible: true, 343 belowDivider: true, 344 items: [{ 345 id: "utility-zip-radius", 346 label: "ZIP radius builder", 347 to: ({ accountId }) => (0, import_dist$1.newNavAdsUtilityPath)({ 348 accountId, 349 utilityId: "zip-radius" 350 }), 351 permission: permissions.adsGetCampaignSettings 352 }, { 353 id: "utility-pixel-tracking", 354 label: "Pixel tracking builder", 355 to: ({ accountId }) => (0, import_dist$1.newNavAdsUtilityPath)({ 356 accountId, 357 utilityId: "pixel-tracking" 358 }), 359 permission: permissions.adsGetCampaignSettings 360 }] 361 } 362]; 363/** A zone with no visible item is dropped whole, so a user without the system permission sees no empty heading. */ 364var getVisibleAdsNavZones = (userRoles) => adsNavZones.map((zone) => ({ 365 ...zone, 366 items: zone.items.filter((item) => isPermitted(userRoles, item.permission)) 367})).filter((zone) => zone.items.length > 0); 368/**
369* Matched on a path boundary rather than a bare prefix, so a nested route (a campaign, a section) keeps its 370* parent row selected while a sibling whose path merely starts with the same characters does not steal it. 371* The zone comes back too, so arriving on a deep link opens the section that holds the row. 372*/ 373var getSelectedAdsNav = (pathname, accountId) => { 374 for (const zone of adsNavZones) { 375 const match = zone.items.find((item) => { 376 const path = item.to({ accountId }); 377 return pathname === path || pathname.startsWith(`${path}/`); 378 }); 379 if (match) return { 380 zoneId: zone.id, 381 itemId: match.id 382 }; 383 } 384 return null; 385}; 386//#endregion 387//#region node_modules/@fortawesome/pro-regular-svg-icons/faBullseyePointer.js 388var require_faBullseyePointer = /* @__PURE__ */ __commonJSMin(((exports) => { 389 Object.defineProperty(exports, "__esModule", { value: true }); 390 var prefix = "far"; 391 var iconName = "bullseye-pointer"; 392 var width = 512; 393 var height = 512; 394 var aliases = []; 395 var unicode = "f649"; 396 var svgPathData = "M464 256c0 114.9-93.1 208-208 208-4.2 0-8.3-.1-12.4-.4l-6.9 20.6c-3.4 10.3-8.9 18.8-15.6 25.4 11.4 1.6 23 2.4 34.9 2.4 141.4 0 256-114.6 256-256S397.4 0 256 0 0 114.6 0 256c0 11.8 .8 23.5 2.4 34.9 6.6-6.7 15.1-12.1 25.4-15.6l20.6-6.9c-.2-4.1-.4-8.2-.4-12.4 0-114.9 93.1-208 208-208s208 93.1 208 208zm-64 0c0-79.5-64.5-144-144-144-76.5 0-139.1 59.7-143.7 135.1L163.6 230c11.3-40.4 48.4-70 92.4-70 53 0 96 43 96 96 0 44-29.6 81.1-70 92.4l-17.1 51.3C340.3 395.1 400 332.5 400 256zM255.2 277.1c1.9-5.8 .4-12.1-3.9-16.4s-10.6-5.8-16.4-3.9l-192 64C36.4 323 32 329.1 32 336s4.4 13 10.9 15.2l63 21-90.9 90.9c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l90.9-90.9 21 63c2.2 6.5 8.3 10.9 15.2 10.9s13-4.4 15.2-10.9l64-192z"; 397 exports.definition = { 398 prefix, 399 iconName, 400 icon: [ 401 width, 402 height, 403 aliases, 404 unicode, 405 svgPathData 406 ] 407 }; 408 exports.faBullseyePointer = exports.definition; 409 exports.prefix = prefix; 410 exports.iconName = iconName; 411 exports.width = width; 412 exports.height = height; 413 exports.ligatures = aliases; 414 exports.unicode = unicode; 415 exports.svgPathData = svgPathData; 416 exports.aliases = aliases; 417})); 418//#endregion 419//#region node_modules/@fortawesome/pro-regular-svg-icons/faLocationDot.js 420var require_faLocationDot = /* @__PURE__ */ __commonJSMin(((exports) => { 421 Object.defineProperty(exports, "__esModule", { value: true }); 422 var prefix = "far"; 423 var iconName = "location-dot"; 424 var width = 384; 425 var height = 512; 426 var aliases = ["map-marker-alt"]; 427 var unicode = "f3c5"; 428 var svgPathData = "M48 188.6C48 111.7 111.7 48 192 48s144 63.7 144 140.6c0 45.6-23.8 101.5-58.9 157.1-28.3 44.8-61 84.8-85.1 112.1-24.1-27.3-56.7-67.2-85.1-112.1-35.1-55.5-58.9-111.5-58.9-157.1zM192 0C86 0 0 84.4 0 188.6 0 307.9 120.2 450.9 170.4 505.4 182.2 518.2 201.8 518.2 213.6 505.4 263.8 450.9 384 307.9 384 188.6 384 84.4 298 0 192 0zM160 192a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm112 0a80 80 0 1 0 -160 0 80 80 0 1 0 160 0z"; 429 exports.definition = { 430 prefix, 431 iconName, 432 icon: [ 433 width, 434 height, 435 aliases, 436 unicode, 437 svgPathData 438 ] 439 }; 440 exports.faLocationDot = exports.definition; 441 exports.prefix = prefix; 442 exports.iconName = iconName; 443 exports.width = width; 444 exports.height = height; 445 exports.ligatures = aliases; 446 exports.unicode = unicode; 447 exports.svgPathData = svgPathData; 448 exports.aliases = aliases; 449})); 450//#endregion 451//#region client/components/newNav/components/ads/adsUtilities.ts 452var import_faBullseyePointer = require_faBullseyePointer(); 453var import_faLocationDot = require_faLocationDot(); 454var adsUtilities = [{ 455 id: "zip-radius", 456 icon: import_faLocationDot.faLocationDot, 457 name: "ZIP radius builder", 458 description: "Expand a list of stores into every ZIP code within a given radius, and the DMAs those ZIP codes fall in." 459}, { 460 id: "pixel-tracking", 461 icon: import_faBullseyePointer.faBullseyePointer, 462 name: "Pixel tracking builder", 463 description: "The advertiser's tracking URLs and the conditions that decide which of them the ad server sends back." 464}]; 465var findAdsUtility = (utilityId) => adsUtilities.find((utility) => utility.id === utilityId); 466//#endregion 467//#region common/adsPixelRulesModel.ts 468var import_faAngleRight = require_faAngleRight(); 469var import_faAngleLeft = require_faAngleLeft(); 470var import_faPlus = require_faPlus(); 471var import_faTrash = require_faTrash(); 472/**
473* The third-party tracking pixel file: one CSV per campaign, read by the ad server at bid time to decide which 474* of the advertiser's tracking URLs belong in the VAST response. The column list, the operators and the two 475* markers below are the format the Java side defines; nothing here may be widened without changing that side 476* first, because a file we accept and the ad server rejects takes a campaign off air. 477*/ 478/** `1002` adds the complete-impression pixels and is what an editor save writes. */ 479var PIXEL_PROTOCOL_VERSION_CURRENT = "1002"; 480/** A file whose first cell is not this literal is parsed as the pre-2018 format instead. */ 481var PIXEL_PROTOCOL_VERSION_HEADER = "Protocol Version"; 482var PIXEL_LANE_HEADER = "Lane"; 483var pixelOperators = [ 484 "equals", 485 "not equals", 486 "contains", 487 "not contains", 488 "contains any of", 489 "contains none of" 490]; 491var pixelUrlKindLabels = { 492 impression: "Impression pixels", 493 thirdPartyClick: "Third-party click pixels", 494 clickThrough: "Click-through URL", 495 vastWrapper: "VAST wrapper", 496 completeImpression: "Complete-impression pixels" 497}; 498/** The 50 columns the ad server reads, in its order, plus the lane column this client appends. */ 499var pixelColumnHeaders = [ 500 PIXEL_PROTOCOL_VERSION_HEADER, 501 "Placement ID", 502 "Placement name", 503 "Creative ID", 504 "Creative Name", 505 "Category", 506 ...Array.from({ length: 5 }).flatMap((_slot, index) => [ 507 `Key ${index + 1}`, 508 `Operator ${index + 1}`, 509 `Value ${index + 1}` 510 ]), 511 "Control Group", 512 "Experiment Group", 513 "Duration", 514 "Exchange", 515 "Audience", 516 "Start Date", 517 "End Date", 518 "Platform Type", 519 "Mobile Device Type", 520 "Impression pixel", 521 "Click Through Url", 522 "3rd Party Click Pixel", 523 "Vast Wrapper", 524 ...Array.from({ length: 5 }).map((_slot, index) => `Impression pixel ${index + 2}`), 525 ...Array.from({ length: 5 }).map((_slot, index) => `3rd Party Click Pixel ${index + 2}`), 526 ...Array.from({ length: 6 }).map((_slot, index) => `Complete Impression Pixel ${index + 1}`), 527 PIXEL_LANE_HEADER 528]; 529var emptyPixelRule = (id) => ({ 530 id, 531 placementId: "", 532 placementName: "", 533 creativeId: "", 534 creativeName: "", 535 category: "", 536 conditions: [], 537 controlGroup: "", 538 experimentGroup: "", 539 duration: "", 540 exchange: "", 541 audience: "", 542 lane: "", 543 startDate: "", 544 endDate: "", 545 platformType: "", 546 mobileDeviceType: "", 547 impressionPixels: [], 548 clickThroughUrl: "", 549 thirdPartyClickPixels: [], 550 vastWrapper: "", 551 completeImpressionPixels: [] 552}); 553/** 554* Excel rewrites these files, so a date is written ISO and read in every form Excel produces. Anything else is 555* returned as null and reported by the validator rather than silently treated as "no bound". 556*/ 557var parsePixelDate = (value) => { 558 const text = value.trim(); 559 if (!text) return null; 560 const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text); 561 if (iso) return `${iso[1]}-${pad(iso[2])}-${pad(iso[3])}`; 562 const american = /^(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})$/.exec(text); 563 if (american) return `${american[3].length === 2 ? `20${american[3]}` : american[3]}-${pad(american[1])}-${pad(american[2])}`; 564 return null; 565}; 566var pad = (value) => value.padStart(2, "0"); 567/** `*` and an empty cell both mean "matches anything". */ 568var isPixelWildcard = (value) => { 569 const text = value.trim(); 570 return text === "" || text === "*"; 571}; 572var isPixelPartial = (value) => value.trim() === "~"; 573/** 574* What the row targets, in one line, from the criteria that are not wildcards. A row that constrains nothing 575* reads as matching everything, which is the thing an operator most needs to be able to see at a glance. 576*/ 577var describePixelRuleMatch = (rule) => { 578 const parts = []; 579 if (!isPixelWildcard(rule.duration)) parts.push(isPixelPartial(rule.duration) ? "duration only if nothing else matches" : `${rule.duration}s`); 580 if (!isPixelWildcard(rule.platformType)) parts.push(describeMarker(rule.platformType, "platform")); 581 if (!isPixelWildcard(rule.mobileDeviceType)) parts.push(describeMarker(rule.mobileDeviceType, "device type")); 582 if (!isPixelWildcard(rule.exchange)) parts.push(describeMarker(rule.exchange, "exchange")); 583 if (!isPixelWildcard(rule.category)) parts.push(describeMarker(rule.category, "category")); 584 if (!isPixelWildcard(rule.controlGroup) && rule.controlGroup.trim().toLowerCase() !== "fal
584se") parts.push("control group"); 585 if (!isPixelWildcard(rule.experimentGroup)) parts.push(describeMarker(rule.experimentGroup, "experiment group")); 586 if (!isPixelWildcard(rule.audience)) parts.push(describeMarker(rule.audience, "audience")); 587 if (!isPixelWildcard(rule.lane)) parts.push(isPixelPartial(rule.lane) ? "lane only if nothing else matches" : `lane ${rule.lane.trim()}`); 588 rule.conditions.filter((condition) => condition.key.trim()).forEach((condition) => parts.push(`aux ${condition.key} ${condition.operator} ${condition.value}`)); 589 if (rule.startDate || rule.endDate) parts.push(`${rule.startDate || "any date"} to ${rule.endDate || "any date"}`); 590 return parts.length > 0 ? parts.join(", ") : "Matches everything"; 591}; 592var describeMarker = (value, criterion) => isPixelPartial(value) ? `${criterion} only if nothing else matches` : value.trim(); 593var countPixelRuleUrls = (rule) => rule.impressionPixels.filter(Boolean).length + rule.thirdPartyClickPixels.filter(Boolean).length + rule.completeImpressionPixels.filter(Boolean).length + (rule.clickThroughUrl ? 1 : 0) + (rule.vastWrapper ? 1 : 0); 594/** Handles the quoted fields the URLs need - a tracking URL with a comma in it is common. */ 595var splitCsvLine = (line) => { 596 const cells = []; 597 let cell = ""; 598 let isQuoted = false; 599 for (let index = 0; index < line.length; index++) { 600 const character = line[index]; 601 if (isQuoted) if (character === "\"") if (line[index + 1] === "\"") { 602 cell += "\""; 603 index++; 604 } else isQuoted = false; 605 else cell += character; 606 else if (character === "\"") isQuoted = true; 607 else if (character === ",") { 608 cells.push(cell); 609 cell = ""; 610 } else cell += character; 611 } 612 cells.push(cell); 613 return cells; 614}; 615var toCsvCell = (value) => /[",\n]/.test(value) ? `"${value.replace(/"/g, "\"\"")}"` : value; 616var parsePixelRulesCsv = (csvText) => { 617 const lines = csvText.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.trim().length > 0); 618 if (lines.length === 0) return { 619 rules: [], 620 protocolVersion: PIXEL_PROTOCOL_VERSION_CURRENT, 621 isLegacyFormat: false 622 }; 623 const isLegacyFormat = splitCsvLine(lines[0])[0]?.trim() !== PIXEL_PROTOCOL_VERSION_HEADER; 624 const bodyLines = isLegacyFormat ? lines : lines.slice(1); 625 const rules = bodyLines.map((line, index) => toRule(splitCsvLine(line), index)); 626 return { 627 rules, 628 protocolVersion: (rules[0] ? cellAt(splitCsvLine(bodyLines[0]), 0) : "") || "1002", 629 isLegacyFormat 630 }; 631}; 632var cellAt = (cells, index) => (cells[index] ?? "").trim(); 633var toRule = (cells, index) => { 634 const conditions = []; 635 for (let slot = 0; slot < 5; slot++) { 636 const base = 6 + slot * 3; 637 const key = cellAt(cells, base); 638 if (!key) continue; 639 conditions.push({ 640 key, 641 operator: cellAt(cells, base + 1), 642 value: cellAt(cells, base + 2) 643 }); 644 } 645 return { 646 id: `row-${index + 1}`, 647 placementId: cellAt(cells, 1), 648 placementName: cellAt(cells, 2), 649 creativeId: cellAt(cells, 3), 650 creativeName: cellAt(cells, 4), 651 category: cellAt(cells, 5), 652 conditions, 653 controlGroup: cellAt(cells, 21), 654 experimentGroup: cellAt(cells, 22), 655 duration: cellAt(cells, 23), 656 exchange: cellAt(cells, 24), 657 audience: cellAt(cells, 25), 658 lane: cellAt(cells, 50), 659 startDate: cellAt(cells, 26), 660 endDate: cellAt(cells, 27), 661 platformType: cellAt(cells, 28), 662 mobileDeviceType: cellAt(cells, 29), 663 impressionPixels: [cellAt(cells, 30), ...range(34, 5).map((at) => cellAt(cells, at))].filter(Boolean), 664 clickThroughUrl: cellAt(cells, 31), 665 thirdPartyClickPixels: [cellAt(cells, 32), ...range(39, 5).map((at) => cellAt(cells, at))].filter(Boolean), 666 vastWrapper: cellAt(cells, 33), 667 completeImpressionPixels: range(44, 6).map((at) => cellAt(cells, at)).filter(Boolean) 668 }; 669}; 670var range = (start, count) => Array.from({ length: count }).map((_item, index) => start + index); 671var slot = (values, index) => values[index] ?? ""; 672/** 673* Excel renders a long numeric id in scientific notation, so `Placement ID` and `Creative ID` are written with 674* a trailing tab to keep it text. The Java reader trims it back off. 675*/ 676var asExcelText = (value) => value ? `${value}\t` : ""; 677var serializePixelRulesCsv = (rules) => { 678 return [pixelColumnHeaders, ...rules.map((rule) => [ 679 PIXEL_PROTOCOL_VERSION_CURRENT, 680 asExcelText(rule.placementId), 681 rule.placementName, 682 asExcelText(rule.creativeId), 683 rule.creativeName, 684 rule.category, 685 ...range(0, 5).flatMap((slotIndex) => { 686 const condition = rule.conditions[slotIndex]; 687 return [ 688 condition?.key ?? "", 689 condition?.operator ?? "", 690 condition?.value ?? "" 691 ]; 692 }), 693 rule.controlGroup, 694 rule.experimentGroup, 695 rule.duration, 696 rule.exchange, 697 rule.audience, 698 rule.startDate, 699 rule.endDate, 700 rule.platformType, 701 rule.mobileDeviceType, 702 slot(rule.impressionPixels, 0), 703 rule.clickThroughUrl, 704 slot(rule.thirdPartyClickPixels, 0), 705 rule.vastWrapper, 706 ...range(1, 5).map((index) => slot(rule.impressionPixels, index)), 707 ...range(1, 5).map((index) => slot(rule.thirdPartyClickPixels, index)), 708 ...range(0, 6).map((index) => slot(rule.completeImpressionPixels, index)), 709 rule.lane 710 ])].map((cells) => cells.map(toCsvCell).join(",")).join("\n"); 711}; 712//#endregion
713//#region client/components/newNav/components/ads/pixels/pixelRulesMatching.ts 714var emptyPixelOpportunity = (date) => ({ 715 date, 716 duration: "", 717 exchange: "", 718 platformType: "", 719 mobileDeviceType: "", 720 hasDeviceId: false, 721 categories: [], 722 controlGroupName: "", 723 experimentGroups: [], 724 audiences: [], 725 lane: "", 726 auxValues: {} 727}); 728var worst = (scores) => { 729 if (scores.includes("NONE")) return "NONE"; 730 return scores.includes("PARTIAL") ? "PARTIAL" : "FULL"; 731}; 732/** Every criterion resolves `*`/empty to FULL and `~` to PARTIAL before it is compared at all. */ 733var scoreCriterion = (cell, compare) => { 734 if (isPixelWildcard(cell)) return "FULL"; 735 if (isPixelPartial(cell)) return "PARTIAL"; 736 return compare(cell.trim()) ? "FULL" : "NONE"; 737}; 738var scoreAuxCondition = (operator, cell, actual) => { 739 const values = cell.split(";").map((value) => value.trim()).filter(Boolean); 740 switch (operator) { 741 case "equals": return actual === cell; 742 case "not equals": return actual !== cell; 743 case "contains": return actual.includes(cell); 744 case "not contains": return !actual.includes(cell); 745 case "contains any of": return values.some((value) => actual.includes(value)); 746 case "contains none of": return values.every((value) => !actual.includes(value)); 747 default: return false; 748 } 749}; 750var scoreMobileDeviceType = (cell, opportunity) => { 751 const text = cell.trim().toLowerCase(); 752 const requiresDevice = text.endsWith("_with_device"); 753 const forbidsDevice = text.endsWith("_without_device"); 754 const base = requiresDevice || forbidsDevice ? text.slice(0, text.lastIndexOf("_with")) : text; 755 if (requiresDevice && !opportunity.hasDeviceId) return "NONE"; 756 if (forbidsDevice && opportunity.hasDeviceId) return "NONE"; 757 return scoreCriterion(base, (value) => value === opportunity.mobileDeviceType.trim().toLowerCase()); 758}; 759var scorePlatformType = (cell, opportunity) => { 760 const actual = opportunity.platformType.trim().toLowerCase(); 761 if (!actual || actual === "all") return "FULL"; 762 if (actual === "unknown") return "PARTIAL"; 763 const normalized = actual === "tablet" ? "mobile" : actual; 764 return scoreCriterion(cell, (value) => value.toLowerCase() === normalized); 765}; 766var matchPixelRules = (rules, opportunity) => rules.map((rule) => { 767 const start = parsePixelDate(rule.startDate); 768 const end = parsePixelDate(rule.endDate); 769 if (start && opportunity.date < start || end && opportunity.date > end) return { 770 rule, 771 score: "NONE", 772 criteria: [], 773 outOfWindow: true 774 }; 775 const criteria = [ 776 { 777 criterion: "Duration", 778 score: scoreCriterion(rule.duration, (value) => value === opportunity.duration.trim()) 779 }, 780 { 781 criterion: "Exchange", 782 score: scoreCriterion(rule.exchange, (value) => opportunity.exchange.startsWith(value)) 783 }, 784 { 785 criterion: "Category", 786 score: scoreCriterion(rule.category, (value) => opportunity.categories.includes(value)) 787 }, 788 { 789 criterion: "Control group", 790 score: scoreControlGroup(rule.controlGroup, opportunity) 791 }, 792 { 793 criterion: "Experiment group", 794 score: scoreCriterion(rule.experimentGroup, (value) => opportunity.experimentGroups.some((group) => group.toLowerCase() === value.toLowerCase())) 795 }, 796 { 797 criterion: "Audience", 798 score: scoreCriterion(rule.audience, (value) => opportunity.audiences.some((audience) => audience.toLowerCase() === value.toLowerCase())) 799 }, 800 { 801 criterion: "Lane", 802 score: scoreCriterion(rule.lane, (value) => value.toLowerCase() === opportunity.lane.trim().toLowerCase()) 803 }, 804 { 805 criterion: "Platform type", 806 score: scorePlatformType(rule.platformType, opportunity) 807 }, 808 { 809 criterion: "Mobile device type", 810 score: scoreMobileDeviceType(rule.mobileDeviceType, opportunity) 811 }, 812 ...rule.conditions.map((condition, index) => ({ 813 criterion: `Condition ${index + 1} (aux ${condition.key})`, 814 score: scoreCriterion(condition.value, (value) => scoreAuxCondition(condition.operator, value, opportunity.auxValues[normalizeAuxKey(condition.key)] ?? "")) 815 })) 816 ]; 817 return { 818 rule, 819 score: worst(criteria.map((result) => result.score)), 820 criteria, 821 outOfWindow: false 822 }; 823}); 824/** 825* A control-group impression matches when the cell is contained in the request's group name; a normal impression 826* matches only the literal `false`. 827*/ 828var scoreControlGroup = (cell, opportunity) => scoreCriterion(cell, (value) => opportunity.controlGroupName ? opportunity.controlGroupName.includes(value) : value.toLowerCase() === "fal
828se"); 829var normalizeAuxKey = (key) => key.replace(/^aux\s*/i, "").trim(); 830/** 831* The URLs the response would carry. Grouped by the VAST element they become, and within each group a FULL row 832* beats every PARTIAL one - which is the whole point of the `~` marker. 833*/ 834var resolvePixelUrls = (matches) => { 835 const surviving = matches.filter((match) => !match.outOfWindow && match.score !== "NONE"); 836 const pick = (kind) => { 837 const fromFullRows = surviving.filter((match) => match.score === "FULL").flatMap((match) => ruleUrls(match.rule, kind)); 838 return fromFullRows.length > 0 ? fromFullRows : surviving.flatMap((match) => ruleUrls(match.rule, kind)); 839 }; 840 return { 841 impression: pick("impression"), 842 thirdPartyClick: pick("thirdPartyClick"), 843 completeImpression: pick("completeImpression"), 844 clickThrough: pick("clickThrough"), 845 vastWrapper: pick("vastWrapper") 846 }; 847}; 848var ruleUrls = (rule, kind) => { 849 switch (kind) { 850 case "impression": return rule.impressionPixels.filter(Boolean); 851 case "thirdPartyClick": return rule.thirdPartyClickPixels.filter(Boolean); 852 case "completeImpression": return rule.completeImpressionPixels.filter(Boolean); 853 case "clickThrough": return rule.clickThroughUrl ? [rule.clickThroughUrl] : []; 854 case "vastWrapper": return rule.vastWrapper ? [rule.vastWrapper] : []; 855 } 856}; 857//#endregion 858//#region client/components/newNav/components/ads/pixels/PixelMatchSimulator.tsx 859var scoreDisplay = { 860 FULL: { 861 label: "Full", 862 color: "success" 863 }, 864 PARTIAL: { 865 label: "Partial", 866 color: "warning" 867 }, 868 NONE: { 869 label: "No match", 870 color: "default" 871 } 872}; 873/** 874* Describe one opportunity and see which rows the ad server would pick and which URLs would be emitted. This is 875* the thing a spreadsheet cannot do, and it is the only reason to prefer this page to Excel rather than merely 876* find it nicer. 877*/ 878var PixelMatchSimulator = (props) => { 879 const [opportunity, setOpportunity] = (0, import_react.useState)(emptyPixelOpportunity(props.today)); 880 const matches = (0, import_react.useMemo)(() => matchPixelRules(props.rules, opportunity), [props.rules, opportunity]); 881 const urls = (0, import_react.useMemo)(() => resolvePixelUrls(matches), [matches]); 882 const set = (key, value) => setOpportunity((current) => ({ 883 ...current, 884 [key]: value 885 })); 886 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 887 sx: simulatorSx$1, 888 children: [ 889 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 890 variant: "h5", 891 children: "Match simulator" 892 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 893 variant: "helper", 894 color: "text.secondary", 895 children: "Describe one impression opportunity - platform, device, categories, control group, experiment groups, audiences, lane, aux values - and this shows which pixel rows the ad server would pick for it and which URLs it would emit. It runs the same scoring the ad server does: a row scores Full only when every criterion is Full, and within each group of URLs a Full row displaces the Partial ones. Nothing is saved. It is the check a spreadsheet cannot do." 896 })] }), 897 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 898 sx: fieldsGridSx$1, 899 children: [ 900 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 901 size: "small", 902 type: "date", 903 label: "Date", 904 value: opportunity.date, 905 onChange: (event) => set("date", event.target.value), 906 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-date-input" } 907 }), 908 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 909 size: "small", 910 label: "Duration (seconds)", 911 value: opportunity.duration, 912 onChange: (event) => set("duration", event.target.value), 913 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-duration-input" } 914 }), 915 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 916 size: "small", 917 label: "Exchange",
918 value: opportunity.exchange, 919 onChange: (event) => set("exchange", event.target.value), 920 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-exchange-input" } 921 }), 922 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 923 size: "small", 924 label: "Platform type", 925 placeholder: "desktop, mobile, tablet, unknown", 926 value: opportunity.platformType, 927 onChange: (event) => set("platformType", event.target.value), 928 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-platform-input" } 929 }), 930 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 931 size: "small", 932 label: "Mobile device type", 933 placeholder: "app or web", 934 value: opportunity.mobileDeviceType, 935 onChange: (event) => set("mobileDeviceType", event.target.value), 936 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-device-input" } 937 }), 938 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 939 size: "small", 940 label: "Categories", 941 placeholder: "Comma separated", 942 value: opportunity.categories.join(","), 943 onChange: (event) => set("categories", splitList(event.target.value)), 944 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-categories-input" } 945 }), 946 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 947 size: "small", 948 label: "Control group name", 949 placeholder: "Empty for a normal impression", 950 value: opportunity.controlGroupName, 951 onChange: (event) => set("controlGroupName", event.target.value), 952 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-control-group-input" } 953 }), 954 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 955 size: "small", 956 label: "Experiment groups", 957 placeholder: "Comma separated", 958 value: opportunity.experimentGroups.join(","), 959 onChange: (event) => set("experimentGroups", splitList(event.target.value)), 960 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-experiment-input" } 961 }), 962 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 963 size: "small", 964 label: "Audiences", 965 placeholder: "Comma separated", 966 value: opportunity.audiences.join(","), 967 onChange: (event) => set("audiences", splitList(event.target.value)), 968 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-audiences-input" } 969 }), 970 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 971 size: "small", 972 label: "Lane", 973 placeholder: "The lane the request lands in", 974 value: opportunity.lane, 975 onChange: (event) => set("lane", event.target.value), 976 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-lane-input" } 977 }), 978 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 979 size: "small", 980 label: "Aux values", 981 placeholder: "1=SKU123, 3=blue", 982 value: formatAuxValues(opportunity.auxValues), 983 onChange: (event) => set("auxValues", parseAuxValues(event.target.value)), 984 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-sim-aux-input" } 985 }), 986 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.FormControlLabel, { 987 control: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Checkbox, { 988 size: "small", 989 checked: opportunity.hasDeviceId, 990 onChange: (event) => set("hasDeviceId", event.target.checked), 991 "data-tracking-id": "tracking-id-ads-pixel-sim-device-id-checkbox" 992 }), 993 label: "The request carries a device id" 994 }) 995 ] 996 }), 997 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Paper, { 998 variant: "outlined", 999 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 1000 size: "small", 1001 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [
1002 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1003 sx: headCellSx, 1004 children: "Placement" 1005 }), 1006 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1007 sx: headCellSx, 1008 children: "Result" 1009 }), 1010 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1011 sx: headCellSx, 1012 children: "Why" 1013 }) 1014 ] }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: matches.map((match) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 1015 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1016 sx: bodyCellSx, 1017 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1018 variant: "subtitle2", 1019 children: match.rule.placementName || match.rule.placementId || "Unnamed placement" 1020 }) 1021 }), 1022 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1023 sx: bodyCellSx, 1024 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 1025 size: "small", 1026 variant: match.score === "NONE" ? "outlined" : "standard", 1027 color: scoreDisplay[match.score].color, 1028 label: match.outOfWindow ? "Outside its dates" : scoreDisplay[match.score].label 1029 }) 1030 }), 1031 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 1032 sx: bodyCellSx, 1033 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1034 variant: "body1", 1035 color: "text.secondary", 1036 children: describeWhy(match.outOfWindow, match.criteria) 1037 }) 1038 }) 1039 ] }, match.rule.id)) })] 1040 }) 1041 }), 1042 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1043 sx: urlsSx, 1044 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1045 variant: "h5", 1046 children: "URLs in the response" 1047 }), Object.keys(pixelUrlKindLabels).map((kind) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1048 variant: "subtitle2", 1049 children: pixelUrlKindLabels[kind] 1050 }), urls[kind].length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1051 variant: "body1", 1052 color: "text.secondary", 1053 children: "None" 1054 }) : urls[kind].map((url, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1055 variant: "body1", 1056 sx: urlSx$1, 1057 children: url 1058 }, index))] }, kind))] 1059 }) 1060 ] 1061 }); 1062}; 1063var describeWhy = (outOfWindow, criteria) => { 1064 if (outOfWindow) return "The opportunity's date is outside this row's start and end dates"; 1065 const failed = criteria.filter((result) => result.score === "NONE").map((result) => result.criterion); 1066 if (failed.length > 0) return `${failed.join(", ")} did not match`; 1067 const partial = criteria.filter((result) => result.score === "PARTIAL").map((result) => result.criterion); 1068 return partial.length > 0 ? `${partial.join(", ")} matched only partially` : "Every criterion matched"; 1069}; 1070var splitList = (value) => value.split(",").map((item) => item.trim()).filter(Boolean); 1071var formatAuxValues = (values) => Object.entries(values).map(([key, value]) => `${key}=${value}`).join(", "); 1072var parseAuxValues = (text) => { 1073 const values = {}; 1074 text.split(",").forEach((pair) => { 1075 const [key, ...rest] = pair.split("="); 1076 if (key.trim() && rest.length > 0) values[key.trim()] = rest.join("=").trim(); 1077 }); 1078 return values; 1079}; 1080var simulatorSx$1 = { 1081 display: "flex", 1082 flexDirection: "column", 1083 gap: 2 1084}; 1085var fieldsGridSx$1 = { 1086 display: "grid", 1087 gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", 1088 gap: 2, 1089 alignItems: "center" 1090}; 1091var urlsSx = { 1092 display: "flex", 1093 flexDirection: "column", 1094 gap: 1 1095}; 1096var urlSx$1 = { wordBreak: "break-all" }; 1097var pixelCriteria = [ 1098 { 1099 key: "duration", 1100 label: "Duration", 1101 help: "The video's length in seconds." 1102 }, 1103 { 1104 key: "platformType", 1105 label: "Platform type", 1106 options: [ 1107 "desktop", 1108 "tablet", 1109 "mobile", 1110 "ctv", 1111 "iptv", 1112 "connected_device" 1113 ] 1114 }, 1115 { 1116 key: "mobileDeviceType", 1117 label: "Mobile device type", 1118 options: [ 1119 "app", 1120 "web", 1121 "app_with_device", 1122 "app_without_device", 1123 "web_with_device", 1124 "web_without_device" 1125 ], 1126 help: "The _with_device and _without_device suffixes also require, or forbid, a device id on the request." 1127 }, 1128 { 1129 key: "exchange", 1130 label: "Exchange", 1131 help: "Matched as a prefix - the opportunity's exchange has to start with this." 1132 }, 1133 { 1134 key: "category", 1135 label: "Category" 1136 }, 1137 { 1138 key: "controlGroup", 1139 label: "Control group", 1140 help: "A normal impression matches the literal false; a control-group one matches a name contained in the request's group." 1141 }, 1142 { 1143 key: "experimentGroup", 1144 label: "Experiment group" 1145 }, 1146 {
1147 key: "audience", 1148 label: "Audience" 1149 }, 1150 { 1151 key: "lane", 1152 label: "Lane", 1153 help: "The budget lane the request landed in, from this campaign's flights and budget." 1154 } 1155]; 1156/** A criterion the file leaves empty or `*` is not a condition at all, so it has no row. */ 1157var isCriterionSet = (rule, key) => !isPixelWildcard(rule[key]); 1158//#endregion 1159//#region client/components/newNav/components/ads/pixels/PixelUrlSlots.tsx 1160/** 1161* The six numbered columns of one URL kind, as an add and remove list. Six numbered inputs would show five empty 1162* boxes for a placement that carries one pixel, and the numbering carries no meaning of its own. 1163*/ 1164var PixelUrlSlots = (props) => { 1165 const setUrl = (index, value) => props.onChange(props.urls.map((url, at) => at === index ? value : url)); 1166 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1167 sx: groupSx$4, 1168 children: [ 1169 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1170 variant: "h5", 1171 children: props.label 1172 }), 1173 props.help && /* @__P
1173URE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1174 variant: "helper", 1175 color: "text.secondary", 1176 children: props.help 1177 }), 1178 props.urls.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1179 variant: "body1", 1180 color: "text.secondary", 1181 children: "None on this placement" 1182 }), 1183 props.urls.map((url, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1184 sx: rowSx$3, 1185 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1186 fullWidth: true, 1187 size: "small", 1188 label: `${props.label} ${index + 1}`, 1189 value: url, 1190 disabled: props.readOnly, 1191 onChange: (event) => setUrl(index, event.target.value), 1192 inputProps: { "data-tracking-id": `${props.trackingIdPrefix}-url-input` } 1193 }), !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 1194 size: "small", 1195 onClick: () => props.onChange(props.urls.filter((_url, at) => at !== index)), 1196 "data-tracking-id": `${props.trackingIdPrefix}-remove-btn`, 1197 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrash.faTrash }) 1198 })] 1199 }, index)), 1200 !props.readOnly && props.urls.length < 6 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1201 variant: "text", 1202 size: "small", 1203 sx: addSx$1, 1204 onClick: () => props.onChange([...props.urls, ""]), 1205 "data-tracking-id": `${props.trackingIdPrefix}-add-btn`, 1206 children: `Add ${props.label.toLowerCase()}` 1207 }) 1208 ] 1209 }); 1210}; 1211var groupSx$4 = { 1212 display: "flex", 1213 flexDirection: "column", 1214 gap: 1 1215}; 1216var rowSx$3 = { 1217 display: "flex", 1218 alignItems: "center", 1219 gap: 1 1220}; 1221var addSx$1 = { alignSelf: "flex-start" }; 1222//#endregion 1223//#region client/components/newNav/components/ads/pixels/PixelRuleDetailPanel.tsx 1224var IS_OPERATOR = "is"; 1225var FALLBACK_OPERATOR = "only if nothing else matches"; 1226/** 1227* The two halves of a placement, kept apart because they are never changed together: which slice of the buy the 1228* row applies to, and which of the advertiser's URLs it carries. 1229* 1230* The match half is a list of the conditions the row actually has, not a field per criterion the file could 1231* carry: nine of the ten cells are empty on a typical row, and an empty cell means "matches anything", which is 1232* the same thing as having no condition at all. 1233*/ 1234var PixelRuleDetailPanel = (props) => { 1235 const [tab, setTab] = (0, import_react.useState)("MATCH"); 1236 const [addAnchor, setAddAnchor] = (0, import_react.useState)(null); 1237 const [pending, setPending] = (0, import_react.useState)([]); 1238 const { rule } = props; 1239 const set = (key, value) => props.onChange({ 1240 ...rule, 1241 [key]: value 1242 }); 1243 const setCondition = (index, condition) => set("conditions", rule.conditions.map((existing, at) => at === index ? condition : existing)); 1244 const optionsFor = (def) => def.key === "lane" ? props.laneNames?.length ? props.laneNames : void 0 : def.options; 1245 const firstValue = (def) => optionsFor(def)?.[0] ?? ""; 1246 const rows = pixelCriteria.filter((def) => isCriterionSet(rule, def.key) || pending.includes(def.key)); 1247 const unused = pixelCriteria.filter((def) => !rows.includes(def)); 1248 const addCriterion = (def) => { 1249 setPending([...pending, def.key]); 1250 set(def.key, firstValue(def)); 1251 setAddAnchor(null); 1252 }; 1253 const removeCriterion = (def) => { 1254 setPending(pending.filter((key) => key !== def.key)); 1255 set(def.key, ""); 1256 }; 1257 const swapCriterion = (from, to) => { 1258 setPending([...pending.filter((key) => key !== from.key), to.key]); 1259 props.onChange({ 1260 ...rule, 1261 [from.key]: "", 1262 [to.key]: firstValue(to) 1263 }); 1264 }; 1265 const addAuxCondition = () => { 1266 set("conditions", [...rule.conditions, { 1267 key: "1", 1268 operator: "equals", 1269 value: "" 1270 }]); 1271 setAddAnchor(null); 1272 }; 1273 const isEmpty = rows.length === 0 && rule.conditions.length === 0; 1274 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1275 sx: panelSx$1, 1276 children: [ 1277 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1278 sx: identitySx, 1279 children: [
1280 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1281 fullWidth: true, 1282 size: "small", 1283 label: "Placement name", 1284 value: rule.placementName, 1285 disabled: props.readOnly, 1286 onChange: (event) => set("placementName", event.target.value), 1287 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-placement-name-input" } 1288 }), 1289 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1290 fullWidth: true, 1291 size: "small", 1292 label: "Placement ID", 1293 value: rule.placementId, 1294 disabled: props.readOnly, 1295 onChange: (event) => set("placementId", event.target.value), 1296 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-placement-id-input" } 1297 }), 1298 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1299 fullWidth: true, 1300 size: "small", 1301 label: "Creative name", 1302 value: rule.creativeName, 1303 disabled: props.readOnly, 1304 onChange: (event) => set("creativeName", event.target.value), 1305 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-creative-name-input" } 1306 }), 1307 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1308 fullWidth: true, 1309 size: "small", 1310 label: "Creative ID", 1311 value: rule.creativeId, 1312 disabled: props.readOnly, 1313 onChange: (event) => set("creativeId", event.target.value), 1314 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-creative-id-input" } 1315 }) 1316 ] 1317 }), 1318 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleToggleButtonGroup, { 1319 exclusive: true, 1320 size: "small", 1321 value: tab, 1322 onChange: (_event, next) => next && setTab(next), 1323 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 1324 value: "MATCH", 1325 sx: tabSx, 1326 "data-tracking-id": "tracking-id-ads-pixel-match-tab", 1327 children: "Match" 1328 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 1329 value: "PIXELS", 1330 sx: tabSx, 1331 "data-tracking-id": "tracking-id-ads-pixel-pixels-tab", 1332 children: "Pixels" 1333 })] 1334 }), 1335 tab === "MATCH" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1336 sx: groupsSx, 1337 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1338 sx: conditionsSx, 1339 children: [ 1340 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1341 variant: "h5", 1342 children: "Flight window" 1343 }), 1344 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1345 variant: "helper", 1346 color: "text.secondary", 1347 children: "An opportunity outside these dates never reaches this row. Leave a date empty for no bound." 1348 }), 1349 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1350 sx: datesSx$1, 1351 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1352 fullWidth: true, 1353 size: "small", 1354 type: "date", 1355 label: "Start date", 1356 value: asDateInputValue(rule.startDate), 1357 disabled: props.readOnly, 1358 onChange: (event) => set("startDate", event.target.value), 1359 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-start-date-input" } 1360 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1361 fullWidth: true, 1362 size: "small", 1363 type: "date", 1364 label: "End date", 1365 value: asDateInputValue(rule.endDate), 1366 disabled: props.readOnly, 1367 onChange: (event) => set("endDate", event.target.value), 1368 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-end-date-input" } 1369 })] 1370 }) 1371 ] 1372 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1373 sx: conditionsSx, 1374 children: [ 1375 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1376 variant: "h5", 1377 children: "Conditions" 1378 }), 1379 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1380 variant: "helper", 1381 color: "text.secondary", 1382 children: `Every condition here has to pass. A criterion with no row matches every opportunity. "${FALLBACK_OPERATOR}" is the ~ in the file: the row is used only when no other row matches the opportunity exactly.` 1383 }),
1384 isEmpty && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1385 variant: "body1", 1386 color: "text.secondary", 1387 children: "No conditions - this placement matches every opportunity in its flight window" 1388 }), 1389 rows.map((def) => { 1390 const value = rule[def.key]; 1391 const isFallback = isPixelPartial(value); 1392 const options = optionsFor(def); 1393 const valueOptions = options && !options.includes(value) ? [...options, value] : options; 1394 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1395 sx: conditionRowSx, 1396 children: [ 1397 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1398 select: true, 1399 size: "small", 1400 label: "Condition", 1401 sx: criterionSx, 1402 value: def.key, 1403 disabled: props.readOnly, 1404 onChange: (event) => { 1405 const next = pixelCriteria.find((candidate) => candidate.key === event.target.value); 1406 next && swapCriterion(def, next); 1407 }, 1408 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-criterion-input" }, 1409 children: [def, ...unused].map((candidate) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1410 value: candidate.key, 1411 children: candidate.label 1412 }, candidate.key)) 1413 }), 1414 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1415 select: true, 1416 size: "small", 1417 label: "Operator", 1418 sx: operatorSx, 1419 value: isFallback ? FALLBACK_OPERATOR : IS_OPERATOR, 1420 disabled: props.readOnly, 1421 onChange: (event) => set(def.key, event.target.value === FALLBACK_OPERATOR ? "~" : firstValue(def)), 1422 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-criterion-operator-input" }, 1423 children: [IS_OPERATOR, FALLBACK_OPERATOR].map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1424 value: operator, 1425 children: operator 1426 }, operator)) 1427 }), 1428 isFallback ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { sx: valueSpacerSx }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1429 select: Boolean(valueOptions), 1430 fullWidth: true, 1431 size: "small", 1432 label: "Value", 1433 helperText: def.help, 1434 value, 1435 disabled: props.readOnly, 1436 onChange: (event) => set(def.key, event.target.value), 1437 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-criterion-value-input" }, 1438 children: valueOptions?.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1439 value: option, 1440 children: option 1441 }, option)) 1442 }), 1443 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 1444 size: "small", 1445 onClick: () => removeCriterion(def), 1446 "data-tracking-id": "tracking-id-ads-pixel-criterion-remove-btn", 1447 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrash.faTrash }) 1448 }) 1449 ] 1450 }, def.key); 1451 }), 1452 rule.conditions.map((condition, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1453 sx: conditionRowSx, 1454 children: [ 1455 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1456 select: true, 1457 size: "small", 1458 label: "Condition", 1459 sx: criterionSx, 1460 value: condition.key, 1461 disabled: props.readOnly, 1462 onChange: (event) => setCondition(index, { 1463 ...condition, 1464 key: event.target.value 1465 }), 1466 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-condition-key-input" }, 1467 children: Array.from({ length: 10 }).map((_key, at) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1468 value: String(at + 1),
1469 children: `Aux ${at + 1}` 1470 }, at)) 1471 }), 1472 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1473 select: true, 1474 size: "small", 1475 label: "Operator", 1476 sx: operatorSx, 1477 value: condition.operator, 1478 disabled: props.readOnly, 1479 onChange: (event) => setCondition(index, { 1480 ...condition, 1481 operator: event.target.value 1482 }), 1483 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-condition-operator-input" }, 1484 children: pixelOperators.map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1485 value: operator, 1486 children: operator 1487 }, operator)) 1488 }), 1489 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1490 fullWidth: true, 1491 size: "small", 1492 label: "Value", 1493 helperText: condition.operator.includes("any of") || condition.operator.includes("none of") ? "Separate values with a semicolon" : `Compared with the request's aux ${condition.key}`, 1494 value: condition.value, 1495 disabled: props.readOnly, 1496 onChange: (event) => setCondition(index, { 1497 ...condition, 1498 value: event.target.value 1499 }), 1500 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-condition-value-input" } 1501 }), 1502 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 1503 size: "small", 1504 onClick: () => set("conditions", rule.conditions.filter((_item, at) => at !== index)), 1505 "data-tracking-id": "tracking-id-ads-pixel-condition-remove-btn", 1506 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrash.faTrash }) 1507 }) 1508 ] 1509 }, index)), 1510 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1511 variant: "text", 1512 size: "small", 1513 sx: addSx, 1514 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 1515 disabled: unused.length === 0 && rule.conditions.length >= 5, 1516 onClick: (event) => setAddAnchor(event.currentTarget), 1517 "data-tracking-id": "tracking-id-ads-pixel-condition-add-btn", 1518 children: "Add condition" 1519 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Menu, { 1520 open: Boolean(addAnchor), 1521 anchorEl: addAnchor, 1522 onClose: () => setAddAnchor(null), 1523 children: [unused.map((def) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1524 onClick: () => addCriterion(def), 1525 "data-tracking-id": "tracking-id-ads-pixel-condition-add-item", 1526 children: def.label 1527 }
1527, def.key)), rule.conditions.length < 5 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 1528 onClick: addAuxCondition, 1529 "data-tracking-id": "tracking-id-ads-pixel-condition-add-aux-item", 1530 children: "An aux key the request carries" 1531 })] 1532 })] }) 1533 ] 1534 })] 1535 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1536 sx: groupsSx, 1537 children: [ 1538 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelUrlSlots, { 1539 label: "Impression pixel", 1540 help: "Fired when the impression is served.", 1541 urls: rule.impressionPixels, 1542 readOnly: props.readOnly, 1543 onChange: (urls) => set("impressionPixels", urls), 1544 trackingIdPrefix: "tracking-id-ads-pixel-impression" 1545 }), 1546 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelUrlSlots, { 1547 label: "Complete impression pixel", 1548 help: "Fired after 100% of the video is viewed.", 1549 urls: rule.completeImpressionPixels, 1550 readOnly: props.readOnly, 1551 onChange: (urls) => set("completeImpressionPixels", urls), 1552 trackingIdPrefix: "tracking-id-ads-pixel-complete" 1553 }), 1554 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelUrlSlots, { 1555 label: "Third-party click pixel", 1556 urls: rule.thirdPartyClickPixels, 1557 readOnly: props.readOnly, 1558 onChange: (urls) => set("thirdPartyClickPixels", urls), 1559 trackingIdPrefix: "tracking-id-ads-pixel-click" 1560 }), 1561 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1562 fullWidth: true, 1563 size: "small", 1564 label: "Click-through URL", 1565 value: rule.clickThroughUrl, 1566 disabled: props.readOnly, 1567 onChange: (event) => set("clickThroughUrl", event.target.value), 1568 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-click-through-input" } 1569 }), 1570 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1571 fullWidth: true, 1572 size: "small", 1573 label: "VAST wrapper", 1574 helperText: "Every wrapper in the file has to come from the same provider, or all be empty.", 1575 value: rule.vastWrapper, 1576 disabled: props.readOnly, 1577 onChange: (event) => set("vastWrapper", event.target.value), 1578 inputProps: { "data-tracking-id": "tracking-id-ads-pixel-vast-wrapper-input" } 1579 }) 1580 ] 1581 }) 1582 ] 1583 }); 1584}; 1585/** 1586* A date input renders nothing at all unless it is given `yyyy-MM-dd`, and Excel writes these files as `7/13/26`, 1587* so the stored date has to be converted or the field looks empty on a row that has one. 1588*/ 1589var asDateInputValue = (value) => value ? parsePixelDate(value) ?? "" : ""; 1590var panelSx$1 = { 1591 display: "flex", 1592 flexDirection: "column", 1593 gap: 3 1594}; 1595var identitySx = { 1596 display: "grid", 1597 gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", 1598 gap: 2 1599}; 1600var tabSx = { flex: 1 }; 1601var groupsSx = { 1602 display: "flex", 1603 flexDirection: "column", 1604 gap: 3 1605}; 1606var conditionsSx = { 1607 display: "flex", 1608 flexDirection: "column", 1609 gap: 1 1610}; 1611var datesSx$1 = { 1612 display: "flex", 1613 gap: 2, 1614 maxWidth: 520 1615}; 1616var conditionRowSx = { 1617 display: "flex", 1618 alignItems: "flex-start", 1619 gap: 1 1620}; 1621var criterionSx = { 1622 width: 200, 1623 flexShrink: 0 1624}; 1625var operatorSx = { 1626 width: 260, 1627 flexShrink: 0 1628}; 1629var valueSpacerSx = { flex: 1 }; 1630var addSx = { alignSelf: "flex-start" }; 1631//#endregion 1632//#region common/adsPixelRulesValidation.ts 1633var mobileDeviceTypes = [ 1634 "app", 1635 "web", 1636 "*", 1637 "~" 1638]; 1639var mobileDeviceSuffixes = ["_with_device", "_without_device"]; 1640/** 1641* Every rule here is one the Java side already enforces - a file it rejects takes the campaign off air. 1642* `laneNames` are the program's budget lanes, where they are known: a lane that is not one of them is a row that 1643* can never match, which nothing in the file itself can reveal. 1644*/ 1645var validatePixelRules = (rules, laneNames) => { 1646 const problems = [];
1647 rules.forEach((rule) => { 1648 const at = (message, isWarning) => { 1649 problems.push({ 1650 ruleId: rule.id, 1651 message, 1652 isWarning 1653 }); 1654 }; 1655 if (!rule.placementId.trim()) at("Placement ID is required - it is the row's identity"); 1656 rule.conditions.forEach((condition, index) => { 1657 const slot = index + 1; 1658 if (!pixelOperators.includes(condition.operator)) at(`Condition ${slot} has an unknown operator "${condition.operator}"`); 1659 if (!condition.value.trim()) at(`Condition ${slot} has a key but no value`); 1660 const key = condition.key.replace(/^aux\s*/i, "").trim(); 1661 if (!/^([1-9]|10)$/.test(key)) at(`Condition ${slot} has key "${condition.key}" - keys are 1 to 10`); 1662 if ((condition.operator === "contains any of" || condition.operator === "contains none of") && !condition.value.includes(";") && condition.value.trim()) at(`Condition ${slot} lists one value; "${condition.operator}" separates values with ";"`, true); 1663 }); 1664 if (laneNames?.length && !isPixelWildcard(rule.lane) && rule.lane.trim() !== "~" && !laneNames.includes(rule.lane.trim())) at(`Lane "${rule.lane.trim()}" is not one of this campaign's lanes (${laneNames.join(", ")}), so this row can never match`, true); 1665 if (!isValidMobileDeviceType(rule.mobileDeviceType)) at(`Mobile device type "${rule.mobileDeviceType}" is not app, web, * or ~`); 1666 if (!isPixelWildcard(rule.duration) && rule.duration.trim() !== "~" && !/^\d+$/.test(rule.duration.trim())) at(`Duration "${rule.duration}" is not a whole number of seconds`); 1667 const start = rule.startDate ? parsePixelDate(rule.startDate) : null; 1668 const end = rule.endDate ? parsePixelDate(rule.endDate) : null; 1669 if (rule.startDate && !start) at(`Start date "${rule.startDate}" is not a date`); 1670 if (rule.endDate && !end) at(`End date "${rule.endDate}" is not a date`); 1671 if (start && end && start > end) at("The end date is before the start date"); 1672 collectRuleUrls(rule).forEach((url) => { 1673 if (!hasBalancedMacros(url)) at(`"${truncate(url)}" has an unbalanced \${...} macro`); 1674 }); 1675 }); 1676 duplicates(rules.map((rule) => rule.placementId.trim()).filter(Boolean)).forEach((placementId) => { 1677 problems.push({ message: `Placement ID "${placementId}" is used by more than one row. A duplicate stops this campaign's attribution metadata being generated.` }); 1678 }); 1679 duplicates(rules.map((rule) => rule.placementName.trim()).filter(Boolean)).forEach((placementName) => { 1680 problems.push({ 1681 message: `Placement name "${placementName}" is used by more than one row, so only one of them can be looked up by name.`, 1682 isWarning: true 1683 }); 1684 }); 1685 const wrapperProviders = rules.filter((rule) => rule.vastWrapper.trim()).map((rule) => ({ 1686 placement: rule.placementId || rule.placementName, 1687 provider: wrapperProvider(rule.vastWrapper) 1688 })); 1689 const firstProvider = wrapperProviders[0]; 1690 const conflicting = wrapperProviders.find((candidate) => candidate.provider !== firstProvider?.provider); 1691 if (firstProvider && conflicting) problems.push({ message: `Every VAST wrapper in the file must come from one provider, or all be empty. "${firstProvider.placement}" uses ${firstProvider.provider} and "${conflicting.placement}" uses ${conflicting.provider}.` }); 1692 return problems; 1693}; 1694var isValidMobileDeviceType = (value) => { 1695 const text = value.trim().toLowerCase(); 1696 if (!text) return true; 1697 const suffix = mobileDeviceSuffixes.find((candidate) => text.endsWith(candidate)); 1698 const base = suffix ? text.slice(0, -suffix.length) : text; 1699 return mobileDeviceTypes.includes(base); 1700}; 1701/** 1702* A `$` that is not part of a `${name}` is what the Java check hunts for, and it exists because a legitimate URL 1703* can end in a bare `$` - so the valid macros come out first and only then is a stray one looked for. 1704*/ 1705var hasBalancedMacros = (url) => { 1706 const withoutMacros = url.replace(/\$\{[^{}]*\}/g, ""); 1707 return !/\$\{|\}|\$[A-Za-z_]/.test(withoutMacros); 1708}; 1709var collectRuleUrls = (rule) => [ 1710 ...rule.impressionPixels, 1711 ...rule.thirdPartyClickPixels, 1712 ...rule.completeImpressionPixels, 1713 rule.clickThroughUrl, 1714 rule.vastWrapper 1715].filter(Boolean); 1716/** The wrapper's host is what decides the provider;
1716 two wrappers from one host are one provider. */ 1717var wrapperProvider = (url) => { 1718 const match = /^[a-z]+:\/\/([^/?]+)/i.exec(url.trim()); 1719 return match ? match[1].toLowerCase() : url.trim().toLowerCase(); 1720}; 1721var duplicates = (values) => { 1722 const seen = /* @__PURE__ */ new Set(); 1723 const repeated = /* @__PURE__ */ new Set(); 1724 values.forEach((value) => { 1725 if (seen.has(value)) repeated.add(value); 1726 seen.add(value); 1727 }); 1728 return Array.from(repeated); 1729}; 1730var truncate = (value) => value.length > 60 ? `${value.slice(0, 57)}...` : value; 1731//#endregion 1732//#region client/components/newNav/components/ads/pixels/PixelRulesImportDialog.tsx 1733/** 1734* Paste or upload a file in the ad server's own format and load it in as unsaved rows. The vendor workbook path 1735* stays on the legacy generator page for now - it is an importer of vendor spreadsheets, which is a different job 1736* from editing the file we already own. 1737*/ 1738var PixelRulesImportDialog = (props) => { 1739 const [csvText, setCsvText] = (0, import_react.useState)(""); 1740 const fileInputRef = (0, import_react.useRef)(null); 1741 (0, import_react.useEffect)(() => { 1742 if (props.open) setCsvText(""); 1743 }, [props.open]); 1744 const parsed = csvText.trim() ? parsePixelRulesCsv(csvText) : null; 1745 const blocking = (parsed ? validatePixelRules(parsed.rules) : []).filter((problem) => !problem.isWarning); 1746 const readFile = (file) => { 1747 const reader = new FileReader(); 1748 reader.onload = () => setCsvText(String(reader.result ?? "")); 1749 reader.readAsText(file); 1750 }; 1751 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 1752 open: props.open, 1753 onClose: props.onClose, 1754 maxWidth: false, 1755 PaperProps: { sx: paperSx$7 }, 1756 children: [ 1757 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 1758 CloseIconButtonProps: { 1759 onClick: props.onClose, 1760 "data-tracking-id": "tracking-id-ads-pixel-import-close-btn" 1761 }, 1762 children: "Import a pixel rules CSV" 1763 }), 1764 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1765 sx: contentSx$11, 1766 children: [ 1767 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1768 variant: "outlined", 1769 size: "medium", 1770 sx: uploadSx, 1771 onClick: () => fileInputRef.current?.click(), 1772 "data-tracking-id": "tracking-id-ads-pixel-import-upload-btn", 1773 children: "Choose a file" 1774 }), 1775 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { 1776 ref: fileInputRef, 1777 type: "file", 1778 accept: ".csv,text/csv", 1779 hidden: true, 1780 onChange: (event) => { 1781 const file = event.target.files?.[0]; 1782 if (file) readFile(file); 1783 } 1784 }), 1785 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1786 fullWidth: true, 1787 multiline: true, 1788 size: "small", 1789 minRows: 8, 1790 maxRows: 12, 1791 label: "Or paste the CSV", 1792 value: csvText, 1793 onChange: (event) => setCsvText(event.target.value), 1794 inputProps: { 1795 "data-tracking-id": "tracking-id-ads-pixel-import-paste-input", 1796 style: { 1797 fontFamily: "monospace", 1798 fontSize: "12px" 1799 } 1800 } 1801 }),
1802 parsed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1803 variant: "body1", 1804 color: "text.secondary", 1805 children: `${parsed.rules.length} ${parsed.rules.length === 1 ? "placement" : "placements"} read.` 1806 }), 1807 parsed?.isLegacyFormat && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 1808 color: "warning", 1809 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "This is the old file format" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "It has no Protocol Version header row, so the ad server reads it as the pre-2018 format. Saving it here writes the current format instead." })] 1810 }), 1811 blocking.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 1812 color: "error", 1813 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: `${blocking.length} ${blocking.length === 1 ? "problem" : "problems"} the ad server would reject` }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1814 sx: problemsSx$1, 1815 children: [blocking.slice(0, MAX_LISTED_PROBLEMS).map((problem, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1816 variant: "body1", 1817 children: problem.message 1818 }, index)), blocking.length >
1818 MAX_LISTED_PROBLEMS && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1819 variant: "body1", 1820 color: "text.secondary", 1821 children: `and ${blocking.length - MAX_LISTED_PROBLEMS} more.` 1822 })] 1823 }) })] 1824 }) 1825 ] 1826 }) }), 1827 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1828 variant: "text", 1829 size: "large", 1830 onClick: props.onClose, 1831 "data-tracking-id": "tracking-id-ads-pixel-import-cancel-btn", 1832 children: "Cancel" 1833 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1834 variant: "contained", 1835 size: "large", 1836 disabled: !parsed || parsed.rules.length === 0, 1837 onClick: () => { 1838 props.onImport(parsed.rules); 1839 props.onClose(); 1840 }, 1841 "data-tracking-id": "tracking-id-ads-pixel-import-submit-btn", 1842 children: "Load into the editor" 1843 })] }) 1844 ] 1845 }); 1846}; 1847var MAX_LISTED_PROBLEMS = 8; 1848var paperSx$7 = { 1849 width: 720, 1850 maxWidth: "calc(100vw - 64px)" 1851}; 1852var contentSx$11 = { 1853 display: "flex", 1854 flexDirection: "column", 1855 gap: 2, 1856 paddingTop: 1 1857}; 1858var uploadSx = { alignSelf: "flex-start" }; 1859var problemsSx$1 = { 1860 display: "flex", 1861 flexDirection: "column", 1862 gap: .5 1863}; 1864//#endregion 1865//#region client/components/newNav/components/ads/pixels/PixelRulesSourceDialog.tsx 1866var import_faCopy = require_faCopy(); 1867/** The generated file, as the ad server will read it - so it can be diffed against what the advertiser was sent. */ 1868var PixelRulesSourceDialog = (props) => { 1869 const copy = () => { 1870 navigator.clipboard.writeText(props.csvText).then(() => setInfoNotification({ message: "The CSV is on your clipboard" })); 1871 }; 1872 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 1873 open: props.open, 1874 onClose: props.onClose, 1875 maxWidth: false, 1876 PaperProps: { sx: paperSx$6 }, 1877 children: [ 1878 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 1879 CloseIconButtonProps: { 1880 onClick: props.onClose, 1881 "data-tracking-id": "tracking-id-ads-pixel-source-close-btn" 1882 }, 1883 children: "Generated CSV" 1884 }), 1885 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 1886 fullWidth: true, 1887 multiline: true, 1888 size: "small", 1889 minRows: 16, 1890 maxRows: 16, 1891 value: props.csvText, 1892 inputProps: { 1893 readOnly: true, 1894 style: { 1895 fontFamily: "monospace", 1896 fontSize: "12px", 1897 whiteSpace: "pre" 1898 } 1899 } 1900 }) }), 1901 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1902 variant: "outlined", 1903 size: "large", 1904 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faCopy.faCopy }), 1905 onClick: copy, 1906 "data-tracking-id": "tracking-id-ads-pixel-source-copy-btn", 1907 children: "Copy" 1908 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1909 variant: "contained", 1910 size: "large", 1911 onClick: props.onClose, 1912 "data-tracking-id": "tracking-id-ads-pixel-source-done-btn", 1913 children: "Done" 1914 })] }) 1915 ] 1916 }); 1917}; 1918var paperSx$6 = { 1919 width: 900, 1920 maxWidth: "calc(100vw - 64px)" 1921}; 1922//#endregion 1923//#region client/components/newNav/components/ads/pixels/PixelRulesEditor.tsx 1924/** 1925* The tracking pixel file as a list of placements rather than a fifty-column grid: a row identifies itself, says 1926* what it targets in one line, and opens for editing in two groups that are never changed together. 1927*/ 1928var PixelRulesEditor = (props) => { 1929 const [savedRules, setSavedRules] = (0, import_react.useState)(props.initialRules ?? []); 1930 const [rules, setRules] = (0, import_react.useState)(props.initialRules ?? []); 1931 const [selectedId, setSelectedId] = (0, import_react.useState)(null); 1932 const [isCommentOpen, setIsCommentOpen] = (0, import_react.useState)(false); 1933 const [isImportOpen, setIsImportOpen] = (0, import_react.useState)(false); 1934 const [isSourceOpen, setIsSourceOpen] = (0, import_react.useState)(false); 1935 const [isSimulatorOpen, setIsSimulatorOpen] = (0, import_react.useState)(false); 1936 const problems = (0, import_react.useMemo)(() => validatePixelRules(rules, props.laneNames), [rules, props.laneNames]); 1937 const blocking = (0, import_react.useMemo)(() => problems.filter((problem) => !problem.isWarning), [problems]); 1938 const warnings = (0, import_react.useMemo)(() => problems.filter((problem) => problem.isWarning), [problems]); 1939 const changeCount = countRowChanges(rules, savedRules); 1940 const csvText = (0, import_react.useMemo)(() => serializePixelRulesCsv(rules), [rules]); 1941 const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); 1942 const selected = rules.find((rule) => rule.id === selectedId); 1943 const addRule = () => { 1944 const rule = emptyPixelRule(`new-${Date.now()}-${rules.length}`); 1945 setRules([...rules, rule]); 1946 setSelectedId(rule.id); 1947 }; 1948 const copyLocation = () => { 1949 navigator.clipboard.writeText(props.location ?? "").then(() => setInfoNotification({ message: "The file's S3 path is on your clipboard" })); 1950 }; 1951 const commitSave = async (comment) => { 1952 try { 1953 await props.onSave(csvText, comment); 1954 setSavedRules(rules); 1955 setIsCommentOpen(false); 1956 setSuccessNotification({ message: "Pixel rules saved" }); 1957 } catch (error) { 1958 setErrorNotification({ message: error?.message ?? "Saving the pixel rules failed" }); 1959 } 1960 }; 1961 const saveChrome = props.readOnly || !props.onSave ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveBar, { 1962 changeCount, 1963 saveDisabledReason: blocking.length > 0 ? `Fix ${blocking.length === 1 ? "the problem" : `the ${blocking.length} problems`} above before saving` : void 0, 1964 onCancel: () => { 1965 setRules(savedRules); 1966 setSelectedId(null); 1967 }, 1968 onSave: () => setIsCommentOpen(true),
1969 saving: props.saving, 1970 trackingIdPrefix: "tracking-id-ads-pixel" 1971 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 1972 open: isCommentOpen, 1973 summary: describeAdsChangeCount(changeCount), 1974 warning: props.campaignName ? `The file is what ${props.campaignName}'s ad server reads within a minute of the change landing.` : void 0, 1975 saving: props.saving, 1976 onCancel: () => setIsCommentOpen(false), 1977 onConfirm: (comment) => void commitSave(comment), 1978 trackingIdPrefix: "tracking-id-ads-pixel" 1979 })] }); 1980 if (selected) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 1981 sx: editorSx, 1982 children: [ 1983 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 1984 variant: "text", 1985 size: "small", 1986 sx: backLinkSx$2, 1987 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleLeft.faAngleLeft }), 1988 onClick: () => setSelectedId(null), 1989 "data-tracking-id": "tracking-id-ads-pixel-back-btn", 1990 children: "All placements" 1991 }), 1992 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 1993 variant: "h4", 1994 children: selected.placementName || selected.placementId || "New placement" 1995 }), 1996 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelRuleDetailPanel, { 1997 rule: selected, 1998 laneNames: props.laneNames, 1999 readOnly: props.readOnly, 2000 onChange: (next) => setRules(rules.map((rule) => rule.id === next.id ? next : rule)) 2001 }), 2002 saveChrome 2003 ] 2004 }); 2005 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2006 sx: editorSx, 2007 children: [ 2008 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2009 sx: headerSx$7, 2010 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2011 sx: { minWidth: 0 }, 2012 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2013 variant: "h5", 2014 children: rules.length > 0 ? `Placements (${rules.length})` : "Placements" 2015 }), props.location ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2016 sx: sourceLineSx, 2017 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2018 variant: "helper", 2019 color: "text.secondary", 2020 sx: sourcePrefixSx, 2021 children: "The ad server reads" 2022 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FrontTrimmedText, { 2023 text: props.location, 2024 tooltip: `Copy ${props.location}`, 2025 variant: "helper", 2026 sx: sourceLinkSx, 2027 onClick: copyLocation, 2028 trackingId: "tracking-id-ads-pixel-location-copy-btn" 2029 })] 2030 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2031 variant: "helper", 2032 color: "text.secondary", 2033 children: describeSource(props) 2034 })] 2035 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2036 sx: headerActionsSx$4, 2037 children: [ 2038 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 2039 variant: "text", 2040 size: "medium", 2041 onClick: () => setIsSimulatorOpen(!isSimulatorOpen), 2042 "data-tracking-id": "tracking-id-ads-pixel-simulator-btn", 2043 children: isSimulatorOpen ? "Hide simulator" : "Simulate a match" 2044 }), 2045 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 2046 variant: "text", 2047 size: "medium", 2048 disabled: rules.length === 0, 2049 onClick: () => setIsSourceOpen(true),
2050 "data-tracking-id": "tracking-id-ads-pixel-source-btn", 2051 children: "View CSV" 2052 }), 2053 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 2054 variant: "outlined", 2055 size: "medium", 2056 onClick: () => setIsImportOpen(true), 2057 "data-tracking-id": "tracking-id-ads-pixel-import-btn", 2058 children: "Import CSV" 2059 }), 2060 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 2061 variant: "contained", 2062 size: "medium", 2063 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 2064 onClick: addRule, 2065 "data-tracking-id": "tracking-id-ads-pixel-add-btn", 2066 children: "Add placement" 2067 }) 2068 ] 2069 })] 2070 }), 2071 blocking.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 2072 color: "error", 2073 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: `${blocking.length} ${blocking.length === 1 ? "problem" : "problems"} the ad server would reject` }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2074 sx: problemsSx, 2075 children: blocking.map((problem, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2076 variant: "body1", 2077 children: problem.message 2078 }, index)) 2079 }) })] 2080 }), 2081 warnings.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 2082 color: "warning", 2083 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2084 sx: problemsSx, 2085 children: warnings.map((problem, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2086 variant: "body1", 2087 children: problem.message 2088 }, index)) 2089 }) }) 2090 }), 2091 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 2092 component: import_material.Paper, 2093 variant: "outlined", 2094 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 2095 size: "small", 2096 stickyHeader: true, 2097 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 2098 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2099 sx: (0, import_dist$2.combineSxProps)(headCellSx, placementColumnSx), 2100 children: "Placement" 2101 }), 2102 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2103 sx: headCellSx, 2104 children: "What it targets" 2105 }), 2106 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2107 sx: headCellSx, 2108 align: "right", 2109 children: "URLs" 2110 }), 2111 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: actionsCellSx }), 2112 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: actionsCellSx }) 2113 ] }) }
2113), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [rules.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2114 colSpan: 5, 2115 sx: emptyCellSx$4, 2116 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2117 variant: "body1", 2118 color: "text.secondary", 2119 children: "No placements yet. Import the advertiser's CSV, or add one." 2120 }) 2121 }) }), rules.map((rule) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 2122 sx: rowHoverSx, 2123 "data-testid": `testId-ads-pixel-row-${rule.id}`, 2124 children: [ 2125 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableCell, { 2126 sx: (0, import_dist$2.combineSxProps)(clickableCellSx$1, placementCellSx), 2127 onClick: () => setSelectedId(rule.id), 2128 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FrontTrimmedText, { 2129 text: rule.placementName || "Unnamed placement", 2130 variant: "subtitle2" 2131 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2132 variant: "caption", 2133 color: "text.secondary", 2134 children: rule.placementId || "No placement ID" 2135 })] 2136 }), 2137 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2138 sx: clickableCellSx$1, 2139 onClick: () => setSelectedId(rule.id), 2140 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2141 variant: "body1", 2142 color: "text.secondary", 2143 children: describePixelRuleMatch(rule) 2144 }) 2145 }), 2146 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2147 sx: clickableCellSx$1, 2148 align: "right", 2149 onClick: () => setSelectedId(rule.id), 2150 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2151 variant: "body1", 2152 children: countPixelRuleUrls(rule) 2153 }) 2154 }), 2155 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2156 sx: actionsCellSx, 2157 children: !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 2158 size: "small", 2159 onClick: () => setRules(rules.filter((candidate) => candidate.id !== rule.id)), 2160 "data-tracking-id": "tracking-id-ads-pixel-remove-btn", 2161 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrash.faTrash }) 2162 }) 2163 }), 2164 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 2165 sx: actionsCellSx, 2166 onClick: () => setSelectedId(rule.id), 2167 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 2168 sx: chevronSx$2, 2169 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }) 2170 }) 2171 }) 2172 ] 2173 }, rule.id))] })] 2174 }) 2175 }), 2176 isSimulatorOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Paper, { 2177 variant: "outlined", 2178 sx: simulatorSx, 2179 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelMatchSimulator, { 2180 rules, 2181 today 2182 }) 2183 }), 2184 saveChrome, 2185 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelRulesImportDialog, { 2186 open: isImportOpen, 2187 onClose: () => setIsImportOpen(false), 2188 onImport: (imported) => { 2189 setRules(imported); 2190 setSelectedId(null); 2191 } 2192 }), 2193 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelRulesSourceDialog, { 2194 open: isSourceOpen, 2195 csvText, 2196 onClose: () => setIsSourceOpen(false) 2197 }) 2198 ] 2199 }); 2200}; 2201/** 2202* One line whose end always survives. These names and S3 paths share a long prefix and say what they are at the 2203* end, so it is the front that goes. A right-to-left box is what puts the ellipsis at the left edge; the letters 2204* and digits are strongly left-to-right, so they still read in order inside it. The whole value is in the tooltip. 2205* 2206* `unicode-bidi: plaintext` looks like it belongs here and must not be added: it resolves the direction from the 2207* content, which lays an ASCII value out left-to-right again and moves the ellipsis back to the end. 2208*/ 2209var FrontTrimmedText = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2210 sx: frontTrimmedRowSx, 2211 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 2212 title: props.tooltip ?? props.text,
2213 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2214 component: "div", 2215 variant: props.variant, 2216 sx: (0, import_dist$2.combineSxProps)(frontTrimmedSx, props.sx), 2217 onClick: props.onClick, 2218 "data-tracking-id": props.trackingId, 2219 children: props.text 2220 }) 2221 }) 2222}); 2223var describeSource = (props) => { 2224 if (props.campaignName) return "This campaign has no pixel rules file yet - saving creates its first one"; 2225 return "Import an advertiser's CSV to read it, or build one from scratch. A campaign's own file is saved from its Tracking pixels panel"; 2226}; 2227var editorSx = { 2228 display: "flex", 2229 flexDirection: "column", 2230 gap: 2 2231}; 2232var backLinkSx$2 = { 2233 alignSelf: "flex-start", 2234 marginBottom: -2 2235}; 2236var headerSx$7 = { 2237 display: "flex", 2238 alignItems: "center", 2239 justifyContent: "space-between", 2240 gap: 2 2241}; 2242var headerActionsSx$4 = { 2243 display: "flex", 2244 alignItems: "center", 2245 gap: 1, 2246 flexShrink: 0 2247}; 2248var problemsSx = { 2249 display: "flex", 2250 flexDirection: "column", 2251 gap: .5 2252}; 2253var sourceLineSx = { 2254 display: "flex", 2255 alignItems: "baseline", 2256 gap: .5, 2257 minWidth: 0 2258}; 2259var sourcePrefixSx = { flexShrink: 0 }; 2260var sourceLinkSx = { 2261 color: "primary.main", 2262 cursor: "pointer" 2263}; 2264var frontTrimmedRowSx = { 2265 display: "flex", 2266 minWidth: 0 2267}; 2268var frontTrimmedSx = { 2269 direction: "rtl", 2270 minWidth: 0, 2271 maxWidth: "100%", 2272 overflow: "hidden", 2273 textOverflow: "ellipsis", 2274 whiteSpace: "nowrap" 2275}; 2276var placementColumnSx = { width: "45%" }; 2277var placementCellSx = { 2278 width: "45%", 2279 maxWidth: 0 2280}; 2281var clickableCellSx$1 = (0, import_dist$2.combineSxProps)(bodyCellSx, { cursor: "pointer" }); 2282var emptyCellSx$4 = { 2283 padding: "32px 16px", 2284 textAlign: "center" 2285}; 2286var chevronSx$2 = { 2287 fontSize: "12px !important", 2288 width: "12px !important", 2289 height: "12px !important", 2290 color: "text.disabled", 2291 cursor: "pointer" 2292}; 2293var simulatorSx = { padding: 2 }; 2294//#endregion 2295//#region node_modules/@fortawesome/pro-regular-svg-icons/faStore.js 2296var require_faStore = /* @__PURE__ */ __commonJSMin(((exports) => { 2297 Object.defineProperty(exports, "__esModule", { value: true }); 2298 var prefix = "far"; 2299 var iconName = "store"; 2300 var width = 512; 2301 var height = 512; 2302 var aliases = []; 2303 var unicode = "f54e"; 2304 var svgPathData = "M29.9 81.9C40.4 52 68.6 32 100.3 32l312.2 0c31.7 0 59.9 20 70.4 49.9l18.4 52.6c11 31.5 1 63.9-20.9 84.4l0 189.2c0 39.8-32.2 72-72 72l-304 0c-39.8 0-72-32.2-72-72l0-189.2C10.5 198.4 .5 166 11.5 134.5L29.9 81.9zM80.4 239.8l0 80.2 352 0 0-80.2c-2 .1-3.9 .2-5.9 .2-21.8 0-41.8-8.9-56.2-23.3-14.6 14.4-34.6 23.3-56.9 23.3-22.2 0-42.4-9-57-23.5-14.6 14.5-34.8 23.5-57 23.5-22.3 0-42.3-8.9-56.9-23.3-14.4 14.5-34.3 23.3-56.2 23.3-2 0-4-.1-6-.2zM174.1 80l-7.6 75.7c-1.9 19.4 13.3 36.3 32.8 36.3 18.2 0 33-14.8 33-33l0-79-58.3 0zm106.3 79c0 18.2 14.8 33 33 33 19.5 0 34.8-16.9 32.8-36.3l-7.6-75.7-58.3 0 0 79zm114.9 4.8c1.6 16 15.1 28.2 31.1 28.2 21.6 0 36.7-21.3 29.5-41.6L437.6 97.8C433.9 87.1 423.8 80 412.5 80l-25.6 0 8.4 83.8zM86.4 192c16.1 0 29.5-12.2 31.1-28.2l8.4-83.8-25.6 0C89 80 79 87.1 75.2 97.8L56.8 150.4c-7.1 20.4 8 41.6 29.5 41.6zm-6 216c0 13.3 10.7 24 24 24l304 0c13.3 0 24-10.7 24-24l0-40-352 0 0 40z"; 2305 exports.definition = { 2306 prefix, 2307 iconName, 2308 icon: [ 2309 width, 2310 height, 2311 aliases, 2312 unicode, 2313 svgPathData 2314 ] 2315 }; 2316 exports.faStore = exports.definition; 2317 exports.prefix = prefix; 2318 exports.iconName = iconName; 2319 exports.width = width; 2320 exports.height = height; 2321 exports.ligatures = aliases; 2322 exports.unicode = unicode; 2323 exports.svgPathData = svgPathData; 2324 exports.aliases = aliases; 2325})); 2326//#endregion 2327//#region node_modules/@fortawesome/pro-regular-svg-icons/faTowerBroadcast.js 2328var require_faTowerBroadcast = /* @__PURE__ */ __commonJSMin(((exports) => { 2329 Object.defineProperty(exports, "__esModule", { value: true }); 2330 var prefix = "far"; 2331 var iconName = "tower-broadcast"; 2332 var width = 576; 2333 var height = 512; 2334 var aliases = ["broadcast-tower"]; 2335 var unicode = "f519"; 2336 var svgPathData = "M87.9 11.5c-11.3-6.9-26.1-3.2-33 8.1-24.8 41-39 89.1-39 140.4s14.2 99.4 39 140.4c6.9 11.3 21.6 15 33 8.1s15-21.6 8.1-33C75.7 241.9 64 202.3 64 160S75.7 78.1 96.1 44.4c6.9-11.3 3.2-26.1-8.1-33zm400.1 0c-11.3 6.9-15 21.6-8.1 33 20.4 33.7 32.1 73.3 32.1 115.6s-11.7 81.9-32.1 115.6c-6.9 11.3-3.2 26.1 8.1 33s26.1 3.2 33-8.1c24.8-41 39-89.1 39-140.4S545.8 60.6 521 19.6c-6.9-11.3-21.6-15-33-8.1zM312 210.6c18.9-9 32-28.3 32-50.6 0-30.9-25.1-56-56-56s-56 25.1-
233656 56c0 22.3 13.1 41.6 32 50.6L264 488c0 13.3 10.7 24 24 24s24-10.7 24-24l0-277.4zM180.2 91c7.2-11.2 3.9-26-7.2-33.2s-26-3.9-33.2 7.2c-17.6 27.4-27.8 60-27.8 95s10.2 67.6 27.8 95c7.2 11.2 22 14.4 33.2 7.2s14.4-22 7.2-33.2c-12.8-19.9-20.2-43.6-20.2-69s7.4-49.1 20.2-69zM436.2 65c-7.2-11.2-22-14.4-33.2-7.2s-14.4 22-7.2 33.2c12.8 19.9 20.2 43.6 20.2 69s-7.4 49.1-20.2 69c-7.2 11.2-3.9 26 7.2 33.2s26 3.9 33.2-7.2c17.6-27.4 27.8-60 27.8-95s-10.2-67.6-27.8-95z"; 2337 exports.definition = { 2338 prefix, 2339 iconName, 2340 icon: [ 2341 width, 2342 height, 2343 aliases, 2344 unicode, 2345 svgPathData 2346 ] 2347 }; 2348 exports.faTowerBroadcast = exports.definition; 2349 exports.prefix = prefix; 2350 exports.iconName = iconName; 2351 exports.width = width; 2352 exports.height = height; 2353 exports.ligatures = aliases; 2354 exports.unicode = unicode; 2355 exports.svgPathData = svgPathData; 2356 exports.aliases = aliases; 2357})); 2358//#endregion 2359//#region client/components/newNav/components/ads/data/zipcode-geo.meta.json 2360var import_faXmark = require_faXmark(); 2361var import_faCircleQuestion = require_faCircleQuestion(); 2362var import_faDownload = require_faDownload(); 2363var import_faArrowRotateRight = require_faArrowRotateRight(); 2364var import_faStore = require_faStore(); 2365var import_faTowerBroadcast = require_faTowerBroadcast(); 2366var zipcode_geo_meta_default = { 2367 generated_utc: "2026-07-28T11:09:42Z", 2368 generator: "ads-platform:tools/zipdata/build_zipcode_geo.py", 2369 generator_repo: "https://github.com/SundaySky/ads-platform", 2370 rows: 41637, 2371 distinct_dmas: 210, 2372 bytes_raw: 1202184, 2373 bytes_gzip: 375145, 2374 coordinate_decimals: 5, 2375 max_zips_on_one_coordinate: 53, 2376 zips_in_clusters_over_10: 893, 2377 census_rows_far_from_geonames: 10, 2378 sources: { 2379 "centroid_primary": { 2380 "name": "US Census ZIP Code Tabulation Area Gazetteer", 2381 "url": "https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2025_Gazetteer/2025_Gaz_zcta_national.zip", 2382 "license": "public domain (US Government work)", 2383 "rows_used": 33791 2384 }, 2385 "centroid_crosswalk": { 2386 "name": "HRSA ZIP Code to ZCTA Crosswalk", 2387 "url": "https://data.hrsa.gov/DataDownload/GeoCareNavigator/ZIP%20Code%20to%20ZCTA%20Crosswalk.xlsx", 2388 "license": "public domain (US Government work)", 2389 "rows_used": 7175, 2390 "rejected_over_100_miles_from_geonames": 4, 2391 "note": "PO-box and point ZIPs inherit the Census centroid of the ZCTA that contains them" 2392 }, 2393 "centroid_fallback": { 2394 "name": "GeoNames US postal codes", 2395 "url": "https://download.geonames.org/export/zip/US.zip", 2396 "license": "CC BY 4.0 -- attribution to geonames.org required", 2397 "rows_used": 671, 2398 "low_confidence_rows": 8 2399 }, 2400 "dma": { 2401 "name": "ad server zipToDma.csv", 2402 "path": "ads-server/sundaysky-ad-server-service/src/main/resources/zipToDma.csv", 2403 "license": "internal; Nielsen DMA definitions are proprietary", 2404 "zips_without_dma": 929 2405 } 2406 }, 2407 attribution_required: "This product includes GeoNames data, licensed CC BY 4.0 (https://www.geonames.org/)." 2408}; 2409//#endregion 2410//#region client/components/newNav/components/ads/zipcodeGeoParse.ts 2411var DEGREES_TO_RADIANS$1 = Math.PI / 180; 2412var CARRIAGE_RETURN = 13; 2413var parseZipcodeGeoCsv = (csvText) => { 2414 const lines = csvText.split("\n"); 2415 const zips = []; 2416 const dmas = []; 2417 const latDeg = new Float64Array(lines.length); 2418 const lonDeg = new Float64Array(lines.length); 2419 const cosLat = new Float64Array(lines.length); 2420 const indexByZip = /* @__PURE__ */ new Map(); 2421 let count = 0; 2422 for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) { 2423 let line = lines[lineIndex]; 2424 if (line.charCodeAt(line.length - 1) === CARRIAGE_RETURN) line = line.slice(0, -1); 2425 if (!line) continue; 2426 const firstComma = line.indexOf(","); 2427 const secondComma = line.indexOf(",", firstComma + 1); 2428 const thirdComma = line.indexOf(",", secondComma + 1); 2429 if (thirdComma < 0) throw new Error(`Malformed ZIP code data on line ${lineIndex + 1}`); 2430 const lat = Number(line.slice(firstComma + 1, secondComma)); 2431 const lon = Number(line.slice(secondComma + 1, thirdComma)); 2432 if (Number.isNaN(lat) || Number.isNaN(lon)) throw new Error(`Malformed ZIP code data on line ${lineIndex + 1}`);
2433 const zip = line.slice(0, firstComma); 2434 zips.push(zip); 2435 dmas.push(line.slice(thirdComma + 1)); 2436 latDeg[count] = lat; 2437 lonDeg[count] = lon; 2438 cosLat[count] = Math.cos(lat * DEGREES_TO_RADIANS$1); 2439 indexByZip.set(zip, count); 2440 count++; 2441 } 2442 const latOrder = new Int32Array(count); 2443 for (let index = 0; index < count; index++) latOrder[index] = index; 2444 latOrder.sort((a, b) => latDeg[a] - latDeg[b]); 2445 const latSorted = new Float64Array(count); 2446 for (let position = 0; position < count; position++) latSorted[position] = latDeg[latOrder[position]]; 2447 return { 2448 zips, 2449 dmas, 2450 latDeg: latDeg.subarray(0, count), 2451 lonDeg: lonDeg.subarray(0, count), 2452 cosLat: cosLat.subarray(0, count), 2453 latOrder, 2454 latSorted, 2455 indexByZip 2456 }; 2457}; 2458//#endregion 2459//#region client/components/newNav/components/ads/zipcodeGeoData.ts 2460var zipcodeGeoGeneratedUtc = zipcode_geo_meta_default.generated_utc; 2461var zipcodeGeoRowCount = zipcode_geo_meta_default.rows; 2462zipcode_geo_meta_default.attribution_required; 2463var zipcodeGeoAttributionUrl = "https://www.geonames.org/"; 2464var zipcodeGeoGeneratorRepo = zipcode_geo_meta_default.generator_repo; 2465var ZIPCODE_GEO_DATA_URL = "/rest/adsSpace/zipcodeGeoData"; 2466var cachedData; 2467/** 2468* Goes through the shared REST client rather than `fetch`: the route is behind the platform's permission 2469* check, which reads the bearer token and account headers that client attaches. The cache is cleared on 2470* failure so calling this again is a real retry, and a dropped connection gets a message that says what to do. 2471*/ 2472var loadZipcodeGeoData = () => { 2473 cachedData ??= getService("commonServices").get([EDITING_MODE, STAGE_MODE], ZIPCODE_GEO_DATA_URL).then(parseZipcodeGeoCsv).catch((error) => { 2474 cachedData = void 0; 2475 if (!axios.isAxiosError(error)) throw error; 2476 throw new Error(error.response ? `The ZIP code data could not be loaded (HTTP ${error.response.status}).` : "The ZIP code data could not be loaded. Check your connection and try again."); 2477 }); 2478 return cachedData; 2479}; 2480//#endregion 2481//#region client/components/newNav/components/ads/ZipDataHelpDialog.tsx 2482var GENERATOR_DIR = "tools/zipdata"; 2483var HUB_DATA_DIR = "smartvideo-hub/client/components/newNav/components/ads/data"; 2484var S3_KEY = "hub/zipcode-geo.csv"; 2485var REGENERATE_COMMANDS = [ 2486 "cd tools/zipdata", 2487 "python3 build_zipcode_geo.py --self-check", 2488 "python3 build_zipcode_geo.py" 2489].join("\n"); 2490var PUBLISH_COMMANDS = [ 2491 "# from the ads-platform checkout, with $HUB pointing at the studio repo", 2492 `aws s3 cp data/zipcode-geo.csv s3://sundaysky-ads-test/${S3_KEY}`, 2493 `aws s3 cp data/zipcode-geo.csv s3://ads.sundaysky.com/${S3_KEY}`, 2494 `cp data/zipcode-geo.meta.json $HUB/${HUB_DATA_DIR}/` 2495].join("\n"); 2496var ZipDataHelpDialog = ({ open, onClose }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 2497 open, 2498 onClose, 2499 maxWidth: "md", 2500 fullWidth: true, 2501 "data-testid": "testId-zip-data-help-dialog", 2502 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogTitle, { 2503 sx: titleSxProps, 2504 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2505 sx: titleTextSxProps, 2506 children: "Where this ZIP code data comes from" 2507 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.IconButton, { 2508 onClick: onClose, 2509 "aria-label": "Close", 2510 size: "small", 2511 sx: closeButtonSxProps, 2512 "data-testid": "testId-zip-data-help-close", 2513 "data-tracking-id": "tracking-id-zip-data-help-close", 2514 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 2515 fontSize: "small", 2516 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faXmark.faXmark }) 2517 }) 2518 })] 2519 }
2519), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2520 sx: bodySxProps, 2521 children: [ 2522 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 2523 variant: "body2", 2524 children: [ 2525 "The ", 2526 zipcodeGeoRowCount.toLocaleString(), 2527 " ZIP codes this tool searches are a vendored data file, last built on ", 2528 new Date(zipcodeGeoGeneratedUtc).toLocaleDateString(), 2529 ". It is not generated in this repo. It is built in the ", 2530 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: "ads-platform" }), 2531 " repo, under ", 2532 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: GENERATOR_DIR }), 2533 ":", 2534 " ", 2535 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 2536 href: zipcodeGeoGeneratorRepo, 2537 target: "_blank", 2538 rel: "noopener noreferrer", 2539 children: zipcodeGeoGeneratorRepo 2540 }) 2541 ] 2542 }), 2543 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 2544 variant: "body2", 2545 children: [ 2546 "It lives there because the DMA column has to come from the ad server's own", 2547 " ", 2548 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "zipToDma.csv" }), 2549 ", which the generator reads as a sibling path inside that monorepo. If this tool built its own copy, the two would drift and the whitelists would stop matching what the ad server enforces at bid time." 2550 ] 2551 }), 2552 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2553 sx: headingSxProps, 2554 children: "1. Rebuild the data" 2555 }), 2556 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2557 variant: "body2", 2558 children: "In an ads-platform checkout. It downloads the Census gazetteer, the HRSA ZIP-to-ZCTA crosswalk and GeoNames, so it needs network access. No pip install required." 2559 }), 2560 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2561 component: "pre", 2562 sx: codeSxProps, 2563 children: REGENERATE_COMMANDS 2564 }), 2565 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2566 sx: headingSxProps, 2567 children: "2. Publish it" 2568 }), 2569 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2570 variant: "body2", 2571 children: "The data file goes to the ads S3 bucket, which this app reads it from - it is not committed. Its metadata is committed, and the two travel together: uploaded without it, this dialog and the footer keep showing the old row count and build date. A new upload is picked up when the server next restarts." 2572 }), 2573 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2574 component: "pre", 2575 sx: codeSxProps, 2576 children: PUBLISH_COMMANDS 2577 }), 2578 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2579 sx: headingSxProps, 2580 children: "3. Check it before committing" 2581 }), 2582 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2583 variant: "body2", 2584 children: "Run the tool's tests in this repo. The generator also prints a per-source row count and any crosswalk joins it rejected as implausible. Read that output: a large swing in the source mix, or a jump in how many ZIPs share a single coordinate, means a source changed shape." 2585 }), 2586 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 2587 component: "pre", 2588 sx: codeSxProps, 2589 children: "npm run test-client -- test/client/components/newNav/components/ads --no-coverage" 2590 }), 2591 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2592 variant: "caption", 2593 color: "text.secondary", 2594 children: "Coordinates are ZCTA centroids, so PO-box-only ZIPs sit at the centre of the ZCTA that contains them rather than at their own post office. Expect sub-mile differences against tools that geocode the post office itself, and occasional disagreement right at a radius boundary." 2595 }) 2596 ] 2597 }) })] 2598}); 2599var titleSxProps = { 2600 display: "flex", 2601 alignItems: "flex-start", 2602 justifyContent: "space-between", 2603 gap: "16px" 2604}; 2605var titleTextSxProps = { 2606 fontSize: "20px", 2607 fontWeight: 600, 2608 lineHeight: 1.5 2609}; 2610var closeButtonSxProps = { 2611 color: (theme) => theme.palette.text.secondary, 2612 marginTop: "-4px" 2613}; 2614var bodySxProps = {
2615 display: "flex", 2616 flexDirection: "column", 2617 gap: "12px" 2618}; 2619var headingSxProps = { 2620 fontSize: "14px", 2621 fontWeight: 600, 2622 marginTop: "4px" 2623}; 2624var codeSxProps = { 2625 backgroundColor: (theme) => theme.palette.action.hover, 2626 borderRadius: "4px", 2627 padding: "12px", 2628 margin: 0, 2629 fontSize: "12px", 2630 fontFamily: "monospace", 2631 whiteSpace: "pre", 2632 overflowX: "auto" 2633}; 2634//#endregion 2635//#region client/components/newNav/components/ads/zipRadiusDma.ts 2636var import_dist = require_dist$2(); 2637var EARTH_RADIUS_MILES = 3958.7613; 2638var MILES_PER_DEGREE_LAT = Math.PI * EARTH_RADIUS_MILES / 180; 2639var DEGREES_TO_RADIANS = Math.PI / 180; 2640var BOX_MARGIN = 1.001; 2641var TIE_EPSILON_MILES = 1e-9; 2642var MAX_STORE_ROWS = 2e4; 2643var STORE_NUMBER_COLUMN = "storenumber"; 2644var ZIP_CODE_COLUMN = "zipcode"; 2645/** Trim, drop the ZIP+4 suffix, then left-pad: Excel turns 00501 into the number 501. */ 2646var normalizeZip = (rawZip) => { 2647 const withoutPlusFour = rawZip.trim().split("-")[0].trim(); 2648 if (!/^\d{1,5}$/.test(withoutPlusFour)) return; 2649 return withoutPlusFour.padStart(5, "0"); 2650}; 2651/** Read here rather than handing the File to papaparse: its File path pulls in a Node stream shim. */ 2652var readFileText = (file) => new Promise((resolve, reject) => { 2653 const reader = new FileReader(); 2654 reader.addEventListener("load", () => resolve(String(reader.result))); 2655 reader.addEventListener("error", () => reject(/* @__PURE__ */ new Error("The file could not be read."))); 2656 reader.readAsText(file); 2657}); 2658/** The file is read in the browser; nothing is uploaded. */ 2659var parseStoreCsv = async (input) => { 2660 const { header, csvData } = await (0, import_dist.csvParse)({ 2661 input: typeof input === "string" ? input : await readFileText(input), 2662 skipValueSets: true 2663 }); 2664 const columnKey = (name) => name.replace(/^\uFEFF/, "").trim().toLowerCase(); 2665 const storeNumberIndex = (header ?? []).findIndex((name) => columnKey(name) === STORE_NUMBER_COLUMN); 2666 const zipCodeIndex = (header ?? []).findIndex((name) => columnKey(name) === ZIP_CODE_COLUMN); 2667 if (storeNumberIndex < 0 || zipCodeIndex < 0) throw new Error("The file must have a StoreNumber column and a ZipCode column."); 2668 const stores = csvData.map((row) => ({ 2669 storeNumber: String(row[storeNumberIndex] ?? "").trim(), 2670 zipCode: String(row[zipCodeIndex] ?? "").trim() 2671 })).filter((store) => store.storeNumber || store.zipCode); 2672 if (!stores.length) throw new Error("The file has no store rows."); 2673 if (stores.length > 2e4) throw new Error(`The file has ${stores.length.toLocaleString()} rows; the limit is ${MAX_STORE_ROWS.toLocaleString()}.`); 2674 return stores; 2675}; 2676var normalizeLongitude = (longitude) => ((longitude + 180) % 360 + 360) % 360 - 180; 2677/** 2678* The window can straddle the date line - ZIP 96507 sits at +178.88 while Alaska is negative - 2679* in which case the test flips to "or". Computed once per store; the inner loop only compares. 2680*/ 2681var longitudeWindow = (centerLongitude, halfWidthDeg) => { 2682 const low = centerLongitude - halfWidthDeg; 2683 const high = centerLongitude + halfWidthDeg; 2684 const normalizedLow = normalizeLongitude(low); 2685 const normalizedHigh = normalizeLongitude(high); 2686 return { 2687 coversAllLongitudes: high - low >= 360, 2688 low: normalizedLow, 2689 high: normalizedHigh, 2690 wrapsAntimeridian: normalizedLow > normalizedHigh 2691 }; 2692}; 2693/** First position whose value is >= target, in an ascending array. */ 2694var lowerBound = (values, target) => { 2695 let low = 0; 2696 let high = values.length; 2697 while (low < high) { 2698 const middle = low + high >>> 1; 2699 if (values[middle] < target) low = middle + 1; 2700 else high = middle; 2701 } 2702 return low; 2703}; 2704var computeZipRadiusDma = (geoData, stores, radiusMiles, includeDmas) => { 2705 const { zips, dmas, latDeg, lonDeg, cosLat, latOrder, latSorted, indexByZip } = geoData; 2706 const nearestByZip = /* @__PURE__ */ new Map(); 2707 const unresolvedStores = []; 2708 for (const store of stores) { 2709 const normalizedZip = normalizeZip(store.zipCode); 2710 const storeIndex = normalizedZip === void 0 ? void 0 : indexByZip.get(normalizedZip); 2711 if (storeIndex === void 0 || !store.storeNumber) { 2712 unresolvedStores.push(store); 2713 continue; 2714 } 2715 const storeLatDeg = latDeg[storeIndex]; 2716 const storeLonDeg = lonDeg[storeIndex]; 2717 const storeCosLat = cosLat[storeIndex]; 2718 const storeLatRad = storeLatDeg * DEGREES_TO_RADIANS; 2719 const halfHeightDeg = radiusMiles / MILES_PER_DEGREE_LAT * BOX_MARGIN; 2720 const { coversAllLongitudes, low: lonLow, high: lonHigh, wrapsAntimeridian } = longitudeWindow(storeLonDeg, Math.min(180, radiusMiles / (MILES_PER_DEGREE_LAT * Math.max(storeCosLat, 1e-6)) * BOX_MARGIN)); 2721 const maxLatDeg = storeLatDeg + halfHeightDeg; 2722 for (let position = lowerBound(latSorted, storeLatDeg - halfHeightDeg); position < latSorted.length && latSorted[position] <= maxLatDeg; position++) { 2723 const index = latOrder[position]; 2724 const longitude = lonDeg[index]; 2725 if (!(coversAllLongitudes || (wrapsAntimeridian ? longitude >= lonLow || longitude <= lonHigh : longitude >= lonLow && longitude <= lonHigh))) continue; 2726 const halfDeltaLat = (latDeg[index] * DEGREES_TO_RADIANS - storeLatRad) / 2;
2727 const halfDeltaLon = (lonDeg[index] - storeLonDeg) * DEGREES_TO_RADIANS / 2; 2728 const sinHalfDeltaLat = Math.sin(halfDeltaLat); 2729 const sinHalfDeltaLon = Math.sin(halfDeltaLon); 2730 const haversine = sinHalfDeltaLat * sinHalfDeltaLat + storeCosLat * cosLat[index] * sinHalfDeltaLon * sinHalfDeltaLon; 2731 const distanceMiles = 2 * EARTH_RADIUS_MILES * Math.asin(Math.min(1, Math.sqrt(haversine))); 2732 if (distanceMiles > radiusMiles) continue; 2733 const zip = zips[index]; 2734 const nearest = nearestByZip.get(zip); 2735 if (!nearest || distanceMiles < nearest.distanceMiles - TIE_EPSILON_MILES || Math.abs(distanceMiles - nearest.distanceMiles) <= TIE_EPSILON_MILES && store.storeNumber < nearest.storeNumber) nearestByZip.set(zip, { 2736 storeNumber: store.storeNumber, 2737 storeZip: normalizedZip, 2738 distanceMiles 2739 }); 2740 } 2741 } 2742 const matchedZips = [...nearestByZip.keys()].sort(); 2743 const matchedDmas = includeDmas ? [...new Set(matchedZips.map((zip) => dmas[indexByZip.get(zip)]).filter(Boolean))].sort((a, b) => Number(a) - Number(b)) : []; 2744 const zipsWithoutDma = includeDmas ? matchedZips.filter((zip) => !dmas[indexByZip.get(zip)]) : []; 2745 return { 2746 radiusMiles, 2747 storesInFile: stores.length, 2748 unresolvedStores, 2749 matchedZips, 2750 matchedDmas, 2751 zipsWithoutDma, 2752 nearestByZip 2753 }; 2754}; 2755var buildZipWhitelistCsv = (result) => `mode,field,value\n${result.matchedZips.map((zip) => `whitelist,ZIP_CODE,${zip}\n`).join("")}`; 2756var buildDmaWhitelistCsv = (result) => `mode,field,value\n${result.matchedDmas.map((dma) => `whitelist,DMA,${dma}\n`).join("")}`; 2757/** 2758* ZIP and DMA rows in one file. The ad server combines location fields with AND, so this can only 2759* narrow delivery relative to either list on its own (spec 9.4) - it exists because it was asked 2760* for, not because it is the safe default. 2761*/ 2762var buildCombinedWhitelistCsv = (result) => `mode,field,value\n${result.matchedZips.map((zip) => `whitelist,ZIP_CODE,${zip}\n`).join("")}${result.matchedDmas.map((dma) => `whitelist,DMA,${dma}\n`).join("")}`; 2763var buildEligibleCodesTxt = (result) => result.matchedZips.map((zip) => `${zip}\n`).join(""); 2764/** 2765* The JS dictionary the old Java tool emitted: a module exporting matched ZIP -> nearest store 2766* number. The leading "ViewerZipcode": "StoreNumber" pair is that file's CSV header carried into 2767* the map; it is kept so this file stays interchangeable with the ones already in use. ZIP codes 2768* only - the DMA option does not affect it. 2769*/ 2770var buildDictionaryJs = (result) => { 2771 return `module.exports = {\n${[["ViewerZipcode", "StoreNumber"], ...result.matchedZips.map((zip) => [zip, result.nearestByZip.get(zip).storeNumber])].map(([key, value]) => `"${key}": "${value}"`).join(",\n")}\n}`; 2772}; 2773var buildViewerZipcodesCsv = (result) => "ViewerZipcode,StoreZipCode,StoreNumber,distanceToStoreInMiles\n" + result.matchedZips.map((zip) => { 2774 const nearest = result.nearestByZip.get(zip); 2775 return `${zip},${nearest.storeZip},${nearest.storeNumber},${nearest.distanceMiles.toFixed(2)}\n`; 2776}).join(""); 2777//#endregion 2778//#region client/components/newNav/components/ads/ZipRadiusDmaTool.tsx 2779var DEFAULT_RADIUS_MILES = "10"; 2780var MAX_FILE_SIZE_MB = 5; 2781var MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; 2782var MAX_LISTED_ITEMS = 20; 2783var DOWNLOADABLE_FILES = [ 2784 { 2785 id: "zip-whitelist", 2786 label: "ZIP whitelist", 2787 description: "Ad Server whitelist CSV, listing matching ZIP code only.", 2788 fileNamePart: "geo_whitelist_zip", 2789 contentType: "text/csv", 2790 build: buildZipWhitelistCsv 2791 }, 2792 { 2793 id: "dma-whitelist", 2794 label: "DMA whitelist", 2795 description: "Ad Server whitelist CSV, listing matching DMAs only.", 2796 fileNamePart: "geo_whitelist_dma", 2797 contentType: "text/csv", 2798 build: buildDmaWhitelistCsv 2799 }, 2800 { 2801 id: "combined-whitelist", 2802 label: "ZIP + DMA whitelist", 2803 description: "Ad Server whitelist CSV with both ZIP code and DMA rows. The Ad Server ANDs location fields, so a viewer must match a ZIP and a DMA - this narrows delivery, it does not broaden it.", 2804 fileNamePart: "geo_whitelist_zip_dma", 2805 contentType: "text/csv", 2806 build: buildCombinedWhitelistCsv 2807 }, 2808 { 2809 id: "eligible-codes", 2810 label: "Eligible codes", 2811 description: "Plain list of the matched ZIP codes, one per line, with no header.", 2812 fileNamePart: "eligible_codes", 2813 contentType: "text/plain", 2814 build: buildEligibleCodesTxt 2815 }, 2816 { 2817 id: "viewer-zipcodes", 2818 label: "ZIP to nearest store", 2819 description: "CSV of the matched ZIP codes with the distance from each one to its nearest store.", 2820 fileNamePart: "viewer_zipcodes", 2821 contentType: "text/csv", 2822 build: buildViewerZipcodesCsv 2823 }, 2824 { 2825 id: "dictionary", 2826 label: "Dictionary (JS)", 2827 description: "JavaScript module mapping of ZIP codes to store (presumably used by Ads-FE).", 2828 fileNamePart: "dictionary", 2829 contentType: "text/plain", 2830 build: buildDictionaryJs 2831 } 2832]; 2833var SAMPLE_STORE_CSV = "StoreNumber,ZipCode\n1,10001\n2,60601\n3,90210\n"; 2834var downloadSampleStoreCsv = () =>
2834 DownloadUtils.createAndDownloadBlobFile("sample_stores.csv", SAMPLE_STORE_CSV, "text/csv"); 2835var formatFileSize = (bytes) => bytes < 1024 ? `${bytes.toLocaleString()} bytes` : `${Math.round(bytes / 1024).toLocaleString()} KB`; 2836var listItems = (items) => items.length > MAX_LISTED_ITEMS ? `${items.slice(0, MAX_LISTED_ITEMS).join(", ")} and ${items.length - MAX_LISTED_ITEMS} more` : items.join(", "); 2837var ZipRadiusDmaFooter = () => { 2838 const [isHelpOpen, setIsHelpOpen] = (0, import_react.useState)(false); 2839 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2840 sx: footerSxProps, 2841 children: [ 2842 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 2843 variant: "helper", 2844 color: "text.secondary", 2845 "data-testid": "testId-zip-radius-dma-footer", 2846 children: [ 2847 zipcodeGeoRowCount.toLocaleString(), 2848 " ZIP codes, updated ", 2849 new Date(zipcodeGeoGeneratedUtc).toLocaleDateString(), 2850 ".", 2851 " ", 2852 "Store files stay in your browser; nothing is uploaded.", 2853 " ", 2854 "This product includes GeoNames data, licensed CC BY 4.0 (", 2855 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 2856 href: zipcodeGeoAttributionUrl, 2857 target: "_blank", 2858 rel: "noopener noreferrer", 2859 children: zipcodeGeoAttributionUrl 2860 }), 2861 ")." 2862 ] 2863 }), 2864 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.IconButton, { 2865 onClick: () => setIsHelpOpen(true), 2866 "aria-label": "Where this ZIP code data comes from", 2867 size: "small", 2868 sx: helpButtonSxProps, 2869 "data-testid": "testId-zip-radius-dma-help", 2870 "data-tracking-id": "tracking-id-zip-radius-dma-help", 2871 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 2872 fontSize: "small", 2873 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faCircleQuestion.faCircleQuestion }) 2874 }) 2875 }), 2876 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ZipDataHelpDialog, { 2877 open: isHelpOpen, 2878 onClose: () => setIsHelpOpen(false) 2879 }) 2880 ] 2881 }); 2882}; 2883var ZipRadiusDmaTool = () => { 2884 const [geoData, setGeoData] = (0, import_react.useState)(); 2885 const [loadError, setLoadError] = (0, import_react.useState)(); 2886 const [storeFile, setStoreFile] = (0, import_react.useState)(); 2887 const [radiusMiles, setRadiusMiles] = (0, import_react.useState)(DEFAULT_RADIUS_MILES); 2888 const [includeDmas, setIncludeDmas] = (0, import_react.useState)(true); 2889 const [isComputing, setIsComputing] = (0, import_react.useState)(false); 2890 const [error, setError] = (0, import_react.useState)(); 2891 const [result, setResult] = (0, import_react.useState)(); 2892 const isItemDragged = useGlobalDragDetector(); 2893 const fileInputRef = (0, import_react.useRef)(); 2894 const isMountedRef = (0, import_react.useRef)(true); 2895 const loadGeoData = (0, import_react.useCallback)(() => { 2896 setLoadError(void 0); 2897 loadZipcodeGeoData().then((data) => isMountedRef.current && setGeoData(data)).catch((loadFailure) => isMountedRef.current && setLoadError(loadFailure.message)); 2898 }, []); 2899 (0, import_react.useEffect)(() => { 2900 isMountedRef.current = true; 2901 loadGeoData(); 2902 return () => { 2903 isMountedRef.current = false; 2904 }; 2905 }, [loadGeoData]); 2906 const acceptFile = (file) => { 2907 setResult(void 0); 2908 setStoreFile(void 0); 2909 if (!file) return; 2910 if (!/\.csv$/i.test(file.name)) { 2911 setError("That is not a CSV file."); 2912 return; 2913 } 2914 if (file.size > MAX_FILE_SIZE_BYTES) { 2915 setError(`The file is larger than the ${MAX_FILE_SIZE_MB} MB limit.`); 2916 return; 2917 } 2918 setError(void 0); 2919 setStoreFile(file); 2920 }; 2921 const onDrop = (acceptedFiles, rejections) => { 2922 if (rejections.length) { 2923 acceptFile(void 0); 2924 setError("Drop a single CSV file."); 2925 return; 2926 } 2927 acceptFile(acceptedFiles[0]); 2928 }; 2929 const { isDragActive, getRootProps } = useDropzone({ 2930 multiple: false, 2931 noClick: true, 2932 noKeyboard: true, 2933 onDrop 2934 }); 2935 const radius = Number(radiusMiles); 2936 const isRadiusValid = Number.isInteger(radius) && radius >= 1 && radius <= 500; 2937 const canGenerate = Boolean(storeFile) && isRadiusValid && Boolean(geoData) && !isComputing; 2938 const onGenerate = async () => { 2939 setIsComputing(true); 2940 setError(void 0); 2941 setResult(void 0); 2942 try { 2943 const stores = await parseStoreCsv(storeFile); 2944 setResult(computeZipRadiusDma(geoData, stores, radius, includeDmas)); 2945 } catch (generateFailure) { 2946 setError(generateFailure.message); 2947 } finally { 2948 setIsComputing(false); 2949 } 2950 }; 2951 const onDownload = (file) => { 2952 const baseName = storeFile.name.replace(/\.csv$/i, ""); 2953 const extension = file.contentType === "text/plain" ? "txt" : "csv"; 2954 const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2955 DownloadUtils.createAndDownloadBlobFile(`${baseName}_${file.fileNamePart}_${result.radiusMiles}mi_${date}.${extension}`, file.build(result), file.contentType); 2956 }; 2957 const hasResults = result && result.matchedZips.length > 0; 2958 const dmaOnlyFileIds = /* @__PURE__ */ new Set(["dma-whitelist", "combined-whitelist"]); 2959 const downloads = DOWNLOADABLE_FILES.filter((file) => includeDmas || !dmaOnlyFileIds.has(file.id)); 2960 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 2961 sx: rootSxProps, 2962 "data-testid": "testId-zip-radius-dma-tool", 2963 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 2964 variant: "outlined", 2965 sx: inputPanelSxProps, 2966 children: [ 2967 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 2968 variant: "h5", 2969 children: "Store list" 2970 }), 2971 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.Dropzone, { 2972 sx: dropzoneSxProps, 2973 hint: isItemDragged, 2974 active: isDragActive, 2975 error: Boolean(error) && !storeFile, 2976 "data-testid": "testId-zip-radius-dma-dropzone", 2977 ...getRootProps(), 2978 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { 2979 ref: fileInputRef, 2980 type: "file", 2981 accept: ".csv,text/csv", 2982 hidden: true, 2983 onChange: (event) => acceptFile(event.target.files?.[0]), 2984 "data-testid": "testId-file-input" 2985 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.UploadFile, { 2986 sx: uploadFileSxProps, 2987 primaryText: storeFile ? storeFile.name : "Drop a store CSV here or", 2988 secondaryText: storeFile ? formatFileSize(storeFile.size) : void 0, 2989 actions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.UploadFileButton, { 2990 onClick: () => fileInputRef.current.click(), 2991 "data-testid": "testId-zip-radius-dma-choose-file", 2992 "data-tracking-id": "tracking-id-zip-radius-dma-choose-file", 2993 children: storeFile ? "Replace store file" : "Choose store file" 2994 }) 2995 })] 2996 }),
2997 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 2998 variant: "helper", 2999 color: "text.secondary", 3000 children: [ 3001 "Two columns: StoreNumber and ZipCode. Up to ", 3002 MAX_FILE_SIZE_MB, 3003 " MB and ", 3004 MAX_STORE_ROWS.toLocaleString(), 3005 " rows.", 3006 " ", 3007 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 3008 component: "button", 3009 type: "button", 3010 variant: "helper", 3011 onClick: downloadSampleStoreCsv, 3012 "data-testid": "testId-zip-radius-dma-sample-file", 3013 "data-tracking-id": "tracking-id-zip-radius-dma-sample-file", 3014 children: "Sample file" 3015 }) 3016 ] 3017 }), 3018 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3019 variant: "h5", 3020 sx: sectionHeadingSxProps, 3021 children: "Catchment" 3022 }), 3023 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3024 sx: optionsRowSxProps, 3025 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 3026 label: "Radius (miles)", 3027 type: "number", 3028 size: "small", 3029 value: radiusMiles, 3030 onChange: (event) => setRadiusMiles(event.target.value), 3031 error: Boolean(radiusMiles) && !isRadiusValid, 3032 sx: radiusFieldSxProps, 3033 "data-testid": "testId-zip-radius-dma-radius", 3034 "data-tracking-id": "tracking-id-zip-radius-dma-radius" 3035 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.FormControlLabel, { 3036 label: "Include DMAs", 3037 sx: includeDmasSxProps, 3038 control: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Checkbox, { 3039 checked: includeDmas, 3040 onChange: (event) => setIncludeDmas(event.target.checked), 3041 "data-testid": "testId-zip-radius-dma-include-dmas", 3042 "data-tracking-id": "tracking-id-zip-radius-dma-include-dmas" 3043 }) 3044 })] 3045 }),
3046 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3047 variant: "helper", 3048 color: isRadiusValid ? "text.secondary" : "error.main", 3049 children: [ 3050 "Whole miles, ", 3051 1, 3052 " to ", 3053 500, 3054 ". Distance is measured from the centre of each ZIP code to the centre of the nearest store ZIP code." 3055 ] 3056 }), 3057 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3058 sx: generateRowSxProps, 3059 children: [ 3060 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 3061 variant: "contained", 3062 disabled: !canGenerate, 3063 onClick: onGenerate, 3064 "data-testid": "testId-zip-radius-dma-generate", 3065 "data-tracking-id": "tracking-id-zip-radius-dma-generate", 3066 children: "Generate" 3067 }), 3068 !geoData && !loadError && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3069 sx: inlineStatusSxProps, 3070 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TruffleCircularProgress, { size: 16 }
3070), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3071 variant: "body2", 3072 color: "text.secondary", 3073 children: "Loading ZIP code data..." 3074 })] 3075 }), 3076 isComputing && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3077 sx: inlineStatusSxProps, 3078 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TruffleCircularProgress, { size: 16 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3079 variant: "body2", 3080 color: "text.secondary", 3081 children: "Computing..." 3082 })] 3083 }) 3084 ] 3085 }), 3086 loadError && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 3087 color: "error", 3088 "data-testid": "testId-zip-radius-dma-load-error", 3089 children: [ 3090 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "ZIP code data unavailable" }), 3091 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3092 variant: "body1", 3093 color: "text.secondary", 3094 children: loadError 3095 }) }), 3096 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxActions, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 3097 variant: "outlined", 3098 size: "medium", 3099 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faArrowRotateRight.faArrowRotateRight }), 3100 onClick: loadGeoData, 3101 "data-testid": "testId-zip-radius-dma-retry-load", 3102 "data-tracking-id": "tracking-id-zip-radius-dma-retry-load-btn", 3103 children: "Try again" 3104 }) }) 3105 ] 3106 }), 3107 error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3108 color: "error", 3109 "data-testid": "testId-zip-radius-dma-error", 3110 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3111 variant: "body1", 3112 color: "text.secondary", 3113 children: error 3114 }) }) 3115 }) 3116 ] 3117 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 3118 variant: "outlined", 3119 sx: resultsPanelSxProps, 3120 "data-testid": "testId-zip-radius-dma-results-panel", 3121 children: [
3122 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3123 sx: resultsHeaderSxProps, 3124 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3125 variant: "h4", 3126 children: "Results" 3127 }), hasResults && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 3128 variant: "contained", 3129 size: "medium", 3130 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faDownload.faDownload }), 3131 onClick: () => downloads.forEach((file) => onDownload(file)), 3132 "data-testid": "testId-zip-radius-dma-download-all", 3133 "data-tracking-id": "tracking-id-zip-radius-dma-download-all", 3134 children: "Download all" 3135 })] 3136 }), 3137 !result && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3138 variant: "body1", 3139 color: "text.secondary", 3140 "data-testid": "testId-zip-radius-dma-results-empty", 3141 children: "Choose a store file, set a radius, and click Generate. The whitelists and the ZIP-to-nearest-store table will appear here." 3142 }), 3143 result && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3144 sx: resultsSxProps, 3145 "data-testid": "testId-zip-radius-dma-results", 3146 children: [ 3147 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3148 sx: statsStripSxProps, 3149 "data-testid": "testId-zip-radius-dma-summary", 3150 children: [ 3151 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ResultTile, { 3152 icon: import_faStore.faStore, 3153 label: "Stores resolved", 3154 value: (result.storesInFile - result.unresolvedStores.length).toLocaleString(), 3155 caption: `of ${result.storesInFile.toLocaleString()} in the file` 3156 }), 3157 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ResultTile, { 3158 icon: import_faLocationDot.faLocationDot, 3159 label: "ZIP codes", 3160 value: result.matchedZips.length.toLocaleString(), 3161 caption: `within ${result.radiusMiles} mi of a store` 3162 }),
3163 includeDmas && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ResultTile, { 3164 icon: import_faTowerBroadcast.faTowerBroadcast, 3165 label: "DMAs", 3166 value: result.matchedDmas.length.toLocaleString(), 3167 caption: "TV markets they fall in" 3168 }) 3169 ] 3170 }), 3171 result.unresolvedStores.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 3172 color: "warning", 3173 "data-testid": "testId-zip-radius-dma-unresolved-warning", 3174 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "Some stores could not be located" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3175 variant: "body1", 3176 color: "text.secondary", 3177 children: [ 3178 result.unresolvedStores.length.toLocaleString(), 3179 " of ", 3180 result.storesInFile.toLocaleString(), 3181 " stores could not be located and ", 3182 result.unresolvedStores.length === 1 ? "was" : "were", 3183 " skipped:", 3184 " ", 3185 listItems(result.unresolvedStores.map((store) => `${store.storeNumber || "no store number"} (${store.zipCode || "no ZIP"})`)) 3186 ] 3187 }) })] 3188 }), 3189 !hasResults && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3190 color: "warning", 3191 "data-testid": "testId-zip-radius-dma-no-results", 3192 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3193 variant: "body1", 3194 color: "text.secondary", 3195 children: [ 3196 "No ZIP codes fell within ", 3197 result.radiusMiles, 3198 " miles of any store, so there is nothing to download." 3199 ] 3200 }) }) 3201 }), 3202 result.zipsWithoutDma.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3203 color: "warning", 3204 "data-testid": "testId-zip-radius-dma-no-dma-warning", 3205 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3206 variant: "body1", 3207 color: "text.secondary", 3208 children: [result.zipsWithoutDma.length === 1 ? "1 matched ZIP code has no DMA and is missing from the DMA whitelist: " : `${result.zipsWithoutDma.length.toLocaleString()} matched ZIP codes have no DMA and are missing from the DMA whitelist: `, listItems(result.zipsWithoutDma)] 3209 }) }) 3210 }), 3211 result.matchedZips.length > 5e3 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3212 color: "warning", 3213 "data-testid": "testId-zip-radius-dma-large-whitelist-warning", 3214 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3215 variant: "body1", 3216 color: "text.secondary", 3217 children: [ 3218 "This ZIP whitelist has ", 3219 result.matchedZips.length.toLocaleString(), 3220 " rows. The ad server scans the whole list on every bid request, so consider a smaller radius or the DMA whitelist." 3221 ] 3222 }) }) 3223 }), 3224 hasResults && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 3225 component: import_material.Paper, 3226 variant: "outlined", 3227 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 3228 size: "small", 3229 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 3230 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 3231 sx: headCellSx, 3232 children: "File" 3233 }), 3234 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 3235 sx: headCellSx, 3236 children: "What it contains" 3237 }),
3238 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: (0, import_dist$2.combineSxProps)(headCellSx, downloadCellSxProps) }) 3239 ] }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: downloads.map((file) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 3240 sx: rowHoverSx, 3241 children: [ 3242 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 3243 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, fileNameCellSxProps), 3244 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3245 variant: "subtitle2", 3246 children: file.label 3247 }) 3248 }), 3249 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 3250 sx: bodyCellSx, 3251 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3252 variant: "body1", 3253 color: "text.secondary", 3254 children: file.description 3255 }) 3256 }), 3257 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 3258 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, downloadCellSxProps), 3259 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 3260 size: "small", 3261 "aria-label": `Download ${file.label}`, 3262 onClick: () => onDownload(file), 3263 "data-testid": `testId-zip-radius-dma-download-${file.id}`, 3264 "data-tracking-id": `tracking-id-zip-radius-dma-download-${file.id}`, 3265 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faDownload.faDownload }) 3266 }) 3267 }) 3268 ] 3269 }, file.id)) })] 3270 }) 3271 }), 3272 hasResults && includeDmas && result.matchedDmas.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3273 color: "info", 3274 "data-testid": "testId-zip-radius-dma-expansion-warning", 3275 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 3276 variant: "body1", 3277 color: "text.secondary", 3278 children: [ 3279 "A DMA whitelist covers entire television markets, so it is far broader than the ZIP list:", 3280 " ", 3281 result.matchedZips.length.toLocaleString(), 3282 " ZIP codes versus ", 3283 result.matchedDmas.length.toLocaleString(), 3284 " whole DMAs. Attach one file or the other, never both." 3285 ] 3286 }) }) 3287 }) 3288 ] 3289 }) 3290 ] 3291 })] 3292 }); 3293}; 3294/** Same tile treatment as the campaigns summary strip, so the space reads as one product. */ 3295var ResultTile = ({ icon, label, value, caption }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 3296 variant: "outlined", 3297 sx: (0, import_dist$2.combineSxProps)(adsSurfaceSx, tileSxProps), 3298 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 3299 sx: adsIconTileSx(32), 3300 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon }) 3301 }
3301), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3302 sx: tileBodySxProps, 3303 children: [ 3304 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3305 variant: "caption", 3306 color: "text.secondary", 3307 noWrap: true, 3308 children: label 3309 }), 3310 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3311 variant: "h2", 3312 noWrap: true, 3313 children: value 3314 }), 3315 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3316 variant: "caption", 3317 color: "text.secondary", 3318 noWrap: true, 3319 children: caption 3320 }) 3321 ] 3322 })] 3323}); 3324var footerSxProps = { 3325 display: "flex", 3326 alignItems: "center", 3327 gap: "4px" 3328}; 3329var helpButtonSxProps = { 3330 color: (theme) => theme.palette.text.secondary, 3331 flexShrink: 0 3332}; 3333var rootSxProps = { 3334 display: "flex", 3335 flexDirection: "row", 3336 gap: "24px", 3337 alignItems: "stretch", 3338 flexGrow: 1, 3339 minHeight: 0 3340}; 3341var inputPanelSxProps = { 3342 display: "flex", 3343 flexDirection: "column", 3344 gap: "16px", 3345 padding: "24px", 3346 width: "380px", 3347 flexShrink: 0, 3348 overflow: "auto" 3349}; 3350var sectionHeadingSxProps = { paddingTop: "8px" }; 3351var dropzoneSxProps = { 3352 width: "100%", 3353 height: "180px", 3354 flexShrink: 0, 3355 borderRadius: "4px", 3356 backgroundColor: "other.editorBackground" 3357}; 3358var uploadFileSxProps = { 3359 width: "100%", 3360 height: "100%", 3361 backgroundColor: "transparent", 3362 padding: "16px" 3363}; 3364var optionsRowSxProps = { 3365 display: "flex", 3366 flexDirection: "row", 3367 alignItems: "flex-end", 3368 justifyContent: "space-between", 3369 gap: "16px" 3370}; 3371var includeDmasSxProps = { marginRight: 0 }; 3372var radiusFieldSxProps = { width: "160px" }; 3373var generateRowSxProps = { 3374 display: "flex", 3375 flexDirection: "row", 3376 alignItems: "center", 3377 gap: "16px" 3378}; 3379var inlineStatusSxProps = { 3380 display: "flex", 3381 flexDirection: "row", 3382 alignItems: "center", 3383 gap: "8px" 3384}; 3385var resultsPanelSxProps = { 3386 display: "flex", 3387 flexDirection: "column", 3388 gap: "16px", 3389 padding: "24px", 3390 flexGrow: 1, 3391 minWidth: 0, 3392 overflow: "auto" 3393}; 3394var resultsHeaderSxProps = { 3395 display: "flex", 3396 alignItems: "center", 3397 justifyContent: "space-between", 3398 gap: "16px" 3399}; 3400var resultsSxProps = { 3401 display: "flex", 3402 flexDirection: "column", 3403 alignItems: "stretch", 3404 gap: "16px" 3405}; 3406var statsStripSxProps = { 3407 display: "flex", 3408 gap: "16px", 3409 flexWrap: "wrap" 3410}; 3411var tileSxProps = { 3412 flex: "1 1 180px", 3413 minWidth: 0 3414}; 3415var tileBodySxProps = { 3416 minWidth: 0, 3417 display: "flex", 3418 flexDirection: "column", 3419 gap: "2px" 3420}; 3421var fileNameCellSxProps = { 3422 whiteSpace: "nowrap", 3423 width: "180px" 3424}; 3425var downloadCellSxProps = { 3426 width: "52px", 3427 textAlign: "center" 3428}; 3429//#endregion 3430//#region client/components/newNav/components/ads/AdsUtilityPage.tsx 3431var AdsUtilityPage = () => { 3432 const accountId = useActiveAccountId(); 3433 const { userRoles } = useUserData(); 3434 const { utilityId } = useParams(); 3435 const utility = findAdsUtility(utilityId); 3436 if (!utility) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: (0, import_dist$1.newNavAdsUtilityPath)({ 3437 accountId, 3438 utilityId: "zip-radius" 3439 }) }); 3440 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3441 sx: adsPageSx, 3442 children: [ 3443 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 3444 area: "Utilities", 3445 title: utility.name, 3446 subtitle: utility.description 3447 }), 3448 utility.notice && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 3449 color: "info", 3450 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "Not available yet" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: utility.notice })] 3451 }), 3452 utility.id === "zip-radius" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ZipRadiusDmaTool, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ZipRadiusDmaFooter, {})] }), 3453 utility.id === "pixel-tracking" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelRulesEditor, { readOnly: !isPermitted(userRoles, permissions.adsEditCampaignSettings) }) 3454 ] 3455 }); 3456}; 3457//#endregion
3458//#region client/components/newNav/components/ads/dashboards/AdsAccountDashboardPage.tsx 3459var TREND_HEIGHT = 220; 3460/** 3461* Portfolio triage, and where the space lands. The first question of a session is "is anything wrong in this 3462* account", which nothing else in the space answers. 3463*/ 3464var AdsAccountDashboardPage = () => { 3465 const theme = (0, import_material.useTheme)(); 3466 const accountId = useActiveAccountId(); 3467 const { delivery, accountName, loading, isRefreshing, error, refresh } = useAdsSpaceAccountDelivery(); 3468 const trendOption = (0, import_react.useMemo)(() => delivery ? trendChartOption(delivery.trend, delivery.trendSpend, theme) : null, [delivery, theme]); 3469 if (loading) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3470 sx: adsPageSx, 3471 children: [ 3472 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 3473 area: "Dashboard", 3474 title: "Account dashboard" 3475 }), 3476 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: 120 }), 3477 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: 280 }) 3478 ] 3479 }); 3480 if (error || !delivery) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3481 sx: adsPageSx, 3482 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 3483 area: "Dashboard", 3484 title: "Account dashboard" 3485 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { 3486 title: "No delivery to show", 3487 message: "This account's delivery could not be read." 3488 })] 3489 }); 3490 const { platformStatus } = delivery; 3491 const shortfall = delivery.expectedByNow - delivery.delivered; 3492 const enabledCount = delivery.campaigns.filter((campaign) => campaign.enabled).length; 3493 const inFlightCount = delivery.campaigns.filter((campaign) => campaign.flight).length; 3494 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3495 sx: adsPageSx, 3496 children: [ 3497 (platformStatus.isPanicMode || platformStatus.trafficReductions.length > 0) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3498 color: platformStatus.isPanicMode ? "error" : "warning", 3499 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3500 variant: "body1", 3501 color: "text.secondary", 3502 children: platformStatus.isPanicMode ? "Panic mode is active: all bids are suppressed platform-wide." : `Traffic is reduced platform-wide: ${platformStatus.trafficReductions.map((reduction) => `${reduction.scope} at ${reduction.percent}%`).join(", ")}.` 3503 }) }) 3504 }), 3505 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AdsPageHeader, { 3506 area: "Dashboard", 3507 title: accountName || "Account dashboard", 3508 subtitle: `${delivery.campaigns.length} campaigns, ${enabledCount} enabled, ${inFlightCount} with a flight covering today`, 3509 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryFreshness, { 3510 lastUpdateTime: delivery.freshness.lastUpdateTime, 3511 isKnown: delivery.freshness.isKnown 3512 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryRefreshButton, { 3513 isRefreshing, 3514 onRefresh: refresh 3515 })] 3516 }), 3517 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3518 sx: kpiRowSx, 3519 children: [ 3520 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 3521 dataTestId: "ads-account-kpi-delivered", 3522 label: "Delivered / goal", 3523 value: `${formatCount(delivery.delivered)} / ${formatCompact(delivery.goal)}`, 3524 subLine: `${formatWholePercent(delivery.goal > 0 ? delivery.delivered / delivery.goal : null)} delivered, ${formatWholePercent(delivery.elapsedShare)} of the flights elapsed`, 3525 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsMeter, { 3526 dataTestId: "ads-account-meter-delivered", 3527 name: "", 3528 fill: delivery.goal > 0 ? delivery.delivered / delivery.goal : 0, 3529 value: "", 3530 tick: delivery.elapsedShare, 3531 color: paceStatusColor(delivery.paceIndex) 3532 }) 3533 }), 3534 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 3535 dataTestId: "ads-account-kpi-pace", 3536 label: "Pace", 3537 value: formatWholePercent(delivery.paceIndex), 3538 subLine: shortfall > 0 ? `${formatCount(shortfall)} impressions behind the expected line` : `${formatCount(-shortfall)} impressions ahead`, 3539 status: paceSkewChip(delivery.delivered, delivery.expectedByNow, delivery.goal), 3540 infoTooltip: "Delivered across every in-flight campaign, divided by what their plans expected by now." 3541 }),
3542 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 3543 dataTestId: "ads-account-kpi-spend", 3544 label: "Spend / revenue", 3545 value: formatMoney(delivery.spend), 3546 subLine: `Revenue ${formatMoney(delivery.revenue)} - margin ${delivery.realisedMarginPercent === null ? "-" : formatRate(delivery.realisedMarginPercent / 100)}` 3547 }), 3548 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 3549 dataTestId: "ads-account-kpi-ecpm", 3550 label: "Blended eCPM", 3551 value: formatMoney(delivery.blendedEcpm), 3552 subLine: delivery.targetEcpm === null ? "no weighted target" : `Weighted target ${formatMoney(delivery.targetEcpm)}`, 3553 status: delivery.targetEcpm !== null && delivery.blendedEcpm > delivery.targetEcpm ? { 3554 text: "over target", 3555 color: "warning" 3556 } : void 0 3557 }) 3558 ] 3559 }), 3560 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountAlertsCard, { 3561 alerts: delivery.alerts, 3562 suppressedAlerts: delivery.suppressedAlerts, 3563 disabledRules: delivery.disabledRules, 3564 accountId 3565 }), 3566 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountCampaignTable, { 3567 campaigns: delivery.campaigns, 3568 accountId 3569 }), 3570 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountDecoupledTable, { 3571 campaigns: delivery.decoupledCampaigns, 3572 accountId 3573 }), 3574 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 3575 dataTestId: "ads-account-trend", 3576 title: "Account delivery, last 30 days", 3577 SubTitle: "impressions and spend", 3578 tooltipText: "Impressions as an area on the left axis, spend as a line on the right, across every campaign in this account.", 3579 option: trendOption, 3580 containerStyle: { height: TREND_HEIGHT } 3581 }) 3582 ] 3583 }); 3584}; 3585var kpiRowSx = { 3586 display: "grid", 3587 gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", 3588 gap: 2 3589}; 3590//#endregion 3591//#region node_modules/@fortawesome/pro-regular-svg-icons/faBrain.js 3592var require_faBrain = /* @__PURE__ */ __commonJSMin(((exports) => { 3593 Object.defineProperty(exports, "__esModule", { value: true }); 3594 var prefix = "far"; 3595 var iconName = "brain"; 3596 var width = 512; 3597 var height = 512; 3598 var aliases = [129504]; 3599 var unicode = "f5dc"; 3600 var svgPathData = "M200 0c-36.6 0-67.5 24.6-77 58.1-47.4 9.7-83 51.6-83 101.9 0 11.3 1.8 22.2 5.1 32.3-22.7 19.1-37.1 47.7-37.1 79.7 0 23.7 8 45.6 21.3 63.1-3.5 10.4-5.3 21.4-5.3 32.9 0 54 41.2 98.5 93.9 103.5 15.6 24.3 42.9 40.5 74.1 40.5 25.2 0 48-10.6 64-27.6 16 17 38.8 27.6 64 27.6 31.1 0 58.4-16.2 74.1-40.5 52.7-5.1 93.9-49.5 93.9-103.5 0-11.5-1.9-22.5-5.3-32.9 13.4-17.5 21.3-39.4 21.3-63.1 0-32-14.5-60.6-37.1-79.7 3.3-10.2 5.1-21.1 5.1-32.3 0-50.3-35.6-92.2-83-101.9-9.5-33.5-40.4-58.1-77-58.1-21.8 0-41.6 8.7-56 22.9-14.4-14.1-34.2-22.9-56-22.9zm32 80l0 347.1c-.1 .6-.2 1.2-.3 1.9-2.5 19.7-19.3 35-39.7 35-16.8 0-31.2-10.3-37.1-25.1-3.8-9.6-13.3-15.6-23.6-15-1.1 .1-2.2 .1-3.2 .1-30.9 0-56-25.1-56-56 0-9.5 2.4-18.5 6.5-26.3 5.1-9.6 3.1-21.4-4.8-28.8-11-10.2-17.8-24.8-17.8-40.9 0-21.6 12.2-40.4 30.3-49.8 5.9-3 10.2-8.4 12-14.7s.9-13.2-2.5-18.9c-5-8.4-7.9-18.1-7.9-28.6 0-30.9 25.1-56 56-56 13.3 0 24-10.7 24-24 0-17.7 14.3-32 32-32s32 14.3 32 32zm48 347.1L280 80c0-17.7 14.3-32 32-32s32 14.3 32 32c0 13.3 10.7 24 24 24 30.9 0 56 25.1 56 56 0 10.5-2.9 20.3-7.9 28.6-3.4 5.7-4.3 12.5-2.5 18.9s6.2 11.7 12 14.7c18 9.3 30.3 28.1 30.3 49.8 0 16.1-6.8 30.7-17.8 40.9-7.9 7.4-9.9 19.2-4.8 28.8 4.2 7.8 6.5 16.7 6.5 26.3 0 30.9-25.1 56-56 56-1.1 0-2.2 0-3.2-.1-10.3-.6-19.8 5.5-23.6 15-5.9 14.7-20.4 25.1-37.1 25.1-20.4 0-37.2-15.3-39.7-35-.1-.6-.2-1.3-.3-1.9z"; 3601 exports.definition = { 3602 prefix, 3603 iconName, 3604 icon: [ 3605 width, 3606 height, 3607 aliases, 3608 unicode, 3609 svgPathData 3610 ] 3611 }; 3612 exports.faBrain = exports.definition; 3613 exports.prefix = prefix; 3614 exports.iconName = iconName; 3615 exports.width = width; 3616 exports.height = height; 3617 exports.ligatures = aliases; 3618 exports.unicode = unicode; 3619 exports.svgPathData = svgPathData; 3620 exports.aliases = aliases; 3621})); 3622//#endregion 3623//#region node_modules/@fortawesome/pro-regular-svg-icons/faBullseye.js 3624var require_faBullseye = /* @__PURE__ */ __commonJSMin(((exports) => { 3625 Object.defineProperty(exports, "__esModule", { value: true }); 3626 var prefix = "far"; 3627 var iconName = "bullseye"; 3628 var width = 512; 3629 var height = 512; 3630 var aliases = []; 3631 var unicode = "f140"; 3632 var svgPathData = "M464 256a208 208 0 1 0 -416 0 208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0zm256 96a96 96 0 1 0 0-192 96 96 0 1 0 0 192zm0-240a144 144 0 1 1 0 288 144 144 0 1 1 0-288zM224 256a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z";
3633 exports.definition = { 3634 prefix, 3635 iconName, 3636 icon: [ 3637 width, 3638 height, 3639 aliases, 3640 unicode, 3641 svgPathData 3642 ] 3643 }; 3644 exports.faBullseye = exports.definition; 3645 exports.prefix = prefix; 3646 exports.iconName = iconName; 3647 exports.width = width; 3648 exports.height = height; 3649 exports.ligatures = aliases; 3650 exports.unicode = unicode; 3651 exports.svgPathData = svgPathData; 3652 exports.aliases = aliases; 3653})); 3654//#endregion 3655//#region node_modules/@fortawesome/pro-regular-svg-icons/faSliders.js 3656var require_faSliders = /* @__PURE__ */ __commonJSMin(((exports) => { 3657 Object.defineProperty(exports, "__esModule", { value: true }); 3658 var prefix = "far"; 3659 var iconName = "sliders"; 3660 var width = 512; 3661 var height = 512; 3662 var aliases = ["sliders-h"]; 3663 var unicode = "f1de"; 3664 var svgPathData = "M24 72C10.7 72 0 82.7 0 96s10.7 24 24 24l91.7 0c10.2 32.5 40.5 56 76.3 56s66.1-23.5 76.3-56L488 120c13.3 0 24-10.7 24-24s-10.7-24-24-24L268.3 72C258.1 39.5 227.8 16 192 16s-66.1 23.5-76.3 56L24 72zm0 160c-13.3 0-24 10.7-24 24s10.7 24 24 24l251.7 0c10.2 32.5 40.5 56 76.3 56s66.1-23.5 76.3-56l59.7 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-59.7 0c-10.2-32.5-40.5-56-76.3-56s-66.1 23.5-76.3 56L24 232zm0 160c-13.3 0-24 10.7-24 24s10.7 24 24 24l59.7 0c10.2 32.5 40.5 56 76.3 56s66.1-23.5 76.3-56L488 440c13.3 0 24-10.7 24-24s-10.7-24-24-24l-251.7 0c-10.2-32.5-40.5-56-76.3-56s-66.1 23.5-76.3 56L24 392zm136 56a32 32 0 1 1 0-64 32 32 0 1 1 0 64zM352 288a32 32 0 1 1 0-64 32 32 0 1 1 0 64zM160 96a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"; 3665 exports.definition = { 3666 prefix, 3667 iconName, 3668 icon: [ 3669 width, 3670 height, 3671 aliases, 3672 unicode, 3673 svgPathData 3674 ] 3675 }; 3676 exports.faSliders = exports.definition; 3677 exports.prefix = prefix; 3678 exports.iconName = iconName; 3679 exports.width = width; 3680 exports.height = height; 3681 exports.ligatures = aliases; 3682 exports.unicode = unicode; 3683 exports.svgPathData = svgPathData; 3684 exports.aliases = aliases; 3685})); 3686//#endregion 3687//#region node_modules/@fortawesome/pro-regular-svg-icons/faTag.js 3688var require_faTag = /* @__PURE__ */ __commonJSMin(((exports) => { 3689 Object.defineProperty(exports, "__esModule", { value: true }); 3690 var prefix = "far"; 3691 var iconName = "tag"; 3692 var width = 576; 3693 var height = 512; 3694 var aliases = [127991]; 3695 var unicode = "f02b"; 3696 var svgPathData = "M96.1 88c0-13.3 10.7-24 24-24l156.1 0c6.4 0 12.5 2.5 17 7l208 208c9.4 9.4 9.4 24.6 0 33.9L345.1 469.1c-9.4 9.4-24.6 9.4-33.9 0l-208-208c-4.5-4.5-7-10.6-7-17L96.1 88zm24-72c-39.8 0-72 32.2-72 72l0 156.1c0 19.1 7.6 37.4 21.1 50.9l208 208c28.1 28.1 73.7 28.1 101.8 0L535.1 346.9c28.1-28.1 28.1-73.7 0-101.8l-208-208C313.6 23.6 295.3 16 276.2 16L120.1 16zm56 160a32 32 0 1 0 0-64 32 32 0 1 0 0 64z"; 3697 exports.definition = { 3698 prefix, 3699 iconName, 3700 icon: [ 3701 width, 3702 height, 3703 aliases, 3704 unicode, 3705 svgPathData 3706 ] 3707 }; 3708 exports.faTag = exports.definition; 3709 exports.prefix = prefix; 3710 exports.iconName = iconName; 3711 exports.width = width; 3712 exports.height = height; 3713 exports.ligatures = aliases; 3714 exports.unicode = unicode; 3715 exports.svgPathData = svgPathData; 3716 exports.aliases = aliases; 3717})); 3718//#endregion 3719//#region client/components/newNav/components/ads/targeting/geoDefinition.ts 3720var import_faCircleInfo = require_faCircleInfo(); 3721var import_faPen = require_faPen(); 3722var import_faTrashCan = require_faTrashCan(); 3723var import_faChartMixed = require_faChartMixed(); 3724var import_faDesktop = require_faDesktop(); 3725var import_faEllipsisV = require_faEllipsisV(); 3726var import_faUserGroup = require_faUserGroup(); 3727var import_faPlug = require_faPlug(); 3728var import_faFilm = require_faFilm(); 3729var import_faFilter = require_faFilter(); 3730var import_faBrain = require_faBrain(); 3731var import_faBullseye = require_faBullseye(); 3732var import_faCalendarDays = require_faCalendarDays(); 3733var import_faSliders = require_faSliders(); 3734var import_faTag = require_faTag(); 3735/** 3736* The two forms a geography definition can take, and the translation between the second one and something a 3737* person can edit. 3738* 3739* The ad server reads this string one of two ways (`ExclusionLogicFactory.getLocationFilters`): a link to a 3740* `mode,field,value` CSV, or an inline expression like `Country=United States;State!=CA,DC`. Both collapse to the 3741* same thing - one include-or-exclude list per location type, with the types ANDed - so that is what is edited 3742* here, and the string is only how it gets written down. 3743*/ 3744/** `ExclusionLogic.LocationField`, spelled the way the parser's `valueOf(toUpperCase())` accepts. */ 3745var geoLocationFieldOptions = [ 3746 { 3747 value: "Country", 3748 label: "Country" 3749 }, 3750 { 3751 value: "State", 3752 label: "State" 3753 }, 3754 { 3755 value: "City", 3756 label: "City" 3757 }, 3758 { 3759 value: "Zip_Code", 3760 label: "Zip code" 3761 }, 3762 { 3763 value: "DMA", 3764 label: "DMA" 3765 }, 3766 { 3767 value: "IP", 3768 label: "IP address" 3769 }, 3770 { 3771 value: "Latitude", 3772 label: "Latitude" 3773 }, 3774 { 3775 value: "Longitude", 3776 label: "Longitude" 3777 } 3778]; 3779/** Zip codes, IPs and coordinates match by prefix, so a partial value covers everything under it. */ 3780var GEO_PREFIX_MATCHED_FIELDS = [ 3781 "Zip_Code", 3782 "IP", 3783 "Latitude", 3784 "Longitude" 3785]; 3786/** The column this is stored in. An inline expression longer than this is silently truncated by MySQL. */ 3787var GEO_DEFINITION_MAX_LENGTH = 1024; 3788var FILE_PREFIXES = /^(s3|file|https?):/i; 3789var isGeoFileLocation = (text) => FILE_PREFIXES.test(text.trim()); 3790var parseRule = (part) => { 3791 const equals = part.indexOf("="); 3792 if (equals < 1 || equals >= part.length - 1) return null; 3793 const include = part.charAt(equals - 1) !== "!"; 3794 const field = part.slice(0, include ? equals : equals - 1).trim(); 3795 const values = part.slice(equals + 1).split(",").map((value) =>
3795 value.trim()).filter(Boolean); 3796 const known = geoLocationFieldOptions.find((option) => option.value.toUpperCase() === field.toUpperCase()); 3797 return known && values.length > 0 ? { 3798 field: known.value, 3799 include, 3800 values 3801 } : null; 3802}; 3803var parseGeoDefinition = (definition) => { 3804 const text = (definition ?? "").trim(); 3805 if (!text) return { 3806 isFile: true, 3807 fileUrl: "", 3808 rules: [], 3809 unparsed: null 3810 }; 3811 if (isGeoFileLocation(text)) return { 3812 isFile: true, 3813 fileUrl: text, 3814 rules: [], 3815 unparsed: null 3816 }; 3817 const parts = text.split(";").map((part) => part.trim()).filter(Boolean); 3818 const rules = []; 3819 for (const part of parts) { 3820 const rule = parseRule(part); 3821 if (!rule || rules.some((existing) => existing.field === rule.field)) return { 3822 isFile: false, 3823 fileUrl: "", 3824 rules: [], 3825 unparsed: text 3826 }; 3827 rules.push(rule); 3828 } 3829 return { 3830 isFile: false, 3831 fileUrl: "", 3832 rules, 3833 unparsed: null 3834 }; 3835}; 3836var compileGeoDefinition = (definition) => { 3837 if (definition.unparsed !== null) return definition.unparsed; 3838 if (definition.isFile) return definition.fileUrl.trim(); 3839 return definition.rules.filter((rule) => rule.values.length > 0).map((rule) => `${rule.field}${rule.include ? "=" : "!="}${rule.values.join(",")}`).join(";"); 3840}; 3841var fieldLabel = (field) => geoLocationFieldOptions.find((option) => option.value === field)?.label ?? field; 3842/** A zip list is unreadable at list width and a URL's interesting half is its tail, so neither is shown raw. */ 3843var describeGeoDefinition = (definition) => { 3844 const parsed = parseGeoDefinition(definition); 3845 if (parsed.unparsed !== null) return "Custom expression"; 3846 if (parsed.isFile) return parsed.fileUrl.split("/").pop() || parsed.fileUrl; 3847 if (parsed.rules.length === 0) return "Not set"; 3848 return parsed.rules.map((rule) => `${rule.include ? "" : "not "}${fieldLabel(rule.field).toLowerCase()} (${rule.values.length})`).join(", "); 3849}; 3850//#endregion 3851//#region client/components/newNav/components/ads/common/AdsStringListInput.tsx 3852/** Add/remove list, used where the old app had nine numbered inputs side by side. */ 3853var AdsStringListInput = ({ label, values, disabled, error, placeholder, entryProblem, onChange, trackingId }) => { 3854 const [draft, setDraft] = (0, import_react.useState)(""); 3855 const add = () => { 3856 const added = draft.split(STRING_LIST_SEPARATORS).map((entry) => entry.trim()).filter((entry) => entry && !values.includes(entry)); 3857 if (added.length > 0) onChange([...values, ...new Set(added)]); 3858 setDraft(""); 3859 }; 3860 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3861 sx: stringListSx, 3862 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 3863 label, 3864 value: draft, 3865 size: "small", 3866 fullWidth: true, 3867 error, 3868 disabled, 3869 placeholder, 3870 onChange: (event) => setDraft(event.target.value), 3871 onKeyDown: (event) => { 3872 if (event.key === "Enter") { 3873 event.preventDefault(); 3874 add(); 3875 } 3876 }, 3877 InputProps: { endAdornment: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 3878 component: "button", 3879 type: "button", 3880 sx: addButtonSx$3, 3881 disabled: disabled || !draft.trim(), 3882 onClick: add, 3883 "data-tracking-id": `${trackingId}-add-btn`, 3884 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }) 3885 }) }, 3886 inputProps: { "data-tracking-id": trackingId } 3887 }), values.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 3888 sx: chipsSx$2, 3889 children: values.map((entry) => { 3890 const problem = entryProblem?.(entry) ?? null; 3891 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 3892 title: problem ?? "", 3893 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Chip, { 3894 size: "small", 3895 label: entry, 3896 color: problem ? "error" : void 0, 3897 disabled, 3898 onDelete: disabled ? void 0 : () => onChange(values.filter((item) => item !== entry)), 3899 "data-tracking-id": `${trackingId}-remove-btn` 3900 }) 3901 }, entry); 3902 }) 3903 })] 3904 }); 3905}; 3906var stringListSx = { 3907 display: "flex", 3908 flexDirection: "column", 3909 gap: 1 3910}; 3911var chipsSx$2 = {
3912 display: "flex", 3913 flexWrap: "wrap", 3914 gap: .5 3915}; 3916var addButtonSx$3 = { 3917 border: 0, 3918 background: "none", 3919 cursor: "pointer", 3920 color: "primary.main", 3921 padding: 0, 3922 fontSize: "14px", 3923 "&:disabled": { 3924 color: "text.disabled", 3925 cursor: "default" 3926 } 3927}; 3928//#endregion 3929//#region client/components/newNav/components/ads/common/AdsGeoDefinitionField.tsx 3930var FILE_HELP = "A CSV with mode, field and value columns - mode is whitelist or blacklist, field is one of the location types, one row per value. s3://, https:// and file: all work."; 3931/** 3932* Where a campaign may serve, as the ad server actually evaluates it: one include-or-exclude list per location 3933* type, all of them ANDed. Typing the expression by hand was the old way and it fails at campaign load rather 3934* than here, so the expression is compiled from these rows instead of being edited directly. 3935*/ 3936var AdsGeoDefinitionField = (props) => { 3937 const [definition, setDefinition] = (0, import_react.useState)(() => parseGeoDefinition(props.value)); 3938 (0, import_react.useEffect)(() => { 3939 if (compileGeoDefinition(definition) !== props.value) setDefinition(parseGeoDefinition(props.value)); 3940 }, [props.value]); 3941 const compiled = compileGeoDefinition(definition); 3942 const update = (next) => { 3943 setDefinition(next); 3944 props.onChange(compileGeoDefinition(next)); 3945 }; 3946 const updateRule = (index, changes) => update({ 3947 ...definition, 3948 rules: definition.rules.map((rule, at) => at === index ? { 3949 ...rule, 3950 ...changes 3951 } : rule) 3952 }); 3953 const unusedFields = geoLocationFieldOptions.filter((option) => !definition.rules.some((rule) => rule.field === option.value)); 3954 if (definition.unparsed !== null) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3955 sx: stackSx$6, 3956 children: [ 3957 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3958 variant: "h6", 3959 children: props.label 3960 }), 3961 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 3962 color: "warning", 3963 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3964 variant: "body1", 3965 color: "text.secondary", 3966 children: "This was written by hand and does not fit the editor - it is shown as text so nothing is lost. Clearing it starts over with the editor." 3967 }) }) 3968 }), 3969 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 3970 value: definition.unparsed, 3971 size: "small", 3972 fullWidth: true, 3973 multiline: true, 3974 minRows: 2, 3975 disabled: props.disabled, 3976 onChange: (event) => props.onChange(event.target.value), 3977 inputProps: { "data-tracking-id": `${props.trackingId}-raw` } 3978 }) 3979 ] 3980 }); 3981 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 3982 sx: stackSx$6, 3983 children: [ 3984 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 3985 variant: "h6", 3986 children: props.label 3987 }), 3988 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.ToggleButtonGroup, { 3989 exclusive: true, 3990 size: "small", 3991 value: definition.isFile ? "file" : "list", 3992 disabled: props.disabled, 3993 onChange: (_event, next) => next && update({ 3994 ...definition, 3995 isFile: next === "file" 3996 }), 3997 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 3998 value: "list", 3999 "data-tracking-id": `${props.trackingId}-list-mode-btn`, 4000 children: "Specify locations" 4001 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 4002 value: "file", 4003 "data-tracking-id": `${props.trackingId}-file-mode-btn`, 4004 children: "A CSV file" 4005 })] 4006 }), 4007 definition.isFile ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4008 sx: stackSx$6, 4009 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4010 label: "CSV location", 4011 value: definition.fileUrl, 4012 size: "small", 4013 fullWidth: true, 4014 disabled: props.disabled, 4015 placeholder: "s3://bucket-name/path/locations.csv", 4016 onChange: (event) => update({ 4017 ...definition, 4018 fileUrl: event.target.value 4019 }), 4020 inputProps: { "data-tracking-id": `${props.trackingId}-file-input` } 4021 }
4021), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4022 variant: "helper", 4023 color: "text.secondary", 4024 children: FILE_HELP 4025 })] 4026 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4027 sx: stackSx$6, 4028 children: [ 4029 definition.rules.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4030 variant: "body1", 4031 color: "text.secondary", 4032 children: "No locations yet - this geography constrains nothing." 4033 }), 4034 definition.rules.map((rule, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 4035 variant: "outlined", 4036 sx: ruleSx, 4037 children: [ 4038 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4039 sx: ruleHeaderSx, 4040 children: [ 4041 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4042 select: true, 4043 label: "Location type", 4044 size: "small", 4045 value: rule.field, 4046 disabled: props.disabled, 4047 sx: typeSelectSx, 4048 onChange: (event) => updateRule(index, { field: event.target.value }), 4049 inputProps: { "data-tracking-id": `${props.trackingId}-type-input` }, 4050 children: [...unusedFields, geoLocationFieldOptions.find((option) => option.value === rule.field)].filter(Boolean).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 4051 value: option.value, 4052 children: option.label 4053 }, option.value)) 4054 }), 4055 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TextField, { 4056 select: true, 4057 label: "Applies as", 4058 size: "small", 4059 value: rule.include ? "include" : "exclude", 4060 disabled: props.disabled, 4061 sx: modeSelectSx, 4062 onChange: (event) => updateRule(index, { include: event.target.value === "include" }), 4063 inputProps: { "data-tracking-id": `${props.trackingId}-mode-input` }, 4064 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 4065 value: "include", 4066 children: "Only these" 4067 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 4068 value: "exclude", 4069 children: "Anywhere but these" 4070 })] 4071 }),
4072 !props.disabled && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 4073 size: "small", 4074 onClick: () => update({ 4075 ...definition, 4076 rules: definition.rules.filter((_rule, at) => at !== index) 4077 }), 4078 "data-tracking-id": `${props.trackingId}-remove-rule-btn`, 4079 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrashCan.faTrashCan }) 4080 }) 4081 ] 4082 }), 4083 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsStringListInput, { 4084 label: "Values", 4085 values: rule.values, 4086 disabled: props.disabled, 4087 placeholder: rule.field === "Zip_Code" ? "029, 028, 06103" : void 0, 4088 onChange: (values) => updateRule(index, { values }), 4089 trackingId: `${props.trackingId}-values` 4090 }), 4091 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4092 variant: "helper", 4093 color: "text.secondary", 4094 children: "Type or paste several at once - commas, spaces and new lines all separate them." 4095 }), 4096 GEO_PREFIX_MATCHED_FIELDS.includes(rule.field) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4097 variant: "helper", 4098 color: "text.secondary", 4099 children: "Matched by prefix, so 029 covers every zip starting 029." 4100 }) 4101 ] 4102 }, rule.field)), 4103 !props.disabled && unusedFields.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 4104 variant: "text", 4105 size: "small", 4106 sx: addButtonSx$2, 4107 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 4108 onClick: () => update({ 4109 ...definition, 4110 rules: [...definition.rules, { 4111 field: unusedFields[0].value, 4112 include: true, 4113 values: [] 4114 }] 4115 }), 4116 "data-tracking-id": `${props.trackingId}-add-rule-btn`, 4117 children: "Add location type" 4118 }), 4119 definition.rules.length > 1 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4120 variant: "helper", 4121 color: "text.secondary", 4122 children: "A request has to satisfy every location type above, not just one of them." 4123 }) 4124 ] 4125 }), 4126 compiled.length > 1024 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 4127 color: "error", 4128 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4129 variant: "body1", 4130 color: "text.secondary", 4131 children: `This is ${compiled.length} characters and only 1024 are stored. Put a list this long in a CSV file instead.` 4132 }) }) 4133 }) 4134 ] 4135 }); 4136}; 4137var stackSx$6 = { 4138 display: "flex", 4139 flexDirection: "column", 4140 gap: 1.5 4141}; 4142var ruleSx = { 4143 display: "flex", 4144 flexDirection: "column", 4145 gap: 1.5, 4146 padding: 2 4147}; 4148var ruleHeaderSx = { 4149 display: "flex", 4150 alignItems: "center", 4151 gap: 1 4152}; 4153var typeSelectSx = { 4154 width: 160, 4155 flexShrink: 0 4156}; 4157var modeSelectSx = { 4158 flex: 1, 4159 minWidth: 150 4160}; 4161var addButtonSx$2 = { alignSelf: "flex-start" }; 4162//#endregion 4163//#region client/components/newNav/components/ads/common/AdsFieldControl.tsx 4164/** 4165* One field of an ads configuration form. The help line is rendered as its own Typography rather than through 4166* TextField's helperText, which the theme hides unless the field is focused or in error - and this help is the 4167* only place several of these settings are explained at all. 4168*/ 4169var AdsFieldControl = ({ field, value, values, error, readOnly, onChange, trackingIdPrefix }) => { 4170 const trackingId = `${trackingIdPrefix}-${field.key}-input`; 4171 const isInert = Boolean(values && field.disabledWhen?.(values)); 4172 const isLocked = readOnly || field.machineWritten || isInert; 4173 const textFieldProps = { 4174 label: field.label, 4175 value: asText(value), 4176 error: Boolean(error), 4177 placeholder: field.placeholder, 4178 disabled: isLocked, 4179 fullWidth: true, 4180 size: "small", 4181 inputProps: { "data-tracking-id": trackingId } 4182 }; 4183 const renderControl = () => { 4184 switch (field.type) { 4185 case "bool": return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4186 variant: "h6", 4187 children: field.label 4188 }
4188), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4189 sx: switchRowSx, 4190 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Switch, { 4191 checked: Boolean(value), 4192 disabled: isLocked, 4193 onChange: (event) => onChange(field.key, event.target.checked), 4194 "data-tracking-id": trackingId 4195 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4196 variant: "body1", 4197 color: "text.secondary", 4198 children: value ? "On" : "Off" 4199 })] 4200 })] }); 4201 case "select": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4202 ...textFieldProps, 4203 select: true, 4204 SelectProps: { displayEmpty: true }, 4205 onChange: (event) => onChange(field.key, event.target.value), 4206 children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 4207 value: option.value, 4208 children: option.label 4209 }, option.value)) 4210 }); 4211 case "multiSelect": { 4212 const selected = asStringList(value); 4213 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4214 ...textFieldProps, 4215 select: true, 4216 value: selected, 4217 SelectProps: { 4218 multiple: true, 4219 renderValue: (chosen) => chosen.map((item) => field.options?.find((option) => option.value === item)?.label ?? item).join(", ") 4220 }, 4221 onChange: (event) => onChange(field.key, event.target.value), 4222 children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.MenuItem, { 4223 value: option.value, 4224 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Checkbox, { 4225 size: "small", 4226 checked: selected.includes(option.value) 4227 }), option.label] 4228 }, option.value)) 4229 }); 4230 } 4231 case "stringList": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsStringListInput, { 4232 label: field.label, 4233 values: asStringList(value), 4234 disabled: isLocked, 4235 error: Boolean(error), 4236 placeholder: field.placeholder, 4237 entryProblem: field.entryProblem, 4238 onChange: (next) => onChange(field.key, next), 4239 trackingId 4240 }); 4241 case "geoDefinition": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsGeoDefinitionField, { 4242 label: field.label, 4243 value: asText(value), 4244 disabled: isLocked, 4245 onChange: (next) => onChange(field.key, next), 4246 trackingId 4247 }); 4248 case "readOnly": return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4249 variant: "h6", 4250 children: field.label 4251 }), asText(value) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ReadOnlyValue, { value: asText(value) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4252 variant: "body1", 4253 color: "text.disabled", 4254 children: "Not set" 4255 })] }); 4256 case "secret": return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4257 variant: "h6", 4258 children: field.label 4259 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4260 variant: "body1", 4261 color: "text.disabled", 4262 children: "Hidden" 4263 })] }); 4264 case "number": 4265 case "decimal": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4266 ...textFieldProps, 4267 type: "number", 4268 inputProps: { 4269 ...textFieldProps.inputProps, 4270 min: field.min, 4271 max: field.max, 4272 step: field.type === "decimal" ? "any" : 1 4273 }, 4274 onChange: (event) => onChange(field.key, event.target.value) 4275 }); 4276 case "date": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4277 ...textFieldProps, 4278 type: "date", 4279 onChange: (event) => onChange(field.key, event.target.value) 4280 }); 4281 case "longText": return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4282 ...textFieldProps, 4283 multiline: true, 4284 minRows: 2, 4285 onChange: (event) => onChange(field.key, event.target.value) 4286 }); 4287 default: return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 4288 ...textFieldProps, 4289 onChange: (event) =>
4289 onChange(field.key, event.target.value) 4290 }); 4291 } 4292 }; 4293 const helpLine = error ?? field.help ?? (field.machineWritten ? "Written by the services server - shown here for reference." : void 0); 4294 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4295 sx: fieldSx$1(field.wide), 4296 children: [renderControl(), helpLine && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4297 variant: "helper", 4298 color: error ? "error.main" : "text.secondary", 4299 children: helpLine 4300 })] 4301 }); 4302}; 4303var ReadOnlyValue = ({ value }) => value.startsWith("http") || value.startsWith("s3://") ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 4304 href: value, 4305 target: "_blank", 4306 rel: "noopener noreferrer", 4307 variant: "body1", 4308 sx: breakSx, 4309 children: value 4310}) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4311 variant: "body1", 4312 sx: breakSx, 4313 children: value 4314}); 4315var fieldSx$1 = (wide) => ({ 4316 display: "flex", 4317 flexDirection: "column", 4318 gap: .5, 4319 gridColumn: wide ? "1 / -1" : "auto" 4320}); 4321var switchRowSx = { 4322 display: "flex", 4323 alignItems: "center", 4324 gap: 1, 4325 minHeight: 40 4326}; 4327var breakSx = { wordBreak: "break-all" }; 4328//#endregion 4329//#region client/components/newNav/components/ads/common/AdsRowDialog.tsx 4330var blankValue = (field) => { 4331 if (field.type === "bool") return false; 4332 return field.type === "multiSelect" || field.type === "stringList" ? [] : ""; 4333}; 4334var emptyValues = (fields) => Object.fromEntries(fields.map((field) => [field.key, field.defaultValue ?? blankValue(field)])); 4335var AdsRowDialog = (props) => { 4336 const [values, setValues] = (0, import_react.useState)(emptyValues(props.fields)); 4337 const [errors, setErrors] = (0, import_react.useState)({}); 4338 (0, import_react.useEffect)(() => { 4339 if (props.open) { 4340 setValues(props.row ? { ...props.row } : emptyValues(props.fields)); 4341 setErrors({}); 4342 } 4343 }, [props.open, props.row]); 4344 const fields = props.fieldsFor?.(values) ?? props.fields; 4345 const validate = (next) => { 4346 const groupErrors = validateFields([{ 4347 id: "row", 4348 title: props.title, 4349 fields: props.fieldsFor?.(next) ?? props.fields 4350 }], next); 4351 const rowErrors = props.validateRow?.({ 4352 ...next, 4353 id: props.row?.id ?? "new" 4354 }, props.otherRows) ?? {}; 4355 return { 4356 ...groupErrors, 4357 ...rowErrors 4358 }; 4359 }; 4360 const handleChange = (key, value) => { 4361 const next = { 4362 ...values, 4363 [key]: value 4364 }; 4365 setValues(next); 4366 setErrors(validate(next)); 4367 }; 4368 const handleSubmit = () => { 4369 const nextErrors = validate(values); 4370 setErrors(nextErrors); 4371 if (!hasErrors(nextErrors)) props.onSubmit({ 4372 ...values, 4373 id: props.row?.id ?? `row-${Date.now()}` 4374 }); 4375 }; 4376 const isRoomy = fields.some((field) => field.type === "geoDefinition"); 4377 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 4378 open: props.open, 4379 onClose: props.onClose, 4380 PaperProps: { sx: isRoomy ? roomyPaperSx : paperSx$5 }, 4381 children: [ 4382 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 4383 CloseIconButtonProps: { 4384 onClick: props.onClose, 4385 "data-tracking-id": `${props.trackingIdPrefix}-row-dialog-close-btn` 4386 }, 4387 children: props.title 4388 }), 4389 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 4390 sx: fieldsSx, 4391 children: fields.map((field) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsFieldControl, { 4392 field, 4393 value: values[field.key] ?? null, 4394 values, 4395 error: errors[field.key], 4396 onChange: handleChange, 4397 trackingIdPrefix: `${props.trackingIdPrefix}-row` 4398 }, field.key)) 4399 }) }), 4400 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 4401 variant: "text", 4402 size: "large", 4403 onClick: props.onClose, 4404 "data-tracking-id": `${props.trackingIdPrefix}-row-dialog-cancel-btn`, 4405 children: "Cancel" 4406 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 4407 variant: "contained", 4408 size: "large", 4409 disabled: hasErrors(errors), 4410 onClick: handleSubmit, 4411 "data-tracking-id": `${props.trackingIdPrefix}-row-dialog-submit-btn`, 4412 children: props.row ? "Save" : "Add" 4413 })] }) 4414 ] 4415 }); 4416}; 4417var paperSx$5 = { 4418 width: 540, 4419 maxWidth: "calc(100vw - 64px)" 4420}; 4421var roomyPaperSx = { 4422 width: 720, 4423 maxWidth: "calc(100vw - 64px)" 4424}; 4425var fieldsSx = {
4426 display: "flex", 4427 flexDirection: "column", 4428 gap: 2, 4429 paddingTop: 1 4430}; 4431//#endregion 4432//#region client/components/newNav/components/ads/common/AdsChildTable.tsx 4433/** 4434* A child entity of a configuration page - flights, group ranges, tag mappings. The old app nested these as 4435* formsets inside the parent form; a table with an add/edit dialog keeps the parent readable once a campaign 4436* carries a dozen of them. 4437*/ 4438var AdsChildTable = (props) => { 4439 const [editing, setEditing] = (0, import_react.useState)(null); 4440 const [isAdding, setIsAdding] = (0, import_react.useState)(false); 4441 const [menuAnchorEl, setMenuAnchorEl] = (0, import_react.useState)(null); 4442 const [menuRow, setMenuRow] = (0, import_react.useState)(null); 4443 const columns = props.columns ?? props.fields.map((field) => ({ 4444 key: field.key, 4445 label: field.label 4446 })); 4447 const closeMenu = () => { 4448 setMenuAnchorEl(null); 4449 setMenuRow(null); 4450 }; 4451 const defaultCell = (row, key) => { 4452 const field = (props.rowFieldsFor?.(row) ?? props.fields).find((candidate) => candidate.key === key); 4453 const value = row[key]; 4454 if (field?.type === "bool") return value ? "Yes" : "No"; 4455 if (field?.type === "select") return field.options?.find((option) => option.value === asText(value))?.label ?? asText(value); 4456 if (field?.type === "multiSelect" || field?.type === "stringList") return asStringList(value).join(", "); 4457 return asText(value) || "-"; 4458 }; 4459 const rowActions = (row) => { 4460 const locked = props.lockedRow?.(row) ?? null; 4461 return [{ 4462 label: "Edit", 4463 icon: import_faPen.faPen, 4464 disabled: Boolean(locked?.edit), 4465 disabledReason: locked?.edit, 4466 onClick: () => { 4467 closeMenu(); 4468 setEditing(row); 4469 } 4470 }, { 4471 label: "Remove", 4472 icon: import_faTrashCan.faTrashCan, 4473 error: true, 4474 dividerBefore: true, 4475 disabled: Boolean(locked), 4476 disabledReason: locked?.remove, 4477 onClick: () => { 4478 closeMenu(); 4479 props.onChange(props.rows.filter((candidate) => candidate.id !== row.id)); 4480 } 4481 }]; 4482 }; 4483 const handleSubmit = (row) => { 4484 props.onChange(props.rows.some((candidate) => candidate.id === row.id) ? props.rows.map((candidate) => candidate.id === row.id ? row : candidate) : [...props.rows, row]); 4485 setEditing(null); 4486 setIsAdding(false); 4487 }; 4488 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4489 sx: sectionSx$11, 4490 children: [ 4491 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4492 sx: headerSx$6, 4493 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4494 variant: "h5", 4495 children: props.title 4496 }
4496), props.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4497 variant: "body2", 4498 color: "text.secondary", 4499 children: props.description 4500 })] }), !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4501 sx: headerActionsSx$3, 4502 children: [props.headerActions, /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 4503 variant: "outlined", 4504 size: "medium", 4505 sx: addButtonSx$1, 4506 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 4507 onClick: () => setIsAdding(true), 4508 "data-tracking-id": `${props.trackingIdPrefix}-add-btn`, 4509 children: props.addLabel 4510 })] 4511 })] 4512 }), 4513 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 4514 component: import_material.Paper, 4515 variant: "outlined", 4516 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 4517 size: "small", 4518 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [columns.map((column) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4519 align: column.align ?? "left", 4520 sx: headCellSx, 4521 children: column.label 4522 }, column.key)), !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: actionsCellSx })] }) }
4522), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [props.rows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4523 colSpan: columns.length + 1, 4524 sx: emptyCellSx$3, 4525 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4526 variant: "body1", 4527 color: "text.secondary", 4528 children: props.emptyText 4529 }) 4530 }) }), props.rows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 4531 sx: rowHoverSx, 4532 "data-testid": `ads-child-row-${row.id}`, 4533 children: [columns.map((column) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4534 align: column.align ?? "left", 4535 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, cellSx$1), 4536 children: column.render ? column.render(row) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4537 variant: "body1", 4538 noWrap: props.fields.find((field) => field.key === column.key)?.type === "date", 4539 children: defaultCell(row, column.key) 4540 }) 4541 }, column.key)), !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4542 sx: actionsCellSx, 4543 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 4544 size: "small", 4545 onClick: (event) => { 4546 setMenuAnchorEl(event.currentTarget); 4547 setMenuRow(row); 4548 }, 4549 "data-tracking-id": `${props.trackingIdPrefix}-row-options-btn`, 4550 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEllipsisV.faEllipsisV }) 4551 }) 4552 })] 4553 }, row.id))] })] 4554 }) 4555 }),
4556 menuRow && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowOptionsMenu, { 4557 anchorEl: menuAnchorEl, 4558 open: Boolean(menuAnchorEl), 4559 onClose: closeMenu, 4560 title: props.rowTitle(menuRow), 4561 actions: rowActions(menuRow), 4562 anchorOrigin: { 4563 vertical: "bottom", 4564 horizontal: "right" 4565 }, 4566 transformOrigin: { 4567 vertical: "top", 4568 horizontal: "right" 4569 } 4570 }), 4571 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 4572 open: props.bulkAdd ? Boolean(editing) : isAdding || Boolean(editing), 4573 title: editing ? `Edit ${props.title.toLowerCase()}` : props.addLabel, 4574 fields: editing && props.rowFields ? props.rowFields(editing) : props.fields, 4575 fieldsFor: props.rowFieldsFor, 4576 row: editing, 4577 otherRows: props.rows.filter((row) => row.id !== editing?.id), 4578 validateRow: props.validateRow, 4579 onClose: () => { 4580 setEditing(null); 4581 setIsAdding(false); 4582 }, 4583 onSubmit: handleSubmit, 4584 trackingIdPrefix: props.trackingIdPrefix 4585 }), 4586 props.bulkAdd && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 4587 open: isAdding, 4588 title: props.addLabel, 4589 fields: props.bulkAdd.fields, 4590 row: null, 4591 otherRows: [], 4592 onClose: () => setIsAdding(false), 4593 onSubmit: (values) => { 4594 props.onChange([...props.rows, ...props.bulkAdd.toRows(values)]); 4595 setIsAdding(false); 4596 }, 4597 trackingIdPrefix: `${props.trackingIdPrefix}-bulk` 4598 }) 4599 ] 4600 }); 4601}; 4602var sectionSx$11 = { 4603 display: "flex", 4604 flexDirection: "column", 4605 gap: 2 4606}; 4607var headerSx$6 = { 4608 display: "flex", 4609 alignItems: "center", 4610 justifyContent: "space-between", 4611 gap: 2 4612}; 4613var headerActionsSx$3 = { 4614 display: "flex", 4615 alignItems: "center", 4616 gap: 1, 4617 flexShrink: 0 4618}; 4619var addButtonSx$1 = { 4620 flexShrink: 0, 4621 whiteSpace: "nowrap" 4622}; 4623var cellSx$1 = { 4624 maxWidth: 320, 4625 wordBreak: "break-word" 4626}; 4627var emptyCellSx$3 = { 4628 padding: "24px 16px", 4629 textAlign: "center" 4630}; 4631//#endregion 4632//#region client/components/newNav/components/ads/dashboards/BudgetLanesPanel.tsx 4633/** 4634* A right-aligned percentage in the band colour, with the two counts under it. The 120px meter it replaces had a 4635* floating percentage and no axis, so it carried no information the number did not. 4636*/ 4637var PaceCell = ({ paceIndex, delivered, expected, scope, dataTestId }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4638 variant: "body2", 4639 align: "right", 4640 color: paceColor(paceIndex, paceTolerance(scope)), 4641 sx: numberSx$1, 4642 "data-testid": dataTestId, 4643 children: formatWholePercent(paceIndex) 4644}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4645 variant: "helper", 4646 align: "right", 4647 color: "text.secondary", 4648 component: "div", 4649 children: `${formatCompact(delivered)} of ${formatCompact(expected)} expected` 4650})] }); 4651/** 4652* Every lane at once, worst pace first, so a laggard is visible without drilling in. The serving side does not 4653* write lane rows yet, so the delivery columns say so rather than showing zeros - a lane at 0 delivered reads as 4654* a broken lane rather than an unshipped feature. 4655*/ 4656var BudgetLanesPanel = ({ lanes, hasLaneDelivery, unaccountedImpressions, campaignDelivered, selectedLaneName, onSelectLane, accountId, campaignId }) => { 4657 if (lanes.length === 0) return null; 4658 const ordered = [...lanes].sort((left, right) => (left.pacing?.paceIndex ?? Infinity) - (right.pacing?.paceIndex ?? Infinity)); 4659 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AdsDashboardCard, { 4660 title: "Budget lanes", 4661 dataTestId: "ads-budget-lanes", 4662 subtitle: hasLaneDelivery ? "worst pace first" : "configuration only", 4663 infoTooltip: hasLaneDelivery ? "Click a row to scope the whole page to that lane." : "The serving side does not write lane rows yet, so these lanes have configuration but no delivery.", 4664 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 4665 size: "small", 4666 variant: "text", 4667 component: Link$6, 4668 to: (0, import_dist$1.newNavAdsCampaignSectionPath)({ 4669 accountId, 4670 campaignId, 4671 sectionId: ADS_CAMPAIGN_FLIGHTS_SECTION_ID 4672 }), 4673 "data-testid": "ads-lanes-edit-link", 4674 "data-tracking-id": "tracking-id-ads-dashboard-edit-lanes-link", 4675 children: "Edit lanes" 4676 }), 4677 children: [
4678 !hasLaneDelivery && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4679 variant: "body2", 4680 color: "text.secondary", 4681 children: "These lanes are configured but the ad servers are not yet writing delivery per lane, so their goals are shown and their delivery is not." 4682 }), 4683 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 4684 size: "small", 4685 children: [ 4686 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 4687 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4688 sx: headCellSx, 4689 children: "Lane" 4690 }), 4691 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4692 align: "right", 4693 sx: headCellSx, 4694 children: "Share" 4695 }), 4696 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4697 align: "right", 4698 sx: headCellSx, 4699 children: "Goal" 4700 }), 4701 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4702 align: "right", 4703 sx: headCellSx, 4704 children: "Flight pace" 4705 }), 4706 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4707 align: "right", 4708 sx: headCellSx, 4709 children: "Today pace" 4710 }) 4711 ] }) }), 4712 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: ordered.map((lane) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 4713 selected: lane.name === selectedLaneName, 4714 onClick: () => onSelectLane(lane.name === selectedLaneName ? null : lane.name), 4715 sx: (0, import_dist$2.combineSxProps)(rowHoverSx, rowSx$2), 4716 "data-testid": `ads-lane-row-${lane.name}`, 4717 "data-tracking-id": "tracking-id-ads-dashboard-lane-row", 4718 children: [ 4719 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableCell, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4720 variant: "body2", 4721 sx: laneNameSx$2, 4722 children: lane.name 4723 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4724 variant: "helper", 4725 color: "text.secondary", 4726 children: lane.isCatchAll ? "everywhere the earlier lanes did not match" : lane.geoFilterName ? `geo ${lane.geoFilterName}` : "no geography" 4727 })] }), 4728 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4729 align: "right", 4730 sx: bodyCellSx, 4731 children: `${lane.sharePercent}%` 4732 }), 4733 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4734 align: "right", 4735 sx: bodyCellSx, 4736 children: formatCompact(lane.goal) 4737 }), 4738 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4739 align: "right", 4740 sx: paceCellSx,
4741 children: lane.pacing ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PaceCell, { 4742 paceIndex: lane.pacing.paceIndex, 4743 delivered: lane.pacing.delivered, 4744 expected: lane.pacing.expectedByNow, 4745 scope: "flight", 4746 dataTestId: `ads-lane-flight-pace-${lane.name}` 4747 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4748 variant: "helper", 4749 color: "text.secondary", 4750 children: "not available yet" 4751 }) 4752 }), 4753 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4754 align: "right", 4755 sx: paceCellSx, 4756 children: lane.pacing ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PaceCell, { 4757 paceIndex: lane.pacing.todayPaceIndex, 4758 delivered: lane.pacing.deliveredToday, 4759 expected: lane.pacing.todayExpected, 4760 scope: "today", 4761 dataTestId: `ads-lane-today-pace-${lane.name}` 4762 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4763 variant: "helper", 4764 color: "text.secondary", 4765 children: "-" 4766 }) 4767 }) 4768 ] 4769 }, lane.name)) }), 4770 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableFooter, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 4771 sx: adsTableTotalRowSx, 4772 children: [ 4773 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4774 sx: bodyCellSx, 4775 children: "Campaign total" 4776 }), 4777 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4778 align: "right", 4779 sx: bodyCellSx, 4780 children: `${lanes.reduce((sum, lane) => sum + (lane.enabled ? lane.sharePercent : 0), 0)}%` 4781 }), 4782 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4783 align: "right", 4784 sx: bodyCellSx, 4785 children: formatCompact(lanes.reduce((sum, lane) => sum + lane.goal, 0)) 4786 }), 4787 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4788 colSpan: 2, 4789 children: `${formatCompact(campaignDelivered)} delivered` 4790 }) 4791 ] 4792 }) }) 4793 ] 4794 }), 4795 unaccountedImpressions !== null && unaccountedImpressions !== 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4796 variant: "body2", 4797 color: "warning.main", 4798 children: `${formatCount(unaccountedImpressions)} impressions are unaccounted for: the lanes do not add up to the campaign, which means a lane row is missing or traffic was served without one.` 4799 }) 4800 ] 4801 }); 4802}; 4803var rowSx$2 = { cursor: "pointer" }; 4804var laneNameSx$2 = { fontWeight: 500 }; 4805var paceCellSx = { minWidth: 120 }; 4806var numberSx$1 = { fontVariantNumeric: "tabular-nums" }; 4807//#endregion 4808//#region client/components/newNav/components/ads/dashboards/DeliveryByExchange.tsx 4809var DeliveryByExchange = ({ exchanges, totalImpressions, totalSpend }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 4810 dataTestId: "ads-delivery-by-exchange", 4811 title: "Delivery by exchange", 4812 subtitle: "this flight", 4813 children: exchanges.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { message: "No delivery has been attributed to an exchange in this flight." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 4814 size: "small", 4815 children: [ 4816 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [
4817 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4818 sx: headCellSx, 4819 children: "Exchange" 4820 }), 4821 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4822 align: "right", 4823 sx: headCellSx, 4824 children: "Impressions" 4825 }), 4826 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4827 align: "right", 4828 sx: headCellSx, 4829 children: "Share" 4830 }), 4831 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4832 align: "right", 4833 sx: headCellSx, 4834 children: "Spend" 4835 }), 4836 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4837 align: "right", 4838 sx: headCellSx, 4839 children: "eCPM" 4840 }), 4841 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4842 align: "right", 4843 sx: headCellSx, 4844 children: "Daily cap" 4845 }), 4846 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4847 sx: headCellSx, 4848 children: "Status" 4849 }) 4850 ] }) }), 4851 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: exchanges.map((exchange) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 4852 sx: rowHoverSx, 4853 children: [ 4854 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableCell, { 4855 sx: bodyCellSx, 4856 children: [exchange.name, /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4857 variant: "helper", 4858 color: "text.secondary", 4859 component: "div", 4860 children: `id ${exchange.prefix}` 4861 })] 4862 }), 4863 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4864 align: "right", 4865 sx: bodyCellSx, 4866 children: formatCount(exchange.impressions) 4867 }), 4868 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4869 align: "right", 4870 sx: bodyCellSx, 4871 children: formatWholePercent(exchange.share) 4872 }), 4873 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4874 align: "right", 4875 sx: bodyCellSx, 4876 children: formatMoney(exchange.spend) 4877 }), 4878 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4879 align: "right", 4880 sx: bodyCellSx, 4881 children: formatMoney(exchange.ecpm) 4882 }), 4883 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4884 align: "right", 4885 sx: bodyCellSx, 4886 children: exchange.isBlocked ? "blocked" : formatCount(exchange.dailyCap) 4887 }), 4888 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { children: exchange.isBlocked ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 4889 size: "small", 4890 variant: "standard", 4891 color: "error", 4892 label: "blocked by cap" 4893 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4894 variant: "helper", 4895 color: "text.secondary", 4896 children: "-" 4897 }) }) 4898 ] 4899 }, exchange.prefix)) }), 4900 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableFooter, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 4901 sx: adsTableTotalRowSx, 4902 children: [ 4903 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4904 sx: bodyCellSx, 4905 children: "Total" 4906 }), 4907 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4908 align: "right", 4909 sx: bodyCellSx, 4910 children: formatCount(totalImpressions) 4911 }), 4912 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4913 align: "right", 4914 sx: bodyCellSx, 4915 children: formatWholePercent(1) 4916 }), 4917 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4918 align: "right", 4919 sx: bodyCellSx, 4920 children: formatMoney(totalSpend) 4921 }), 4922 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 4923 align: "right", 4924 sx: bodyCellSx, 4925 children: totalImpressions > 0 ? formatMoney(totalSpend * 1e3 / totalImpressions) : "-" 4926 }),
4927 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { colSpan: 2 }) 4928 ] 4929 }) }) 4930 ] 4931 }) 4932}); 4933//#endregion 4934//#region client/components/newNav/components/ads/dashboards/LaneScopeSelector.tsx 4935/** 4936* The scope switch doubles as a summary: every lane carries its own pace, so a laggard is visible before anyone 4937* clicks into it. 4938* 4939* "Whole campaign" is deliberately outside the toggle group. In one group at one visual level nothing said the 4940* lanes are subsets of it, so the chevron and the words between the two say it instead. 4941*/ 4942var LaneScopeSelector = ({ lanes, selected, onSelect, campaignPaceIndex, campaignTolerance }) => { 4943 const options = lanes.map((lane) => ({ 4944 value: lane.name, 4945 name: lane.name, 4946 detail: `${lane.sharePercent}% share`, 4947 paceIndex: lane.pacing?.paceIndex ?? null, 4948 hasData: Boolean(lane.pacing) 4949 })); 4950 const selectedLane = lanes.find((lane) => lane.name === selected); 4951 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4952 sx: wrapperSx$2, 4953 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 4954 sx: barSx$2, 4955 children: [
4956 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4957 variant: "caption", 4958 color: "text.secondary", 4959 children: "Scope" 4960 }), 4961 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Button, { 4962 variant: selected === null ? "contained" : "outlined", 4963 sx: campaignButtonSx, 4964 onClick: () => onSelect(null), 4965 "data-testid": "ads-dashboard-scope-campaign", 4966 "data-tracking-id": "tracking-id-ads-dashboard-scope-campaign-btn", 4967 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4968 variant: "subtitle1", 4969 color: "inherit", 4970 noWrap: true, 4971 children: "Whole campaign" 4972 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 4973 size: "small", 4974 variant: "standard", 4975 color: paceStatusColor(campaignPaceIndex, campaignTolerance), 4976 label: formatWholePercent(campaignPaceIndex) 4977 })] 4978 }), 4979 options.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 4980 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }), 4981 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4982 variant: "caption", 4983 color: "text.secondary", 4984 children: "filtered to" 4985 }), 4986 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleToggleButtonGroup, { 4987 exclusive: true, 4988 value: selected ?? "", 4989 onChange: (_event, value) => onSelect(value === null || value === "" ? null : value), 4990 "aria-label": "Budget lane scope", 4991 sx: groupSx$3, 4992 children: options.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.ToggleButton, { 4993 value: option.value, 4994 sx: buttonSx, 4995 "data-testid": `ads-dashboard-scope-${option.value}`, 4996 "data-tracking-id": "tracking-id-ads-dashboard-scope-btn", 4997 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 4998 variant: "subtitle2", 4999 color: "secondary", 5000 noWrap: true, 5001 children: option.name 5002 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5003 sx: metaSx, 5004 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5005 variant: "helper", 5006 color: "text.secondary", 5007 children: option.detail 5008 }), option.hasData ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5009 size: "small", 5010 variant: "standard", 5011 color: paceStatusColor(option.paceIndex, paceTolerance()), 5012 label: formatWholePercent(option.paceIndex) 5013 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5014 size: "small", 5015 variant: "dashed", 5016 color: "default", 5017 label: "no data" 5018 })] 5019 })] 5020 }, option.value)) 5021 }) 5022 ] }) 5023 ] 5024 }), selectedLane && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5025 variant: "helper", 5026 color: "text.secondary", 5027 "data-testid": "ads-dashboard-scope-note", 5028 children: `Showing the ${selectedLane.name} lane only - ${selectedLane.sharePercent}% of the campaign goal. Every number below is this lane's.` 5029 })] 5030 }); 5031}; 5032var wrapperSx$2 = {
5033 display: "flex", 5034 flexDirection: "column", 5035 gap: .5, 5036 minWidth: 0 5037}; 5038var barSx$2 = { 5039 display: "flex", 5040 alignItems: "center", 5041 gap: 1.5, 5042 flexWrap: "wrap" 5043}; 5044var groupSx$3 = { 5045 flexWrap: "wrap", 5046 marginLeft: 1 5047}; 5048var campaignButtonSx = { 5049 display: "flex", 5050 alignItems: "center", 5051 gap: 1, 5052 textTransform: "none", 5053 paddingY: 1, 5054 paddingX: 2 5055}; 5056var buttonSx = { 5057 display: "flex", 5058 flexDirection: "column", 5059 alignItems: "flex-start", 5060 justifyContent: "center", 5061 gap: .5, 5062 textTransform: "none", 5063 paddingY: 1, 5064 paddingX: 1.5, 5065 minWidth: 140 5066}; 5067var metaSx = { 5068 display: "flex", 5069 alignItems: "center", 5070 gap: .75 5071}; 5072//#endregion 5073//#region client/components/newNav/components/ads/dashboards/PriceAndQualityCards.tsx 5074var Row = ({ label, value, note, valueColor, status, statusText }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5075 sx: rowSx$1, 5076 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5077 variant: "body2", 5078 color: "text.secondary", 5079 children: label 5080 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5081 sx: rowValueSx, 5082 children: [ 5083 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5084 variant: "body2", 5085 sx: numberSx, 5086 color: valueColor, 5087 children: value 5088 }), 5089 note && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5090 variant: "helper", 5091 color: "text.secondary", 5092 children: note 5093 }), 5094 status && status !== "default" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5095 size: "small", 5096 variant: "standard", 5097 color: status, 5098 label: statusText ?? BAND_LABELS[status] 5099 }) 5100 ] 5101 })] 5102}); 5103/** 5104* Where the four non-impression goal types live, as indicators rather than charts. Every metric uses the same 5105* shape - value, target beside it, one bar with the target as a tick - so one visual idiom covers the page. 5106*/ 5107var PriceAndQualityCards = ({ price, quality, elapsedShare, tolerance, delivered }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5108 sx: twoUpSx$2, 5109 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5110 dataTestId: "ads-price-margin", 5111 title: "Price and margin", 5112 subtitle: "campaign total", 5113 children: price ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 5114 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5115 label: "Customer CPM", 5116 value: formatMoney(price.customerCpm) 5117 }), 5118 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5119 label: "Margin configured", 5120 value: formatRate(price.marginPercent / 100) 5121 }), 5122 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5123 label: "Target CPM", 5124 value: formatMoney(price.targetCpm), 5125 note: "(1 - margin) x customer CPM" 5126 }), 5127 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5128 label: "Spend", 5129 value: formatMoney(price.spend) 5130 }), 5131 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5132 label: "Revenue", 5133 value: formatMoney(price.revenue) 5134 }), 5135 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5136 label: "Realised margin", 5137 value: price.realisedMarginPercent === null ? "-" : formatRate(price.realisedMarginPercent / 100), 5138 valueColor: marginColor(price.realisedMarginPercent, price.marginPercent) 5139 }), 5140 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { 5141 label: "Bid range", 5142 value: price.lowBidPrice === null || price.highBidPrice === null ? "-" : `${formatMoney(price.lowBidPrice)} - ${formatMoney(price.highBidPrice)}` 5143 }), 5144 price.spendCap !== null && price.spendCap > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsMeter, { 5145 name: "Spend against the flight cap", 5146 fill: price.spend / price.spendCap, 5147 value: formatWholePercent(price.spend / price.spendCap), 5148 target: `${formatMoney(price.spend)} of ${formatMoney(price.spendCap)}`, 5149 tick: elapsedShare, 5150 tickTitle: "Where the flight should be right now", 5151 color: inverseStatusColor(price.spendCap > 0 ? price.spend / price.spendCap : null, elapsedShare, tolerance), 5152 dataTestId: "ads-price-spend-cap" 5153 }
5153), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5154 variant: "helper", 5155 color: "text.secondary", 5156 children: "Spend tracking below the tick while the eCPM is under target is the healthy case." 5157 })] }) 5158 ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5159 variant: "body2", 5160 color: "text.secondary", 5161 children: "No flight covers today, so there is no price to show." 5162 }) 5163 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5164 dataTestId: "ads-quality", 5165 title: "Delivery quality vs target", 5166 infoTooltip: "Each metric is its running sum divided by impressions.", 5167 children: quality.map((metric) => { 5168 const isUnreported = metric.key === "completionRate" && metric.actual === 0; 5169 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [ 5170 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsMeter, { 5171 name: QUALITY_LABELS[metric.key] ?? metric.key, 5172 fill: isUnreported ? null : metric.actual, 5173 value: metric.actual === null || isUnreported ? "-" : formatRate(metric.actual), 5174 target: isUnreported ? "no completed views in the delivery data" : metric.target === null ? metric.key === "completionRate" ? "no target - informational" : "no target configured" : `target ${formatRate(metric.target)}`, 5175 tick: metric.target, 5176 tickTitle: "The configured target", 5177 color: metric.target === null || metric.actual === null || isUnreported ? "default" : paceStatusColor(metric.actual / metric.target, tolerance), 5178 dataTestId: `ads-quality-${metric.key}` 5179 }),
5180 isUnreported && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5181 variant: "helper", 5182 color: "text.secondary", 5183 children: "The tracking pipeline has recorded zero completed views for this campaign across the whole flight. Until completion events arrive, this rate cannot be computed." 5184 }), 5185 metric.key === "completionRate" && delivered <= 1e3 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5186 variant: "helper", 5187 color: "text.secondary", 5188 children: `Fewer than ${formatCount(1e3)} impressions so far, so the CPCV goal is still using the assumed 50%.` 5189 }) 5190 ] }, metric.key); 5191 }) 5192 })] 5193}); 5194var twoUpSx$2 = { 5195 display: "grid", 5196 gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))", 5197 gap: 3, 5198 minWidth: 0 5199}; 5200var rowSx$1 = { 5201 display: "grid", 5202 gridTemplateColumns: "minmax(0, 1fr) auto", 5203 alignItems: "baseline", 5204 gap: 2, 5205 maxWidth: 480 5206}; 5207var rowValueSx = { 5208 display: "flex", 5209 alignItems: "baseline", 5210 gap: 1 5211}; 5212var numberSx = { fontVariantNumeric: "tabular-nums" }; 5213//#endregion 5214//#region client/components/newNav/components/ads/dashboards/ScoreThresholdPanel.tsx 5215var CHART_HEIGHT$3 = 120; 5216var STATE_COLORS = { 5217 [GqlClientAdsSpaceThresholdState.PINNED_AT_MINIMUM]: "error", 5218 [GqlClientAdsSpaceThresholdState.LOOSENING]: "warning", 5219 [GqlClientAdsSpaceThresholdState.STEADY]: "success", 5220 [GqlClientAdsSpaceThresholdState.TIGHTENING]: "warning", 5221 [GqlClientAdsSpaceThresholdState.PINNED_AT_MAXIMUM]: "error" 5222}; 5223var formatThreshold = (value) => value === null || value === void 0 ? "-" : value.toFixed(2); 5224/** 5225* The admission bar: a bid request whose score does not clear it gets no bid. The raw one-minute series is mostly 5226* jitter, so what is shown is a state, a position on the campaign's own range, and a smoothed band. 5227*/ 5228var ScoreThresholdPanel = ({ threshold, lanes, laneName }) => { 5229 const theme = (0, import_material.useTheme)(); 5230 const option = (0, import_react.useMemo)(() => { 5231 if (!threshold || threshold.history.length === 0) return null; 5232 const labels = threshold.history.map((point) => formatUtcTime(point.at).slice(0, 5)); 5233 return { 5234 grid: { 5235 left: 44, 5236 right: 12, 5237 top: 8, 5238 bottom: 20 5239 }, 5240 tooltip: { 5241 trigger: "axis", 5242 backgroundColor: theme.palette.background.paper, 5243 borderColor: theme.palette.divider, 5244 textStyle: { 5245 color: theme.palette.text.primary, 5246 fontSize: 12 5247 } 5248 }, 5249 xAxis: { 5250 type: "category", 5251 data: labels, 5252 axisLabel: { 5253 color: theme.palette.text.secondary, 5254 fontSize: 10 5255 }, 5256 axisLine: { lineStyle: { color: theme.palette.divider } }, 5257 axisTick: { show: false } 5258 }, 5259 yAxis: { 5260 type: "value", 5261 scale: true, 5262 axisLabel: { 5263 color: theme.palette.text.secondary, 5264 fontSize: 10, 5265 formatter: (value) => value.toFixed(2) 5266 }, 5267 splitLine: { lineStyle: { 5268 color: theme.palette.divider, 5269 type: "dashed" 5270 } } 5271 }, 5272 series: [ 5273 { 5274 name: "min", 5275 type: "line", 5276 stack: "band", 5277 showSymbol: false, 5278 lineStyle: { opacity: 0 }, 5279 data: threshold.history.map((point) => point.min) 5280 }, 5281 { 5282 name: "spread", 5283 type: "line", 5284 stack: "band", 5285 showSymbol: false, 5286 lineStyle: { opacity: 0 }, 5287 areaStyle: { 5288 color: theme.palette.primary.light, 5289 opacity: .35 5290 }, 5291 data: threshold.history.map((point) => point.max - point.min) 5292 }, 5293 { 5294 name: "median", 5295 type: "line", 5296 showSymbol: false, 5297 smooth: true, 5298 lineStyle: { 5299 color: theme.palette.primary.main, 5300 width: 2 5301 }, 5302 itemStyle: { color: theme.palette.primary.main }, 5303 data: threshold.history.map((point) => point.median) 5304 } 5305 ] 5306 }; 5307 }, [threshold, theme]); 5308 if (!laneName && lanes.length > 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5309 dataTestId: "ads-score-threshold", 5310 title: "Score threshold", 5311 subtitle: "one per lane", 5312 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LaneThresholdSummary, { lanes }) 5313 }); 5314 if (!threshold) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5315 dataTestId: "ads-score-threshold", 5316 title: "Score threshold", 5317 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { message: "This campaign has no pacing adjustments to show." }) 5318 }); 5319 if (!threshold.isCurrent) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5320 dataTestId: "ads-score-threshold", 5321 title: "Score threshold", 5322 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { message: threshold.writtenAt ? `No recent pacing data. The last value was written ${formatRelativeAge(threshold.writtenAt)}, so it does not describe this campaign now.` : "No pacing data has been written for this campaign." }) 5323 }); 5324 const railMin = threshold.isRangeDefaulted && threshold.observedMin !== null ? threshold.observedMin : threshold.rangeMin;
5325 const railMax = threshold.isRangeDefaulted && threshold.observedMax !== null ? threshold.observedMax : threshold.rangeMax; 5326 const span = Math.max(railMax - railMin, Number.EPSILON); 5327 const position = Math.min(1, Math.max(0, (threshold.value - railMin) / span)); 5328 const bandStart = Math.min(1, Math.max(0, ((threshold.observedMin ?? railMin) - railMin) / span)); 5329 const bandWidth = Math.min(1 - bandStart, Math.max(0, ((threshold.observedMax ?? railMax) - (threshold.observedMin ?? railMin)) / span)); 5330 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AdsDashboardCard, { 5331 dataTestId: "ads-score-threshold", 5332 title: "Score threshold", 5333 subtitle: `updated ${formatUtcTime(threshold.writtenAt)}`, 5334 infoTooltip: `The admission bar: a bid request scoring below it gets no bid.${threshold.host ? ` Last written by ${threshold.host}.` : ""}`, 5335 children: [ 5336 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5337 sx: stateRowSx, 5338 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5339 size: "medium", 5340 variant: "standard", 5341 color: STATE_COLORS[threshold.state], 5342 label: THRESHOLD_STATE_LABELS[threshold.state] 5343 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5344 variant: "h4", 5345 sx: valueSx, 5346 children: formatThreshold(threshold.value) 5347 })] 5348 }), 5349 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5350 variant: "body2", 5351 color: "text.secondary", 5352 children: THRESHOLD_STATE_MEANINGS[threshold.state] 5353 }), 5354 threshold.overrideValue !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5355 variant: "body2", 5356 color: "warning.main", 5357 children: `This campaign is pinned to ${formatThreshold(threshold.overrideValue)} by an override, so it is not adapting at all.` 5358 }), 5359 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [
5360 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5361 sx: railSx, 5362 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { sx: observedBandSx(bandStart, bandWidth) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { sx: markerSx(position) })] 5363 }), 5364 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5365 sx: railLabelsSx, 5366 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5367 variant: "helper", 5368 color: "text.secondary", 5369 children: `${formatThreshold(railMin)} - bid on everything` 5370 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5371 variant: "helper", 5372 color: "text.secondary", 5373 children: `bid on nothing - ${formatThreshold(railMax)}` 5374 })] 5375 }), 5376 threshold.isRangeDefaulted && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5377 variant: "helper", 5378 color: "text.secondary", 5379 children: "The configured range is not set for this campaign, so the scale is drawn from the value's own observed range." 5380 }) 5381 ] }),
5382 option && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 5383 dataTestId: "ads-score-threshold-history", 5384 title: "Last 30 minutes", 5385 option, 5386 containerStyle: { height: CHART_HEIGHT$3 }, 5387 removeBorder: true 5388 }) 5389 ] 5390 }); 5391}; 5392/** At all-lanes scope: one compact row per lane, since there is no single campaign value to show. */ 5393var LaneThresholdSummary = ({ lanes }) => { 5394 const withData = lanes.filter((lane) => lane.scoreThreshold?.isCurrent); 5395 if (withData.length === 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { message: "Per-lane pacing data is not available yet, so there is no threshold to show for any lane." }); 5396 const pinned = withData.filter((lane) => lane.scoreThreshold.state === GqlClientAdsSpaceThresholdState.PINNED_AT_MINIMUM || lane.scoreThreshold.state === GqlClientAdsSpaceThresholdState.PINNED_AT_MAXIMUM).length; 5397 const values = withData.map((lane) => lane.scoreThreshold.value); 5398 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 5399 variant: "body2", 5400 color: "text.secondary", 5401 children: [`${formatThreshold(Math.min(...values))} to ${formatThreshold(Math.max(...values))} across ${withData.length} lanes`, pinned > 0 ? `, ${pinned} pinned` : ""] 5402 }), withData.map((lane) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5403 sx: laneRowSx, 5404 children: [ 5405 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5406 variant: "body2", 5407 sx: laneNameSx$1, 5408 children: lane.name 5409 }), 5410 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5411 size: "small", 5412 variant: "standard", 5413 color: STATE_COLORS[lane.scoreThreshold.state], 5414 label: THRESHOLD_STATE_LABELS[lane.scoreThreshold.state] 5415 }), 5416 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5417 variant: "subtitle2", 5418 sx: valueSx, 5419 children: formatThreshold(lane.scoreThreshold.value) 5420 }) 5421 ] 5422 }, lane.name))] }); 5423}; 5424var stateRowSx = { 5425 display: "flex", 5426 alignItems: "center", 5427 justifyContent: "space-between", 5428 gap: 2 5429}; 5430var valueSx = { fontVariantNumeric: "tabular-nums" }; 5431var railSx = { 5432 position: "relative", 5433 height: 12, 5434 borderRadius: "6px", 5435 backgroundColor: "grey.200", 5436 overflow: "hidden" 5437}; 5438var observedBandSx = (start, width) => ({ 5439 position: "absolute", 5440 top: 0, 5441 bottom: 0, 5442 left: `${start * 100}%`, 5443 width: `${width * 100}%`, 5444 backgroundColor: "primary.light" 5445}); 5446var markerSx = (position) => ({ 5447 position: "absolute", 5448 top: 0, 5449 bottom: 0, 5450 left: `${position * 100}%`, 5451 width: "3px", 5452 backgroundColor: "primary.main" 5453}); 5454var railLabelsSx = { 5455 display: "flex", 5456 justifyContent: "space-between", 5457 gap: 1, 5458 marginTop: .5 5459}; 5460var laneRowSx = { 5461 display: "flex", 5462 alignItems: "center", 5463 gap: 1, 5464 justifyContent: "space-between" 5465}; 5466var laneNameSx$1 = { 5467 fontWeight: 500, 5468 minWidth: 0, 5469 overflow: "hidden", 5470 textOverflow: "ellipsis", 5471 whiteSpace: "nowrap" 5472}; 5473//#endregion 5474//#region client/components/newNav/components/ads/dashboards/VolumeDistributionCard.tsx 5475var CHART_HEIGHT$2 = 320; 5476/** 5477* The hours-distribution plan behind every "expected by now" number on this page. It sits directly under the 5478* Today chart because the dashed line there is this distribution - the two explain each other. 5479*/ 5480var VolumeDistributionCard = ({ distribution }) => { 5481 const theme = (0, import_material.useTheme)(); 5482 const [isStacked, setIsStacked] = (0, import_react.useState)(false); 5483 const option = (0, import_react.useMemo)(() => distribution.isCustom ? volumeDistributionChartOption(distribution.byDay, theme, isStacked) : null, [ 5484 distribution, 5485 theme, 5486 isStacked 5487 ]); 5488 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 5489 dataTestId: "ads-volume-distribution", 5490 title: "Volume distribution", 5491 infoTooltip: "The share of the week's delivery the plan puts in each hour. This is what every expected figure on the page is derived from.", 5492 headerActions: distribution.isCustom ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleToggleButtonGroup, { 5493 exclusive: true, 5494 size: "small", 5495 value: isStacked ? "stacked" : "byDay", 5496 onChange: (_event, value) => value !== null && setIsStacked(value === "stacked"), 5497 "aria-label": "Volume distribution view", 5498 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 5499 value: "byDay", 5500 "data-tracking-id": "tracking-id-ads-volume-distribution-by-day-btn", 5501 children: "By day" 5502 }
5502), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 5503 value: "stacked", 5504 "data-tracking-id": "tracking-id-ads-volume-distribution-stacked-btn", 5505 children: "Stacked" 5506 })] 5507 }) : void 0, 5508 children: distribution.isCustom ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5509 sx: pathSx, 5510 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5511 variant: "helper", 5512 color: "text.secondary", 5513 children: "Read from" 5514 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 5515 value: distribution.path, 5516 monospace: true, 5517 maxWidth: 520, 5518 dataTestId: "ads-volume-distribution-path", 5519 trackingId: "tracking-id-ads-volume-distribution-path-copy" 5520 })] 5521 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 5522 dataTestId: "ads-volume-distribution-chart", 5523 title: "", 5524 option, 5525 removeBorder: true, 5526 containerStyle: { height: CHART_HEIGHT$2 } 5527 })] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5528 variant: "body2", 5529 color: "text.secondary", 5530 children: "No hours-distribution file was readable for this campaign, so delivery is planned flat across the whole flight and every \"expected by now\" on this page is elapsed clock time." 5531 }) 5532 }); 5533}; 5534var pathSx = { 5535 display: "flex", 5536 alignItems: "center", 5537 gap: 1, 5538 minWidth: 0 5539}; 5540//#endregion 5541//#region client/components/newNav/components/ads/dashboards/CampaignDashboard.tsx 5542var CHART_HEIGHT$1 = 300; 5543/** 5544* Delivery against plan for one campaign, or for one of its lanes. Everything is read-only; the settings that 5545* explain what is shown here are the sections next door. 5546*/ 5547var CampaignDashboard = ({ campaignId }) => { 5548 const theme = (0, import_material.useTheme)(); 5549 const accountId = useActiveAccountId(); 5550 const [laneName, setLaneName] = (0, import_react.useState)(null); 5551 const { delivery, loading, isRefreshing, error, refresh, campaignPaceIndex } = useAdsSpaceCampaignDelivery(campaignId, laneName); 5552 const flightOption = (0, import_react.useMemo)(() => delivery ? flightChartOption(delivery.flightSeries, delivery.pacing?.goal ?? 0, theme) : null, [delivery, theme]); 5553 const todayOption = (0, import_react.useMemo)(() => delivery ? todayChartOption(delivery.todaySeries, theme) : null, [delivery, theme]); 5554 if (loading) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5555 sx: stackSx$5, 5556 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: 96 }
5556), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: CHART_HEIGHT$1 })] 5557 }); 5558 if (error || !delivery) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { 5559 title: "No delivery to show", 5560 message: "This campaign's delivery could not be read. The settings sections still apply." 5561 }); 5562 const { pacing, flight } = delivery; 5563 const selectedLane = delivery.lanes.find((lane) => lane.name === laneName); 5564 const stateChip = campaignStateChip(delivery.enabled, Boolean(flight), delivery.status, delivery.isPausedOutsideSpace); 5565 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5566 sx: stackSx$5, 5567 children: [ 5568 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5569 sx: headerSx$5, 5570 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5571 sx: chipsSx$1, 5572 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 5573 title: stateChip.tooltip ?? "", 5574 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 5575 size: "small", 5576 label: stateChip.label, 5577 color: stateChip.color, 5578 variant: "standard" 5579 }) 5580 }), flight && pacing && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5581 variant: "caption", 5582 color: "text.secondary", 5583 children: `Flight ${flight.position} of ${flight.count} - day ${pacing.dayIndex} of ${pacing.dayCount} - ${pacing.daysLeft} days left` 5584 })] 5585 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5586 sx: headerRightSx, 5587 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5588 sx: asOfSx, 5589 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryFreshness, { 5590 lastUpdateTime: delivery.freshness.lastUpdateTime, 5591 isKnown: delivery.freshness.isKnown 5592 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 5593 variant: "helper", 5594 color: "text.secondary", 5595 children: `Config version ${delivery.configVersion ?? "-"}, last changed ${delivery.updatedAt ? formatDate(delivery.updatedAt.slice(0, 10)) : "-"}` 5596 })] 5597 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryRefreshButton, { 5598 isRefreshing, 5599 onRefresh: refresh 5600 })] 5601 })] 5602 }), 5603 delivery.lanes.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 5604 sx: stickyScopeSx, 5605 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LaneScopeSelector, { 5606 lanes: delivery.lanes, 5607 selected: laneName, 5608 onSelect: setLaneName, 5609 campaignPaceIndex, 5610 campaignTolerance: paceTolerance() 5611 }) 5612 })] }),
5613 !flight || !pacing ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { 5614 title: "No flight covers today", 5615 message: "There is no goal to pace against until a flight starts. The settings sections still apply." 5616 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5617 sx: contentSx$10(isRefreshing), 5618 children: [ 5619 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5620 sx: kpiStripSx$1, 5621 children: [ 5622 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 5623 dataTestId: "ads-kpi-goal", 5624 label: "Goal", 5625 value: formatCount(pacing.goal), 5626 subLine: selectedLane ? `${selectedLane.sharePercent}% of the campaign` : "impressions, this flight" 5627 }), 5628 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 5629 dataTestId: "ads-kpi-delivered", 5630 label: "Delivered", 5631 value: formatCount(pacing.delivered), 5632 subLine: `${formatWholePercent(pacing.goal > 0 ? pacing.delivered / pacing.goal : null)} of goal` 5633 }), 5634 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 5635 dataTestId: "ads-kpi-pace", 5636 label: "Pace", 5637 value: describePace(pacing), 5638 subLine: `expected by now ${formatCompact(pacing.expectedByNow)}`, 5639 status: pacing.hasEnoughData ? paceSkewChip(pacing.delivered, pacing.expectedByNow, pacing.goal) : void 0, 5640 infoTooltip: "Delivered divided by what the volume-weighted plan expected by now. 100% is exactly on plan." 5641 }), 5642 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 5643 dataTestId: "ads-kpi-today", 5644 label: "Today", 5645 value: formatCount(pacing.deliveredToday), 5646 subLine: `of ${formatCompact(pacing.todayGoal)} planned for today`, 5647 status: { 5648 text: formatWholePercent(pacing.todayPaceIndex), 5649 color: paceStatusColor(pacing.todayPaceIndex, paceTolerance("today")) 5650 } 5651 }), 5652 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 5653 dataTestId: "ads-kpi-ecpm", 5654 label: "eCPM", 5655 value: formatMoney(delivery.price?.actualEcpm), 5656 subLine: delivery.price ? `target ${formatMoney(delivery.price.targetCpm)} - margin ${formatRate(delivery.price.marginPercent / 100)}` : "no price configured", 5657 status: ecpmStatus(delivery.price?.actualEcpm, delivery.price?.targetCpm, paceTolerance()), 5658 infoTooltip: "Spend so far divided by impressions so far, against the target the configured margin implies. Under target is the good direction." 5659 }) 5660 ] 5661 }), 5662 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5663 sx: twoUpSx$1, 5664 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 5665 dataTestId: "ads-flight-delivery", 5666 title: "Flight: delivered vs expected", 5667 SubTitle: `${formatDate(flight.startDate)} - ${formatDate(flight.endDate)}`, 5668 tooltipText: `Cumulative delivered against the volume-weighted plan. Green is delivery that ran ahead of plan, red is the amount it is behind by. ${formatCompact(pacing.remaining)} still to deliver.` + (pacing.isVolumeWeighted ? "" : " No hours-distribution file was readable, so delivery is expected flat and the pace is approximate."), 5669 option: flightOption, 5670 containerStyle: { height: CHART_HEIGHT$1 } 5671 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 5672 dataTestId: "ads-today-delivery", 5673 title: "Today: delivered vs expected", 5674 SubTitle: `${formatWholePercent(pacing.todayPaceIndex)} of expected`, 5675 tooltipText: "Each finished hour against what the plan expected of it. The hour in progress shows what it has delivered plus, in dashed gray, what the plan still expects of it before the hour ends. The reference is expected, not goal - the engine also folds in an overclock factor and holidays.", 5676 option: todayOption, 5677 containerStyle: { height: CHART_HEIGHT$1 } 5678 })] 5679 }), 5680 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VolumeDistributionCard, { distribution: delivery.volumeDistribution }),
5681 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 5682 sx: twoUpSx$1, 5683 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ScoreThresholdPanel, { 5684 threshold: delivery.scoreThreshold, 5685 lanes: delivery.lanes, 5686 laneName 5687 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BudgetLanesPanel, { 5688 lanes: delivery.lanes, 5689 hasLaneDelivery: delivery.hasLaneDelivery, 5690 unaccountedImpressions: delivery.unaccountedImpressions, 5691 campaignDelivered: pacing.delivered, 5692 selectedLaneName: laneName, 5693 onSelectLane: setLaneName, 5694 accountId, 5695 campaignId 5696 })] 5697 }), 5698 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PriceAndQualityCards, { 5699 price: delivery.price, 5700 quality: delivery.quality, 5701 elapsedShare: pacing.elapsedShare, 5702 tolerance: paceTolerance(), 5703 delivered: pacing.delivered 5704 }), 5705 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryByExchange, { 5706 exchanges: delivery.exchanges, 5707 totalImpressions: pacing.delivered, 5708 totalSpend: delivery.price?.spend ?? 0 5709 }) 5710 ] 5711 }) 5712 ] 5713 }); 5714}; 5715var stackSx$5 = { 5716 display: "flex", 5717 flexDirection: "column", 5718 gap: 3, 5719 minWidth: 0, 5720 flex: 1, 5721 minHeight: 0, 5722 overflowY: "auto", 5723 paddingBottom: 2, 5724 ...import_dist$2.scrollOnHoverSxProps 5725}; 5726var contentSx$10 = (isRefreshing) => ({ 5727 display: "flex", 5728 flexDirection: "column", 5729 gap: 3, 5730 minWidth: 0, 5731 opacity: isRefreshing ? .6 : 1, 5732 transition: "opacity 150ms ease-in-out" 5733}); 5734var stickyScopeSx = { 5735 position: "sticky", 5736 top: 0, 5737 zIndex: 2, 5738 flexShrink: 0, 5739 backgroundColor: "background.paper", 5740 borderBottom: 1, 5741 borderColor: "divider", 5742 paddingY: 1.5 5743}; 5744var headerSx$5 = { 5745 display: "flex", 5746 justifyContent: "space-between", 5747 alignItems: "flex-start", 5748 gap: 2, 5749 flexWrap: "wrap" 5750}; 5751var chipsSx$1 = { 5752 display: "flex", 5753 gap: 1, 5754 flexWrap: "wrap", 5755 alignItems: "center" 5756}; 5757var headerRightSx = { 5758 display: "flex", 5759 alignItems: "flex-start", 5760 gap: 1 5761}; 5762var asOfSx = { 5763 display: "flex", 5764 flexDirection: "column", 5765 alignItems: "flex-end", 5766 textAlign: "right" 5767}; 5768var kpiStripSx$1 = { 5769 display: "grid", 5770 gridTemplateColumns: { 5771 xs: "repeat(2, 1fr)", 5772 sm: "repeat(3, 1fr)", 5773 md: "repeat(5, 1fr)" 5774 }, 5775 gap: 2 5776}; 5777var twoUpSx$1 = { 5778 display: "grid", 5779 gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))", 5780 gap: 2, 5781 minWidth: 0 5782}; 5783//#endregion 5784//#region client/components/newNav/components/ads/common/adsReferenceData.ts 5785/** 5786* The closed value sets of the ads configuration model. The old app rendered most of these as free text, which 5787* is how `platform_type` and `platformType` both ended up in the data - a key the server does not understand is 5788* ignored without a word. 5789*/ 5790/** Exchange prefix to name. Also the value set behind exchange caps and per-exchange score bumps. */ 5791var exchangeOptions = [ 5792 { 5793 value: "2", 5794 label: "Oath" 5795 }, 5796 { 5797 value: "4", 5798 label: "Infolinks" 5799 }, 5800 { 5801 value: "6", 5802 label: "RobustApps" 5803 }, 5804 { 5805 value: "7", 5806 label: "Teads" 5807 }, 5808 { 5809 value: "8", 5810 label: "Connatix" 5811 }, 5812 { 5813 value: "9", 5814 label: "Google" 5815 }, 5816 { 5817 value: "10", 5818 label: "AppNexus" 5819 }, 5820 { 5821 value: "11", 5822 label: "Equativ" 5823 }, 5824 { 5825 value: "12", 5826 label: "Pubmatic" 5827 }, 5828 { 5829 value: "13", 5830 label: "Vidazoo" 5831 }, 5832 { 5833 value: "14", 5834 label: "Tremor" 5835 }, 5836 { 5837 value: "15", 5838 label: "ConvertMedia" 5839 }, 5840 { 5841 value: "16", 5842 label: "BeachFront" 5843 }, 5844 { 5845 value: "17", 5846 label: "OpenX" 5847 }, 5848 { 5849 value: "18", 5850 label: "EXCO" 5851 }, 5852 { 5853 value: "19", 5854 label: "Rubicon" 5855 }, 5856 { 5857 value: "50", 5858 label: "TheTradeDesk" 5859 }, 5860 { 5861 value: "51", 5862 label: "Adobe" 5863 }, 5864 { 5865 value: "52", 5866 label: "DV360" 5867 }, 5868 { 5869 value: "100", 5870 label: "LiveRail" 5871 } 5872]; 5873var siteActionMethodOptions = [ 5874 { 5875 value: "uui", 5876 label: "uui" 5877 }, 5878 { 5879 value: "cart", 5880 label: "cart" 5881 }, 5882 { 5883 value: "atc", 5884 label: "atc" 5885 }, 5886 { 5887 value: "conv", 5888 label: "conv" 5889 } 5890]; 5891var productCatalogDbOptions = [{ 5892 value: "0", 5893 label: "default_db" 5894}, { 5895 value: "4", 5896 label: "redis2" 5897}]; 5898/** Sync partners an account can be excluded from. */ 5899var syncPartnerOptions = [ 5900 { 5901 value: "adobe", 5902 label: "adobe" 5903 }, 5904 { 5905 value: "beachfront", 5906 label: "beachfront" 5907 }, 5908 { 5909 value: "bluekai", 5910 label: "bluekai" 5911 }, 5912 { 5913 value: "crosswise", 5914 label: "crosswise" 5915 }, 5916 { 5917 value: "liveramp", 5918 label: "liveramp" 5919 }, 5920 { 5921 value: "teads", 5922 label: "teads" 5923 }, 5924 { 5925 value: "thetradedesk", 5926 label: "thetradedesk" 5927 } 5928]; 5929var reportDateFormatOptions = [{ 5930 value: "%Y-%m-%d", 5931 label: "yyyy-mm-dd (Default)" 5932}, { 5933 value: "%d-%b-%y", 5934 label: "dd-MM-yy" 5935}]; 5936var viewabilityOverrideOptions = [ 5937 { 5938 value: "NONE", 5939 label: "Disabled" 5940 }, 5941 { 5942 value: "BY_SITE", 5943 label: "By site" 5944 }, 5945 { 5946 value: "AUTO_CORRECT", 5947 label: "Auto correct" 5948 } 5949]; 5950var videoFormatOptions = [ 5951 { 5952 value: "mp4", 5953 label: "mp4" 5954 }, 5955 { 5956 value: "webm", 5957 label: "webm" 5958 }, 5959 { 5960 value: "hls", 5961 label: "hls" 5962 } 5963]; 5964/** The hash space an experiment splits between its groups. */ 5965var EXPERIMENT_RANGE_MAX = 1e4; 5966var deviceModeOptions = [{ 5967 value: "DESKTOP_MOBILE", 5968 label: "Desktop & Mobile" 5969}, { 5970 value: "TV", 5971 label: "CTV" 5972}];
5973/** The same three values behind each of the four per-platform behaviour fields. */ 5974var deviceBehaviorOptions = [ 5975 { 5976 value: "0", 5977 label: "Allow cross-device" 5978 }, 5979 { 5980 value: "1", 5981 label: "Disallow cross-device" 5982 }, 5983 { 5984 value: "2", 5985 label: "Disable Campaign" 5986 } 5987]; 5988/** Stored as the raw player-size range strings the ad server matches against. */ 5989var desktopPlayerSizeOptions = [ 5990 { 5991 value: "0x0-299x4096;0x0-4096x249", 5992 label: "Tiny (Width < 300 or Height < 250)" 5993 }, 5994 { 5995 value: "300x250-300x250", 5996 label: "Small (300x250 Exactly)" 5997 }, 5998 { 5999 value: "301x250-599x4096;300x251-300x4096", 6000 label: "Medium (300 < Width < 600)" 6001 }, 6002 { 6003 value: "600x250-4096x4096", 6004 label: "Large (Width >= 600)" 6005 } 6006]; 6007var mobilePlayerSizeOptions = [ 6008 { 6009 value: "0x0-4096x47", 6010 label: "Tiny (Height < 48)" 6011 }, 6012 { 6013 value: "0x48-4096x199;120x600-120x600", 6014 label: "Small (48 <= Height < 200, or 120x600)" 6015 }, 6016 { 6017 value: "0x200-4096x480", 6018 label: "Medium (200 <= Height <= 480)" 6019 }, 6020 { 6021 value: "0x481-4096x599;0x600-119x600;121x600-4096x600;0x601-4096x4096", 6022 label: "Large (Height > 480)" 6023 } 6024]; 6025var controlGroupModeOptions = [ 6026 { 6027 value: "DISABLED", 6028 label: "Disabled (all bids will display a SmartVideo)" 6029 }, 6030 { 6031 value: "ENABLED", 6032 label: "Enabled (users who fall inside the group will receive a special video)" 6033 }, 6034 { 6035 value: "GHOST", 6036 label: "Ghost (users who fall inside the group will not receive a bid)" 6037 } 6038]; 6039var videoQualityProfileOptions = [ 6040 { 6041 value: "SD", 6042 label: "SD" 6043 }, 6044 { 6045 value: "SD_INTERMEDIATE", 6046 label: "SD_INTERMEDIATE" 6047 }, 6048 { 6049 value: "SD_HIGH", 6050 label: "SD_HIGH" 6051 }, 6052 { 6053 value: "HD_READY", 6054 label: "HD_READY" 6055 }, 6056 { 6057 value: "HD_READY_HIGH", 6058 label: "HD_READY_HIGH" 6059 }, 6060 { 6061 value: "HD", 6062 label: "HD" 6063 } 6064]; 6065var optimizationControlModeOptions = [ 6066 { 6067 value: "FIXED_VALUE", 6068 label: "Fixed value" 6069 }, 6070 { 6071 value: "RANDOM_VALUE", 6072 label: "Random from model values" 6073 }, 6074 { 6075 value: "BASED_ON_EXPERIMENT_GROUP", 6076 label: "By experiment group" 6077 } 6078]; 6079var reportingSourceOptions = [ 6080 { 6081 value: "USER_EVENT", 6082 label: "Site Event" 6083 }, 6084 { 6085 value: "OPPORTUNITY", 6086 label: "Opportunity" 6087 }, 6088 { 6089 value: "RECOMMENDATION_ENGINE", 6090 label: "Recommendation Engine" 6091 }, 6092 { 6093 value: "USER_DATA", 6094 label: "User Data" 6095 }, 6096 { 6097 value: "LOCATION", 6098 label: "Location" 6099 } 6100]; 6101/** 6102* Which Field values a reporting definition accepts depends on its Source. Free text is how `platform_type` and 6103* `platformType` both ended up in the data. 6104*/ 6105var reportingFieldOptionsBySource = { 6106 RECOMMENDATION_ENGINE: [ 6107 "rule", 6108 "story", 6109 "video_sku", 6110 "sku", 6111 "product_catalog_category", 6112 "user_event_category", 6113 "user_event_method", 6114 "qualification_groups" 6115 ].map((value) => ({ 6116 value, 6117 label: value 6118 })), 6119 USER_DATA: [ 6120 { 6121 value: "experiments", 6122 label: "experiments (experiment groups, comma delimited)" 6123 }, 6124 { 6125 value: "dmp_segments", 6126 label: "dmp_segments (user's DMP segments, comma delimited)" 6127 }, 6128 { 6129 value: "audience", 6130 label: "audience (first DMP List audience value from the Audience Mapping Logic csv)" 6131 }, 6132 { 6133 value: "audiences", 6134 label: "audiences (matching Program audience names, comma delimited)" 6135 } 6136 ], 6137 LOCATION: [ 6138 { 6139 value: "zip_code", 6140 label: "zip_code" 6141 }, 6142 { 6143 value: "city", 6144 label: "city" 6145 }, 6146 { 6147 value: "state", 6148 label: "state" 6149 } 6150 ] 6151}; 6152/** `a1`..`a9` and `x1`..`x9`, the aux slots a reporting definition can land in. */ 6153var reportingTargetFieldOptions = [...Array.from({ length: 9 }, (_unused, index) => `a${index + 1}`), ...Array.from({ length: 9 }, (_unused, index) => `x${index + 1}`)].map((value) => ({ 6154 value, 6155 label: value 6156})); 6157var goalAdjustmentTypeOptions = [ 6158 { 6159 value: "0", 6160 label: "score_threshold" 6161 }, 6162 { 6163 value: "1", 6164 label: "spend_saving" 6165 }, 6166 { 6167 value: "2", 6168 label: "viewability_adj" 6169 }, 6170 { 6171 value: "3", 6172 label: "mobile_adj" 6173 }, 6174 { 6175 value: "4", 6176 label: "desktop_adj" 6177 } 6178]; 6179/** 6180* The campaign advanced-property keys touched in the last 12 months, plus the rest that carry live rows. Adding 6181* a key the server does not read is silently ignored, which is why this is a picker rather than free text. 6182*/
6183var campaignPropertyKeyOptions = [ 6184 "rule.override.class.name", 6185 "enforced.experiment", 6186 "excluded.experiments", 6187 "random.score.context.factor", 6188 "random.score.use.user", 6189 "support.standalone.vast.wrapper", 6190 "vpaid.required", 6191 "isSupportSkippable", 6192 "googleClickThroughUrl", 6193 "googleBuyerCreativeId", 6194 "vpaid.wrapper.provider", 6195 "vpaid.provider", 6196 "support.max.rules", 6197 "log.bids", 6198 "videoQualityProfile", 6199 "catalog.id", 6200 "allow.only.tv", 6201 "tv.type", 6202 "optimization.endpoint", 6203 "optimization.systems", 6204 "disable.user.throttling", 6205 "enable.third.party.id", 6206 "blocked.placements", 6207 "first.price.auction.shadow", 6208 "ghost.bid.factor", 6209 "throttle.ghost.bids", 6210 "static.url" 6211].map((value) => ({ 6212 value, 6213 label: value 6214})); 6215//#endregion 6216//#region client/components/newNav/components/ads/decoupled/decoupledFieldGroups.ts 6217/** 6218* The decoupled campaign's own sections. A partner owns the buy, so everything about money and audience is 6219* absent by design - no flights, no bid price, no frequency caps, no device behaviour - and what is left is 6220* about which creative we return when they call us. 6221*/ 6222var buildDecoupledIdentityGroups = () => [{ 6223 id: "identity", 6224 title: "Identity and status", 6225 description: "The name is also the identifier in the DSP's live tag, so renaming a campaign changes how it is called.", 6226 fields: [ 6227 { 6228 key: "name", 6229 label: "Name", 6230 type: "text" 6231 }, 6232 { 6233 key: "enabled", 6234 label: "Enabled", 6235 type: "bool" 6236 }, 6237 { 6238 key: "clickThroughUrl", 6239 label: "Click-through URL", 6240 type: "url", 6241 wide: true, 6242 placeholder: "https://www.example.com/offer" 6243 }, 6244 { 6245 key: "restrictedContentAlcohol", 6246 label: "Restricted content: alcohol", 6247 type: "bool", 6248 help: "Enable this for VAST to include declaration of alcohol in creative" 6249 } 6250 ] 6251}]; 6252var buildDecoupledFallbackGroups = () => [{ 6253 id: "fallback", 6254 title: "Fallback video", 6255 description: "What the ad server serves when it cannot build a personalized video for the request. These are server settings keyed by the campaign name, not campaign fields.", 6256 fields: [ 6257 { 6258 key: "fallbackVideoQuality", 6259 label: "Video quality", 6260 type: "select", 6261 options: [{ 6262 value: "", 6263 label: "Not set" 6264 }, ...videoQualityProfileOptions] 6265 }, 6266 { 6267 key: "fallbackAspectRatio", 6268 label: "Aspect ratio", 6269 type: "text", 6270 placeholder: "16:9" 6271 }, 6272 { 6273 key: "fallbackDuration", 6274 label: "Duration", 6275 type: "text", 6276 placeholder: "30" 6277 } 6278 ] 6279}]; 6280var buildDecoupledDeliveryGroups = () => [{ 6281 id: "catalog", 6282 title: "Product catalog", 6283 fields: [{ 6284 key: "productCatalogName", 6285 label: "Product catalog name", 6286 type: "text" 6287 }, { 6288 key: "productCatalogSkuTemplate", 6289 label: "SKU template", 6290 type: "text", 6291 wide: true 6292 }] 6293}, { 6294 id: "serving", 6295 title: "Serving", 6296 fields: [ 6297 { 6298 key: "pixelRulesLocation", 6299 label: "Pixel rules CSV", 6300 type: "url", 6301 wide: true, 6302 help: "The S3 location for the Pixel Rules CSV" 6303 }, 6304 { 6305 key: "videoKbps", 6306 label: "Video bitrate", 6307 type: "number", 6308 min: 0, 6309 help: "Video bit rate in KB" 6310 }, 6311 { 6312 key: "useSskyVpaid", 6313 label: "Use SundaySky VPAID", 6314 type: "bool", 6315 help: "Whether to use VPAID response with additional player data" 6316 } 6317 ] 6318}]; 6319var buildDecoupledControlGroupGroups = () => [{ 6320 id: "control-group", 6321 title: "Control group", 6322 advanced: true, 6323 description: "Disabled on every decoupled campaign in production. Kept because turning it on is a real, if rare, choice.", 6324 fields: [{ 6325 key: "controlGroupMode", 6326 label: "Control group mode", 6327 type: "select", 6328 options: controlGroupModeOptions, 6329 help: "Control group mode for this program" 6330 }, { 6331 key: "controlGroupPercent", 6332 label: "Control group percent", 6333 type: "number", 6334 min: 0, 6335 max: 100, 6336 help: "Percent of users who belong to the control group (irrelevant if 'Disabled')" 6337 }] 6338}]; 6339/** The endpoints a DSP calls. Which one a campaign is sold through is not stored, so all three are listed. */ 6340var DECOUPLED_SERVING_ENDPOINTS = [ 6341 { 6342 dsp: "The Trade Desk", 6343 endpoint: "/dec_ttd" 6344 }, 6345 { 6346 dsp: "Adobe", 6347 endpoint: "/dec_adb" 6348 }, 6349 { 6350 dsp: "DV360", 6351 endpoint: "/dec_dv360" 6352 } 6353]; 6354//#endregion
6355//#region client/components/newNav/components/ads/decoupled/DecoupledDeliveryPanel.tsx 6356/** 6357* How a DSP actually calls this campaign. Derived, never stored - and incomplete: the ad server's host is not 6358* configured anywhere in the hub, and which of the three DSPs a campaign is sold through is not in the schema, 6359* so all three endpoints are listed and the host is left as the placeholder it is. 6360*/ 6361var DecoupledDeliveryPanel = ({ campaignName }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6362 sx: sectionSx$10, 6363 children: [ 6364 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6365 variant: "h5", 6366 children: "DSP delivery" 6367 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6368 variant: "body2", 6369 color: "text.secondary", 6370 children: "The tag the partner's DSP calls for a creative. It carries this campaign's name, which is why renaming the campaign changes how it is called." 6371 })] }), 6372 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Paper, { 6373 variant: "outlined", 6374 sx: paperSx$4, 6375 children: DECOUPLED_SERVING_ENDPOINTS.map(({ dsp, endpoint }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6376 sx: rowSx, 6377 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6378 variant: "subtitle2", 6379 children: dsp 6380 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6381 variant: "body1", 6382 sx: urlSx, 6383 children: `https://<ad server host>${endpoint}?program=${encodeURIComponent(campaignName)}` 6384 })] 6385 }, endpoint)) 6386 }), 6387 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 6388 color: "info", 6389 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6390 variant: "body1", 6391 color: "text.secondary", 6392 children: "The ad server's host is not recorded in the hub, so these are paths rather than URLs you can copy. Nothing stores which DSP a campaign is sold through either, so all three are shown." 6393 }) }) 6394 }) 6395 ] 6396}); 6397var sectionSx$10 = { 6398 display: "flex", 6399 flexDirection: "column", 6400 gap: 2 6401}; 6402var paperSx$4 = { 6403 display: "flex", 6404 flexDirection: "column", 6405 gap: 1.5, 6406 padding: 2 6407}; 6408var rowSx = { 6409 display: "flex", 6410 flexDirection: "column" 6411}; 6412var urlSx = { 6413 wordBreak: "break-all", 6414 fontFamily: "monospace" 6415}; 6416//#endregion 6417//#region client/components/newNav/components/ads/dashboards/DecoupledDashboard.tsx 6418var CHART_HEIGHT = 220; 6419/** 6420* A decoupled campaign skips the auction: the partner's DSP has already bought the impression and calls us for 6421* the video. There is nothing to pace and no goal to miss, so the only way it can fail is by stopping - which is 6422* what this page is for. No goal, pace, projection, expected line or score threshold appears here at all. 6423*/ 6424var DecoupledDashboard = ({ campaignId }) => { 6425 const theme = (0, import_material.useTheme)(); 6426 const { delivery, loading, error } = useAdsSpaceCampaignDelivery(campaignId, null); 6427 const todayOption = (0, import_react.useMemo)(() => delivery ? todayChartOption(delivery.todaySeries, theme, false) : null, [delivery, theme]); 6428 const trendOption = (0, import_react.useMemo)(() => delivery ? trendChartOption(delivery.last30Days, delivery.last30Days, theme) : null, [delivery, theme]); 6429 if (loading) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6430 sx: stackSx$4, 6431 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: 96 }
6431), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliverySkeleton, { height: CHART_HEIGHT })] 6432 }); 6433 if (error || !delivery) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { 6434 title: "No delivery to show", 6435 message: "This campaign's delivery could not be read." 6436 }); 6437 const today = delivery.todaySeries.reduce((sum, point) => sum + (point.delivered ?? 0), 0); 6438 const yesterday = delivery.last30Days[delivery.last30Days.length - 2]?.delivered ?? 0; 6439 const completed = delivery.last30Days.length > 0 ? delivery.last30Days.reduce((sum, point) => sum + (point.delivered ?? 0), 0) : 0; 6440 const completionRate = delivery.quality.find((metric) => metric.key === "completionRate")?.actual ?? null; 6441 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6442 sx: stackSx$4, 6443 children: [ 6444 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6445 sx: headerSx$4, 6446 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6447 sx: chipsSx, 6448 children: [ 6449 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 6450 size: "small", 6451 label: delivery.enabled ? "enabled" : "disabled", 6452 color: delivery.enabled ? "success" : "default", 6453 variant: "standard" 6454 }), 6455 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 6456 size: "small", 6457 label: "decoupled", 6458 color: "info", 6459 variant: "standard" 6460 }), 6461 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 6462 size: "small", 6463 label: "no flights, no goals", 6464 color: "default", 6465 variant: "standard" 6466 }), 6467 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 6468 size: "small", 6469 label: STATUS_LABELS[delivery.status], 6470 color: STATUS_COLORS[delivery.status], 6471 variant: "standard" 6472 }) 6473 ] 6474 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryFreshness, { 6475 lastUpdateTime: delivery.freshness.lastUpdateTime, 6476 isKnown: delivery.freshness.isKnown 6477 })] 6478 }), 6479 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 6480 dataTestId: "ads-decoupled-delivering", 6481 title: "Is it still delivering?", 6482 infoTooltip: "A decoupled campaign has no goal and nothing to pace, so stopping is the only way it can fail.", 6483 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6484 sx: kpiStripSx, 6485 children: [ 6486 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 6487 dataTestId: "ads-decoupled-kpi-last-impression", 6488 label: "Last impression", 6489 value: delivery.lastImpressionAt ? formatRelativeAge(delivery.lastImpressionAt) : "never", 6490 subLine: delivery.lastImpressionAt ? formatUtcTime(delivery.lastImpressionAt) : "no impressions in the last 30 days", 6491 status: { 6492 text: STATUS_LABELS[delivery.status], 6493 color: delivery.status === GqlClientAdsSpaceDeliveryStatus.DELIVERING ? "success" : "error" 6494 } 6495 }), 6496 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 6497 dataTestId: "ads-decoupled-kpi-this-hour", 6498 label: "This hour", 6499 value: formatCount(delivery.impressionsThisHour) 6500 }), 6501 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 6502 dataTestId: "ads-decoupled-kpi-today", 6503 label: "Today", 6504 value: formatCount(today), 6505 subLine: `${formatCount(yesterday)} yesterday` 6506 }), 6507 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsKpiTile, { 6508 dataTestId: "ads-decoupled-kpi-completion", 6509 label: "Completion rate", 6510 value: completionRate === null ? "-" : formatRate(completionRate), 6511 subLine: `${formatCount(completed)} impressions in 30 days` 6512 }) 6513 ] 6514 }) 6515 }), 6516 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6517 sx: twoUpSx, 6518 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 6519 dataTestId: "ads-decoupled-today", 6520 title: "Today by hour", 6521 SubTitle: formatCount(today), 6522 tooltipText: "No expected line - a decoupled campaign has no plan to compare against.", 6523 option: todayOption, 6524 containerStyle: { height: CHART_HEIGHT } 6525 }
6525), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AnalyticsChart, { 6526 dataTestId: "ads-decoupled-trend", 6527 title: "Last 30 days", 6528 SubTitle: formatCount(completed), 6529 option: trendOption, 6530 containerStyle: { height: CHART_HEIGHT } 6531 })] 6532 }), 6533 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDashboardCard, { 6534 dataTestId: "ads-decoupled-dsp", 6535 title: "Delivery by DSP", 6536 infoTooltip: "A DSP disappearing from this table is a stall one level down.", 6537 children: delivery.exchanges.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeliveryEmptyState, { message: "No impressions have been attributed to a DSP in the last 30 days." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 6538 size: "small", 6539 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 6540 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { children: "DSP" }), 6541 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 6542 align: "right", 6543 children: "Impressions" 6544 }), 6545 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 6546 align: "right", 6547 children: "Share" 6548 }) 6549 ] }) }
6549), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: delivery.exchanges.map((exchange) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 6550 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { children: exchange.name }), 6551 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 6552 align: "right", 6553 children: formatCount(exchange.impressions) 6554 }), 6555 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 6556 align: "right", 6557 children: exchange.share === null ? "-" : formatRate(exchange.share) 6558 }) 6559 ] }, exchange.prefix)) })] 6560 }) 6561 }), 6562 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecoupledDeliveryPanel, { campaignName: delivery.campaignName }), 6563 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 6564 color: "warning", 6565 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6566 variant: "body1", 6567 color: "text.secondary", 6568 children: "This campaign's name is a public identifier: it is the string the partner's DSP carries in its tag URL, so renaming it breaks live delivery." 6569 }) }) 6570 }) 6571 ] 6572 }); 6573}; 6574var stackSx$4 = { 6575 display: "flex", 6576 flexDirection: "column", 6577 gap: 3, 6578 minWidth: 0, 6579 flex: 1, 6580 minHeight: 0, 6581 overflowY: "auto", 6582 ...import_dist$2.scrollOnHoverSxProps 6583}; 6584var headerSx$4 = { 6585 display: "flex", 6586 justifyContent: "space-between", 6587 alignItems: "flex-start", 6588 gap: 2, 6589 flexWrap: "wrap" 6590}; 6591var chipsSx = { 6592 display: "flex", 6593 gap: 1, 6594 flexWrap: "wrap", 6595 alignItems: "center" 6596}; 6597var kpiStripSx = { 6598 display: "grid", 6599 gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", 6600 gap: 2 6601}; 6602var twoUpSx = { 6603 display: "grid", 6604 gridTemplateColumns: { 6605 xs: "1fr", 6606 lg: "1fr 1fr" 6607 }, 6608 gap: 3, 6609 minWidth: 0 6610}; 6611//#endregion 6612//#region client/components/newNav/components/ads/common/AdsSettingsForm.tsx 6613/** 6614* The shared shape of every ads configuration form: field groups, an optional collapsed advanced group, and the 6615* space's one save contract - edits stay local, the pinned bar counts them, Save asks for the change comment, 6616* and Cancel or leaving the page confirms first. Version and revision fields are deliberately absent from every 6617* group; saving bumps them, which is what makes the ad servers reload the configuration. 6618*/ 6619var AdsSettingsForm = (props) => { 6620 const { form } = props; 6621 const [isCancelConfirmOpen, setIsCancelConfirmOpen] = (0, import_react.useState)(false); 6622 const [isCommentOpen, setIsCommentOpen] = (0, import_react.useState)(false); 6623 const guard = useAdsUnsavedChangesGuard(form.dirty && !form.saving && !props.readOnly, props.navigationScope); 6624 const mainGroups = props.groups.filter((group) => !group.advanced); 6625 const advancedGroups = props.groups.filter((group) => group.advanced); 6626 const invalidLabels = props.invalidLabels ?? props.groups.flatMap((group) => group.fields).filter((field) => form.errors[field.key]).map((field) => field.label); 6627 const continueEditing = () => { 6628 setIsCancelConfirmOpen(false); 6629 guard.cancelNavigation(); 6630 }; 6631 const discard = () => { 6632 form.onDiscard(); 6633 setIsCancelConfirmOpen(false); 6634 guard.confirmNavigation(); 6635 }; 6636 const confirmSave = async (comment) => { 6637 try { 6638 await form.onSave(comment); 6639 } catch {} finally { 6640 setIsCommentOpen(false); 6641 } 6642 }; 6643 const renderFieldGrid = (fields, columns) => fields.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 6644 sx: columns === 1 ? singleColumnSx : fieldsGridSx, 6645 children: fields.map((field) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsFieldControl, { 6646 field, 6647 value: form.values[field.key] ?? null, 6648 values: form.values, 6649 error: form.errors[field.key], 6650 readOnly: props.readOnly, 6651 onChange: form.onChange, 6652 trackingIdPrefix: props.trackingIdPrefix 6653 }, field.key)) 6654 }); 6655 /** A group with sections shows its fields under them; one without shows them as a single grid. */ 6656 const renderFields = (group) => { 6657 if (!group.sections) return renderFieldGrid(group.fields, group.columns); 6658 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6659 sx: sectionsSx, 6660 children: [renderFieldGrid(group.fields.filter((field) => !field.section), group.columns), group.sections.map((section) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6661 sx: sectionSx$9, 6662 children: [
6663 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6664 variant: "h6", 6665 children: section.title 6666 }), section.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6667 variant: "body2", 6668 color: "text.secondary", 6669 children: section.description 6670 })] }), 6671 renderFieldGrid(group.fields.filter((field) => field.section === section.title), section.columns ?? group.columns), 6672 section.content 6673 ] 6674 }, section.title))] 6675 }); 6676 }; 6677 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6678 sx: formSx, 6679 children: [ 6680 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6681 sx: bodySx$3, 6682 children: [ 6683 props.headerChildren, 6684 mainGroups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6685 sx: groupSx$2, 6686 id: `ads-group-${group.id}`, 6687 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6688 sx: groupTitleSx, 6689 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6690 variant: "h5", 6691 children: group.title 6692 }), group.titleAdornment] 6693 }
6693), group.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6694 variant: "body2", 6695 color: "text.secondary", 6696 children: group.description 6697 })] }), renderFields(group)] 6698 }, group.id)), 6699 advancedGroups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleAccordion, { 6700 sx: accordionSx, 6701 id: `ads-group-${group.id}`, 6702 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.AccordionSummary, { 6703 expandIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleDown.faAngleDown }), 6704 "data-tracking-id": `${props.trackingIdPrefix}-${group.id}-expand-btn`, 6705 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 6706 sx: groupTitleSx, 6707 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6708 variant: "h5", 6709 children: group.title 6710 }), group.titleAdornment] 6711 }) 6712 }
6712), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.AccordionDetails, { 6713 sx: accordionDetailsSx, 6714 children: [group.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 6715 variant: "body2", 6716 color: "text.secondary", 6717 children: group.description 6718 }), renderFields(group)] 6719 })] 6720 }, group.id)), 6721 props.children 6722 ] 6723 }), 6724 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveBar, { 6725 changeCount: form.changeCount, 6726 saveDisabledReason: describeInvalidFields(invalidLabels) ?? props.saveBlockedReason, 6727 saving: form.saving, 6728 onCancel: () => setIsCancelConfirmOpen(true), 6729 onSave: () => setIsCommentOpen(true), 6730 trackingIdPrefix: props.trackingIdPrefix 6731 }), 6732 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiscardChangesDialog, { 6733 open: isCancelConfirmOpen || guard.pendingNavigation, 6734 onContinueEditing: continueEditing, 6735 onDiscard: discard 6736 }), 6737 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 6738 open: isCommentOpen, 6739 summary: describeAdsChangeCount(form.changeCount), 6740 warning: props.saveWarning, 6741 saving: form.saving, 6742 onCancel: () => setIsCommentOpen(false), 6743 onConfirm: (comment) => void confirmSave(comment), 6744 trackingIdPrefix: props.trackingIdPrefix 6745 }) 6746 ] 6747 }); 6748}; 6749var describeInvalidFields = (labels) => { 6750 if (labels.length === 0) return; 6751 return labels.length <= 2 ? `Fix ${labels.join(" and ")} before saving` : `Fix ${labels.length} fields before saving`; 6752}; 6753var formSx = { 6754 display: "flex", 6755 flexDirection: "column", 6756 minHeight: 0, 6757 flex: 1 6758}; 6759var bodySx$3 = { 6760 display: "flex", 6761 flexDirection: "column", 6762 gap: 4, 6763 flex: 1, 6764 minHeight: 0, 6765 overflow: "auto", 6766 ...import_dist$2.scrollOnHoverSxProps 6767}; 6768var groupTitleSx = { 6769 display: "flex", 6770 alignItems: "center", 6771 gap: 1 6772}; 6773var groupSx$2 = {
6774 display: "flex", 6775 flexDirection: "column", 6776 gap: 2 6777}; 6778var sectionsSx = { 6779 display: "flex", 6780 flexDirection: "column", 6781 gap: 3 6782}; 6783var sectionSx$9 = { 6784 display: "flex", 6785 flexDirection: "column", 6786 gap: 1.5 6787}; 6788var fieldsGridSx = { 6789 display: "grid", 6790 gridTemplateColumns: "repeat(auto-fit, minmax(320px, max-content))", 6791 gap: 3, 6792 alignItems: "start", 6793 maxWidth: 720 6794}; 6795var singleColumnSx = { 6796 display: "grid", 6797 gridTemplateColumns: "1fr", 6798 gap: 3, 6799 alignItems: "start", 6800 maxWidth: 360 6801}; 6802var accordionSx = { 6803 border: 1, 6804 borderColor: "divider", 6805 borderRadius: "8px", 6806 "&:before": { display: "none" } 6807}; 6808var accordionDetailsSx = { 6809 display: "flex", 6810 flexDirection: "column", 6811 gap: 2 6812}; 6813//#endregion 6814//#region client/components/newNav/components/ads/targeting/targetingModel.ts 6815/** 6816* Audiences and geographies as account-level entities. 6817* 6818* Both used to be per-campaign columns: `program.locationFilter` for the geography, and an `audience` row owned by 6819* one program for the segments. The same "US minus Florida" rule and the same retargeting audience were therefore 6820* retyped campaign after campaign, with nothing linking the copies. Here they are defined once and campaigns and 6821* budget lanes point at them by id. 6822*/ 6823/** How a campaign applies one of its geo filters. Blacklist rows subtract; whitelist rows union. */ 6824var GeoFilterMode = /* @__PURE__ */ function(GeoFilterMode) { 6825 GeoFilterMode["WHITELIST"] = "whitelist"; 6826 GeoFilterMode["BLACKLIST"] = "blacklist"; 6827 return GeoFilterMode; 6828}({}); 6829var geoFilterModeOptions = [{ 6830 value: "whitelist", 6831 label: "Serve only here" 6832}, { 6833 value: "blacklist", 6834 label: "Never serve here" 6835}]; 6836/** Same enums the ads configuration app uses for the audience rule tree. */ 6837var ruleMatchTypeOptions = [{ 6838 value: "MATCH_ALL_RULES", 6839 label: "Match all rules" 6840}, { 6841 value: "MATCH_ANY_RULE", 6842 label: "Match any rule" 6843}]; 6844var audienceRuleOperatorOptions = [ 6845 { 6846 value: "EQUALS", 6847 label: "Equals" 6848 }, 6849 { 6850 value: "DOES_NOT_EQUAL", 6851 label: "Does not equal" 6852 }, 6853 { 6854 value: "EXISTS", 6855 label: "Exists" 6856 }, 6857 { 6858 value: "DOES_NOT_EXIST", 6859 label: "Does not exist" 6860 }, 6861 { 6862 value: "STRING_CONTAINS", 6863 label: "Contains" 6864 }, 6865 { 6866 value: "STRING_DOES_NOT_CONTAIN", 6867 label: "Does not contain" 6868 }, 6869 { 6870 value: "ONE_OF", 6871 label: "One of" 6872 }, 6873 { 6874 value: "NOT_ONE_OF", 6875 label: "Not one of" 6876 } 6877]; 6878/** Turns a row of an account collection into a picker option, so every surface offers the same names. */ 6879var toEntityOptions = (rows) => rows.map((row) => ({ 6880 value: row.id, 6881 label: asText(row.name) 6882})); 6883var findEntityLabel = (options, id) => options.find((option) => option.value === id)?.label ?? "No longer exists"; 6884/** 6885* What the ad server shows a viewer who matched an audience. Values are the strings `product_rule.type` holds, 6886* labels are the ones the ads configuration app uses - note DUMMY_SKU is what it calls "No products". 6887*/ 6888var ProductSelectorType = /* @__PURE__ */ function(ProductSelectorType) { 6889 ProductSelectorType["LOCATION_TOP_SELLER"] = "LOCATION_TOP_SELLER"; 6890 ProductSelectorType["SITE_TOP_SELLER"] = "SITE_TOP_SELLER"; 6891 ProductSelectorType["CATEGORY_TOP_SELLER"] = "CATEGORY_TOP_SELLER"; 6892 ProductSelectorType["BEST_MATCH"] = "BEST_MATCH"; 6893 ProductSelectorType["SPECIFIC_CATEGORY"] = "SPECIFIC_CATEGORY"; 6894 ProductSelectorType["SPECIFIC_PRODUCT"] = "SPECIFIC_PRODUCT"; 6895 ProductSelectorType["NO_PRODUCTS"] = "DUMMY_SKU"; 6896 return ProductSelectorType; 6897}({}); 6898var productSelectorTypeOptions = [ 6899 { 6900 value: "LOCATION_TOP_SELLER", 6901 label: "Top seller by location" 6902 }, 6903 { 6904 value: "SITE_TOP_SELLER", 6905 label: "Site top seller" 6906 }, 6907 { 6908 value: "CATEGORY_TOP_SELLER", 6909 label: "Category top seller" 6910 }, 6911 { 6912 value: "BEST_MATCH", 6913 label: "Best match" 6914 }, 6915 { 6916 value: "SPECIFIC_CATEGORY", 6917 label: "Specific category" 6918 }, 6919 { 6920 value: "SPECIFIC_PRODUCT", 6921 label: "Specific product" 6922 }, 6923 { 6924 value: "DUMMY_SKU", 6925 label: "No products" 6926 } 6927]; 6928/** The three the ads configuration app requires a value for. The rest may carry one and do not need one. */ 6929var PRODUCT_SELECTOR_TYPES_NEEDING_VALUE = [ 6930 "SPECIFIC_CATEGORY", 6931 "SPECIFIC_PRODUCT", 6932 "DUMMY_SKU" 6933]; 6934var productSelectorTypeLabel = (type) => productSelectorTypeOptions.find((option) => option.value === type)?.label ?? type; 6935var targetingRuntime = (input) => { 6936 const hasLegacyGeo = Boolean(input.locationFilter.trim()); 6937 return { 6938 geo: input.accountGeoCount > 0 ? hasLegacyGeo ? "CONFLICT" : "ACCOUNT" : hasLegacyGeo ? "LEGACY" : "NONE", 6939 audience: input.accountAudienceCount > 0 ? input.hasInlineAudiences ? "CONFLICT" : "ACCOUNT" : input.hasInlineAudiences ? "LEGACY" : "NONE" 6940 }; 6941};
6942/** The older settings decide something. That is what earns them the top of the tab and a red badge. */ 6943var isLegacyTargetingInForce = (runtime) => runtime.geo === "LEGACY" || runtime.audience === "LEGACY"; 6944/** Both mechanisms are configured, so something on screen is not being applied. */ 6945var hasTargetingConflict = (runtime) => runtime.geo === "CONFLICT" || runtime.audience === "CONFLICT"; 6946/** Never red. Leftover settings a campaign is already ignoring are housekeeping, not a fault. */ 6947var targetingBoxColor = (runtime) => isLegacyTargetingInForce(runtime) ? "warning" : "info"; 6948/** 6949* What actually serves. One line while the account-level settings are what serve - that is the good state, and 6950* a paragraph about the older settings still lying around reads as a fault. Two lines, one per mechanism, only 6951* when the older settings are actually deciding something. No "above" or "below": they render first when they 6952* are in force and last when they are not, so a direction word would be wrong in one of the two layouts. 6953*/ 6954var targetingRuntimeLines = (runtime, counts) => { 6955 if (!isLegacyTargetingInForce(runtime)) return [`This campaign serves on ${runtime.geo === "NONE" ? "everywhere" : `the account geographies (${counts.accountGeoCount})`} and bids on ${runtime.audience === "NONE" ? "everyone" : `the account audiences (${counts.accountAudienceCount})`}.`]; 6956 const geoLines = { 6957 ACCOUNT: `Where it serves: the attached account geographies (${counts.accountGeoCount}).`, 6958 LEGACY: "Where it serves: the older campaign-level location filter - deprecated, and the only geography in force.", 6959 NONE: "Where it serves: everywhere. No geography is set at either level.", 6960 CONFLICT: `Where it serves: the attached account geographies (${counts.accountGeoCount}). The older location filter is configured but ignored entirely - clear it so the screen matches what serves.` 6961 }; 6962 const audienceLines = { 6963 ACCOUNT: `Who it bids on: the attached account audiences (${counts.accountAudienceCount}).`, 6964 LEGACY: "Who it bids on: its older campaign-owned audiences - deprecated, and the only audiences in force.", 6965 NONE: "Who it bids on: everyone. No audience is set at either level.", 6966 CONFLICT: `Who it bids on: the attached account audiences (${counts.accountAudienceCount}). The older campaign-owned audiences are configured but not applied - remove them so the screen matches what serves.` 6967 }; 6968 return [geoLines[runtime.geo], audienceLines[runtime.audience]]; 6969}; 6970var scrollToTargetingElement = (elementId) => document.getElementById(elementId)?.scrollIntoView({ 6971 behavior: "smooth", 6972 block: "start" 6973}); 6974//#endregion 6975//#region client/components/newNav/components/ads/campaigns/campaignFieldGroups.ts 6976/** 6977* The campaign form as data. The old app splits this across a Campaign page and a Program page that the user has 6978* to edit together to configure one thing; here it is one entity, ordered by how often each field is actually 6979* changed rather than by which table it lands in. Version fields are absent everywhere - saving bumps them. 6980* 6981* Every `help` string is quoted from the ads configuration app. Fields it never documented carry no help rather 6982* than an invented one. 6983*/ 6984var ratioSumError = "Sum of mobile and desktop ratios exceed 1. We usually have trouble exceeding 100% of the views"; 6985var DELIVERY_SECTIONS = { 6986 reach: "Where it can run", 6987 trafficSplit: "Traffic split" 6988}; 6989var PLAYER_SIZE_HELP = "The ad slot sizes this campaign will bid on. An opportunity whose player falls outside every size selected here is not bid on at all. Sizes are the slot's pixel dimensions as the exchange reports them."; 6990/** `deviceMode` is a two-way choice in the ads schema, so a CTV campaign cannot also carry desktop or mobile. */ 6991var CTV_DEVICE_MODE = "TV"; 6992var isCtvCampaign = (values) => asText(values.deviceMode) === CTV_DEVICE_MODE; 6993var isNotCtvCampaign = (values) => !isCtvCampaign(values); 6994var validateBidPair = (value, values) => { 6995 const floor = asText(values.lowBidPrice); 6996 const ceiling = asText(values.highBidPrice); 6997 if (!floor || !ceiling) return null; 6998 return Number(floor) > Number(ceiling) ? "The bid floor cannot be above the bid ceiling" : null; 6999}; 7000var validateRatioPair = (value, values) => { 7001 const rangeError = inRange(0, 1, "Ratio must be between 0 and 1")(value); 7002 if (rangeError) return rangeError; 7003 return Number(asText(values.mobileRatio) || 0) + Number(asText(values.desktopRatio) || 0) > 1 ? ratioSumError : null; 7004}; 7005var buildOverviewGroups = () => [{ 7006 id: "identity", 7007 title: "Identity and status", 7008 description: "Enabling and disabling a campaign is the single most-used control in the whole system.", 7009 fields: [ 7010 { 7011 key: "name", 7012 label: "Name", 7013 type: "text", 7014 validate: required("A campaign needs a name") 7015 }, 7016 { 7017 key: "enabled", 7018 label: "Enabled", 7019 type: "bool" 7020 }, 7021 { 7022 key: "clickThroughUrl", 7023 label: "Click-through URL", 7024 type: "url", 7025 wide: true, 7026 placeholder: "https://www.example.com/offer", 7027 help: "The advertiser's site, sent as the campaign-level click-through. A pixel rule with its own click-through URL overrides it for the rows it matches." 7028 }, 7029 { 7030 key: "buyerCreativeId", 7031 label: "Buyer creative id", 7032 type: "text", 7033 help: "Entered by hand. Refreshing it is the lever for a Google throttle, which is why it is not on a schedule." 7034 } 7035 ] 7036}, { 7037 id: "provenance", 7038 title: "From Studio", 7039 description: "How this campaign is linked to Studio. The campaign id is this platform's own key and is what appears in the URL and in the delivery data. The Studio program is the Create-side program it reports into. The Salesforce account is the account in the URL, one level above this campaign.", 7040 fields: [{ 7041 key: "studioProgramId", 7042 label: "Studio program id", 7043 type: "readOnly" 7044 }, { 7045 key: "campaignId", 7046 label: "Campaign id", 7047 type: "readOnly" 7048 }] 7049}]; 7050var buildDeliveryGroups = () => [{ 7051 id: "devices", 7052 title: "Devices and platforms", 7053 description: "A campaign is either Desktop and mobile or CTV, never both. The mobile and desktop ratios do n
7053ot apply to a CTV campaign.", 7054 sections: [{ title: DELIVERY_SECTIONS.reach }, { 7055 title: DELIVERY_SECTIONS.trafficSplit, 7056 description: "How the campaign's impressions are meant to divide between the two. They may not sum above 1.", 7057 columns: 1 7058 }], 7059 fields: [ 7060 { 7061 key: "deviceMode", 7062 label: "Device mode", 7063 type: "select", 7064 options: deviceModeOptions, 7065 section: DELIVERY_SECTIONS.reach, 7066 help: "Which one should be targeted" 7067 }, 7068 { 7069 key: "behaviorDesktop", 7070 label: "Desktop behaviour", 7071 type: "select", 7072 options: deviceBehaviorOptions, 7073 section: DELIVERY_SECTIONS.reach, 7074 disabledWhen: isCtvCampaign, 7075 help: "Behavior of campaign for desktop users" 7076 }, 7077 { 7078 key: "behaviorMobileApp", 7079 label: "Mobile app behaviour", 7080 type: "select", 7081 options: deviceBehaviorOptions, 7082 section: DELIVERY_SECTIONS.reach, 7083 disabledWhen: isCtvCampaign, 7084 help: "Behavior of campaign for mobile/tablet users inside apps" 7085 }, 7086 { 7087 key: "behaviorMobileWeb", 7088 label: "Mobile web behaviour", 7089 type: "select", 7090 options: deviceBehaviorOptions, 7091 section: DELIVERY_SECTIONS.reach, 7092 disabledWhen: isCtvCampaign, 7093 help: "Behavior of campaign for mobile/tablet users during web browsing" 7094 }, 7095 { 7096 key: "behaviorCtv", 7097 label: "CTV behaviour", 7098 type: "select", 7099 options: deviceBehaviorOptions, 7100 section: DELIVERY_SECTIONS.reach, 7101 disabledWhen: isNotCtvCampaign, 7102 help: "Behavior of campaign for ctv users" 7103 }, 7104 { 7105 key: "eligibleDesktopVideoPlayerSizes", 7106 label: "Desktop player sizes", 7107 type: "multiSelect", 7108 options: desktopPlayerSizeOptions, 7109 section: DELIVERY_SECTIONS.reach, 7110 wide: true, 7111 disabledWhen: isCtvCampaign, 7112 help: PLAYER_SIZE_HELP 7113 }, 7114 { 7115 key: "eligibleMobileVideoPlayerSizes", 7116 label: "Mobile player sizes", 7117 type: "multiSelect", 7118 options: mobilePlayerSizeOptions, 7119 section: DELIVERY_SECTIONS.reach, 7120 wide: true, 7121 disabledWhen: isCtvCampaign, 7122 help: PLAYER_SIZE_HELP 7123 }, 7124 { 7125 key: "mobileRatio", 7126 label: "Mobile ratio", 7127 type: "decimal", 7128 min: 0, 7129 max: 1, 7130 section: DELIVERY_SECTIONS.trafficSplit, 7131 disabledWhen: isCtvCampaign, 7132 help: "Ratio for mobile views (0-1)", 7133 validate: validateRatioPair 7134 }, 7135 { 7136 key: "desktopRatio", 7137 label: "Desktop ratio", 7138 type: "decimal", 7139 min: 0, 7140 max: 1, 7141 section: DELIVERY_SECTIONS.trafficSplit, 7142 disabledWhen: isCtvCampaign, 7143 help: "Ratio for desktop views (0-1)", 7144 validate: validateRatioPair 7145 } 7146 ] 7147}, { 7148 id: "frequency", 7149 title: "Frequency caps", 7150 description: "How often one user may see this campaign. 0 means no cap.", 7151 columns: 1, 7152 fields: [ 7153 { 7154 key: "hourlyFrequencyCap", 7155 label: "Hourly", 7156 type: "number", 7157 min: 0 7158 }, 7159 { 7160 key: "dailyFrequencyCap", 7161 label: "Daily", 7162 type: "number", 7163 min: 0 7164 }, 7165 { 7166 key: "weeklyFrequencyCap", 7167 label: "Weekly", 7168 type: "number", 7169 min: 0 7170 }, 7171 { 7172 key: "monthlyFrequencyCap", 7173 label: "Monthly", 7174 type: "number", 7175 min: 0 7176 } 7177 ] 7178}]; 7179/** 7180* Bid prices, the VPAID toggle and the hours-distribution path. Platform knobs rather than delivery settings, 7181* which is why this sits under Advanced beside exchange caps and goal adjustments. 7182*/ 7183var buildPacingGroups = () => [{ 7184 id: "pacing", 7185 title: "Pacing and bidding", 7186 columns: 1, 7187 description: "Every value here belongs to this campaign alone - nothing is inherited. They happen to be identical on every live campaign today, so change one only with a reason. Margin is the one setting in the space that genuinely inherits, and it says so on the budget lanes.", 7188 fields: [ 7189 { 7190 key: "lowBidPrice", 7191 label: "Bid floor", 7192 type: "decimal", 7193 min: 0, 7194 help: "Lowest bid price the campaign will offer (USD per CPM)", 7195 validate: validateBidPair 7196 }, 7197 { 7198 key: "highBidPrice", 7199 label: "Bid ceiling", 7200 type: "decimal", 7201 min: 0, 7202 help: "Highest bid price the campaign will offer (USD per CPM)", 7203 validate: validateBidPair 7204 }, 7205 { 7206 key: "useSskyVpaid", 7207 label: "Use SundaySky VPAID", 7208 type: "bool", 7209 help: "Whether to use VPAID response with additional player data" 7210 }, 7211 { 7212 key: "volumeDistributionPath", 7213 label: "Hours distribution CSV", 7214 type: "readOnly", 7215 wide: true, 7216 machineWritten: true, 7217 help: "The s3 location of a hours csv file. For example s3://bucket-name/path/file-name.csv" 7218 } 7219 ] 7220}]; 7221var LEGACY_TARGETING_SECTIONS = { 7222 location: "Location", 7223 audience: "Audience", 7224 dmp: "DMP segments" 7225}; 7226/**
7227* `audiences` is the read-only list of the campaign's own audiences, which has no field of its own to sit under. 7228* `titleAdornment` is the deprecated-but-in-use badge, built by the caller because it is a rendered node. 7229* 7230* The older group is a collapsed accordion only while it is not the mechanism in force. On a campaign serving 7231* entirely on its own location filter, collapsing the one thing that decides where it serves is what made the 7232* setting invisible. 7233*/ 7234var buildTargetingGroups = (audiences, isLegacyInForce = false, titleAdornment) => [{ 7235 id: "legacy", 7236 title: "Older targeting settings", 7237 advanced: !isLegacyInForce, 7238 titleAdornment, 7239 description: isLegacyInForce ? "These older campaign-level settings are what this campaign actually serves on today. They are deprecated - move them to account-level geographies and audiences so a correction reaches every campaign that shares the same list." : "Superseded by the account's audiences and geographies, and kept because campaigns that have not moved over still serve on them.", 7240 sections: [ 7241 { 7242 title: LEGACY_TARGETING_SECTIONS.location, 7243 description: "A campaign with any account geography attached ignores this entirely." 7244 }, 7245 { 7246 title: LEGACY_TARGETING_SECTIONS.audience, 7247 description: "Audiences this campaign owns rather than pointing at the account's.", 7248 content: audiences 7249 }, 7250 { title: LEGACY_TARGETING_SECTIONS.dmp } 7251 ], 7252 fields: [ 7253 { 7254 key: "locationFilter", 7255 label: "Location filter", 7256 type: "geoDefinition", 7257 section: LEGACY_TARGETING_SECTIONS.location, 7258 wide: true 7259 }, 7260 { 7261 key: "dmpAccountName", 7262 label: "DMP account name", 7263 type: "text", 7264 section: LEGACY_TARGETING_SECTIONS.dmp, 7265 help: "The DMP account whose segments this campaign may bid on" 7266 }, 7267 { 7268 key: "allowThirdPartySegmentsInOptimization", 7269 label: "Allow third-party segments in optimization", 7270 type: "bool", 7271 section: LEGACY_TARGETING_SECTIONS.dmp 7272 } 7273 ] 7274}]; 7275/** What the campaign refuses to serve against. Nothing here names a place or a person - that is the targeting tab. */ 7276var buildFilterGroups = () => [{ 7277 id: "filters", 7278 title: "Content filters", 7279 description: "Where the ad may not appear, judged on the page or app the opportunity came from. Each accepts a comma-separated list or a link to a CSV.", 7280 fields: [ 7281 { 7282 key: "categoryFilter", 7283 label: "IAB categories filter", 7284 type: "text", 7285 wide: true, 7286 help: "Location of CSV file with list of IAB categories to filter out, or comma separated list of categories" 7287 }, 7288 { 7289 key: "urlFilter", 7290 label: "URL filter", 7291 type: "text", 7292 wide: true, 7293 help: "The location of a white list or black list of URLs. The format of this field is 'white:' or 'black:' followed by a location. For example 'white:s3://bucket-name/path/file-name.csv', or comma separated list of urls. Prefix a domain with ! to match the root domain only rather than everything beneath it. The list logic is unchanged from the previous platform.", 7294 validate: matches(/^\S*$/, "The URL filter may not contain spaces") 7295 }, 7296 { 7297 key: "keywordsFilter", 7298 label: "Keywords filter", 7299 type: "text", 7300 wide: true, 7301 help: "Location of CSV file with list of keywords to filter out, or comma separated list of keywords" 7302 }, 7303 { 7304 key: "appNameFilter", 7305 label: "App name filter", 7306 type: "text", 7307 wide: true, 7308 help: "Location of CSV file with list of apps to filter out, or comma separated list of app names" 7309 } 7310 ] 7311}]; 7312/** Third-party measurement. Nothing here changes who the campaign serves to. */ 7313var buildTrackingGroups = () => [{ 7314 id: "pixel-file", 7315 title: "Where the rules live", 7316 description: "The file the ad server reads the pixels from. The editor below writes it; this is the path.", 7317 fields: [{ 7318 key: "pixelRulesLocation", 7319 label: "Pixel rules CSV", 7320 type: "url", 7321 wide: true, 7322 help: "The S3 location for the Pixel Rules CSV" 7323 }] 7324}]; 7325/** The account geographies a campaign serves in. Several rows union; a "never serve" row subtracts from them. */ 7326var programGeoFilterFields = (geoOptions) => [{ 7327 key: "geoFilterId", 7328 label: "Geography", 7329 type: "select", 7330 options: geoOptions, 7331 wide: true, 7332 validate: required("Choose one of the account's geographies") 7333}, { 7334 key: "mode", 7335 label: "How it applies", 7336 type: "select", 7337 options: geoFilterModeOptions, 7338 defaultValue: GeoFilterMode.WHITELIST, 7339 help: "Several \"serve only here\" rows are added together. A \"never serve here\" row is subtracted from them, whatever the others say." 7340}]; 7341/** The account audiences a campaign bids on. Matching any one of them qualifies the request. */ 7342var programAudienceFields = (audienceOptions) => [ 7343 {
7344 key: "audienceId", 7345 label: "Audience", 7346 type: "select", 7347 options: audienceOptions, 7348 wide: true, 7349 validate: required("Choose one of the account's audiences") 7350 }, 7351 { 7352 key: "audienceIdx", 7353 label: "Priority", 7354 type: "number", 7355 min: 0, 7356 help: "Matching any one audience qualifies the request. When a user matches several, the lowest number here is the audience whose name is sent to third-party pixels and used to pick the story.", 7357 validate: inRange(0, 999, "Use a whole number") 7358 }, 7359 { 7360 key: "enabled", 7361 label: "Enabled", 7362 type: "bool", 7363 defaultValue: true 7364 } 7365]; 7366/** 7367* A product selector per audience. The ad server looks one up for the audience it matched and refuses to serve 7368* without it, which is why an audience attached above and missing here is called out rather than left silent. 7369*/ 7370var productSelectorFields = (audienceOptions) => [ 7371 { 7372 key: "audienceId", 7373 label: "Audience", 7374 type: "select", 7375 options: audienceOptions, 7376 wide: true, 7377 validate: required("Choose one of the audiences this campaign targets") 7378 }, 7379 { 7380 key: "type", 7381 label: "Show", 7382 type: "select", 7383 options: productSelectorTypeOptions, 7384 wide: true, 7385 defaultValue: ProductSelectorType.LOCATION_TOP_SELLER, 7386 validate: required("Choose what to show") 7387 }, 7388 { 7389 key: "extraData", 7390 label: "Value", 7391 type: "text", 7392 wide: true, 7393 help: "A category name for Specific category, a product name for Specific product, and the destination URL for No products. The other types do not need one; a value already stored is kept.", 7394 validate: (value, values) => PRODUCT_SELECTOR_TYPES_NEEDING_VALUE.includes(asText(values.type)) && !asText(value) ? `${productSelectorTypeLabel(asText(values.type))} needs a value` : null 7395 } 7396]; 7397var unattached = (options, rows, key) => options.filter((option) => !rows.some((row) => asText(row[key]) === option.value)); 7398/** Attaching is picking from a list someone else wrote, so it is one multi-select rather than a dialog per row. */ 7399var attachGeoFiltersFields = (geoOptions, attached) => [{ 7400 key: "geoFilterIds", 7401 label: "Geographies", 7402 type: "multiSelect", 7403 options: unattached(geoOptions, attached, "geoFilterId"), 7404 wide: true, 7405 validate: required("Pick at least one geography") 7406}, { 7407 key: "mode", 7408 label: "How they apply", 7409 type: "select", 7410 options: geoFilterModeOptions, 7411 defaultValue: GeoFilterMode.WHITELIST, 7412 help: "Applies to every geography picked above. Add a second batch to attach some the other way round." 7413}]; 7414var attachAudiencesFields = (audienceOptions, attached) => [{
7415 key: "audienceIds", 7416 label: "Audiences", 7417 type: "multiSelect", 7418 options: unattached(audienceOptions, attached, "audienceId"), 7419 wide: true, 7420 validate: required("Pick at least one audience") 7421}]; 7422var isControlGroupOff = (values) => asText(values.controlGroupMode) === "DISABLED"; 7423var buildControlGroupGroups = (videoResponseOptions) => [{ 7424 id: "control-group", 7425 title: "Control group", 7426 description: "Holds a slice of users back so the campaign's effect can be measured against them.", 7427 fields: [ 7428 { 7429 key: "controlGroupMode", 7430 label: "Control group mode", 7431 type: "select", 7432 options: controlGroupModeOptions, 7433 help: "Control group mode for this program" 7434 }, 7435 { 7436 key: "controlGroupPercent", 7437 label: "Control group percent", 7438 type: "number", 7439 min: 0, 7440 max: 100, 7441 help: "Percent of users who belong to the control group (irrelevant if 'Disabled')", 7442 disabledWhen: isControlGroupOff, 7443 validate: inRange(0, 100, "Percent runs from 0 to 100") 7444 }, 7445 { 7446 key: "controlGroupResponseId", 7447 label: "Control group video", 7448 type: "select", 7449 options: [{ 7450 value: "", 7451 label: "None" 7452 }, ...videoResponseOptions], 7453 wide: true, 7454 disabledWhen: isControlGroupOff, 7455 help: "Video to use for control group (only when mode is 'Enabled')" 7456 } 7457 ] 7458}]; 7459var buildOptimizationGroups = (predictionModelOptions, experimentOptions, experimentGroupOptions) => [{ 7460 id: "optimization-experiment", 7461 title: "Test and control", 7462 advanced: true, 7463 description: "The group pickers only offer groups of the experiment selected above.", 7464 fields: [ 7465 { 7466 key: "optimizationExperimentEnabled", 7467 label: "Test/control experiment enabled", 7468 type: "bool", 7469 help: "Whether to use test/control experiment in optimization" 7470 }, 7471 { 7472 key: "optimizationExperimentId", 7473 label: "Experiment", 7474 type: "select", 7475 options: [{ 7476 value: "", 7477 label: "None" 7478 }, ...experimentOptions] 7479 }, 7480 { 7481 key: "optimizationTestGroupId", 7482 label: "Test group", 7483 type: "select", 7484 options: [{ 7485 value: "", 7486 label: "None" 7487 }, ...experimentGroupOptions] 7488 }, 7489 { 7490 key: "optimizationControlGroupId", 7491 label: "Control group", 7492 type: "select", 7493 options: [{ 7494 value: "", 7495 label: "None" 7496 }, ...experimentGroupOptions] 7497 }, 7498 { 7499 key: "optimizationControlGroupMode", 7500 label: "Control value mode", 7501 type: "select", 7502 options: optimizationControlModeOptions, 7503 wide: true, 7504 help: "Fixed value - control group will use the same value always; Random value - a random value from within the model values will be chosen each time; By experiment group - a separate experiment (set below) will be used, and the matching group name will be the optimization value" 7505 }, 7506 { 7507 key: "optimizationControlFixedValue", 7508 label: "Control fixed value", 7509 type: "text", 7510 help: "Fixed optimization value for control users (when the appropriate mode is set)" 7511 }, 7512 { 7513 key: "optimizationTestExperimentId", 7514 label: "Control experiment", 7515 type: "select", 7516 options: [{ 7517 value: "", 7518 label: "None" 7519 }, ...experimentOptions], 7520 help: "Experiment used to select control users optimization value (when the appropriate mode is set)" 7521 } 7522 ] 7523}, { 7524 id: "optimization", 7525 title: "Optimization model", 7526 advanced: true, 7527 description: "Hands the creative choice to a prediction model instead of the campaign's own rules.", 7528 fields: [ 7529 { 7530 key: "optimizationEnabled", 7531 label: "Optimization enabled", 7532 type: "bool", 7533 help: "Whether to use optimization" 7534 }, 7535 { 7536 key: "optimizationModelId", 7537 label: "Optimization model", 7538 type: "select", 7539 options: [{ 7540 value: "", 7541 label: "None" 7542 }, ...predictionModelOptions], 7543 wide: true, 7544 help: "Please ensure that: (1) the model contains all the necessary files - feature, event fields, etc. (2) the model's description contains the SageMaker endpoint (3) there is a file called creativeMapping.json (no model id prefix) in the same S3 folder as the other model files, containing the mapping between creative values and the SageMaker action response" 7545 }, 7546 { 7547 key: "optimizationOutputField", 7548 label: "Optimization output field", 7549 type: "text", 7550 help: "Field in Video URL that will contain the optimization result (e.g. creative or experiment)", 7551 validate: matches(/^\S*$/, "The output field may not contain spaces") 7552 } 7553 ] 7554}]; 7555/** Child table field sets. Each is a row editor, not a section of the parent form. */ 7556var flightFields = [ 7557 { 7558 key: "startDate", 7559 label: "Start date", 7560 type: "date", 7561 validate: required("A flight needs a start date") 7562 }, 7563 { 7564 key: "endDate", 7565 label: "End date", 7566 type: "date", 7567 validate: required("A flight needs an end date") 7568 }, 7569 { 7570 key: "totalImpressions", 7571 label: "Total impressions", 7572 type: "number", 7573 min: 0 7574 }, 7575 { 7576 key: "totalCompletedViews", 7577 label: "Total completed views", 7578 type: "number", 7579 min: 0, 7580 help: "Use instead of impressions for CPCV deals." 7581 }, 7582 { 7583 key: "customerCpm", 7584 label: "Customer CPM", 7585 type: "decimal", 7586 min: 0, 7587 help: "CPM received from the customer for this goal period" 7588 }, 7589 { 7590 key: "customerCpcv", 7591 label: "Customer CPCV", 7592 type: "decimal", 7593 min: 0, 7594 help: "CPCV received from the customer for this goal period" 7595 }, 7596 { 7597 key: "margin", 7598 label: "Margin", 7599 type: "decimal", 7600 min: 0, 7601 help: "Margin between customer CPM and the provider CPM" 7602 }, 7603 { 7604 key: "insertionOrder", 7605 label: "Insertion order", 7606 type: "text", 7607 help: "Optional" 7608 } 7609]; 7610/** Flights of one campaign may not overlap, and two may not carry identical dates. */ 7611var validateFlightRow = (row, rows) => { 7612 const start = asText(row.startDate); 7613 const end = asText(row.endDate); 7614 if (start && end && start > end) return { endDate: "The end date cannot be before the start date" }; 7615 if (rows.find((other) =>
7615 asText(other.startDate) === start && asText(other.endDate) === end)) return { startDate: `Period with same dates already exists: ${start} - ${end}` }; 7616 const overlapping = rows.find((other) => start <= asText(other.endDate) && asText(other.startDate) <= end); 7617 if (overlapping) return { startDate: `Period with intersecting dates exists: ${asText(overlapping.startDate)} - ${asText(overlapping.endDate)}` }; 7618 return {}; 7619}; 7620/** 7621* Defaults apply to a new row only, so no existing creative is rewritten. Story one is what nearly every campaign 7622* runs unless an error forces another identifier, and one SKU is the standard experience count. 7623*/ 7624var creativeFields = [ 7625 { 7626 key: "story", 7627 label: "Story name", 7628 type: "text", 7629 defaultValue: "story1", 7630 help: "Story Name. Remove the row to stop using a story - there is no per-row disable.", 7631 validate: required("A creative needs a story name") 7632 }, 7633 { 7634 key: "numSkus", 7635 label: "Number of SKUs", 7636 type: "number", 7637 min: 0, 7638 defaultValue: "1", 7639 help: "Number of SKUs in rule" 7640 }, 7641 { 7642 key: "videoParameters", 7643 label: "Video parameters", 7644 type: "text", 7645 wide: true, 7646 placeholder: "creative=retail,rule=${rule},cat=${category}", 7647 help: "Query parameters added to the video URL, comma separated. Macros: ${story}, ${rule}, ${category}, ${location.zip_code}, ${location.city}, ${location.state}, ${a1}..${a9}, and ${<experiment name>} for the matching group's name." 7648 }, 7649 { 7650 key: "allowedDurations", 7651 label: "Allowed durations", 7652 type: "text", 7653 help: "supported video durations comma delimited. i.e - 15,30" 7654 }, 7655 { 7656 key: "videoQualityProfile", 7657 label: "Video quality profile", 7658 type: "select", 7659 options: videoQualityProfileOptions, 7660 defaultValue: "HD_READY", 7661 help: "video quality profile" 7662 }, 7663 { 7664 key: "supportHorizontalAspectRatio", 7665 label: "Horizontal (16:9)", 7666 type: "bool", 7667 defaultValue: true 7668 }, 7669 { 7670 key: "supportSquareAspectRatio", 7671 label: "Square (1:1)", 7672 type: "bool" 7673 }, 7674 { 7675 key: "supportVerticalAspectRatio", 7676 label: "Vertical (9:16)", 7677 type: "bool" 7678 } 7679]; 7680/** Two rows for one story is the case that reads as one replacing the other, so it is the one made impossible. */ 7681var validateCreativeRow = (row, rows) => rows.some((other) => asText(other.story).trim() === asText(row.story).trim()) ? { story: "This campaign already has a creative for that story" } : {}; 7682var buildReportingFields = (source) => [ 7683 { 7684 key: "name", 7685 label: "Name", 7686 type: "text", 7687 help: "Name", 7688 validate: required("A reporting definition needs a name") 7689 }, 7690 { 7691 key: "source", 7692 label: "Source", 7693 type: "select", 7694 options: reportingSourceOptions, 7695 help: "Which set of fields the definition may draw from. Audience and audiences are under User data; zip code, city and state are under Location; a site event's own field names are typed by hand." 7696 }, 7697 reportingFieldOptionsBySource[source] ? { 7698 key: "field", 7699 label: "Field", 7700 type: "select", 7701 options: reportingFieldOptionsBySource[source] 7702 } : { 7703 key: "field", 7704 label: "Field", 7705 type: "text", 7706 help: "Pick a Source first - the fields available depend on it. A source with no list takes the site event field name." 7707 }, 7708 { 7709 key: "tagName", 7710 label: "Tag name", 7711 type: "text", 7712 help: "case sensitive! must be populated if field=product_catalog_tag" 7713 }, 7714 { 7715 key: "targetField", 7716 label: "Target field", 7717 type: "select", 7718 options: reportingTargetFieldOptions, 7719 help: "Target Field" 7720 } 7721]; 7722var exchangeCapFields = (exchangeOptions) => [{ 7723 key: "exchangePrefix", 7724 label: "Exchange", 7725 type: "select", 7726 options: exchangeOptions 7727}, { 7728 key: "dailyImpressions", 7729 label: "Daily impressions", 7730 type: "number", 7731 min: 0, 7732 help: "0 blocks the exchange outright. Remove the row instead to leave it uncapped." 7733}]; 7734var campaignPropertyFields = [{ 7735 key: "propKey", 7736 label: "Property", 7737 type: "select", 7738 options: campaignPropertyKeyOptions 7739}, { 7740 key: "propValue", 7741 label: "Value", 7742 type: "text", 7743 wide: true 7744}]; 7745var modelMappingFields = (predictionModelOptions) => [ 7746 { 7747 key: "predictionModelId", 7748 label: "Prediction model", 7749 type: "select", 7750 options: predictionModelOptions 7751 }, 7752 { 7753 key: "enabled", 7754 label: "Enabled", 7755 type: "bool" 7756 }, 7757 { 7758 key: "enabledFrom", 7759 label: "Enabled from", 7760 type: "date" 7761 }, 7762 { 7763 key: "enabledTo", 7764 label: "Enabled to", 7765 type: "date" 7766 } 7767]; 7768var goalAdjustmentFields = [ 7769 { 7770 key: "type", 7771 label: "Type", 7772 type: "select", 7773 options: goalAdjustmentTypeOptions, 7774 help: "The type of the adjustment we configure", 7775 validate: required("An adjustment needs a type") 7776 }, 7777 { 7778 key: "base", 7779 label: "Base", 7780 type: "decimal", 7781 help: "The base value of the adjustment" 7782 }, 7783 { 7784 key: "changerate", 7785 label: "Change rate", 7786 type: "decimal", 7787 help: "The rate in which the adjustment value will change in each step" 7788 }, 7789 { 7790 key: "minValue", 7791 label: "Min value", 7792 type: "decimal", 7793 help: "The min value of the adjustment" 7794 }, 7795 { 7796 key: "maxValue", 7797 label: "Max value", 7798 type: "decimal", 7799 help: "The max value of the adjustment" 7800 }, 7801 { 7802 key: "overrideValue", 7803 label: "Override value", 7804 type: "decimal", 7805 help: "The fixed value of the adjustment if we want to make it constant" 7806 } 7807]; 7808/** The ad server keys adjustments by type, so a second row of the same type is silently dropped rather than merged. */ 7809var validateGoalAdjustmentRow = (row, rows) => rows.some((other) => asText(other.type) === asText(row.type)) ? { type: "This campaign already has an adjustment of that type" } : {}; 7810//#endregion
7811//#region client/components/newNav/components/ads/pixels/CampaignPixelRulesPanel.tsx 7812/** 7813* The campaign's own way into the pixel editor, beside the field that until now only showed a path. The editor is 7814* a dialog here rather than a route so the campaign's editing session survives - opening it must not discard 7815* unsaved changes on the tab behind it. 7816* 7817* The file is fetched only once the dialog opens: it is an S3 read over the ads VPC, and the Targeting tab renders 7818* far more often than anyone opens the editor. 7819*/ 7820var CampaignPixelRulesPanel = (props) => { 7821 const [isOpen, setIsOpen] = (0, import_react.useState)(false); 7822 const [isRechecking, setIsRechecking] = (0, import_react.useState)(false); 7823 const [checkedAt, setCheckedAt] = (0, import_react.useState)(null); 7824 const [notice, setNotice] = (0, import_react.useState)(null); 7825 const { data, loading, error } = useCurrentAccountAdsSpacePixelRulesQuery({ 7826 variables: { campaignId: props.campaignId }, 7827 skip: !isOpen 7828 }); 7829 const [save, { loading: saving }] = useSaveAdsSpacePixelRulesMutation(); 7830 const file = data?.currentAccount?.adsSpace?.pixelRules; 7831 const location = file?.location ?? props.location; 7832 const rules = file?.csvText ? parsePixelRulesCsv(file.csvText).rules : []; 7833 const persist = async (csvText, comment) => { 7834 const saved = (await save({ variables: { 7835 campaignId: props.campaignId, 7836 input: { 7837 campaignId: props.campaignId, 7838 updatedAt: props.updatedAt, 7839 changeComment: comment, 7840 csvText 7841 } 7842 } })).data?.saveAdsSpacePixelRules?.adsSpace?.pixelRules?.location ?? null; 7843 if (saved) props.onSaved(saved); 7844 const rechecked = await recheck(); 7845 setNotice(rechecked > 0 ? `Pixel rules saved. ${rechecked} placements are now mapped to this campaign.` : "Pixel rules saved, but no placements are mapped to this campaign. Nothing will be attributed to it."); 7846 }; 7847 const recheck = async () => { 7848 setIsRechecking(true); 7849 try { 7850 return await props.onRecheck(); 7851 } finally { 7852 setIsRechecking(false); 7853 setCheckedAt((/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { 7854 hour: "2-digit", 7855 minute: "2-digit" 7856 })); 7857 } 7858 }; 7859 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 7860 variant: "outlined", 7861 sx: adsSurfaceSx, 7862 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 7863 sx: adsIconTileSx(40), 7864 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faBullseyePointer.faBullseyePointer }) 7865 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 7866 sx: bodySx$2, 7867 children: [
7868 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 7869 variant: "h5", 7870 children: "Tracking pixels" 7871 }), 7872 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 7873 variant: "body2", 7874 color: "text.secondary", 7875 children: location ? `The advertiser's tracking URLs and their conditions, from ${location}.` : "This campaign has no pixel rules file yet." 7876 }), 7877 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 7878 variant: "body2", 7879 color: props.mappedPlacementCount === 0 ? "error.main" : "text.primary", 7880 "data-testid": "ads-campaign-placement-count", 7881 children: props.mappedPlacementCount === 0 ? "No placements are mapped to this campaign, so nothing will be attributed to it." : `${props.mappedPlacementCount} placements mapped${checkedAt ? `, last checked ${checkedAt}` : ""}.` 7882 }), 7883 notice && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 7884 variant: "helper", 7885 color: "text.secondary", 7886 children: notice 7887 }),
7888 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 7889 sx: actionsSx, 7890 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 7891 variant: "outlined", 7892 size: "medium", 7893 onClick: () => setIsOpen(true), 7894 "data-tracking-id": "tracking-id-ads-campaign-pixel-rules-btn", 7895 children: props.readOnly ? "View pixel rules" : "Edit pixel rules" 7896 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 7897 variant: "text", 7898 size: "medium", 7899 disabled: isRechecking, 7900 onClick: () => void recheck().catch(() => setNotice("Could not re-read the placement mapping.")), 7901 "data-testid": "ads-campaign-placement-recheck-btn", 7902 "data-tracking-id": "tracking-id-ads-campaign-placement-recheck-btn", 7903 children: "Re-check" 7904 })] 7905 }) 7906 ] 7907 })] 7908 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 7909 open: isOpen, 7910 onClose: saving ? void 0 : () => setIsOpen(false), 7911 maxWidth: false, 7912 PaperProps: { sx: dialogPaperSx$3 }, 7913 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 7914 CloseIconButtonProps: { 7915 onClick: () => setIsOpen(false), 7916 disabled: saving, 7917 "data-tracking-id": "tracking-id-ads-campaign-pixel-rules-close-btn" 7918 }, 7919 children: `Tracking pixels - ${props.campaignName}` 7920 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { children: [ 7921 loading && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 7922 variant: "rounded", 7923 height: 320 7924 }), 7925 error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 7926 color: "error", 7927 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 7928 variant: "body1", 7929 children: `This campaign's pixel rules file could not be read from ${location || "S3"}.` 7930 }) }) 7931 }), 7932 !loading && !error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PixelRulesEditor, { 7933 campaignName: props.campaignName, 7934 location, 7935 initialRules: rules, 7936 laneNames: props.laneNames, 7937 readOnly: props.readOnly, 7938 onSave: props.readOnly ? void 0 : persist, 7939 saving 7940 }) 7941 ] })] 7942 })] }); 7943}; 7944var bodySx$2 = {
7945 display: "flex", 7946 flexDirection: "column", 7947 gap: 1, 7948 minWidth: 0 7949}; 7950var actionsSx = { 7951 display: "flex", 7952 gap: 1, 7953 alignSelf: "flex-start" 7954}; 7955var dialogPaperSx$3 = { 7956 width: 1100, 7957 maxWidth: "calc(100vw - 64px)", 7958 height: "calc(100vh - 96px)" 7959}; 7960//#endregion 7961//#region client/components/newNav/components/ads/common/useAdsSpaceEntities.ts 7962var toRow = (entity) => { 7963 const values = {}; 7964 entity.values.forEach((value) => { 7965 values[value.key] = value.value; 7966 }); 7967 return { 7968 ...values, 7969 id: entity.id 7970 }; 7971}; 7972var toWireValues$1 = (row) => Object.entries(row).filter(([key]) => key !== "id").map(([key, value]) => ({ 7973 key, 7974 value: Array.isArray(value) ? value.join(",") : value === null ? null : asText(value) 7975})); 7976/** 7977* One configuration table, read and written through the space's generic entity path. Every settings and system 7978* surface uses this rather than a query and a mutation of its own - the server registry is what knows which 7979* table and column each key means. 7980*/ 7981var useAdsSpaceEntities = (kind, parentId, options) => { 7982 const { data, loading, error } = useCurrentAccountAdsSpaceEntitiesQuery({ 7983 variables: { 7984 kind, 7985 parentId 7986 }, 7987 skip: options?.skipRead 7988 }); 7989 const [saveEntities, { loading: saving }] = useSaveAdsSpaceEntitiesMutation(); 7990 const rows = (0, import_react.useMemo)(() => (data?.currentAccount?.adsSpace?.entities ?? []).map(toRow), [data]); 7991 const runSave = async (changed, comment, deletedIds, intoParentId) => { 7992 const target = intoParentId ?? parentId; 7993 try { 7994 const result = await saveEntities({ variables: { 7995 kind, 7996 parentId: target, 7997 input: { 7998 kind, 7999 parentId: target, 8000 changeComment: comment, 8001 rows: changed.map((row) => ({ 8002 id: row.id, 8003 values: toWireValues$1(row) 8004 })), 8005 deletedIds 8006 } 8007 } }); 8008 setSuccessNotification({ message: "Saved" }); 8009 return (result.data?.saveAdsSpaceEntities?.adsSpace?.entities ?? []).map(toRow); 8010 } catch (saveError) { 8011 setErrorNotification({ message: saveError?.message ?? "Saving failed" }); 8012 throw saveError; 8013 } 8014 }; 8015 const create = async (row, comment, matchKey = "name") => { 8016 const knownIds = new Set(rows.map((existing) => existing.id)); 8017 const added = (await runSave([{ 8018 ...row, 8019 id: "" 8020 }], comment)).filter((candidate) => !knownIds.has(candidate.id)); 8021 const wanted = asText(row[matchKey]); 8022 return added.find((candidate) => asText(candidate[matchKey]) === wanted) ?? added[0] ?? null; 8023 }; 8024 return { 8025 rows, 8026 loading, 8027 error: Boolean(error), 8028 saving, 8029 save: async (changed, comment, deletedIds, intoParentId) => { 8030 await runSave(changed, comment, deletedIds, intoParentId); 8031 }, 8032 create 8033 }; 8034}; 8035/** 8036* A child collection edited inside a parent's form - video files on a response, sync conditions on DSP account 8037* data. The rows are held locally until the parent saves, which is the space's contract: nothing reaches the 8038* server without an explicit save and a comment. 8039*/ 8040var useAdsSpaceChildRows = (kind, parentId) => { 8041 const { rows: savedRows, saving, loading, save } = useAdsSpaceEntities(kind, parentId); 8042 const [rows, setRows] = (0, import_react.useState)([]); 8043 (0, import_react.useEffect)(() => setRows(savedRows), [savedRows]); 8044 const changeCount = (0, import_react.useMemo)(() => { 8045 const savedById = new Map(savedRows.map((row) => [row.id, row])); 8046 return rows.filter((row) => JSON.stringify(savedById.get(row.id) ?? null) !== JSON.stringify(row)).length + savedRows.filter((saved) => !rows.some((row) => row.id === saved.id)).length; 8047 }, [rows, savedRows]); 8048 const persist = async (comment) => { 8049 if (changeCount === 0) return; 8050 const savedById = new Map(savedRows.map((row) => [row.id, row])); 8051 const changed = rows.filter((row) => JSON.stringify(savedById.get(row.id) ?? null) !== JSON.stringify(row)); 8052 const deletedIds = savedRows.filter((saved) => !rows.some((row) => row.id === saved.id)).map((row) => row.id); 8053 await save(changed, comment, deletedIds.length > 0 ? deletedIds : void 0); 8054 }; 8055 return { 8056 rows, 8057 setRows, 8058 changeCount, 8059 persist, 8060 saving, 8061 loading, 8062 reset: () => setRows(savedRows) 8063 }; 8064}; 8065//#endregion
8066//#region client/components/newNav/components/ads/campaigns/AdsChangeCommentField.tsx 8067/** `audit_log_record.comments` is varchar(255), so the limit belongs where the comment is typed. */ 8068var COMMENT_MAX_LENGTH$1 = 255; 8069/** Every write in the space carries a change comment; the campaign dialogs collect theirs inline. */ 8070var AdsChangeCommentField = ({ value, label, onChange, trackingId }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 8071 label: label ?? "What changed and why", 8072 fullWidth: true, 8073 multiline: true, 8074 minRows: 2, 8075 size: "small", 8076 value, 8077 onChange: (event) => onChange(event.target.value), 8078 inputProps: { 8079 "data-tracking-id": trackingId, 8080 maxLength: COMMENT_MAX_LENGTH$1 8081 } 8082}); 8083//#endregion 8084//#region client/components/newNav/components/ads/campaigns/MoveCampaignDialog.tsx 8085/** 8086* Repoints a campaign at another Studio program. The list is the programs this account's campaigns already use - 8087* a program with no ads campaign yet cannot be picked here. 8088*/ 8089var MoveCampaignDialog = (props) => { 8090 const [studioProgramSfId, setStudioProgramSfId] = (0, import_react.useState)(""); 8091 const [comment, setComment] = (0, import_react.useState)(""); 8092 (0, import_react.useEffect)(() => { 8093 setStudioProgramSfId(""); 8094 setComment(""); 8095 }, [props.campaign]); 8096 const targets = props.studioPrograms.filter((program) => program.value !== props.campaign?.salesforceId); 8097 const canSave = Boolean(studioProgramSfId) && comment.trim().length > 0 && !props.saving; 8098 const handleMove = async () => { 8099 if (!canSave) return; 8100 if (await props.onMove({ 8101 campaignId: props.campaign.id, 8102 studioProgramSfId, 8103 changeComment: comment.trim() 8104 })) props.onClose(); 8105 }; 8106 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 8107 open: Boolean(props.campaign), 8108 onClose: props.saving ? void 0 : props.onClose, 8109 maxWidth: false, 8110 PaperProps: { sx: dialogPaperSx$2 }, 8111 children: [ 8112 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 8113 CloseIconButtonProps: { 8114 onClick: props.onClose, 8115 disabled: props.saving, 8116 "data-tracking-id": "tracking-id-ads-move-campaign-close-btn" 8117 }, 8118 children: "Move to another program" 8119 }), 8120 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { 8121 sx: contentSx$9, 8122 children: [ 8123 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 8124 select: true, 8125 label: "Studio program", 8126 fullWidth: true, 8127 value: studioProgramSfId, 8128 onChange: (event) => setStudioProgramSfId(event.target.value), 8129 inputProps: { "data-tracking-id": "tracking-id-ads-move-campaign-program-input" }, 8130 children: targets.map((program) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 8131 value: program.value, 8132 children: program.label 8133 }, program.value)) 8134 }), 8135 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 8136 value: comment, 8137 onChange: setComment, 8138 trackingId: "tracking-id-ads-move-campaign-comment-input" 8139 }), 8140 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 8141 color: "warning", 8142 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8143 variant: "body1", 8144 color: "text.secondary", 8145 children: "The program decides which creative logic this campaign resolves against, so moving it changes what it serves. Only a disabled campaign can be moved." 8146 }) }) 8147 }) 8148 ] 8149 }), 8150 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 8151 variant: "text", 8152 size: "large", 8153 disabled: props.saving, 8154 onClick: props.onClose, 8155 "data-tracking-id": "tracking-id-ads-move-campaign-cancel-btn", 8156 children: "Cancel" 8157 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 8158 variant: "contained", 8159 size: "large", 8160 loading: props.saving, 8161 disabled: !canSave, 8162 onClick: () => void handleMove(), 8163 "data-tracking-id": "tracking-id-ads-move-campaign-submit-btn", 8164 children: "Move campaign" 8165 })] }) 8166 ] 8167 }); 8168}; 8169var dialogPaperSx$2 = { 8170 width: 540, 8171 maxWidth: "calc(100vw - 64px)" 8172}; 8173var contentSx$9 = {
8174 display: "flex", 8175 flexDirection: "column", 8176 gap: 2 8177}; 8178//#endregion 8179//#region client/components/newNav/components/ads/campaigns/useAdsSpaceCampaignActions.ts 8180/** Create, duplicate and move. Each reports whether it landed, so a dialog only closes on success. */ 8181var useAdsSpaceCampaignActions = () => { 8182 const [createCampaign, createState] = useCreateAdsSpaceCampaignMutation(); 8183 const [duplicateCampaign, duplicateState] = useDuplicateAdsSpaceCampaignMutation(); 8184 const [moveCampaign, moveState] = useMoveAdsSpaceCampaignMutation(); 8185 const run = async (what, action, onFailure) => { 8186 try { 8187 const result = await action(); 8188 setSuccessNotification({ message: what }); 8189 return result; 8190 } catch (error) { 8191 setErrorNotification({ message: error?.message ?? `${what} failed` }); 8192 return onFailure; 8193 } 8194 }; 8195 /** 8196 * The mutation returns the account's whole campaign list, so the copy's id is in the response the caller is 8197 * already waiting on. The ads services server assigns it, which is why it has to be read back by name. 8198 */ 8199 const duplicate = async (input) => run(`Campaign copied to "${input.name}"`, async () => { 8200 return ((await duplicateCampaign({ variables: { input } })).data?.duplicateAdsSpaceCampaign?.adsSpace?.campaigns ?? []).find((campaign) => campaign.name === input.name)?.id ?? null; 8201 }, null); 8202 return { 8203 create: (input) => run(`Campaign "${input.name}" created`, async () => { 8204 await createCampaign({ variables: { input } }); 8205 return true; 8206 }, false), 8207 duplicate, 8208 move: (input) => run("Campaign moved to another program", async () => { 8209 await moveCampaign({ variables: { input } }); 8210 return true; 8211 }, false), 8212 saving: createState.loading || duplicateState.loading || moveState.loading 8213 }; 8214}; 8215//#endregion 8216//#region client/components/newNav/components/ads/campaigns/campaignsModel.ts 8217var CampaignStatus = /* @__PURE__ */ function(CampaignStatus) { 8218 CampaignStatus["ACTIVE"] = "ACTIVE"; 8219 CampaignStatus["PAUSED"] = "PAUSED"; 8220 CampaignStatus["SCHEDULED"] = "SCHEDULED"; 8221 CampaignStatus["ENDED"] = "ENDED"; 8222 CampaignStatus["DRAFT"] = "DRAFT"; 8223 return CampaignStatus; 8224}({}); 8225var CampaignPricingModel = /* @__PURE__ */ function(CampaignPricingModel) { 8226 CampaignPricingModel["CPM"] = "CPM"; 8227 CampaignPricingModel["CPCV"] = "CPCV"; 8228 return CampaignPricingModel; 8229}({}); 8230var campaignStatusDisplay = { 8231 ["ACTIVE"]: { 8232 label: "Active", 8233 color: "success", 8234 variant: "standard" 8235 }, 8236 ["PAUSED"]: { 8237 label: "Paused", 8238 color: "warning", 8239 variant: "standard" 8240 }, 8241 ["SCHEDULED"]: { 8242 label: "Scheduled", 8243 color: "info", 8244 variant: "standard" 8245 }, 8246 ["ENDED"]: { 8247 label: "Ended", 8248 color: "default", 8249 variant: "outlined" 8250 }, 8251 ["DRAFT"]: { 8252 label: "Draft", 8253 color: "default", 8254 variant: "dashed" 8255 } 8256}; 8257/** 8258* The status is not stored: it follows from the switch and the booked flights, so it is derived here, once, 8259* and never held as a column. The order matters - a paused campaign reads as paused whatever its dates say. 8260*/ 8261var deriveCampaignStatus = (enabled, flights, today) => { 8262 if (!enabled) return "PAUSED"; 8263 if (flights.length === 0) return "DRAFT"; 8264 if (flights.every((flight) => flight.startDate > today)) return "SCHEDULED"; 8265 if (flights.every((flight) =>
8265 flight.endDate < today)) return "ENDED"; 8266 return "ACTIVE"; 8267}; 8268var getBookedBudget = (campaign) => campaign.flights.reduce((total, flight) => total + flight.budget, 0); 8269var getImpressionsGoal = (campaign) => campaign.flights.reduce((total, flight) => total + flight.impressionsGoal, 0); 8270var getFlightRange = (campaign) => { 8271 if (campaign.flights.length === 0) return null; 8272 const starts = campaign.flights.map((flight) => flight.startDate).sort(); 8273 const ends = campaign.flights.map((flight) => flight.endDate).sort(); 8274 return { 8275 start: starts[0], 8276 end: ends[ends.length - 1] 8277 }; 8278}; 8279var currencyFormatter = new Intl.NumberFormat("en-US", { 8280 style: "currency", 8281 currency: "USD", 8282 maximumFractionDigits: 0 8283}); 8284var compactFormatter = new Intl.NumberFormat("en-US", { 8285 notation: "compact", 8286 maximumFractionDigits: 1 8287}); 8288var dayFormatter = new Intl.DateTimeFormat("en-US", { 8289 month: "short", 8290 day: "numeric" 8291}); 8292var formatCurrency = (amount) => currencyFormatter.format(amount); 8293var formatCompactNumber = (value) => compactFormatter.format(value); 8294var formatFlightRange = (range) => range ? `${dayFormatter.format(new Date(range.start))} - ${dayFormatter.format(new Date(range.end))}` : "-"; 8295var sortValue = (campaign, key) => { 8296 switch (key) { 8297 case "name": return campaign.name.toLowerCase(); 8298 case "status": return campaignStatusDisplay[campaign.status].label; 8299 case "flight": return getFlightRange(campaign)?.start ?? ""; 8300 case "budget": return getBookedBudget(campaign); 8301 case "impressions": return getImpressionsGoal(campaign); 8302 case "updatedAt": return campaign.updatedAt ?? ""; 8303 } 8304}; 8305var sortCampaigns = (campaigns, key, direction) => { 8306 const factor = direction === "asc" ? 1 : -1; 8307 return [...campaigns].sort((a, b) => { 8308 const left = sortValue(a, key); 8309 const right = sortValue(b, key); 8310 if (left === right) return 0; 8311 return (left < right ? -1 : 1) * factor; 8312 }); 8313}; 8314var filterCampaigns = (campaigns, searchTerm, status) => { 8315 const term = searchTerm.trim().toLowerCase(); 8316 return campaigns.filter((campaign) => { 8317 if (status && campaign.status !== status) return false; 8318 if (!term) return true; 8319 return campaign.name.toLowerCase().includes(term) || campaign.insertionOrder.toLowerCase().includes(term) || campaign.salesforceId.toLowerCase().includes(term); 8320 }); 8321}; 8322var summarizeCampaigns = (campaigns) => ({ 8323 activeCount: campaigns.filter((campaign) => campaign.status === "ACTIVE").length, 8324 totalCount: campaigns.length 8325}); 8326//#endregion 8327//#region client/components/newNav/components/ads/campaigns/useAdsSpaceCampaigns.ts 8328init_networkStatus(); 8329var toFlight = (flight) => { 8330 const impressionsGoal = flight.impressions ?? 0; 8331 const customerCpm = flight.CPM ?? 0; 8332 return { 8333 id: flight.id, 8334 startDate: flight.startDate, 8335 endDate: flight.endDate, 8336 budget: Math.round(impressionsGoal / 1e3 * customerCpm), 8337 impressionsGoal, 8338 totalCompletedViews: flight.completedViews ?? 0, 8339 customerCpm, 8340 customerCpcv: flight.CPCV ?? 0, 8341 margin: flight.margin ?? 0, 8342 insertionOrder: flight.insertionOrder ?? "" 8343 }; 8344}; 8345var toListItem = (campaign, today) => { 8346 const flights = campaign.flights.map(toFlight); 8347 return { 8348 id: campaign.id, 8349 name: campaign.name, 8350 insertionOrder: campaign.insertionOrder ?? "", 8351 salesforceId: campaign.salesforceId ?? "", 8352 status: deriveCampaignStatus(campaign.enabled, flights, today), 8353 pricingModel: campaign.isCPCV ? CampaignPricingModel.CPCV : CampaignPricingModel.CPM, 8354 flights, 8355 creativeCount: campaign.creativeCount, 8356 updatedAt: campaign.updatedAt ?? "" 8357 }; 8358}; 8359/** The account's campaigns, straight from the ads-space schema. `error` covers "the ads DB is unreachable". */ 8360var useAdsSpaceCampaigns = () => { 8361 const { data, loading, error, refetch, networkStatus } = useCurrentAccountAdsSpaceCampaignsQuery({ notifyOnNetworkStatusChange: true }); 8362 const rows = data?.currentAccount?.adsSpace?.campaigns; 8363 const decoupledRows = data?.currentAccount?.adsSpace?.decoupledCampaigns; 8364 const programs = data?.currentAccount?.adsSpace?.studioPrograms; 8365 return { 8366 campaigns: (0, import_react.useMemo)(() => { 8367 const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); 8368 return (rows ?? []).map((campaign) => toListItem(campaign, today)); 8369 }, [rows]), 8370 decoupledCampaigns: (0, import_react.useMemo)(() => { 8371 const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); 8372 return (decoupledRows ?? []).map((campaign) => toListItem(campaign, today)); 8373 }, [decoupledRows]), 8374 studioPrograms: (0, import_react.useMemo)(() => (programs ?? []).map((program) => ({ 8375 value: program.value, 8376 label: program.label 8377 })), [programs]), 8378 loading, 8379 refetching: networkStatus === NetworkStatus.refetch, 8380 error: Boolean(error), 8381 refetch 8382 }; 8383}; 8384//#endregion
8385//#region client/components/newNav/components/ads/common/AdsEntityRowsSection.tsx 8386var import_faTriangleExclamation = require_faTriangleExclamation(); 8387/** 8388* A collection of configuration rows on real data, with the space's save contract around it: edits stay local, 8389* the pinned bar counts them, Save asks for the change comment, Cancel and leaving confirm. Every settings and 8390* system collection uses this - the only thing that differs between them is the field descriptors. 8391*/ 8392var AdsEntityRowsSection = (props) => { 8393 const { rows: savedRows, loading, error, saving, save } = useAdsSpaceEntities(props.kind, props.parentId); 8394 const [rows, setRows] = (0, import_react.useState)([]); 8395 const [isCommentOpen, setIsCommentOpen] = (0, import_react.useState)(false); 8396 const [isDiscardOpen, setIsDiscardOpen] = (0, import_react.useState)(false); 8397 (0, import_react.useEffect)(() => setRows(savedRows), [savedRows]); 8398 const changeCount = (0, import_react.useMemo)(() => countRowChanges(rows, savedRows), [rows, savedRows]); 8399 const guard = useAdsUnsavedChangesGuard(changeCount > 0 && !saving && !props.readOnly); 8400 const persist = async (comment) => { 8401 const savedById = new Map(savedRows.map((row) => [row.id, row])); 8402 const changed = rows.filter((row) => JSON.stringify(savedById.get(row.id) ?? null) !== JSON.stringify(row)); 8403 const deletedIds = savedRows.filter((saved) => !rows.some((row) => row.id === saved.id)).map((row) => row.id); 8404 try { 8405 await save(changed, comment, deletedIds.length > 0 ? deletedIds : void 0); 8406 } catch {} finally { 8407 setIsCommentOpen(false); 8408 } 8409 }; 8410 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8411 sx: sectionSx$8, 8412 children: [ 8413 error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 8414 color: "error", 8415 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "The ads configuration is unreachable" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "This list could not be read, so it is not the whole picture." })] 8416 }), 8417 props.children, 8418 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 8419 sx: bodySx$1, 8420 children: loading && rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 8421 variant: "rounded", 8422 height: 160 8423 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 8424 title: props.title, 8425 description: props.description, 8426 fields: props.fields, 8427 columns: props.columns, 8428 rows, 8429 addLabel: props.addLabel, 8430 emptyText: props.emptyText, 8431 rowTitle: props.rowTitle, 8432 readOnly: props.readOnly, 8433 onChange: setRows, 8434 validateRow: props.validateRow, 8435 trackingIdPrefix: props.trackingIdPrefix 8436 }) 8437 }), 8438 !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveBar, { 8439 changeCount, 8440 saving, 8441 onCancel: () => setIsDiscardOpen(true), 8442 onSave: () => setIsCommentOpen(true), 8443 trackingIdPrefix: props.trackingIdPrefix 8444 }), 8445 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 8446 open: isCommentOpen, 8447 summary: changeCount === 1 ? "1 row changed" : `${changeCount} rows changed`, 8448 saving, 8449 onCancel: () => setIsCommentOpen(false), 8450 onConfirm: (comment) => void persist(comment), 8451 trackingIdPrefix: props.trackingIdPrefix 8452 }), 8453 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiscardChangesDialog, { 8454 open: isDiscardOpen || guard.pendingNavigation, 8455 onContinueEditing: () => { 8456 setIsDiscardOpen(false); 8457 guard.cancelNavigation(); 8458 }, 8459 onDiscard: () => { 8460 setRows(savedRows); 8461 setIsDiscardOpen(false); 8462 guard.confirmNavigation(); 8463 } 8464 }) 8465 ] 8466 }); 8467}; 8468var sectionSx$8 = { 8469 display: "flex", 8470 flexDirection: "column", 8471 gap: 3, 8472 flex: 1, 8473 minHeight: 0 8474}; 8475var bodySx$1 = { 8476 flex: 1, 8477 minHeight: 0, 8478 overflow: "auto" 8479}; 8480//#endregion 8481//#region client/components/newNav/components/ads/targeting/GeoListsSection.tsx 8482var geoFilterFields = [ 8483 { 8484 key: "name", 8485 label: "Name", 8486 type: "text", 8487 validate: required("A geography needs a name") 8488 }, 8489 { 8490 key: "definition", 8491 label: "Locations", 8492 type: "geoDefinition", 8493 wide: true, 8494 validate: (value) => { 8495 const definition = asText(value); 8496 if (!definition.trim()) return "A geography with no locations constrains nothing"; 8497 return definition.length > 1024 ? `Only ${GEO_DEFINITION_MAX_LENGTH} characters are stored - use a CSV file for a list this long` : null; 8498 } 8499 }, 8500 {
8501 key: "description", 8502 label: "Description", 8503 type: "longText", 8504 wide: true, 8505 help: "Why this geography exists. Whoever edits it in six months is not the person who made it." 8506 } 8507]; 8508var validateGeoFilterRow = (row, otherRows) => otherRows.some((other) => asText(other.name).trim().toLowerCase() === asText(row.name).trim().toLowerCase()) ? { name: "This account already has a geography with that name" } : {}; 8509/** 8510* The account's saved geographies. A campaign points at these rather than carrying a location filter of its own, 8511* so a zip list lives in exactly one place and correcting it corrects every campaign that uses it at once. 8512*/ 8513var GeoListsSection = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEntityRowsSection, { 8514 kind: GqlClientAdsSpaceEntityKind.GEO_FILTER, 8515 title: "Geographies", 8516 description: "Where campaigns may serve. Campaign targeting and budget lanes both pick from this list.", 8517 fields: geoFilterFields, 8518 columns: [ 8519 { 8520 key: "name", 8521 label: "Geography" 8522 }, 8523 { 8524 key: "definition", 8525 label: "Locations", 8526 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8527 variant: "body1", 8528 children: describeGeoDefinition(asText(row.definition)) 8529 }) 8530 }, 8531 { 8532 key: "definitionRaw", 8533 label: "Reads as", 8534 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 8535 value: asText(row.definition), 8536 monospace: true, 8537 maxWidth: 280, 8538 trackingId: "tracking-id-ads-targeting-geo-lists-definition-copy" 8539 }) 8540 }, 8541 { 8542 key: "description", 8543 label: "Description" 8544 } 8545 ], 8546 addLabel: "New geography", 8547 emptyText: "No geographies defined yet", 8548 rowTitle: (row) => asText(row.name), 8549 validateRow: validateGeoFilterRow, 8550 trackingIdPrefix: "tracking-id-ads-targeting-geo-lists", 8551 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 8552 color: "info", 8553 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 8554 variant: "body1", 8555 color: "text.secondary", 8556 children: [ 8557 "A request has to satisfy every location type in a geography, so \"state = MA\" and \"city = Boston\" together mean Boston, not either one. Two separate trade areas are two geographies, both attached to the campaign.", 8558 " ", 8559 "Layers work the same way: a DMA minus a handful of zips is one geography with DMA set to \"only these\" and Zip code set to \"anywhere but these\". Serve-only geographies attached to a campaign are unioned; never-serve ones are subtracted from the result." 8560 ] 8561 }) }) 8562 }) 8563}); 8564//#endregion 8565//#region client/components/newNav/components/ads/targeting/AdsNewGeographyDialog.tsx 8566/** 8567* Creating an account geography from a screen that asks for one. Geographies save through their own mutation, 8568* so making one mid-edit does not commit the campaign - the pending campaign edits stay pending. 8569*/ 8570var AdsNewGeographyDialog = ({ open, initialRow, onClose, onCreated }) => { 8571 const { rows, create } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.GEO_FILTER); 8572 const handleSubmit = async (row) => { 8573 const created = await create(row, `Created geography ${asText(row.name)}`); 8574 onClose(); 8575 if (created) onCreated(created.id); 8576 }; 8577 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 8578 open, 8579 title: "New geography", 8580 fields: geoFilterFields, 8581 row: initialRow ?? null, 8582 otherRows: rows, 8583 onClose, 8584 onSubmit: (row) => void handleSubmit(row).catch(() => void 0), 8585 trackingIdPrefix: "tracking-id-ads-new-geography" 8586 }); 8587}; 8588//#endregion 8589//#region common/adsBudgetLanes.ts 8590/** Shares are doubles, so a 33.3/33.3/33.4 split has to be allowed to be 100. */ 8591var SHARE_EPSILON = .001; 8592var totalSharePercent = (lanes) => lanes.reduce((total, lane) => total + lane.sharePercent, 0); 8593/** Trailing float noise makes a message read as nonsense: "adds up to 99.99999999%". */ 8594var describeShare = (share) => String(Number(share.toFixed(2))); 8595/** 8596* The one reason this set of lanes cannot be saved, or null. Both callers show it verbatim, so it names the 8597* fix rather than the rule it broke. 8598*/ 8599var budgetLanesProblem = (lanes) => { 8600 if (lanes.length === 0) return null; 8601 if (lanes.filter((lane) => !lane.name.trim()).length >
8601 0) return "Every budget lane needs a name - it becomes part of the pacing key the ad server delivers against."; 8602 const repeated = lanes.map((lane) => lane.name.trim()).find((name, index, names) => names.indexOf(name) !== index); 8603 if (repeated) return `Two lanes are named "${repeated}". Each lane needs its own name - the name is what its delivery is filed under.`; 8604 const catchAll = lanes.filter((lane) => !lane.isTargeted); 8605 if (catchAll.length === 0) return "These lanes have no catch-all lane, so a request matching none of them would be dropped. Exactly one lane must have no targeting of its own."; 8606 if (catchAll.length > 1) return `${catchAll.length} lanes have no targeting of their own. Only one lane can be the catch-all - give the others a geography or remove them.`; 8607 if (lanes.some((lane) => lane.laneIdx > catchAll[0].laneIdx)) return "The catch-all lane has to be last. It matches everything, so any lane ordered after it would never be used."; 8608 const total = totalSharePercent(lanes); 8609 if (Math.abs(total - 100) > SHARE_EPSILON) return total > 100 ? `The budget lanes claim ${describeShare(total)}% of the goal. They have to come to exactly 100%.` : `The budget lanes only account for ${describeShare(total)}% of the goal. They have to come to exactly 100%.`; 8610 return null; 8611}; 8612//#endregion 8613//#region client/components/newNav/components/ads/campaigns/budgetLanes.ts 8614/** 8615* Budget lanes split one campaign's booked goal between named slices of its traffic. 8616* 8617* "Give Boston 40% of this campaign" is otherwise a second campaign with its own flight, its own budget worked 8618* out by hand, and two sets of settings to keep in step. A lane keeps the booking whole: it claims a share of the
8619* same goal and names the geography that routes a request into it. 8620* 8621* Exactly one lane is the fallback. It names no geography and its share is not chosen - it is whatever the 8622* targeted lanes leave, which is why a split can never fail to add up to 100%. 8623*/ 8624var FALLBACK_LANE_NAME = "fallback"; 8625/** 8626* A lane with no predicate at all matches whatever the lanes above it did not. Both halves have to be empty: 8627* the ads platform resolves a lane as geography AND audience over whichever is set, so an audience-only lane 8628* is targeted, not the catch-all. 8629*/ 8630var isFallbackLane = (lane) => !asText(lane.geoFilterId) && !asText(lane.audienceId); 8631var getTargetedLanes = (lanes) => lanes.filter((lane) => !isFallbackLane(lane)); 8632var getFallbackLane = (lanes) => lanes.find(isFallbackLane); 8633var getLaneShare = (lane) => Number(asText(lane.sharePercent)) || 0; 8634/** A lane's own margin override, or null when it inherits. Empty is the common case, so it is not a zero. */ 8635var getLaneMargin = (lane) => { 8636 const text = asText(lane.margin).trim(); 8637 if (!text) return null; 8638 const parsed = Number(text); 8639 return Number.isFinite(parsed) ? parsed : null; 8640}; 8641/** 8642* What a lane without an override of its own runs at. Only answerable when every flight agrees on one 8643* margin - with none set, or several disagreeing, the lane still inherits but this cannot name the number. 8644*/ 8645var inheritedMargin = (flights) => { 8646 const distinct = [...new Set(flights.map((flight) => { 8647 const text = asText(flight.margin).trim(); 8648 return text && Number.isFinite(Number(text)) ? Number(text) : null; 8649 }))]; 8650 return distinct.length === 1 && distinct[0] !== null ? distinct[0] : null; 8651}; 8652/** Two decimal places, which is what the column holds and therefore the limit of any exact figure typed here. */ 8653var roundToStoredPrecision = (share) => Math.round(share * 100) / 100; 8654/** What the named lanes claim between them. Above 100 the campaign is over-allocated. */ 8655var getTargetedSharePercent = (lanes) => getTargetedLanes(lanes).reduce((total, lane) => total + getLaneShare(lane), 0); 8656/** 8657* Rounded to the two decimal places the column holds. A share entered as impressions or spend is rarely a whole 8658* number, and the raw subtraction leaves floating-point dust that renders as "33.900000000000006%". 8659*/ 8660var getFallbackSharePercent = (lanes) => roundToStoredPrecision(Math.max(0, 100 - getTargetedSharePercent(lanes))); 8661var getLaneImpressions = (share, impressionsGoal) => Math.round(impressionsGoal * share / 100); 8662/** 8663* What the split is entered in. The stored column is always a percentage - it is what the ad server reads - so 8664* the other two units are converted on the way in and derived on the way out. 8665*/ 8666var BudgetLaneUnit = /* @__PURE__ */ function(BudgetLaneUnit) { 8667 BudgetLaneUnit["SHARE"] = "SHARE"; 8668 BudgetLaneUnit["IMPRESSIONS"] = "IMPRESSIONS"; 8669 BudgetLaneUnit["SPEND"] = "SPEND"; 8670 return BudgetLaneUnit; 8671}({}); 8672var budgetLaneUnitLabels = { 8673 ["SHARE"]: "Share (%)", 8674 ["IMPRESSIONS"]: "Impressions", 8675 ["SPEND"]: "Spend (USD)" 8676}; 8677var asStoredShare = (share) => roundToStoredPrecision(Math.max(0, Math.min(100, share))); 8678/** The campaign's CPM, weighted by each flight's impressions. Null when no flight prices its impressions. */ 8679var weightedCampaignCpm = (flights) => { 8680 let impressions = 0; 8681 let spend = 0; 8682 flights.forEach((flight) => { 8683 const goal = Number(asText(flight.totalImpressions)) || 0; 8684 const cpm = Number(asText(flight.customerCpm)) || 0; 8685 impressions += goal; 8686 spend += goal / 1e3 * cpm; 8687 }); 8688 return impressions > 0 && spend > 0 ? spend / impressions * 1e3 : null; 8689}; 8690var getLaneSpend = (share, impressionsGoal, cpm) => cpm === null ? null : getLaneImpressions(share, impressionsGoal) / 1e3 * cpm; 8691var shareFromImpressions = (impressions, impressionsGoal) => impressionsGoal > 0 ? asStoredShare(impressions / impressionsGoal * 100) : 0; 8692var shareFromSpend = (spend, impressionsGoal, cpm) => cpm && cpm > 0 ? shareFromImpressions(spend / cpm * 1e3, impressionsGoal) : 0; 8693var describeLaneGeography = (lane, geoOptions) => isFallbackLane(lane) ? "Everyone the lanes above did not match" : findEntityLabel(geoOptions, asText(lane.geoFilterId)); 8694/** 8695* Lanes go over the wire as they are ordered here, and the ad server takes the first one a request matches, so 8696* the fallback has to be last. Its share is recomputed on every edit rather than typed, and the row exists so the 8697* remainder is a real allocation the ad server can serve against rather than money quietly left unspent. 8698*/ 8699var normalizeLanes = (targeted, existingFallback) => { 8700 const ordered = targeted.map((lane, index) => ({ 8701 ...lane, 8702 laneIdx: String(index), 8703 enabled: true 8704 })); 8705 if (ordered.length === 0) return []; 8706 const fallback = existingFallback ?? { 8707 id: `lane-fallback-${Date.now()}`, 8708 name: FALLBACK_LANE_NAME 8709 }; 8710 return [...ordered, { 8711 ...fallback, 8712 name: asText(fallback.name) || FALLBACK_LANE_NAME, 8713 geoFilterId: "", 8714 enabled: true, 8715 laneIdx: String(ordered.length), 8716 sharePercent: String(getFallbackSharePercent(ordered)) 8717 }]; 8718}; 8719var toLaneShapes = (lanes) => lanes.map((lane, index) => ({ 8720 name: asText(lane.name), 8721 laneIdx: Number(asText(lane.laneIdx)) || index, 8722 sharePercent: getLaneShare(lane), 8723 isTargeted: !isFallbackLane(lane) 8724})); 8725/** 8726* Judged on the rows as they would be written, not on the remainder the card derives - a campaign whose stored 8727* fallback share was left behind by an earlier edit has to be fixed rather than displayed as though it were fine. 8728*/ 8729var getLanesError = (lanes) => budgetLanesProblem(toLaneShapes(lanes)); 8730//#endregion
8731//#region client/components/newNav/components/ads/campaigns/BudgetLaneBar.tsx 8732/** 8733* The campaign goal laid out end to end, one block per lane. The fallback block is drawn differently because it 8734* is not a share anyone chose - it is whatever the named lanes did not claim. 8735*/ 8736var BudgetLaneBar = ({ lanes, impressionsGoal, geoOptions }) => { 8737 const targetedLanes = getTargetedLanes(lanes); 8738 const targeted = getTargetedSharePercent(lanes); 8739 const fallback = getFallbackLane(lanes); 8740 const fallbackShare = fallback ? getLaneShare(fallback) : 0; 8741 const overAllocated = targeted > 100; 8742 const segments = [...targetedLanes.map((lane) => ({ 8743 key: lane.id, 8744 name: asText(lane.name), 8745 share: getLaneShare(lane), 8746 matches: describeLaneGeography(lane, geoOptions), 8747 fallback: false 8748 })), { 8749 key: "fallback", 8750 name: fallback ? asText(fallback.name) : "Catch-all", 8751 share: fallbackShare, 8752 matches: "Everyone the lanes above did not match", 8753 fallback: true 8754 }]; 8755 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8756 sx: wrapperSx$1, 8757 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 8758 sx: barSx$1, 8759 children: segments.filter((segment) => segment.share > 0).map((segment) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 8760 title: `${segment.name}: ${segment.share}% of the goal (${formatCompactNumber(getLaneImpressions(segment.share, impressionsGoal))} impressions) - ${segment.matches}`, 8761 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 8762 sx: laneSegmentSx(segment.fallback, Math.min(segment.share, 100)), 8763 children: segment.share >
8763 10 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8764 variant: "caption", 8765 sx: laneLabelSx(segment.fallback), 8766 children: `${segment.name} ${segment.share}%` 8767 }) 8768 }) 8769 }, segment.key)) 8770 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8771 sx: legendSx$1, 8772 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8773 variant: "helper", 8774 color: "text.secondary", 8775 children: `Targeted lanes: ${targeted}%` 8776 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8777 variant: "helper", 8778 color: overAllocated ? "error.main" : "text.secondary", 8779 children: overAllocated ? `Over-allocated by ${targeted - 100}% - the catch-all lane has no share left` : `Catch-all lane: ${fallbackShare}%` 8780 })] 8781 })] 8782 }); 8783}; 8784var wrapperSx$1 = { 8785 display: "flex", 8786 flexDirection: "column", 8787 gap: .5, 8788 width: "100%" 8789}; 8790var barSx$1 = { 8791 display: "flex", 8792 height: 32, 8793 borderRadius: "8px", 8794 overflow: "hidden", 8795 backgroundColor: "grey.200" 8796}; 8797var laneSegmentSx = (fallback, share) => ({ 8798 width: `${share}%`, 8799 backgroundColor: (theme) => fallback ? (0, import_material.alpha)(theme.palette.primary.main, .3) : theme.palette.primary.main, 8800 borderRight: 1, 8801 borderColor: "background.paper", 8802 display: "flex", 8803 alignItems: "center", 8804 justifyContent: "center", 8805 overflow: "hidden", 8806 minWidth: 0 8807}); 8808var laneLabelSx = (fallback) => ({ 8809 color: fallback ? "primary.dark" : "primary.contrastText", 8810 whiteSpace: "nowrap", 8811 paddingX: .5 8812}); 8813var legendSx$1 = { 8814 display: "flex", 8815 justifyContent: "space-between", 8816 gap: 1 8817}; 8818//#endregion 8819//#region client/components/newNav/components/ads/campaigns/BudgetLanesSection.tsx
8820var CATCH_ALL_REMOVE_REASON = "You cannot remove the catch-all lane. Without it, traffic matching none of the lanes above would have no lane at all."; 8821var CATCH_ALL_HELP = "The catch-all lane bids and serves like any other lane. It is not a holdout - traffic landing here still sees a video."; 8822var ROUNDING_NOTE = "Stored as a percentage with two decimal places, so an exact impression count may land one or two impressions out."; 8823/** The virtual keys the impressions and spend units are typed under. Neither is stored - both become a share. */ 8824var IMPRESSIONS_KEY = "laneImpressions"; 8825var SPEND_KEY = "laneSpend"; 8826/** 8827* Splits the campaign's booked impressions between named slices of its traffic. The catch-all lane sits in the 8828* table as a row like any other, because it is the same kind of thing - the only differences are that the table 8829* maintains its share rather than a person typing it, and it cannot be removed. 8830*/ 8831var BudgetLanesSection = (props) => { 8832 const [isCreatingGeography, setIsCreatingGeography] = (0, import_react.useState)(false); 8833 const [unit, setUnit] = (0, import_react.useState)(BudgetLaneUnit.SHARE); 8834 const fallback = getFallbackLane(props.lanes); 8835 const lanesError = getLanesError(props.lanes); 8836 const isStarved = Boolean(fallback) && getLaneShare(fallback) === 0; 8837 const marginField = { 8838 key: "margin", 8839 label: "Margin (%)", 8840 type: "number", 8841 min: 0, 8842 max: 100, 8843 help: props.inheritedMargin === null ? "Leave empty to use the flight's margin. Set one only when this slice prices differently." : `Leave empty to use the flight's ${props.inheritedMargin}%. Set one only when this slice prices differently.`, 8844 validate: inRange(0, 100, "A margin runs from 0 to 100") 8845 }; 8846 const shareField = unit === BudgetLaneUnit.SHARE ? { 8847 key: "sharePercent", 8848 label: "Share of the goal (%)", 8849 type: "number", 8850 min: 0, 8851 max: 100, 8852 help: `Whatever these lanes leave goes to the catch-all lane. ${ROUNDING_NOTE}`, 8853 validate: inRange(0, 100, "A share runs from 0 to 100") 8854 } : unit === BudgetLaneUnit.IMPRESSIONS ? { 8855 key: IMPRESSIONS_KEY, 8856 label: "Impressions", 8857 type: "number", 8858 min: 0, 8859 max: props.impressionsGoal, 8860 help: `Of the campaign's ${props.impressionsGoal.toLocaleString()} booked. ${ROUNDING_NOTE}`, 8861 validate: inRange(0, props.impressionsGoal, `The campaign has ${props.impressionsGoal.toLocaleString()} impressions to split`) 8862 } : { 8863 key: SPEND_KEY, 8864 label: "Spend (USD)", 8865 type: "number", 8866 min: 0, 8867 help: props.customerCpm === null ? "No flight prices this campaign's impressions yet, so a spend cannot be converted to a share." : `At the campaign's $${props.customerCpm.toFixed(2)} CPM. ${ROUNDING_NOTE}`, 8868 disabledWhen: () => props.customerCpm === null 8869 }; 8870 const laneFields = [ 8871 { 8872 key: "name", 8873 label: "Lane name", 8874 type: "text", 8875 help: "Letters, digits, hyphen and underscore only - it becomes part of the pacing key.", 8876 validate: (value) => required("A lane needs a name")(value) ?? matches(/^[A-Za-z0-9_-]+$/, "Use letters, digits, hyphens and underscores only")(value) 8877 }, 8878 { 8879 key: "geoFilterId", 8880 label: "Geography", 8881 type: "select", 8882 options: props.geoOptions, 8883 wide: true, 8884 help: "One of the account's saved geographies. Lanes are matched top to bottom and the first match wins." 8885 }, 8886 shareField, 8887 marginField 8888 ]; 8889 const validateLaneRow = (row, otherRows) => { 8890 if (fallback && row.id === fallback.id) return {}; 8891 const otherTargeted = getTargetedLanes(otherRows); 8892 if (!asText(row.geoFilterId)) return { geoFilterId: "Choose the geography this lane serves" }; 8893 if (otherTargeted.some((other) => asText(other.geoFilterId) === asText(row.geoFilterId))) return { geoFilterId: "Another lane already covers that geography. A request only ever lands in one lane." }; 8894 const total = otherTargeted.reduce((sum, other) => sum + getLaneShare(other), 0) + getLaneShare(row); 8895 return total > 100 ? { sharePercent: `These lanes would claim ${total}% of the goal, leaving the catch-all lane nothing` } : {}; 8896 }; 8897 /** A unit with nothing to convert against cannot be picked, rather than being pickable and inert. */ 8898 const isUnitUnavailable = (option) => option === BudgetLaneUnit.IMPRESSIONS && props.impressionsGoal <= 0 || option === BudgetLaneUnit.SPEND && props.customerCpm === null; 8899 /** Fills the virtual key the current unit is typed under, so the edit dialog opens on the right number. */ 8900 const withUnitValues = (lane) => { 8901 const share = getLaneShare(lane); 8902 const spend = getLaneSpend(share, props.impressionsGoal, props.customerCpm); 8903 return { 8904 ...lane, 8905 [IMPRESSIONS_KEY]: String(getLaneImpressions(share, props.impressionsGoal)), 8906 [SPEND_KEY]: spend === null ? "" : String(Math.round(spend)) 8907 }; 8908 }; 8909 /** 8910 * The reverse: whatever unit was typed becomes the percentage the ad server reads. A unit with nothing to 8911 * convert against - no booked impressions, or no CPM - leaves the stored share alone, because deriving it 8912 * from a blank box would silently zero every lane the moment any row is edited. 8913 */ 8914 const toStoredShare = (lane) => { 8915 const { [IMPRESSIONS_KEY]: typedImpressions, [SPEND_KEY]: typedSpend, ...stored } = lane; 8916 if (unit === BudgetLaneUnit.SHARE || isFallbackLane(lane)) return stored; 8917 const isImpressions = unit === BudgetLaneUnit.IMPRESSIONS; 8918 const typed = asText(isImpressions ? typedImpressions : typedSpend); 8919 const canConvert = isImpressions ? props.impressionsGoal > 0 : Boolean(props.customerCpm); 8920 if (!typed || !canConvert) return stored; 8921 const share = isImpressions ? shareFromImpressions(Number(typed) || 0, props.impressionsGoal) : shareFromSpend(Number(typed) || 0, props.impressionsGoal, props.customerCpm); 8922 return { 8923 ...stored, 8924 sharePercent: String(share) 8925 }; 8926 }; 8927 const renderLaneName = (row) => { 8928 if (!isFallbackLane(row)) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8929 variant: "body1", 8930 children: asText(row.name) 8931 }); 8932 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8933 sx: laneNameSx, 8934 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8935 sx: laneTitleSx, 8936 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8937 variant: "body1", 8938 children: asText(row.name) 8939 }
8939), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 8940 size: "small", 8941 color: "default", 8942 variant: "outlined", 8943 label: "Catch-all" 8944 })] 8945 }), isStarved && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8946 sx: warningLineSx, 8947 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 8948 fontSize: "small", 8949 color: "warning", 8950 sx: warningIconSx, 8951 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTriangleExclamation.faTriangleExclamation }) 8952 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8953 variant: "helper", 8954 color: "warning.main", 8955 children: "0% share, so this lane never bids and its traffic gets no ad." 8956 })] 8957 })] 8958 }); 8959 }; 8960 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8961 sx: sectionSx$7, 8962 children: [ 8963 props.lanes.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BudgetLaneBar, { 8964 lanes: props.lanes, 8965 impressionsGoal: props.impressionsGoal, 8966 geoOptions: props.geoOptions 8967 }), 8968 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 8969 sx: unitRowSx, 8970 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleToggleButtonGroup, { 8971 exclusive: true, 8972 size: "small", 8973 value: unit, 8974 onChange: (_event, next) => next && setUnit(next), 8975 children: Object.values(BudgetLaneUnit).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 8976 value: option, 8977 disabled: isUnitUnavailable(option), 8978 "data-tracking-id": `tracking-id-ads-campaign-lanes-unit-${option.toLowerCase()}-btn`, 8979 children: budgetLaneUnitLabels[option] 8980 }, option)) 8981 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8982 variant: "helper", 8983 color: "text.secondary", 8984 children: ROUNDING_NOTE 8985 })] 8986 }), 8987 lanesError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 8988 color: "error", 8989 "data-testid": "ads-campaign-lanes-error", 8990 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 8991 variant: "body1", 8992 color: "text.secondary", 8993 children: lanesError 8994 }) }) 8995 }), 8996 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 8997 title: "Budget lanes", 8998 description: `Split the booked impressions between geographies instead of running one campaign per region. The catch-all lane takes whatever the others leave. ${CATCH_ALL_HELP}`, 8999 fields: laneFields, 9000 columns: [ 9001 { 9002 key: "name", 9003 label: "Lane", 9004 render: renderLaneName 9005 }, 9006 { 9007 key: "geoFilterId", 9008 label: "Geography", 9009 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9010 variant: "body1", 9011 children: describeLaneGeography(row, props.geoOptions) 9012 }) 9013 }, 9014 { 9015 key: "sharePercent", 9016 label: "Share", 9017 align: "right", 9018 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9019 variant: "body1", 9020 color: isFallbackLane(row) && isStarved ? "warning.main" : void 0, 9021 children: `${getLaneShare(row)}%` 9022 }) 9023 }, 9024 { 9025 key: "impressions", 9026 label: "Impressions", 9027 align: "right", 9028 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9029 variant: "body1", 9030 noWrap: true, 9031 color: isFallbackLane(row) && isStarved ? "warning.main" : void 0, 9032 children: formatCompactNumber(getLaneImpressions(getLaneShare(row), props.impressionsGoal)) 9033 }) 9034 }, 9035 { 9036 key: SPEND_KEY, 9037 label: "Spend", 9038 align: "right", 9039 render: (row) => { 9040 const spend = getLaneSpend(getLaneShare(row), props.impressionsGoal, props.customerCpm); 9041 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9042 variant: "body1", 9043 noWrap: true, 9044 color: isFallbackLane(row) && isStarved ? "warning.main" : void 0, 9045 children: spend === null ? "-" : `$${Math.round(spend).toLocaleStr
9045ing()}` 9046 }); 9047 } 9048 }, 9049 { 9050 key: "margin", 9051 label: "Margin", 9052 align: "right", 9053 render: (row) => { 9054 const own = getLaneMargin(row); 9055 if (own !== null) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9056 variant: "body1", 9057 noWrap: true, 9058 children: `${own}%` 9059 }); 9060 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9061 variant: "body1", 9062 noWrap: true, 9063 color: "text.secondary", 9064 children: props.inheritedMargin === null ? "inherited" : `${props.inheritedMargin}% inherited` 9065 }); 9066 } 9067 } 9068 ], 9069 rows: props.lanes.map(withUnitValues), 9070 addLabel: "Add lane", 9071 emptyText: "No lanes - the whole goal is spent across the campaign's targeting", 9072 rowTitle: (row) => asText(row.name), 9073 readOnly: props.readOnly, 9074 validateRow: validateLaneRow, 9075 lockedRow: (row) => isFallbackLane(row) ? { remove: CATCH_ALL_REMOVE_REASON } : null, 9076 rowFields: (row) => isFallbackLane(row) ? [marginField] : laneFields, 9077 onChange: (rows) => { 9078 const stored = rows.map(toStoredShare); 9079 props.onChange(normalizeLanes(getTargetedLanes(stored), getFallbackLane(stored))); 9080 }, 9081 headerActions: props.readOnly ? void 0 : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 9082 variant: "text", 9083 size: "medium", 9084 onClick: () => setIsCreatingGeography(true), 9085 "data-testid": "ads-lanes-new-geography-btn", 9086 "data-tracking-id": "tracking-id-ads-lanes-new-geography-btn", 9087 children: "New geography" 9088 }), 9089 trackingIdPrefix: "tracking-id-ads-campaign-lanes" 9090 }), 9091 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsNewGeographyDialog, { 9092 open: isCreatingGeography, 9093 onClose: () => setIsCreatingGeography(false), 9094 onCreated: () => setIsCreatingGeography(false) 9095 }) 9096 ] 9097 }); 9098}; 9099var sectionSx$7 = { 9100 display: "flex", 9101 flexDirection: "column", 9102 gap: 2 9103}; 9104var unitRowSx = { 9105 display: "flex", 9106 alignItems: "center", 9107 gap: 2, 9108 flexWrap: "wrap" 9109}; 9110var laneNameSx = { 9111 display: "flex", 9112 flexDirection: "column", 9113 gap: .25, 9114 minWidth: 150 9115}; 9116var laneTitleSx = { 9117 display: "flex", 9118 alignItems: "center", 9119 gap: 1 9120}; 9121var warningLineSx = { 9122 display: "flex", 9123 alignItems: "flex-start", 9124 gap: .5 9125}; 9126var warningIconSx = { 9127 marginTop: "1px", 9128 flexShrink: 0 9129}; 9130//#endregion 9131//#region client/components/newNav/components/ads/targeting/audienceModel.ts 9132var AudienceEvidence = /* @__PURE__ */ function(AudienceEvidence) { 9133 AudienceEvidence["DMP"] = "DMP_SEGMENT"; 9134 AudienceEvidence["SITE"] = "SITE_VISITOR"; 9135 AudienceEvidence["IMPRESSION"] = "IMPRESSION"; 9136 return AudienceEvidence; 9137}({}); 9138/** A rule's value is split on commas by the ad server whatever the operator, so every value is a list. */ 9139var ruleValues = (value) => (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean); 9140var DMP_SEGMENT_FIELD = "segment"; 9141var IMPRESSION_PROGRAM_FIELD = "program"; 9142/** Site-event field names carried by live audiences, most used first. `url` is the page address. */ 9143var siteVisitorFieldOptions = [ 9144 { 9145 value: "url", 9146 label: "Page URL" 9147 }, 9148 { 9149 value: "Page Type", 9150 label: "Page Type" 9151 }, 9152 { 9153 value: "Existing Subscription", 9154 label: "Existing Subscription" 9155 }, 9156 { 9157 value: "User Segment", 9158 label: "User Segment" 9159 }, 9160 { 9161 value: "Listing Type", 9162 label: "Listing Type" 9163 }, 9164 { 9165 value: "Model", 9166 label: "Model" 9167 }, 9168 { 9169 value: "Category Viewed", 9170 label: "Category Viewed" 9171 }, 9172 { 9173 value: "Product Viewed", 9174 label: "Product Viewed" 9175 }, 9176 { 9177 value: "Business User", 9178 label: "Business User" 9179 }, 9180 { 9181 value: "Product Purchased", 9182 label: "Product Purchased" 9183 }, 9184 { 9185 value: "Shop Name", 9186 label: "Shop Name" 9187 }, 9188 { 9189 value: "Product Carted", 9190 label: "Product Carted" 9191 }, 9192 { 9193 value: "Cart Viewed", 9194 label: "Cart Viewed" 9195 }, 9196 { 9197 value: "Zip Code", 9198 label: "Zip Code" 9199 }, 9200 { 9201 value: "Category Carted", 9202 label: "Category Carted" 9203 } 9204]; 9205/** `ConditionOperator`, phrased for a value that is always a list. */ 9206var siteVisitorOperatorOptions = [ 9207 { 9208 value: "STRING_CONTAINS", 9209 label: "contains any of" 9210 }, 9211 { 9212 value: "STRING_DOES_NOT_CONTAIN",
9213 label: "contains none of" 9214 }, 9215 { 9216 value: "EQUALS", 9217 label: "is any of" 9218 }, 9219 { 9220 value: "DOES_NOT_EQUAL", 9221 label: "is none of" 9222 }, 9223 { 9224 value: "EXISTS", 9225 label: "has any value" 9226 }, 9227 { 9228 value: "DOES_NOT_EXIST", 9229 label: "is missing" 9230 } 9231]; 9232/** The two operators that ask only whether the field is present, so their value box is meaningless. */ 9233var OPERATORS_WITHOUT_VALUE = ["EXISTS", "DOES_NOT_EXIST"]; 9234var evidenceTitle = { 9235 ["DMP_SEGMENT"]: "DMP segments", 9236 ["SITE_VISITOR"]: "Site activity", 9237 ["IMPRESSION"]: "Not already reached" 9238}; 9239var evidenceDescription = { 9240 ["DMP_SEGMENT"]: "Segment ids from the data provider. This is what most audiences are.", 9241 ["SITE_VISITOR"]: "What the user did on the advertiser's own site, as tracked by the SundaySky tag.", 9242 ["IMPRESSION"]: "Leaves out anyone another campaign has already served, so two campaigns do not chase the same person." 9243}; 9244var toDmpSegments = (group) => { 9245 const segments = { 9246 include: [], 9247 exclude: [] 9248 }; 9249 for (const rule of group.rules) { 9250 if (rule.field !== "segment") return null; 9251 if (rule.operator === "EQUALS" || rule.operator === "ONE_OF") segments.include.push(...ruleValues(rule.value)); 9252 else if (rule.operator === "DOES_NOT_EQUAL" || rule.operator === "NOT_ONE_OF") segments.exclude.push(...ruleValues(rule.value)); 9253 else return null; 9254 } 9255 return segments; 9256}; 9257var toExcludedPrograms = (group) => { 9258 const excluded = []; 9259 for (const rule of group.rules) { 9260 if (rule.field !== "program" || rule.operator !== "DOES_NOT_EQUAL") return null; 9261 excluded.push(...ruleValues(rule.value)); 9262 } 9263 return excluded; 9264}; 9265var isKnownEvidence = (ruleType) => Object.values(AudienceEvidence).some((evidence) => evidence === ruleType); 9266/** 9267* Whether this editor can round-trip the audience. Anything it cannot is left to the four-table view rather 9268* than being rewritten into the nearest shape it does understand - which would quietly change who matches. 9269*/ 9270var canEditSimply = (audience) => { 9271 if (!audience.isSimple) return false; 9272 const types = audience.blocks.map((block) => block.ruleType); 9273 if (types.some((type) => !isKnownEvidence(type)) || new Set(types).size !== types.length) return false; 9274 return audience.blocks.every((block) => { 9275 if (block.ruleType === "DMP_SEGMENT") return block.groups.every((group) => toDmpSegments(group) !== null); 9276 if (block.ruleType === "IMPRESSION") return block.groups.length <= 1 && block.groups.every((group) => toExcludedPrograms(group) !== null); 9277 return block.groups.every((group) => group.rules.every((rule) => siteVisitorOperatorOptions.some((option) => option.value === rule.operator))); 9278 }); 9279}; 9280var hasDays = (days) => days !== null && days !== void 0; 9281var describeWindow = (group) => { 9282 if (hasDays(group.maxAgeDays) && hasDays(group.minAgeDays)) return ` seen ${group.minAgeDays}-${group.maxAgeDays} days ago`; 9283 if (hasDays(group.maxAgeDays)) return ` in the last ${group.maxAgeDays} days`; 9284 if (hasDays(group.minAgeDays)) return ` seen at least ${group.minAgeDays} days ago`; 9285 return ""; 9286}; 9287var pluralize = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`; 9288var describeGroup = (ruleType, group) => { 9289 if (ruleType === "DMP_SEGMENT") { 9290 const segments = toDmpSegments(group); 9291 if (segments) { 9292 const excluded = segments.exclude.length > 0 ? `, ${segments.exclude.length} excluded` : ""; 9293 return `${pluralize(segments.include.length, "DMP segment")}${excluded}`; 9294 } 9295 return "DMP segments"; 9296 } 9297 if (ruleType === "IMPRESSION") { 9298 const programs = toExcludedPrograms(group); 9299 return programs ? `not reached by ${programs.join(", ")}` : "impression history"; 9300 } 9301 return `${pluralize(group.rules.length, "site condition")}${describeWindow(group)}`; 9302}; 9303/** Groups are ORed, so the count leads - the ids themselves are unreadable at list width anyway. */ 9304var describeBlock = (block) => { 9305 if (block.groups.length === 0) return "nothing"; 9306 if (block.groups.length === 1) return describeGroup(block.ruleType, block.groups[0]); 9307 return `any of ${block.groups.length} groups (${block.groups.map((group) => describeGroup(block.ruleType, group)).join(" / ")})`; 9308}; 9309/** One line for the list, so a name is not the only thing distinguishing two audiences. */ 9310var describeAudience = (audience) => audience.blocks.length === 0 ? "Matches nobody" : audience.blocks.map(describeBlock).join(" and "); 9311//#endregion
9312//#region client/components/newNav/components/ads/targeting/useCopyAudienceToAccount.ts 9313/** 9314* Copies a campaign's own audience up to the account. The mutation returns the account with its audiences, so 9315* the targeting page picks the new one up without a refetch. 9316*/ 9317var useCopyAudienceToAccount = () => { 9318 const [copyAudience, { loading }] = useCopyAdsSpaceAudienceToAccountMutation(); 9319 const copy = async (input) => { 9320 try { 9321 await copyAudience({ variables: { input } }); 9322 setSuccessNotification({ message: `${input.name} added to the account's audiences` }); 9323 } catch (copyError) { 9324 setErrorNotification({ message: copyError?.message ?? "Copying failed" }); 9325 throw copyError; 9326 } 9327 }; 9328 return { 9329 copying: loading, 9330 copy 9331 }; 9332}; 9333//#endregion 9334//#region client/components/newNav/components/ads/campaigns/CopyAudienceToAccountDialog.tsx 9335/** `audit_log_record.comments` is varchar(255), so the limit is enforced where the comment is written. */ 9336var COMMENT_MAX_LENGTH = 255; 9337/** 9338* Names the account-level copy of a campaign's own audience. The name is asked for rather than inherited because 9339* two account audiences may not share one, and several campaigns carry an audience of the same name. 9340*/ 9341var CopyAudienceToAccountDialog = (props) => { 9342 const [name, setName] = (0, import_react.useState)(props.audience.name); 9343 const [comment, setComment] = (0, import_react.useState)(""); 9344 const { copying, copy } = useCopyAudienceToAccount(); 9345 const confirm = async () => { 9346 try { 9347 await copy({ 9348 audienceId: props.audience.id, 9349 name: name.trim(), 9350 changeComment: comment.trim() 9351 }); 9352 props.onClose(); 9353 } catch {} 9354 }; 9355 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 9356 open: true, 9357 onClose: copying ? void 0 : props.onClose, 9358 PaperProps: { sx: paperSx$3 }, 9359 children: [ 9360 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 9361 CloseIconButtonProps: { 9362 onClick: props.onClose, 9363 disabled: copying, 9364 "data-tracking-id": "tracking-id-ads-campaign-copy-audience-close-btn" 9365 }, 9366 children: "Copy to account audiences" 9367 }), 9368 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9369 sx: contentSx$8, 9370 children: [ 9371 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9372 variant: "body2", 9373 color: "text.secondary", 9374 children: `Copies ${props.audience.name} and its rules to the account, where any campaign can point at it. This campaign keeps the original and goes on bidding on it, so nothing it serves changes today.` 9375 }), 9376 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9377 variant: "body2", 9378 color: "text.secondary", 9379 children: describeAudience(props.audience) 9380 }), 9381 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 9382 autoFocus: true, 9383 fullWidth: true, 9384 size: "small", 9385 label: "Name on the account", 9386 value: name, 9387 onChange: (event) => setName(event.target.value), 9388 inputProps: { "data-tracking-id": "tracking-id-ads-campaign-copy-audience-name-input" } 9389 }), 9390 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 9391 fullWidth: true, 9392 multiline: true, 9393 minRows: 3, 9394 size: "small", 9395 label: "What changed and why", 9396 value: comment, 9397 onChange: (event) => setComment(event.target.value), 9398 inputProps: { 9399 "data-tracking-id": "tracking-id-ads-campaign-copy-audience-comment-input", 9400 maxLength: COMMENT_MAX_LENGTH 9401 } 9402 }) 9403 ] 9404 }) }), 9405 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 9406 variant: "text", 9407 size: "large", 9408 disabled: copying, 9409 onClick: props.onClose, 9410 "data-tracking-id": "tracking-id-ads-campaign-copy-audience-cancel-btn", 9411 children: "Cancel" 9412 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 9413 variant: "contained", 9414 size: "large", 9415 loading: copying, 9416 disabled: !name.trim() || !comment.trim(), 9417 onClick: () => void confirm(), 9418 "data-tracking-id": "tracking-id-ads-campaign-copy-audience-confirm-btn", 9419 children: "Copy audience" 9420 })] }) 9421 ] 9422 }); 9423}; 9424var paperSx$3 = { 9425 width: 540, 9426 maxWidth: "calc(100vw - 64px)" 9427}; 9428var contentSx$8 = {
9429 display: "flex", 9430 flexDirection: "column", 9431 gap: 2, 9432 paddingTop: 1 9433}; 9434//#endregion 9435//#region client/components/newNav/components/ads/campaigns/InlineAudiencesPanel.tsx 9436var compareRows = (left, right, key, direction) => { 9437 const sign = direction === "asc" ? 1 : -1; 9438 if (key === "enabled") return left.enabled === right.enabled ? left.name.localeCompare(right.name) : sign * (Number(left.enabled) - Number(right.enabled)); 9439 return sign * left[key].localeCompare(right[key]); 9440}; 9441var SortableHeadCell = ({ label, sortKey, active, direction, onSort }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9442 sx: headCellSx, 9443 sortDirection: active === sortKey ? direction : false, 9444 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableSortLabel, { 9445 active: active === sortKey, 9446 direction: active === sortKey ? direction : "asc", 9447 onClick: () => onSort(sortKey), 9448 "data-testid": `ads-inline-audiences-sort-${sortKey}`, 9449 "data-tracking-id": "tracking-id-ads-campaign-inline-audiences-sort-btn", 9450 children: label 9451 }) 9452}); 9453/** 9454* Audiences the campaign owns itself instead of pointing at the account's. Most campaigns on this shape carry 9455* far more of them than they bid on - the ones left over from earlier buys are still on the row, just switched 9456* off - so which are in use is the first thing the table has to answer. 9457* 9458* Read-only: nothing in this space writes an audience belonging to one campaign. Copying one up to the account 9459* is the way out, and it leaves the original alone. 9460*/ 9461var InlineAudiencesPanel = (props) => { 9462 const [sortKey, setSortKey] = (0, import_react.useState)("enabled"); 9463 const [sortDirection, setSortDirection] = (0, import_react.useState)("desc"); 9464 const [menuAnchorEl, setMenuAnchorEl] = (0, import_react.useState)(null); 9465 const [menuAudience, setMenuAudience] = (0, import_react.useState)(null); 9466 const [copying, setCopying] = (0, import_react.useState)(null); 9467 const rows = (0, import_react.useMemo)(() => props.audiences.map((audience) => ({ 9468 audience, 9469 name: audience.name || `Audience ${audience.id}`, 9470 matches: describeAudience(audience), 9471 enabled: audience.enabled, 9472 selector: audience.productSelectorType ? productSelectorTypeLabel(audience.productSelectorType) : "" 9473 })), [props.audiences]); 9474 const sorted = (0, import_react.useMemo)(() => [...rows].sort((left, right) => compareRows(left, right, sortKey, sortDirection)), [ 9475 rows, 9476 sortKey, 9477 sortDirection 9478 ]); 9479 if (props.audiences.length === 0) return null; 9480 const onSort = (key) => { 9481 if (key === sortKey) { 9482 setSortDirection((direction) => direction === "asc" ? "desc" : "asc"); 9483 return; 9484 } 9485 setSortKey(key); 9486 setSortDirection(key === "enabled" ? "desc" : "asc"); 9487 }; 9488 const closeMenu = () => { 9489 setMenuAnchorEl(null); 9490 setMenuAudience(null); 9491 }; 9492 const rowActions = (audience) => [{ 9493 label: "Copy to account audiences", 9494 icon: import_faCopy.faCopy, 9495 onClick: () => { 9496 closeMenu(); 9497 setCopying(audience); 9498 } 9499 }]; 9500 const inUse = rows.filter((row) => row.enabled).length; 9501 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9502 sx: stackSx$3, 9503 children: [ 9504 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 9505 color: "info", 9506 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9507 variant: "body1", 9508 color: "text.secondary", 9509 children: `${inUse} of ${rows.length} are in use. Read-only here - nothing in the ads space edits an audience that belongs to one campaign. Copy one to the account to point other campaigns at it; the copy leaves this one running.` 9510 }) }) 9511 }), 9512 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 9513 component: import_material.Paper, 9514 variant: "outlined", 9515 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 9516 size: "small", 9517 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [
9518 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SortableHeadCell, { 9519 label: "Audience", 9520 sortKey: "name", 9521 active: sortKey, 9522 direction: sortDirection, 9523 onSort 9524 }), 9525 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SortableHeadCell, { 9526 label: "What it matches", 9527 sortKey: "matches", 9528 active: sortKey, 9529 direction: sortDirection, 9530 onSort 9531 }), 9532 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SortableHeadCell, { 9533 label: "Shows", 9534 sortKey: "selector", 9535 active: sortKey, 9536 direction: sortDirection, 9537 onSort 9538 }), 9539 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SortableHeadCell, { 9540 label: "In use", 9541 sortKey: "enabled", 9542 active: sortKey, 9543 direction: sortDirection, 9544 onSort 9545 }), 9546 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: headCellSx }) 9547 ] }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: sorted.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 9548 sx: rowHoverSx, 9549 children: [ 9550 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9551 sx: bodyCellSx, 9552 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9553 variant: "body1", 9554 children: row.name 9555 }) 9556 }), 9557 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9558 sx: bodyCellSx, 9559 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9560 variant: "body2", 9561 color: "text.secondary", 9562 children: row.matches 9563 }) 9564 }), 9565 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9566 sx: bodyCellSx, 9567 children: row.selector ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9568 variant: "body2", 9569 color: "text.secondary", 9570 children: row.selector 9571 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 9572 size: "small", 9573 variant: "dashed", 9574 color: row.enabled ? "warning" : "default", 9575 label: "No product selector" 9576 }) 9577 }), 9578 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9579 sx: bodyCellSx, 9580 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 9581 size: "small", 9582 variant: row.enabled ? "standard" : "dashed", 9583 color: row.enabled ? "success" : "default", 9584 label: row.enabled ? "In use" : "Not in use" 9585 }) 9586 }), 9587 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 9588 sx: actionsCellSx, 9589 children: !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 9590 size: "small", 9591 onClick: (event) => { 9592 setMenuAnchorEl(event.currentTarget); 9593 setMenuAudience(row.audience); 9594 }, 9595 "data-tracking-id": "tracking-id-ads-campaign-inline-audiences-row-menu-btn", 9596 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEllipsisV.faEllipsisV }) 9597 }) 9598 }) 9599 ] 9600 }, row.audience.id)) })] 9601 }) 9602 }), 9603 menuAudience && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowOptionsMenu, { 9604 anchorEl: menuAnchorEl, 9605 open: Boolean(menuAnchorEl), 9606 onClose: closeMenu, 9607 title: menuAudience.name, 9608 actions: rowActions(menuAudience), 9609 anchorOrigin: { 9610 vertical: "bottom", 9611 horizontal: "right" 9612 }, 9613 transformOrigin: { 9614 vertical: "top", 9615 horizontal: "right" 9616 } 9617 }), 9618 copying && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CopyAudienceToAccountDialog, { 9619 audience: copying, 9620 onClose: () => setCopying(null) 9621 }) 9622 ] 9623 }); 9624}; 9625var stackSx$3 = { 9626 display: "flex", 9627 flexDirection: "column", 9628 gap: 2 9629}; 9630//#endregion 9631//#region node_modules/@fortawesome/pro-regular-svg-icons/faUsersViewfinder.js 9632var require_faUsersViewfinder = /* @__PURE__ */ __commonJSMin(((exports) => { 9633 Object.defineProperty(exports, "__esModule", { value: true }); 9634 var prefix = "far"; 9635 var iconName = "users-viewfinder"; 9636 var width = 576; 9637 var height = 512; 9638 var aliases = []; 9639 var unicode = "e595"; 9640 var svgPathData = "M64 0C28.7 0 0 28.7 0 64l0 72c0 13.3 10.7 24 24 24s24-10.7 24-24l0-72c0-8.8 7.2-16 16-16l72 0c13.3 0 24-10.7 24-24S149.3 0 136 0L64 0zM440 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0c8.8 0 16 7.2 16 16l0 72c0 13.3 10.7 24 24 24s24-10.7 24-24l0-72c0-35.3-28.7-64-64-64L440 0zM48 376c0-13.3-10.7-24-24-24S0 362.7 0 376l0 72c0 35.3 28.7 64 64 64l72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-72 0c-8.8 0-16-7.2-16-16l0-72zm528 0c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 72c0 8.8-7.2 16-16 16l-72 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0c35.3 0 64-28.7 64-64l0-72zM288 128a32 32 0 1 1 0 64 32 32 0 1 1 0-64zm0 112a80 80 0 1 0 0-160 80 80 0 1 0 0 160zM160 160c0-16.4 3.1-32 8.7-46.4-32.4 7-56.7 35.9-56.7 70.4 0 39.8 32.2 72 72 72 5.9 0 11.6-.7 17.1-2-25.3-23.4-41.1-56.8-41.1-94zm232 96c39.8 0 72-32.2 72-72 0-34.5-24.3-63.3-56.7-70.4 5.6 14.4 8.7 30 8.7 46.4 0 37.1-15.8 70.6-41.1 94 5.5 1.3 11.2 2 17.1 2zM170.5 288.2C120.1 291 80 332.8 80 384l0 8c0 13.3 10.7 24 24 24s24-10.7 24-24l0-8c0-4.6 .6-9 1.8-13.1 4.9-31.8 19.5-60.4 40.7-82.7zm275.7 82.7c1.2 4.2 1.8 8.6 1.8 13.1l0 8c0 13.3 10.7 24 24 24s24-10.7 24-24l0-8c0-51.2-40.1-93-90.5-95.8 21.2 22.3 35.8 50.9 40.7 82.7zM272 288c-53 0-96 43-96 96l0 8c0 13.3 10.7 24 24 24s24-10.7 24-24l0-8c0-26.5 21.5-48 48-48l32 0c26.5 0 48 21.5 48 48l0 8c0 13.3 10.7 24 24 24s24-10.7 24-24l0-8c0-53-43-96-96-96l-32 0z";
9641 exports.definition = { 9642 prefix, 9643 iconName, 9644 icon: [ 9645 width, 9646 height, 9647 aliases, 9648 unicode, 9649 svgPathData 9650 ] 9651 }; 9652 exports.faUsersViewfinder = exports.definition; 9653 exports.prefix = prefix; 9654 exports.iconName = iconName; 9655 exports.width = width; 9656 exports.height = height; 9657 exports.ligatures = aliases; 9658 exports.unicode = unicode; 9659 exports.svgPathData = svgPathData; 9660 exports.aliases = aliases; 9661})); 9662//#endregion 9663//#region client/components/newNav/components/ads/targeting/AudienceEditor.tsx 9664var import_faEarthAmericas = require_faEarthAmericas(); 9665var import_faUsersViewfinder = require_faUsersViewfinder(); 9666/** 9667* A group's React key. Loading is keyed by position so `toDraft` stays pure - the unsaved-changes check compares 9668* a fresh draft against the edited one - while a group added here takes a counter key that no position can clash 9669* with. A key has to outlive its neighbours being removed: the conditions table under it holds its own dialog 9670* state, and reusing a key would hand that state to a different group. 9671*/ 9672var addedGroupCount = 0; 9673var loadedKey = (index) => `group-${index}`; 9674var addedKey = () => `added-group-${addedGroupCount++}`; 9675var emptyDmpGroup = (key) => ({ 9676 key, 9677 include: [], 9678 exclude: [], 9679 maxAgeDays: "" 9680}); 9681var emptySiteGroup = (key) => ({ 9682 key, 9683 minAgeDays: "", 9684 maxAgeDays: "", 9685 rules: [] 9686}); 9687var blockOf = (audience, evidence) => audience?.blocks.find((block) => block.ruleType === evidence); 9688var dayText = (days) => days === null || days === void 0 ? "" : String(days); 9689var toDmpGroupDraft = (group, index) => { 9690 const segments = toDmpSegments(group); 9691 return { 9692 key: loadedKey(index), 9693 include: segments?.include ?? [], 9694 exclude: segments?.exclude ?? [], 9695 maxAgeDays: dayText(group.maxAgeDays) 9696 }; 9697}; 9698var toSiteGroupDraft = (group, index) => ({ 9699 key: loadedKey(index), 9700 minAgeDays: dayText(group.minAgeDays), 9701 maxAgeDays: dayText(group.maxAgeDays), 9702 rules: group.rules.map((rule, at) => ({ 9703 id: `rule-${at}`, 9704 field: rule.field, 9705 operator: rule.operator, 9706 value: ruleValues(rule.value) 9707 })) 9708}); 9709/** An empty group is kept in the draft even when the section is off, so switching it on has something to fill in. */ 9710var groupDrafts = (block, convert, empty) => block && block.groups.length > 0 ? block.groups.map(convert) : [empty()]; 9711var toDraft = (audience) => { 9712 const dmpBlock = blockOf(audience, AudienceEvidence.DMP); 9713 const siteBlock = blockOf(audience, AudienceEvidence.SITE); 9714 const impressionBlock = blockOf(audience, AudienceEvidence.IMPRESSION); 9715 return { 9716 name: audience?.name ?? "", 9717 dmp: { 9718 on: Boolean(dmpBlock), 9719 groups: groupDrafts(dmpBlock, toDmpGroupDraft, () => emptyDmpGroup(loadedKey(0))) 9720 }, 9721 site: { 9722 on: Boolean(siteBlock), 9723 groups: groupDrafts(siteBlock, toSiteGroupDraft, () => emptySiteGroup(loadedKey(0))) 9724 }, 9725 impression: { 9726 on: Boolean(impressionBlock), 9727 programs: impressionBlock?.groups[0] ? toExcludedPrograms(impressionBlock.groups[0]) ?? [] : [] 9728 } 9729 }; 9730}; 9731var dayCount = (text) => text.trim() ? Number(text) : null; 9732var toBlocks = (draft) => { 9733 const blocks = []; 9734 if (draft.dmp.on) blocks.push({ 9735 ruleType: AudienceEvidence.DMP, 9736 groups: draft.dmp.groups.map((group) => ({ 9737 minAgeDays: null, 9738 maxAgeDays: dayCount(group.maxAgeDays), 9739 rules: [...group.include.length > 0 ? [{ 9740 field: DMP_SEGMENT_FIELD, 9741 operator: "EQUALS", 9742 value: group.include.join(",") 9743 }] : [], ...group.exclude.length > 0 ? [{ 9744 field: DMP_SEGMENT_FIELD, 9745 operator: "DOES_NOT_EQUAL", 9746 value: group.exclude.join(",") 9747 }] : []] 9748 })) 9749 }); 9750 if (draft.site.on) blocks.push({ 9751 ruleType: AudienceEvidence.SITE, 9752 groups: draft.site.groups.map((group) => ({ 9753 minAgeDays: dayCount(group.minAgeDays), 9754 maxAgeDays: dayCount(group.maxAgeDays), 9755 rules: group.rules.map((rule) => ({ 9756 field: asText(rule.field), 9757 operator: asText(rule.operator), 9758 value: OPERATORS_WITHOUT_VALUE.includes(asText(rule.operator)) ? null : asStringList(rule.value).join(",") 9759 })) 9760 })) 9761 }); 9762 if (draft.impression.on) blocks.push({ 9763 ruleType: AudienceEvidence.IMPRESSION, 9764 groups: [{ 9765 minAgeDays: null, 9766 maxAgeDays: null, 9767 rules: [{ 9768 field: IMPRESSION_PROGRAM_FIELD, 9769 operator: "DOES_NOT_EQUAL", 9770 value: draft.impression.programs.join(",") 9771 }] 9772 }] 9773 }); 9774 return blocks; 9775}; 9776/** Names the group when there is more than one, since the offending box is otherwise anyone's guess. */ 9777var groupProblem = (index, total, problem) => total === 1 ? problem : `${problem} in group ${index + 1}`; 9778var describeDraftProblem = (draft) => { 9779 if (!draft.name.trim()) return "Name the audience before saving"; 9780 if (!draft.dmp.on && !draft.site.on && !draft.impression.on) return "Turn on at least one of the three - an audie
9780nce with nothing in it matches nobody"; 9781 if (draft.dmp.on) { 9782 const empty = draft.dmp.groups.findIndex((group) => group.include.length === 0 && group.exclude.length === 0); 9783 if (empty >= 0) return groupProblem(empty, draft.dmp.groups.length, "Add at least one DMP segment id"); 9784 } 9785 if (draft.site.on) { 9786 const empty = draft.site.groups.findIndex((group) => group.rules.length === 0); 9787 if (empty >= 0) return groupProblem(empty, draft.site.groups.length, "Add at least one site activity condition"); 9788 } 9789 if (draft.impression.on && draft.impression.programs.length === 0) return "Name at least one campaign to leave out"; 9790 return null; 9791}; 9792var siteRuleFields = [ 9793 { 9794 key: "field", 9795 label: "Where", 9796 type: "select", 9797 options: siteVisitorFieldOptions, 9798 defaultValue: "url" 9799 }, 9800 { 9801 key: "operator", 9802 label: "Test", 9803 type: "select", 9804 options: siteVisitorOperatorOptions, 9805 defaultValue: "STRING_CONTAINS" 9806 }, 9807 { 9808 key: "value", 9809 label: "Values", 9810 type: "stringList", 9811 wide: true, 9812 placeholder: "internet, get-connected", 9813 help: "Any one of these satisfies the test. Paste a comma-separated list to add them all at once.", 9814 disabledWhen: (values) => OPERATORS_WITHOUT_VALUE.includes(asText(values.operator)) 9815 } 9816]; 9817var BlockCard = ({ evidence, on, readOnly, onToggle, children }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 9818 variant: "outlined", 9819 sx: cardSx, 9820 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9821 sx: cardHeaderSx, 9822 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Switch, { 9823 checked: on, 9824 disabled: readOnly, 9825 onChange: (event) => onToggle(event.target.checked), 9826 "data-tracking-id": `tracking-id-ads-audience-${evidence.toLowerCase()}-toggle` 9827 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9828 variant: "h5", 9829 children: evidenceTitle[evidence] 9830 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9831 variant: "body2", 9832 color: "text.secondary", 9833 children: evidenceDescription[evidence] 9834 })] })] 9835 }
9835), on && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 9836 sx: cardBodySx, 9837 children 9838 })] 9839}); 9840/** 9841* The condition groups of one evidence type. Matching any single group qualifies a request, which is what the ad 9842* server does with a rule set's rule groups - so a segment list that is really four alternative lists stays four 9843* groups here instead of being merged into one. 9844*/ 9845var GroupList = ({ groups, keyOf, addLabel, readOnly, trackingId, onChange, empty, renderGroup }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9846 sx: groupListSx, 9847 children: [ 9848 groups.map((group, index) => { 9849 const update = (changes) => onChange(groups.map((existing, at) => at === index ? { 9850 ...existing, 9851 ...changes 9852 } : existing)); 9853 if (groups.length === 1) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 9854 sx: groupSx$1, 9855 children: renderGroup(group, update) 9856 }, keyOf(group)); 9857 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 9858 variant: "outlined", 9859 sx: groupPaperSx, 9860 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9861 sx: groupHeaderSx$1, 9862 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9863 variant: "h6", 9864 children: index === 0 ? "Group 1" : `Or group ${index + 1}` 9865 }), !readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 9866 size: "small", 9867 onClick: () => onChange(groups.filter((_group, at) => at !== index)), 9868 "data-tracking-id": `${trackingId}-remove-group-btn`, 9869 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrashCan.faTrashCan }) 9870 })] 9871 }), renderGroup(group, update)] 9872 }, keyOf(group)); 9873 }), 9874 !readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 9875 variant: "text", 9876 size: "small", 9877 sx: addButtonSx, 9878 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 9879 onClick: () => onChange([...groups, empty()]), 9880 "data-tracking-id": `${trackingId}-add-group-btn`, 9881 children: addLabel 9882 }), 9883 groups.length > 1 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9884 variant: "helper", 9885 color: "text.secondary", 9886 children: "Matching any one of these groups is enough - they are alternatives, not extra requirements." 9887 }) 9888 ] 9889}); 9890/** 9891* One audience, in the terms the buy is described in rather than in the four tables it is stored as. The rule 9892* tree underneath is assembled on save and never shown. 9893*/ 9894var AudienceEditor = ({ draft, readOnly, onChange }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9895 sx: stackSx$2, 9896 children: [ 9897 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 9898 label: "Name", 9899 value: draft.name, 9900 size: "small", 9901 fullWidth: true, 9902 disabled: readOnly, 9903 onChange: (event) => onChange({ 9904 ...draft, 9905 name: event.target.value 9906 }), 9907 inputProps: { "data-tracking-id": "tracking-id-ads-audience-name-input" } 9908 }), 9909 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 9910 variant: "helper", 9911 color: "text.secondary", 9912 children: "This name reaches third-party pixels and story selection, so renaming it changes what partners receive." 9913 }), 9914 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BlockCard, { 9915 evidence: AudienceEvidence.DMP, 9916 on: draft.dmp.on, 9917 readOnly, 9918 onToggle: (on) => onChange({ 9919 ...draft, 9920 dmp: { 9921 ...draft.dmp, 9922 on 9923 } 9924 }), 9925 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GroupList, { 9926 groups: draft.dmp.groups, 9927 keyOf: (group) => group.key, 9928 addLabel: "Add segment group", 9929 readOnly, 9930 trackingId: "tracking-id-ads-audience-dmp", 9931 empty: () => emptyDmpGroup(addedKey()), 9932 onChange: (groups) => onChange({ 9933 ...draft, 9934 dmp: { 9935 ...draft.dmp, 9936 groups 9937 } 9938 }), 9939 renderGroup: (group, update) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 9940 sx: groupBodySx, 9941 children: [
9942 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsStringListInput, { 9943 label: "In any of these segments", 9944 values: group.include, 9945 disabled: readOnly, 9946 placeholder: "1008560101, 1011990061", 9947 onChange: (include) => update({ include }), 9948 trackingId: "tracking-id-ads-audience-dmp-include" 9949 }), 9950 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsStringListInput, { 9951 label: "But in none of these", 9952 values: group.exclude, 9953 disabled: readOnly, 9954 placeholder: "15153561, 17355442", 9955 onChange: (exclude) => update({ exclude }), 9956 trackingId: "tracking-id-ads-audience-dmp-exclude" 9957 }), 9958 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 9959 label: "Placed in the segment within the last (days)", 9960 type: "number", 9961 size: "small", 9962 sx: windowFieldSx, 9963 value: group.maxAgeDays, 9964 disabled: readOnly, 9965 helperText: "Leave empty to accept the segment however long ago it was assigned.", 9966 onChange: (event) => update({ maxAgeDays: event.target.value }), 9967 inputProps: { 9968 min: 0, 9969 "data-tracking-id": "tracking-id-ads-audience-dmp-max-age-input" 9970 } 9971 }) 9972 ] 9973 }) 9974 }) 9975 }), 9976 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BlockCard, { 9977 evidence: AudienceEvidence.SITE, 9978 on: draft.site.on, 9979 readOnly, 9980 onToggle: (on) => onChange({ 9981 ...draft, 9982 site: { 9983 ...draft.site, 9984 on 9985 } 9986 }), 9987 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GroupList, { 9988 groups: draft.site.groups, 9989 keyOf: (group) => group.key, 9990 addLabel: "Add activity group", 9991 readOnly, 9992 trackingId: "tracking-id-ads-audience-site", 9993 empty: () => emptySiteGroup(addedKey()), 9994 onChange: (groups) => onChange({ 9995 ...draft, 9996 site: { 9997 ...draft.site, 9998 groups 9999 } 10000 }), 10001 renderGroup: (group, update) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10002 sx: groupBodySx, 10003 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10004 sx: windowRowSx, 10005 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 10006 label: "Seen in the last (days)", 10007 type: "number", 10008 size: "small", 10009 value: group.maxAgeDays, 10010 disabled: readOnly, 10011 onChange: (event) => update({ maxAgeDays: event.target.value }), 10012 inputProps: { 10013 min: 0, 10014 "data-tracking-id": "tracking-id-ads-audience-site-max-age-input" 10015 } 10016 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 10017 label: "But not more recently than (days)", 10018 type: "number", 10019 size: "small", 10020 value: group.minAgeDays, 10021 disabled: readOnly, 10022 onChange: (event) => update({ minAgeDays: event.target.value }), 10023 inputProps: { 10024 min: 0, 10025 "data-tracking-id": "tracking-id-ads-audience-site-min-age-input" 10026 } 10027 })] 10028 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 10029 title: "Conditions", 10030 description: "All of these have to hold for the same user.", 10031 fields: siteRuleFields, 10032 columns: [ 10033 { 10034 key: "field", 10035 label: "Where", 10036 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10037 variant: "body1", 10038 children: siteVisitorFieldOptions.find((option) => option.value === asText(row.field))?.label ?? asText(row.field) 10039 }) 10040 }, 10041 { 10042 key: "operator", 10043 label: "Test", 10044 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10045 variant: "body1", 10046 children: siteVisitorOperatorOptions.find((option) => option.value === asText(row.operator))?.label ?? asText(row.operator) 10047 }) 10048 }, 10049 { 10050 key: "value", 10051 label: "Values", 10052 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10053 variant: "body1", 10054 children: asStringList(row.value).join(", ") || "-" 10055 }) 10056 } 10057 ], 10058 rows: group.rules, 10059 addLabel: "Add condition", 10060 emptyText: "No conditions yet", 10061 rowTitle: (row) => asText(row.field), 10062 readOnly, 10063 onChange: (rules) => update({ rules }), 10064 trackingIdPrefix: "tracking-id-ads-audience-site-rules" 10065 })] 10066 }) 10067 }) 10068 }),
10069 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(BlockCard, { 10070 evidence: AudienceEvidence.IMPRESSION, 10071 on: draft.impression.on, 10072 readOnly, 10073 onToggle: (on) => onChange({ 10074 ...draft, 10075 impression: { 10076 ...draft.impression, 10077 on 10078 } 10079 }), 10080 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsStringListInput, { 10081 label: "Leave out anyone already served by", 10082 values: draft.impression.programs, 10083 disabled: readOnly, 10084 placeholder: "martins_retain", 10085 onChange: (programs) => onChange({ 10086 ...draft, 10087 impression: { 10088 ...draft.impression, 10089 programs 10090 } 10091 }), 10092 trackingId: "tracking-id-ads-audience-impression-programs" 10093 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10094 variant: "helper", 10095 color: "text.secondary", 10096 children: "Campaign names exactly as they appear in the campaigns list." 10097 })] 10098 }), 10099 [ 10100 draft.dmp.on, 10101 draft.site.on, 10102 draft.impression.on 10103 ].filter(Boolean).length > 1 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 10104 color: "info", 10105 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10106 variant: "body1", 10107 color: "text.secondary", 10108 children: "A user has to satisfy every section switched on above, not just one of them." 10109 }) }) 10110 }) 10111 ] 10112}); 10113var stackSx$2 = {
10114 display: "flex", 10115 flexDirection: "column", 10116 gap: 2 10117}; 10118var cardSx = { 10119 display: "flex", 10120 flexDirection: "column", 10121 gap: 2, 10122 padding: 2 10123}; 10124var cardHeaderSx = { 10125 display: "flex", 10126 alignItems: "flex-start", 10127 gap: 1.5 10128}; 10129var cardBodySx = { 10130 display: "flex", 10131 flexDirection: "column", 10132 gap: 2, 10133 paddingLeft: 7 10134}; 10135var groupListSx = { 10136 display: "flex", 10137 flexDirection: "column", 10138 gap: 2 10139}; 10140var groupSx$1 = { 10141 display: "flex", 10142 flexDirection: "column", 10143 gap: 1.5 10144}; 10145var groupPaperSx = { 10146 display: "flex", 10147 flexDirection: "column", 10148 gap: 1.5, 10149 padding: 2 10150}; 10151var groupHeaderSx$1 = { 10152 display: "flex", 10153 alignItems: "center", 10154 justifyContent: "space-between", 10155 gap: 1 10156}; 10157var groupBodySx = { 10158 display: "flex", 10159 flexDirection: "column", 10160 gap: 2 10161}; 10162var windowRowSx = { 10163 display: "flex", 10164 gap: 2, 10165 flexWrap: "wrap" 10166}; 10167var windowFieldSx = { maxWidth: 320 }; 10168var addButtonSx = { alignSelf: "flex-start" }; 10169//#endregion 10170//#region client/components/newNav/components/ads/targeting/useAdsSpaceAudiences.ts 10171/** 10172* The account's audiences with their rule trees. One query rather than a walk down four collections, because 10173* the list has to say what each audience matches and the editor holds the whole tree at once. 10174*/ 10175var useAdsSpaceAudiences = () => { 10176 const { data, loading, error, refetch } = useCurrentAccountAdsSpaceAudiencesQuery(); 10177 const [saveAudience, { loading: saving }] = useSaveAdsSpaceAudienceMutation(); 10178 const audiences = (0, import_react.useMemo)(() => data?.currentAccount?.adsSpace?.audiences ?? [], [data]); 10179 const save = async (input) => { 10180 try { 10181 await saveAudience({ variables: { input } }); 10182 setSuccessNotification({ message: "Saved" }); 10183 } catch (saveError) { 10184 setErrorNotification({ message: saveError?.message ?? "Saving failed" }); 10185 throw saveError; 10186 } 10187 }; 10188 const reload = async () => { 10189 return (await refetch()).data?.currentAccount?.adsSpace?.audiences ?? []; 10190 }; 10191 return { 10192 audiences, 10193 loading, 10194 error: Boolean(error), 10195 saving, 10196 save, 10197 refetch: reload 10198 }; 10199}; 10200//#endregion 10201//#region client/components/newNav/components/ads/targeting/AdsNewAudienceDialog.tsx 10202/** 10203* Creating an account audience from a screen that asks for one. Audiences save through their own mutation, so 10204* making one mid-edit does not commit the campaign - the pending campaign edits stay pending. 10205*/ 10206var AdsNewAudienceDialog = ({ open, onClose, onCreated }) => { 10207 const { save, saving, refetch } = useAdsSpaceAudiences(); 10208 const [draft, setDraft] = (0, import_react.useState)(() => toDraft(null)); 10209 const [comment, setComment] = (0, import_react.useState)(""); 10210 (0, import_react.useEffect)(() => { 10211 if (open) { 10212 setDraft(toDraft(null)); 10213 setComment(""); 10214 } 10215 }, [open]); 10216 const problem = describeDraftProblem(draft); 10217 const canSave = problem === null && comment.trim().length > 0 && !saving; 10218 const handleSave = async () => { 10219 await save({ 10220 name: draft.name, 10221 blocks: toBlocks(draft), 10222 changeComment: comment 10223 }); 10224 const reloaded = await refetch(); 10225 onClose(); 10226 const created = reloaded.find((audience) => audience.name === draft.name); 10227 if (created) onCreated(created.id); 10228 }; 10229 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 10230 open, 10231 onClose: saving ? void 0 : onClose, 10232 maxWidth: "md", 10233 fullWidth: true, 10234 "data-testid": "ads-new-audience-dialog", 10235 children: [ 10236 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 10237 CloseIconButtonProps: { 10238 onClick: onClose, 10239 disabled: saving, 10240 "data-tracking-id": "tracking-id-ads-new-audience-close-btn" 10241 }, 10242 children: "New audience" 10243 }), 10244 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { children: [
10245 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AudienceEditor, { 10246 draft, 10247 onChange: setDraft 10248 }), 10249 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 10250 value: comment, 10251 onChange: setComment, 10252 trackingId: "tracking-id-ads-new-audience-comment-input" 10253 }), 10254 problem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10255 variant: "helper", 10256 color: "error.main", 10257 children: problem 10258 }) 10259 ] }), 10260 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10261 variant: "text", 10262 size: "large", 10263 disabled: saving, 10264 onClick: onClose, 10265 "data-tracking-id": "tracking-id-ads-new-audience-cancel-btn", 10266 children: "Cancel" 10267 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10268 variant: "contained", 10269 size: "large", 10270 disabled: !canSave, 10271 onClick: () => void handleSave().catch(() => void 0), 10272 "data-tracking-id": "tracking-id-ads-new-audience-save-btn", 10273 children: "Create" 10274 })] }) 10275 ] 10276 }); 10277}; 10278//#endregion 10279//#region client/components/newNav/components/ads/campaigns/CampaignEntityTargeting.tsx 10280var duplicateError = (key, message) => (row, otherRows) => otherRows.some((other) => asText(other[key]) === asText(row[key])) ? { [key]: message } : {}; 10281var validateGeoRow = duplicateError("geoFilterId", "This campaign already targets that geography"); 10282var validateAudienceRow = duplicateError("audienceId", "This campaign already targets that audience"); 10283var validateSelectorRow = duplicateError("audienceId", "That audience already has a product selector"); 10284/** 10285* The older settings' title row when they are deciding something. Red rather than a caption: the complaint this 10286* answers is that a deprecated setting looked like an inactive one. 10287*/ 10288var LegacyTargetingBadge = ({ runtime }) => { 10289 if (isLegacyTargetingInForce(runtime)) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10290 sx: badgeSx, 10291 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 10292 fontSize: "small", 10293 color: "error", 10294 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTriangleExclamation.faTriangleExclamation }) 10295 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 10296 size: "small", 10297 color: "error", 10298 variant: "outlined", 10299 label: "In use - deprecated" 10300 })] 10301 }); 10302 return hasTargetingConflict(runtime) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 10303 size: "small", 10304 color: "warning", 10305 variant: "outlined", 10306 label: "Configured but ignored" 10307 }) : null; 10308}; 10309/** 10310* The two account-level collections a campaign points at. Both are plain link rows - the geography and the 10311* audience themselves live on the account, so what is edited here is only which of them apply and how. 10312*/ 10313var CampaignEntityTargeting = (props) => { 10314 const accountId = useActiveAccountId(); 10315 const geoRows = props.form.childRows(GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS); 10316 const audienceRows = props.form.childRows(GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES); 10317 const selectorRows = props.form.childRows(GqlClientAdsSpaceCampaignChildTable.PRODUCT_SELECTORS); 10318 const noEntitiesDefined = props.geoOptions.length === 0 && props.audienceOptions.length === 0; 10319 const runtime = targetingRuntime({ 10320 locationFilter: asText(props.form.values.locationFilter), 10321 hasInlineAudiences: props.hasInlineAudiences, 10322 accountGeoCount: geoRows.length, 10323 accountAudienceCount: audienceRows.length 10324 }); 10325 const part = props.part ?? "all"; 10326 const isLegacyInForce = isLegacyTargetingInForce(runtime); 10327 const attachedAudienceOptions = props.audienceOptions.filter((option) => audienceRows.some((row) => asText(row.audienceId) === option.value)); 10328 const [newEntity, setNewEntity] = (0, import_react.useState)(null); 10329 const [isConverting, setIsConverting] = (0, import_react.useState)(false); 10330 const locationFilter = asText(props.form.values.locationFilter); 10331 const isConvertible = Boolean(locationFilter.trim()) && parseGeoDefinition(locationFilter).unparsed === null; 10332 const convertedGeoRow = { 10333 id: "new", 10334 name: `${props.campaignName}_geo`, 10335 definition: locationFilter, 10336 description: `Converted from the older location filter on ${props.campaignName}.` 10337 }; 10338 const attachGeography = (geoFilterId) => props.form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS, [...geoRows, { 10339 id: `geo-${geoFilterId}`, 10340 geoFilterId, 10341 mode: GeoFilterMode.WHITELIST, 10342 positionIdx: String(geoRows.length) 10343 }]); 10344 const attachAudience = (audienceId) => props.form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES, [...audienceRows, {
10345 id: `audience-${audienceId}`, 10346 audienceId, 10347 enabled: true, 10348 audienceIdx: String(audienceRows.length) 10349 }]); 10350 const uncovered = audienceRows.filter((row) => row.enabled !== false).filter((row) => !selectorRows.some((selector) => asText(selector.audienceId) === asText(row.audienceId))).map((row) => findEntityLabel(props.audienceOptions, asText(row.audienceId))); 10351 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10352 sx: stackSx$1, 10353 children: [ 10354 noEntitiesDefined && part !== "box" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 10355 color: "info", 10356 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBoxContent, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10357 variant: "body1", 10358 color: "text.secondary", 10359 children: "This account has no saved audiences or geographies yet. Define them once on the targeting page and every campaign can point at them from here." 10360 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10361 component: Link$6, 10362 to: (0, import_dist$1.newNavAdsTargetingSectionPath)({ 10363 accountId, 10364 sectionId: "geo-lists" 10365 }), 10366 variant: "text", 10367 size: "small", 10368 "data-tracking-id": "tracking-id-ads-campaign-targeting-define-entities-btn", 10369 children: "Go to targeting" 10370 })] }) 10371 }), 10372 part !== "tables" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 10373 color: targetingBoxColor(runtime), 10374 "data-testid": "testId-ads-campaign-targeting-runtime", 10375 children: [isLegacyInForce && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "What this campaign serves on right now" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBoxContent, { 10376 sx: isLegacyInForce ? void 0 : compactBoxSx, 10377 children: [targetingRuntimeLines(runtime, { 10378 accountGeoCount: geoRows.length, 10379 accountAudienceCount: audienceRows.length 10380 }).map((line) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10381 variant: "body1", 10382 color: "text.secondary", 10383 children: line 10384 }, line)), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10385 sx: boxActionsSx, 10386 children: [ 10387 isLegacyInForce && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10388 variant: "text", 10389 size: "small", 10390 onClick: () => scrollToTargetingElement("ads-targeting-account-geo"), 10391 "data-tracking-id": "tracking-id-ads-campaign-jump-account-targeting-btn", 10392 children: "See the account-level targeting" 10393 }),
10394 hasTargetingConflict(runtime) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10395 variant: "text", 10396 size: "small", 10397 onClick: () => scrollToTargetingElement("ads-group-legacy"), 10398 "data-tracking-id": "tracking-id-ads-campaign-jump-legacy-btn", 10399 children: "See the older settings that are being ignored" 10400 }), 10401 runtime.geo === "LEGACY" && !props.readOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 10402 title: isConvertible ? "" : "This filter was written by hand and cannot be converted automatically. Create the account geography and clear this field by hand.", 10403 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10404 variant: "text", 10405 size: "small", 10406 disabled: !isConvertible, 10407 onClick: () => { 10408 setIsConverting(true); 10409 setNewEntity("geography"); 10410 }, 10411 "data-testid": "ads-campaign-move-to-account-geo-btn", 10412 "data-tracking-id": "tracking-id-ads-campaign-move-to-account-geo-btn", 10413 children: "Move to account geographies" 10414 }) }) 10415 }) 10416 ] 10417 })] 10418 })] 10419 }), 10420 part !== "box" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10421 sx: groupSx, 10422 id: "ads-targeting-account-geo", 10423 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10424 sx: groupHeaderSx, 10425 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 10426 fontSize: "small", 10427 color: "action", 10428 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEarthAmericas.faEarthAmericas }) 10429 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10430 variant: "h5", 10431 children: "Where it serves" 10432 })] 10433 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 10434 title: "Geographies", 10435 description: "Serve-only rows add up; a never-serve row is then subtracted, so a place covered by both is excluded. None attached means unrestricted.", 10436 fields: programGeoFilterFields(props.geoOptions), 10437 columns: [{ 10438 key: "geoFilterId", 10439 label: "Geography", 10440 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10441 variant: "body1", 10442 children: findEntityLabel(props.geoOptions, asText(row.geoFilterId)) 10443 }) 10444 }, { 10445 key: "mode", 10446 label: "How it applies", 10447 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 10448 size: "small", 10449 variant: "standard", 10450 color: asText(row.mode) === GeoFilterMode.BLACKLIST ? "warning" : "success", 10451 label: asText(row.mode) === GeoFilterMode.BLACKLIST ? "Never serve here" : "Serve only here" 10452 }) 10453 }], 10454 rows: geoRows, 10455 addLabel: "Add geographies", 10456 emptyText: runtime.geo === "LEGACY" ? "No account geographies attached - this campaign serves on its older location filter, in the deprecated settings section" : "No geographies attached - this campaign may serve anywhere", 10457 rowTitle: (row) => findEntityLabel(props.geoOptions, asText(row.geoFilterId)), 10458 readOnly: props.readOnly, 10459 validateRow: validateGeoRow, 10460 bulkAdd: { 10461 fields: attachGeoFiltersFields(props.geoOptions, geoRows), 10462 toRows: (values) => asStringList(values.geoFilterIds).map((geoFilterId) => ({ 10463 id: `geo-${geoFilterId}`, 10464 geoFilterId, 10465 mode: asText(values.mode) || GeoFilterMode.WHITELIST 10466 })) 10467 }, 10468 onChange: (rows) => props.form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS, rows.map((row, index) => ({ 10469 ...row, 10470 mode: asText(row.mode) || GeoFilterMode.WHITELIST, 10471 positionIdx: String(index) 10472 }))), 10473 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10474 variant: "text", 10475 size: "medium", 10476 onClick: () => { 10477 setIsConverting(false); 10478 setNewEntity("geography"); 10479 }, 10480 "data-testid": "ads-campaign-new-geography-btn", 10481 "data-tracking-id": "tracking-id-ads-campaign-new-geography-btn", 10482 children: "New geography" 10483 }), 10484 trackingIdPrefix: "tracking-id-ads-campaign-geo-filters" 10485 })] 10486 }),
10487 part !== "box" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 10488 part !== "box" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10489 sx: groupSx, 10490 id: "ads-targeting-account-audiences", 10491 children: [ 10492 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10493 sx: groupHeaderSx, 10494 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 10495 fontSize: "small", 10496 color: "action", 10497 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faUsersViewfinder.faUsersViewfinder }) 10498 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10499 variant: "h5", 10500 children: "Who it bids on" 10501 })] 10502 }), 10503 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 10504 title: "Audiences", 10505 description: "Matching any one of these qualifies a request; priority only decides which name is reported. None attached means everyone the campaign may serve to.", 10506 fields: programAudienceFields(props.audienceOptions), 10507 columns: [ 10508 { 10509 key: "audienceId", 10510 label: "Audience", 10511 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10512 variant: "body1", 10513 children: findEntityLabel(props.audienceOptions, asText(row.audienceId)) 10514 }) 10515 }, 10516 { 10517 key: "audienceIdx", 10518 label: "Reporting priority", 10519 align: "right" 10520 }, 10521 { 10522 key: "enabled", 10523 label: "Status", 10524 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 10525 size: "small", 10526 variant: row.enabled ? "standard" : "dashed", 10527 color: row.enabled ? "success" : "default", 10528 label: row.enabled ? "Enabled" : "Paused" 10529 }) 10530 } 10531 ], 10532 rows: audienceRows, 10533 addLabel: "Add audiences", 10534 emptyText: props.hasInlineAudiences ? "No account audiences attached - this campaign targets its own older audiences, in the deprecated settings section" : "No audiences attached - this campaign bids on everyone", 10535 rowTitle: (row) => findEntityLabel(props.audienceOptions, asText(row.audienceId)), 10536 readOnly: props.readOnly, 10537 validateRow: validateAudienceRow, 10538 bulkAdd: { 10539 fields: attachAudiencesFields(props.audienceOptions, audienceRows), 10540 toRows: (values) => asStringList(values.audienceIds).map((audienceId) => ({ 10541 id: `audience-${audienceId}`, 10542 audienceId, 10543 enabled: true 10544 })) 10545 }, 10546 onChange: (rows) => props.form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES, rows.map((row, index) => ({ 10547 ...row, 10548 audienceIdx: asText(row.audienceIdx) || String(index), 10549 enabled: row.enabled ?? true 10550 }))), 10551 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 10552 variant: "text", 10553 size: "medium", 10554 onClick: () => setNewEntity("audience"), 10555 "data-testid": "ads-campaign-new-audience-btn", 10556 "data-tracking-id": "tracking-id-ads-campaign-new-audience-btn", 10557 children: "New audience" 10558 }), 10559 trackingIdPrefix: "tracking-id-ads-campaign-audiences" 10560 }), 10561 uncovered.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 10562 color: "warning", 10563 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 10564 variant: "body1", 10565 color: "text.secondary", 10566 children: [ 10567 `${uncovered.length === 1 ? "One audience has" : `${uncovered.length} audiences have`}`, 10568 " no product selector: ", 10569 uncovered.join(", "), 10570 ". The ad server looks one up for the audience it matched and will not serve", 10571 " without it." 10572 ] 10573 }) }) 10574 }), 10575 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 10576 title: "Product selectors",
10577 description: "What a viewer who matched an audience is shown. One per attached audience. Campaign-owned audiences keep theirs in the deprecated settings section and are not editable here.", 10578 fields: productSelectorFields(attachedAudienceOptions), 10579 columns: [ 10580 { 10581 key: "audienceId", 10582 label: "Audience", 10583 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10584 variant: "body1", 10585 children: findEntityLabel(props.audienceOptions, asText(row.audienceId)) 10586 }) 10587 }, 10588 { 10589 key: "type", 10590 label: "Show", 10591 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10592 variant: "body1", 10593 children: productSelectorTypeLabel(asText(row.type)) 10594 }) 10595 }, 10596 { 10597 key: "extraData", 10598 label: "Value" 10599 } 10600 ], 10601 rows: selectorRows, 10602 addLabel: "Add product selector", 10603 emptyText: "No product selectors - a matched audience has no product to show", 10604 rowTitle: (row) => findEntityLabel(props.audienceOptions, asText(row.audienceId)), 10605 readOnly: props.readOnly, 10606 validateRow: validateSelectorRow, 10607 onChange: (rows) => props.form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.PRODUCT_SELECTORS, rows.map((row) => ({ 10608 ...row, 10609 type: asText(row.type) || ProductSelectorType.LOCATION_TOP_SELLER 10610 }))), 10611 trackingIdPrefix: "tracking-id-ads-campaign-product-selectors" 10612 }) 10613 ] 10614 }), 10615 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsNewGeographyDialog, { 10616 open: newEntity === "geography", 10617 initialRow: isConverting ? convertedGeoRow : null, 10618 onClose: () => setNewEntity(null), 10619 onCreated: (geoFilterId) => { 10620 attachGeography(geoFilterId); 10621 if (isConverting) props.form.onChange("locationFilter", ""); 10622 } 10623 }), 10624 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsNewAudienceDialog, { 10625 open: newEntity === "audience", 10626 onClose: () => setNewEntity(null), 10627 onCreated: attachAudience 10628 }) 10629 ] 10630 }); 10631}; 10632var badgeSx = { 10633 display: "flex", 10634 alignItems: "center", 10635 gap: 1 10636}; 10637var compactBoxSx = { 10638 display: "flex", 10639 alignItems: "center", 10640 flexWrap: "wrap", 10641 gap: 1 10642}; 10643var boxActionsSx = { 10644 display: "flex", 10645 alignItems: "center", 10646 flexWrap: "wrap", 10647 gap: 1 10648}; 10649var stackSx$1 = { 10650 display: "flex", 10651 flexDirection: "column", 10652 gap: 4 10653}; 10654var groupSx = { 10655 display: "flex", 10656 flexDirection: "column", 10657 gap: 2 10658}; 10659var groupHeaderSx = { 10660 display: "flex", 10661 alignItems: "center", 10662 gap: 1 10663}; 10664//#endregion 10665//#region client/components/newNav/components/ads/campaigns/adsCampaignValues.ts 10666/** 10667* The server stores every campaign setting as a string and the field descriptors say what it means, so the map 10668* below is the one place that knows a value is a switch or a multi-select. Options and rendered content are 10669* irrelevant to the type, which is why the builders are called with empty ones here. 10670*/ 10671var fieldTypeByKey = Object.fromEntries([ 10672 ...[ 10673 ...buildOverviewGroups(), 10674 ...buildDeliveryGroups(), 10675 ...buildPacingGroups(), 10676 ...buildTargetingGroups(null), 10677 ...buildControlGroupGroups([]), 10678 ...buildOptimizationGroups([], [], []) 10679 ].flatMap((group) => group.fields), 10680 ...flightFields, 10681 ...creativeFields, 10682 ...buildReportingFields(""), 10683 ...exchangeCapFields([]), 10684 ...campaignPropertyFields, 10685 ...modelMappingFields([]), 10686 ...goalAdjustmentFields, 10687 ...programGeoFilterFields([]), 10688 ...programAudienceFields([]) 10689].map((field) => [field.key, field.type])); 10690var fromWireValue = (key, value) => { 10691 const type = fieldTypeByKey[key]; 10692 if (type === "bool") return value === "true"; 10693 if (type === "multiSelect" || type === "stringList") return value ? value.split(listSeparatorFor(type)).filter(Boolean) : []; 10694 return value ?? ""; 10695}; 10696var toWireValue = (key, value) => { 10697 const type = fieldTypeByKey[key]; 10698 if (type === "bool") return value ? "true" : "false"; 10699 if (type === "multiSelect" || type === "stringList") return asStringList(value).join(","); 10700 return asText(value); 10701}; 10702var fromWireValues = (values) => Object.fromEntries(values.map((entry) => [entry.key, fromWireValue(entry.key, entry.value)])); 10703var toWireValues = (values, keys) => keys.map((key) => ({ 10704 key, 10705 value: toWireValue(key, values[key] ?? null) 10706})); 10707/** A child row is the same bag with an id, so it converts the same way in both directions. */ 10708var toWireChildRow = (row, keys) => ({ 10709 id: row.id, 10710 values: toWireValues(row, keys) 10711}
10711); 10712//#endregion 10713//#region client/components/newNav/components/ads/campaigns/campaignChildTables.ts 10714var keysOf = (fields) => fields.map((field) => field.key); 10715/** Ordering columns nobody types - the editor assigns them from the row's position before the save. */ 10716var ORDER_KEYS = { 10717 geoFilter: "positionIdx", 10718 lane: "laneIdx" 10719}; 10720/** Which value keys travel with a row of each child table. The server rejects any key not in its own registry. */ 10721var campaignChildFieldKeys = { 10722 [GqlClientAdsSpaceCampaignChildTable.FLIGHTS]: keysOf(flightFields), 10723 [GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES]: [ 10724 "name", 10725 "geoFilterId", 10726 "audienceId", 10727 "sharePercent", 10728 "margin", 10729 "enabled", 10730 ORDER_KEYS.lane 10731 ], 10732 [GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS]: [...keysOf(programGeoFilterFields([])), ORDER_KEYS.geoFilter], 10733 [GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES]: keysOf(programAudienceFields([])), 10734 [GqlClientAdsSpaceCampaignChildTable.PRODUCT_SELECTORS]: keysOf(productSelectorFields([])), 10735 [GqlClientAdsSpaceCampaignChildTable.CREATIVES]: keysOf(creativeFields), 10736 [GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS]: keysOf(buildReportingFields("")), 10737 [GqlClientAdsSpaceCampaignChildTable.EXCHANGE_CAPS]: keysOf(exchangeCapFields([])), 10738 [GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES]: keysOf(campaignPropertyFields), 10739 [GqlClientAdsSpaceCampaignChildTable.MODEL_MAPPINGS]: keysOf(modelMappingFields([])), 10740 [GqlClientAdsSpaceCampaignChildTable.GOAL_ADJUSTMENTS]: keysOf(goalAdjustmentFields) 10741}; 10742//#endregion 10743//#region client/components/newNav/components/ads/campaigns/useAdsCampaignForm.ts 10744/** 10745* One editing session for a whole campaign. Every tab writes into this one state, so switching tabs keeps the 10746* edits; a single Save sends every tab's fields and child tables in one mutation with one change comment and one 10747* optimistic-lock check. The saved baseline is the campaign the page read, so the mutation's returned campaign 10748* clears the dirty state and re-seeds `updatedAt` for the next save. 10749*/ 10750var useAdsCampaignForm = (campaign, sections) => { 10751 const [values, setValues] = (0, import_react.useState)(campaign.values); 10752 const [rowsByTable, setRowsByTable] = (0, import_react.useState)({}); 10753 const [errors, setErrors] = (0, import_react.useState)({}); 10754 const [save, { loading: saving }] = useUpdateAdsSpaceCampaignSectionMutation(); 10755 const allGroups = (0, import_react.useMemo)(() => sections.flatMap((section) => section.groups), [sections]); 10756 const allTables = (0, import_react.useMemo)(() => Array.from(new Set(sections.flatMap((section) => section.childTables))), [sections]); 10757 const childRows = (table) => rowsByTable[table] ?? campaign.childRows[table]; 10758 const changeCount = (0, import_react.useMemo)(() => countChanges(values, campaign.values) + allTables.reduce((total, table) => total + countRowChanges(rowsByTable[table] ?? campaign.childRows[table], campaign.childRows[table]), 0), [ 10759 values, 10760 rowsByTable, 10761 campaign, 10762 allTables 10763 ]); 10764 const invalidLabels = (0, import_react.useMemo)(() => sections.flatMap((section) => section.groups.flatMap((group) => group.fields).filter((field) => errors[field.key]).map((field) => `${field.label} on ${section.label}`)), [sections, errors]); 10765 const onChange = (key, value) => { 10766 const next = { 10767 ...values, 10768 [key]: value 10769 }; 10770 setValues(next); 10771 setErrors(validateFields(allGroups, next)); 10772 }; 10773 const persist = async (comment) => { 10774 const sectionInputs = sections.map((section) => ({ 10775 section: section.label, 10776 editableKeys: section.groups.flatMap((group) => group.fields).filter((field) => field.type !== "readOnly" && !field.machineWritten).map((field) => field.key) 10777 })).filter((section) => section.editableKeys.some((key) => hasFieldChanged(values, campaign.values, key))).map((section) => ({ 10778 section: section.section, 10779 values: toWireValues(values, section.editableKeys) 10780 })); 10781 const changedTables = allTables.filter((table) => countRowChanges(childRows(table), campaign.childRows[table]) > 0); 10782 try { 10783 await save({ variables: { 10784 campaignId: campaign.id, 10785 input: { 10786 campaignId: campaign.id, 10787 updatedAt: campaign.updatedAt, 10788 changeComment: comment, 10789 sections: sectionInputs, 10790 childTables: changedTables.map((table) => ({ 10791 table, 10792 rows: childRows(table).map((row) => toWireChildRow(row, campaignChildFieldKeys[table])) 10793 })) 10794 } 10795 } }); 10796 setRowsByTable({}); 10797 setSuccessNotification({ message: "Campaign saved" }); 10798 } catch (error) { 10799 setErrorNotification({ message: error?.message ?? "Saving this campaign failed" }); 10800 } 10801 }; 10802 const onDiscard = () => { 10803 setValues(campaign.values); 10804 setRowsByTable({}); 10805 setErrors({}); 10806 }; 10807 return { 10808 values, 10809 errors, 10810 dirty: changeCount > 0, 10811 changeCount, 10812 invalidLabels, 10813 onChange, 10814 onSave: persist, 10815 onDiscard, 10816 childRows, 10817 onChildRowsChange: (table, rows) => setRowsByTable((current) => ({ 10818 ...current, 10819 [table]: rows 10820 })), 10821 saving 10822 }; 10823}; 10824//#endregion
10825//#region client/components/newNav/components/ads/campaigns/useAdsSpaceCampaign.ts 10826var emptyChildRows = () => Object.values(GqlClientAdsSpaceCampaignChildTable).reduce((rows, table) => ({ 10827 ...rows, 10828 [table]: [] 10829}), {}); 10830var groupChildRows = (rows) => { 10831 const grouped = emptyChildRows(); 10832 rows.forEach((row) => { 10833 grouped[row.table].push({ 10834 ...fromWireValues(row.values), 10835 id: row.id 10836 }); 10837 }); 10838 return grouped; 10839}; 10840var toDetail = (campaign) => ({ 10841 id: campaign.id, 10842 name: campaign.name, 10843 enabled: campaign.enabled, 10844 isCPCV: campaign.isCPCV, 10845 mode: campaign.mode, 10846 salesforceId: campaign.salesforceId ?? "", 10847 insertionOrder: campaign.insertionOrder ?? "", 10848 updatedAt: campaign.updatedAt ?? null, 10849 values: { 10850 ...fromWireValues(campaign.values), 10851 salesforceId: campaign.salesforceId ?? "", 10852 campaignId: campaign.id 10853 }, 10854 childRows: groupChildRows(campaign.childRows), 10855 experiments: campaign.experiments.map((experiment) => ({ 10856 id: experiment.id, 10857 name: experiment.name, 10858 groups: experiment.groups.map((group) => ({ 10859 id: group.id, 10860 name: group.name, 10861 min: group.min, 10862 max: group.max 10863 })) 10864 })), 10865 predictionModelOptions: campaign.predictionModels.map((model) => ({ 10866 value: model.value, 10867 label: model.label 10868 })), 10869 inlineAudiences: campaign.inlineAudiences, 10870 mappedPlacementCount: campaign.mappedPlacementCount 10871}); 10872/** One campaign as the settings page reads it. `campaign` is null once loaded when the id is not this account's. */ 10873var useAdsSpaceCampaign = (campaignId) => { 10874 const { data, loading, error, refetch } = useCurrentAccountAdsSpaceCampaignQuery({ variables: { campaignId } }); 10875 const row = data?.currentAccount?.adsSpace?.campaign; 10876 const campaign = (0, import_react.useMemo)(() => row ? toDetail(row) : null, [row]); 10877 const recheckPlacements = async () => { 10878 return (await refetch()).data?.currentAccount?.adsSpace?.campaign?.mappedPlacementCount ?? 0; 10879 }; 10880 return { 10881 campaign, 10882 loading, 10883 error: Boolean(error), 10884 recheckPlacements 10885 }; 10886}; 10887//#endregion 10888//#region client/components/newNav/components/ads/campaigns/CampaignDetailPage.tsx 10889/** Impression goals run to eight digits; unseparated they are unreadable. */ 10890var countCell = (key) => (row) => { 10891 const value = Number(asText(row[key])); 10892 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10893 variant: "body1", 10894 children: value > 0 ? value.toLocaleString() : "-" 10895 }); 10896}; 10897/** The aspect ratios a creative supports, readable from the list without opening a row. */ 10898var ASPECT_RATIOS = [ 10899 ["supportHorizontalAspectRatio", "16:9"], 10900 ["supportSquareAspectRatio", "1:1"], 10901 ["supportVerticalAspectRatio", "9:16"] 10902]; 10903var aspectRatioCell = (row) => { 10904 const on = ASPECT_RATIOS.filter(([key]) => row[key]).map(([, label]) => label); 10905 return on.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 10906 variant: "body1", 10907 color: "text.secondary", 10908 children: "none" 10909 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 10910 sx: chipRowSx, 10911 children: on.map((label) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 10912 size: "small", 10913 variant: "standard", 10914 color: "info", 10915 label 10916 }, label)) 10917 }); 10918}; 10919/** 10920* Enabled first, then the window that runs latest, then the newest model. Sorting purely by model id puts a 10921* disabled model at the top of the list, which is the trap. 10922*/ 10923var sortModelMappings = (rows) => [...rows].sort((left, right) => { 10924 if (Boolean(left.enabled) !== Boolean(right.enabled)) return left.enabled ? -1 : 1; 10925 const byWindow = asText(right.enabledTo).localeCompare(asText(left.enabledTo)); 10926 return byWindow !== 0 ? byWindow : Number(right.predictionModelId) - Number(left.predictionModelId); 10927}); 10928var sumRows = (rows, key) => rows.reduce((total, row) => total + (Number(asText(row[key])) || 0), 0); 10929var CampaignDetailPage = () => { 10930 const { campaignId } = useParams(); 10931 const { campaign, loading, recheckPlacements } = useAdsSpaceCampaign(campaignId); 10932 if (loading && !campaign) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10933 sx: adsPageSx, 10934 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 10935 area: "Programmatic / Campaigns", 10936 title: "Campaign" 10937 }
10937), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 10938 variant: "rounded", 10939 height: 320 10940 })] 10941 }); 10942 if (!campaign) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 10943 sx: adsPageSx, 10944 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 10945 area: "Programmatic / Campaigns", 10946 title: "Campaign" 10947 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEmptyState, { 10948 icon: import_faCircleInfo.faCircleInfo, 10949 heading: "This campaign no longer exists", 10950 subtitle: "It may have been removed since this page was opened." 10951 })] 10952 }); 10953 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignEditor, { 10954 campaign, 10955 onRecheckPlacements: recheckPlacements 10956 }); 10957}; 10958/** 10959* The whole campaign is one editing session: a single form backs every tab, so switching tabs keeps the edits 10960* and does not confirm; one Save persists every tab together with one change comment. The discard confirmation 10961* fires only when the user leaves this campaign - `navigationScope` lets same-campaign tab routes pass through. 10962*/ 10963var CampaignEditor = ({ campaign, onRecheckPlacements }) => { 10964 const accountId = useActiveAccountId(); 10965 const { userRoles } = useUserData(); 10966 const canEdit = isPermitted(userRoles, permissions.adsEditCampaignSettings); 10967 const isDecoupled = campaign.mode === GqlClientAdsSpaceCampaignMode.DECOUPLED; 10968 const flightRows = campaign.childRows[GqlClientAdsSpaceCampaignChildTable.FLIGHTS]; 10969 const impressionsGoal = sumRows(flightRows, "totalImpressions"); 10970 const geoOptions = toEntityOptions(useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.GEO_FILTER).rows); 10971 const audienceOptions = toEntityOptions(useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.AUDIENCE).rows); 10972 const videoResponseOptions = toEntityOptions(useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE).rows); 10973 const experimentOptions = campaign.experiments.map((experiment) => ({ 10974 value: experiment.id, 10975 label: experiment.name 10976 })); 10977 const experimentGroupOptions = (campaign.experiments.find((experiment) => experiment.id === asText(campaign.values.optimizationExperimentId))?.groups ?? []).map((group) => ({ 10978 value: group.id, 10979 label: `${group.name} (${group.min}-${group.max})` 10980 })); 10981 const isFreshCopy = Boolean(useLocation().state?.isFreshCopy); 10982 const savedTargetingRuntime = targetingRuntime({ 10983 locationFilter: asText(campaign.values.locationFilter), 10984 hasInlineAudiences: campaign.inlineAudiences.length > 0, 10985 accountGeoCount: campaign.childRows[GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS].length, 10986 accountAudienceCount: campaign.childRows[GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES].length 10987 }); 10988 const dashboardSectionDef = { 10989 id: ADS_CAMPAIGN_DASHBOARD_SECTION_ID, 10990 label: "Dashboard", 10991 icon: import_faChartMixed.faChartMixed, 10992 groups: [], 10993 childTables: [], 10994 renderInstead: () => isDecoupled ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecoupledDashboard, { campaignId: campaign.id }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignDashboard, { campaignId: campaign.id }) 10995 }; 10996 const programmaticSectionDefs = [ 10997 dashboardSectionDef, 10998 { 10999 id: "overview", 11000 label: "Overview", 11001 icon: import_faCircleInfo.faCircleInfo, 11002 groups: buildOverviewGroups(), 11003 childTables: [], 11004 renderHeader: () => isFreshCopy ? freshCopyNotice : null 11005 }, 11006 { 11007 id: "targeting", 11008 label: "Targeting", 11009 icon: import_faBullseye.faBullseye, 11010 groups: buildTargetingGroups(/* @__PURE__ */ (0, import_jsx_runtime.jsx)(InlineAudiencesPanel, { 11011 audiences: campaign.inlineAudiences, 11012 readOnly: !canEdit 11013 }), isLegacyTargetingInForce(savedTargetingRuntime), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LegacyTargetingBadge, { runtime: savedTargetingRuntime })), 11014 childTables: [ 11015 GqlClientAdsSpaceCampaignChildTable.PROGRAM_GEO_FILTERS, 11016 GqlClientAdsSpaceCampaignChildTable.PROGRAM_AUDIENCES, 11017 GqlClientAdsSpaceCampaignChildTable.PRODUCT_SELECTORS 11018 ], 11019 renderHeader: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignEntityTargeting, { 11020 form, 11021 geoOptions, 11022 audienceOptions, 11023 hasInlineAudiences: campaign.inlineAudiences.length > 0, 11024 campaignName: campaign.name, 11025 part: isLegacyTargetingInForce(savedTargetingRuntime) ? "box" : "all", 11026 readOnly: !canEdit 11027 }), 11028 renderChildren: (form) =>
11028 isLegacyTargetingInForce(savedTargetingRuntime) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignEntityTargeting, { 11029 form, 11030 geoOptions, 11031 audienceOptions, 11032 hasInlineAudiences: campaign.inlineAudiences.length > 0, 11033 campaignName: campaign.name, 11034 part: "tables", 11035 readOnly: !canEdit 11036 }) : null 11037 }, 11038 { 11039 id: "flights", 11040 label: "Flights and budget", 11041 icon: import_faCalendarDays.faCalendarDays, 11042 groups: [], 11043 childTables: [GqlClientAdsSpaceCampaignChildTable.FLIGHTS, GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES], 11044 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 11045 sx: stackSx, 11046 children: [ 11047 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11048 title: "Flights", 11049 description: "Booked delivery windows. Flights of one campaign may not overlap.", 11050 fields: flightFields, 11051 columns: [ 11052 { 11053 key: "startDate", 11054 label: "Start" 11055 }, 11056 { 11057 key: "endDate", 11058 label: "End" 11059 }, 11060 { 11061 key: "totalImpressions", 11062 label: "Impressions", 11063 align: "right", 11064 render: countCell("totalImpressions") 11065 }, 11066 { 11067 key: "totalCompletedViews", 11068 label: "Completed views", 11069 align: "right", 11070 render: countCell("totalCompletedViews") 11071 }, 11072 { 11073 key: "customerCpm", 11074 label: "Customer CPM", 11075 align: "right" 11076 }, 11077 { 11078 key: "margin", 11079 label: "Margin", 11080 align: "right" 11081 }, 11082 { 11083 key: "insertionOrder", 11084 label: "Insertion order" 11085 } 11086 ], 11087 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.FLIGHTS), 11088 addLabel: "Add flight", 11089 emptyText: "No flights booked yet", 11090 rowTitle: (row) => `${asText(row.startDate)} - ${asText(row.endDate)}`, 11091 readOnly: !canEdit, 11092 validateRow: validateFlightRow, 11093 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.FLIGHTS, rows), 11094 trackingIdPrefix: "tracking-id-ads-campaign-flights" 11095 }), 11096 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 11097 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BudgetLanesSection, { 11098 lanes: form.childRows(GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES), 11099 impressionsGoal: sumRows(form.childRows(GqlClientAdsSpaceCampaignChildTable.FLIGHTS), "totalImpressions"), 11100 customerCpm: weightedCampaignCpm(form.childRows(GqlClientAdsSpaceCampaignChildTable.FLIGHTS)), 11101 inheritedMargin: inheritedMargin(form.childRows(GqlClientAdsSpaceCampaignChildTable.FLIGHTS)), 11102 geoOptions, 11103 readOnly: !canEdit, 11104 onChange: (lanes) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES, lanes) 11105 }) 11106 ] 11107 }) 11108 }, 11109 { 11110 id: "creatives", 11111 label: "Creatives", 11112 icon: import_faFilm.faFilm, 11113 groups: [], 11114 childTables: [GqlClientAdsSpaceCampaignChildTable.CREATIVES], 11115 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11116 title: "Creative definitions", 11117 description: "One row per story. A campaign with several stories has several rows and they all apply; the ad server picks by rule.", 11118 fields: creativeFields, 11119 columns: [ 11120 { 11121 key: "story", 11122 label: "Story" 11123 }, 11124 { 11125 key: "numSkus", 11126 label: "SKUs", 11127 align: "right" 11128 }, 11129 { 11130 key: "allowedDurations", 11131 label: "Durations" 11132 }, 11133 { 11134 key: "videoQualityProfile", 11135 label: "Quality" 11136 }, 11137 { 11138 key: "videoParameters", 11139 label: "Video parameters" 11140 }, 11141 { 11142 key: "aspectRatios", 11143 label: "Aspect ratios", 11144 render: aspectRatioCell 11145 } 11146 ], 11147 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.CREATIVES), 11148 addLabel: "Add creative", 11149 emptyText: "No creatives defined yet", 11150 rowTitle: (row) => asText(row.story), 11151 readOnly: !canEdit, 11152 validateRow: validateCreativeRow, 11153 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.CREATIVES, rows), 11154 trackingIdPrefix: "tracking-id-ads-campaign-creatives" 11155 }) 11156 }, 11157 { 11158 id: "delivery", 11159 label: "Delivery", 11160 icon: import_faDesktop.faDesktop, 11161 groups: buildDeliveryGroups(), 11162 childTables: [] 11163 }, 11164 { 11165 id: "filters", 11166 label: "Filters", 11167 icon: import_faFilter.faFilter, 11168 groups: buildFilterGroups(), 11169 childTables: [] 11170 }, 11171 { 11172 id: "tracking", 11173 label: "Tracking and reporting", 11174 icon: import_faTag.faTag, 11175 groups: buildTrackingGroups(), 11176 childTables: [GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS], 11177 renderHeader: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11178 title: "Reporting definitions", 11179 description: "Extra fields carried into reporting. Which Field values are offered depends on the Source.", 11180 fields: buildReportingFields(""), 11181 rowFieldsFor: (values) => buildReportingFields(asText(values.source)), 11182 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS), 11183 addLabel: "Add definition", 11184 emptyText: "No reporting definitions yet", 11185 rowTitle: (row) => asText(row.name), 11186 readOnly: !canEdit, 11187 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS, rows), 11188 trackingIdPrefix: "tracking-id-ads-campaign-reporting" 11189 }), 11190 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignPixelRulesPanel, { 11191 campaignId: campaign.id, 11192 campaignName: campaign.name, 11193 location: asText(form.values.pixelRulesLocation), 11194 updatedAt: campaign.updatedAt, 11195 laneNames: form.childRows(GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES).map((lane)
11195=> asText(lane.name)).filter(Boolean), 11196 mappedPlacementCount: campaign.mappedPlacementCount, 11197 onRecheck: onRecheckPlacements, 11198 onSaved: (location) => form.onChange("pixelRulesLocation", location), 11199 readOnly: !canEdit 11200 }) 11201 }, 11202 { 11203 id: "optimization", 11204 label: "Optimization", 11205 icon: import_faBrain.faBrain, 11206 groups: [...buildOptimizationGroups(campaign.predictionModelOptions, experimentOptions, experimentGroupOptions), ...buildControlGroupGroups(videoResponseOptions)], 11207 childTables: [GqlClientAdsSpaceCampaignChildTable.MODEL_MAPPINGS], 11208 renderHeader: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11209 title: "Prediction model mappings", 11210 description: "Which model runs for this campaign, and when. Retiring one model and opening a window for the next is the usual edit.", 11211 fields: modelMappingFields(campaign.predictionModelOptions), 11212 rows: sortModelMappings(form.childRows(GqlClientAdsSpaceCampaignChildTable.MODEL_MAPPINGS)), 11213 addLabel: "Add mapping", 11214 emptyText: "No models mapped to this campaign", 11215 rowTitle: (row) => `Model ${asText(row.predictionModelId)}`, 11216 readOnly: !canEdit, 11217 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.MODEL_MAPPINGS, rows), 11218 trackingIdPrefix: "tracking-id-ads-campaign-model-mappings" 11219 }) 11220 }, 11221 { 11222 id: "advanced", 11223 label: "Advanced", 11224 icon: import_faSliders.faSliders, 11225 groups: buildPacingGroups(), 11226 childTables: [ 11227 GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES, 11228 GqlClientAdsSpaceCampaignChildTable.EXCHANGE_CAPS, 11229 GqlClientAdsSpaceCampaignChildTable.GOAL_ADJUSTMENTS 11230 ], 11231 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 11232 sx: stackSx, 11233 children: [ 11234 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11235 title: "Advanced properties", 11236 description: "A closed key set. A key the ad server does not read is ignored without a word, which is why this is a picker.", 11237 fields: campaignPropertyFields, 11238 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES), 11239 addLabel: "Add property", 11240 emptyText: "No advanced properties set", 11241 rowTitle: (row) => asText(row.propKey), 11242 readOnly: !canEdit, 11243 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES, rows), 11244 trackingIdPrefix: "tracking-id-ads-campaign-properties" 11245 }), 11246 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11247 title: "Exchange caps", 11248 description: "No row means the exchange is uncapped. A cap of 0 blocks it outright.", 11249 fields: exchangeCapFields(exchangeOptions), 11250 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.EXCHANGE_CAPS), 11251 addLabel: "Add cap", 11252 emptyText: "Every exchange is uncapped", 11253 rowTitle: (row) => exchangeOptions.find((option) => option.value === asText(row.exchangePrefix))?.label ?? "Exchange", 11254 readOnly: !canEdit, 11255 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.EXCHANGE_CAPS, rows), 11256 trackingIdPrefix: "tracking-id-ads-campaign-exchange-caps" 11257 }), 11258 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11259 title: "Goal adjustments", 11260 description: "Pacing tuning, one row per adjustment type. A handful of campaigns use this.", 11261 fields: goalAdjustmentFields, 11262 columns: [ 11263 { 11264 key: "type", 11265 label: "Type", 11266 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11267 variant: "body1", 11268 children: goalAdjustmentTypeOptions.find((option) => option.value === asText(row.type))?.label ?? "-" 11269 }) 11270 }, 11271 { 11272 key: "base", 11273 label: "Base", 11274 align: "right" 11275 }, 11276 { 11277 key: "changerate", 11278 label: "Change rate", 11279 align: "right" 11280 }, 11281 { 11282 key: "minValue", 11283 label: "Min", 11284 align: "right" 11285 }, 11286 { 11287 key: "maxValue", 11288 label: "Max", 11289 align: "right" 11290 }, 11291 { 11292 key: "overrideValue", 11293 label: "Override", 11294 align: "right" 11295 } 11296 ], 11297 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.GOAL_ADJUSTMENTS), 11298 addLabel: "Add adjustment", 11299 emptyText: "No goal adjustments on this campaign", 11300 rowTitle: (row) => goalAdjustmentTypeOptions.find((option) => option.value === asText(row.type))?.label ?? "Adjustment", 11301 readOnly: !canEdit, 11302 validateRow: validateGoalAdjustmentRow, 11303 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.GOAL_ADJUSTMENTS, rows), 11304 trackingIdPrefix: "tracking-id-ads-campaign-goal-adjustments" 11305 }) 11306 ] 11307 }) 11308 } 11309 ]; 11310 const decoupledSectionDefs = [ 11311 dashboardSectionDef, 11312 { 11313 id: "overview", 11314 label: "Identity", 11315 icon: import_faCircleInfo.faCircleInfo, 11316 groups: buildDecoupledIdentityGroups(), 11317 childTables: [] 11318 }, 11319 { 11320 id: "dsp-delivery", 11321 label: "DSP delivery", 11322 icon: import_faPlug.faPlug, 11323 groups: [], 11324 childTables: [], 11325 renderChildren: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecoupledDeliveryPanel, { campaignName: campaign.name }) 11326 }, 11327 { 11328 id: "fallback", 11329 label: "Fallback video", 11330 icon: import_faFilm.faFilm, 11331 groups: buildDecoupledFallbackGroups(), 11332 childTables: [] 11333 }, 11334 { 11335 id: "creatives", 11336 label: "Creative", 11337 icon: import_faFilm.faFilm, 11338 groups: [], 11339 childTables: [GqlClientAdsSpaceCampaignChildTable.CREATIVES], 11340 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11341 title: "Creative definitions", 11342 description: "What maps this campaign to its videos. One row per story.", 11343 fields: creativeFields, 11344 columns: [ 11345 { 11346 key: "story", 11347 label: "Story" 11348 }, 11349 { 11350 key: "numSkus", 11351 label: "SKUs", 11352 align: "right" 11353 }, 11354 { 11355 key: "allowedDurations", 11356 label: "Durations" 11357 }, 11358 { 11359 key: "videoQualityProfile", 11360 label: "Quality" 11361 }, 11362 { 11363 key: "videoParameters", 11364 label: "Video parameters" 11365 } 11366 ], 11367 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.CREATIVES), 11368 addLabel: "Add creative", 11369 emptyText: "No creatives defined yet", 11370 rowTitle: (row) => asText(row.story), 11371 readOnly: !canEdit, 11372 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.CREATIVES, rows), 11373 trackingIdPrefix: "tracking-id-ads-decoupled-creatives" 11374 }) 11375 }, 11376 { 11377 id: "reporting", 11378 label: "Reporting definitions", 11379 icon: import_faChartMixed.faChartMixed, 11380 groups: [], 11381 childTables: [GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS], 11382 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11383 title: "Reporting definitions", 11384 description: "Extra fields carried into reporting. Which Field values are offered depends on the Source.", 11385 fields: buildReportingFields(""), 11386 rowFieldsFor: (values) => buildReportingFields(asText(values.source)), 11387 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS), 11388 addLabel: "Add definition", 11389 emptyText: "No reporting definitions yet", 11390 rowTitle: (row) => asText(row.name), 11391 readOnly: !canEdit, 11392 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.REPORTING_DEFINITIONS, rows), 11393 trackingIdPrefix: "tracking-id-ads-decoupled-reporting" 11394 }) 11395 }, 11396 { 11397 id: "delivery", 11398 label: "Delivery settings", 11399 icon: import_faDesktop.faDesktop, 11400 groups: buildDecoupledDeliveryGroups(), 11401 childTables: [] 11402 }, 11403 { 11404 id: "control-group", 11405 label: "Control group", 11406 icon: import_faUserGroup.faUserGroup, 11407 groups: buildDecoupledControlGroupGroups(), 11408 childTables: [] 11409 }, 11410 { 11411 id: "advanced", 11412 label: "Advanced properties", 11413 icon: import_faSliders.faSliders, 11414 groups: [], 11415 childTables: [GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES], 11416 renderChildren: (form) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 11417 title: "Advanced properties", 11418 description: "A closed key set. A key the ad server does not read is ignored without a word, which is why this is a picker.",
11419 fields: campaignPropertyFields, 11420 rows: form.childRows(GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES), 11421 addLabel: "Add property", 11422 emptyText: "No advanced properties set", 11423 rowTitle: (row) => asText(row.propKey), 11424 readOnly: !canEdit, 11425 onChange: (rows) => form.onChildRowsChange(GqlClientAdsSpaceCampaignChildTable.CAMPAIGN_PROPERTIES, rows), 11426 trackingIdPrefix: "tracking-id-ads-decoupled-properties" 11427 }) 11428 } 11429 ]; 11430 const sectionDefs = isDecoupled ? decoupledSectionDefs : programmaticSectionDefs; 11431 const form = useAdsCampaignForm(campaign, sectionDefs); 11432 const willDisable = campaign.enabled && form.values.enabled === false; 11433 const saveWarning = form.values.name !== void 0 && asText(form.values.name) !== campaign.name && isDecoupled ? "The DSP's live tag carries this name. Renaming changes the tag they must call, and the campaign's fallback-video settings are re-keyed to the new name in the same save." : willDisable ? "This campaign stops serving as soon as the ad servers pick the change up." : void 0; 11434 const lanesProblem = isDecoupled ? null : getLanesError(form.childRows(GqlClientAdsSpaceCampaignChildTable.BUDGET_LANES)); 11435 const isWithinCampaign = (location) => { 11436 const base = isDecoupled ? (0, import_dist$1.newNavAdsDecoupledCampaignPath)({ 11437 accountId, 11438 campaignId: campaign.id 11439 }) : (0, import_dist$1.newNavAdsCampaignPath)({ 11440 accountId, 11441 campaignId: campaign.id 11442 }); 11443 return location.pathname === base || location.pathname.startsWith(`${base}/`); 11444 }; 11445 const sections = sectionDefs.map((def) => ({ 11446 id: def.id, 11447 label: def.label, 11448 icon: def.icon, 11449 render: () => def.renderInstead?.() ?? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 11450 groups: def.groups, 11451 form, 11452 readOnly: !canEdit, 11453 navigationScope: isWithinCampaign, 11454 invalidLabels: form.invalidLabels, 11455 saveBlockedReason: lanesProblem ?? void 0, 11456 saveWarning, 11457 headerChildren: def.renderHeader?.(form), 11458 trackingIdPrefix: "tracking-id-ads-campaign", 11459 children: def.renderChildren?.(form) 11460 }) 11461 })); 11462 const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); 11463 const { studioPrograms } = useAdsSpaceCampaigns(); 11464 const { move, saving } = useAdsSpaceCampaignActions(); 11465 const [isMoveOpen, setIsMoveOpen] = (0, import_react.useState)(false); 11466 const status = campaignStatusDisplay[deriveCampaignStatus(campaign.enabled, flightRows.map((row) => ({ 11467 startDate: asText(row.startDate), 11468 endDate: asText(row.endDate) 11469 })), today)]; 11470 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 11471 area: isDecoupled ? "Decoupled / Decoupled campaigns" : "Programmatic / Campaigns", 11472 title: campaign.name, 11473 subtitle: isDecoupled ? "A partner owns this buy and calls us for the creative" : `${campaign.insertionOrder || "No insertion order"} - ${formatCompactNumber(impressionsGoal)} impressions booked`, 11474 sections, 11475 sectionPath: (sectionId) => isDecoupled ? (0, import_dist$1.newNavAdsDecoupledCampaignSectionPath)({ 11476 accountId, 11477 campaignId: campaign.id, 11478 sectionId 11479 }) : (0, import_dist$1.newNavAdsCampaignSectionPath)({ 11480 accountId, 11481 campaignId: campaign.id, 11482 sectionId 11483 }), 11484 backLink: isDecoupled ? { 11485 label: "Back to decoupled campaigns", 11486 to: (0, import_dist$1.newNavAdsDecoupledCampaignsPath)({ accountId }) 11487 } : { 11488 label: "Back to campaigns", 11489 to: (0, import_dist$1.newNavAdsCampaignsPath)({ accountId }) 11490 }, 11491 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 11492 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 11493 size: "medium", 11494 label: status.label, 11495 color: status.color, 11496 variant: status.variant 11497 }), 11498 !isDecoupled && canEdit && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 11499 title: campaign.enabled ? "Disable the campaign before moving it - the move rewrites which program its delivery reports into." : "", 11500 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 11501 variant: "outlined", 11502 size: "small", 11503 disabled: campaign.enabled, 11504 onClick: () => setIsMoveOpen(true),
11505 "data-testid": "ads-campaign-move-btn", 11506 "data-tracking-id": "tracking-id-ads-campaign-move-btn", 11507 children: "Move to another Studio program" 11508 }) }) 11509 }), 11510 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MoveCampaignDialog, { 11511 campaign: isMoveOpen ? { 11512 id: campaign.id, 11513 salesforceId: campaign.salesforceId 11514 } : null, 11515 studioPrograms, 11516 saving, 11517 onClose: () => setIsMoveOpen(false), 11518 onMove: async (input) => { 11519 const done = await move(input); 11520 if (done) setIsMoveOpen(false); 11521 return done; 11522 } 11523 }) 11524 ] }), 11525 historyCampaignName: campaign.name, 11526 trackingIdPrefix: "tracking-id-ads-campaign" 11527 }); 11528}; 11529var freshCopyNotice = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 11530 color: "info", 11531 "data-testid": "testId-ads-campaign-fresh-copy", 11532 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "This is a copy, and it is not serving yet" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "It is disabled, and it has no pixel rules and no buyer creative id - both have to be unique per campaign. Set them, then enable it." })] 11533}); 11534var stackSx = { 11535 display: "flex", 11536 flexDirection: "column", 11537 gap: 4 11538}; 11539var chipRowSx = { 11540 display: "flex", 11541 gap: .5, 11542 flexWrap: "wrap" 11543}; 11544//#endregion 11545//#region client/components/newNav/components/ads/campaigns/CampaignsSummaryStrip.tsx 11546var import_faBullhorn = require_faBullhorn(); 11547/** 11548* Summarizes the whole account, not the filtered table below it - filtering the table should not silently 11549* change the account totals someone is reading this for. One tile only, per D-10: everything else that used 11550* to be here needed a delivery feed the hub does not have. 11551*/ 11552var CampaignsSummaryStrip = ({ campaigns }) => { 11553 const summary = summarizeCampaigns(campaigns); 11554 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 11555 sx: stripSx, 11556 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 11557 variant: "outlined", 11558 sx: (0, import_dist$2.combineSxProps)(adsSurfaceSx, tileSx), 11559 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 11560 sx: adsIconTileSx(32), 11561 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faBullseye.faBullseye }) 11562 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 11563 sx: tileBodySx, 11564 children: [ 11565 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11566 variant: "caption", 11567 color: "text.secondary", 11568 noWrap: true, 11569 children: "Active campaigns" 11570 }), 11571 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11572 variant: "h2", 11573 noWrap: true, 11574 children: `${summary.activeCount}` 11575 }), 11576 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11577 variant: "caption", 11578 color: "text.secondary", 11579 noWrap: true, 11580 children: `of ${summary.totalCount} in this account` 11581 }) 11582 ] 11583 })] 11584 }) 11585 }); 11586}; 11587var stripSx = { display: "flex" }; 11588var tileSx = { minWidth: 220 }; 11589var tileBodySx = { 11590 minWidth: 0, 11591 display: "flex", 11592 flexDirection: "column", 11593 gap: "2px" 11594}; 11595//#endregion 11596//#region node_modules/@fortawesome/pro-regular-svg-icons/faPause.js 11597var require_faPause = /* @__PURE__ */ __commonJSMin(((exports) => { 11598 Object.defineProperty(exports, "__esModule", { value: true }); 11599 var prefix = "far"; 11600 var iconName = "pause"; 11601 var width = 384; 11602 var height = 512; 11603 var aliases = [9208]; 11604 var unicode = "f04c"; 11605 var svgPathData = "M48 80l0 352 64 0 0-352-64 0zM0 80C0 53.5 21.5 32 48 32l64 0c26.5 0 48 21.5 48 48l0 352c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48L0 80zm272 0l0 352 64 0 0-352-64 0zm-48 0c0-26.5 21.5-48 48-48l64 0c26.5 0 48 21.5 48 48l0 352c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-352z"; 11606 exports.definition = { 11607 prefix, 11608 iconName, 11609 icon: [ 11610 width, 11611 height, 11612 aliases, 11613 unicode, 11614 svgPathData 11615 ] 11616 }; 11617 exports.faPause = exports.definition; 11618 exports.prefix = prefix; 11619 exports.iconName = iconName; 11620 exports.width = width; 11621 exports.height = height; 11622 exports.ligatures = aliases; 11623 exports.unicode = unicode;
11624 exports.svgPathData = svgPathData; 11625 exports.aliases = aliases; 11626})); 11627//#endregion 11628//#region client/components/newNav/components/ads/campaigns/CampaignsTable.tsx 11629var import_faArrowRightArrowLeft = require_faArrowRightArrowLeft(); 11630var import_faPause = require_faPause(); 11631var import_faPlay = require_faPlay(); 11632var columns = [ 11633 { 11634 key: "name", 11635 label: "Campaign" 11636 }, 11637 { 11638 key: "status", 11639 label: "Status" 11640 }, 11641 { 11642 key: "flight", 11643 label: "Flight" 11644 }, 11645 { 11646 key: "budget", 11647 label: "Budget", 11648 numeric: true 11649 }, 11650 { 11651 key: "impressions", 11652 label: "Impressions", 11653 numeric: true 11654 }, 11655 { 11656 key: "updatedAt", 11657 label: "Updated" 11658 } 11659]; 11660var CampaignsTable = ({ campaigns, accountId, canEdit, onDuplicate, onMove }) => { 11661 const history = useHistory(); 11662 const [sortKey, setSortKey] = (0, import_react.useState)("updatedAt"); 11663 const [sortDirection, setSortDirection] = (0, import_react.useState)("desc"); 11664 const [menuAnchorEl, setMenuAnchorEl] = (0, import_react.useState)(null); 11665 const [menuCampaign, setMenuCampaign] = (0, import_react.useState)(null); 11666 const sortedCampaigns = (0, import_react.useMemo)(() => sortCampaigns(campaigns, sortKey, sortDirection), [ 11667 campaigns, 11668 sortKey, 11669 sortDirection 11670 ]); 11671 const handleSort = (key) => { 11672 if (key === sortKey) { 11673 setSortDirection(sortDirection === "asc" ? "desc" : "asc"); 11674 return; 11675 } 11676 setSortKey(key); 11677 setSortDirection(key === "name" ? "asc" : "desc"); 11678 }; 11679 const closeMenu = () => { 11680 setMenuAnchorEl(null); 11681 setMenuCampaign(null); 11682 }; 11683 const notYetAvailable = (action) => () => { 11684 closeMenu(); 11685 setInfoNotification({ message: `${action} is not available yet` }); 11686 }; 11687 const openSection = (campaign, sectionId) => () => { 11688 closeMenu(); 11689 history.push((0, import_dist$1.newNavAdsCampaignSectionPath)({ 11690 accountId, 11691 campaignId: campaign.id, 11692 sectionId 11693 })); 11694 }; 11695 const openDialog = (campaign, open) => () => { 11696 closeMenu(); 11697 open(campaign); 11698 }; 11699 const menuActions = (campaign) => [ 11700 { 11701 label: "Edit campaign settings", 11702 icon: import_faPen.faPen, 11703 onClick: openSection(campaign, "overview") 11704 }, 11705 { 11706 label: "Duplicate campaign", 11707 icon: import_faCopy.faCopy, 11708 onClick: openDialog(campaign, onDuplicate) 11709 }, 11710 { 11711 label: "Move to another program", 11712 icon: import_faArrowRightArrowLeft.faArrowRightArrowLeft, 11713 onClick: openDialog(campaign, onMove) 11714 }, 11715 campaign.status === CampaignStatus.PAUSED ? { 11716 label: "Resume campaign", 11717 icon: import_faPlay.faPlay, 11718 onClick: openSection(campaign, "overview"), 11719 dividerBefore: true 11720 } : { 11721 label: "Pause campaign", 11722 icon: import_faPause.faPause, 11723 onClick: openSection(campaign, "overview"), 11724 dividerBefore: true 11725 }, 11726 { 11727 label: "Delete campaign", 11728 icon: import_faTrashCan.faTrashCan, 11729 onClick: notYetAvailable("Deleting a campaign"), 11730 error: true, 11731 dividerBefore: true 11732 } 11733 ]; 11734 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableContainer, { 11735 component: import_material.Paper, 11736 variant: "outlined", 11737 sx: containerSx$1, 11738 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 11739 size: "small", 11740 stickyHeader: true, 11741 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [columns.map((column, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11742 align: column.numeric ? "right" : "left", 11743 sortDirection: sortKey === column.key ? sortDirection : false, 11744 sx: index === 0 ? (0, import_dist$2.combineSxProps)(headCellSx, stickyLeftSx("default", true)) : headCellSx, 11745 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableSortLabel, { 11746 active: sortKey === column.key, 11747 direction: sortKey === column.key ? sortDirection : "asc", 11748 onClick: () => handleSort(column.key), 11749 "data-tracking-id": `tracking-id-ads-campaigns-sort-${column.key}-btn`, 11750 children: column.label 11751 }) 11752 }, column.key)), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: (0, import_dist$2.combineSxProps)(actionsCellSx, stickyRightSx("default", true)) })] }) }
11752), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [sortedCampaigns.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11753 colSpan: columns.length + 1, 11754 sx: noResultsCellSx$1, 11755 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11756 variant: "body1", 11757 color: "text.secondary", 11758 children: "No campaigns match your filters" 11759 }) 11760 }) }), sortedCampaigns.map((campaign) => { 11761 const status = campaignStatusDisplay[campaign.status]; 11762 const impressionsGoal = getImpressionsGoal(campaign); 11763 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 11764 sx: rowHoverSx, 11765 "data-testid": `testId-ads-campaign-row-${campaign.id}`, 11766 children: [ 11767 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11768 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, stickyLeftSx("default")), 11769 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 11770 sx: nameCellSx$2, 11771 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Link$6, { 11772 to: (0, import_dist$1.newNavAdsCampaignSectionPath)({ 11773 accountId, 11774 campaignId: campaign.id, 11775 sectionId: ADS_CAMPAIGN_DASHBOARD_SECTION_ID 11776 }), 11777 style: { 11778 textDecoration: "none", 11779 color: "inherit" 11780 }, 11781 "data-tracking-id": "tracking-id-ads-campaigns-name-link", 11782 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TypographyWithTooltipOnOverflow, { 11783 variant: "subtitle2", 11784 multiline: true, 11785 children: campaign.name 11786 }) 11787 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11788 variant: "caption", 11789 color: "text.secondary", 11790 noWrap: true, 11791 children: campaign.insertionOrder 11792 })] 11793 }) 11794 }), 11795 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11796 sx: bodyCellSx, 11797 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 11798 size: "small", 11799 label: status.label, 11800 color: status.color, 11801 variant: status.variant 11802 }) 11803 }), 11804 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11805 sx: bodyCellSx, 11806 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11807 variant: "body1", 11808 noWrap: true, 11809 children: formatFlightRange(getFlightRange(campaign)) 11810 }) 11811 }), 11812 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableCell, { 11813 align: "right", 11814 sx: bodyCellSx, 11815 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11816 variant: "body1", 11817 noWrap: true, 11818 children: formatCurrency(getBookedBudget(campaign)) 11819 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11820 variant: "caption", 11821 color: "text.secondary", 11822 children: campaign.pricingModel 11823 })] 11824 }), 11825 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11826 align: "right", 11827 sx: bodyCellSx, 11828 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11829 variant: "body1", 11830 noWrap: true, 11831 children: impressionsGoal > 0 ? formatCompactNumber(impressionsGoal) : "-" 11832 }) 11833 }), 11834 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11835 sx: bodyCellSx, 11836 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11837 variant: "body1", 11838 noWrap: true, 11839 children: campaign.updatedAt ? TimeUtils.getDesignSystemReadableTimeDate(campaign.updatedAt, true) : "-" 11840 }) 11841 }), 11842 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 11843 sx: (0, import_dist$2.combineSxProps)(actionsCellSx, stickyRightSx("default")), 11844 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 11845 size: "small", 11846 disabled: !canEdit, 11847 onClick: (event) => { 11848 setMenuAnchorEl(event.currentTarget); 11849 setMenuCampaign(campaign); 11850 }, 11851 "data-tracking-id": "tracking-id-ads-campaigns-row-options-btn", 11852 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEllipsisV.faEllipsisV }) 11853 }) 11854 }) 11855 ] 11856 }, campaign.id); 11857 })] })] 11858 }
11858), menuCampaign && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowOptionsMenu, { 11859 anchorEl: menuAnchorEl, 11860 open: Boolean(menuAnchorEl), 11861 onClose: closeMenu, 11862 title: menuCampaign.name, 11863 captions: [menuCampaign.insertionOrder ? `Insertion order: ${menuCampaign.insertionOrder}` : "No insertion order", menuCampaign.salesforceId ? `Salesforce id: ${menuCampaign.salesforceId}` : "Not linked to Salesforce"], 11864 actions: menuActions(menuCampaign), 11865 anchorOrigin: { 11866 vertical: "bottom", 11867 horizontal: "right" 11868 }, 11869 transformOrigin: { 11870 vertical: "top", 11871 horizontal: "right" 11872 } 11873 })] 11874 }); 11875}; 11876var containerSx$1 = { 11877 flex: 1, 11878 minHeight: 0 11879}; 11880var nameCellSx$2 = { 11881 display: "flex", 11882 flexDirection: "column", 11883 maxWidth: 280 11884}; 11885var noResultsCellSx$1 = { 11886 padding: "32px 16px", 11887 textAlign: "center" 11888}; 11889//#endregion 11890//#region client/components/newNav/components/ads/campaigns/DuplicateCampaignDialog.tsx 11891var DUPLICATE_NAME_SUFFIX = "_copy"; 11892var DUPLICATE_NAME_ERROR$1 = "A campaign with this name already exists."; 11893/** The copy keeps the flights, creatives, reporting and targeting, and comes up disabled. */ 11894var DuplicateCampaignDialog = (props) => { 11895 const [name, setName] = (0, import_react.useState)(""); 11896 const [comment, setComment] = (0, import_react.useState)(""); 11897 (0, import_react.useEffect)(() => { 11898 setName(props.campaign ? `${props.campaign.name}${DUPLICATE_NAME_SUFFIX}` : ""); 11899 setComment(""); 11900 }, [props.campaign]); 11901 const trimmedName = name.trim(); 11902 const isNameTaken = trimmedName.length > 0 && props.existingCampaignNames.some((existing) => existing.trim().toLowerCase() === trimmedName.toLowerCase()); 11903 const canSave = trimmedName.length > 0 && !isNameTaken && comment.trim().length > 0 && !props.saving; 11904 const handleDuplicate = async () => { 11905 if (!canSave) return; 11906 if (await props.onDuplicate({ 11907 campaignId: props.campaign.id, 11908 name: trimmedName, 11909 changeComment: comment.trim() 11910 })) props.onClose(); 11911 }; 11912 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 11913 open: Boolean(props.campaign), 11914 onClose: props.saving ? void 0 : props.onClose, 11915 maxWidth: false, 11916 PaperProps: { sx: dialogPaperSx$1 }, 11917 children: [ 11918 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 11919 CloseIconButtonProps: { 11920 onClick: props.onClose, 11921 disabled: props.saving, 11922 "data-tracking-id": "tracking-id-ads-duplicate-campaign-close-btn" 11923 }, 11924 children: "Duplicate campaign" 11925 }), 11926 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { 11927 sx: contentSx$7, 11928 children: [ 11929 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 11930 label: "Name of the copy", 11931 fullWidth: true, 11932 value: name, 11933 onChange: (event) => setName(event.target.value), 11934 error: isNameTaken, 11935 helperText: isNameTaken ? DUPLICATE_NAME_ERROR$1 : void 0, 11936 inputProps: { "data-tracking-id": "tracking-id-ads-duplicate-campaign-name-input" } 11937 }), 11938 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 11939 value: comment, 11940 onChange: setComment, 11941 trackingId: "tracking-id-ads-duplicate-campaign-comment-input" 11942 }), 11943 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 11944 color: "info", 11945 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 11946 variant: "body1", 11947 color: "text.secondary", 11948 children: "The copy carries this campaign's flights, creatives, reporting definitions, exch
11948ange caps and targeting. It comes up disabled, without pixel rules and without a buyer creative id - both have to be unique per campaign." 11949 }) }) 11950 }) 11951 ] 11952 }), 11953 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 11954 variant: "text", 11955 size: "large", 11956 disabled: props.saving, 11957 onClick: props.onClose, 11958 "data-tracking-id": "tracking-id-ads-duplicate-campaign-cancel-btn", 11959 children: "Cancel" 11960 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 11961 variant: "contained", 11962 size: "large", 11963 loading: props.saving, 11964 disabled: !canSave, 11965 onClick: () => void handleDuplicate(), 11966 "data-tracking-id": "tracking-id-ads-duplicate-campaign-submit-btn", 11967 children: "Duplicate" 11968 })] }) 11969 ] 11970 }); 11971}; 11972var dialogPaperSx$1 = { 11973 width: 540, 11974 maxWidth: "calc(100vw - 64px)" 11975}; 11976var contentSx$7 = { 11977 display: "flex", 11978 flexDirection: "column", 11979 gap: 2 11980}; 11981//#endregion 11982//#region client/components/newNav/components/ads/campaigns/NewCampaignDialog.tsx 11983var DUPLICATE_NAME_ERROR = "A campaign with this name already exists."; 11984/** 11985* Everything a campaign needs before its settings page is worth opening. The Salesforce id is deliberately not 11986* asked for - it comes from the Studio program, and the ads app's own help text calls its editable copy 11987* deprecated. 11988*/ 11989var NewCampaignDialog = (props) => { 11990 const [name, setName] = (0, import_react.useState)(""); 11991 const [pricingModel, setPricingModel] = (0, import_react.useState)(CampaignPricingModel.CPM); 11992 const [deviceMode, setDeviceMode] = (0, import_react.useState)(deviceModeOptions[0].value); 11993 const [comment, setComment] = (0, import_react.useState)(""); 11994 const [nameError, setNameError] = (0, import_react.useState)(null); 11995 const isNameTaken = (candidate) => { 11996 const trimmed = candidate.trim(); 11997 return trimmed.length > 0 && props.existingCampaignNames.some((existing) => existing.trim().toLowerCase() === trimmed.toLowerCase()); 11998 }; 11999 const trimmedName = name.trim(); 12000 const canSave = trimmedName.length > 0 && !nameError && comment.trim().length > 0 && !props.saving; 12001 const handleClose = () => { 12002 setName(""); 12003 setPricingModel(CampaignPricingModel.CPM); 12004 setDeviceMode(deviceModeOptions[0].value); 12005 setComment(""); 12006 setNameError(null); 12007 props.onClose(); 12008 }; 12009 const handleCreate = async () => { 12010 if (!canSave) return; 12011 if (await props.onCreate({ 12012 name: trimmedName, 12013 isCPCV: pricingModel === CampaignPricingModel.CPCV, 12014 deviceMode, 12015 mode: props.mode, 12016 changeComment: comment.trim() 12017 })) handleClose(); 12018 }; 12019 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 12020 open: props.open, 12021 onClose: props.saving ? void 0 : handleClose, 12022 maxWidth: false, 12023 PaperProps: { sx: dialogPaperSx }, 12024 children: [ 12025 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 12026 CloseIconButtonProps: { 12027 onClick: handleClose, 12028 disabled: props.saving, 12029 "data-tracking-id": "tracking-id-ads-new-campaign-close-btn" 12030 }, 12031 children: "New campaign" 12032 }), 12033 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { 12034 sx: contentSx$6, 12035 children: [ 12036 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 12037 label: "Campaign name", 12038 fullWidth: true, 12039 value: name, 12040 onChange: (event) => { 12041 const nextName = event.target.value; 12042 setName(nextName); 12043 setNameError(isNameTaken(nextName) ? DUPLICATE_NAME_ERROR : null); 12044 }, 12045 error: !!nameError, 12046 helperText: nameError ?? void 0, 12047 "data-testid": "testId-new-campaign-name-input", 12048 inputProps: { "data-tracking-id": "tracking-id-ads-new-campaign-name-input" } 12049 }), 12050 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.FormControl, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.FormLabel, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12051 variant: "h5", 12052 children: "Pricing model" 12053 }) }
12053), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.RadioGroup, { 12054 row: true, 12055 value: pricingModel, 12056 onChange: (event) => setPricingModel(event.target.value), 12057 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.FormControlLabel, { 12058 value: CampaignPricingModel.CPM, 12059 control: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Radio, { "data-tracking-id": "tracking-id-ads-new-campaign-cpm-radio" }), 12060 label: "CPM - cost per thousand impressions" 12061 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.FormControlLabel, { 12062 value: CampaignPricingModel.CPCV, 12063 control: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Radio, { "data-tracking-id": "tracking-id-ads-new-campaign-cpcv-radio" }), 12064 label: "CPCV - cost per completed view" 12065 })] 12066 })] }), 12067 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 12068 select: true, 12069 label: "Device mode", 12070 fullWidth: true, 12071 value: deviceMode, 12072 onChange: (event) => setDeviceMode(event.target.value), 12073 inputProps: { "data-tracking-id": "tracking-id-ads-new-campaign-device-mode-input" }, 12074 children: deviceModeOptions.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 12075 value: option.value, 12076 children: option.label 12077 }, option.value)) 12078 }), 12079 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 12080 value: comment, 12081 onChange: setComment, 12082 trackingId: "tracking-id-ads-new-campaign-comment-input" 12083 }), 12084 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 12085 color: "info", 12086 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12087 variant: "body1", 12088 color: "text.secondary", 12089 children: "The campaign is created disabled. Book its flights and add its creatives on the settings page before enabling it. Its Salesforce id arrives from the Studio program." 12090 }) }) 12091 }) 12092 ] 12093 }), 12094 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12095 variant: "text", 12096 size: "large", 12097 disabled: props.saving, 12098 onClick: handleClose, 12099 "data-tracking-id": "tracking-id-ads-new-campaign-cancel-btn", 12100 children: "Cancel" 12101 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12102 variant: "contained", 12103 size: "large", 12104 loading: props.saving, 12105 disabled: !canSave, 12106 onClick: () => void handleCreate(), 12107 "data-tracking-id": "tracking-id-ads-new-campaign-submit-btn", 12108 "data-testid": "testId-new-campaign-submit", 12109 children: "Create campaign" 12110 })] }) 12111 ] 12112 }); 12113}; 12114var dialogPaperSx = { 12115 width: 540, 12116 maxWidth: "calc(100vw - 64px)" 12117}; 12118var contentSx$6 = { 12119 display: "flex", 12120 flexDirection: "column", 12121 gap: 2 12122}; 12123//#endregion 12124//#region client/components/newNav/components/ads/campaigns/CampaignsPage.tsx 12125var CampaignsPage = () => { 12126 const accountId = useActiveAccountId(); 12127 const history = useHistory(); 12128 const { userRoles } = useUserData(); 12129 const canEdit = isPermitted(userRoles, permissions.adsEditCampaignSettings); 12130 const { campaigns, studioPrograms, loading, refetching, error, refetch } = useAdsSpaceCampaigns(); 12131 const actions = useAdsSpaceCampaignActions(); 12132 const [searchTerm, setSearchTerm] = (0, import_react.useState)(""); 12133 const [statusFilter, setStatusFilter] = (0, import_react.useState)(CampaignStatus.ACTIVE); 12134 const [filterAnchorEl, setFilterAnchorEl] = (0, import_react.useState)(null); 12135 const [isNewCampaignDialogOpen, setIsNewCampaignDialogOpen] = (0, import_react.useState)(false); 12136 const [campaignToDuplicate, setCampaignToDuplicate] = (0, import_react.useState)(null); 12137 const [campaignToMove, setCampaignToMove] = (0, import_react.useState)(null); 12138 const visibleCampaigns = (0, import_react.useMemo)(() => filterCampaigns(campaigns, searchTerm, statusFilter), [ 12139 campaigns, 12140 searchTerm, 12141 statusFilter 12142 ]); 12143 const handleDuplicate = async (input) => { 12144 const newCampaignId = await actions.duplicate(input); 12145 if (!newCampaignId) return false;
12146 history.push({ 12147 pathname: (0, import_dist$1.newNavAdsCampaignSectionPath)({ 12148 accountId, 12149 campaignId: newCampaignId, 12150 sectionId: "overview" 12151 }), 12152 state: { isFreshCopy: true } 12153 }); 12154 return true; 12155 }; 12156 const newCampaignButton = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12157 variant: "contained", 12158 size: "medium", 12159 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 12160 onClick: () => setIsNewCampaignDialogOpen(true), 12161 "data-tracking-id": "tracking-id-ads-campaigns-new-campaign-btn", 12162 children: "New campaign" 12163 }); 12164 const hasCampaigns = campaigns.length > 0; 12165 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12166 sx: adsPageSx, 12167 children: [ 12168 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 12169 area: "Programmatic / Campaigns", 12170 title: "Campaigns", 12171 count: campaigns.length, 12172 children: hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 12173 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Filter, { 12174 placeholderValue: campaignStatusDisplay[CampaignStatus.ACTIVE].label, 12175 selectedValue: statusFilter === CampaignStatus.ACTIVE ? null : statusFilter ? campaignStatusDisplay[statusFilter].label : "All statuses", 12176 onClear: () => setStatusFilter(CampaignStatus.ACTIVE), 12177 popoverTitle: "Filter by status", 12178 anchorEl: filterAnchorEl, 12179 setAnchorEl: setFilterAnchorEl, 12180 "data-tracking-id": "tracking-id-ads-campaigns-status-filter-btn", 12181 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.List, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 12182 dense: true, 12183 selected: statusFilter === null, 12184 onClick: () => { 12185 setStatusFilter(null); 12186 setFilterAnchorEl(null); 12187 }, 12188 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { primary: "All statuses" }) 12189 }), Object.values(CampaignStatus).map((status) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 12190 dense: true, 12191 selected: statusFilter === status, 12192 onClick: () => { 12193 setStatusFilter(status); 12194 setFilterAnchorEl(null); 12195 }, 12196 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { primary: campaignStatusDisplay[status].label }) 12197 }, status))] }) 12198 }), 12199 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 12200 sx: searchWrapperSx$3, 12201 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Search, { 12202 placeholder: "Search campaigns", 12203 value: searchTerm, 12204 onChange: (event) => setSearchTerm(event.target.value), 12205 onClear: () => setSearchTerm(""), 12206 numberOfResults: visibleCampaigns.length, 12207 inputProps: { "data-tracking-id": "tracking-id-ads-campaigns-search-input" } 12208 }) 12209 }), 12210 canEdit && newCampaignButton 12211 ] }) 12212 }), 12213 error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 12214 color: "error", 12215 "data-testid": "testId-ads-campaigns-error", 12216 children: [ 12217 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "The ads configuration is unreachable" }), 12218 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "This account's campaigns could not be read, so this list is not the whole picture." }), 12219 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxActions, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12220 variant: "text", 12221 size: "small", 12222 loading: refetching, 12223 onClick: () => void refetch(), 12224 "data-tracking-id": "tracking-id-ads-campaigns-retry-btn", 12225 children: "Try again" 12226 }) }) 12227 ] 12228 }),
12229 loading && !hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTableSkeleton, {}), 12230 !loading && !error && !hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEmptyState, { 12231 icon: import_faBullhorn.faBullhorn, 12232 heading: "No campaigns yet", 12233 subtitle: "Campaigns hold the flights, budgets and targeting that decide where this account's ads run.", 12234 children: canEdit && newCampaignButton 12235 }), 12236 hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignsSummaryStrip, { campaigns }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignsTable, { 12237 campaigns: visibleCampaigns, 12238 accountId, 12239 canEdit, 12240 onDuplicate: setCampaignToDuplicate, 12241 onMove: setCampaignToMove 12242 })] }), 12243 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NewCampaignDialog, { 12244 open: isNewCampaignDialogOpen, 12245 mode: GqlClientAdsSpaceCampaignMode.PROGRAMMATIC, 12246 saving: actions.saving, 12247 onClose: () => setIsNewCampaignDialogOpen(false), 12248 existingCampaignNames: campaigns.map((campaign) => campaign.name), 12249 onCreate: actions.create 12250 }), 12251 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DuplicateCampaignDialog, { 12252 campaign: campaignToDuplicate, 12253 saving: actions.saving, 12254 onClose: () => setCampaignToDuplicate(null), 12255 existingCampaignNames: campaigns.map((campaign) => campaign.name), 12256 onDuplicate: handleDuplicate 12257 }),
12258 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MoveCampaignDialog, { 12259 campaign: campaignToMove, 12260 studioPrograms, 12261 saving: actions.saving, 12262 onClose: () => setCampaignToMove(null), 12263 onMove: actions.move 12264 }) 12265 ] 12266 }); 12267}; 12268var searchWrapperSx$3 = { width: 280 }; 12269//#endregion 12270//#region client/components/newNav/components/ads/decoupled/DecoupledCampaignsTable.tsx 12271var import_faLinkSlash = require_faLinkSlash(); 12272/** 12273* Which partner buys a decoupled campaign, and which of the three serving endpoints they call, is not recorded 12274* anywhere in the schema - so those two columns are absent rather than guessed. 12275*/ 12276var DecoupledCampaignsTable = ({ campaigns, accountId }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 12277 component: import_material.Paper, 12278 variant: "outlined", 12279 sx: containerSx, 12280 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 12281 size: "small", 12282 stickyHeader: true, 12283 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 12284 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12285 sx: headCellSx, 12286 children: "Campaign" 12287 }), 12288 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12289 sx: headCellSx, 12290 children: "Status" 12291 }), 12292 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12293 align: "right", 12294 sx: headCellSx, 12295 children: "Creatives" 12296 }), 12297 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12298 sx: headCellSx, 12299 children: "Updated" 12300 }) 12301 ] }) }
12301), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: campaigns.map((campaign) => { 12302 const status = campaignStatusDisplay[campaign.status]; 12303 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 12304 sx: rowHoverSx, 12305 "data-testid": `testId-ads-decoupled-row-${campaign.id}`, 12306 children: [ 12307 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12308 sx: bodyCellSx, 12309 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 12310 sx: nameCellSx$1, 12311 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 12312 component: Link$6, 12313 variant: "subtitle2", 12314 underline: "hover", 12315 to: (0, import_dist$1.newNavAdsDecoupledCampaignSectionPath)({ 12316 accountId, 12317 campaignId: campaign.id, 12318 sectionId: "overview" 12319 }), 12320 "data-tracking-id": "tracking-id-ads-decoupled-campaign-link", 12321 children: campaign.name 12322 }) 12323 }) 12324 }), 12325 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12326 sx: bodyCellSx, 12327 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 12328 size: "small", 12329 label: status.label, 12330 color: status.color, 12331 variant: status.variant 12332 }) 12333 }), 12334 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12335 align: "right", 12336 sx: bodyCellSx, 12337 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12338 variant: "body1", 12339 children: campaign.creativeCount 12340 }) 12341 }), 12342 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12343 sx: bodyCellSx, 12344 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12345 variant: "body1", 12346 noWrap: true, 12347 children: campaign.updatedAt ? TimeUtils.getDesignSystemReadableTimeDate(campaign.updatedAt, true) : "-" 12348 }) 12349 }) 12350 ] 12351 }, campaign.id); 12352 }) })] 12353 }) 12354}); 12355var containerSx = { 12356 flex: 1, 12357 minHeight: 0 12358}; 12359var nameCellSx$1 = { 12360 display: "flex", 12361 flexDirection: "column", 12362 maxWidth: 320 12363}; 12364//#endregion 12365//#region client/components/newNav/components/ads/decoupled/DecoupledCampaignsPage.tsx 12366/** 12367* Decoupled campaigns: a partner owns the buy and calls us for the creative. Eight of them exist in production, 12368* all disabled since 2023, so this page's first real job is creating number nine. 12369*/ 12370var DecoupledCampaignsPage = () => { 12371 const accountId = useActiveAccountId(); 12372 const { userRoles } = useUserData(); 12373 const canEdit = isPermitted(userRoles, permissions.adsEditCampaignSettings); 12374 const { decoupledCampaigns, campaigns, loading, error, refetch } = useAdsSpaceCampaigns(); 12375 const actions = useAdsSpaceCampaignActions(); 12376 const [isNewCampaignDialogOpen, setIsNewCampaignDialogOpen] = (0, import_react.useState)(false); 12377 const newCampaignButton = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12378 variant: "contained", 12379 size: "medium", 12380 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 12381 onClick: () => setIsNewCampaignDialogOpen(true), 12382 "data-tracking-id": "tracking-id-ads-decoupled-new-campaign-btn", 12383 children: "New decoupled campaign" 12384 }); 12385 const hasCampaigns = decoupledCampaigns.length > 0; 12386 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12387 sx: adsPageSx, 12388 children: [ 12389 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 12390 area: "Decoupled / Decoupled campaigns", 12391 title: "Decoupled campaigns", 12392 count: decoupledCampaigns.length, 12393 children: hasCampaigns && canEdit && newCampaignButton 12394 }), 12395 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 12396 color: "info", 12397 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "SundaySky is not the DSP here" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "A partner has already won the impression and calls us for the creative, so there are no flights, no budget and n
12397o bidding on these campaigns - only what decides which video we return." })] 12398 }), 12399 error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 12400 color: "error", 12401 "data-testid": "testId-ads-decoupled-error", 12402 children: [ 12403 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "The ads configuration is unreachable" }), 12404 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "This account's decoupled campaigns could not be read, so this list is not the whole picture." }), 12405 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxActions, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12406 variant: "text", 12407 size: "small", 12408 onClick: () => void refetch(), 12409 "data-tracking-id": "tracking-id-ads-decoupled-retry-btn", 12410 children: "Try again" 12411 }) }) 12412 ] 12413 }), 12414 loading && !hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTableSkeleton, { rows: 3 }),
12415 !loading && !error && !hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEmptyState, { 12416 icon: import_faLinkSlash.faLinkSlash, 12417 heading: "No decoupled campaigns yet", 12418 subtitle: "A decoupled campaign is what a partner's DSP calls when it wants a video from us. Create one and give them its name.", 12419 children: canEdit && newCampaignButton 12420 }), 12421 hasCampaigns && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecoupledCampaignsTable, { 12422 campaigns: decoupledCampaigns, 12423 accountId 12424 }), 12425 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NewCampaignDialog, { 12426 open: isNewCampaignDialogOpen, 12427 mode: GqlClientAdsSpaceCampaignMode.DECOUPLED, 12428 saving: actions.saving, 12429 onClose: () => setIsNewCampaignDialogOpen(false), 12430 existingCampaignNames: [...campaigns, ...decoupledCampaigns].map((campaign) => campaign.name), 12431 onCreate: actions.create 12432 }) 12433 ] 12434 }); 12435}; 12436//#endregion 12437//#region client/components/newNav/components/ads/common/AdsDrillDownSection.tsx 12438/** 12439* A collection of configuration entities inside a settings section. The detail replaces the list in the same 12440* pane rather than opening a third column - the space already spends 475px on navigation before the content 12441* starts. 12442*/ 12443var AdsDrillDownSection = (props) => { 12444 const [selectedId, setSelectedId] = (0, import_react.useState)(null); 12445 const [searchTerm, setSearchTerm] = (0, import_react.useState)(""); 12446 const visibleRows = (0, import_react.useMemo)(() => { 12447 const term = searchTerm.trim().toLowerCase(); 12448 if (!term) return props.rows; 12449 return props.rows.filter((row) => props.searchKeys.some((key) => asText(row[key]).toLowerCase().includes(term))); 12450 }, [ 12451 props.rows, 12452 props.searchKeys, 12453 searchTerm 12454 ]); 12455 const canDrillDown = Boolean(props.renderDetail); 12456 const selected = canDrillDown ? props.rows.find((row) => row.id === selectedId) : void 0; 12457 if (selected && props.renderDetail) return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12458 sx: detailSx, 12459 children: [ 12460 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 12461 variant: "text", 12462 size: "small", 12463 sx: backLinkSx$1, 12464 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleLeft.faAngleLeft }), 12465 onClick: () => setSelectedId(null), 12466 "data-tracking-id": `${props.trackingIdPrefix}-back-btn`, 12467 children: props.title 12468 }), 12469 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12470 variant: "h4", 12471 children: props.getTitle(selected) 12472 }), 12473 props.renderDetail(selected) 12474 ] 12475 }); 12476 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12477 sx: listSx, 12478 children: [ 12479 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12480 sx: headerSx$3, 12481 children: [!props.titleInPageHeader && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12482 variant: "h5", 12483 sx: { minWidth: 0 }, 12484 children: props.title 12485 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 12486 sx: headerActionsSx$2, 12487 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 12488 sx: searchWrapperSx$2, 12489 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Search, { 12490 placeholder: props.searchPlaceholder, 12491 value: searchTerm, 12492 onChange: (event) => setSearchTerm(event.target.value), 12493 onClear: () => setSearchTerm(""), 12494 numberOfResults: visibleRows.length, 12495 inputProps: { "data-tracking-id": `${props.trackingIdPrefix}-search-input` } 12496 }) 12497 }), props.headerActions] 12498 })] 12499 }),
12500 props.description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12501 variant: "body2", 12502 color: "text.secondary", 12503 sx: descriptionSx, 12504 children: props.description 12505 }), 12506 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 12507 component: import_material.Paper, 12508 variant: "outlined", 12509 sx: tableContainerSx, 12510 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 12511 size: "small", 12512 stickyHeader: true, 12513 sx: tableSx$1, 12514 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [props.columns.map((column) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12515 align: column.align ?? "left", 12516 sx: headCellSx, 12517 children: column.label 12518 }, column.key)), canDrillDown && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: actionsCellSx })] }) }
12518), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [ 12519 props.loading && props.rows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12520 colSpan: props.columns.length + 1, 12521 sx: emptyCellSx$2, 12522 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTableSkeleton, { 12523 rows: 5, 12524 height: 32 12525 }) 12526 }) }), 12527 !props.loading && visibleRows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12528 colSpan: props.columns.length + 1, 12529 sx: emptyCellSx$2, 12530 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12531 variant: "body1", 12532 color: "text.secondary", 12533 children: searchTerm ? `No results for "${searchTerm}"` : props.emptyText ?? "Nothing here yet" 12534 }) 12535 }) }), 12536 visibleRows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 12537 sx: rowHoverSx, 12538 onClick: canDrillDown ? () => setSelectedId(row.id) : void 0, 12539 "data-testid": `ads-drilldown-row-${row.id}`, 12540 "data-tracking-id": canDrillDown ? `${props.trackingIdPrefix}-row-btn` : void 0, 12541 children: [props.columns.map((column) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12542 align: column.align ?? "left", 12543 sx: canDrillDown ? clickableCellSx : bodyCellSx, 12544 children: column.render ? column.render(row) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 12545 variant: "body1", 12546 children: asText(row[column.key]) || "-" 12547 }) 12548 }
12548, column.key)), canDrillDown && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 12549 sx: actionsCellSx, 12550 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 12551 sx: chevronSx$1, 12552 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }) 12553 }) 12554 })] 12555 }, row.id)) 12556 ] })] 12557 }) 12558 }) 12559 ] 12560 }); 12561}; 12562var listSx = { 12563 display: "flex", 12564 flexDirection: "column", 12565 gap: 2, 12566 flex: 1, 12567 minHeight: 0 12568}; 12569var detailSx = { 12570 display: "flex", 12571 flexDirection: "column", 12572 gap: 3, 12573 flex: 1, 12574 minHeight: 0, 12575 overflowY: "auto", 12576 paddingBottom: 2, 12577 ...import_dist$2.scrollOnHoverSxProps 12578}; 12579var tableContainerSx = { 12580 flex: 1, 12581 minHeight: 0, 12582 ...import_dist$2.scrollOnHoverSxProps 12583}; 12584var tableSx$1 = { 12585 tableLayout: "fixed", 12586 "& .MuiTableCell-root": { overflowWrap: "anywhere" } 12587}; 12588var backLinkSx$1 = { 12589 alignSelf: "flex-start", 12590 marginBottom: -2 12591}; 12592var headerSx$3 = { 12593 display: "flex", 12594 alignItems: "center", 12595 justifyContent: "space-between", 12596 gap: 2 12597}; 12598var descriptionSx = { marginTop: -1.5 }; 12599var headerActionsSx$2 = {
12600 display: "flex", 12601 alignItems: "center", 12602 gap: 1, 12603 flexShrink: 0 12604}; 12605var searchWrapperSx$2 = { width: 280 }; 12606var clickableCellSx = (0, import_dist$2.combineSxProps)(bodyCellSx, { cursor: "pointer" }); 12607var emptyCellSx$2 = { 12608 padding: "32px 16px", 12609 textAlign: "center" 12610}; 12611var chevronSx$1 = { 12612 fontSize: "12px !important", 12613 width: "12px !important", 12614 height: "12px !important", 12615 color: "text.disabled" 12616}; 12617//#endregion 12618//#region client/components/newNav/components/ads/common/useAdsSettingsForm.ts 12619/** 12620* Form state for one ads configuration entity. Nothing here reaches the server: an edit updates local state and 12621* only an explicit save commits it, which is the contract every surface in the space keeps. Errors are 12622* recomputed on every change rather than derived at render, so a field never flashes an error before it has 12623* been touched. 12624* 12625* A surface with no `onPersist` has no write path yet, and its save says so rather than silently doing nothing. 12626*/ 12627var useAdsSettingsForm = (groups, initialValues, entityLabel, onPersist, saving, childCollections = []) => { 12628 const [savedValues, setSavedValues] = (0, import_react.useState)(initialValues); 12629 const [values, setValues] = (0, import_react.useState)(initialValues); 12630 const [errors, setErrors] = (0, import_react.useState)({}); 12631 const childChangeCount = childCollections.reduce((total, collection) => total + collection.changeCount, 0); 12632 const changeCount = (0, import_react.useMemo)(() => countChanges(values, savedValues) + childChangeCount, [ 12633 values, 12634 savedValues, 12635 childChangeCount 12636 ]); 12637 const onChange = (key, value) => { 12638 const next = { 12639 ...values, 12640 [key]: value 12641 }; 12642 setValues(next); 12643 setErrors(validateFields(groups, next)); 12644 }; 12645 const onSave = async (comment) => { 12646 if (!onPersist) { 12647 setSavedValues(values); 12648 setInfoNotification({ message: `${entityLabel} is not connected to the ads configuration service yet, so this change stays on this page` }); 12649 return; 12650 } 12651 await onPersist(values, comment); 12652 setSavedValues(values); 12653 }; 12654 const onDiscard = () => { 12655 setValues(savedValues); 12656 setErrors({}); 12657 childCollections.forEach((collection) => collection.reset()); 12658 }; 12659 return { 12660 values, 12661 errors, 12662 dirty: changeCount > 0, 12663 changeCount, 12664 saving, 12665 onChange, 12666 onSave, 12667 onDiscard 12668 }; 12669}; 12670//#endregion 12671//#region client/components/newNav/components/ads/dsp/nexxenIntegration.ts 12672/** 12673* The Nexxen integration's fixed values, from the platform's own reference (`ads-platform`, 12674* `docs/NEXXEN-INTEGRATION.md`). None of them varies by account: Nexxen tells our accounts apart from the 12675* `acc=` value inside the data pixel, not from the ids, so there is nothing to request from them when a new 12676* account is onboarded. What is genuinely per-account is only the experiment that splits its shoppers. 12677*/ 12678var NEXXEN_DSP_NAME = "nexxen"; 12679/** Every site event. Stored as a plain URL - the route A generator wraps it, route B redirects to it. */ 12680var NEXXEN_DATA_PIXEL_TEMPLATE = "https://d.turn.com/r/dd/id/${NEXXEN_ID}/kv/cat=${CATEGORY},cb=${CB},ordid=${EVENT_LABEL},method=${METHOD},prod=${PRODUCT},acc=${ACCOUNT},seg=${udt.seg},sskyid=${SSKY_USER_ID},sp=${SOURCE_PAGE},fam=${udt.family},value=${EVENT_VALUE}"; 12681/** Conversions only. Carries the shopper itself, because route B drops the data pixel on a conversion. */ 12682var NEXXEN_BEACON_TEMPLATE = "https://r.turn.com/r/beacon?b2=${NEXXEN_BEACON_ID}&cid=${EVENT_LABEL}&bprice=${EVENT_VALUE}&fpcid=${SSKY_USER_ID}"; 12683/** Both Nexxen ids, pre-encoded as Nexxen supplies them. The same string for every account. */ 12684var NEXXEN_LINK_DATA = "id=L21rdC8yMzQ0L2NpZC8xNzUwODExMTc2L3QvMg;b2=1Iu7eiTMLvRqedXJrhojE-JrZ2skq51XXiS63prY2DjNJkug-ESTYTT70Sg7a3bIcPpo9gvl9eRS4Wwqj-KKw"; 12685/** Half the shoppers go to Nexxen. The tracking server rejects any other split, so this is not a preference. */ 12686var NEXXEN_SPLIT_POINT = 5e3; 12687var NEXXEN_RANGE_MAX = 1e4; 12688/** 12689* The names the tracking server rebuilds from the account name and compares character for character. All 12690* lowercase; a capital anywhere fails validation and Nexxen stays off for the account. 12691*/ 12692var nexxenNames = (adsAccountName) => { 12693 const account = (adsAccountName ?? "").trim().toLowerCase(); 12694 return { 12695 account, 12696 experiment: `${account}_nexxen_ab`, 12697 sundayskyGroup: `${account}_sundaysky`, 12698 nexxenGroup: `${account}_nexxen` 12699 }; 12700}; 12701/** What each macro in the two templates resolves to, for someone reading a template rather than writing one. */ 12702var nexxenMacros = [ 12703 { 12704 macro: "${NEXXEN_ID}", 12705 meaning: "The data pixel id, from this account's link data key id" 12706 }, 12707 { 12708 macro: "${NEXXEN_BEACON_ID}", 12709 meaning: "The beacon token, from this account's link data key b2" 12710 }, 12711 { 12712 macro: "${SSKY_USER_ID}", 12713 meaning: "The SundaySky shopper id - without it Nexxen cannot attribute anything" 12714 }, 12715 { 12716 macro: "${ACCOUNT}", 12717 meaning: "The account the event came from. This is how Nexxen tells our accounts apart" 12718 }, 12719 { 12720 macro: "${METHOD}",
12721 meaning: "The site-action method: uui, cat, atc, conv" 12722 }, 12723 { 12724 macro: "${EVENT_LABEL}", 12725 meaning: "The order id on a conversion" 12726 }, 12727 { 12728 macro: "${EVENT_VALUE}", 12729 meaning: "The order value on a conversion" 12730 }, 12731 { 12732 macro: "${CATEGORY}, ${PRODUCT}", 12733 meaning: "The category and product the shopper viewed" 12734 }, 12735 { 12736 macro: "${SOURCE_PAGE}", 12737 meaning: "The page the event came from" 12738 }, 12739 { 12740 macro: "${udt.seg}, ${udt.family}", 12741 meaning: "Segment and family, from the site tag's udt fields" 12742 }, 12743 { 12744 macro: "${CB}", 12745 meaning: "A fresh cache buster per link" 12746 } 12747]; 12748//#endregion 12749//#region client/components/newNav/components/ads/dsp/dspModel.ts 12750/** 12751* The DSP surfaces. A DSP is the demand side of a decoupled campaign: a partner buys the impression and calls 12752* the ad server for the video, and these rows are how the tracking server builds the retargeting and sync links 12753* it hands them. 12754*/ 12755/** Substituted at runtime by the tracking server. Documented on the page because nothing else documents them. */ 12756var dspTemplatePlaceholders = [ 12757 { 12758 placeholder: "${SID}", 12759 meaning: "The segment id for the event type, taken from the account's DSP data" 12760 }, 12761 { 12762 placeholder: "${REDIRECT}", 12763 meaning: "The URL to redirect to after the DSP pixel fires" 12764 }, 12765 { 12766 placeholder: "${TM_USER_ID}", 12767 meaning: "The DSP's user id" 12768 }, 12769 { 12770 placeholder: "${SSKY_USER_ID}", 12771 meaning: "The SundaySky user id" 12772 } 12773]; 12774var dspTemplateExamples = [{ 12775 label: "Retargeting", 12776 value: "SSKY.fire('https://rtd-tm.everesttech.net/upi/?sid=${SID}&cs=1&redir=${REDIRECT}');" 12777}, { 12778 label: "Exchange sync", 12779 value: "https://vop.sundaysky.com/exchange_sync?exchange=51&exchangeUserId=${TM_USER_ID}&sskyUserId=${SSKY_USER_ID}" 12780}]; 12781/** The nine operators the sync-condition evaluator understands. */ 12782var dspConditionOperators = [ 12783 { 12784 value: "EQUALS", 12785 label: "Equals" 12786 }, 12787 { 12788 value: "DOES_NOT_EQUAL", 12789 label: "Does not equal" 12790 }, 12791 { 12792 value: "EXISTS", 12793 label: "Exists" 12794 }, 12795 { 12796 value: "DOES_NOT_EXIST", 12797 label: "Does not exist" 12798 }, 12799 { 12800 value: "STRING_CONTAINS", 12801 label: "Contains" 12802 }, 12803 { 12804 value: "STRING_DOES_NOT_CONTAIN", 12805 label: "Does not contain" 12806 }, 12807 { 12808 value: "ONE_OF", 12809 label: "One of" 12810 }, 12811 { 12812 value: "NOT_ONE_OF", 12813 label: "Not one of" 12814 }, 12815 { 12816 value: "GREATER_THAN", 12817 label: "Greater than" 12818 } 12819]; 12820/** 12821* The link-data key set depends on the DSP, and the keys are the same event-type vocabulary as the site-action 12822* `method` values on the account tags page. A typo in one silently stops that event type being reported, which 12823* is why the value is a dropdown rather than a line of text. 12824*/ 12825var dspLinkDataKeys = { 12826 adobe: [ 12827 { 12828 value: "cat", 12829 label: "cat - category view" 12830 }, 12831 { 12832 value: "uui", 12833 label: "uui - user identification" 12834 }, 12835 { 12836 value: "atc", 12837 label: "atc - add to cart" 12838 }, 12839 { 12840 value: "conv", 12841 label: "conv - conversion" 12842 }, 12843 { 12844 value: "cart", 12845 label: "cart - cart view" 12846 } 12847 ], 12848 thetradedesk: [{ 12849 value: "adv", 12850 label: "adv - advertiser" 12851 }, { 12852 value: "ct", 12853 label: "ct - conversion tracker" 12854 }], 12855 [NEXXEN_DSP_NAME]: [{ 12856 value: "id", 12857 label: "id - the data pixel id" 12858 }, { 12859 value: "b2", 12860 label: "b2 - the conversion beacon token" 12861 }] 12862}; 12863/** Empty for a DSP whose vocabulary is not known here, which is what makes the key a free-text field instead. */ 12864var dspLinkDataKeyOptions = (dspName) => dspLinkDataKeys[(dspName ?? "").toLowerCase()] ?? []; 12865/** 12866* Stored as `key=value` entries; edited as rows so a mistyped key is visible. Separated by a semicolon or a 12867* newline and split on the first `=`, matching the tracking server's own parser - a row written by hand as 12868* `id=...;b2=...` has to come back as two rows, and a base64 value's trailing `=` padding has to survive. 12869*/ 12870var parseDspLinkData = (linkData) => asText(linkData).split(/[;\r\n]+/).map((line) => line.trim()).filter(Boolean).map((line, index) => { 12871 const separator = line.indexOf("="); 12872 return separator === -1 ? { 12873 id: `link-${index}`, 12874 linkKey: line, 12875 linkValue: "" 12876 } : { 12877 id: `link-${index}`, 12878 linkKey: line.slice(0, separator).trim(), 12879 linkValue: line.slice(separator + 1).trim() 12880 }; 12881}); 12882var serializeDspLinkData = (rows) => rows.filter((row) => asText(row.linkKey)).map((row) => `${asText(row.linkKey)}=${asText(row.linkValue)}`).join("\n"); 12883/** A filter that does not compile reports nobody; one that is empty or `.*` reports everybody. */ 12884var validateUserFilterRegex = (value) => { 12885 const text = asText(value).trim(); 12886 if (!text) return null; 12887 try { 12888 new RegExp(text); 12889 return null; 12890 } catch { 12891 return "This is not a valid regular expression"; 12892 } 12893}; 12894var describeUserFilter = (value) => { 12895 const text = asText(value).trim(); 12896 return !text || text === ".*" ? "All users will be reported to this DSP." : null; 12897}; 12898//#endregion
12899//#region client/components/newNav/components/ads/dsp/DspAccountDataSection.tsx 12900var accountDataGroups = (dspOptions) => [{ 12901 id: "identity", 12902 title: "Platform", 12903 fields: [{ 12904 key: "dspId", 12905 label: "Demand-side platform", 12906 type: "select", 12907 options: dspOptions, 12908 validate: required("Pick the platform this data belongs to") 12909 }, { 12910 key: "userFilterRegex", 12911 label: "User filter", 12912 type: "text", 12913 wide: true, 12914 validate: validateUserFilterRegex, 12915 help: "If set, only users matching this regex will be reported to the DSP" 12916 }] 12917}]; 12918/** 12919* A known DSP gets its own vocabulary as a dropdown, because a typo there silently stops that entry being 12920* reported. One this page has no vocabulary for gets a text box, so a new integration is not blocked on an 12921* edit here. 12922*/ 12923var linkDataFields = (dspName) => { 12924 const options = dspLinkDataKeyOptions(dspName); 12925 return [options.length > 0 ? { 12926 key: "linkKey", 12927 label: "Key", 12928 type: "select", 12929 options 12930 } : { 12931 key: "linkKey", 12932 label: "Key", 12933 type: "text" 12934 }, { 12935 key: "linkValue", 12936 label: "Value", 12937 type: "text" 12938 }]; 12939}; 12940var newAccountDataFields = (dspOptions) => [ 12941 { 12942 key: "dspId", 12943 label: "Demand-side platform", 12944 type: "select", 12945 options: dspOptions, 12946 validate: required("Pick the platform this account will report to") 12947 }, 12948 { 12949 key: "linkData", 12950 label: "Link data", 12951 type: "longText", 12952 wide: true, 12953 help: "One key=value per line. Nexxen takes the shared id and b2 pair; leave it empty to fill it in afterwards" 12954 }, 12955 { 12956 key: "userFilterRegex", 12957 label: "User filter", 12958 type: "text", 12959 wide: true, 12960 validate: validateUserFilterRegex, 12961 help: "If set, only users matching this regex will be reported to the DSP" 12962 } 12963]; 12964var linkDataDescription = (dspName) => dspName.toLowerCase() === "nexxen" ? "The two Nexxen pixel ids, substituted into the platform's templates. The same pair for every account - copy it from the Nexxen integration guide rather than asking Nexxen for one." : "What segment id to report for each event type. The keys are the same vocabulary as the site-action methods on the account tags page."; 12965var conditionFields = [ 12966 { 12967 key: "eventField", 12968 label: "Event field", 12969 type: "text", 12970 help: "Name of event field to test" 12971 }, 12972 { 12973 key: "type", 12974 label: "Operator", 12975 type: "select", 12976 options: dspConditionOperators, 12977 help: "Condition operator" 12978 }, 12979 { 12980 key: "eventValues", 12981 label: "Values", 12982 type: "text", 12983 help: "Values of events (comma-separated)" 12984 } 12985]; 12986var AccountDataDetail = (props) => { 12987 const groups = accountDataGroups(props.dspOptions); 12988 const [linkRows, setLinkRows] = (0, import_react.useState)(parseDspLinkData(props.accountData.linkData)); 12989 const conditions = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DSP_SYNC_CONDITION, props.accountData.id); 12990 const [conditionRows, setConditionRows] = (0, import_react.useState)(null); 12991 const rows = conditionRows ?? conditions.rows; 12992 const form = useAdsSettingsForm(groups, { 12993 ...props.accountData, 12994 linkData: serializeDspLinkData(linkRows) 12995 }, "The DSP account data", async (values, comment) => { 12996 await props.save([{ 12997 ...values, 12998 linkData: serializeDspLinkData(linkRows), 12999 id: props.accountData.id 13000 }], comment); 13001 const removedIds = conditions.rows.filter((saved) => !rows.some((row) => row.id === saved.id)).map((row) => row.id); 13002 if (conditionRows) { 13003 await conditions.save(rows, comment, removedIds); 13004 setConditionRows(null); 13005 } 13006 }, props.saving || conditions.saving); 13007 const filterWarning = describeUserFilter(form.values.userFilterRegex); 13008 const dspName = props.dspNames[asText(form.values.dspId)] ?? ""; 13009 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AdsSettingsForm, { 13010 groups, 13011 form, 13012 trackingIdPrefix: "tracking-id-ads-dsp-account-data", 13013 children: [ 13014 filterWarning && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 13015 color: "warning", 13016 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: filterWarning }) 13017 }), 13018 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 13019 title: "Link data", 13020 description: linkDataDescription(dspName), 13021 fields: linkDataFields(dspName), 13022 rows: linkRows, 13023 addLabel: "Add entry", 13024 emptyText: "No link data - this platform has nothing to substitute into
13024its templates", 13025 rowTitle: (row) => asText(row.linkKey) || "Entry", 13026 onChange: (next) => { 13027 setLinkRows(next); 13028 form.onChange("linkData", serializeDspLinkData(next)); 13029 }, 13030 trackingIdPrefix: "tracking-id-ads-dsp-link-data" 13031 }), 13032 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 13033 title: "Sync conditions", 13034 description: "All conditions must match for an event to produce a retargeting link. An event failing any of them gets no link, and no conditions means no filtering.", 13035 fields: conditionFields, 13036 rows, 13037 addLabel: "Add condition", 13038 emptyText: "No conditions - every event produces a link", 13039 rowTitle: (row) => asText(row.eventField) || "Condition", 13040 onChange: setConditionRows, 13041 trackingIdPrefix: "tracking-id-ads-dsp-sync-conditions" 13042 }) 13043 ] 13044 }); 13045}; 13046/** 13047* This account's data on each DSP: who gets reported, and what segment id each event type carries. Reachable 13048* from account settings and from the decoupled area, because someone setting up decoupled delivery should not 13049* have to know it is filed under account settings. 13050*/ 13051var DspAccountDataSection = () => { 13052 const platforms = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DSP); 13053 const accountData = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DSP_ACCOUNT_DATA); 13054 const [isAdding, setIsAdding] = (0, import_react.useState)(false); 13055 const dspOptions = platforms.rows.map((row) => ({ 13056 value: row.id, 13057 label: asText(row.name) 13058 })); 13059 const dspNames = Object.fromEntries(platforms.rows.map((row) => [row.id, asText(row.name)])); 13060 const createAccountData = async (row) => { 13061 await accountData.create(row, `Connected this account to ${dspNames[asText(row.dspId)] ?? "a demand-side platform"}`, "dspId"); 13062 setIsAdding(false); 13063 }; 13064 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13065 sx: sectionSx$6, 13066 children: [ 13067 platforms.rows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 13068 color: "info", 13069 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "No demand-side platforms are configured" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "A platform has to exist before an account can carry data for it." })] 13070 }), 13071 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 13072 title: "DSP account data", 13073 titleInPageHeader: true, 13074 searchPlaceholder: "Search platforms", 13075 rows: accountData.rows, 13076 loading: accountData.loading, 13077 searchKeys: ["dspId", "userFilterRegex"], 13078 columns: [ 13079 { 13080 key: "dspId", 13081 label: "Platform", 13082 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13083 variant: "subtitle2", 13084 children: dspNames[asText(row.dspId)] ?? asText(row.dspId) 13085 }) 13086 }, 13087 { 13088 key: "userFilterRegex", 13089 label: "User filter" 13090 }, 13091 { 13092 key: "linkData", 13093 label: "Event types", 13094 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: parseDspLinkData(row.linkData).length }) 13095 } 13096 ], 13097 getTitle: (row) => dspNames[asText(row.dspId)] ?? "DSP account data",
13098 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountDataDetail, { 13099 accountData: row, 13100 dspOptions, 13101 dspNames, 13102 save: accountData.save, 13103 saving: accountData.saving 13104 }, row.id), 13105 emptyText: "This account reports to no demand-side platform yet.", 13106 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13107 variant: "outlined", 13108 size: "medium", 13109 disabled: platforms.rows.length === 0, 13110 onClick: () => setIsAdding(true), 13111 "data-testid": "ads-dsp-account-data-new-btn", 13112 "data-tracking-id": "tracking-id-ads-dsp-account-data-new-btn", 13113 children: "Connect a platform" 13114 }), 13115 trackingIdPrefix: "tracking-id-ads-dsp-account-data" 13116 }), 13117 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 13118 open: isAdding, 13119 title: "Connect a demand-side platform", 13120 fields: newAccountDataFields(dspOptions), 13121 row: null, 13122 otherRows: accountData.rows, 13123 validateRow: (row, otherRows) => otherRows.some((other) => asText(other.dspId) === asText(row.dspId)) ? { dspId: "This account already reports to that platform" } : {}, 13124 onClose: () => setIsAdding(false), 13125 onSubmit: (row) => void createAccountData(row).catch(() => setIsAdding(false)), 13126 trackingIdPrefix: "tracking-id-ads-dsp-account-data-new" 13127 }) 13128 ] 13129 }); 13130}; 13131var sectionSx$6 = { 13132 display: "flex", 13133 flexDirection: "column", 13134 gap: 2, 13135 flex: 1, 13136 minHeight: 0 13137}; 13138//#endregion 13139//#region client/components/newNav/components/ads/dsp/DspPlatformsSection.tsx 13140var placeholderHelp = dspTemplatePlaceholders.map((entry) => `${entry.placeholder} - ${entry.meaning}`).join("; "); 13141var dspGroups = [{ 13142 id: "identity", 13143 title: "Identity", 13144 fields: [{ 13145 key: "name", 13146 label: "Name", 13147 type: "text", 13148 validate: required("A DSP needs a name"), 13149 help: "Identifies the DSP; used to pick the right link generator in the tracking server" 13150 }] 13151}, { 13152 id: "templates", 13153 title: "Link templates", 13154 description: `Placeholders substituted at runtime: ${placeholderHelp}`, 13155 fields: [{ 13156 key: "retargetingTemplate", 13157 label: "Retargeting template", 13158 type: "longText", 13159 wide: true, 13160 help: "General template for retargeting links for this DSP" 13161 }, { 13162 key: "exchangeSyncTemplate", 13163 label: "Exchange sync template", 13164 type: "longText", 13165 wide: true, 13166 help: "General template for sync redirect (to VOP) links for this DSP" 13167 }] 13168}]; 13169var DspDetail = ({ dsp, save, saving }) => { 13170 const form = useAdsSettingsForm(dspGroups, dsp, "The demand-side platform", (values, comment) => save([{ 13171 ...values, 13172 id: dsp.id 13173 }], comment), saving); 13174 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 13175 groups: dspGroups, 13176 form, 13177 saveWarning: "The retargeting template runs as JavaScript on customer pages. A malformed one stops all retargeting for every account on this DSP.", 13178 trackingIdPrefix: "tracking-id-ads-dsp", 13179 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13180 sx: examplesSx, 13181 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13182 variant: "h5", 13183 children: "Adobe, as a worked example" 13184 }), dspTemplateExamples.map((example) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13185 variant: "helper", 13186 color: "text.secondary", 13187 sx: codeSx, 13188 children: `${example.label}: ${example.value}` 13189 }, example.label))] 13190 }) 13191 }); 13192}; 13193/** 13194* The demand-side platforms the tracking server knows how to build links for. One row is live today, and both of 13195* its templates are code that runs against real traffic - so this page carries the warning and the worked 13196* example rather than presenting them as ordinary text fields. 13197*/ 13198var DspPlatformsSection = () => { 13199 const { rows, save, saving, loading, create } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DSP); 13200 const [isAdding, setIsAdding] = (0, import_react.useState)(false); 13201 const createPlatform = async (row) => { 13202 await create(row, `Added the demand-side platform ${asText(row.name)}`); 13203 setIsAdding(false); 13204 }; 13205 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13206 sx: sectionSx$5, 13207 children: [
13208 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 13209 color: "warning", 13210 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "These templates run on customer pages" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "The retargeting template is executed as JavaScript by the tracking server's site tag. Adding a DSP row alone serves nothing - the ad server also needs a matching /dec_ endpoint, which is code." })] 13211 }), 13212 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 13213 title: "Demand-side platforms", 13214 titleInPageHeader: true, 13215 searchPlaceholder: "Search platforms", 13216 rows, 13217 loading, 13218 searchKeys: ["name"], 13219 columns: [{ 13220 key: "name", 13221 label: "Name", 13222 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13223 variant: "subtitle2", 13224 children: asText(row.name) 13225 }) 13226 }, { 13227 key: "retargetingTemplate", 13228 label: "Retargeting template" 13229 }], 13230 getTitle: (row) => asText(row.name),
13231 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DspDetail, { 13232 dsp: row, 13233 save, 13234 saving 13235 }, row.id), 13236 emptyText: "No demand-side platforms are configured", 13237 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13238 variant: "outlined", 13239 size: "medium", 13240 onClick: () => setIsAdding(true), 13241 "data-testid": "ads-dsp-new-platform-btn", 13242 "data-tracking-id": "tracking-id-ads-dsp-new-platform-btn", 13243 children: "New platform" 13244 }), 13245 trackingIdPrefix: "tracking-id-ads-dsp-platforms" 13246 }), 13247 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 13248 open: isAdding, 13249 title: "New demand-side platform", 13250 fields: dspGroups.flatMap((group) => group.fields), 13251 row: null, 13252 otherRows: rows, 13253 validateRow: (row, otherRows) => otherRows.some((other) => asText(other.name).toLowerCase() === asText(row.name).trim().toLowerCase()) ? { name: "A platform with that name already exists" } : {}, 13254 onClose: () => setIsAdding(false), 13255 onSubmit: (row) => void createPlatform(row).catch(() => setIsAdding(false)), 13256 trackingIdPrefix: "tracking-id-ads-dsp-new-platform" 13257 }) 13258 ] 13259 }); 13260}; 13261var sectionSx$5 = { 13262 display: "flex", 13263 flexDirection: "column", 13264 gap: 2, 13265 flex: 1, 13266 minHeight: 0 13267}; 13268var examplesSx = { 13269 display: "flex", 13270 flexDirection: "column", 13271 gap: 1 13272}; 13273var codeSx = { 13274 fontFamily: "monospace", 13275 wordBreak: "break-all" 13276}; 13277//#endregion 13278//#region client/components/newNav/components/ads/dsp/NexxenIntegrationDialog.tsx 13279var Step = ({ number, title, children }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13280 sx: stepSx, 13281 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13282 sx: stepHeaderSx, 13283 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13284 sx: stepNumberSx, 13285 children: number 13286 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13287 variant: "h5", 13288 children: title 13289 })] 13290 }
13290), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13291 sx: stepBodySx, 13292 children 13293 })] 13294}); 13295var Field = ({ label, children }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13296 sx: fieldSx, 13297 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13298 variant: "subtitle2", 13299 sx: fieldLabelSx, 13300 children: label 13301 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13302 sx: fieldValueSx, 13303 children 13304 })] 13305}); 13306/** 13307* How to put one account onto Nexxen, on the page where two of the three steps are done. Everything Nexxen 13308* needs is the same for every account, so this is a copy-and-paste job rather than a conversation with them - 13309* the only thing that has to be typed out per account is the experiment that splits its shoppers in half. 13310*/ 13311var NexxenIntegrationDialog = ({ open, onClose }) => { 13312 const accountId = useActiveAccountId(); 13313 const { adsAccountName } = useAdsSpaceAccount(); 13314 const names = nexxenNames(adsAccountName); 13315 const hasAccountName = Boolean(names.account); 13316 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 13317 open, 13318 onClose, 13319 PaperProps: { sx: paperSx$2 }, 13320 children: [ 13321 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 13322 CloseIconButtonProps: { 13323 onClick: onClose, 13324 "data-tracking-id": "tracking-id-ads-nexxen-guide-close-btn" 13325 }, 13326 children: "Nexxen integration" 13327 }), 13328 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13329 sx: contentSx$5, 13330 "data-testid": "ads-nexxen-guide", 13331 children: [ 13332 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13333 variant: "body1", 13334 color: "text.secondary", 13335 children: "Nexxen bids on shoppers we have seen. Once an account is switched on, every site event it sends us is also reported to Nexxen - but only for half its shoppers, so the two halves can be compared. Nexxen supplies the same two pixels and the same two ids to every account and tells our accounts apart from the account name inside the pixel, so there is nothing to request from them when you add one." 13336 }), 13337 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 13338 color: "warning", 13339 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "Check the account can actually receive it first" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13340 variant: "body1", 13341 color: "text.secondary", 13342 children: [ 13343 "An ineligible account fails silently: the counters look healthy and nothing ever reaches Nexxen. The account has to be enabled in the ads platform - a disabled one can never load its experiment - and its site events have to arrive in a shape where a pixel reaches the shopper's browser. Events sent to", 13344 " ", 13345 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "/t/v1/img" }), 13346 " ", 13347 "always do. Events sent to", 13348 " ", 13349 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "/t/v1/j" }), 13350 " ", 13351 "only do when the page runs the response as a script; when our own SDK pulls it as an image the browser throws every pixel away. Confirm which one the page does before switching the account on." 13352 ] 13353 }) })] 13354 }), 13355 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 13356 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Step, { 13357 number: 1, 13358 title: "The Nexxen platform - once for the whole system", 13359 children: [ 13360 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13361 variant: "body1", 13362 color: "text.secondary", 13363 children: [ 13364 "Skip this if a platform named", 13365 " ", 13366 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: NEXXEN_DSP_NAME }), 13367 " ", 13368 "is already in the list behind this dialog. It is shared by every account, and it is inert until an account points at it. Both templates are plain URLs - never JavaScript, because one pair has to serve both integration shapes." 13369 ] 13370 }),
13371 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13372 label: "Name", 13373 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13374 value: NEXXEN_DSP_NAME, 13375 monospace: true, 13376 maxWidth: 520, 13377 trackingId: "tracking-id-ads-nexxen-guide-name-copy" 13378 }) 13379 }), 13380 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13381 label: "Retargeting template - the data pixel, sent on every site event", 13382 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13383 value: NEXXEN_DATA_PIXEL_TEMPLATE, 13384 monospace: true, 13385 maxWidth: 520, 13386 trackingId: "tracking-id-ads-nexxen-guide-data-pixel-copy" 13387 }) 13388 }), 13389 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13390 label: "Exchange sync template - the beacon, sent on conversions only", 13391 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13392 value: NEXXEN_BEACON_TEMPLATE, 13393 monospace: true, 13394 maxWidth: 520, 13395 trackingId: "tracking-id-ads-nexxen-guide-beacon-copy" 13396 }) 13397 }), 13398 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13399 variant: "helper", 13400 color: "text.secondary", 13401 children: "Nexxen writes their own macros as [ORDER_ID], [REVENUE] and [SSKYID]. Those are their notation for \"put this here\", not something we substitute - the templates above are already translated. Paste their spec verbatim and the URL we serve carries the literal brackets." 13402 }), 13403 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13404 sx: macrosSx, 13405 children: nexxenMacros.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13406 variant: "helper", 13407 color: "text.secondary", 13408 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: entry.macro }), ` - ${entry.meaning}`] 13409 }, entry.macro)) 13410 }), 13411 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13412 variant: "text", 13413 size: "small", 13414 component: Link$6, 13415 to: (0, import_dist$1.newNavAdsDspSectionPath)({ 13416 accountId, 13417 sectionId: "platforms" 13418 }), 13419 onClick: onClose, 13420 "data-tracking-id": "tracking-id-ads-nexxen-guide-platforms-link", 13421 children: "Go to Platforms" 13422 }) }) 13423 ] 13424 }), 13425 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 13426 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Step, { 13427 number: 2, 13428 title: "The 50/50 split - once for this account", 13429 children: [ 13430 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13431 variant: "body1", 13432 color: "text.secondary", 13433 children: [ 13434 "This is the only genuinely per-account piece. The tracking server rebuilds these three names from the account name and compares them character for character, so they are not free text: all lowercase, exactly as below. The two groups have to cover 0 to", 13435 ` ${NEXXEN_RANGE_MAX} `, 13436 "with no gap and no overlap and", 13437 ` ${NEXXEN_SPLIT_POINT} `, 13438 "each - anything else fails validation and Nexxen stays off for the account." 13439 ] 13440 }), 13441 hasAccountName ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 13442 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13443 label: "Experiment name and salt", 13444 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13445 value: names.experiment, 13446 monospace: true, 13447 maxWidth: 520, 13448 trackingId: "tracking-id-ads-nexxen-guide-experiment-copy" 13449 }) 13450 }), 13451 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13452 label: `First group - the half that stays ours, 0 to ${NEXXEN_SPLIT_POINT}`, 13453 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13454 value: names.sundayskyGroup, 13455 monospace: true, 13456 maxWidth: 520, 13457 trackingId: "tracking-id-ads-nexxen-guide-sundaysky-group-copy" 13458 }) 13459 }),
13460 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13461 label: `Second group - the half reported to Nexxen, ${NEXXEN_SPLIT_POINT} to ${NEXXEN_RANGE_MAX}`, 13462 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13463 value: names.nexxenGroup, 13464 monospace: true, 13465 maxWidth: 520, 13466 trackingId: "tracking-id-ads-nexxen-guide-nexxen-group-copy" 13467 }) 13468 }) 13469 ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 13470 color: "info", 13471 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "This Studio account is not connected to an ads account yet, so the names cannot be worked out. They are the ads account name, lowercase, followed by _nexxen_ab for the experiment and _sundaysky and _nexxen for its two groups." }) 13472 }), 13473 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13474 variant: "helper", 13475 color: "text.secondary", 13476 children: "Leave the end date empty. A start date of today is fine - the split takes effect as soon as the experiment is running. New experiments open with a control and a test group already covering the right halves, so only the three names have to be changed." 13477 }), 13478 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13479 variant: "text", 13480 size: "small", 13481 component: Link$6, 13482 to: (0, import_dist$1.newNavAdsExperimentsPath)({ accountId }), 13483 onClick: onClose, 13484 "data-tracking-id": "tracking-id-ads-nexxen-guide-experiments-link", 13485 children: "Go to Experiments" 13486 }) }) 13487 ] 13488 }), 13489 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 13490 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Step, { 13491 number: 3, 13492 title: "Switch this account on", 13493 children: [ 13494 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13495 variant: "body1", 13496 color: "text.secondary", 13497 children: [ 13498 "Do this last. Connecting the account before its experiment exists reports nobody and logs a fatal for the whole time in between. On the", 13499 " ", 13500 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: "This account" }), 13501 " ", 13502 "tab, connect the", 13503 " ", 13504 /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: NEXXEN_DSP_NAME }), 13505 " ", 13506 "platform and paste the link data below. Leave the user filter empty: the experiment is what decides who is reported." 13507 ] 13508 }), 13509 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, { 13510 label: "Link data - the same pair for every account", 13511 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsCopyableText, { 13512 value: NEXXEN_LINK_DATA, 13513 monospace: true, 13514 maxWidth: 520, 13515 trackingId: "tracking-id-ads-nexxen-guide-link-data-copy" 13516 }) 13517 }), 13518 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Typography, { 13519 variant: "helper", 13520 color: "text.secondary", 13521 children: [ 13522 "These are ids, never URLs. Putting a template in here substitutes a whole URL into the placeholder and the pixel we serve carries a literal $", 13523 "{", 13524 "NEXXEN_ID", 13525 "}", 13526 ". The two are different values and are not interchangeable: id feeds the data pixel, b2 feeds the beacon, and swapping them produces URLs that still look perfectly well formed." 13527 ] 13528 }), 13529 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13530 variant: "text", 13531 size: "small", 13532 component: Link$6, 13533 to: (0, import_dist$1.newNavAdsDspSectionPath)({ 13534 accountId, 13535 sectionId: "account-data" 13536 }), 13537 onClick: onClose, 13538 "data-tracking-id": "tracking-id-ads-nexxen-guide-account-data-link", 13539 children: "Go to This account" 13540 }) }) 13541 ] 13542 }),
13543 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, {}), 13544 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13545 sx: afterSx, 13546 children: [ 13547 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13548 sx: afterHeaderSx, 13549 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 13550 size: "small", 13551 variant: "standard", 13552 color: "success", 13553 label: "After you save" 13554 }) 13555 }), 13556 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13557 variant: "body1", 13558 color: "text.secondary", 13559 children: "Saving here already nudges the running servers - the ads services pick the account up on their next scan, about a minute later, with nothing else to run. Then watch the two per-account counters in the tracking server's summaries: reported and skipped_sundaysky_group should be roughly equal. Both report even at zero, so seeing them at all is the confirmation the account is live. Silence means it is not." 13560 }), 13561 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13562 variant: "body1", 13563 color: "text.secondary", 13564 children: "To pause an account without unpicking anything, give its experiment an end date. The configuration survives, reporting stops within a minute, and clearing the end date starts it again." 13565 }) 13566 ] 13567 }) 13568 ] 13569 }) }), 13570 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogActions, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13571 variant: "contained", 13572 size: "large", 13573 onClick: onClose, 13574 "data-tracking-id": "tracking-id-ads-nexxen-guide-done-btn", 13575 children: "Done" 13576 }) }) 13577 ] 13578 }); 13579}; 13580var paperSx$2 = { 13581 width: 720, 13582 maxWidth: "calc(100vw - 64px)" 13583}; 13584var contentSx$5 = { 13585 display: "flex", 13586 flexDirection: "column", 13587 gap: 2, 13588 paddingTop: 1 13589}; 13590var stepSx = { 13591 display: "flex", 13592 flexDirection: "column", 13593 gap: 1 13594}; 13595var stepHeaderSx = { 13596 display: "flex", 13597 alignItems: "center", 13598 gap: 1.5 13599}; 13600var stepNumberSx = { 13601 width: 24, 13602 height: 24, 13603 borderRadius: "50%", 13604 backgroundColor: "primary.light", 13605 color: "primary.main", 13606 display: "flex", 13607 alignItems: "center", 13608 justifyContent: "center", 13609 fontSize: "0.75rem", 13610 fontWeight: 600, 13611 flexShrink: 0 13612}; 13613var stepBodySx = { 13614 display: "flex", 13615 flexDirection: "column", 13616 gap: 1, 13617 paddingLeft: "36px", 13618 minWidth: 0 13619}; 13620var fieldSx = { 13621 display: "flex", 13622 flexDirection: "column", 13623 gap: .25, 13624 minWidth: 0 13625}; 13626var fieldLabelSx = { color: "text.secondary" }; 13627var fieldValueSx = { minWidth: 0 }; 13628var macrosSx = { 13629 display: "flex", 13630 flexDirection: "column", 13631 gap: .25, 13632 paddingTop: .5 13633}; 13634var afterSx = { 13635 display: "flex", 13636 flexDirection: "column", 13637 gap: 1 13638}; 13639var afterHeaderSx = { display: "flex" }; 13640//#endregion 13641//#region client/components/newNav/components/ads/dsp/AdsDspPage.tsx 13642var sections$2 = [{ 13643 id: "platforms", 13644 label: "Platforms", 13645 icon: import_faTowerBroadcast.faTowerBroadcast, 13646 entityKinds: [GqlClientAdsSpaceEntityKind.DSP], 13647 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DspPlatformsSection, {}) 13648}, { 13649 id: "account-data", 13650 label: "This account", 13651 icon: import_faSliders.faSliders, 13652 entityKinds: [GqlClientAdsSpaceEntityKind.DSP_ACCOUNT_DATA, GqlClientAdsSpaceEntityKind.DSP_SYNC_CONDITION], 13653 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DspAccountDataSection, {}) 13654}]; 13655/** The two levels of DSP configuration in one place: the platform itself, and what this account sends it. */ 13656var AdsDspPage = () => { 13657 const accountId = useActiveAccountId(); 13658 const [isNexxenGuideOpen, setIsNexxenGuideOpen] = (0, import_react.useState)(false); 13659 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 13660 area: "Decoupled / Demand-side platforms", 13661 title: "Demand-side platforms", 13662 subtitle: "The partners that buy decoupled impressions and call the ad server for the video.", 13663 sections: sections$2, 13664 sectionPath: (sectionId) => (0, import_dist$1.newNavAdsDspSectionPath)({ 13665 accountId, 13666 sectionId 13667 }), 13668 headerActions: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 13669 variant: "outlined", 13670 size: "medium", 13671 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faCircleQuestion.faCircleQuestion }), 13672 onClick: () => setIsNexxenGuideOpen(true),
13673 "data-testid": "ads-dsp-nexxen-guide-btn", 13674 "data-tracking-id": "tracking-id-ads-dsp-nexxen-guide-btn", 13675 children: "Nexxen integration" 13676 }), 13677 trackingIdPrefix: "tracking-id-ads-dsp" 13678 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NexxenIntegrationDialog, { 13679 open: isNexxenGuideOpen, 13680 onClose: () => setIsNexxenGuideOpen(false) 13681 })] }); 13682}; 13683//#endregion 13684//#region client/components/newNav/components/ads/experiments/experimentsModel.ts 13685var import_faCalendarPlus = (/* @__PURE__ */ __commonJSMin(((exports) => { 13686 Object.defineProperty(exports, "__esModule", { value: true }); 13687 var prefix = "far"; 13688 var iconName = "calendar-plus"; 13689 var width = 448; 13690 var height = 512; 13691 var aliases = []; 13692 var unicode = "f271"; 13693 var svgPathData = "M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 288c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-288c0-8.8-7.2-16-16-16l-264 0zm104 64c13.3 0 24 10.7 24 24l0 48 48 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-48 0 0 48c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-48-48 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0 0-48c0-13.3 10.7-24 24-24z"; 13694 exports.definition = { 13695 prefix, 13696 iconName, 13697 icon: [ 13698 width, 13699 height, 13700 aliases, 13701 unicode, 13702 svgPathData 13703 ] 13704 }; 13705 exports.faCalendarPlus = exports.definition; 13706 exports.prefix = prefix; 13707 exports.iconName = iconName; 13708 exports.width = width; 13709 exports.height = height; 13710 exports.ligatures = aliases; 13711 exports.unicode = unicode; 13712 exports.svgPathData = svgPathData; 13713 exports.aliases = aliases; 13714})))(); 13715var ExperimentStatus = /* @__PURE__ */ function(ExperimentStatus) { 13716 ExperimentStatus["SCHEDULED"] = "SCHEDULED"; 13717 ExperimentStatus["RUNNING"] = "RUNNING"; 13718 ExperimentStatus["ENDED"] = "ENDED"; 13719 return ExperimentStatus; 13720}({}); 13721var experimentStatusDisplay = { 13722 ["RUNNING"]: { 13723 label: "Running", 13724 color: "success", 13725 variant: "standard" 13726 }, 13727 ["SCHEDULED"]: { 13728 label: "Scheduled", 13729 color: "info", 13730 variant: "standard" 13731 }, 13732 ["ENDED"]: { 13733 label: "Ended", 13734 color: "default", 13735 variant: "outlined" 13736 } 13737}; 13738/** An experiment without an end date has started or not; it never ends on its own. */ 13739var getExperimentStatus = (experiment, today) => { 13740 if (experiment.start > today) return "SCHEDULED"; 13741 if (!experiment.end) return "RUNNING"; 13742 return experiment.end < today ? "ENDED" : "RUNNING"; 13743}; 13744/** Experiment names reach the video URL, so the platform only accepts letters, digits, dot, dash and underscore. */ 13745var EXPERIMENT_NAME_PATTERN = /^[A-Za-z0-9_.-]*$/; 13746/** 13747* The 0-10000 hash space laid out end to end, gaps included. Splitting ranges by hand across a dozen groups is 13748* the actual work on this page, and the old app validated none of it. 13749*/ 13750var getExperimentCoverage = (groups) => { 13751 const sorted = [...groups].sort((left, right) => left.min - right.min); 13752 const segments = []; 13753 let cursor = 0; 13754 sorted.forEach((group) => { 13755 if (group.min > cursor) segments.push({ 13756 group: null, 13757 min: cursor, 13758 max: group.min 13759 }); 13760 segments.push({ 13761 group, 13762 min: group.min, 13763 max: group.max 13764 }); 13765 cursor = Math.max(cursor, group.max); 13766 }); 13767 if (cursor < 1e4) segments.push({ 13768 group: null, 13769 min: cursor, 13770 max: EXPERIMENT_RANGE_MAX 13771 }); 13772 return segments; 13773}; 13774var getCoveredShare = (groups) => { 13775 return getExperimentCoverage(groups).filter((segment) => segment.group).reduce((total, segment) => total + (segment.max - segment.min), 0) / EXPERIMENT_RANGE_MAX; 13776}; 13777/** 13778* Groups have to tile the whole hash space end to end. A gap silently drops those users out of the experiment 13779* and an overlap makes the group a user lands in depend on iteration order, so neither can be saved. 13780* Ranges are half-open, matching `findOverlappingGroup`. 13781*/ 13782var validateExperimentCoverage = (groups) => { 13783 if (groups.length === 0) return `Add groups covering 0 to ${EXPERIMENT_RANGE_MAX}`; 13784 const sorted = [...groups].sort((left, right) => left.min - right.min); 13785 let cursor = 0; 13786 for (const group of sorted) { 13787 if (group.min > cursor) return `Nothing covers ${cursor} to ${group.min}`;
13788 if (group.min < cursor) return `"${group.name}" overlaps the group before it`; 13789 cursor = group.max; 13790 } 13791 return cursor === 1e4 ? null : `Nothing covers ${cursor} to ${EXPERIMENT_RANGE_MAX}`; 13792}; 13793/** 13794* What a new experiment starts with. Nearly every experiment is a control and a test at even odds, so the 13795* common case is created without typing a range at all. 13796*/ 13797var defaultExperimentGroups = () => [{ 13798 id: "seed-control", 13799 name: "control", 13800 min: 0, 13801 max: EXPERIMENT_RANGE_MAX / 2 13802}, { 13803 id: "seed-test", 13804 name: "test", 13805 min: EXPERIMENT_RANGE_MAX / 2, 13806 max: EXPERIMENT_RANGE_MAX 13807}]; 13808var findOverlappingGroup = (candidate, groups) => groups.find((group) => group.id !== candidate.id && candidate.min < group.max && group.min < candidate.max) ?? null; 13809var filterExperiments = (experiments, searchTerm, status, today) => { 13810 const term = searchTerm.trim().toLowerCase(); 13811 return experiments.filter((experiment) => { 13812 if (status && getExperimentStatus(experiment, today) !== status) return false; 13813 if (!term) return true; 13814 return experiment.name.toLowerCase().includes(term) || experiment.accountName.toLowerCase().includes(term) || experiment.groups.some((group) => group.name.toLowerCase().includes(term)); 13815 }); 13816}; 13817//#endregion 13818//#region client/components/newNav/components/ads/experiments/ExperimentRangeBar.tsx 13819/** 13820* The hash space an experiment splits between its groups, laid out end to end. Uncovered space is a real state, 13821* not an error: users who fall in it get no group at all. 13822*/ 13823/** Neighbouring groups have to be told apart at a glance; these are theme palette keys, so they stay on-brand. */ 13824var groupPaletteKeys = [ 13825 "primary", 13826 "success", 13827 "warning", 13828 "secondary", 13829 "info" 13830]; 13831var ExperimentRangeBar = ({ groups, dense }) => { 13832 const segments = getExperimentCoverage(groups); 13833 const covered = getCoveredShare(groups); 13834 const paletteKeyByGroupId = new Map([...groups].sort((left, right) => left.min - right.min).map((group, index) => [group.id, groupPaletteKeys[index % groupPaletteKeys.length]])); 13835 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13836 sx: wrapperSx(dense), 13837 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13838 sx: barSx(dense), 13839 children: segments.map((segment) => { 13840 const width = (segment.max - segment.min) / EXPERIMENT_RANGE_MAX * 100; 13841 const title = segment.group ? `${segment.group.name}: ${segment.min} to ${segment.max}` : `Unassigned: ${segment.min} to ${segment.max}`; 13842 const paletteKey = segment.group ? paletteKeyByGroupId.get(segment.group.id) ?? "primary" : null; 13843 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Tooltip, { 13844 title, 13845 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 13846 sx: segmentSx(paletteKey, width), 13847 children: !dense && width >
13847 8 && segment.group && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13848 variant: "caption", 13849 sx: segmentLabelSx(paletteKey), 13850 children: segment.group.name 13851 }) 13852 }) 13853 }, `${segment.min}-${segment.max}`); 13854 }) 13855 }), !dense && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 13856 sx: legendSx, 13857 children: [ 13858 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13859 variant: "helper", 13860 color: "text.secondary", 13861 children: "0" 13862 }), 13863 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13864 variant: "helper", 13865 color: covered < 1 ? "warning.main" : "text.secondary", 13866 children: covered < 1 ? `${Math.round((1 - covered) * 100)}% of users fall outside every group` : "Every user falls into a group" 13867 }), 13868 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13869 variant: "helper", 13870 color: "text.secondary", 13871 children: 1e4 13872 }) 13873 ] 13874 })] 13875 }); 13876}; 13877var wrapperSx = (dense) => ({ 13878 display: "flex", 13879 flexDirection: "column", 13880 gap: .5, 13881 width: dense ? 120 : "100%" 13882}); 13883var barSx = (dense) => ({ 13884 display: "flex", 13885 height: dense ? 6 : 32, 13886 borderRadius: dense ? "3px" : "8px", 13887 overflow: "hidden", 13888 backgroundColor: "grey.200" 13889}); 13890var segmentSx = (paletteKey, width) => ({ 13891 width: `${width}%`, 13892 backgroundColor: paletteKey ? `${paletteKey}.main` : "grey.200", 13893 borderRight: paletteKey ? 1 : 0, 13894 borderColor: "background.paper", 13895 display: "flex", 13896 alignItems: "center", 13897 justifyContent: "center", 13898 overflow: "hidden", 13899 minWidth: 0 13900}); 13901var segmentLabelSx = (paletteKey) => ({ 13902 color: `${paletteKey ?? "primary"}.contrastText`, 13903 whiteSpace: "nowrap", 13904 paddingX: .5 13905}); 13906var legendSx = { 13907 display: "flex", 13908 justifyContent: "space-between", 13909 gap: 1 13910}; 13911//#endregion 13912//#region client/components/newNav/components/ads/experiments/ExperimentValuesMatrix.tsx 13913var cellKey = (groupId, propertyId) => `${groupId}:${propertyId}`; 13914var propertyField = [{ 13915 key: "propertyKey", 13916 label: "Property key", 13917 type: "text", 13918 help: "The name the video reads this value under. One column of the matrix.", 13919 validate: required("Enter the property key") 13920}]; 13921/** 13922* One group's row. The values of a group are a child collection of that group, so each row owns its own read - 13923* there is no experiment-wide read of them in the ads schema. 13924*/ 13925var ValuesRow = ({ group, properties, drafts, readOnly, onLoaded, onCellChange }) => { 13926 const values = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXPERIMENT_PROPERTY_VALUE, group.id); 13927 (0, import_react.useEffect)(() => onLoaded(group.id, values.rows), [ 13928 group.id, 13929 values.rows, 13930 onLoaded 13931 ]); 13932 const storedValue = (propertyId) => asText(values.rows.find((row) => asText(row.propertyKeyId) === propertyId)?.value); 13933 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 13934 "data-testid": `ads-experiment-values-row-${group.id}`, 13935 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableCell, { 13936 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, groupCellSx), 13937 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13938 variant: "subtitle2", 13939 children: group.name 13940 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 13941 variant: "caption", 13942 color: "text.secondary", 13943 children: `${group.min} - ${group.max}` 13944 })] 13945 }), properties.map((property) => { 13946 const key = cellKey(group.id, property.id); 13947 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 13948 sx: bodyCellSx, 13949 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 13950 size: "small", 13951 fullWidth: true, 13952 disabled: readOnly || values.loading, 13953 value: drafts[key] ?? storedValue(property.id), 13954 onChange: (event) => onCellChange(group.id, property.id, event.target.value), 13955 inputProps: { "data-tracking-id": "tracking-id-ads-experiment-value-input" } 13956 }) 13957 }, property.id); 13958 })] 13959 }); 13960}; 13961/** 13962* Property keys down the columns and groups down the rows, because that is the shape of the data: one 13963* `experiment_property_value` row per group per key, and a blank cell is a group with no value for that key. 13964*/ 13965var ExperimentValuesMatrix = ({ experimentId, groups, readOnly }) => { 13966 const properties = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXPERIMENT_PROPERTY, experimentId); 13967 const values = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXPERIMENT_PROPERTY_VALUE, void 0, { skipRead: true }); 13968 const [drafts, setDrafts] = (0, import_react.useState)({}); 13969 const [storedByGroup, setStoredByGroup] = (0, import_react.useState)({}); 13970 const [isAddingProperty, setIsAddingProperty] = (0, import_react.useState)(false); 13971 const [propertyToDelete, setPropertyToDelete] = (0, import_react.useState)(null); 13972 const [isSaveOpen, setIsSaveOpen] = (0, import_react.useState)(false); 13973 const onLoaded = import_react.useCallback((groupId, rows) => setStoredByGroup((current) => current[groupId] === rows ? current : { 13974 ...current, 13975 [groupId]: rows 13976 }), []); 13977 const storedCell = (groupId, propertyId) => (storedByGroup[groupId] ?? []).find((row) => asText(row.propertyKeyId) === propertyId); 13978 const changedKeys = Object.keys(drafts).filter((key) => { 13979 const [groupId, propertyId] = key.split(":"); 13980 return drafts[key] !== asText(storedCell(groupId, propertyId)?.value); 13981 }); 13982 const validateProperty = (row, otherRows) => { 13983 const wanted = asText(row.propertyKey).trim().toLowerCase(); 13984 return otherRows.some((other) => asText(other.propertyKey).trim().toLowerCase() === wanted) ? { propertyKey: "This experiment already has a property with that key" } : {}; 13985 }; 13986 const saveValues = async (comment) => { 13987 const byGroup = {};
13988 changedKeys.forEach((key) => { 13989 const groupId = key.split(":")[0]; 13990 byGroup[groupId] = [...byGroup[groupId] ?? [], key]; 13991 }); 13992 for (const [groupId, keys] of Object.entries(byGroup)) { 13993 const changed = []; 13994 const deletedIds = []; 13995 keys.forEach((key) => { 13996 const propertyId = key.split(":")[1]; 13997 const stored = storedCell(groupId, propertyId); 13998 if (!drafts[key].trim()) { 13999 if (stored) deletedIds.push(stored.id); 14000 return; 14001 } 14002 changed.push({ 14003 id: stored?.id ?? "", 14004 value: drafts[key].trim(), 14005 propertyKeyId: Number(propertyId) 14006 }); 14007 }); 14008 if (changed.length === 0 && deletedIds.length === 0) continue; 14009 await values.save(changed, comment, deletedIds.length > 0 ? deletedIds : void 0, groupId); 14010 } 14011 setDrafts({}); 14012 setIsSaveOpen(false); 14013 }; 14014 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14015 sx: matrixSx, 14016 children: [ 14017 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14018 sx: headerSx$2, 14019 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14020 variant: "h5", 14021 children: "Experiment values" 14022 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14023 variant: "body1", 14024 color: "text.secondary", 14025 children: "One value per group per property key. The video reads the value of the group its user landed in; a blank cell means that group has no value for that key." 14026 })] }
14026), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14027 sx: headerActionsSx$1, 14028 children: [changedKeys.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14029 variant: "contained", 14030 size: "small", 14031 onClick: () => setIsSaveOpen(true), 14032 "data-tracking-id": "tracking-id-ads-experiment-values-save-btn", 14033 children: `Save ${changedKeys.length} ${changedKeys.length === 1 ? "value" : "values"}` 14034 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14035 variant: "outlined", 14036 size: "small", 14037 disabled: readOnly, 14038 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 14039 onClick: () => setIsAddingProperty(true), 14040 "data-tracking-id": "tracking-id-ads-experiment-values-add-property-btn", 14041 children: "Add property" 14042 })] 14043 })] 14044 }), 14045 properties.rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14046 variant: "body1", 14047 color: "text.secondary", 14048 children: "This experiment has no property keys, so its groups carry no values. Add one to start." 14049 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 14050 component: import_material.Paper, 14051 variant: "outlined", 14052 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 14053 size: "small", 14054 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14055 sx: headCellSx, 14056 children: "Group" 14057 }), properties.rows.map((property) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14058 sx: headCellSx, 14059 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14060 sx: columnHeadSx, 14061 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: asText(property.propertyKey) }
14061), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 14062 size: "small", 14063 disabled: readOnly, 14064 onClick: () => setPropertyToDelete(property), 14065 "data-tracking-id": "tracking-id-ads-experiment-values-remove-property-btn", 14066 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrashCan.faTrashCan }) 14067 })] 14068 }) 14069 }, property.id))] }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableBody, { children: groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ValuesRow, { 14070 group, 14071 properties: properties.rows, 14072 drafts, 14073 readOnly, 14074 onLoaded, 14075 onCellChange: (groupId, propertyId, value) => setDrafts((current) => ({ 14076 ...current, 14077 [cellKey(groupId, propertyId)]: value 14078 })) 14079 }, group.id)) })] 14080 }) 14081 }), 14082 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsRowDialog, { 14083 open: isAddingProperty, 14084 title: "Add property", 14085 fields: propertyField, 14086 row: null, 14087 otherRows: properties.rows, 14088 validateRow: validateProperty, 14089 onClose: () => setIsAddingProperty(false), 14090 onSubmit: (row) => { 14091 setIsAddingProperty(false); 14092 properties.save([{ 14093 ...row, 14094 id: "" 14095 }], `Added experiment property ${asText(row.propertyKey)}`).catch(() => void 0); 14096 }, 14097 trackingIdPrefix: "tracking-id-ads-experiment-property" 14098 }), 14099 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 14100 open: Boolean(propertyToDelete), 14101 onClose: () => setPropertyToDelete(null), 14102 PaperProps: { sx: confirmPaperSx }, 14103 children: [ 14104 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 14105 CloseIconButtonProps: { 14106 onClick: () => setPropertyToDelete(null), 14107 "data-tracking-id": "tracking-id-ads-experiment-property-delete-close-btn" 14108 }, 14109 children: "Delete this property" 14110 }), 14111 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14112 variant: "body1", 14113 color: "text.secondary", 14114 children: `Deleting "${asText(propertyToDelete?.propertyKey)}" also deletes every group's value for it. The values have to go first, so this cannot leave one column half-removed.` 14115 }) }), 14116 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14117 variant: "text", 14118 size: "large", 14119 onClick: () => setPropertyToDelete(null), 14120 "data-tracking-id": "tracking-id-ads-experiment-property-delete-cancel-btn", 14121 children: "Cancel" 14122 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14123 variant: "contained", 14124 color: "error", 14125 size: "large", 14126 onClick: () => { 14127 const removed = propertyToDelete; 14128 setPropertyToDelete(null); 14129 properties.save([], `Removed experiment property ${asText(removed.propertyKey)}`, [removed.id]).catch(() => void 0); 14130 }, 14131 "data-tracking-id": "tracking-id-ads-experiment-property-delete-confirm-btn", 14132 children: "Delete" 14133 })] }) 14134 ] 14135 }), 14136 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 14137 open: isSaveOpen, 14138 summary: `${changedKeys.length} ${changedKeys.length === 1 ? "experiment value" : "experiment values"}`, 14139 saving: values.saving, 14140 onCancel: () => setIsSaveOpen(false), 14141 onConfirm: (comment) => void saveValues(comment).catch(() => void 0), 14142 trackingIdPrefix: "tracking-id-ads-experiment-values" 14143 }) 14144 ] 14145 }); 14146}; 14147var matrixSx = { 14148 display: "flex", 14149 flexDirection: "column", 14150 gap: 2 14151}; 14152var headerSx$2 = {
14153 display: "flex", 14154 justifyContent: "space-between", 14155 alignItems: "flex-start", 14156 gap: 2 14157}; 14158var headerActionsSx$1 = { 14159 display: "flex", 14160 gap: 1, 14161 flexShrink: 0 14162}; 14163var columnHeadSx = { 14164 display: "flex", 14165 alignItems: "center", 14166 gap: .5 14167}; 14168var groupCellSx = { 14169 display: "flex", 14170 flexDirection: "column" 14171}; 14172var confirmPaperSx = { 14173 width: 480, 14174 maxWidth: "calc(100vw - 64px)" 14175}; 14176//#endregion 14177//#region client/components/newNav/components/ads/experiments/experimentsStore.ts 14178var toExperiment = (experiment) => ({ 14179 id: experiment.id, 14180 name: experiment.name, 14181 accountName: experiment.accountName, 14182 salt: experiment.salt ?? "", 14183 start: experiment.startDate ?? "", 14184 end: experiment.endDate ?? "", 14185 groups: experiment.groups.map((group) => ({ 14186 id: group.id, 14187 name: group.name, 14188 min: group.min, 14189 max: group.max 14190 })) 14191}); 14192/** 14193* The account's experiments, from the ads schema. The campaign optimization pickers read the same rows. 14194* `loading` matters to every caller: an empty list before the read lands is indistinguishable from an account 14195* with no experiments, and a detail page that treats it as "not found" redirects away from a valid URL. 14196*/ 14197var useExperiments = () => { 14198 const { data, loading } = useCurrentAccountAdsSpaceExperimentsQuery(); 14199 const rows = data?.currentAccount?.adsSpace?.experiments; 14200 return { 14201 experiments: (0, import_react.useMemo)(() => (rows ?? []).map(toExperiment), [rows]), 14202 loading 14203 }; 14204}; 14205/** 14206* Writes go through the space's generic entity path, so an experiment save is audited like every other one. 14207* Groups are a child collection keyed by the experiment's id, which the ads services server assigns - so a 14208* create reads the stored experiment back and then attaches its groups to that id. 14209*/ 14210var useExperimentActions = () => { 14211 const experiments = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXPERIMENT); 14212 const groups = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXPERIMENT_GROUP, void 0, { skipRead: true }); 14213 const toValues = (input) => ({ 14214 id: "new", 14215 name: input.name, 14216 salt: input.salt, 14217 startDate: input.start, 14218 endDate: input.end 14219 }); 14220 const create = async (input, newGroups, comment) => { 14221 const stored = await experiments.create(toValues(input), comment); 14222 if (!stored || newGroups.length === 0) return; 14223 await groups.save(newGroups.map((group) => ({ 14224 id: "", 14225 name: group.name, 14226 min: group.min, 14227 max: group.max, 14228 active: true 14229 })), comment, void 0, stored.id); 14230 }; 14231 return { 14232 saving: experiments.saving || groups.saving, 14233 create, 14234 update: (experimentId, input, comment) => experiments.save([{ 14235 ...toValues(input), 14236 id: experimentId 14237 }], comment) 14238 }; 14239}; 14240//#endregion 14241//#region client/components/newNav/components/ads/experiments/ExtendEndDateDialog.tsx 14242var extensionOptions = [ 14243 7, 14244 14, 14245 30, 14246 90 14247]; 14248/** 14249* Pushing an experiment's end date out is the single most common action on this page - 89 of the 228 edits in a 14250* year. It gets one click and a preview instead of a datetime picker inside a form. 14251*/ 14252var ExtendEndDateDialog = ({ experiment, onClose, onExtend }) => { 14253 const [days, setDays] = (0, import_react.useState)(extensionOptions[0]); 14254 const [comment, setComment] = (0, import_react.useState)(""); 14255 (0, import_react.useEffect)(() => { 14256 if (experiment) { 14257 setDays(extensionOptions[0]); 14258 setComment(""); 14259 } 14260 }, [experiment]); 14261 const newEnd = experiment ? addDays(experiment.end, days) : ""; 14262 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 14263 open: Boolean(experiment), 14264 onClose, 14265 maxWidth: "sm", 14266 fullWidth: true, 14267 children: [ 14268 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 14269 CloseIconButtonProps: { 14270 onClick: onClose, 14271 "data-tracking-id": "tracking-id-ads-experiments-extend-close-btn" 14272 }, 14273 children: "Extend the experiment" 14274 }), 14275 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14276 sx: contentSx$4, 14277 children: [ 14278 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14279 variant: "body1", 14280 color: "text.secondary", 14281 children: experiment ? `${experiment.name} currently ends on ${TimeUtils.getDesignSystemReadableTimeDate(experiment.end, true)}.` : "" 14282 }),
14283 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleToggleButtonGroup, { 14284 exclusive: true, 14285 size: "small", 14286 value: days, 14287 onChange: (_event, next) => next && setDays(next), 14288 children: extensionOptions.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 14289 value: option, 14290 sx: { flex: 1 }, 14291 "data-tracking-id": `tracking-id-ads-experiments-extend-${option}-days-btn`, 14292 children: `${option} days` 14293 }, option)) 14294 }), 14295 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14296 variant: "body1", 14297 children: newEnd ? `New end date: ${TimeUtils.getDesignSystemReadableTimeDate(newEnd, true)}` : "" 14298 }), 14299 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 14300 value: comment, 14301 onChange: setComment, 14302 trackingId: "tracking-id-ads-experiments-extend-comment-input" 14303 }) 14304 ] 14305 }) }), 14306 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14307 variant: "text", 14308 size: "large", 14309 onClick: onClose, 14310 "data-tracking-id": "tracking-id-ads-experiments-extend-cancel-btn", 14311 children: "Cancel" 14312 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14313 variant: "contained", 14314 size: "large", 14315 disabled: !comment.trim(), 14316 onClick: () => { 14317 if (experiment) onExtend(experiment.id, newEnd, comment.trim()); 14318 }, 14319 "data-tracking-id": "tracking-id-ads-experiments-extend-confirm-btn", 14320 children: "Extend" 14321 })] }) 14322 ] 14323 }); 14324}; 14325var contentSx$4 = { 14326 display: "flex", 14327 flexDirection: "column", 14328 gap: 2, 14329 paddingTop: 1 14330}; 14331//#endregion 14332//#region client/components/newNav/components/ads/experiments/ExperimentDetailPage.tsx 14333var experimentGroups = [{ 14334 id: "identity", 14335 title: "Experiment", 14336 fields: [ 14337 { 14338 key: "name", 14339 label: "Name", 14340 type: "text", 14341 help: "Experiment name", 14342 validate: matches(EXPERIMENT_NAME_PATTERN, "Use letters, digits, dots, dashes and underscores only") 14343 }, 14344 { 14345 key: "accountName", 14346 label: "Account name", 14347 type: "readOnly", 14348 help: "The ads account this experiment belongs to. Set when it is created." 14349 }, 14350 { 14351 key: "salt", 14352 label: "Salt", 14353 type: "text", 14354 wide: true, 14355 help: "A salt for the one-way-function from the user id to the user's group" 14356 } 14357 ] 14358}, { 14359 id: "schedule", 14360 title: "Schedule", 14361 description: "Dates are UTC and run from midnight to midnight.", 14362 fields: [{ 14363 key: "start", 14364 label: "Start", 14365 type: "date", 14366 help: "Start date of the experiment", 14367 validate: required("Pick a start date") 14368 }, { 14369 key: "end", 14370 label: "End (optional)", 14371 type: "date", 14372 help: "Leave it empty to run until someone stops it. An experiment with no end date never reads as ended." 14373 }] 14374}]; 14375var groupFields = [ 14376 { 14377 key: "name", 14378 label: "Name", 14379 type: "text", 14380 validate: required("Enter the group name") 14381 }, 14382 { 14383 key: "min", 14384 label: "Min", 14385 type: "number", 14386 min: 0, 14387 max: EXPERIMENT_RANGE_MAX, 14388 help: "Start of the group range (total range is 0-10000)" 14389 }, 14390 { 14391 key: "max", 14392 label: "Max", 14393 type: "number", 14394 min: 0, 14395 max: EXPERIMENT_RANGE_MAX, 14396 help: "End of the group range (total range is 0-10000)" 14397 } 14398]; 14399var toGroups = (rows) => rows.map((row) => ({ 14400 id: row.id, 14401 name: asText(row.name), 14402 min: Number(asText(row.min)) || 0, 14403 max: Number(asText(row.max)) || 0 14404})); 14405/** The old app validated none of this, and hand-split ranges across a dozen groups is the job on this page. */ 14406var validateGroupRow = (row, otherRows) => { 14407 const min = Number(asText(row.min)); 14408 const max = Number(asText(row.max)); 14409 if (Number.isNaN(min) || min < 0 || min > 1e4) return { min: `The range runs from 0 to ${EXPERIMENT_RANGE_MAX}` }; 14410 if (Number.isNaN(max) || max < 0 || max > 1e4) return { max: `The range runs from 0 to ${EXPERIMENT_RANGE_MAX}` }; 14411 if (max <= min) return { max: "Max has to be greater than min" }; 14412 const clash = findOverlappingGroup({ 14413 id: row.id, 14414 min, 14415 max 14416 }, toGroups(otherRows)); 14417 return clash ? { min: `This range overlaps "${clash.name}" (${clash.min} to ${clash.max})` } : {}; 14418}; 14419var ExperimentDetailPage = () => { 14420 const accountId = useActiveAccountId(); 14421 const { experimentId } = useParams(); 14422 const { experiments, loading } = useExperiments(); 14423 const experiment = experiments.find((candidate) => candidate.id === experimentId); 14424 if (loading && !experiment) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 14425 variant: "rounded", 14426 height: 400, 14427 sx: loadingSx 14428 }); 14429 if (!experiment) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: (0, import_dist$1.newNavAdsExperimentsPath)({ accountId }) }); 14430 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentDetail, { experiment }, experiment.id); 14431}; 14432var ExperimentDetail = ({ experiment }) => { 14433 const accountId = useActiveAccountId(); 14434 const { userRoles } = useUserData(); 14435 const canEdit = isPermitted(userRoles, permissions.adsEditCampaignSettings); 14436 const actions = useExperimentActions(); 14437 const groupRows = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.EXPERIMENT_GROUP, experiment.id); 14438 const today = (0, import_react.useMemo)(() => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), []);
14439 const [isExtending, setIsExtending] = (0, import_react.useState)(false); 14440 const form = useAdsSettingsForm(experimentGroups, { 14441 name: experiment.name, 14442 accountName: experiment.accountName, 14443 salt: experiment.salt, 14444 start: experiment.start, 14445 end: experiment.end 14446 }, "The experiment", async (values, comment) => { 14447 await actions.update(experiment.id, { 14448 name: asText(values.name), 14449 salt: asText(values.salt), 14450 start: asText(values.start), 14451 end: asText(values.end) 14452 }, comment); 14453 await groupRows.persist(comment); 14454 }, actions.saving || groupRows.saving, [groupRows]); 14455 const status = experimentStatusDisplay[getExperimentStatus(experiment, today)]; 14456 const coverageProblem = groupRows.changeCount > 0 ? validateExperimentCoverage(toGroups(groupRows.rows)) : null; 14457 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14458 sx: adsFixedPageSx, 14459 children: [ 14460 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14461 component: Link$6, 14462 to: (0, import_dist$1.newNavAdsExperimentsPath)({ accountId }), 14463 variant: "text", 14464 size: "small", 14465 sx: backLinkSx, 14466 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleLeft.faAngleLeft }), 14467 "data-tracking-id": "tracking-id-ads-experiment-back-btn", 14468 children: "Experiments" 14469 }), 14470 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AdsPageHeader, { 14471 area: "Programmatic / Experiments", 14472 title: experiment.name, 14473 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 14474 size: "small", 14475 label: status.label, 14476 color: status.color, 14477 variant: status.variant 14478 }), canEdit && experiment.end && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14479 variant: "outlined", 14480 size: "medium", 14481 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faCalendarPlus.faCalendarPlus }), 14482 onClick: () => setIsExtending(true), 14483 "data-tracking-id": "tracking-id-ads-experiment-extend-btn", 14484 children: "Extend end date" 14485 })] 14486 }), 14487 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Paper, { 14488 variant: "outlined", 14489 sx: panelSx, 14490 children: [ 14491 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14492 sx: coverageSx, 14493 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14494 variant: "h5", 14495 children: "Group coverage" 14496 }), groupRows.loading && groupRows.rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 14497 variant: "rounded", 14498 animation: "wave", 14499 height: 32 14500 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentRangeBar, { groups: toGroups(groupRows.rows) })] 14501 }), 14502 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 14503 groups: experimentGroups, 14504 form, 14505 readOnly: !canEdit, 14506 saveBlockedReason: coverageProblem ?? void 0, 14507 trackingIdPrefix: "tracking-id-ads-experiment", 14508 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 14509 title: "Groups", 14510 description: "Each group claims a slice of the 0 to 10000 hash space. The group name is what the video receives.", 14511 fields: groupFields, 14512 rows: groupRows.rows, 14513 addLabel: "Add group", 14514 emptyText: "This experiment has no groups, so no user falls into it", 14515 rowTitle: (row) => asText(row.name), 14516 readOnly: !canEdit, 14517 onChange: groupRows.setRows, 14518 validateRow: validateGroupRow, 14519 trackingIdPrefix: "tracking-id-ads-experiment-group" 14520 }) 14521 }), 14522 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentValuesMatrix, { 14523 experimentId: experiment.id, 14524 groups: experiment.groups, 14525 readOnly: !canEdit 14526 }) 14527 ] 14528 }), 14529 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExtendEndDateDialog, { 14530 experiment: isExtending ? experiment : null, 14531 onClose: () => setIsExtending(false), 14532 onExtend: (id, end, comment) => { 14533 actions.update(id, { end }, comment).catch(() => void 0); 14534 form.onChange("end", end); 14535 setIsExtending(false); 14536 } 14537 }) 14538 ] 14539 }); 14540}; 14541var loadingSx = { margin: "32px" }; 14542var backLinkSx = { 14543 alignSelf: "flex-start", 14544 marginBottom: -2 14545}; 14546var panelSx = { 14547 padding: 3, 14548 display: "flex", 14549 flexDirection: "column", 14550 gap: 4, 14551 flex: 1, 14552 minHeight: 0 14553}; 14554var coverageSx = {
14555 display: "flex", 14556 flexDirection: "column", 14557 gap: 2 14558}; 14559//#endregion 14560//#region client/components/newNav/components/ads/experiments/NewExperimentDialog.tsx 14561var import_faFlask = require_faFlask(); 14562/** 14563* The one page in the ads configuration model with real creation traffic - 75 new experiments in a year - so 14564* unlike the campaign pages it gets a create flow rather than edit-only. The groups are defined here too: an 14565* experiment with no groups catches nobody, so creating one and then leaving is never the finished job. 14566*/ 14567var NewExperimentDialog = ({ open, existingNames, onClose, onCreate }) => { 14568 const [name, setName] = (0, import_react.useState)(""); 14569 const [salt, setSalt] = (0, import_react.useState)(""); 14570 const [isSaltOwnValue, setIsSaltOwnValue] = (0, import_react.useState)(false); 14571 const [start, setStart] = (0, import_react.useState)(""); 14572 const [end, setEnd] = (0, import_react.useState)(""); 14573 const [groups, setGroups] = (0, import_react.useState)(defaultExperimentGroups()); 14574 const [nameError, setNameError] = (0, import_react.useState)(null); 14575 const [dateError, setDateError] = (0, import_react.useState)(null); 14576 const [comment, setComment] = (0, import_react.useState)(""); 14577 (0, import_react.useEffect)(() => { 14578 if (open) { 14579 setName(""); 14580 setSalt(""); 14581 setIsSaltOwnValue(false); 14582 setStart(""); 14583 setEnd(""); 14584 setGroups(defaultExperimentGroups()); 14585 setNameError(null); 14586 setDateError(null); 14587 setComment(""); 14588 } 14589 }, [open]); 14590 const handleNameChange = (value) => { 14591 setName(value); 14592 if (!isSaltOwnValue) setSalt(value); 14593 if (!EXPERIMENT_NAME_PATTERN.test(value)) { 14594 setNameError("Use letters, digits, dots, dashes and underscores only"); 14595 return; 14596 } 14597 setNameError(existingNames.includes(value) ? "An experiment with this name already exists" : null); 14598 }; 14599 const handleDatesChange = (nextStart, nextEnd) => { 14600 setStart(nextStart); 14601 setEnd(nextEnd); 14602 setDateError(nextStart && nextEnd && nextEnd <= nextStart ? "The end date has to come after the start date" : null); 14603 }; 14604 const updateGroup = (index, change) => setGroups(groups.map((group, position) => position === index ? { 14605 ...group, 14606 ...change 14607 } : group)); 14608 const addGroup = () => { 14609 const highest = groups.reduce((edge, group) => Math.max(edge, group.max), 0); 14610 setGroups([...groups, { 14611 id: v4(), 14612 name: "", 14613 min: highest, 14614 max: EXPERIMENT_RANGE_MAX 14615 }]); 14616 }; 14617 const coverageProblem = validateExperimentCoverage(groups); 14618 const unnamedGroup = groups.some((group) => !group.name.trim()); 14619 const canCreate = Boolean(name && start) && !nameError && !dateError && !coverageProblem && !unnamedGroup; 14620 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 14621 open, 14622 onClose, 14623 PaperProps: { sx: paperSx$1 }, 14624 children: [ 14625 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 14626 CloseIconButtonProps: { 14627 onClick: onClose, 14628 "data-tracking-id": "tracking-id-ads-experiments-new-close-btn" 14629 }, 14630 children: "New experiment" 14631 }), 14632 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14633 sx: contentSx$3, 14634 children: [ 14635 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14636 label: "Name", 14637 value: name, 14638 error: Boolean(nameError), 14639 size: "small", 14640 fullWidth: true, 14641 onChange: (event) => handleNameChange(event.target.value), 14642 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-new-name-input" } 14643 }), 14644 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14645 variant: "helper", 14646 color: nameError ? "error.main" : "text.secondary", 14647 children: nameError ?? "The name reaches the video URL, so it takes letters, digits, dots, dashes and underscores." 14648 }), 14649 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14650 label: "Salt", 14651 value: salt, 14652 size: "small", 14653 fullWidth: true, 14654 onChange: (event) => { 14655 setIsSaltOwnValue(true); 14656 setSalt(event.target.value); 14657 }, 14658 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-new-salt-input" } 14659 }),
14660 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14661 variant: "helper", 14662 color: "text.secondary", 14663 children: "Defaults to the name. Change it only to re-randomise which users land in which group." 14664 }), 14665 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14666 sx: datesSx, 14667 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14668 label: "Start", 14669 type: "date", 14670 value: start, 14671 error: Boolean(dateError), 14672 size: "small", 14673 fullWidth: true, 14674 onChange: (event) => handleDatesChange(event.target.value, end), 14675 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-new-start-input" } 14676 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14677 label: "End (optional)", 14678 type: "date", 14679 value: end, 14680 error: Boolean(dateError), 14681 size: "small", 14682 fullWidth: true, 14683 onChange: (event) => handleDatesChange(start, event.target.value), 14684 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-new-end-input" } 14685 })] 14686 }), 14687 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14688 variant: "helper", 14689 color: dateError ? "error.main" : "text.secondary", 14690 children: dateError ?? "Dates are UTC and run from midnight to midnight. Leave the end empty to run until it is stopped." 14691 }), 14692 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14693 variant: "h5", 14694 sx: groupsHeadingSx, 14695 children: "Groups" 14696 }), 14697 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentRangeBar, { groups }), 14698 groups.map((group, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14699 sx: groupRowSx, 14700 children: [ 14701 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14702 label: "Name", 14703 value: group.name, 14704 size: "small", 14705 fullWidth: true, 14706 onChange: (event) => updateGroup(index, { name: event.target.value }), 14707 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-new-group-name-input" } 14708 }), 14709 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14710 label: "Min", 14711 type: "number", 14712 value: group.min, 14713 size: "small", 14714 onChange: (event) => updateGroup(index, { min: Number(event.target.value) }), 14715 inputProps: { 14716 min: 0, 14717 max: EXPERIMENT_RANGE_MAX, 14718 "data-tracking-id": "tracking-id-ads-experiments-new-group-min-input" 14719 } 14720 }), 14721 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 14722 label: "Max", 14723 type: "number", 14724 value: group.max, 14725 size: "small", 14726 onChange: (event) => updateGroup(index, { max: Number(event.target.value) }), 14727 inputProps: { 14728 min: 0, 14729 max: EXPERIMENT_RANGE_MAX, 14730 "data-tracking-id": "tracking-id-ads-experiments-new-group-max-input" 14731 } 14732 }), 14733 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 14734 size: "small", 14735 disabled: groups.length === 1, 14736 onClick: () => setGroups(groups.filter((_, position) => position !== index)), 14737 "data-tracking-id": "tracking-id-ads-experiments-new-group-remove-btn", 14738 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faTrashCan.faTrashCan }) 14739 }) 14740 ] 14741 }, group.id)), 14742 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14743 variant: "text", 14744 size: "small", 14745 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 14746 onClick: addGroup, 14747 "data-tracking-id": "tracking-id-ads-experiments-new-group-add-btn", 14748 children: "Add group" 14749 }) }), 14750 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14751 variant: "helper", 14752 color: coverageProblem || unnamedGroup ? "error.main" : "text.secondary", 14753 children: coverageProblem ?? (unnamedGroup ? "Every group needs a name - it is what the video receives." : `The groups tile 0 to 10000 with no gap and no overlap.`) 14754 }),
14755 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, { sx: reasonDividerSx }), 14756 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 14757 label: "Reason (optional)", 14758 value: comment, 14759 onChange: setComment, 14760 trackingId: "tracking-id-ads-experiments-new-comment-input" 14761 }) 14762 ] 14763 }) }), 14764 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14765 variant: "text", 14766 size: "large", 14767 onClick: onClose, 14768 "data-tracking-id": "tracking-id-ads-experiments-new-cancel-btn", 14769 children: "Cancel" 14770 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14771 variant: "contained", 14772 size: "large", 14773 disabled: !canCreate, 14774 onClick: () => onCreate({ 14775 name, 14776 salt, 14777 start, 14778 end 14779 }, groups, comment.trim()), 14780 "data-tracking-id": "tracking-id-ads-experiments-new-create-btn", 14781 children: "Create experiment" 14782 })] }) 14783 ] 14784 }); 14785}; 14786var paperSx$1 = { 14787 width: 640, 14788 maxWidth: "calc(100vw - 64px)" 14789}; 14790var contentSx$3 = { 14791 display: "flex", 14792 flexDirection: "column", 14793 gap: 1, 14794 paddingTop: 1 14795}; 14796var datesSx = { 14797 display: "flex", 14798 gap: 2 14799}; 14800var groupsHeadingSx = { marginTop: 2 }; 14801var groupRowSx = { 14802 display: "flex", 14803 gap: 1, 14804 alignItems: "center", 14805 "& .MuiTextField-root:not(:first-of-type)": { 14806 width: 96, 14807 flexShrink: 0 14808 } 14809}
14809; 14810var reasonDividerSx = { marginTop: 2 }; 14811//#endregion 14812//#region client/components/newNav/components/ads/experiments/ExperimentsPage.tsx 14813var ExperimentsPage = () => { 14814 const history = useHistory(); 14815 const accountId = useActiveAccountId(); 14816 const { userRoles } = useUserData(); 14817 const canEdit = isPermitted(userRoles, permissions.adsEditCampaignSettings); 14818 const { experiments, loading } = useExperiments(); 14819 const actions = useExperimentActions(); 14820 const today = (0, import_react.useMemo)(() => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), []); 14821 const [searchTerm, setSearchTerm] = (0, import_react.useState)(""); 14822 const [statusFilter, setStatusFilter] = (0, import_react.useState)(null); 14823 const [filterAnchorEl, setFilterAnchorEl] = (0, import_react.useState)(null); 14824 const [menuAnchorEl, setMenuAnchorEl] = (0, import_react.useState)(null); 14825 const [menuExperiment, setMenuExperiment] = (0, import_react.useState)(null); 14826 const [extending, setExtending] = (0, import_react.useState)(null); 14827 const [isNewDialogOpen, setIsNewDialogOpen] = (0, import_react.useState)(false); 14828 const visibleExperiments = (0, import_react.useMemo)(() => filterExperiments(experiments, searchTerm, statusFilter, today), [ 14829 experiments, 14830 searchTerm, 14831 statusFilter, 14832 today 14833 ]); 14834 const closeMenu = () => { 14835 setMenuAnchorEl(null); 14836 setMenuExperiment(null); 14837 }; 14838 const menuActions = (experiment) => [{ 14839 label: "Extend end date", 14840 icon: import_faCalendarPlus.faCalendarPlus, 14841 disabled: !experiment.end, 14842 disabledReason: "This experiment has no end date, so there is nothing to push out.", 14843 onClick: () => { 14844 closeMenu(); 14845 setExtending(experiment); 14846 } 14847 }, { 14848 label: "Edit experiment", 14849 icon: import_faPen.faPen, 14850 onClick: () => { 14851 closeMenu(); 14852 history.push((0, import_dist$1.newNavAdsExperimentPath)({ 14853 accountId, 14854 experimentId: experiment.id 14855 })); 14856 } 14857 }]; 14858 const newExperimentButton = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 14859 variant: "contained", 14860 size: "medium", 14861 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 14862 onClick: () => setIsNewDialogOpen(true), 14863 "data-tracking-id": "tracking-id-ads-experiments-new-btn", 14864 children: "New experiment" 14865 }); 14866 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14867 sx: adsPageSx, 14868 children: [ 14869 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPageHeader, { 14870 area: "Programmatic / Experiments", 14871 title: "Experiments", 14872 count: experiments.length, 14873 subtitle: "Splits users into groups by a hash of their id. The matching group name is handed to the video.", 14874 children: experiments.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 14875 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Filter, { 14876 placeholderValue: "All statuses", 14877 selectedValue: statusFilter ? experimentStatusDisplay[statusFilter].label : null, 14878 onClear: () => setStatusFilter(null), 14879 popoverTitle: "Filter by status", 14880 anchorEl: filterAnchorEl, 14881 setAnchorEl: setFilterAnchorEl, 14882 "data-tracking-id": "tracking-id-ads-experiments-status-filter-btn", 14883 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.List, { children: Object.values(ExperimentStatus).map((status) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 14884 dense: true, 14885 selected: statusFilter === status, 14886 onClick: () => { 14887 setStatusFilter(status); 14888 setFilterAnchorEl(null); 14889 }, 14890 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { primary: experimentStatusDisplay[status].label }) 14891 }, status)) }) 14892 }), 14893 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 14894 sx: searchWrapperSx$1, 14895 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Search, { 14896 placeholder: "Search experiments and groups", 14897 value: searchTerm, 14898 onChange: (event) => setSearchTerm(event.target.value), 14899 onClear: () => setSearchTerm(""), 14900 numberOfResults: visibleExperiments.length, 14901 inputProps: { "data-tracking-id": "tracking-id-ads-experiments-search-input" } 14902 }) 14903 }), 14904 canEdit && newExperimentButton 14905 ] }) 14906 }),
14907 loading && experiments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTableSkeleton, {}), 14908 !loading && experiments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEmptyState, { 14909 icon: import_faFlask.faFlask, 14910 heading: "No experiments yet", 14911 subtitle: "An experiment splits users into groups so a campaign can serve each group a different video.", 14912 children: canEdit && newExperimentButton 14913 }), 14914 experiments.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 14915 component: import_material.Paper, 14916 variant: "outlined", 14917 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 14918 size: "small", 14919 stickyHeader: true, 14920 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 14921 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14922 sx: (0, import_dist$2.combineSxProps)(headCellSx, stickyLeftSx("default", true)), 14923 children: "Experiment" 14924 }), 14925 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14926 sx: headCellSx, 14927 children: "Status" 14928 }), 14929 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14930 sx: headCellSx, 14931 children: "Runs" 14932 }), 14933 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14934 sx: headCellSx, 14935 children: "Groups" 14936 }), 14937 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14938 sx: headCellSx, 14939 children: "Coverage" 14940 }), 14941 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: (0, import_dist$2.combineSxProps)(actionsCellSx, stickyRightSx("default", true)) }) 14942 ] }) }
14942), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [visibleExperiments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14943 colSpan: 6, 14944 sx: noResultsCellSx, 14945 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14946 variant: "body1", 14947 color: "text.secondary", 14948 children: "No experiments match your filters" 14949 }) 14950 }) }), visibleExperiments.map((experiment) => { 14951 const status = experimentStatusDisplay[getExperimentStatus(experiment, today)]; 14952 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 14953 sx: rowHoverSx, 14954 "data-testid": `ads-experiment-row-${experiment.id}`, 14955 children: [ 14956 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14957 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, stickyLeftSx("default")), 14958 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 14959 sx: nameCellSx, 14960 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14961 variant: "subtitle2", 14962 children: experiment.name 14963 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14964 variant: "caption", 14965 color: "text.secondary", 14966 children: experiment.accountName 14967 })] 14968 }) 14969 }), 14970 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14971 sx: bodyCellSx, 14972 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 14973 size: "small", 14974 label: status.label, 14975 color: status.color, 14976 variant: status.variant 14977 }) 14978 }), 14979 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14980 sx: bodyCellSx, 14981 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14982 variant: "body1", 14983 noWrap: true, 14984 children: `${TimeUtils.getDesignSystemReadableTimeDate(experiment.start, true)} - ${experiment.end ? TimeUtils.getDesignSystemReadableTimeDate(experiment.end, true) : "No end date"}` 14985 }) 14986 }), 14987 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14988 sx: bodyCellSx, 14989 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 14990 variant: "body1", 14991 children: experiment.groups.length 14992 }) 14993 }), 14994 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 14995 sx: bodyCellSx, 14996 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentRangeBar, { 14997 groups: experiment.groups, 14998 dense: true 14999 }) 15000 }), 15001 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 15002 sx: (0, import_dist$2.combineSxProps)(actionsCellSx, stickyRightSx("default")), 15003 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 15004 size: "small", 15005 disabled: !canEdit, 15006 onClick: (event) => { 15007 setMenuAnchorEl(event.currentTarget); 15008 setMenuExperiment(experiment); 15009 }, 15010 "data-tracking-id": "tracking-id-ads-experiments-row-options-btn", 15011 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEllipsisV.faEllipsisV }) 15012 }) 15013 }) 15014 ] 15015 }, experiment.id); 15016 })] })] 15017 }) 15018 }), 15019 menuExperiment && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowOptionsMenu, { 15020 anchorEl: menuAnchorEl, 15021 open: Boolean(menuAnchorEl), 15022 onClose: closeMenu, 15023 title: menuExperiment.name, 15024 captions: [`Account: ${menuExperiment.accountName}`, `Groups: ${menuExperiment.groups.length}`], 15025 actions: menuActions(menuExperiment), 15026 anchorOrigin: { 15027 vertical: "bottom", 15028 horizontal: "right" 15029 }, 15030 transformOrigin: { 15031 vertical: "top", 15032 horizontal: "right" 15033 } 15034 }),
15035 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExtendEndDateDialog, { 15036 experiment: extending, 15037 onClose: () => setExtending(null), 15038 onExtend: (experimentId, end, comment) => { 15039 actions.update(experimentId, { end }, comment).catch(() => void 0); 15040 setExtending(null); 15041 } 15042 }), 15043 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NewExperimentDialog, { 15044 open: isNewDialogOpen, 15045 existingNames: experiments.map((experiment) => experiment.name), 15046 onClose: () => setIsNewDialogOpen(false), 15047 onCreate: (input, groups, comment) => { 15048 actions.create(input, groups, comment).catch(() => void 0); 15049 setIsNewDialogOpen(false); 15050 } 15051 }) 15052 ] 15053 }); 15054}; 15055var searchWrapperSx$1 = { width: 280 }; 15056var nameCellSx = { 15057 display: "flex", 15058 flexDirection: "column", 15059 maxWidth: 280 15060}; 15061var noResultsCellSx = { 15062 padding: "32px 16px", 15063 textAlign: "center" 15064}; 15065//#endregion 15066//#region node_modules/@fortawesome/pro-regular-svg-icons/faBuilding.js 15067var require_faBuilding = /* @__PURE__ */ __commonJSMin(((exports) => { 15068 Object.defineProperty(exports, "__esModule", { value: true }); 15069 var prefix = "far"; 15070 var iconName = "building"; 15071 var width = 384; 15072 var height = 512; 15073 var aliases = [127970, 61687]; 15074 var unicode = "f1ad"; 15075 var svgPathData = "M64 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16l80 0 0-80c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 80 80 0c8.8 0 16-7.2 16-16l0-384c0-8.8-7.2-16-16-16L64 48zM0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM240 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM96 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm144-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z"; 15076 exports.definition = { 15077 prefix, 15078 iconName, 15079 icon: [ 15080 width, 15081 height, 15082 aliases, 15083 unicode, 15084 svgPathData 15085 ] 15086 }; 15087 exports.faBuilding = exports.definition; 15088 exports.prefix = prefix; 15089 exports.iconName = iconName; 15090 exports.width = width; 15091 exports.height = height; 15092 exports.ligatures = aliases; 15093 exports.unicode = unicode; 15094 exports.svgPathData = svgPathData; 15095 exports.aliases = aliases; 15096})); 15097//#endregion 15098//#region client/components/newNav/components/ads/common/useAdsSpaceEntityForm.ts 15099var import_faWaveformLines = require_faWaveformLines(); 15100var import_faBuilding = require_faBuilding(); 15101var listKeys = (groups) => groups.flatMap((group) => group.fields).filter((field) => field.type === "multiSelect" || field.type === "stringList").map((field) => ({ 15102 key: field.key, 15103 separator: listSeparatorFor(field.type) 15104})); 15105var splitLists = (values, keys) => { 15106 const next = { ...values }; 15107 keys.forEach(({ key, separator }) => { 15108 const text = asText(next[key]); 15109 next[key] = text ? text.split(separator).map((part) => part.trim()).filter(Boolean) : []; 15110 }); 15111 return next; 15112}; 15113var joinLists = (values, keys) => { 15114 const next = { ...values }; 15115 keys.forEach(({ key }) => { 15116 next[key] = asStringList(next[key]).join(","); 15117 }); 15118 return next; 15119}; 15120/** A read-only field may be shown from somewhere other than this row, so sending it back would fail on an unknown column. */ 15121var withoutReadOnly = (values, groups) => { 15122 const next = { ...values }; 15123 groups.flatMap((group) => group.fields).filter((field) => field.type === "readOnly").forEach((field) => delete next[field.key]); 15124 return next; 15125}; 15126/** 15127* One row of a configuration table as a form: the account, or the single row of a one-row table. The row's id 15128* travels with the save, so the write path updates it rather than adding a second one. 15129*/ 15130var useAdsSpaceEntityForm = (kind, groups) => { 15131 const { rows, loading, error, saving, save } = useAdsSpaceEntities(kind); 15132 const keys = listKeys(groups); 15133 const row = rows[0]; 15134 return { 15135 values: row ? splitLists(row, keys) : null, 15136 loading, 15137 error, 15138 saving, 15139 persist: (values, comment) => save([{ 15140 ...withoutReadOnly(joinLists(values, keys), groups), 15141 id: row?.id ?? "new" 15142 }], comment) 15143 }; 15144}; 15145/** 15146* A key/value table rendered as a typed form - `tracking_property` is nine keys across the whole platform, and a 15147* bag of strings is how a misspelled key gets saved and then silently ignored. 15148*/ 15149var useAdsSpaceKeyValueForm = (kind, groups) => { 15150 const { rows, loading, error, saving, save } = useAdsSpaceEntities(kind); 15151 const keys = listKeys(groups); 15152 const values = {}; 15153 rows.forEach((row) => { 15154 values[asText(row.propKey)] = row.propValue; 15155 }); 15156 const persist = async (next, comment) => { 15157 const joined = joinLists(next, keys); 15158 const changed = Object.entries(joined).filter(([key, value]) => { 15159 return asText(rows.find((row) => asText(row.propKey) === key)?.propValue) !== asText(value); 15160 }).map(([key, value]) => { 15161 return { 15162 id: rows.find((row) => asText(row.propKey) === key)?.id ?? `new-${key}`, 15163 propKey: key, 15164 propValue: asText(value) 15165 }; 15166 }); 15167 if (changed.length > 0) await save(changed, comment); 15168 }; 15169 return { 15170 values: loading ? null : splitLists(values, keys), 15171 loading, 15172 error, 15173 saving, 15174 persist 15175 }; 15176}; 15177//#endregion
15178//#region client/components/newNav/components/ads/settings/AccountSection.tsx 15179var validateAccountName = (value) => { 15180 const name = asText(value); 15181 if (name.length > 15) return "The account name should have 15 letters top"; 15182 if (name && !/^[a-z]+$/.test(name)) return "The account name should contain only lower case letters"; 15183 return null; 15184}; 15185var groups$1 = [ 15186 { 15187 id: "identity", 15188 title: "Identity", 15189 fields: [ 15190 { 15191 key: "name", 15192 label: "Name", 15193 type: "text", 15194 validate: validateAccountName 15195 }, 15196 { 15197 key: "enabled", 15198 label: "Enabled", 15199 type: "bool" 15200 }, 15201 { 15202 key: "medusaName", 15203 label: "Medusa name", 15204 type: "text" 15205 }, 15206 { 15207 key: "repositoryPath", 15208 label: "Repository path", 15209 type: "text" 15210 }, 15211 { 15212 key: "productCatalogDb", 15213 label: "Product catalog DB", 15214 type: "select", 15215 options: productCatalogDbOptions, 15216 help: "The location of the product catalog for this account" 15217 } 15218 ] 15219 }, 15220 { 15221 id: "studio", 15222 title: "Studio mapping", 15223 description: "The link between this ads account and the Studio product. It is the studio_account row, changed by linking the account rather than by typing here.", 15224 fields: [{ 15225 key: "adsAccountId", 15226 label: "Ads account id", 15227 type: "readOnly" 15228 }, { 15229 key: "adsAccountName", 15230 label: "Linked ads account", 15231 type: "readOnly" 15232 }] 15233 }, 15234 { 15235 id: "notifications", 15236 title: "Notifications and metadata", 15237 fields: [ 15238 { 15239 key: "notificationEmailAddresses", 15240 label: "Notification emails", 15241 type: "stringList", 15242 wide: true, 15243 placeholder: "[email protected]", 15244 entryProblem: emailEntryProblem, 15245 help: "List (separated by comma) of mail addresses to notify of changes in the account" 15246 }, 15247 { 15248 key: "advertiserDomains", 15249 label: "Advertiser domains", 15250 type: "stringList", 15251 wide: true, 15252 help: "Comma separated list of domains relevant to this account. Google advertisers should use the numerical value, prefixed by 'g.'. For example: g.10234" 15253 }, 15254 { 15255 key: "categories", 15256 label: "Categories", 15257 type: "stringList", 15258 wide: true, 15259 help: "Uses the same 'g.' prefix convention as advertiser domains." 15260 }, 15261 { 15262 key: "tagsDefinitionLocation", 15263 label: "Tags definition location", 15264 type: "url", 15265 wide: true, 15266 help: "Location of the Excel file with the tags definitions for this account" 15267 } 15268 ] 15269 } 15270]; 15271var AccountForm = ({ source }) => { 15272 const form = useAdsSettingsForm(groups$1, source.values, "The account page", source.persist, source.saving); 15273 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 15274 groups: groups$1, 15275 form, 15276 trackingIdPrefix: "tracking-id-ads-settings-account" 15277 }); 15278}; 15279var AccountSection = () => { 15280 const source = useAdsSpaceEntityForm(GqlClientAdsSpaceEntityKind.ADS_ACCOUNT, groups$1); 15281 const { adsAccountId, adsAccountName } = useAdsSpaceAccount();
15282 if (!source.values) return source.loading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 15283 variant: "rounded", 15284 height: 320 15285 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsUnreachableNotice, { what: "This ads account" }); 15286 const values = { 15287 ...source.values, 15288 adsAccountId, 15289 adsAccountName 15290 }; 15291 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountForm, { source: { 15292 ...source, 15293 values 15294 } }, JSON.stringify(values)); 15295}; 15296//#endregion 15297//#region client/components/newNav/components/ads/settings/AttributionReportsSection.tsx 15298/** Each of these holds a column header exactly as it appears in the customer's own report file. */ 15299var columnMappingField = (key, label, extraHelp) => ({ 15300 key, 15301 label, 15302 type: "text", 15303 help: extraHelp ? `Report '${label}' field. ${extraHelp}` : `Report '${label}' field` 15304}); 15305var internalReportGroups = [ 15306 { 15307 id: "identity", 15308 title: "Identity", 15309 fields: [{ 15310 key: "fileName", 15311 label: "Report file name", 15312 type: "text", 15313 validate: required("Enter the report file name") 15314 }, { 15315 key: "subject", 15316 label: "Email subject", 15317 type: "text", 15318 help: "(Optional) Email Subject" 15319 }] 15320 }, 15321 { 15322 id: "parsing", 15323 title: "Parsing", 15324 fields: [{ 15325 key: "updateWindowDays", 15326 label: "Update window (days)", 15327 type: "number", 15328 min: 0, 15329 help: "number of days to look back at reports data; set to 0 in order to process all the data in the report" 15330 }, { 15331 key: "expectedDateFormat", 15332 label: "Expected date format", 15333 type: "select", 15334 options: reportDateFormatOptions 15335 }] 15336 }, 15337 { 15338 id: "columns", 15339 title: "Column mapping", 15340 description: "The header name each value carries in the customer's file.", 15341 fields: [ 15342 columnMappingField("date", "Date"), 15343 columnMappingField("impressions", "Impressions"), 15344 columnMappingField("clicks", "Clicks"), 15345 columnMappingField("clickThroughConversions", "Click-through Conversions"), 15346 columnMappingField("clickThroughRevenue", "Click-through Revenue"),
15347 columnMappingField("viewThroughConversions", "View-through Conversions"), 15348 columnMappingField("viewThroughRevenue", "View-through Revenue"), 15349 columnMappingField("totalConversions", "Total Conversions"), 15350 columnMappingField("totalRevenue", "Total Revenue"), 15351 columnMappingField("placement", "Placement", "Can be calculated from multiple fields separated by ,"), 15352 columnMappingField("placementId", "Placement Id") 15353 ] 15354 }, 15355 { 15356 id: "custom", 15357 title: "Custom fields", 15358 advanced: true, 15359 description: "Custom field that will be populated in redshift", 15360 fields: [ 15361 1, 15362 2, 15363 3, 15364 4, 15365 5, 15366 6, 15367 7, 15368 8, 15369 9 15370 ].map((index) => ({ 15371 key: `customField${index}`, 15372 label: `Custom field ${index}`, 15373 type: "text" 15374 })) 15375 } 15376]; 15377var validateReportHeaders = (value) => asText(value).split(",").map((part) => part.trim()).filter(Boolean).length >= 3 ? null : "Insert at least 3 headers that will show in the report for sure"; 15378var thirdPartyGroups = [ 15379 { 15380 id: "identity", 15381 title: "Identity", 15382 fields: [ 15383 { 15384 key: "fileName", 15385 label: "Report file name", 15386 type: "text", 15387 validate: required("Enter the report file name") 15388 }, 15389 { 15390 key: "subject", 15391 label: "Email subject", 15392 type: "text", 15393 help: "(Optional) Email Subject" 15394 }, 15395 { 15396 key: "redshiftTableName", 15397 label: "Redshift table name", 15398 type: "readOnly", 15399 wide: true, 15400 help: "Redshift table name. When multiple tabs, a table is opened for each tab with tab name as its suffix" 15401 } 15402 ] 15403 }, 15404 { 15405 id: "parsing", 15406 title: "Parsing", 15407 fields: [{ 15408 key: "reportHeaders", 15409 label: "Report header", 15410 type: "text", 15411 wide: true, 15412 help: "Insert at least 3 (comma delimited) headers that will show in the report for sure! i.e. date,media partner,placement", 15413 validate: validateReportHeaders 15414 }] 15415 }, 15416 { 15417 id: "tabs", 15418 title: "Tabs", 15419 advanced: true, 15420 description: "tab name to process in case of multiple tabs", 15421 fields: [ 15422 1, 15423 2, 15424 3, 15425 4, 15426 5, 15427 6, 15428 7, 15429 8, 15430 9 15431 ].map((index) => ({ 15432 key: `tabName${index}`, 15433 label: `Tab ${index}`, 15434 type: "text" 15435 })) 15436 } 15437]; 15438var ReportDetail = ({ report, groups, trackingIdPrefix, save, saving }) => { 15439 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 15440 groups, 15441 form: useAdsSettingsForm(groups, report, "Attribution report configuration", (values, comment) => save([{ 15442 ...values, 15443 id: report.id 15444 }], comment), saving), 15445 trackingIdPrefix 15446 }); 15447}; 15448var AttributionReportsSection = () => { 15449 const [kind, setKind] = (0, import_react.useState)("internal"); 15450 const internal = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.ATTRIBUTION_REPORT); 15451 const thirdParty = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.ATTRIBUTION_REPORT_THIRD_PARTY); 15452 const placements = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.ATTRIBUTION_PLACEMENT_MAPPING); 15453 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 15454 sx: sectionSx$4, 15455 children: [ 15456 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleToggleButtonGroup, { 15457 exclusive: true, 15458 size: "small", 15459 value: kind, 15460 onChange: (_event, next) => next && setKind(next), 15461 children: [ 15462 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 15463 value: "internal", 15464 "data-tracking-id": "tracking-id-ads-settings-attribution-internal-btn", 15465 children: "Internal" 15466 }), 15467 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 15468 value: "thirdParty", 15469 "data-tracking-id": "tracking-id-ads-settings-attribution-third-party-btn", 15470 children: "Third party" 15471 }), 15472 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 15473 value: "placements", 15474 "data-tracking-id": "tracking-id-ads-settings-attribution-placements-btn", 15475 children: "Placement mapping" 15476 }) 15477 ] 15478 }), 15479 kind === "internal" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 15480 title: "Internal reports", 15481 description: "Maps the columns of a customer's report file onto the fields SundaySky attributes on.", 15482 searchPlaceholder: "Search report files", 15483 rows: internal.rows, 15484 loading: internal.loading, 15485 searchKeys: ["fileName", "subject"], 15486 columns: [ 15487 { 15488 key: "fileName", 15489 label: "Report file" 15490 }, 15491 { 15492 key: "subject", 15493 label: "Email subject" 15494 }, 15495 { 15496 key: "updateWindowDays", 15497 label: "Update window (days)", 15498 align: "right" 15499 } 15500 ], 15501 getTitle: (row) => asText(row.fileName),
15502 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ReportDetail, { 15503 report: row, 15504 groups: internalReportGroups, 15505 trackingIdPrefix: "tracking-id-ads-settings-attribution-internal", 15506 save: internal.save, 15507 saving: internal.saving 15508 }, row.id), 15509 emptyText: "No internal report configurations for this account", 15510 trackingIdPrefix: "tracking-id-ads-settings-attribution-internal" 15511 }), 15512 kind === "thirdParty" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 15513 title: "Third party reports", 15514 description: "Reports that arrive from a media partner rather than from the customer.", 15515 searchPlaceholder: "Search report files", 15516 rows: thirdParty.rows, 15517 loading: thirdParty.loading, 15518 searchKeys: ["fileName", "redshiftTableName"], 15519 columns: [{ 15520 key: "fileName", 15521 label: "Report file" 15522 }, { 15523 key: "redshiftTableName", 15524 label: "Redshift table" 15525 }], 15526 getTitle: (row) => asText(row.fileName), 15527 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ReportDetail, { 15528 report: row, 15529 groups: thirdPartyGroups, 15530 trackingIdPrefix: "tracking-id-ads-settings-attribution-third-party", 15531 save: thirdParty.save, 15532 saving: thirdParty.saving 15533 }, row.id), 15534 emptyText: "No third party report configurations for this account", 15535 trackingIdPrefix: "tracking-id-ads-settings-attribution-third-party" 15536 }), 15537 kind === "placements" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 15538 title: "Placement mapping", 15539 description: "Which placement in a report belongs to which campaign. Written by the report ingestion, read-only here.", 15540 searchPlaceholder: "Search placements", 15541 rows: placements.rows, 15542 loading: placements.loading, 15543 searchKeys: ["placementId", "identifier"], 15544 columns: [ 15545 { 15546 key: "placementId", 15547 label: "Placement id" 15548 }, 15549 { 15550 key: "identifier", 15551 label: "Identifier" 15552 }, 15553 { 15554 key: "programId", 15555 label: "Campaign id" 15556 } 15557 ], 15558 getTitle: (row) => asText(row.placementId), 15559 emptyText: "No placements are mapped for this account", 15560 trackingIdPrefix: "tracking-id-ads-settings-attribution-placements" 15561 }) 15562 ] 15563 }); 15564}; 15565var sectionSx$4 = { 15566 display: "flex", 15567 flexDirection: "column", 15568 gap: 3, 15569 alignItems: "stretch" 15570}; 15571//#endregion 15572//#region client/components/newNav/components/ads/settings/SiteActionTagsSection.tsx 15573/** A tag may only map to one site action, which is the clash the old app let you save twice. */ 15574var validateRow = (row, otherRows) => otherRows.some((other) => asText(other.tag) === asText(row.tag) && asText(other.actionId) === asText(row.actionId)) ? { tag: "This tag is already mapped to that site action" } : {}; 15575var SiteActionTagsSection = () => { 15576 const { rows: siteActions } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.SITE_ACTION); 15577 const actionOptions = (0, import_react.useMemo)(() => siteActions.map((action) => ({ 15578 value: action.id, 15579 label: asText(action.name) 15580 })), [siteActions]); 15581 const fields = [ 15582 { 15583 key: "actionId", 15584 label: "Site action", 15585 type: "select", 15586 options: actionOptions, 15587 validate: required("Pick the site action this tag maps to") 15588 }, 15589 { 15590 key: "tag", 15591 label: "Tag", 15592 type: "text", 15593 help: "The event name as it arrives from the account's own site.", 15594 validate: required("Enter the tag as the site sends it") 15595 }, 15596 { 15597 key: "method", 15598 label: "Method", 15599 type: "select", 15600 options: siteActionMethodOptions, 15601 help: "optional, use when tags are not unique identifiers" 15602 } 15603 ]; 15604 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEntityRowsSection, { 15605 kind: GqlClientAdsSpaceEntityKind.ACCOUNT_ACTION_TAG, 15606 title: "Site action tags", 15607 fields, 15608 columns: [ 15609 { 15610 key: "actionId", 15611 label: "Site action", 15612 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 15613 variant: "subtitle2", 15614 children: actionOptions.find((option) => option.value === asText(row.actionId))?.label ?? asText(row.actionId) 15615 }) 15616 }, 15617 { 15618 key: "tag", 15619 label: "Tag" 15620 }, 15621 { 15622 key: "method", 15623 label: "Method", 15624 render: (row) =>
15624 asText(row.method) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 15625 size: "small", 15626 label: asText(row.method), 15627 color: "default", 15628 variant: "outlined" 15629 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 15630 variant: "body1", 15631 color: "text.disabled", 15632 children: "-" 15633 }) 15634 } 15635 ], 15636 addLabel: "Add tag mapping", 15637 emptyText: "No tags are mapped for this account yet", 15638 rowTitle: (row) => asText(row.tag), 15639 validateRow, 15640 trackingIdPrefix: "tracking-id-ads-settings-site-actions", 15641 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 15642 color: "info", 15643 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "Site actions are the canonical list SundaySky reports on. This page maps them to the event names this account's own site sends." }) 15644 }) 15645 }); 15646}; 15647//#endregion 15648//#region client/components/newNav/components/ads/settings/TrackingPropertiesSection.tsx 15649/** 15650* Stored as a key/value bag, but the key set is closed - nine keys across the whole platform. Typed inputs 15651* instead of a bag mean a misspelled key can no longer be saved and then silently ignored. 15652*/ 15653var groups = [ 15654 { 15655 id: "sampling", 15656 title: "Sampling", 15657 fields: [{ 15658 key: "samplingRate", 15659 label: "Sampling rate", 15660 type: "decimal", 15661 min: 0, 15662 max: 1, 15663 help: "Tracking sampling rate for the account, between 0 and 1.", 15664 validate: inRange(0, 1, "Sampling rate must be between 0 and 1") 15665 }, { 15666 key: "redShiftUuiSamplingRate", 15667 label: "Redshift UUI sampling rate", 15668 type: "decimal", 15669 min: 0, 15670 max: 1, 15671 help: "UUI logging sampling rate into Redshift.", 15672 validate: inRange(0, 1, "Sampling rate must be between 0 and 1") 15673 }] 15674 }, 15675 { 15676 id: "syncing", 15677 title: "User syncing", 15678 fields: [ 15679 { 15680 key: "maxSyncsPerPage", 15681 label: "Max syncs per page", 15682 type: "number", 15683 min: 0, 15684 help: "Max user syncs injected per page." 15685 }, 15686 { 15687 key: "syncBatchSize", 15688 label: "Sync batch size", 15689 type: "number", 15690 min: 0 15691 }, 15692 { 15693 key: "sync_blacklist", 15694 label: "Excluded sync partners", 15695 type: "multiSelect", 15696 options: syncPartnerOptions, 15697 wide: true, 15698 help: "Partners excluded from syncing for this account." 15699 } 15700 ] 15701 }, 15702 { 15703 id: "advanced", 15704 title: "Advanced", 15705 advanced: true, 15706 description: "Single-account settings. Each of these is currently in use by exactly one account.", 15707 fields: [ 15708 { 15709 key: "MinimumUserAgeSecondsForSync", 15710 label: "Minimum user age for sync (seconds)", 15711 type: "number", 15712 min: 0 15713 }, 15714 { 15715 key: "AddScriptSuffixTypes", 15716 label: "Add script suffix types", 15717 type: "text" 15718 }, 15719 { 15720 key: "BrightRollSegId", 15721 label: "BrightRoll segment id", 15722 type: "text" 15723 }, 15724 { 15725 key: "UdtFieldsToTrackingFields", 15726 label: "UDT fields to tracking fields", 15727 type: "text", 15728 wide: true, 15729 placeholder: "vmake=make,vmdl=model" 15730 } 15731 ] 15732 } 15733]; 15734var TrackingPropertiesForm = ({ source }) => { 15735 const form = useAdsSettingsForm(groups, source.values, "Sampling & sync", source.persist, source.saving); 15736 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 15737 groups, 15738 form, 15739 trackingIdPrefix: "tracking-id-ads-settings-tracking" 15740 }); 15741}; 15742var TrackingPropertiesSection = () => { 15743 const source = useAdsSpaceKeyValueForm(GqlClientAdsSpaceEntityKind.TRACKING_PROPERTY, groups);
15744 if (!source.values) return source.loading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 15745 variant: "rounded", 15746 height: 320 15747 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsUnreachableNotice, { what: "These tracking properties" }); 15748 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TrackingPropertiesForm, { source }, JSON.stringify(source.values)); 15749}; 15750//#endregion 15751//#region client/components/newNav/components/ads/settings/AdsSettingsPage.tsx 15752var sections$1 = [ 15753 { 15754 id: "account", 15755 label: "Account", 15756 icon: import_faBuilding.faBuilding, 15757 entityKinds: [GqlClientAdsSpaceEntityKind.ADS_ACCOUNT], 15758 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AccountSection, {}) 15759 }, 15760 { 15761 id: "tracking", 15762 label: "Sampling & sync", 15763 icon: import_faWaveformLines.faWaveformLines, 15764 entityKinds: [GqlClientAdsSpaceEntityKind.TRACKING_PROPERTY], 15765 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TrackingPropertiesSection, {}) 15766 }, 15767 { 15768 id: "site-actions", 15769 label: "Site actions", 15770 icon: import_faTag.faTag, 15771 entityKinds: [GqlClientAdsSpaceEntityKind.ACCOUNT_ACTION_TAG, GqlClientAdsSpaceEntityKind.SITE_ACTION], 15772 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SiteActionTagsSection, {}) 15773 }, 15774 { 15775 id: "attribution", 15776 label: "Attribution reports", 15777 icon: import_faChartMixed.faChartMixed, 15778 entityKinds: [ 15779 GqlClientAdsSpaceEntityKind.ATTRIBUTION_REPORT, 15780 GqlClientAdsSpaceEntityKind.ATTRIBUTION_REPORT_THIRD_PARTY, 15781 GqlClientAdsSpaceEntityKind.ATTRIBUTION_PLACEMENT_MAPPING 15782 ], 15783 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AttributionReportsSection, {}) 15784 }, 15785 { 15786 id: "dsp-data", 15787 label: "DSP data", 15788 icon: import_faTowerBroadcast.faTowerBroadcast, 15789 entityKinds: [GqlClientAdsSpaceEntityKind.DSP_ACCOUNT_DATA, GqlClientAdsSpaceEntityKind.DSP_SYNC_CONDITION], 15790 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DspAccountDataSection, {}) 15791 } 15792]; 15793/** 15794* Settings of the ads account itself. Each section is its own row in the left panel and its own page here, so 15795* reaching one is a single click rather than a page plus an inner menu. 15796*/ 15797var AdsSettingsPage = () => { 15798 const accountId = useActiveAccountId(); 15799 const { sectionId } = useParams(); 15800 const selected = sections$1.find((section) => section.id === sectionId); 15801 if (!selected) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: (0, import_dist$1.newNavAdsSettingsSectionPath)({ 15802 accountId, 15803 sectionId: "account" 15804 }) }); 15805 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 15806 area: "Accounts", 15807 title: selected.label, 15808 sections: [selected], 15809 trackingIdPrefix: "tracking-id-ads-settings" 15810 }); 15811}; 15812//#endregion 15813//#region client/components/newNav/components/ads/system/AdsDmpAccountMappingsPage.tsx 15814/** 15815* Which of each provider's own account names feed this ads account. Provider-wide configuration lives on the 15816* Providers page behind the system permission; this is the account-scoped half of the same data, which the 15817* server gives us by matching the mapping's SundaySky account name. 15818*/ 15819var DmpAccountMappingsSection = () => { 15820 const { rows, loading } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DMP_MAPPING); 15821 const providers = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DMP_PROVIDER); 15822 const providerName = (providerId) => asText(providers.rows.find((provider) => provider.id === providerId)?.name) || providerId; 15823 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 15824 title: "Account mappings", 15825 titleInPageHeader: true, 15826 searchPlaceholder: "Search mappings", 15827 rows, 15828 loading, 15829 searchKeys: ["dmpAccountName", "sskyAccountName"], 15830 columns: [{ 15831 key: "dmpProviderId", 15832 label: "Provider", 15833 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 15834 variant: "subtitle2", 15835 children: providerName(asText(row.dmpProviderId)) 15836 }) 15837 }, { 15838 key: "dmpAccountName", 15839 label: "DMP account name" 15840 }], 15841 getTitle: (row) => asText(row.dmpAccountName), 15842 emptyText: "No DMP account is mapped to this ads account", 15843 trackingIdPrefix: "tracking-id-ads-dmp-account-mappings" 15844 }); 15845}; 15846var AdsDmpAccountMappingsPage = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 15847 area: "DMP / Account mappings", 15848 title: "Account mappings",
15849 subtitle: "The provider account names whose offline files are ingested for this ads account.", 15850 sections: [{ 15851 id: "dmp-account-mappings", 15852 label: "Account mappings", 15853 entityKinds: [GqlClientAdsSpaceEntityKind.DMP_MAPPING], 15854 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DmpAccountMappingsSection, {}) 15855 }], 15856 trackingIdPrefix: "tracking-id-ads-dmp-account-mappings" 15857}); 15858//#endregion 15859//#region client/components/newNav/components/ads/system/DmpProvidersSection.tsx 15860var providerGroups = [{ 15861 id: "identity", 15862 title: "Provider", 15863 fields: [{ 15864 key: "name", 15865 label: "Name", 15866 type: "text", 15867 help: "The DMP Provider Name", 15868 validate: required("Enter the provider name") 15869 }] 15870}]; 15871var sourceFields = [ 15872 { 15873 key: "bucket", 15874 label: "Source bucket", 15875 type: "text", 15876 help: "The DMP's source bucket", 15877 validate: required("Enter the source bucket") 15878 }, 15879 { 15880 key: "path", 15881 label: "Source path", 15882 type: "text", 15883 help: "The DMP's source path" 15884 }, 15885 { 15886 key: "regex", 15887 label: "Source file regex", 15888 type: "text", 15889 help: "The DMP's source file regex", 15890 validate: (value) => { 15891 const pattern = asText(value); 15892 if (!pattern) return null; 15893 try { 15894 RegExp(pattern); 15895 return null; 15896 } catch { 15897 return "This is not a valid regular expression"; 15898 } 15899 } 15900 } 15901]; 15902var mappingFields = [{ 15903 key: "dmpAccountName", 15904 label: "DMP account name", 15905 type: "text", 15906 validate: required("Enter the provider's account name") 15907}, { 15908 key: "sskyAccountName", 15909 label: "SundaySky account name", 15910 type: "text", 15911 validate: required("Enter the SundaySky account name") 15912}]; 15913var ProviderDetail = ({ provider, save, saving }) => { 15914 const sources = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.DMP_SOURCE, provider.id); 15915 const mappings = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.DMP_MAPPING, provider.id); 15916 const form = useAdsSettingsForm(providerGroups, provider, "The DMP provider", async (values, comment) => { 15917 await save([{ 15918 ...values, 15919 id: provider.id 15920 }], comment); 15921 await Promise.all([sources.persist(comment), mappings.persist(comment)]); 15922 }, saving || sources.saving || mappings.saving, [sources, mappings]); 15923 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 15924 groups: providerGroups, 15925 form, 15926 trackingIdPrefix: "tracking-id-ads-system-dmp-provider", 15927 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 15928 sx: childTablesSx$1, 15929 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 15930 title: "Sources", 15931 description: "Where the provider's files land and which of them to ingest.", 15932 fields: sourceFields, 15933 rows: sources.rows, 15934 addLabel: "Add source", 15935 emptyText: "No sources are configured for this provider", 15936 rowTitle: (row) => asText(row.bucket), 15937 onChange: sources.setRows, 15938 trackingIdPrefix: "tracking-id-ads-system-dmp-source" 15939 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 15940 title: "Account mappings", 15941 description: "The provider's own account name for each SundaySky account it sends files for.", 15942 fields: mappingFields, 15943 rows: mappings.rows, 15944 addLabel: "Add mapping", 15945 emptyText: "No accounts are mapped to this provider", 15946 rowTitle: (row) => asText(row.dmpAccountName), 15947 onChange: mappings.setRows, 15948 trackingIdPrefix: "tracking-id-ads-system-dmp-mapping" 15949 })] 15950 }) 15951 }); 15952}; 15953var DmpProvidersSection = () => { 15954 const { rows, save, saving, loading } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.DMP_PROVIDER); 15955 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 15956 title: "Providers", 15957 titleInPageHeader: true, 15958 searchPlaceholder: "Search providers", 15959 rows, 15960 loading, 15961 searchKeys: ["name"], 15962 columns: [{ 15963 key: "name", 15964 label: "Provider" 15965 }, { 15966 key: "account", 15967 label: "Provider account" 15968 }], 15969 getTitle: (row) => asText(row.name),
15970 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProviderDetail, { 15971 provider: row, 15972 save, 15973 saving 15974 }, row.id), 15975 emptyText: "No DMP providers are configured", 15976 trackingIdPrefix: "tracking-id-ads-system-dmp" 15977 }); 15978}; 15979var childTablesSx$1 = { 15980 display: "flex", 15981 flexDirection: "column", 15982 gap: 4 15983}; 15984//#endregion 15985//#region client/components/newNav/components/ads/system/AdsDmpProvidersPage.tsx 15986var AdsDmpProvidersPage = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 15987 area: "DMP / Providers", 15988 title: "Providers", 15989 subtitle: "Where audience data comes from, and where each provider's files land.", 15990 sections: [{ 15991 id: "dmp-providers", 15992 label: "Providers", 15993 entityKinds: [GqlClientAdsSpaceEntityKind.DMP_PROVIDER, GqlClientAdsSpaceEntityKind.DMP_SOURCE], 15994 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DmpProvidersSection, {}) 15995 }], 15996 trackingIdPrefix: "tracking-id-ads-dmp-providers" 15997}); 15998//#endregion 15999//#region client/components/newNav/components/ads/system/ExchangesSection.tsx 16000var rate = (key, label, help) => ({ 16001 key, 16002 label, 16003 type: "decimal", 16004 min: 0, 16005 max: 1, 16006 help, 16007 validate: inRange(0, 1, "Sampling rates are between 0 and 1") 16008}); 16009var exchangeGroups = [ 16010 { 16011 id: "identity", 16012 title: "Identity", 16013 fields: [ 16014 { 16015 key: "prefix", 16016 label: "Prefix", 16017 type: "readOnly" 16018 }, 16019 { 16020 key: "name", 16021 label: "Name", 16022 type: "text", 16023 help: "Human readable name of exchange" 16024 }, 16025 { 16026 key: "adsTxtName", 16027 label: "ads.txt name", 16028 type: "text", 16029 help: "The name by which the exchange is recognized in ads.txt" 16030 } 16031 ] 16032 }, 16033 { 16034 id: "bidding", 16035 title: "Bidding", 16036 fields: [ 16037 { 16038 key: "maxBidTime", 16039 label: "Max bid time (ms)", 16040 type: "number", 16041 min: 0, 16042 help: "The number of milliseconds after which the ad server will abort handling a bid request." 16043 }, 16044 { 16045 key: "lowBallRate", 16046 label: "Low ball rate", 16047 type: "decimal", 16048 min: 0, 16049 max: 1, 16050 help: "The Low Ball rate [0 = never,1 = always], turning failed bids into 1 cent bids", 16051 validate: inRange(0, 1, "The low ball rate is between 0 and 1") 16052 }, 16053 { 16054 key: "lowBallDummyProgram", 16055 label: "Low ball dummy campaign", 16056 type: "text", 16057 help: "The program whose control group video is used for Low Ball response generation" 16058 }, 16059 { 16060 key: "minSecondsBetweenSameOpportunity", 16061 label: "Min seconds between opportunities", 16062 type: "number", 16063 min: 0, 16064 help: "The minimum number of seconds between two opportunities for the same user (subsequent opportunities will be throttled)" 16065 } 16066 ] 16067 }, 16068 { 16069 id: "sampling", 16070 title: "Sampling", 16071 description: "What the ad server keeps from this exchange's traffic. Lowering these loses training data for future models.", 16072 fields: [ 16073 rate("opportunityCreationRate", "Opportunity creation rate", "Sampling rate for opportunity creation. If a request is not sampled then the user profile is not fetched and prospecting model scores cannot be calculated."), 16074 rate("opportunityCreationNonSSkyUserRate", "Non-SundaySky user creation rate", "Sampling rate for non ssky opportunity creation."), 16075 rate("opportunityLogRate", "Opportunity log rate", "Sampling rate for opportunity logging. Sampling rate only for requests which passed the opportunity creation filters. If a request is not sampled it is not sent to the processor and cannot be used for training future models."), 16076 rate("fullOpportunityLogRate", "Full opportunity log rate", "Sampling rate for full opportunity logging."), 16077 rate("reqRespLogRate", "Request and response log rate", "Sampling rate for logging requests and responses handled by the ad server.") 16078 ] 16079 }, 16080 {
16081 id: "viewability", 16082 title: "Viewability", 16083 fields: [ 16084 { 16085 key: "viewabilityDefault", 16086 label: "Default viewability", 16087 type: "number", 16088 help: "The default viewability for this exchange - to use when no value is reported" 16089 }, 16090 { 16091 key: "viewabilityFactor", 16092 label: "Viewability factor", 16093 type: "decimal", 16094 help: "The factor to apply to the viewability when reported by the exchange." 16095 }, 16096 { 16097 key: "viewabilityOverrideType", 16098 label: "Correction mode", 16099 type: "select", 16100 options: viewabilityOverrideOptions, 16101 help: "Viewability Correction Mode" 16102 }, 16103 { 16104 key: "viewabilityOverrideMapPath", 16105 label: "Correction data path", 16106 type: "url", 16107 help: "S3 Path to the CSV of viewability correction data" 16108 }, 16109 { 16110 key: "viewabilityOverrideRatio", 16111 label: "Correction ratio", 16112 type: "decimal", 16113 min: 0, 16114 max: 1, 16115 help: "Ratio of correction from the final viewability score. 1.0 = use correction value completely; 0.0 = use original value completely; 0.25 = 0.75*correction + 0.25*original", 16116 validate: inRange(0, 1, "The correction ratio is between 0 and 1") 16117 } 16118 ] 16119 }, 16120 { 16121 id: "users", 16122 title: "Users", 16123 fields: [{ 16124 key: "useExchangeUserResolving", 16125 label: "Use exchange user resolving", 16126 type: "bool", 16127 help: "enable/disable exchange user resolving with third party id" 16128 }] 16129 }, 16130 { 16131 id: "reports", 16132 title: "Reports", 16133 advanced: true, 16134 description: "How the exchange's own delivery report is recognised and parsed.", 16135 fields: [ 16136 { 16137 key: "reportSender", 16138 label: "Report sender", 16139 type: "text", 16140 help: "Report sender domain (after the @)" 16141 }, 16142 { 16143 key: "reportDate", 16144 label: "Date column", 16145 type: "text", 16146 help: "Date column in exchange report" 16147 }, 16148 { 16149 key: "reportImpressions", 16150 label: "Impressions column", 16151 type: "text", 16152 help: "Impressions column in exchange report" 16153 }, 16154 { 16155 key: "reportCost", 16156 label: "Cost column", 16157 type: "text", 16158 help: "Cost column in exchange report" 16159 }, 16160 { 16161 key: "updateWindowDays", 16162 label: "Update window (days)", 16163 type: "number", 16164 min: 0, 16165 help: "number of days to look back at reports data; set to 0 in order to process all the data in the report" 16166 } 16167 ] 16168 } 16169]; 16170var ExchangeDetail = ({ exchange, save, saving }) => { 16171 const form = useAdsSettingsForm(exchangeGroups, exchange, "The exchange", (values, comment) => save([{ 16172 ...values, 16173 id: exchange.id 16174 }], comment), saving); 16175 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 16176 groups: exchangeGroups, 16177 form, 16178 saveWarning: "These settings apply to every campaign bidding into this exchange, on every account.", 16179 trackingIdPrefix: "tracking-id-ads-system-exchange" 16180 }); 16181}; 16182/** 16183* Twenty rows, changed four times in a year - and every one of them applies to all traffic from that exchange. 16184* Low traffic, high blast radius, which is why the sampling rates carry their full help rather than a label. 16185*/ 16186var ExchangesSection = () => { 16187 const { rows, save, saving, loading } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.EXCHANGE); 16188 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 16189 title: "Exchanges", 16190 titleInPageHeader: true, 16191 searchPlaceholder: "Search exchanges", 16192 rows, 16193 loading, 16194 searchKeys: [ 16195 "name", 16196 "prefix", 16197 "adsTxtName" 16198 ], 16199 columns: [ 16200 { 16201 key: "name", 16202 label: "Exchange", 16203 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16204 variant: "subtitle2", 16205 children: asText(row.name) 16206 }) 16207 }, 16208 { 16209 key: "prefix", 16210 label: "Prefix", 16211 align: "right" 16212 }, 16213 { 16214 key: "maxBidTime", 16215 label: "Max bid time (ms)", 16216 align: "right" 16217 }, 16218 { 16219 key: "viewabilityOverrideType", 16220 label: "Viewability correction", 16221 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 16222 size: "small", 16223 variant: asText(row.viewabilityOverrideType) === "NONE" ? "dashed" : "standard", 16224 color: asText(row.viewabilityOverrideType) === "NONE" ? "default" : "info", 16225 label: viewabilityOverrideOptions.find((option) => option.value === asText(row.viewabilityOverrideType))?.label ?? "Disabled" 16226 }) 16227 } 16228 ], 16229 getTitle: (row) => asText(row.name),
16230 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExchangeDetail, { 16231 exchange: row, 16232 save, 16233 saving 16234 }, row.id), 16235 emptyText: "No exchanges are configured", 16236 trackingIdPrefix: "tracking-id-ads-system-exchanges" 16237 }); 16238}; 16239//#endregion 16240//#region client/components/newNav/components/ads/system/AdsExchangesPage.tsx 16241var AdsExchangesPage = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 16242 area: "Exchanges", 16243 title: "Exchanges", 16244 subtitle: "Per-exchange timeouts, sampling and viewability. Each setting applies to every campaign bidding into that exchange.", 16245 sections: [{ 16246 id: "exchanges", 16247 label: "Exchanges", 16248 entityKinds: [GqlClientAdsSpaceEntityKind.EXCHANGE], 16249 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExchangesSection, {}) 16250 }], 16251 trackingIdPrefix: "tracking-id-ads-exchanges" 16252}); 16253//#endregion 16254//#region client/components/newNav/components/ads/system/PredictionModelsSection.tsx 16255/** 16256* Written by the training pipeline, not by people - the last human edit was in 2024. Everything is read-only 16257* except the description, which is where the SageMaker endpoint is recorded and which the campaign optimization 16258* help text tells people to fill in. 16259*/ 16260var modelGroups = [{ 16261 id: "identity", 16262 title: "Model", 16263 fields: [ 16264 { 16265 key: "id", 16266 label: "Model id", 16267 type: "readOnly" 16268 }, 16269 { 16270 key: "modelType", 16271 label: "Model type", 16272 type: "readOnly" 16273 }, 16274 { 16275 key: "targetType", 16276 label: "Target", 16277 type: "readOnly" 16278 }, 16279 { 16280 key: "useCase", 16281 label: "Use case", 16282 type: "readOnly" 16283 }, 16284 { 16285 key: "trainedOnAccount", 16286 label: "Trained on account", 16287 type: "readOnly" 16288 }, 16289 { 16290 key: "trainedOnCampaign", 16291 label: "Trained on campaign", 16292 type: "readOnly" 16293 }, 16294 { 16295 key: "trainedAt", 16296 label: "Trained at", 16297 type: "readOnly" 16298 } 16299 ] 16300}, {
16301 id: "description", 16302 title: "Description", 16303 description: "The one editable field. Campaign optimization expects the SageMaker endpoint to be recorded here.", 16304 fields: [{ 16305 key: "description", 16306 label: "Description", 16307 type: "longText", 16308 wide: true 16309 }] 16310}]; 16311var ModelDetail = ({ model, save, saving }) => { 16312 const form = useAdsSettingsForm(modelGroups, model, "The prediction model description", (values, comment) => save([{ 16313 ...values, 16314 id: model.id 16315 }], comment), saving); 16316 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 16317 groups: modelGroups, 16318 form, 16319 trackingIdPrefix: "tracking-id-ads-system-prediction-model" 16320 }); 16321}; 16322var PredictionModelsSection = () => { 16323 const { rows, save, saving, loading } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.PREDICTION_MODEL); 16324 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16325 sx: sectionSx$3, 16326 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 16327 color: "info", 16328 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "Models are produced by the training pipeline. Search here by account, campaign or target - a campaign's optimization picker reads the same list." }) 16329 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 16330 title: "Prediction models", 16331 titleInPageHeader: true, 16332 searchPlaceholder: "Search by account, campaign or target", 16333 rows, 16334 loading, 16335 searchKeys: [ 16336 "id", 16337 "trainedOnAccount", 16338 "trainedOnCampaign", 16339 "targetType", 16340 "modelType", 16341 "description" 16342 ], 16343 columns: [ 16344 { 16345 key: "trainedOnAccount", 16346 label: "Trained on", 16347 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16348 variant: "subtitle2", 16349 children: asText(row.trainedOnAccount) 16350 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16351 variant: "caption", 16352 color: "text.secondary", 16353 children: asText(row.trainedOnCampaign) 16354 })] }) 16355 }, 16356 { 16357 key: "targetType", 16358 label: "Target", 16359 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 16360 size: "small", 16361 color: "default", 16362 variant: "outlined", 16363 label: asText(row.targetType) 16364 }) 16365 }, 16366 { 16367 key: "modelType", 16368 label: "Model type" 16369 }, 16370 { 16371 key: "trainedAt", 16372 label: "Trained", 16373 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16374 variant: "body1", 16375 noWrap: true, 16376 children: TimeUtils.getDesignSystemReadableTimeDate(asText(row.trainedAt), true) 16377 }) 16378 }, 16379 { 16380 key: "id", 16381 label: "Id", 16382 align: "right" 16383 } 16384 ], 16385 getTitle: (row) => `Model ${asText(row.id)}`,
16386 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModelDetail, { 16387 model: row, 16388 save, 16389 saving 16390 }, row.id), 16391 emptyText: "No prediction models", 16392 trackingIdPrefix: "tracking-id-ads-system-prediction-models" 16393 })] 16394 }); 16395}; 16396var sectionSx$3 = { 16397 display: "flex", 16398 flexDirection: "column", 16399 gap: 3 16400}; 16401//#endregion 16402//#region client/components/newNav/components/ads/system/AdsPredictionModelsPage.tsx 16403var AdsPredictionModelsPage = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 16404 area: "Programmatic / Prediction models", 16405 title: "Prediction models", 16406 sections: [{ 16407 id: "prediction-models", 16408 label: "Prediction models", 16409 entityKinds: [GqlClientAdsSpaceEntityKind.PREDICTION_MODEL], 16410 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PredictionModelsSection, {}) 16411 }], 16412 trackingIdPrefix: "tracking-id-ads-prediction-models" 16413}); 16414//#endregion 16415//#region client/components/newNav/components/ads/system/KillSwitchConfirmDialog.tsx 16416/** 16417* Some settings stop traffic across the platform. The old app let one flip with the same two clicks as a log 16418* sampling rate, so the destructive treatment starts here. 16419*/ 16420var KillSwitchConfirmDialog = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 16421 open: props.open, 16422 onClose: props.onCancel, 16423 maxWidth: "sm", 16424 fullWidth: true, 16425 children: [ 16426 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 16427 CloseIconButtonProps: { 16428 onClick: props.onCancel, 16429 "data-tracking-id": "tracking-id-ads-system-kill-switch-close-btn" 16430 }, 16431 children: "Change a kill switch?" 16432 }), 16433 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.DialogContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16434 sx: contentSx$2, 16435 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16436 variant: "body1", 16437 color: "text.secondary", 16438 children: `${props.settingKey} will be set to ${props.nextValue} on every server of this type, immediately after the next configuration reload.` 16439 }
16439), props.help && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16440 variant: "body1", 16441 color: "text.secondary", 16442 children: props.help 16443 })] 16444 }) }), 16445 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 16446 variant: "text", 16447 size: "large", 16448 onClick: props.onCancel, 16449 "data-tracking-id": "tracking-id-ads-system-kill-switch-cancel-btn", 16450 children: "Cancel" 16451 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 16452 variant: "contained", 16453 color: "error", 16454 size: "large", 16455 onClick: props.onConfirm, 16456 "data-tracking-id": "tracking-id-ads-system-kill-switch-confirm-btn", 16457 children: "Change it" 16458 })] }) 16459 ] 16460}); 16461var contentSx$2 = { 16462 display: "flex", 16463 flexDirection: "column", 16464 gap: 2 16465}; 16466//#endregion 16467//#region client/components/newNav/components/ads/system/serverSettingsModel.ts 16468var serverTypes = [ 16469 "ADS", 16470 "TRACKING", 16471 "SERVICES", 16472 "DMP", 16473 "WALLED_GARDEN" 16474]; 16475var serverTypeLabels = { 16476 ADS: "Ads Server", 16477 TRACKING: "Tracking Server", 16478 SERVICES: "Services Server", 16479 DMP: "DMP", 16480 WALLED_GARDEN: "Walled Garden Engine" 16481}; 16482/** The walled garden track is not being rebuilt, so its configuration is readable but not editable. */ 16483var readOnlyServerTypes = ["WALLED_GARDEN"]; 16484/** What a definition may say a value is. `SECRET` is deliberately absent - that is a flag, not a type. */ 16485var serverSettingValueTypes = [ 16486 "BOOLEAN", 16487 "INT", 16488 "FLOAT", 16489 "RATIO", 16490 "LIST", 16491 "ENUM", 16492 "STRING" 16493]; 16494var ServerSettingFilter = /* @__PURE__ */ function(ServerSettingFilter) { 16495 ServerSettingFilter["ALL"] = "ALL"; 16496 ServerSettingFilter["KILL_SWITCH"] = "KILL_SWITCH"; 16497 ServerSettingFilter["UNDOCUMENTED"] = "UNDOCUMENTED"; 16498 ServerSettingFilter["NOT_SET"] = "NOT_SET"; 16499 ServerSettingFilter["DEPRECATED"] = "DEPRECATED"; 16500 return ServerSettingFilter; 16501}({}); 16502var serverSettingFilterLabels = { 16503 ["ALL"]: "All settings", 16504 ["KILL_SWITCH"]: "Kill switches", 16505 ["UNDOCUMENTED"]: "Missing help text", 16506 ["NOT_SET"]: "Not set", 16507 ["DEPRECATED"]: "Deprecated" 16508}; 16509var matchesServerSettingFilter = (row, filter) => { 16510 switch (filter) { 16511 case "KILL_SWITCH": return row.isKillSwitch; 16512 case "UNDOCUMENTED": return !row.description; 16513 case "NOT_SET": return !row.hasValue; 16514 case "DEPRECATED": return row.isDeprecated; 16515 default: return true; 16516 } 16517}; 16518var filterServerSettings = (rows, searchTerm, filter) => { 16519 const term = searchTerm.trim().toLowerCase(); 16520 return rows.filter((row) => matchesServerSettingFilter(row, filter) && (!term || row.key.toLowerCase().includes(term) || (row.description ?? "").toLowerCase().includes(term))); 16521}; 16522var countUndocumented = (rows) => rows.filter((row) => !row.description).length; 16523/** Documented keys nobody has given a value - the add-setting picker's whole list. */ 16524var undocumentedValueKeys = (rows) => rows.filter((row) => !row.hasValue); 16525//#endregion 16526//#region client/components/newNav/components/ads/system/NewServerSettingDialog.tsx 16527/** Documents a key and optionally gives it a value. A key the server does not read is ignored without a word. */ 16528var NewServerSettingDialog = (props) => { 16529 const [propertyName, setPropertyName] = (0, import_react.useState)(""); 16530 const [valueType, setValueType] = (0, import_react.useState)(serverSettingValueTypes[serverSettingValueTypes.length - 1]); 16531 const [description, setDescription] = (0, import_react.useState)(""); 16532 const [value, setValue] = (0, import_react.useState)(""); 16533 const [comment, setComment] = (0, import_react.useState)(""); 16534 const trimmedName = propertyName.trim(); 16535 const isTaken = props.existingKeys.includes(trimmedName); 16536 const canCreate = Boolean(trimmedName) && !isTaken && Boolean(comment.trim()) && !props.saving; 16537 const close = () => { 16538 setPropertyName(""); 16539 setDescription(""); 16540 setValue(""); 16541 setComment(""); 16542 props.onClose(); 16543 }; 16544 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Dialog, { 16545 open: props.open, 16546 onClose: props.saving ? void 0 : close, 16547 PaperProps: { sx: paperSx }, 16548 children: [ 16549 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleDialogTitle, { 16550 CloseIconButtonProps: { 16551 onClick: close, 16552 "data-tracking-id": "tracking-id-ads-system-new-setting-close-btn" 16553 }, 16554 children: `New ${props.serverType} setting` 16555 }),
16556 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.DialogContent, { 16557 sx: contentSx$1, 16558 children: [ 16559 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16560 label: "Key", 16561 fullWidth: true, 16562 value: propertyName, 16563 error: isTaken, 16564 helperText: isTaken ? "This key is already documented" : void 0, 16565 onChange: (event) => setPropertyName(event.target.value), 16566 inputProps: { "data-tracking-id": "tracking-id-ads-system-new-setting-key-input" } 16567 }), 16568 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16569 select: true, 16570 label: "Value type", 16571 fullWidth: true, 16572 value: valueType, 16573 onChange: (event) => setValueType(event.target.value), 16574 inputProps: { "data-tracking-id": "tracking-id-ads-system-new-setting-type-input" }, 16575 children: serverSettingValueTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.MenuItem, { 16576 value: type, 16577 children: type 16578 }, type)) 16579 }), 16580 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16581 label: "What it means", 16582 fullWidth: true, 16583 multiline: true, 16584 minRows: 2, 16585 value: description, 16586 onChange: (event) => setDescription(event.target.value), 16587 inputProps: { "data-tracking-id": "tracking-id-ads-system-new-setting-description-input" } 16588 }), 16589 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16590 label: "Value (optional)", 16591 fullWidth: true, 16592 value, 16593 onChange: (event) => setValue(event.target.value), 16594 inputProps: { "data-tracking-id": "tracking-id-ads-system-new-setting-value-input" } 16595 }), 16596 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChangeCommentField, { 16597 value: comment, 16598 onChange: setComment, 16599 trackingId: "tracking-id-ads-system-new-setting-comment-input" 16600 }), 16601 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 16602 color: "info", 16603 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "Documenting a key does not make the server read it. Only keys the server already looks for have any effect; the rest are ignored silently." }) 16604 }) 16605 ] 16606 }), 16607 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.TruffleDialogActions, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 16608 variant: "text", 16609 size: "large", 16610 disabled: props.saving, 16611 onClick: close, 16612 "data-tracking-id": "tracking-id-ads-system-new-setting-cancel-btn", 16613 children: "Cancel" 16614 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 16615 variant: "contained", 16616 size: "large", 16617 loading: props.saving, 16618 disabled: !canCreate, 16619 onClick: () => { 16620 props.onCreate({ 16621 propertyName: trimmedName, 16622 valueType, 16623 description, 16624 isKillSwitch: false, 16625 value 16626 }, comment.trim()); 16627 close(); 16628 }, 16629 "data-tracking-id": "tracking-id-ads-system-new-setting-create-btn", 16630 children: "Add setting" 16631 })] }) 16632 ] 16633 }); 16634}; 16635var paperSx = { 16636 width: 540, 16637 maxWidth: "calc(100vw - 64px)" 16638}; 16639var contentSx$1 = { 16640 display: "flex", 16641 flexDirection: "column", 16642 gap: 2 16643}; 16644//#endregion 16645//#region client/components/newNav/components/ads/system/useAdsSpaceServerSettings.ts 16646/** One ads server's runtime configuration: the hub-owned definitions merged with the live values. */ 16647var useAdsSpaceServerSettings = (serverType) => { 16648 const { data, loading, error } = useCurrentAccountAdsSpaceServerSettingsQuery({ variables: { serverType } }); 16649 const [saveSettings, { loading: saving }] = useSaveAdsSpaceServerSettingsMutation(); 16650 const rows = (0, import_react.useMemo)(() => data?.currentAccount?.adsSpace?.serverSettings ?? [], [data]); 16651 const save = async (values, comment) => { 16652 try { 16653 await saveSettings({ variables: { 16654 serverType, 16655 input: { 16656 serverType, 16657 changeComment: comment, 16658 values 16659 } 16660 } }); 16661 setSuccessNotification({ message: "Configuration saved - the servers reload it on their next poll" }); 16662 } catch (saveError) { 16663 setErrorNotification({ message: saveError?.message ?? "Saving this configuration failed" }); 16664 } 16665 }; 16666 return { 16667 rows, 16668 loading, 16669 error: Boolean(error), 16670 saving, 16671 save 16672 }; 16673}; 16674//#endregion
16675//#region client/components/newNav/components/ads/system/ServerConfigurationSection.tsx 16676/** 16677* The platform's runtime configuration console, and the page ops opens during an incident. What the old app did 16678* not do: the documented meaning of every key lives beside it and can be edited, a kill switch says so before it 16679* is flipped, and a value that would not fit the column is refused rather than silently truncated. 16680*/ 16681var ServerConfigurationSection = () => { 16682 const [serverType, setServerType] = (0, import_react.useState)("ADS"); 16683 const [searchTerm, setSearchTerm] = (0, import_react.useState)(""); 16684 const [filter, setFilter] = (0, import_react.useState)(ServerSettingFilter.ALL); 16685 const [filterAnchorEl, setFilterAnchorEl] = (0, import_react.useState)(null); 16686 const [addAnchorEl, setAddAnchorEl] = (0, import_react.useState)(null); 16687 const [valueEdits, setValueEdits] = (0, import_react.useState)({}); 16688 const [descriptionEdits, setDescriptionEdits] = (0, import_react.useState)({}); 16689 const [pendingKillSwitch, setPendingKillSwitch] = (0, import_react.useState)(null); 16690 const [isCommentOpen, setIsCommentOpen] = (0, import_react.useState)(false); 16691 const [isDiscardOpen, setIsDiscardOpen] = (0, import_react.useState)(false); 16692 const [isNewOpen, setIsNewOpen] = (0, import_react.useState)(false); 16693 const { rows, loading, saving, save } = useAdsSpaceServerSettings(serverType); 16694 const definitions = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.SERVER_SETTING_DEFINITION); 16695 const visibleRows = (0, import_react.useMemo)(() => filterServerSettings(rows, searchTerm, filter), [ 16696 rows, 16697 searchTerm, 16698 filter 16699 ]); 16700 const isReadOnly = readOnlyServerTypes.includes(serverType); 16701 const changeCount = Object.keys(valueEdits).length + Object.keys(descriptionEdits).length; 16702 const guard = useAdsUnsavedChangesGuard(changeCount > 0 && !saving && !isReadOnly); 16703 const valueOf = (row) => valueEdits[row.key] ?? row.value ?? ""; 16704 const descriptionOf = (row) => descriptionEdits[row.key] ?? row.description ?? ""; 16705 const tooLong = Object.entries(valueEdits).filter(([, value]) => value.length > 255).map(([key]) => key); 16706 const editValue = (row, value) => { 16707 if (row.isKillSwitch && !valueEdits[row.key]) { 16708 setPendingKillSwitch({ 16709 row, 16710 value 16711 }); 16712 return; 16713 } 16714 setValueEdits({ 16715 ...valueEdits, 16716 [row.key]: value 16717 }); 16718 }; 16719 const discard = () => { 16720 setValueEdits({}); 16721 setDescriptionEdits({}); 16722 setIsDiscardOpen(false); 16723 guard.confirmNavigation(); 16724 }; 16725 const persist = async (comment) => { 16726 const values = Object.entries(valueEdits).map(([key, value]) => ({ 16727 key, 16728 value 16729 })); 16730 const changedDefinitions = Object.entries(descriptionEdits).map(([key, description]) => { 16731 const row = rows.find((candidate) => candidate.key === key); 16732 return row?.definitionId ? { 16733 id: row.definitionId, 16734 description 16735 } : { 16736 id: `new-${key}`, 16737 serverType, 16738 propertyName: key, 16739 valueType: "STRING", 16740 description 16741 }; 16742 }); 16743 try { 16744 if (values.length > 0) await save(values, comment); 16745 if (changedDefinitions.length > 0) await definitions.save(changedDefinitions, comment); 16746 setValueEdits({}); 16747 setDescriptionEdits({}); 16748 } catch {} finally { 16749 setIsCommentOpen(false); 16750 } 16751 }; 16752 const createSetting = async (input, comment) => { 16753 try { 16754 await definitions.save([{ 16755 id: `new-${input.propertyName}`, 16756 serverType, 16757 propertyName: input.propertyName, 16758 valueType: input.valueType, 16759 description: input.description, 16760 isKillSwitch: String(input.isKillSwitch), 16761 isSecret: "false", 16762 isDeprecated: "false" 16763 }], comment); 16764 if (input.value) await save([{ 16765 key: input.propertyName, 16766 value: input.value 16767 }], comment); 16768 } catch {} 16769 }; 16770 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16771 sx: sectionSx$2, 16772 children: [ 16773 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleToggleButtonGroup, { 16774 exclusive: true, 16775 size: "small", 16776 value: serverType, 16777 onChange: (_event, next) => next && setServerType(next), 16778 children: serverTypes.map((type) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ToggleButton, { 16779 value: type, 16780 "data-tracking-id": `tracking-id-ads-system-server-type-${type.toLowerCase()}-btn`, 16781 children: serverTypeLabels[type] 16782 }, type)) 16783 }),
16784 isReadOnly ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 16785 color: "warning", 16786 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "The walled garden track is not being rebuilt. Its settings are here so nothing is lost, but they cannot be changed from this page." }) 16787 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 16788 color: "info", 16789 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "Saving bumps the configuration version, which is what makes the servers reload. Values apply to every account." }) 16790 }), 16791 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16792 sx: headerSx$1, 16793 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16794 sx: { minWidth: 0 }, 16795 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16796 variant: "h5", 16797 children: `${serverTypeLabels[serverType]} (${rows.length})` 16798 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16799 variant: "body2", 16800 color: "text.secondary", 16801 children: `${countUndocumented(rows)} of these have no documented meaning yet.` 16802 })] 16803 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16804 sx: headerActionsSx, 16805 children: [ 16806 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Filter, { 16807 placeholderValue: serverSettingFilterLabels[ServerSettingFilter.ALL], 16808 selectedValue: filter === ServerSettingFilter.ALL ? null : serverSettingFilterLabels[filter], 16809 onClear: () => setFilter(ServerSettingFilter.ALL), 16810 popoverTitle: "Filter settings", 16811 anchorEl: filterAnchorEl, 16812 setAnchorEl: setFilterAnchorEl, 16813 "data-tracking-id": "tracking-id-ads-system-server-config-filter-btn", 16814 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.List, { children: Object.values(ServerSettingFilter).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 16815 dense: true, 16816 selected: filter === option, 16817 onClick: () => { 16818 setFilter(option); 16819 setFilterAnchorEl(null); 16820 }, 16821 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { primary: serverSettingFilterLabels[option] }) 16822 }, option)) }) 16823 }), 16824 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 16825 sx: searchWrapperSx, 16826 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Search, { 16827 placeholder: "Search keys and help", 16828 value: searchTerm, 16829 onChange: (event) => setSearchTerm(event.target.value), 16830 onClear: () => setSearchTerm(""), 16831 numberOfResults: visibleRows.length, 16832 inputProps: { "data-tracking-id": "tracking-id-ads-system-server-config-search-input" } 16833 }) 16834 }), 16835 !isReadOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Filter, { 16836 placeholderValue: "Add setting", 16837 selectedValue: null, 16838 onClear: () => setAddAnchorEl(null), 16839 popoverTitle: "Documented keys with no value", 16840 anchorEl: addAnchorEl, 16841 setAnchorEl: setAddAnchorEl, 16842 "data-tracking-id": "tracking-id-ads-system-server-config-add-btn", 16843 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.List, { children: undocumentedValueKeys(rows).map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 16844 dense: true, 16845 onClick: () => { 16846 setValueEdits({ 16847 ...valueEdits, 16848 [row.key]: "" 16849 }); 16850 setSearchTerm(row.key); 16851 setAddAnchorEl(null); 16852 }, 16853 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { primary: row.key }) 16854 }, row.key)) }) 16855 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 16856 variant: "outlined", 16857 size: "medium", 16858 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 16859 onClick: () => setIsNewOpen(true),
16860 "data-tracking-id": "tracking-id-ads-system-server-config-new-key-btn", 16861 children: "New key" 16862 })] }) 16863 ] 16864 })] 16865 }), 16866 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 16867 component: import_material.Paper, 16868 variant: "outlined", 16869 sx: tableSx, 16870 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 16871 size: "small", 16872 stickyHeader: true, 16873 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 16874 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16875 sx: headCellSx, 16876 children: "Setting" 16877 }), 16878 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16879 sx: headCellSx, 16880 children: "Type" 16881 }), 16882 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16883 sx: valueHeadCellSx, 16884 children: "Value" 16885 }) 16886 ] }) }
16886), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [ 16887 loading && rows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16888 colSpan: 3, 16889 sx: emptyCellSx$1, 16890 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTableSkeleton, { 16891 rows: 6, 16892 height: 32 16893 }) 16894 }) }), 16895 !loading && visibleRows.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16896 colSpan: 3, 16897 sx: emptyCellSx$1, 16898 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16899 variant: "body1", 16900 color: "text.secondary", 16901 children: "No settings match this filter" 16902 }) 16903 }) }), 16904 visibleRows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 16905 sx: rowHoverSx, 16906 "data-testid": `ads-server-setting-${row.key}`, 16907 children: [ 16908 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16909 sx: bodyCellSx, 16910 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16911 sx: keyCellSx, 16912 children: [ 16913 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 16914 sx: keyLineSx, 16915 children: [ 16916 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16917 variant: "subtitle2", 16918 sx: keyTextSx, 16919 children: row.key 16920 }), 16921 row.isKillSwitch && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 16922 size: "small", 16923 color: "error", 16924 variant: "standard", 16925 label: "Kill switch" 16926 }),
16927 row.isDeprecated && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 16928 size: "small", 16929 color: "warning", 16930 variant: "outlined", 16931 label: "Deprecated" 16932 }), 16933 !row.hasValue && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 16934 size: "small", 16935 color: "default", 16936 variant: "dashed", 16937 label: "Not set" 16938 }) 16939 ] 16940 }), 16941 isReadOnly ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16942 variant: "helper", 16943 color: row.description ? "text.secondary" : "text.disabled", 16944 children: row.description || "No documented meaning yet" 16945 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16946 variant: "standard", 16947 size: "small", 16948 fullWidth: true, 16949 multiline: true, 16950 placeholder: "No documented meaning yet - write one", 16951 value: descriptionOf(row), 16952 onChange: (event) => setDescriptionEdits({ 16953 ...descriptionEdits, 16954 [row.key]: event.target.value 16955 }), 16956 inputProps: { "data-tracking-id": "tracking-id-ads-system-server-config-description-input" } 16957 }), 16958 row.overrides.map((override) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16959 variant: "helper", 16960 color: "text.secondary", 16961 children: `Override for hosts containing "${override.host}": ${override.value}` 16962 }, override.host)) 16963 ] 16964 }) 16965 }), 16966 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16967 sx: bodyCellSx, 16968 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16969 variant: "caption", 16970 color: "text.secondary", 16971 children: row.valueType 16972 }) 16973 }), 16974 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 16975 sx: valueCellSx, 16976 children: row.isSecret ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 16977 variant: "body1", 16978 color: "text.disabled", 16979 children: "Hidden" 16980 }) : row.valueType === "BOOLEAN" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Switch, { 16981 checked: valueOf(row) === "true", 16982 disabled: isReadOnly, 16983 onChange: (event) => editValue(row, String(event.target.checked)), 16984 "data-tracking-id": "tracking-id-ads-system-server-config-value-toggle" 16985 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TextField, { 16986 size: "small", 16987 fullWidth: true, 16988 disabled: isReadOnly, 16989 value: valueOf(row), 16990 error: tooLong.includes(row.key), 16991 placeholder: row.hasValue ? void 0 : "Not set", 16992 onChange: (event) => editValue(row, event.target.value), 16993 inputProps: { "data-tracking-id": "tracking-id-ads-system-server-config-value-input" } 16994 }) 16995 }) 16996 ] 16997 }, row.key)) 16998 ] })] 16999 }) 17000 }), 17001 !isReadOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveBar, { 17002 changeCount, 17003 saveDisabledReason: tooLong.length > 0 ? `A value is longer than 255 characters: ${tooLong[0]}` : void 0, 17004 saving: saving || definitions.saving, 17005 onCancel: () => setIsDiscardOpen(true), 17006 onSave: () => setIsCommentOpen(true), 17007 trackingIdPrefix: "tracking-id-ads-system-server-config" 17008 }), 17009 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KillSwitchConfirmDialog, { 17010 open: Boolean(pendingKillSwitch), 17011 settingKey: pendingKillSwitch?.row.key ?? "", 17012 help: pendingKillSwitch?.row.description ?? void 0, 17013 nextValue: pendingKillSwitch?.value ?? "", 17014 onCancel: () => setPendingKillSwitch(null), 17015 onConfirm: () => { 17016 if (pendingKillSwitch) setValueEdits({ 17017 ...valueEdits, 17018 [pendingKillSwitch.row.key]: pendingKillSwitch.value 17019 }); 17020 setPendingKillSwitch(null); 17021 } 17022 }),
17023 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NewServerSettingDialog, { 17024 open: isNewOpen, 17025 serverType, 17026 existingKeys: rows.map((row) => row.key), 17027 saving: definitions.saving, 17028 onClose: () => setIsNewOpen(false), 17029 onCreate: (input, comment) => void createSetting(input, comment) 17030 }), 17031 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 17032 open: isCommentOpen, 17033 summary: changeCount === 1 ? "1 change" : `${changeCount} changes`, 17034 warning: "These values apply to every account, on every server of this type.", 17035 saving: saving || definitions.saving, 17036 onCancel: () => setIsCommentOpen(false), 17037 onConfirm: (comment) => void persist(comment), 17038 trackingIdPrefix: "tracking-id-ads-system-server-config" 17039 }), 17040 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiscardChangesDialog, { 17041 open: isDiscardOpen || guard.pendingNavigation, 17042 onContinueEditing: () => { 17043 setIsDiscardOpen(false); 17044 guard.cancelNavigation(); 17045 }, 17046 onDiscard: discard 17047 }) 17048 ] 17049 }); 17050}; 17051var sectionSx$2 = {
17052 display: "flex", 17053 flexDirection: "column", 17054 gap: 2, 17055 alignItems: "stretch", 17056 flex: 1, 17057 minHeight: 0 17058}; 17059var headerSx$1 = { 17060 display: "flex", 17061 alignItems: "center", 17062 justifyContent: "space-between", 17063 gap: 2, 17064 flexWrap: "wrap" 17065}; 17066var headerActionsSx = { 17067 display: "flex", 17068 alignItems: "center", 17069 gap: 1, 17070 flexShrink: 0 17071}; 17072var searchWrapperSx = { width: 280 }; 17073var tableSx = { 17074 flex: 1, 17075 minHeight: 0 17076}; 17077var keyCellSx = { 17078 display: "flex", 17079 flexDirection: "column", 17080 gap: .5, 17081 maxWidth: 420 17082}; 17083var keyLineSx = { 17084 display: "flex", 17085 alignItems: "center", 17086 gap: 1, 17087 flexWrap: "wrap" 17088}; 17089var keyTextSx = { 17090 fontFamily: "monospace", 17091 wordBreak: "break-all" 17092}; 17093var valueHeadCellSx = { 17094 padding: "10px 16px", 17095 whiteSpace: "nowrap", 17096 width: 220 17097}; 17098var valueCellSx = { 17099 padding: "10px 16px", 17100 width: 220 17101}; 17102var emptyCellSx$1 = { 17103 padding: "32px 16px", 17104 textAlign: "center" 17105}; 17106//#endregion 17107//#region client/components/newNav/components/ads/system/AdsSystemPage.tsx 17108/** Sections that became their own nav rows in the drawer. Their old links keep working. */ 17109var movedSections = { 17110 exchanges: import_dist$1.newNavAdsExchangesPath, 17111 "video-responses": import_dist$1.newNavAdsVideoResponsesPath, 17112 "prediction-models": import_dist$1.newNavAdsPredictionModelsPath, 17113 "dmp-providers": import_dist$1.newNavAdsDmpProvidersPath 17114}; 17115/** 17116* The platform's runtime configuration, behind the user-scope adsSystemManagement permission - an account admin 17117* must not be able to reconfigure the ad servers for every account. 17118*/ 17119var AdsSystemPage = () => { 17120 const accountId = useActiveAccountId(); 17121 const { sectionId } = useParams(); 17122 const moved = movedSections[sectionId]; 17123 if (moved) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: moved({ accountId }) }); 17124 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 17125 area: "Server configuration", 17126 title: "Server configuration", 17127 subtitle: "Platform-wide ads configuration. Changes here affect every account.", 17128 sections: [{ 17129 id: "server-configuration", 17130 label: "Server configuration", 17131 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ServerConfigurationSection, {}) 17132 }], 17133 historyChangeTypeContains: "Server Configuration", 17134 trackingIdPrefix: "tracking-id-ads-system" 17135 }); 17136}; 17137//#endregion 17138//#region client/components/newNav/components/ads/system/VideoResponsesSection.tsx 17139var videoResponseGroups = [ 17140 { 17141 id: "identity", 17142 title: "Identity", 17143 fields: [ 17144 { 17145 key: "title", 17146 label: "Title", 17147 type: "text", 17148 validate: required("Enter a title") 17149 }, 17150 { 17151 key: "description", 17152 label: "Description", 17153 type: "text" 17154 }, 17155 { 17156 key: "sku", 17157 label: "SKU", 17158 type: "text" 17159 }, 17160 { 17161 key: "productURL", 17162 label: "Product URL", 17163 type: "url", 17164 wide: true 17165 } 17166 ] 17167 }, 17168 { 17169 id: "format", 17170 title: "Format", 17171 fields: [ 17172 { 17173 key: "width", 17174 label: "Width", 17175 type: "number", 17176 min: 0 17177 }, 17178 { 17179 key: "height", 17180 label: "Height", 17181 type: "number", 17182 min: 0 17183 }, 17184 { 17185 key: "duration", 17186 label: "Duration (seconds)", 17187 type: "number", 17188 min: 0 17189 }, 17190 { 17191 key: "bitrateKbs", 17192 label: "Bitrate (kbps)", 17193 type: "number", 17194 min: 0 17195 } 17196 ] 17197 }, 17198 { 17199 id: "aux", 17200 title: "Auxiliary values", 17201 advanced: true, 17202 description: "Carried into the video URL as ${a1} to ${a4}.", 17203 fields: [ 17204 { 17205 key: "aux1", 17206 label: "Aux 1", 17207 type: "text" 17208 }, 17209 { 17210 key: "aux2", 17211 label: "Aux 2", 17212 type: "text" 17213 }, 17214 { 17215 key: "aux3", 17216 label: "Aux 3", 17217 type: "text" 17218 }, 17219 { 17220 key: "aux4", 17221 label: "Aux 4", 17222 type: "text" 17223 } 17224 ] 17225 } 17226]; 17227var videoFileFields = [ 17228 { 17229 key: "format", 17230 label: "Format", 17231 type: "select", 17232 options: videoFormatOptions, 17233 validate: required("Pick the file format") 17234 }, 17235 { 17236 key: "url", 17237 label: "URL", 17238 type: "url", 17239 validate: required("Enter the file URL") 17240 }, 17241 { 17242 key: "videoid", 17243 label: "Video id", 17244 type: "text" 17245 } 17246]; 17247var impressionPixelFields = [{ 17248 key: "impressionPixel", 17249 label: "Impression pixel", 17250 type: "url", 17251 validate: required("Enter the pixel URL") 17252}]; 17253var companionBannerFields = [ 17254 { 17255 key: "creativeType", 17256 label: "Creative type", 17257 type: "text", 17258 help: "The type of the image (jpg, gif, etc.)" 17259 }, 17260 { 17261 key: "companionUri", 17262 label: "Companion URI", 17263 type: "url", 17264 help: "The URI of the companion banner" 17265 }, 17266 { 17267 key: "width", 17268 label: "Width", 17269 type: "number", 17270 min: 0 17271 }, 17272 { 17273 key: "height", 17274 label: "Height", 17275 type: "number", 17276 min: 0 17277 } 17278]; 17279var VideoResponseDetail = ({ response, save, saving }) => { 17280 const videos = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_FILE, response.id); 17281 const pixels = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_PIXEL, response.id); 17282 const banners = useAdsSpaceChildRows(GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_BANNER, response.id); 17283 const form = useAdsSettingsForm(videoResponseGroups, response, "The video response", async (values, comment) => { 17284 await save([{ 17285 ...values, 17286 id: response.id 17287 }], comment); 17288 await Promise.all([ 17289 videos.persist(comment), 17290 pixels.persist(comment), 17291 banners.persist(comment) 17292 ]); 17293 }, saving || videos.saving || pixels.saving || banners.saving, [ 17294 videos, 17295 pixels, 17296 banners 17297 ]); 17298 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsForm, { 17299 groups: videoResponseGroups, 17300 form, 17301 trackingIdPrefix: "tracking-id-ads-system-video-response", 17302 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17303 sx: childTablesSx, 17304 children: [
17305 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 17306 title: "Video files", 17307 description: "One row per format the player can be handed.", 17308 fields: videoFileFields, 17309 rows: videos.rows, 17310 addLabel: "Add file", 17311 emptyText: "No files on this response yet", 17312 rowTitle: (row) => asText(row.url), 17313 onChange: videos.setRows, 17314 trackingIdPrefix: "tracking-id-ads-system-video-file" 17315 }), 17316 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 17317 title: "Impression pixels", 17318 fields: impressionPixelFields, 17319 rows: pixels.rows, 17320 addLabel: "Add pixel", 17321 emptyText: "No impression pixels on this response", 17322 rowTitle: (row) => asText(row.impressionPixel), 17323 onChange: pixels.setRows, 17324 trackingIdPrefix: "tracking-id-ads-system-video-pixel" 17325 }), 17326 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsChildTable, { 17327 title: "Companion banners", 17328 fields: companionBannerFields, 17329 rows: banners.rows, 17330 addLabel: "Add banner", 17331 emptyText: "No companion banners on this response", 17332 rowTitle: (row) => asText(row.companionUri), 17333 onChange: banners.setRows, 17334 trackingIdPrefix: "tracking-id-ads-system-video-banner" 17335 }) 17336 ] 17337 }) 17338 }); 17339}; 17340/** 17341* The concrete assets a campaign's control group picker chooses from, so finding one is the whole job - and 17342* most rows carry no title, description or SKU at all. What identifies them in practice is the id a campaign 17343* points at and the product URL, so those lead and the title is shown when there is one. 17344*/ 17345var VideoResponsesSection = () => { 17346 const { rows, save, saving, loading } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE); 17347 const describe = (row) => asText(row.title) || asText(row.productURL) || `Response ${row.id}`; 17348 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDrillDownSection, { 17349 title: "Video responses", 17350 titleInPageHeader: true, 17351 searchPlaceholder: "Search by id, product URL or title", 17352 rows, 17353 loading, 17354 searchKeys: [ 17355 "id", 17356 "title", 17357 "sku", 17358 "description", 17359 "productURL" 17360 ], 17361 columns: [ 17362 { 17363 key: "id", 17364 label: "Id", 17365 align: "right" 17366 }, 17367 { 17368 key: "title", 17369 label: "Video response", 17370 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17371 variant: "subtitle2", 17372 children: describe(row) 17373 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17374 variant: "caption", 17375 color: "text.secondary", 17376 children: asText(row.description) || `${asText(row.width)}x${asText(row.height)}` 17377 })] }) 17378 }, 17379 { 17380 key: "sku", 17381 label: "SKU" 17382 }, 17383 { 17384 key: "duration", 17385 label: "Duration", 17386 align: "right", 17387 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17388 variant: "body1", 17389 children: `${asText(row.duration)}s` 17390 }) 17391 } 17392 ], 17393 getTitle: describe,
17394 renderDetail: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VideoResponseDetail, { 17395 response: row, 17396 save, 17397 saving 17398 }, row.id), 17399 emptyText: "No video responses are configured", 17400 trackingIdPrefix: "tracking-id-ads-system-video-responses" 17401 }); 17402}; 17403var childTablesSx = { 17404 display: "flex", 17405 flexDirection: "column", 17406 gap: 4 17407}; 17408//#endregion 17409//#region client/components/newNav/components/ads/system/AdsVideoResponsesPage.tsx 17410var AdsVideoResponsesPage = () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 17411 area: "Video responses", 17412 title: "Video responses", 17413 subtitle: "Ready-made videos the ad server can serve directly, including the control group videos campaigns pick by name.", 17414 sections: [{ 17415 id: "video-responses", 17416 label: "Video responses", 17417 entityKinds: [ 17418 GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE, 17419 GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_FILE, 17420 GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_PIXEL, 17421 GqlClientAdsSpaceEntityKind.VIDEO_RESPONSE_BANNER 17422 ], 17423 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VideoResponsesSection, {}) 17424 }], 17425 trackingIdPrefix: "tracking-id-ads-video-responses" 17426}); 17427//#endregion 17428//#region client/components/newNav/components/ads/targeting/AudienceTreeEditor.tsx 17429var optionLabel = (options, value) => options.find((option) => option.value === value)?.label ?? value; 17430var describeRecency = (row) => { 17431 const min = asText(row.minAgeDays); 17432 const max = asText(row.maxAgeDays); 17433 if (!min && !max) return "Any time"; 17434 return `${min || "0"} to ${max || "any"} days ago`; 17435}; 17436var levels = [ 17437 { 17438 kind: GqlClientAdsSpaceEntityKind.AUDIENCE_RULE_SUPER_GROUP, 17439 title: "Rule sets", 17440 description: "Each set covers one source of evidence. All the sets of an audience must match.", 17441 addLabel: "Add rule set", 17442 emptyText: "No rule sets - this audience matches nobody", 17443 fields: [{ 17444 key: "ruleType", 17445 label: "Evidence", 17446 type: "text", 17447 defaultValue: "SITE_VISITOR" 17448 }, { 17449 key: "matchType", 17450 label: "Rule groups must", 17451 type: "select", 17452 options: ruleMatchTypeOptions, 17453 defaultValue: "MATCH_ANY_RULE" 17454 }], 17455 columns: [{ 17456 key: "ruleType", 17457 label: "Evidence" 17458 }, { 17459 key: "matchType", 17460 label: "Rule groups must", 17461 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17462 variant: "body1", 17463 children: optionLabel(ruleMatchTypeOptions, asText(row.matchType)) 17464 }) 17465 }], 17466 rowTitle: (row) => asText(row.ruleType), 17467 drillLabel: "Rule groups" 17468 }, 17469 { 17470 kind: GqlClientAdsSpaceEntityKind.AUDIENCE_RULE_GROUP, 17471 title: "Rule groups", 17472 description: "A group narrows its rules to a window of time - how long ago the evidence was seen.", 17473 addLabel: "Add rule group", 17474 emptyText: "No rule groups in this set", 17475 fields: [ 17476 { 17477 key: "matchType", 17478 label: "Rules must", 17479 type: "select", 17480 options: ruleMatchTypeOptions, 17481 defaultValue: "MATCH_ALL_RULES" 17482 }, 17483 { 17484 key: "minAgeDays", 17485 label: "Seen no sooner than (days ago)", 17486 type: "number", 17487 min: 0, 17488 validate: inRange(0, 3650, "Use a whole number of days") 17489 }, 17490 { 17491 key: "maxAgeDays", 17492 label: "Seen no longer ago than (days)", 17493 type: "number", 17494 min: 0, 17495 validate: inRange(0, 3650, "Use a whole number of days") 17496 } 17497 ], 17498 columns: [{ 17499 key: "matchType", 17500 label: "Rules must", 17501 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17502 variant: "body1", 17503 children: optionLabel(ruleMatchTypeOptions, asText(row.matchType)) 17504 }) 17505 }, { 17506 key: "recency", 17507 label: "Seen", 17508 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17509 variant: "body1", 17510 children: describeRecency(row) 17511 }) 17512 }], 17513 rowTitle: (row) => describeRecency(row), 17514 drillLabel: "Rules" 17515 }, 17516 { 17517 kind: GqlClientAdsSpaceEntityKind.AUDIENCE_RULE, 17518 title: "Rules", 17519 description: "One condition on the evidence - a segment id, a page name, a product.", 17520 addLabel: "Add rule", 17521 emptyText: "No rules in this group", 17522 fields: [ 17523 { 17524 key: "name", 17525 label: "Field", 17526 type: "text", 17527 validate: required("A rule needs a field") 17528 }, 17529 { 17530 key: "operator", 17531 label: "Operator", 17532 type: "select",
17533 options: audienceRuleOperatorOptions 17534 }, 17535 { 17536 key: "value", 17537 label: "Value", 17538 type: "text", 17539 wide: true, 17540 help: "The ad server splits this on commas, so several values are a comma-separated list." 17541 } 17542 ], 17543 columns: [ 17544 { 17545 key: "name", 17546 label: "Field" 17547 }, 17548 { 17549 key: "operator", 17550 label: "Operator", 17551 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17552 variant: "body1", 17553 children: optionLabel(audienceRuleOperatorOptions, asText(row.operator)) 17554 }) 17555 }, 17556 { 17557 key: "value", 17558 label: "Value" 17559 } 17560 ], 17561 rowTitle: (row) => asText(row.name) 17562 } 17563]; 17564/** 17565* The audience rule tree exactly as the schema holds it, a level at a time. This is the escape hatch: the 17566* everyday editor covers the three shapes audiences actually take, and anything outside them - a rule set with 17567* several rule groups - is edited here rather than being flattened into the nearest shape that editor can hold. 17568*/ 17569var AudienceTreeEditor = ({ audienceId }) => { 17570 const [path, setPath] = (0, import_react.useState)([]); 17571 const level = levels[path.length]; 17572 const parentId = path.length === 0 ? audienceId : path[path.length - 1].id; 17573 const drillColumn = { 17574 key: "drill", 17575 label: "", 17576 align: "right", 17577 render: (row) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 17578 variant: "text", 17579 size: "small", 17580 endIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }), 17581 onClick: () => setPath([...path, { 17582 id: row.id, 17583 label: level.rowTitle(row) 17584 }]), 17585 "data-tracking-id": "tracking-id-ads-targeting-audience-drill-btn", 17586 children: level.drillLabel 17587 }) 17588 }; 17589 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17590 sx: sectionSx$1, 17591 children: [path.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Breadcrumbs, { 17592 separator: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }), 17593 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 17594 component: "button", 17595 variant: "body1", 17596 underline: "hover", 17597 onClick: () => setPath([]), 17598 "data-tracking-id": "tracking-id-ads-targeting-audience-breadcrumb-btn", 17599 children: "Rule sets" 17600 }), path.map((crumb, index) => index === path.length - 1 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17601 variant: "body1", 17602 color: "text.primary", 17603 children: crumb.label 17604 }, crumb.id) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 17605 component: "button", 17606 variant: "body1", 17607 underline: "hover", 17608 onClick: () => setPath(path.slice(0, index + 1)), 17609 "data-tracking-id": "tracking-id-ads-targeting-audience-breadcrumb-btn", 17610 children: crumb.label 17611 }, crumb.id))] 17612 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsEntityRowsSection, { 17613 kind: level.kind, 17614 parentId, 17615 title: level.title, 17616 description: level.description, 17617 fields: level.fields, 17618 columns: level.drillLabel ? [...level.columns, drillColumn] : level.columns, 17619 addLabel: level.addLabel, 17620 emptyText: level.emptyText, 17621 rowTitle: level.rowTitle, 17622 trackingIdPrefix: "tracking-id-ads-targeting-audience-tree" 17623 }, `${level.kind}-${parentId}`)] 17624 }); 17625}; 17626var sectionSx$1 = { 17627 display: "flex", 17628 flexDirection: "column", 17629 gap: 2, 17630 flex: 1, 17631 minHeight: 0 17632}; 17633//#endregion 17634//#region client/components/newNav/components/ads/targeting/AudiencesSection.tsx 17635/** 17636* The account's audiences. An audience is four tables deep in the ads schema and the configuration app makes 17637* people walk all four to define one; here the everyday shapes - a segment list, site activity, an exclusion of 17638* whoever another campaign already reached - are entered directly and the tree is assembled on save. 17639*/ 17640var AudiencesSection = () => { 17641 const { audiences, loading, error, saving, save, refetch } = useAdsSpaceAudiences(); 17642 const { save: saveEntities, saving: deleting } = useAdsSpaceEntities(GqlClientAdsSpaceEntityKind.AUDIENCE); 17643 const [editing, setEditing] = (0, import_react.useState)(null); 17644 const [advancedId, setAdvancedId] = (0, import_react.useState)(null); 17645 const [isCommentOpen, setIsCommentOpen] = (0, import_react.useState)(false); 17646 const [isDiscardOpen, setIsDiscardOpen] = (0, import_react.useState)(false); 17647 const [menuAnchorEl, setMenuAnchorEl] = (0, import_react.useState)(null); 17648 const [menuAudience, setMenuAudience] = (0, import_react.useState)(null); 17649 const isDirty = Boolean(editing) && JSON.stringify(editing.draft) !== JSON.stringify(toDraft(editing.audience)); 17650 const guard = useAdsUnsavedChangesGuard(isDirty && !saving); 17651 const closeMenu = () => { 17652 setMenuAnchorEl(null); 17653 setMenuAudience(null); 17654 }; 17655 const remove = async (audience) => { 17656 closeMenu(); 17657 try { 17658 await saveEntities([], `Removed audience ${audience.name}`, [audience.id]); 17659 await refetch(); 17660 } catch {} 17661 }; 17662 const persist = async (comment) => { 17663 try { 17664 await save({ 17665 id: editing.audience?.id, 17666 name: editing.draft.name.trim(), 17667 blocks: toBlocks(editing.draft), 17668 changeComment: comment 17669 }); 17670 setEditing(null); 17671 } catch {} finally { 17672 setIsCommentOpen(false); 17673 } 17674 }; 17675 const rowActions = (audience) => [{ 17676 label: canEditSimply(audience) ? "Edit" : "Edit rule tree", 17677 icon: import_faPen.faPen, 17678 onClick: () => { 17679 closeMenu(); 17680 if (canEditSimply(audience)) setEditing({ 17681 audience, 17682 draft: toDraft(audience) 17683 }); 17684 else setAdvancedId(audience.id); 17685 } 17686 }, { 17687 label: "Remove", 17688 icon: import_faTrashCan.faTrashCan, 17689 error: true, 17690 dividerBefore: true, 17691 onClick: () => void remove(audience) 17692 }]; 17693 if (advancedId) { 17694 const audience = audiences.find((candidate) => candidate.id === advancedId); 17695 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17696 sx: sectionSx, 17697 children: [
17698 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Breadcrumbs, { 17699 separator: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }), 17700 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 17701 component: "button", 17702 variant: "body1", 17703 underline: "hover", 17704 onClick: () => setAdvancedId(null), 17705 "data-tracking-id": "tracking-id-ads-targeting-audiences-breadcrumb-btn", 17706 children: "Audiences" 17707 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17708 variant: "body1", 17709 color: "text.primary", 17710 children: audience?.name ?? "Audience" 17711 })] 17712 }), 17713 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 17714 color: "warning", 17715 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "This audience uses a shape the simple editor cannot hold" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "One of its rule sets has more than one rule group, which is an either/or the simple editor would flatten. It is edited here as the four tables it is stored in." })] 17716 }),
17717 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AudienceTreeEditor, { audienceId: advancedId }) 17718 ] 17719 }); 17720 } 17721 if (editing) { 17722 const problem = describeDraftProblem(editing.draft); 17723 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17724 sx: sectionSx, 17725 children: [ 17726 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Breadcrumbs, { 17727 separator: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleRight.faAngleRight }), 17728 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Link, { 17729 component: "button", 17730 variant: "body1", 17731 underline: "hover", 17732 onClick: () => isDirty ? setIsDiscardOpen(true) : setEditing(null), 17733 "data-tracking-id": "tracking-id-ads-targeting-audiences-breadcrumb-btn", 17734 children: "Audiences" 17735 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17736 variant: "body1", 17737 color: "text.primary", 17738 children: editing.audience?.name || "New audience" 17739 })] 17740 }),
17741 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 17742 sx: bodySx, 17743 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AudienceEditor, { 17744 draft: editing.draft, 17745 onChange: (draft) => setEditing({ 17746 ...editing, 17747 draft 17748 }) 17749 }) 17750 }), 17751 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveBar, { 17752 changeCount: isDirty ? 1 : 0, 17753 saveDisabledReason: problem ?? void 0, 17754 saving, 17755 onCancel: () => isDirty ? setIsDiscardOpen(true) : setEditing(null), 17756 onSave: () => setIsCommentOpen(true), 17757 trackingIdPrefix: "tracking-id-ads-targeting-audiences" 17758 }), 17759 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSaveCommentDialog, { 17760 open: isCommentOpen, 17761 summary: editing.audience ? `Changes to ${editing.audience.name}` : "A new audience", 17762 warning: editing.audience ? "Every campaign pointing at this audience serves on the new rules." : void 0, 17763 saving, 17764 onCancel: () => setIsCommentOpen(false), 17765 onConfirm: (comment) => void persist(comment), 17766 trackingIdPrefix: "tracking-id-ads-targeting-audiences" 17767 }),
17768 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiscardChangesDialog, { 17769 open: isDiscardOpen || guard.pendingNavigation, 17770 onContinueEditing: () => { 17771 setIsDiscardOpen(false); 17772 guard.cancelNavigation(); 17773 }, 17774 onDiscard: () => { 17775 setEditing(null); 17776 setIsDiscardOpen(false); 17777 guard.confirmNavigation(); 17778 } 17779 }) 17780 ] 17781 }); 17782 } 17783 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17784 sx: sectionSx, 17785 children: [ 17786 error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_dist$2.AttentionBox, { 17787 color: "error", 17788 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxTitle, { children: "The ads configuration is unreachable" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: "This list could not be read, so it is not the whole picture." })] 17789 }), 17790 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBox, { 17791 color: "info", 17792 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.AttentionBoxContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17793 variant: "body1", 17794 color: "text.secondary", 17795 children: "An audience belongs to the account, not to one campaign. Changing it changes every campaign pointing at it, and its name is what third-party pixels receive." 17796 }) }) 17797 }), 17798 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17799 sx: headerSx, 17800 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17801 variant: "h5", 17802 children: "Audiences" 17803 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17804 variant: "body2", 17805 color: "text.secondary", 17806 children: "Who campaigns may bid on. Define one here and point any number of campaigns at it." 17807 })] }
17807), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 17808 variant: "outlined", 17809 size: "medium", 17810 startIcon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faPlus.faPlus }), 17811 onClick: () => setEditing({ 17812 audience: null, 17813 draft: toDraft(null) 17814 }), 17815 "data-tracking-id": "tracking-id-ads-targeting-audiences-add-btn", 17816 children: "New audience" 17817 })] 17818 }), 17819 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 17820 sx: bodySx, 17821 children: loading && audiences.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Skeleton, { 17822 variant: "rounded", 17823 height: 160 17824 }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableContainer, { 17825 component: import_material.Paper, 17826 variant: "outlined", 17827 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Table, { 17828 size: "small", 17829 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableHead, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { children: [ 17830 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17831 sx: headCellSx, 17832 children: "Audience" 17833 }), 17834 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17835 sx: headCellSx, 17836 children: "Matches" 17837 }), 17838 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { sx: actionsCellSx }) 17839 ] }) }
17839), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableBody, { children: [audiences.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableRow, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17840 colSpan: 3, 17841 sx: emptyCellSx, 17842 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17843 variant: "body1", 17844 color: "text.secondary", 17845 children: "No audiences defined yet" 17846 }) 17847 }) }), audiences.map((audience) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.TableRow, { 17848 sx: rowHoverSx, 17849 "data-testid": `ads-audience-row-${audience.id}`, 17850 children: [ 17851 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17852 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, cellSx), 17853 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17854 variant: "body1", 17855 children: audience.name 17856 }) 17857 }), 17858 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17859 sx: (0, import_dist$2.combineSxProps)(bodyCellSx, cellSx), 17860 children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17861 sx: matchesCellSx, 17862 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Typography, { 17863 variant: "body1", 17864 children: describeAudience(audience) 17865 }
17865), !canEditSimply(audience) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.Label, { 17866 size: "small", 17867 variant: "dashed", 17868 color: "default", 17869 label: "Rule tree" 17870 })] 17871 }) 17872 }), 17873 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.TableCell, { 17874 sx: actionsCellSx, 17875 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dist$2.TruffleIconButton, { 17876 size: "small", 17877 disabled: deleting, 17878 onClick: (event) => { 17879 setMenuAnchorEl(event.currentTarget); 17880 setMenuAudience(audience); 17881 }, 17882 "data-tracking-id": "tracking-id-ads-targeting-audiences-row-options-btn", 17883 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faEllipsisV.faEllipsisV }) 17884 }) 17885 }) 17886 ] 17887 }, audience.id))] })] 17888 }) 17889 }) 17890 }), 17891 menuAudience && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowOptionsMenu, { 17892 anchorEl: menuAnchorEl, 17893 open: Boolean(menuAnchorEl), 17894 onClose: closeMenu, 17895 title: menuAudience.name, 17896 actions: rowActions(menuAudience), 17897 anchorOrigin: { 17898 vertical: "bottom", 17899 horizontal: "right" 17900 }, 17901 transformOrigin: { 17902 vertical: "top", 17903 horizontal: "right" 17904 } 17905 }) 17906 ] 17907 }); 17908}; 17909var sectionSx = { 17910 display: "flex", 17911 flexDirection: "column", 17912 gap: 3, 17913 flex: 1, 17914 minHeight: 0 17915}; 17916var headerSx = { 17917 display: "flex", 17918 alignItems: "center", 17919 justifyContent: "space-between", 17920 gap: 2 17921}; 17922var bodySx = { 17923 flex: 1, 17924 minHeight: 0, 17925 overflow: "auto" 17926}; 17927var cellSx = { 17928 maxWidth: 420, 17929 wordBreak: "break-word" 17930}; 17931var matchesCellSx = { 17932 display: "flex", 17933 alignItems: "center", 17934 gap: 1 17935}; 17936var emptyCellSx = { 17937 padding: "24px 16px", 17938 textAlign: "center" 17939}; 17940//#endregion 17941//#region client/components/newNav/components/ads/targeting/AdsTargetingPage.tsx 17942var sections = [{ 17943 id: "audiences", 17944 label: "Audiences", 17945 icon: import_faUsersViewfinder.faUsersViewfinder, 17946 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AudiencesSection, {}) 17947}, { 17948 id: "geo-lists", 17949 label: "Geographies", 17950 icon: import_faEarthAmericas.faEarthAmericas, 17951 render: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GeoListsSection, {}) 17952}]; 17953/** 17954* The account's reusable targeting objects. Both were per-campaign columns in the old app, which is why the same 17955* audience and the same state exclusion list exist in a dozen slightly different copies. 17956*/ 17957var AdsTargetingPage = () => { 17958 const accountId = useActiveAccountId(); 17959 return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSectionPage, { 17960 area: "Programmatic / Targeting", 17961 title: "Targeting", 17962 subtitle: "Audiences and geographies defined once for the account. Campaigns and budget lanes point at them.", 17963 sections, 17964 sectionPath: (sectionId) => (0, import_dist$1.newNavAdsTargetingSectionPath)({ 17965 accountId, 17966 sectionId 17967 }), 17968 trackingIdPrefix: "tracking-id-ads-targeting" 17969 }); 17970}; 17971//#endregion 17972//#region client/components/newNav/components/ads/AdsSpaceShell.tsx 17973var AdsSpaceShell = ({ logout }) => { 17974 const accountId = useActiveAccountId(); 17975 const { userRoles } = useUserData(); 17976 const location = useLocation(); 17977 const navZones = (0, import_react.useMemo)(() => getVisibleAdsNavZones(userRoles), [userRoles]); 17978 const selectedNav = (0, import_react.useMemo)(() => getSelectedAdsNav(location.pathname, accountId), [location.pathname, accountId]); 17979 const selectedNavId = selectedNav?.itemId ?? null; 17980 const [opened, setOpened] = (0, import_react.useState)(null); 17981 const expandedZoneId = [ 17982 opened?.pathname === location.pathname ? opened.zoneId : null, 17983 selectedNav?.zoneId, 17984 "programmatic" 17985 ].find((candidate) => navZones.some((zone) => zone.id === candidate && zone.collapsible)) ?? navZones.find((zone) => zone.collapsible)?.id; 17986 const canEditSettings = isPermitted(userRoles, permissions.adsEditCampaignSettings); 17987 const canManageSystem = isPermitted(userRoles, permissions.adsSystemManagement); 17988 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17989 sx: spaceSx, 17990 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Drawer, { 17991 sx: drawerSx, 17992 variant: "permanent", 17993 anchor: "left", 17994 children: [
17995 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 17996 sx: lockupSx, 17997 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Link$6, { 17998 to: (0, import_dist$1.newNavAdsDashboardPath)({ accountId }), 17999 "data-tracking-id": "tracking-id-ads-space-left-panel-ssky-logo-btn", 18000 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { 18001 src: sundaysky_default, 18002 alt: "sundaysky-logo", 18003 width: "100%", 18004 style: { padding: "0 32px" } 18005 }) 18006 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsAccountLabel, {})] 18007 }), 18008 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, { sx: lockupDividerSx }), 18009 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.List, { 18010 sx: navListSx, 18011 component: "nav", 18012 children: navZones.map((zone, zoneIndex) => { 18013 const isExpanded = !zone.collapsible || expandedZoneId === zone.id; 18014 const isFirstBelowDivider = Boolean(zone.belowDivider) && !navZones[zoneIndex - 1]?.belowDivider; 18015 const rows = zone.items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemButton, { 18016 dense: true, 18017 component: Link$6, 18018 to: item.to({ accountId }), 18019 selected: selectedNavId === item.id, 18020 sx: zone.collapsible ? nestedItemSx : void 0, 18021 "data-tracking-id": `tracking-id-ads-space-left-panel-${item.id}-tab`, 18022 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { 18023 primary: item.label, 18024 slotProps: { primary: selectedNavId === item.id ? { 18025 variant: "subtitle2", 18026 color: "primary.main", 18027 noWrap: true 18028 } : { noWrap: true } } 18029 }) 18030 }, item.id)); 18031 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react.Fragment, { children: [ 18032 isFirstBelowDivider && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, { sx: navDividerSx }), 18033 zone.collapsible && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.ListItemButton, { 18034 dense: true, 18035 onClick: () => setOpened({ 18036 zoneId: zone.id, 18037 pathname: location.pathname 18038 }), 18039 "data-tracking-id": `tracking-id-ads-space-left-panel-${zone.id}-section-btn`, 18040 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.ListItemText, { 18041 primary: zone.label, 18042 slotProps: { primary: { 18043 variant: "subtitle2", 18044 color: "text.secondary", 18045 noWrap: true 18046 } } 18047 }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.SvgIcon, { 18048 fontSize: "small", 18049 color: "action", 18050 sx: chevronSx(isExpanded), 18051 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FontAwesomeIcon, { icon: import_faAngleDown.faAngleDown }) 18052 })] 18053 }),
18054 zone.collapsible ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Collapse, { 18055 in: isExpanded, 18056 unmountOnExit: true, 18057 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.List, { 18058 component: "div", 18059 disablePadding: true, 18060 children: rows 18061 }) 18062 }) : rows 18063 ] }, zone.id); 18064 }) 18065 }), 18066 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Button, { 18067 variant: "outlined", 18068 size: "small", 18069 fullWidth: true, 18070 component: Link$6, 18071 to: (0, import_dist$1.newNavAdsManagementPath)(), 18072 sx: allAccountsButtonSx, 18073 "data-tracking-id": "tracking-id-ads-space-left-panel-ads-management-btn", 18074 children: "All-accounts management" 18075 }), 18076 /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 18077 sx: userMenuSx, 18078 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Divider, { sx: { width: "100%" } }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(UserMenu, { 18079 trackingId: "tracking-id-avatar-3dots-menu-ads-space", 18080 maxWidth: `203px`, 18081 logout 18082 })] 18083 }) 18084 ] 18085 }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_material.Box, { 18086 sx: contentSx, 18087 children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsNonProductionBanner, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_material.Box, { 18088 sx: scrollingContentSx, 18089 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsAccountLinkGate, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Switch$3, { children: [ 18090 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18091 exact: true, 18092 path: import_dist$1.newNavAdsDashboardPath.pattern, 18093 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsAccountDashboardPage, {}) 18094 }), 18095 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18096 exact: true, 18097 path: import_dist$1.newNavAdsCampaignsPath.pattern, 18098 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignsPage, {}) 18099 }), 18100 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18101 exact: true, 18102 path: [import_dist$1.newNavAdsCampaignSectionPath.pattern, import_dist$1.newNavAdsCampaignPath.pattern], 18103 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignDetailPage, {}) 18104 }), 18105 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18106 exact: true, 18107 path: import_dist$1.newNavAdsDecoupledCampaignsPath.pattern, 18108 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DecoupledCampaignsPage, {}) 18109 }), 18110 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18111 exact: true, 18112 path: [import_dist$1.newNavAdsDecoupledCampaignSectionPath.pattern, import_dist$1.newNavAdsDecoupledCampaignPath.pattern], 18113 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CampaignDetailPage, {}) 18114 }), 18115 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18116 exact: true, 18117 path: [import_dist$1.newNavAdsTargetingSectionPath.pattern, import_dist$1.newNavAdsTargetingPath.pattern], 18118 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsTargetingPage, {}) 18119 }), 18120 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18121 exact: true, 18122 path: import_dist$1.newNavAdsFlightsPath.pattern, 18123 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsFlightsPage, {}) 18124 }), 18125 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18126 exact: true, 18127 path: import_dist$1.newNavAdsExperimentsPath.pattern, 18128 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentsPage, {}) 18129 }), 18130 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18131 exact: true, 18132 path: import_dist$1.newNavAdsExperimentPath.pattern, 18133 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExperimentDetailPage, {}) 18134 }), 18135 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18136 exact: true, 18137 path: import_dist$1.newNavAdsAuditLogPath.pattern, 18138 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsAuditLogPage, {}) 18139 }), 18140 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18141 exact: true, 18142 path: import_dist$1.newNavAdsUtilitiesPath.pattern, 18143 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: (0, import_dist$1.newNavAdsUtilityPath)({ 18144 accountId, 18145 utilityId: "zip-radius" 18146 }) }) 18147 }),
18148 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18149 exact: true, 18150 path: import_dist$1.newNavAdsUtilityPath.pattern, 18151 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsUtilityPage, {}) 18152 }), 18153 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18154 exact: true, 18155 path: import_dist$1.newNavAdsDmpAccountMappingsPath.pattern, 18156 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDmpAccountMappingsPage, {}) 18157 }), 18158 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18159 exact: true, 18160 path: import_dist$1.newNavAdsExchangesPath.pattern, 18161 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsExchangesPage, {}) 18162 }), 18163 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18164 exact: true, 18165 path: import_dist$1.newNavAdsVideoResponsesPath.pattern, 18166 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsVideoResponsesPage, {}) 18167 }), 18168 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18169 exact: true, 18170 path: import_dist$1.newNavAdsPredictionModelsPath.pattern, 18171 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsPredictionModelsPage, {}) 18172 }), 18173 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18174 exact: true, 18175 path: import_dist$1.newNavAdsDmpProvidersPath.pattern, 18176 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDmpProvidersPage, {}) 18177 }), 18178 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18179 exact: true, 18180 path: [import_dist$1.newNavAdsDspSectionPath.pattern, import_dist$1.newNavAdsDspsPath.pattern], 18181 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsDspPage, {}) 18182 }), 18183 canEditSettings && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18184 exact: true, 18185 path: [import_dist$1.newNavAdsSettingsSectionPath.pattern, import_dist$1.newNavAdsSettingsPath.pattern], 18186 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSettingsPage, {}) 18187 }), 18188 canManageSystem && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18189 exact: true, 18190 path: [import_dist$1.newNavAdsSystemSectionPath.pattern, import_dist$1.newNavAdsSystemPath.pattern], 18191 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSystemPage, {}) 18192 }), 18193 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Route, { 18194 path: "*", 18195 children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Redirect, { to: (0, import_dist$1.newNavAdsDashboardPath)({ accountId }) }) 18196 }) 18197 ] }) }) 18198 })] 18199 })] 18200 }); 18201}; 18202var spaceSx = { 18203 display: "grid", 18204 gridTemplateAreas: ` 18205 "drawer content" 18206 `, 18207 gridTemplateRows: "1fr", 18208 gridTemplateColumns: `235px 1fr`, 18209 minHeight: "100%", 18210 backgroundColor: (theme) => theme.palette.other.editorBackground 18211}; 18212var drawerSx = { 18213 width: `235px`, 18214 gridArea: "drawer", 18215 "& .MuiDrawer-paper": { 18216 width: `235px`, 18217 boxSizing: "border-box", 18218 padding: "24px 16px 16px 16px", 18219 display: "flex", 18220 flexDirection: "column" 18221 } 18222}; 18223var lockupSx = { 18224 display: "flex", 18225 flexDirection: "column", 18226 gap: "2px", 18227 width: "100%", 18228 flexShrink: 0 18229}; 18230var lockupDividerSx = { 18231 margin: "16px 0 8px", 18232 flexShrink: 0 18233}; 18234var navListSx = { 18235 padding: 0, 18236 width: "100%", 18237 flex: 1, 18238 minHeight: 0, 18239 overflowY: "auto", 18240 ...import_dist$2.scrollOnHoverSxProps 18241}; 18242var nestedItemSx = { paddingLeft: 3 }; 18243var chevronSx = (isExpanded) => ({ 18244 transform: isExpanded ? "rotate(0deg)" : "rotate(-90deg)", 18245 transition: (theme) => theme.transitions.create("transform", { duration: theme.transitions.duration.shortest }) 18246}); 18247var navDividerSx = { margin: "8px 0" }; 18248var allAccountsButtonSx = { 18249 marginTop: "auto", 18250 flexShrink: 0 18251}; 18252var userMenuSx = { 18253 marginTop: 2, 18254 width: "100%", 18255 display: "flex", 18256 flexDirection: "column", 18257 alignItems: "flex-start", 18258 gap: "10px" 18259}; 18260var contentSx = { 18261 gridArea: "content", 18262 height: "100vh", 18263 minWidth: 0, 18264 display: "flex", 18265 flexDirection: "column", 18266 overflow: "hidden" 18267}; 18268var scrollingContentSx = { 18269 flex: 1, 18270 minHeight: 0, 18271 overflow: "auto", 18272 ...import_dist$2.scrollOnHoverSxProps 18273}; 18274//#endregion
18275//#region client/components/newNav/components/ads/AdsContainer.tsx 18276var AdsContainer = (props) => { 18277 const { name, accountId: activeAccountId } = useActiveAccount(); 18278 const { userName, userRoles } = useUserData(); 18279 const pendo = PendoService.getInstance(); 18280 const pendoAccountInfo = { 18281 id: activeAccountId, 18282 name 18283 }; 18284 (0, import_react.useEffect)(() => { 18285 pendo.initUserInfoIfRequired(userName, userRoles?.join(", "), pendoAccountInfo); 18286 }, []); 18287 pendo.updateAccountInfoIfChanged(pendoAccountInfo); 18288 return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ 18289 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdsSpaceShell, { logout: props.logout }), 18290 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EditHelpCenterPopover, {}), 18291 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HelpCenterPopover, {}), 18292 /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NotificationSnackbar, {}) 18293 ] }); 18294}; 18295//#endregion 18296export { AdsContainer }; 18297 18298//# sourceMappingURL=AdsContainer.Cc8xLuBn.js.map 18299//# debugId=da6fd120-86a6-583d-8992-c5cd2201c1e6
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.