@aglyn/plugins-marketing 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +16 -16
- package/src/lib/analytics-provider.d.ts +177 -0
- package/src/lib/analytics-provider.js +370 -0
- package/src/lib/analytics-provider.js.map +1 -0
- package/src/lib/components/besigner-section-experiments.d.ts +20 -0
- package/src/lib/components/besigner-section-experiments.js +128 -0
- package/src/lib/components/besigner-section-experiments.js.map +1 -0
- package/src/lib/components/campaign-conversions-card.js +1 -1
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +16 -16
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-members-section.js +15 -14
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaign-reach-sections.js +4 -1
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/campaign-report-card.js +8 -6
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +1 -1
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/conversion-attribution.component.d.ts +1 -1
- package/src/lib/components/conversion-attribution.component.js +2 -2
- package/src/lib/components/conversion-attribution.component.js.map +1 -1
- package/src/lib/components/email-detail.js +12 -7
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-messages-widget.js +1 -1
- package/src/lib/components/email-messages-widget.js.map +1 -1
- package/src/lib/components/emails-list-card.js +11 -5
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +12 -7
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/org-experiments-card.js +1 -1
- package/src/lib/components/org-experiments-card.js.map +1 -1
- package/src/lib/components/record-attribution-widget.js +1 -1
- package/src/lib/components/record-attribution-widget.js.map +1 -1
- package/src/lib/components/record-route-context.d.ts +41 -0
- package/src/lib/components/record-route-context.js +63 -0
- package/src/lib/components/record-route-context.js.map +1 -0
- package/src/lib/components/site-runtime.js +2 -2
- package/src/lib/components/site-runtime.js.map +1 -1
- package/src/lib/components/use-marketing-hub-path.d.ts +18 -5
- package/src/lib/components/use-marketing-hub-path.js +36 -6
- package/src/lib/components/use-marketing-hub-path.js.map +1 -1
- package/src/lib/constants/operator-alerts.js +4 -1
- package/src/lib/constants/operator-alerts.js.map +1 -1
- package/src/lib/declarations.server.d.ts +3 -1
- package/src/lib/declarations.server.js +17 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/campaign-conversions.d.ts +301 -0
- package/src/lib/model/campaign-conversions.js +252 -0
- package/src/lib/model/campaign-conversions.js.map +1 -0
- package/src/lib/model/campaign-kind.d.ts +50 -0
- package/src/lib/model/campaign-kind.js +52 -0
- package/src/lib/model/campaign-kind.js.map +1 -0
- package/src/lib/model/campaign-members-query.js +1 -1
- package/src/lib/model/campaign-members-query.js.map +1 -1
- package/src/lib/model/campaign-revenue.d.ts +327 -0
- package/src/lib/model/campaign-revenue.js +332 -0
- package/src/lib/model/campaign-revenue.js.map +1 -0
- package/src/lib/model/campaign-send-time.d.ts +74 -0
- package/src/lib/model/campaign-send-time.js +120 -0
- package/src/lib/model/campaign-send-time.js.map +1 -0
- package/src/lib/model/experiments.js +1 -1
- package/src/lib/model/experiments.js.map +1 -1
- package/src/lib/model/index.d.ts +3 -0
- package/src/lib/model/index.js +4 -1
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/overlay-record-list.d.ts +33 -0
- package/src/lib/model/overlay-record-list.js +53 -0
- package/src/lib/model/overlay-record-list.js.map +1 -0
- package/src/lib/plugin.js +12 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/campaign-batch-plan.d.ts +73 -0
- package/src/lib/server/campaign-batch-plan.js +79 -0
- package/src/lib/server/campaign-batch-plan.js.map +1 -0
- package/src/lib/server/campaign-manage.js +19 -60
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +1 -1
- package/src/lib/server/campaign-send.js +32 -11
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +16 -16
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/list-send-time-facts.d.ts +60 -0
- package/src/lib/server/list-send-time-facts.js +108 -0
- package/src/lib/server/list-send-time-facts.js.map +1 -0
- package/src/lib/server/overlay-beacon.d.ts +46 -0
- package/src/lib/server/overlay-beacon.js +76 -0
- package/src/lib/server/overlay-beacon.js.map +1 -0
- package/src/lib/server.js +4 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.d.ts +29 -0
- package/src/lib/tenant-emails.js +41 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-emails-page-routes.d.ts +27 -0
- package/src/lib/testing/stand-in-emails-page-routes.js +42 -0
- package/src/lib/testing/stand-in-emails-page-routes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -58
- package/src/lib/components/use-emails-hub-path.js +0 -109
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/marketing/src/lib/analytics-provider.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n AnalyticsEventOptions,\n AnalyticsGrants,\n AnalyticsMountOptions,\n AnalyticsProvider,\n AnalyticsTagMount,\n} from '@aglyn/aglyn/app-utils/analytics-provider'\nimport { analyticsEnvironmentForcesInternal } from '@aglyn/aglyn/app-utils/analytics-environment'\nimport {\n INTERNAL_TRAFFIC_FORCED_SNIPPET,\n INTERNAL_TRAFFIC_GTAG_SNIPPET,\n} from '@aglyn/aglyn/app-utils/internal-traffic'\nimport { PLATFORM_GA_MEASUREMENT_ID } from '@aglyn/aglyn/app-utils/platform-marketing-host'\nimport {\n analyticsConsentSignals,\n consentModeSignals,\n GA_MEASUREMENT_ID_PATTERN,\n resolveGaMeasurementId,\n resolveGtmContainerId,\n type VisitorConsentHost,\n} from '@aglyn/aglyn/app-utils/visitor-consent'\n\n/**\n * The Google tag adapter: Google Analytics 4 and Google Tag Manager on a\n * published site, and the resident Google tag of any document that has one\n * (AGL-3080).\n *\n * Core decides whether a visitor may be measured and names no vendor\n * (`analytics-provider.ts`); this is where Google's shape lives — the inline\n * boot and library each tag needs, Consent Mode, the window flag that\n * silences a resident tag, and how an event reaches `gtag`.\n *\n * Loaded as a chunk of its own, and only where it is used: by a published\n * page whose site configures a measurement id or a container, and by the\n * console, whose own analytics tag the consent controls answer to.\n */\n\n/** The `gtag` a loaded Google tag defines on `window`. */\ntype Gtag = (...args: unknown[]) => void\n\n/** The resident `gtag`, or null when no Google tag is on the page. */\nfunction residentGtag(): Gtag | null {\n if (typeof window === 'undefined') return null\n const gtag = (window as unknown as { gtag?: unknown }).gtag\n return typeof gtag === 'function' ? (gtag as Gtag) : null\n}\n\n/**\n * The `window` flag `gtag.js` consults before every hit: with\n * `window['ga-disable-G-XXXX'] === true` the tag for that property sends\n * nothing and writes nothing. Google's own documented opt-out mechanism, and\n * the one that works on a tag which never received a consent-mode default\n * (AGL-1608) — a case that still exists after AGL-1622, because a tag arriving\n * through GTM or a host's own CMP is not one this adapter declared for.\n */\nexport const GA_DISABLE_FLAG_PREFIX = 'ga-disable-'\n\n/**\n * The consent-mode `default` declaration that goes in front of the tag\n * (AGL-1622), for injection into the same inline block that creates\n * `dataLayer` and calls `gtag('config', …)`.\n *\n * ## What this is, and — load-bearing — what it is NOT\n *\n * It is NOT a licence to load the tag earlier. It is emitted INSIDE the block\n * that only renders when the AGL-1498 gate has already said yes, so it is\n * reached only on a pageview where analytics is granted. On every gated\n * pageview — an EU/UK/EEA visitor with no explicit accept, an unknown region, a\n * declined or opted-out visitor, a GPC browser — nothing here renders, no\n * request goes to `googletagmanager.com`, and this constant is never evaluated\n * into the page. \"The script never LOADS, rather than load-then-suppress\" is a\n * stated AGL-1498 property and it is intact everywhere the gate applies;\n * `consent-mode-default.spec.tsx` fails if that ever stops being true.\n *\n * the decision, 2026-08-14: load-then-restrict is approved for the UNITED\n * STATES, where the implied-consent posture already permits the load and the\n * restriction signals act on a tag that is legitimately resident. EU and UK\n * keep the prior-consent gate exactly as it is, because loading an analytics\n * tag before consent is the specific thing prior-consent law prohibits. So the\n * consent-mode mechanism is strictly ADDITIVE to the gate, never a replacement\n * for it.\n *\n * What declaring it actually buys, given the tag is granted by the time it is\n * read:\n *\n * - The advertising signals are denied FROM THE FIRST HIT. Without a default,\n * a freshly loaded tag runs with `ad_storage` unrestricted and only becomes\n * denied if the visitor happens to withdraw and change their mind again\n * (AGL-1608's `update`). The load-time state now matches the tool's actual\n * scope instead of being wider than it.\n * - A later `update` is a transition from a declared state rather than a tag's\n * first-ever consent signal, which is the shape GA4's consent-mode reporting\n * and any third-party CMP alongside ours both expect.\n *\n * The value is a literal, built from `analyticsConsentSignals` — no\n * interpolated input reaches it, which matters because it lands inside an\n * inline script (the AGL-138 concern).\n */\nexport const GA_CONSENT_DEFAULT_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n analyticsConsentSignals(true),\n)});`\n\n/**\n * The same declaration for a visitor who granted ADVERTISING too (AGL-1649).\n *\n * A second CONSTANT rather than a parameterised builder, for the reason the\n * first one is a constant: it lands inside an inline script (the AGL-138\n * concern), and a constant cannot interpolate anything. Both values are\n * literals fixed at module load; the only thing the caller chooses is which\n * of the two to emit.\n *\n * Reached only where the page's `advertising` answer is yes — host opted in,\n * explicit accept, explicit yes to this category — which is evaluated\n * client-side after hydration, like every other part of this gate. The ISR\n * property is unaffected: the cached HTML contains neither snippet, because\n * the whole block is inside the client-side `analyticsAllowed` condition.\n *\n * Declaring the grant as the DEFAULT rather than sending a later `update` is\n * what keeps the tag's first hit correct. An update would arrive after\n * `config`, so the session's first pageview — usually the entire session for\n * a marketing visit — would carry the denied state the visitor did not\n * choose.\n */\nexport const GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n consentModeSignals({ analytics: true, advertising: true }),\n)});`\n\n/**\n * Consent-mode URL passthrough for the platform's own tag (AGL-2548).\n *\n * The advertising signals in {@link GA_CONSENT_DEFAULT_SNIPPET} are denied, so\n * on `aglyn.com` gtag writes no `_gcl_aw` cookie and the `gclid` an ad click\n * arrives with lives only in that first URL. The conversions it should be\n * credited for fire one hop later, on the console, where the posture grants\n * advertising storage outside the prior-consent regions — and without this\n * declaration the click id never makes that hop. The console reports a\n * conversion with no click behind it, and Google Ads counts nothing.\n *\n * `url_passthrough` is Google's mechanism for exactly this state: while\n * `ad_storage` is denied, gtag appends the click identifiers to links into the\n * cross-domain-linked hosts as URL parameters instead of setting a cookie. The\n * receiving tag reads them under its own consent state. Nothing is stored on\n * the denying surface, so the marketing site's opt-in advertising posture is\n * unchanged; the click id simply survives the link it was always meant to\n * survive.\n *\n * `ads_data_redaction` is the companion Google documents alongside it: with\n * `ad_storage` denied, the ad click identifiers are stripped from the hits this\n * tag sends and its network requests go to a cookieless endpoint. Declared\n * together so a denied visitor is measured with less, not merely differently.\n *\n * Both are `set` calls, so they must precede `gtag('config', …)` in the same\n * inline block — a `set` applies to hits processed after it, and the session's\n * first pageview is the one carrying the click.\n *\n * PLATFORM ID ONLY. A customer's tag is configured with their id and their\n * consent posture; how their ad click ids travel is theirs to decide, and a\n * passthrough declared on their behalf would rewrite links on their site.\n */\nexport const GA_CLICK_ID_PASSTHROUGH_SNIPPET =\n \"gtag('set', 'url_passthrough', true);\" +\n \"gtag('set', 'ads_data_redaction', true);\"\n\n/** The marker `advertising-tags.ts` names in `sharesLibrary` for gtag.js. */\nexport const GTAG_LIBRARY = 'googletagmanager.com/gtag/js'\n\n/** `dataLayer` and the canonical `gtag` shim, the start of every boot. */\nconst GTAG_SHIM =\n 'window.dataLayer=window.dataLayer||[];' +\n 'function gtag(){dataLayer.push(arguments);}'\n\n/**\n * The consent-mode default a boot opens with — none when the host runs its\n * own consent solution (`consentRequired` false): their solution owns the\n * default, and a second one racing it would overwrite their visitor's answer\n * with ours.\n */\nfunction consentDefault(options: AnalyticsMountOptions): string {\n if (!options.consentRequired) return ''\n return options.advertising\n ? GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET\n : GA_CONSENT_DEFAULT_SNIPPET\n}\n\n/**\n * The Google Analytics pair (AGL-138/661): the site's configured measurement\n * id, booted and loaded.\n *\n * Order is load-bearing twice over. The inline block precedes the library, and\n * inside it the consent `default` precedes `config`, so no hit is ever sent\n * before the tag has been told what it may store. Google's canonical snippet\n * shape.\n *\n * ## The platform's own property\n *\n * `aglyn.com` is a site on this platform pointed at the platform's measurement\n * id, and three things are said to THAT property only, because a customer's\n * site configures its own id and their property gets no opinion of ours:\n *\n * - THE INTERNAL-TRAFFIC STAMP (AGL-2064), between the shim and `gtag('js')`.\n * It is a CONSTANT string, evaluated in the browser: these pages are\n * ISR-cached, so a server-side branch on \"is this us\" would bake one\n * browser's answer into the cache for everyone. It runs BEFORE `config`,\n * because the hits that leak are the ones no call site writes —\n * `session_start`, `first_visit`, `user_engagement`, the automatic\n * `page_view` — and a `set` applies only to hits processed after it. Wrongly\n * flagging a real visitor erases them from every report and a GA4 data\n * filter is not retroactive, so the id equality is the guard that keeps the\n * expensive direction unreachable. When the analytics escape hatch\n * deliberately re-enables a non-production build, the stamp is\n * UNCONDITIONAL (AGL-2067): a build that emits because someone asked it to is\n * ours by definition.\n * - THE CLICK-ID PASSTHROUGH (AGL-2548), also a `set` before `config`.\n * - `content_group: 'marketing'` on `config` (AGL-1857): the one-click\n * marketing/docs/console split in GA4 standard reports. The platform's id is\n * the discriminator because same-property IS the definition of \"this is our\n * surface\".\n */\nfunction googleAnalyticsMount(\n measurementId: string,\n options: AnalyticsMountOptions,\n): AnalyticsTagMount {\n const own = measurementId === PLATFORM_GA_MEASUREMENT_ID\n return {\n id: 'ga',\n boot:\n GTAG_SHIM +\n consentDefault(options) +\n (own\n ? analyticsEnvironmentForcesInternal()\n ? INTERNAL_TRAFFIC_FORCED_SNIPPET\n : INTERNAL_TRAFFIC_GTAG_SNIPPET\n : '') +\n (own ? GA_CLICK_ID_PASSTHROUGH_SNIPPET : '') +\n \"gtag('js', new Date());\" +\n (own\n ? `gtag('config', '${measurementId}', {'content_group':'marketing'});`\n : `gtag('config', '${measurementId}');`),\n src: `https://www.googletagmanager.com/gtag/js?id=${measurementId}`,\n library: GTAG_LIBRARY,\n }\n}\n\n/**\n * The Google Tag Manager pair (AGL-2486), under the same gate as GA and never\n * a looser one.\n *\n * A container is not a tag — it is a LOADER, and what it loads is decided in\n * Google's UI by whoever owns it, not here. That is exactly why it cannot have\n * a weaker gate than GA: analytics may run on implied consent outside the\n * EU/EEA/UK while ADVERTISING is opt-in everywhere, and a container is the\n * likeliest thing on a page to carry an advertising tag.\n *\n * CONSENT MODE V2 comes first, in the same script, before `gtm.js` is\n * requested: defaults set after the container has loaded are defaults its\n * tags have already run past. The advertising signals stay denied unless the\n * visitor granted advertising, which is what makes a container holding ad\n * tags safe to load at all.\n *\n * NO `<noscript>` IFRAME, deliberately, and it is the one piece of Google's\n * standard snippet omitted. That iframe fires the container with no\n * JavaScript — so no consent defaults, no gate, nothing to suppress it — and\n * these pages are ISR-cached, so it would sit in shared HTML identical for\n * every visitor and every region. A visitor with JavaScript off gets no\n * container, which is the correct answer.\n */\nfunction tagManagerMount(\n containerId: string,\n options: AnalyticsMountOptions,\n): AnalyticsTagMount {\n return {\n id: 'gtm',\n boot:\n GTAG_SHIM +\n consentDefault(options) +\n \"dataLayer.push({'gtm.start':new Date().getTime(),event:'gtm.js'});\",\n src: `https://www.googletagmanager.com/gtm.js?id=${containerId}`,\n }\n}\n\n/**\n * The measurement ids whose tag is actually RESIDENT in this page.\n *\n * Discovered from the page rather than taken from the host document on\n * purpose: what has to be silenced is whatever gtag.js is currently loaded and\n * configured for, which is not always what the host record configures — a tag\n * can arrive through GTM, the console's is injected by its analytics SDK, and\n * a stale `<script>` from earlier in the pageview is exactly the thing this\n * function exists to find.\n *\n * Ids are format-checked before they are used, because each one becomes a\n * `window` property name.\n */\nexport function residentGaMeasurementIds(): string[] {\n if (typeof document === 'undefined') return []\n const ids = new Set<string>()\n try {\n const loaded = document.querySelectorAll(`script[src*=\"${GTAG_LIBRARY}\"]`)\n for (const element of Array.from(loaded)) {\n const src = String((element as HTMLScriptElement).src ?? '')\n const match = /[?&]id=([^&]*)/.exec(src)\n const id = match ? decodeURIComponent(match[1]) : ''\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // A hostile or absent DOM: the dataLayer pass below still applies.\n }\n try {\n const layer = (window as unknown as Record<string, unknown>)?.dataLayer\n for (const entry of Array.from((layer ?? []) as ArrayLike<unknown>)) {\n // `arguments` objects, not arrays — gtag pushes its own call sites.\n const args = Array.from((entry ?? []) as ArrayLike<unknown>)\n if (args[0] !== 'config') continue\n const id = String(args[1] ?? '')\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // No dataLayer, or one that is not iterable: the script pass stands.\n }\n return [...ids]\n}\n\n/**\n * Make every resident Google tag agree with the visitor's answer, and return\n * the measurement ids it acted on.\n *\n * ## Why deleting the cookies is not enough (AGL-1608)\n *\n * The AGL-1498 gate stops the script from LOADING, which is the right\n * enforcement for a fresh pageview and the wrong tool for a visitor who\n * withdraws mid-pageview: `gtag.js` has already executed, and React unmounting\n * the `<script>` element does not unload it. `window.gtag` stays live, GA4\n * enhanced measurement fires on its own (scroll depth, outbound click, file\n * download), and the tag re-writes `_ga_<id>` AFTER the platform's cookie\n * sweep has deleted it. Reproduced on aglyn.com: an opt-out, a hand-run sweep\n * to zero cookies, then one scroll to the footer brought `_ga_YW5PG16YTM`\n * back.\n *\n * Two signals, because they fail differently and neither is guaranteed:\n * the `ga-disable-<id>` window flag silences a tag that never received a\n * consent-mode default, and the `consent`/`update` call reaches a tag that\n * arrived through GTM with its own id this function never saw.\n *\n * ## What this deliberately does NOT do\n *\n * It never touches the gate. Both signals act only on a tag that is already\n * resident — which, by construction, only exists after a grant. The\n * consent-mode DEFAULT ({@link GA_CONSENT_DEFAULT_SNIPPET}) is declared inside\n * the gated block and therefore only on a pageview the gate already allowed.\n *\n * Symmetric on purpose: a visitor who opts out and changes their mind in the\n * same pageview would otherwise stay silently unmeasured until they navigated,\n * because the re-rendered `<Script>` cannot re-execute an already-loaded tag.\n * The re-grant restores exactly what `grants` says, and core clamps\n * advertising to analytics before it arrives, so a withdrawal can never\n * restore advertising alone.\n */\nexport function applyGoogleTagConsent(grants: AnalyticsGrants): string[] {\n if (typeof window === 'undefined') return []\n const granted = grants.analytics === true\n const ids = residentGaMeasurementIds()\n const scope = window as unknown as Record<string, unknown>\n for (const id of ids) {\n scope[`${GA_DISABLE_FLAG_PREFIX}${id}`] = !granted\n }\n try {\n const send = residentGtag()\n if (send) {\n // The same payload builder the load-time `default` is made from\n // (AGL-1622), so the two declarations cannot drift.\n send(\n 'consent',\n 'update',\n consentModeSignals({\n analytics: granted,\n advertising: grants.advertising === true,\n }),\n )\n }\n } catch {\n // A tag that throws on its own consent call is one we cannot silence;\n // the flag above and the cookie sweep still stand.\n }\n return ids\n}\n\n/**\n * Hand one event to the resident tag.\n *\n * ## `measurementOnly`: the measurement properties, not the ads account (AGL-2710)\n *\n * A gtag event with no `send_to` goes to every destination the loader has\n * configured, and on a surface that also runs Google Ads that includes the\n * ads account. Core Web Vitals have nothing to do with advertising: measured\n * on `aglyn.com`, one such event attempted seven requests to\n * `googleads.g.doubleclick.net` and `google.com/{pagead,rmkt,ccm}` — per\n * metric, per pageview, four metrics deep — and Google Ads has no report that\n * reads any of them. Naming the GA4 ids drops all seven and leaves the GA hit\n * exactly as it was.\n *\n * The ids are read at DELIVERY time, from the page: the tag routinely arrives\n * after the first metric, and what has to be addressed is whatever gtag is\n * configured for now — which on a customer site is their own property and not\n * ours.\n *\n * No ids found means no `send_to` and the event goes wherever it went before.\n * That is the fail-open side on purpose: a tag that arrived through GTM with\n * an id this reader never saw would otherwise be handed an empty destination\n * list, and losing the measurement is a worse outcome than an ads account\n * receiving a metric it ignores.\n */\nfunction sendGoogleTagEvent(\n name: string,\n params: Record<string, unknown>,\n options?: AnalyticsEventOptions,\n): void {\n const gtag = residentGtag()\n if (!gtag) return\n try {\n if (!options?.measurementOnly) {\n gtag('event', name, params)\n return\n }\n const destinations = residentGaMeasurementIds()\n gtag(\n 'event',\n name,\n destinations.length ? { ...params, send_to: destinations } : params,\n )\n } catch {\n // Analytics never breaks the page.\n }\n}\n\n/** The adapter core registers from each app's generated manifest. */\nexport const analyticsProvider: AnalyticsProvider = {\n mounts(host: VisitorConsentHost | null | undefined, options) {\n const mounts: AnalyticsTagMount[] = []\n const measurementId = resolveGaMeasurementId(host)\n if (measurementId) mounts.push(googleAnalyticsMount(measurementId, options))\n const containerId = resolveGtmContainerId(host)\n if (containerId) mounts.push(tagManagerMount(containerId, options))\n return mounts\n },\n applyConsent: applyGoogleTagConsent,\n resident: () => residentGtag() !== null,\n sendEvent: sendGoogleTagEvent,\n}\n"],"names":["analyticsEnvironmentForcesInternal","INTERNAL_TRAFFIC_FORCED_SNIPPET","INTERNAL_TRAFFIC_GTAG_SNIPPET","PLATFORM_GA_MEASUREMENT_ID","analyticsConsentSignals","consentModeSignals","GA_MEASUREMENT_ID_PATTERN","resolveGaMeasurementId","resolveGtmContainerId","residentGtag","window","gtag","GA_DISABLE_FLAG_PREFIX","GA_CONSENT_DEFAULT_SNIPPET","JSON","stringify","GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET","analytics","advertising","GA_CLICK_ID_PASSTHROUGH_SNIPPET","GTAG_LIBRARY","GTAG_SHIM","consentDefault","options","consentRequired","googleAnalyticsMount","measurementId","own","id","boot","src","library","tagManagerMount","containerId","residentGaMeasurementIds","document","ids","Set","loaded","querySelectorAll","element","Array","from","String","match","exec","decodeURIComponent","test","add","layer","dataLayer","entry","args","applyGoogleTagConsent","grants","granted","scope","send","sendGoogleTagEvent","name","params","measurementOnly","destinations","length","send_to","analyticsProvider","mounts","host","push","applyConsent","resident","sendEvent"],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ConsoleBesignerInteractionsZoneProps } from '@aglyn/aglyn';
|
|
2
|
+
/**
|
|
3
|
+
* The site's section experiments, reported to the besigner's Interactions
|
|
4
|
+
* section through the `besignerInteractions` zone. Draws nothing.
|
|
5
|
+
*
|
|
6
|
+
* The section badges an element that has an experiment and refuses a second
|
|
7
|
+
* one on it, so it is handed every live section experiment on the site, not
|
|
8
|
+
* only those on the page in the editor: an element of a layout or a component
|
|
9
|
+
* is the same node on every page that places it.
|
|
10
|
+
*
|
|
11
|
+
* Starting one writes a DRAFT, with two even variants and the form-submission
|
|
12
|
+
* goal, on the page under edit. Nothing serves until it is started from the
|
|
13
|
+
* A/B testing list, where its variants' versions are pinned. It is written
|
|
14
|
+
* through `writeSiteWideChange` all the same, like every other write of the
|
|
15
|
+
* site's experiments: the page enricher reads the collection at render, and
|
|
16
|
+
* whether one document of it changes what a page shows is not this widget's
|
|
17
|
+
* to judge.
|
|
18
|
+
*/
|
|
19
|
+
export declare function BesignerSectionExperiments(props: ConsoleBesignerInteractionsZoneProps): any;
|
|
20
|
+
export default BesignerSectionExperiments;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { createResourceUid } from "@aglyn/aglyn";
|
|
19
|
+
import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
|
|
20
|
+
import { useSnackbar } from "@aglyn/shared-ui-snackstack";
|
|
21
|
+
import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
22
|
+
import { useFirestore, useFirestoreCollection, useUser } from "@aglyn/tenant-feature-instance";
|
|
23
|
+
import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change";
|
|
24
|
+
import { collection, doc, limit, query } from "firebase/firestore";
|
|
25
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
26
|
+
/** The name this plugin reports under; the section keys reports by it. */ const REPORTER_ID = 'marketing-section-experiments';
|
|
27
|
+
/**
|
|
28
|
+
* The site's section experiments, reported to the besigner's Interactions
|
|
29
|
+
* section through the `besignerInteractions` zone. Draws nothing.
|
|
30
|
+
*
|
|
31
|
+
* The section badges an element that has an experiment and refuses a second
|
|
32
|
+
* one on it, so it is handed every live section experiment on the site, not
|
|
33
|
+
* only those on the page in the editor: an element of a layout or a component
|
|
34
|
+
* is the same node on every page that places it.
|
|
35
|
+
*
|
|
36
|
+
* Starting one writes a DRAFT, with two even variants and the form-submission
|
|
37
|
+
* goal, on the page under edit. Nothing serves until it is started from the
|
|
38
|
+
* A/B testing list, where its variants' versions are pinned. It is written
|
|
39
|
+
* through `writeSiteWideChange` all the same, like every other write of the
|
|
40
|
+
* site's experiments: the page enricher reads the collection at render, and
|
|
41
|
+
* whether one document of it changes what a page shows is not this widget's
|
|
42
|
+
* to judge.
|
|
43
|
+
*/ export function BesignerSectionExperiments(props) {
|
|
44
|
+
const { hostId, screenId, reportSectionExperiments } = props;
|
|
45
|
+
const firestore = useFirestore();
|
|
46
|
+
const { data: user } = useUser();
|
|
47
|
+
const { enqueueSnackbar } = useSnackbar();
|
|
48
|
+
const { data: experimentDocs } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'experiments'), limit(50)) : null, [
|
|
49
|
+
firestore,
|
|
50
|
+
hostId
|
|
51
|
+
], {
|
|
52
|
+
idField: '$id'
|
|
53
|
+
});
|
|
54
|
+
const experiments = useMemo(()=>(experimentDocs != null ? experimentDocs : []).filter((experiment)=>!experiment.deletedAt && experiment.target === 'section' && experiment.nodeId).map((experiment)=>({
|
|
55
|
+
id: experiment.$id,
|
|
56
|
+
name: experiment.name,
|
|
57
|
+
nodeId: experiment.nodeId,
|
|
58
|
+
status: experiment.status
|
|
59
|
+
})), [
|
|
60
|
+
experimentDocs
|
|
61
|
+
]);
|
|
62
|
+
const create = useCallback(({ nodeId })=>{
|
|
63
|
+
if (!screenId) return;
|
|
64
|
+
const id = createResourceUid();
|
|
65
|
+
void writeSiteWideChange({
|
|
66
|
+
firestore,
|
|
67
|
+
user,
|
|
68
|
+
hostId,
|
|
69
|
+
write: (batch)=>batch.set(doc(firestore, 'hosts', hostId, 'experiments', id), _extends({}, nameSearchFields(`Section test — ${nodeId.slice(0, 8)}`), {
|
|
70
|
+
status: 'draft',
|
|
71
|
+
target: 'section',
|
|
72
|
+
screenId,
|
|
73
|
+
nodeId,
|
|
74
|
+
variants: [
|
|
75
|
+
{
|
|
76
|
+
id: 'a',
|
|
77
|
+
name: 'A (control)',
|
|
78
|
+
weight: 1
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'b',
|
|
82
|
+
name: 'B',
|
|
83
|
+
weight: 1
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
goal: {
|
|
87
|
+
event: 'formSubmission'
|
|
88
|
+
},
|
|
89
|
+
createdAt: Timestamp.now()
|
|
90
|
+
}))
|
|
91
|
+
}).then(()=>enqueueSnackbar('Draft experiment created — pin variant versions and ' + 'start it from Marketing → Experiments', {
|
|
92
|
+
variant: 'success',
|
|
93
|
+
persist: false
|
|
94
|
+
})).catch((error)=>{
|
|
95
|
+
console.error(error);
|
|
96
|
+
enqueueSnackbar('Could not create the experiment', {
|
|
97
|
+
variant: 'error'
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}, [
|
|
101
|
+
firestore,
|
|
102
|
+
user,
|
|
103
|
+
hostId,
|
|
104
|
+
screenId,
|
|
105
|
+
enqueueSnackbar
|
|
106
|
+
]);
|
|
107
|
+
useEffect(()=>{
|
|
108
|
+
reportSectionExperiments(REPORTER_ID, _extends({
|
|
109
|
+
experiments
|
|
110
|
+
}, screenId ? {
|
|
111
|
+
create
|
|
112
|
+
} : {}));
|
|
113
|
+
}, [
|
|
114
|
+
reportSectionExperiments,
|
|
115
|
+
experiments,
|
|
116
|
+
screenId,
|
|
117
|
+
create
|
|
118
|
+
]);
|
|
119
|
+
// Withdrawn when the zone stops drawing this plugin: switched off for the
|
|
120
|
+
// workspace, or the editor closed.
|
|
121
|
+
useEffect(()=>()=>reportSectionExperiments(REPORTER_ID, null), [
|
|
122
|
+
reportSectionExperiments
|
|
123
|
+
]);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
export default BesignerSectionExperiments;
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=besigner-section-experiments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/besigner-section-experiments.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n createResourceUid,\n type ConsoleBesignerInteractionsZoneProps,\n type ConsoleBesignerSectionExperiment,\n} from '@aglyn/aglyn'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport { collection, doc, limit, query } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo } from 'react'\n\n/** The name this plugin reports under; the section keys reports by it. */\nconst REPORTER_ID = 'marketing-section-experiments'\n\n/**\n * The site's section experiments, reported to the besigner's Interactions\n * section through the `besignerInteractions` zone. Draws nothing.\n *\n * The section badges an element that has an experiment and refuses a second\n * one on it, so it is handed every live section experiment on the site, not\n * only those on the page in the editor: an element of a layout or a component\n * is the same node on every page that places it.\n *\n * Starting one writes a DRAFT, with two even variants and the form-submission\n * goal, on the page under edit. Nothing serves until it is started from the\n * A/B testing list, where its variants' versions are pinned. It is written\n * through `writeSiteWideChange` all the same, like every other write of the\n * site's experiments: the page enricher reads the collection at render, and\n * whether one document of it changes what a page shows is not this widget's\n * to judge.\n */\nexport function BesignerSectionExperiments(\n props: ConsoleBesignerInteractionsZoneProps,\n) {\n const { hostId, screenId, reportSectionExperiments } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { data: experimentDocs } = useFirestoreCollection<any>(\n () =>\n hostId\n ? query(collection(firestore, 'hosts', hostId, 'experiments'), limit(50))\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const experiments = useMemo<ConsoleBesignerSectionExperiment[]>(\n () =>\n (experimentDocs ?? [])\n .filter(\n (experiment: any) =>\n !experiment.deletedAt &&\n experiment.target === 'section' &&\n experiment.nodeId,\n )\n .map((experiment: any) => ({\n id: experiment.$id as string,\n name: experiment.name as string | undefined,\n nodeId: experiment.nodeId as string,\n status: experiment.status as string | undefined,\n })),\n [experimentDocs],\n )\n\n const create = useCallback(\n ({ nodeId }: { nodeId: string }) => {\n if (!screenId) return\n const id = createResourceUid()\n void writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.set(doc(firestore, 'hosts', hostId, 'experiments', id), {\n // With the search keys the A/B testing list's filter and search\n // read (AGL-3321).\n ...nameSearchFields(`Section test — ${nodeId.slice(0, 8)}`),\n status: 'draft',\n target: 'section',\n screenId,\n nodeId,\n variants: [\n { id: 'a', name: 'A (control)', weight: 1 },\n { id: 'b', name: 'B', weight: 1 },\n ],\n goal: { event: 'formSubmission' },\n createdAt: Timestamp.now(),\n }),\n })\n .then(() =>\n enqueueSnackbar(\n 'Draft experiment created — pin variant versions and ' +\n 'start it from Marketing → Experiments',\n { variant: 'success', persist: false },\n ),\n )\n .catch((error) => {\n console.error(error)\n enqueueSnackbar('Could not create the experiment', {\n variant: 'error',\n })\n })\n },\n [firestore, user, hostId, screenId, enqueueSnackbar],\n )\n\n useEffect(() => {\n reportSectionExperiments(REPORTER_ID, {\n experiments,\n ...(screenId ? { create } : {}),\n })\n }, [reportSectionExperiments, experiments, screenId, create])\n\n // Withdrawn when the zone stops drawing this plugin: switched off for the\n // workspace, or the editor closed.\n useEffect(\n () => () => reportSectionExperiments(REPORTER_ID, null),\n [reportSectionExperiments],\n )\n\n return null\n}\n\nexport default BesignerSectionExperiments\n"],"names":["createResourceUid","nameSearchFields","useSnackbar","Timestamp","useFirestore","useFirestoreCollection","useUser","writeSiteWideChange","collection","doc","limit","query","useCallback","useEffect","useMemo","REPORTER_ID","BesignerSectionExperiments","props","hostId","screenId","reportSectionExperiments","firestore","data","user","enqueueSnackbar","experimentDocs","idField","experiments","filter","experiment","deletedAt","target","nodeId","map","id","$id","name","status","create","write","batch","set","slice","variants","weight","goal","event","createdAt","now","then","variant","persist","catch","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,iBAAiB,QAGZ,eAAc;AACrB,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,SAAS,QAAQ,+BAA8B;AACxD,SACEC,YAAY,EACZC,sBAAsB,EACtBC,OAAO,QACF,iCAAgC;AACvC,SAASC,mBAAmB,QAAQ,gEAA+D;AACnG,SAASC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAClE,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,QAAQ,QAAO;AAEvD,wEAAwE,GACxE,MAAMC,cAAc;AAEpB;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,2BACdC,KAA2C;IAE3C,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,wBAAwB,EAAE,GAAGH;IACvD,MAAMI,YAAYjB;IAClB,MAAM,EAAEkB,MAAMC,IAAI,EAAE,GAAGjB;IACvB,MAAM,EAAEkB,eAAe,EAAE,GAAGtB;IAC5B,MAAM,EAAEoB,MAAMG,cAAc,EAAE,GAAGpB,uBAC/B,IACEa,SACIP,MAAMH,WAAWa,WAAW,SAASH,QAAQ,gBAAgBR,MAAM,OACnE,MACN;QAACW;QAAWH;KAAO,EACnB;QAAEQ,SAAS;IAAM;IAGnB,MAAMC,cAAcb,QAClB,IACE,CAACW,yBAAAA,iBAAkB,EAAE,EAClBG,MAAM,CACL,CAACC,aACC,CAACA,WAAWC,SAAS,IACrBD,WAAWE,MAAM,KAAK,aACtBF,WAAWG,MAAM,EAEpBC,GAAG,CAAC,CAACJ,aAAqB,CAAA;gBACzBK,IAAIL,WAAWM,GAAG;gBAClBC,MAAMP,WAAWO,IAAI;gBACrBJ,QAAQH,WAAWG,MAAM;gBACzBK,QAAQR,WAAWQ,MAAM;YAC3B,CAAA,IACJ;QAACZ;KAAe;IAGlB,MAAMa,SAAS1B,YACb,CAAC,EAAEoB,MAAM,EAAsB;QAC7B,IAAI,CAACb,UAAU;QACf,MAAMe,KAAKlC;QACX,KAAKO,oBAAoB;YACvBc;YACAE;YACAL;YACAqB,OAAO,CAACC,QACNA,MAAMC,GAAG,CAAChC,IAAIY,WAAW,SAASH,QAAQ,eAAegB,KAAK,aAGzDjC,iBAAiB,CAAC,eAAe,EAAE+B,OAAOU,KAAK,CAAC,GAAG,IAAI;oBAC1DL,QAAQ;oBACRN,QAAQ;oBACRZ;oBACAa;oBACAW,UAAU;wBACR;4BAAET,IAAI;4BAAKE,MAAM;4BAAeQ,QAAQ;wBAAE;wBAC1C;4BAAEV,IAAI;4BAAKE,MAAM;4BAAKQ,QAAQ;wBAAE;qBACjC;oBACDC,MAAM;wBAAEC,OAAO;oBAAiB;oBAChCC,WAAW5C,UAAU6C,GAAG;;QAE9B,GACGC,IAAI,CAAC,IACJzB,gBACE,yDACE,yCACF;gBAAE0B,SAAS;gBAAWC,SAAS;YAAM,IAGxCC,KAAK,CAAC,CAACC;YACNC,QAAQD,KAAK,CAACA;YACd7B,gBAAgB,mCAAmC;gBACjD0B,SAAS;YACX;QACF;IACJ,GACA;QAAC7B;QAAWE;QAAML;QAAQC;QAAUK;KAAgB;IAGtDX,UAAU;QACRO,yBAAyBL,aAAa;YACpCY;WACIR,WAAW;YAAEmB;QAAO,IAAI,CAAC;IAEjC,GAAG;QAAClB;QAA0BO;QAAaR;QAAUmB;KAAO;IAE5D,0EAA0E;IAC1E,mCAAmC;IACnCzB,UACE,IAAM,IAAMO,yBAAyBL,aAAa,OAClD;QAACK;KAAyB;IAG5B,OAAO;AACT;AAEA,eAAeJ,2BAA0B"}
|
|
@@ -32,7 +32,7 @@ import { useFirestore, useOrgDataScope, usePagedCollection } from "@aglyn/tenant
|
|
|
32
32
|
* `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks
|
|
33
33
|
* would otherwise lose them, and neither is a hook.
|
|
34
34
|
*/ import { ceilingedWindow, collectionCeiling, collectionPage } from "@aglyn/tenant-feature-instance/hooks/host-collection-queries";
|
|
35
|
-
import { CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, campaignConversionsCoverage, campaignTouchLabel } from "
|
|
35
|
+
import { CAMPAIGN_CONVERSION_KINDS, CAMPAIGN_CONVERSION_KIND_COPY, campaignConversionsCoverage, campaignTouchLabel } from "../model/campaign-conversions.js";
|
|
36
36
|
/**
|
|
37
37
|
* WHAT THE CAMPAIGNS CAUSED, from the conversions' end.
|
|
38
38
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CAPTURED_BY_HOST_FIELD, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '@aglyn/shared-ui-email-campaigns/model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * A PATH STRING and a BOOLEAN, not an object.\n *\n * Both are effect dependencies, and an object rebuilt on every render\n * reopens the effect on every render — which here means paying for two\n * aggregation counts per render rather than per kind. Primitives compare by\n * value, so the reads happen when the question changes and not when React\n * re-runs the component.\n */\n const totalCollectionPath = useMemo((): string | null => {\n switch (kind) {\n case 'form':\n return `hosts/${hostId}/formSubmissions`\n case 'lead':\n /*\n * The ORG's leads (AGL-3275), narrowed below to the ones this site\n * CAPTURED. A lead is one document per person, stamped in\n * `capturedByHostIds` with every site that met them, and its\n * attribution is written by the site that captured it — so this\n * site's captures are exactly the population its lead attributions\n * are drawn from, and the total does not cross hosts.\n *\n * Not `visibleTo`: that also names the sites a consent group shares a\n * person with, and would count leads a sibling brand captured that\n * could never have been credited here. The price is that a\n * collaborator scoped to one site cannot run the count — the rules\n * prove a lead read by `visibleTo` — and is shown the withheld split,\n * as they already are for contacts.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/leads` : null\n case 'booking':\n return `hosts/${hostId}/bookings`\n case 'contact':\n /*\n * ORG-SCOPED, and the coverage model is told so below. A contact is\n * shared across every site in the org while an attribution belongs to\n * one host, so this total counts contacts created on another site\n * that could never have been credited here.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null\n default:\n return null\n }\n }, [kind, hostId, dataScope])\n\n /** The total counts only the org records THIS site captured. */\n const totalCapturedHere = kind === 'lead'\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = kind === 'contact'\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n if (campaignId || !totalCollectionPath) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n const segments = totalCollectionPath.split('/')\n const records = collection(firestore, segments[0], ...segments.slice(1))\n void getCountFromServer(\n totalCapturedHere\n ? query(\n records,\n where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId),\n )\n : records,\n )\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [\n attributions,\n firestore,\n kind,\n campaignId,\n totalCollectionPath,\n totalCapturedHere,\n hostId,\n ])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n const submissions = collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n )\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const chunk = refIds.slice(index, index + ID_CHUNK)\n const found = await getDocs(\n query(submissions, where(documentId(), 'in', chunk)),\n )\n found.docs.forEach((entry) => {\n const path = String((entry.data() as any)?.path ?? '').trim()\n if (path) paths.set(entry.id, path)\n })\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would order\n // only that page.\n disableColumnSorting\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would\n // order only that page.\n disableColumnSorting\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["CAPTURED_BY_HOST_FIELD","pluginDocsHelp","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","documentId","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","totalCollectionPath","totalCapturedHere","totalCrossesHosts","attributedCount","setAttributedCount","totalCount","setTotalCount","active","then","snapshot","data","count","catch","segments","split","records","slice","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","submissions","paths","Map","index","chunk","found","forEach","path","trim","set","counts","missing","get","entries","conversions","$id","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","disableColumnSorting","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,SAASA,sBAAsB,EAAEC,cAAc,QAAQ,eAAc;AACrE,SAASC,aAAa,EAAEC,mBAAmB,QAAQ,yBAAwB;AAC3E,SAASC,OAAO,EAAEC,WAAW,EAAEC,OAAO,QAAQ,uBAAsB;AACpE,SACEC,cAAc,EACdC,SAAS,EACTC,iBAAiB,QACZ,uDAAsD;AAC7D,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,MAAM,EAAEC,OAAO,QAAQ,6DAA4D;AAC5F,SACEC,KAAK,EACLC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,YAAY,EACZC,iBAAiB,EACjBC,UAAU,QACL,gBAAe;AACtB,SACEC,UAAU,EACVC,UAAU,EACVC,kBAAkB,EAClBC,OAAO,EACPC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAC3B,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACjE,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SACEC,YAAY,EACZC,eAAe,EACfC,kBAAkB,QACb,iCAAgC;AACvC;;;;CAIC,GACD,SACEC,eAAe,EACfC,iBAAiB,EACjBC,cAAc,QACT,+DAA8D;AACrE,SACEC,yBAAyB,EACzBC,6BAA6B,EAC7BC,2BAA2B,EAC3BC,kBAAkB,QAGb,8DAA6D;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED;;;;;;;CAOC,GACD,MAAMC,uBAAuB;AAE7B,sDAAsD,GACtD,MAAMC,WAAW;AAEjB,MAAMC,sBAAsB3C,eAAe,kBAAkB;IAC3D4C,QAAQ;IACRC,SACE,0EACA,2EACA;AACJ;AAKA,MAAMC,MAAM,CAACC;IACX,MAAMC,QAAQC,OAAOF,aAAAA,KAAM;IAC3B,OAAOE,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IACrC,IAAIG,KAAKH,OAAOI,cAAc,KAC9B;AACN;AAoBA,OAAO,SAASC,wBAAwBC,KAAmC;IACzE,MAAM,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,UAAU,EAAE,GAAGH;IACzC,MAAMI,YAAY3B;IAClB,MAAM4B,SAAS7B;IACf,MAAM,EAAE8B,OAAOC,SAAS,EAAE,GAAG7B,gBAAgB;QAAEuB;IAAO;IACtD,MAAM,CAACO,MAAMC,QAAQ,GAAGlC,SAAiC;IAEzD,MAAMmC,eAAetC,YACnB,IAAMN,WAAWsC,WAAW,SAASH,QAAQ,yBAC7C;QAACG;QAAWH;KAAO;IAGrB;;;;;;;;;;;;;;;6CAe2C,GAC3C,MAAMU,YAAYhC,mBAChB,CAACiC,YACC9B,eACEqB,aACIjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,cAAc,MAAMgC,eAE5BjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,WAAW,MAAM,WAE7ByC,YAEJ;QAACR;QAAWH;QAAQO;QAAML;KAAW,EACrC;QAAEU,SAAS;IAAM;IAGnB,MAAMC,UAAUnC,mBACd,CAACiC,YACCT,aACI,OACArB,eACEZ,MACEwC,gBACAvC,MAAM,QAAQ,MAAMqC,OACpBrC,MAAM,WAAW,MAAM,SAEzByC,YAER;QAACR;QAAWH;QAAQO;QAAML;KAAW,EACrC;QAAEU,SAAS;IAAM;IAGnB;;;;;;;;;;;;;;6CAc2C,GAC3C;;;;;;;;GAQC,GACD,MAAME,sBAAsBzC,QAAQ;QAClC,OAAQkC;YACN,KAAK;gBACH,OAAO,CAAC,MAAM,EAAEP,OAAO,gBAAgB,CAAC;YAC1C,KAAK;gBACH;;;;;;;;;;;;;;SAcC,GACD,OAAOM,YAAY,GAAGA,SAAS,CAAC,EAAE,CAAC,CAAC,EAAEA,SAAS,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG;YAC/D,KAAK;gBACH,OAAO,CAAC,MAAM,EAAEN,OAAO,SAAS,CAAC;YACnC,KAAK;gBACH;;;;;SAKC,GACD,OAAOM,YAAY,GAAGA,SAAS,CAAC,EAAE,CAAC,CAAC,EAAEA,SAAS,CAAC,EAAE,CAAC,SAAS,CAAC,GAAG;YAClE;gBACE,OAAO;QACX;IACF,GAAG;QAACC;QAAMP;QAAQM;KAAU;IAE5B,8DAA8D,GAC9D,MAAMS,oBAAoBR,SAAS;IAEnC,yEAAyE,GACzE,MAAMS,oBAAoBT,SAAS;IAEnC,MAAM,CAACU,iBAAiBC,mBAAmB,GAAG5C,SAAwB;IACtE,MAAM,CAAC6C,YAAYC,cAAc,GAAG9C,SAAwB;IAE5DF,UAAU;QACR,uEAAuE;QACvE,uDAAuD;QACvD,IAAI8B,cAAc,CAACY,qBAAqB;YACtCI,mBAAmB;YACnBE,cAAc;YACd;QACF;QACA,IAAIC,SAAS;QACbH,mBAAmB;QACnBE,cAAc;QACd,KAAKrD,mBAAmBE,MAAMwC,gBAAgBvC,MAAM,QAAQ,MAAMqC,QAC/De,IAAI,CAAC,CAACC;YACL,IAAIF,QAAQH,mBAAmBK,SAASC,IAAI,GAAGC,KAAK;QACtD,GACCC,KAAK,CAAC;QACL,+DAA+D;QACjE;QACF,MAAMC,WAAWb,oBAAoBc,KAAK,CAAC;QAC3C,MAAMC,UAAUhE,WAAWsC,WAAWwB,QAAQ,CAAC,EAAE,KAAKA,SAASG,KAAK,CAAC;QACrE,KAAK/D,mBACHgD,oBACI9C,MACE4D,SACA3D,MAAM1B,wBAAwB,kBAAkBwD,WAElD6B,SAEHP,IAAI,CAAC,CAACC;YACL,IAAIF,QAAQD,cAAcG,SAASC,IAAI,GAAGC,KAAK;QACjD,GACCC,KAAK,CAAC;QACL,+DAA+D;QACjE;QACF,OAAO;YACLL,SAAS;QACX;IACF,GAAG;QACDZ;QACAN;QACAI;QACAL;QACAY;QACAC;QACAf;KACD;IAED,MAAM+B,WAAW/C,4BAA4B;QAC3CuB;QACAyB,YAAYf;QACZgB,OAAOd;QACPe,gBAAgBlB;IAClB;IAEA;;;;;;;;;;;;;;6CAc2C,GAC3C,MAAM,CAACmB,aAAaC,eAAe,GAAG9D,SAAS;IAC/C,MAAM,CAAC+D,cAAcC,gBAAgB,GAAGhE,SAAwB;IAChE,MAAM,CAACiE,SAASC,WAAW,GAAGlE,SAQpB;IAEV,2EAA2E;IAC3E,uDAAuD;IACvDF,UAAU;QACRoE,WAAW;QACXF,gBAAgB;IAClB,GAAG;QAAC/B;QAAML;KAAW;IAErB,MAAMuC,qBAAqBtE,YAAY;QACrC,IAAIgE,aAAa;QACjBC,eAAe;QACfE,gBAAgB;QAChB,IAAI;YACF,MAAMI,WAAW,MAAM1E,QACrBY,kBACEsB,aACIjC,MACEwC,gBACAvC,MAAM,QAAQ,MAAM,SACpBA,MAAM,cAAc,MAAMgC,eAE5BjC,MAAMwC,gBAAgBvC,MAAM,QAAQ,MAAM,UAC9CgB;YAGJ,MAAM,EAAEyD,MAAMC,OAAO,EAAEC,SAAS,EAAE,GAAGlE,gBACnC+D,SAASI,IAAI,CAACC,GAAG,CAAC,CAACC,QAAUA,MAAMxB,IAAI,KACvCtC;YAEF,MAAM+D,SAASC,MAAMC,IAAI,CACvB,IAAIC,IACFR,QACGG,GAAG,CAAC,CAACM;oBAAkBA;uBAAPC,QAAOD,gBAAAA,OAAOE,KAAK,YAAZF,gBAAgB;eACvCG,MAAM,CAAC,CAACC,KAAOA,GAAGC,MAAM,GAAG;YAIlC,MAAMC,cAAc9F,WAClBsC,WACA,SACAH,QACA;YAEF,MAAM4D,QAAQ,IAAIC;YAClB,IAAK,IAAIC,QAAQ,GAAGA,QAAQb,OAAOS,MAAM,EAAEI,SAAS3E,SAAU;gBAC5D,MAAM4E,QAAQd,OAAOnB,KAAK,CAACgC,OAAOA,QAAQ3E;gBAC1C,MAAM6E,QAAQ,MAAMhG,QAClBC,MAAM0F,aAAazF,MAAMJ,cAAc,MAAMiG;gBAE/CC,MAAMlB,IAAI,CAACmB,OAAO,CAAC,CAACjB;;wBACGA;oBAArB,MAAMkB,OAAOZ,gBAAQN,cAAAA,MAAMxB,IAAI,uBAAX,AAACwB,YAAsBkB,IAAI,mBAAI,IAAIC,IAAI;oBAC3D,IAAID,MAAMN,MAAMQ,GAAG,CAACpB,MAAMS,EAAE,EAAES;gBAChC;YACF;YAEA,MAAMG,SAAS,IAAIR;YACnB,IAAIS,UAAU;YACdrB,OAAOgB,OAAO,CAAC,CAACV;oBAaIc;gBAZlB,MAAMH,OAAON,MAAMW,GAAG,CAAChB;gBACvB,IAAI,CAACW,MAAM;oBACT;;;;;;WAMC,GACDI,WAAW;oBACX;gBACF;gBACAD,OAAOD,GAAG,CAACF,MAAM,EAACG,cAAAA,OAAOE,GAAG,CAACL,iBAAXG,cAAoB,KAAK;YAC7C;YAEA7B,WAAW;gBACTG,MAAMO,MAAMC,IAAI,CAACkB,OAAOG,OAAO,IAC5BzB,GAAG,CAAC,CAAC,CAACmB,MAAMO,YAAY,GAAM,CAAA;wBAAEC,KAAKR;wBAAMA;wBAAMO;oBAAY,CAAA,GAC7DE,IAAI,CACH,CAACC,GAAGC,IACFA,EAAEJ,WAAW,GAAGG,EAAEH,WAAW,IAAIG,EAAEV,IAAI,CAACY,aAAa,CAACD,EAAEX,IAAI;gBAElEa,MAAM9B,OAAOS,MAAM;gBACnBb;gBACAyB;YACF;QACF,EAAE,OAAOU,OAAO;YACdC,QAAQD,KAAK,CAACA;YACd1C,gBAAgB;QAClB,SAAU;YACRF,eAAe;QACjB;IACF,GAAG;QAAC3B;QAAcP;QAAYC;QAAWH;QAAQmC;KAAY;IAE7D,MAAM+C,eAAe,CAACC,MACpBA,IAAIjF,UAAU,GAAG,GAAGD,SAAS,WAAW,EAAEkF,IAAIjF,UAAU,EAAE,GAAGkF;IAE/D,MAAMC,UAAUhH,QACd,IAAM;YACJ;gBACEiH,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO;wBAGXA;yCAFZ,MAAC1H;wBAAMkI,IAAI;4BAAEF,UAAU;wBAAE;;0CACvB,KAAC7H;gCAAWgI,SAAQ;gCAAQC,MAAM;0CAC/BvC,QAAO6B,aAAAA,IAAI5B,KAAK,YAAT4B,aAAaA,IAAIT,GAAG;;0CAE9B,KAAC9G;gCAAWgI,SAAQ;gCAAUE,OAAM;gCAAiBD,MAAM;0CACxD9G,6BAA6B,CAACwB,KAAK,CAACwF,KAAK;;;;;YAIlD;YACA;gBACET,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO;oBACvB,MAAMY,QAAQ9G,mBAAmBkG,QAAQ;oBACzC,MAAMa,OAAOd,aAAaC;oBAC1B,OAAOa,qBACL,KAACpJ;wBACCoJ,MAAMA;wBACNC,SAAS,CAACC,QACRA,MAAMC,eAAe;kCAGtBJ;uCAGH,KAACnI;wBAAWgI,SAAQ;wBAAQC,MAAM;kCAC/BE;;gBAGP;YACF;YACA;gBACET,OAAO;gBACPC,YAAY;gBACZa,OAAO;gBACPV,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACvH;wBAAWgI,SAAQ;wBAAQC,MAAM;kCAC/BtG,IAAI4F,IAAIkB,aAAa;;YAG5B;YACApJ,kBAAkB,CAACkI;oBAEDA;qCADhB,KAACpI;oBACCgJ,OAAOzC,QAAO6B,aAAAA,IAAI5B,KAAK,YAAT4B,aAAaA,IAAIT,GAAG;oBAClC4B,OAAO;wBACL;4BACEC,KAAK;4BACLR,OAAO;4BACPS,oBAAM,KAAC1J;gCAAQoH,MAAMxH,cAAcwH,IAAI;gCAAEuC,MAAM;;4BAC/CT,MAAMd,aAAaC;4BACnBuB,UAAU,CAACxB,aAAaC;4BACxB;;;;;eAKC,GACDwB,gBACE,gEACA;wBACJ;qBACD;;;SAGN,EACD,uDAAuD;IACvD;QAACpG;QAAMN;KAAS;IAGlB,MAAM2G,iBAAiBvI,QACrB,IAAM;YACJ;gBACEiH,OAAO;gBACPC,YAAY;gBACZC,MAAM;gBACNC,UAAU;gBACVC,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACvH;wBAAWgI,SAAQ;wBAAQC,MAAM;kCAC/BV,IAAIjB,IAAI;;YAGf;YACA;gBACEoB,OAAO;gBACPC,YAAY;gBACZa,OAAO;gBACPS,OAAO;gBACPC,aAAa;gBACbpB,YAAY,CAAC,EAAEP,GAAG,EAAO,iBACvB,KAACvH;wBAAWgI,SAAQ;kCACjBlG,OAAOyF,IAAIV,WAAW,EAAE5E,cAAc;;YAG7C;SACD,EACD,EAAE;IAGJ,MAAMkH,WAAWhI,6BAA6B,CAACwB,KAAK;IAEpD,qBACE,KAAC1D;QACCmK,QAAO;QACPC,WACE/G,aACI,8BACA;QAENgH,MAAM9H;QACN+H,aAAa;YACXC,QAAQlH,2BACN,KAAC5C;gBACC+J,WAAWzK;eACN;gBAAE0K,kBAAkB;gBAASC,cAAc;YAAM;gBACtDvB,MAAM,GAAG/F,SAAS,YAAY,CAAC;gBAC/BwG,MAAK;gBACLX,OAAM;0BAEL;kBAEDV;QACN;QACAoC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAChK;YAAMiK,SAAS;;8BAUd,MAACjK;oBAAMiK,SAAS;;sCACd,KAAC/J;4BACCgK,SAAS;4BACTlB,MAAK;4BACLX,OAAM;4BACNrG,OAAOc;4BACPqH,UAAU,CAACC,QAAQC;gCACjB,IAAIA,MAAMtH,QAAQsH;4BACpB;4BACAC,cAAW;sCAEVjJ,0BAA0BiE,GAAG,CAAC,CAACiF,oBAC9B,KAACtK;oCAAuB+B,OAAOuI;8CAC5BjJ,6BAA6B,CAACiJ,IAAI,CAACjC,KAAK;mCADxBiC;;sCAKvB,KAACpK;4BAAWgI,SAAQ;4BAAUE,OAAM;sCACjC,mEACC,+DACA,gEACA;;;;8BAIN,KAACtI;gBASA0C,2BACC,KAAC7C;oBAAM4K,UAAS;8BACb,8DACC,kEACA;qBAEFlG,yBACF,MAAC3E;oBAAQ8K,OAAO,GAAGnB,SAAShB,KAAK,CAAC,aAAa,CAAC;;sCAC9C,MAACtI;4BACC0K,WAAU;4BACVT,SAAS;4BACTU,UAAU;4BACVzC,IAAI;gCAAE0C,UAAU;4BAAO;;8CAEvB,KAAClL;oCACC4I,OAAM;oCACNtG,OAAOsC,SAASC,UAAU;oCAC1BsG,MAAK;;8CAEP,KAACnL;oCACC4I,OAAM;oCACNtG,OAAOsC,SAASwG,YAAY;oCAC5BD,MAAK;;8CAEP,KAACnL;oCACC4I,OAAO,GAAGgB,SAAShB,KAAK,CAAC,SAAS,CAAC;oCACnCtG,OAAOsC,SAASE,KAAK;oCACrBqG,MACEvG,SAASyG,KAAK,GAAG,iBAAiB;;;;wBAIvCzG,SAAS0G,OAAO,CAAC1F,GAAG,CAAC,CAAC2F,uBACrB,KAACrL;gCAAsB4K,UAAS;0CAC7BS,OAAOC,OAAO;+BADLD,OAAOjF,EAAE;;mCAMzB,KAACpG;oBAAM4K,UAAS;8BACb,iEACC,mEACA;;8BAIN,KAACzK;8BAED,MAACJ;oBAAQ8K,OAAM;;sCACb,KAAClL;4BACC2F,MAAMjC,UAAUiC,IAAI;4BACpB0C,SAASA;4BACTuD,UAAU;4BACV,gEAAgE;4BAChE,kBAAkB;4BAClBC,oBAAoB;4BACpBC,QAAQ,CAACC,KAAK5D;gCACZ,MAAMa,OAAOd,aAAaC;gCAC1B,IAAIa,MAAM5F,OAAO4I,IAAI,CAAChD;4BACxB;4BACAiD,aAAY;4BACZC,mBACE,kEACA;;sCAGJ,KAAChM;4BACCiM,MAAMzI,UAAUyI,IAAI;4BACpBC,UAAU1I,UAAU0I,QAAQ;4BAC5BC,UAAU3I,UAAUiC,IAAI,CAACe,MAAM;4BAC/B4F,SAAS5I,UAAU4I,OAAO;4BAC1BC,cAAc7I,UAAU8I,OAAO;4BAC/BC,kBAAkB/I,UAAUgJ,WAAW;;sCAEzC,KAAC9L;4BAAWgI,SAAQ;4BAAUE,OAAM;sCACjC,iEACC,kEACA;;;;gBAiBL5F,aAAa,qBACZ,MAAC9C;oBAAQ8K,OAAM;;sCACb,KAACtK;4BAAWgI,SAAQ;4BAAQE,OAAM;sCAC/B,kEACC,gEACA,0DACA;;sCAEJ,KAAC9I;4BACC2F,MAAM9B,QAAQ8B,IAAI;4BAClB0C,SAASA;4BACTuD,UAAU;4BACV,0DAA0D;4BAC1D,wBAAwB;4BACxBC,oBAAoB;4BACpBI,aAAY;4BACZC,mBACE,+DACA;;sCAGJ,KAAChM;4BACCiM,MAAMtI,QAAQsI,IAAI;4BAClBC,UAAUvI,QAAQuI,QAAQ;4BAC1BC,UAAUxI,QAAQ8B,IAAI,CAACe,MAAM;4BAC7B4F,SAASzI,QAAQyI,OAAO;4BACxBC,cAAc1I,QAAQ2I,OAAO;4BAC7BC,kBAAkB5I,QAAQ6I,WAAW;;;;gBAY1CnJ,SAAS,uBACR;;sCACE,KAAC/C;sCACD,MAACJ;4BAAQ8K,OAAM;;8CACb,KAACtK;oCAAWgI,SAAQ;oCAAQE,OAAM;8CAC/B,+DACC,0BACA,GAAG5G,qBAAqBW,cAAc,GAAG,aAAa,CAAC,GACvD,4DACA;;8CAEJ,KAACpC;oCAAM0K,WAAU;oCAAMT,SAAS;8CAC9B,cAAA,KAACpK;wCACCmJ,MAAK;wCACLb,SAAQ;wCACRc,UAAUvE;wCACVwH,yBACE,KAAC7M;4CAAQoH,MAAMvH,oBAAoBuH,IAAI;4CAAEuC,MAAM;;wCAEjDR,SAAS,IAAM,KAAKxD;kDAEnBN,cAAc,aAAa;;;gCAG/BE,6BACC,KAAChF;oCAAM4K,UAAS;8CAAW5F;qCACzB;gCACHE,wBACC,MAAC9E;oCAAMiK,SAAS;;sDAMd,KAACrK;4CAAM4K,UAAS;sDACb,yDACC,wDACA,wDACA,0DACA,0DACA,0DACA,0DACA;;wCAEH1F,QAAQM,SAAS,iBAChB,KAACxF;4CAAM4K,UAAS;sDACb,CAAC,UAAU,EAAE/I,qBAAqBW,cAAc,GAAG,CAAC,CAAC,GACpD,iDACA,GAAG0C,QAAQwC,IAAI,CAAClF,cAAc,GAAG,iBAAiB,CAAC,GACnD,uDACA;6CAEF;wCACH0C,QAAQ+B,OAAO,iBACd,KAACjH;4CAAM4K,UAAS;sDACb,GAAG1F,QAAQ+B,OAAO,CAACzE,cAAc,GAAG,QAAQ,CAAC,GAC5C,GAAG0C,QAAQwC,IAAI,CAAClF,cAAc,GAAG,UAAU,CAAC,GAC5C,qDACA,oDACA,uDACA;6CAEF;sDACJ,KAAC7C;4CACC2F,MAAMJ,QAAQI,IAAI;4CAClB0C,SAASuB;4CACTgC,UAAU;4CACVK,aAAY;4CACZC,mBAAkB;;sDAEpB,MAACzL;4CAAM0K,WAAU;4CAAMT,SAAS;;8DAC9B,KAACnK;oDACCkJ,MAAK;oDACLb,SAAQ;oDACRG,OAAO,GAAGxD,QAAQwC,IAAI,CAAClF,cAAc,GAAG,0BAA0B,CAAC;;8DAErE,KAACtC;oDACCkJ,MAAK;oDACLb,SAAQ;oDACRG,OAAO,GAAGxD,QAAQI,IAAI,CAACe,MAAM,CAAC7D,cAAc,GAAG,MAAM,CAAC;;;;;qCAI1D;;;;qBAGN;;;;AAIZ;AACAC,wBAAwB8J,WAAW,GAAG;AAEtC,eAAe9J,wBAAuB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/campaign-conversions-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CAPTURED_BY_HOST_FIELD, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiEyeOutline, mdiMapMarkerOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport {\n ListRowActions,\n ListTable,\n listActionsColumn,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Figure, Section } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport {\n Alert,\n Button,\n Chip,\n Divider,\n Stack,\n ToggleButton,\n ToggleButtonGroup,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n getCountFromServer,\n getDocs,\n query,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n useFirestore,\n useOrgDataScope,\n usePagedCollection,\n} from '@aglyn/tenant-feature-instance'\n/*\n * The MODULE, not the barrel, for the pure query helpers — a spec that mocks\n * `@aglyn/tenant-feature-instance` wholesale to stage its Firestore hooks\n * would otherwise lose them, and neither is a hook.\n */\nimport {\n ceilingedWindow,\n collectionCeiling,\n collectionPage,\n} from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n CAMPAIGN_CONVERSION_KIND_COPY,\n campaignConversionsCoverage,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../model/campaign-conversions'\n\n/**\n * WHAT THE CAMPAIGNS CAUSED, from the conversions' end.\n *\n * A campaign's own report says what that campaign caused. This is the list\n * the report cannot be: every credited conversion on the site, the ones\n * credited to a tagged web link rather than to a campaign document, and — the\n * half a report of successes can never show — how many were credited to\n * nothing at all.\n *\n * ## ONE KIND AT A TIME, and that is the whole design\n *\n * A form submission by a new person writes a submission record, a contact and\n * a lead. Three true statements about one visit, and a screen that showed\n * them together would invite the reader to add them and treble every figure.\n * So the kind is a MODE rather than a column: the query carries\n * `where('kind','==', …)`, only one kind's rows are ever on screen, and there\n * is no view in which two kinds could be totalled because there is no view in\n * which two kinds are both present.\n *\n * ## THE TWO CHANNELS ARE TWO LISTS, and never one\n *\n * An email touch names a campaign document — a real entity with a report to\n * open. A web touch names `utm_` text a marketer typed into a URL: no\n * document, no id, no bound on how many distinct values exist. Merging them\n * would put a linkable record and an unlinkable label in one column and imply\n * they are the same kind of thing, and grouping the web side into a rollup\n * would build a map anybody who can vary a query string can grow. So there\n * are two tables, the web one is read as records, and neither is summed into\n * the other.\n *\n * ## The read cost\n *\n * Two paged listeners bounded by the page size, and two aggregation counts.\n * Nothing scans a collection, no row triggers a read of its own, and the\n * landing-page join — the only expensive thing here — is behind a button and\n * says what it will cost before it runs.\n */\n\n/**\n * How many attribution records the landing-page join reads.\n *\n * A CEILING rather than a page size: grouping is over a window and a window\n * sliced ten at a time cannot be grouped at all. Kept low because this is the\n * one read on the surface that is measured in hundreds — see the button that\n * asks for it.\n */\nconst LANDING_PAGE_CEILING = 100\n\n/** Firestore's cap on the values in one `in` filter. */\nconst ID_CHUNK = 30\n\nconst conversionsDocsHelp = pluginDocsHelp('emailCampaigns', {\n anchor: '#the-campaign-report',\n excerpt:\n 'A conversion is credited to the last campaign whose link the visitor ' +\n 'followed. The four kinds count different things about the same visits ' +\n 'and are never added together.',\n})\n\n/** The stored record plus the document name the reader listed it under. */\ntype ConversionRow = CampaignConversionRecord & { $id: string }\n\nconst day = (ms: unknown): string => {\n const value = Number(ms ?? 0)\n return Number.isFinite(value) && value > 0\n ? new Date(value).toLocaleString()\n : '—'\n}\n\nexport interface CampaignConversionsCardProps {\n hostId: string\n /** The marketing hub URL, for the link to each credited campaign. */\n basePath: string\n /**\n * One campaign's conversions rather than the whole site's.\n *\n * Set from `/marketing/conversions/{campaignId}`. It narrows the EMAIL list\n * only: a web-channel record carries no campaign id, so there is nothing\n * for it to be narrowed by — and the uncredited figure is withheld\n * entirely, because a conversion credited to nobody belongs to no campaign\n * and attributing the site's whole direct traffic to whichever campaign the\n * reader happens to be looking at is the exact inference this join refuses\n * to make.\n */\n campaignId?: string\n}\n\nexport function CampaignConversionsCard(props: CampaignConversionsCardProps) {\n const { hostId, basePath, campaignId } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { scope: dataScope } = useOrgDataScope({ hostId })\n const [kind, setKind] = useState<CampaignConversionKind>('form')\n\n const attributions = useCallback(\n () => collection(firestore, 'hosts', hostId, 'campaignAttributions'),\n [firestore, hostId],\n )\n\n /*==========================================\n * THE TWO LISTS.\n *\n * Both walk the collection in DOCUMENT-NAME order through `collectionPage`,\n * which is the console's one ordering decision: `orderBy` on a field\n * matches only documents that HAVE it, so ordering on a date would hide\n * rows rather than merely mis-order them, and an unordered `limit()` is a\n * pseudo-random sample whose gaps leave nothing to notice. The walk is\n * total — every row is reachable by paging — and it is deliberately NOT\n * newest-first, which the note under each table says out loud.\n *\n * `campaignId` narrows the email list when the reader arrived from one\n * campaign. It is never applied to the web list: a `utm_` label is not a\n * campaign id and a query pretending otherwise would return nothing while\n * reading as \"this campaign caused no web conversions\".\n *=========================================*/\n const emailList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n collectionPage(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', kind),\n where('campaignId', '==', campaignId),\n )\n : query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'email'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n const webList = usePagedCollection<ConversionRow>(\n (pageLimit) =>\n campaignId\n ? null\n : collectionPage(\n query(\n attributions(),\n where('kind', '==', kind),\n where('channel', '==', 'web'),\n ),\n pageLimit,\n ),\n [firestore, hostId, kind, campaignId],\n { idField: '$id' },\n )\n\n /*==========================================\n * THE UNCREDITED HALF.\n *\n * Two aggregation counts, and they are on the page by DEFAULT rather than\n * behind a button, because the tables above them are a list of the\n * successes: showing only those renders \"we credited nine of these\" as\n * \"nine of these happened\", which is the conclusion this surface exists to\n * stop a reader drawing. An aggregation is billed per thousand index\n * entries rather than per document, so the honest figure costs about as\n * much as one row.\n *\n * Withheld entirely when either count fails. Defaulting the total to the\n * attributed figure would render every conversion as attributed, which is\n * the most flattering wrong answer available here.\n *=========================================*/\n /*\n * A PATH STRING and a BOOLEAN, not an object.\n *\n * Both are effect dependencies, and an object rebuilt on every render\n * reopens the effect on every render — which here means paying for two\n * aggregation counts per render rather than per kind. Primitives compare by\n * value, so the reads happen when the question changes and not when React\n * re-runs the component.\n */\n const totalCollectionPath = useMemo((): string | null => {\n switch (kind) {\n case 'form':\n return `hosts/${hostId}/formSubmissions`\n case 'lead':\n /*\n * The ORG's leads (AGL-3275), narrowed below to the ones this site\n * CAPTURED. A lead is one document per person, stamped in\n * `capturedByHostIds` with every site that met them, and its\n * attribution is written by the site that captured it — so this\n * site's captures are exactly the population its lead attributions\n * are drawn from, and the total does not cross hosts.\n *\n * Not `visibleTo`: that also names the sites a consent group shares a\n * person with, and would count leads a sibling brand captured that\n * could never have been credited here. The price is that a\n * collaborator scoped to one site cannot run the count — the rules\n * prove a lead read by `visibleTo` — and is shown the withheld split,\n * as they already are for contacts.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/leads` : null\n case 'booking':\n return `hosts/${hostId}/bookings`\n case 'contact':\n /*\n * ORG-SCOPED, and the coverage model is told so below. A contact is\n * shared across every site in the org while an attribution belongs to\n * one host, so this total counts contacts created on another site\n * that could never have been credited here.\n */\n return dataScope ? `${dataScope[0]}/${dataScope[1]}/contacts` : null\n default:\n return null\n }\n }, [kind, hostId, dataScope])\n\n /** The total counts only the org records THIS site captured. */\n const totalCapturedHere = kind === 'lead'\n\n /** The kind's records live outside this host, so the total over-counts. */\n const totalCrossesHosts = kind === 'contact'\n\n const [attributedCount, setAttributedCount] = useState<number | null>(null)\n const [totalCount, setTotalCount] = useState<number | null>(null)\n\n useEffect(() => {\n // A campaign-scoped view has no uncredited figure to compute — see the\n // prop's docblock — so it does not pay for one either.\n if (campaignId || !totalCollectionPath) {\n setAttributedCount(null)\n setTotalCount(null)\n return\n }\n let active = true\n setAttributedCount(null)\n setTotalCount(null)\n void getCountFromServer(query(attributions(), where('kind', '==', kind)))\n .then((snapshot) => {\n if (active) setAttributedCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n const segments = totalCollectionPath.split('/')\n const records = collection(firestore, segments[0], ...segments.slice(1))\n void getCountFromServer(\n totalCapturedHere\n ? query(\n records,\n where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId),\n )\n : records,\n )\n .then((snapshot) => {\n if (active) setTotalCount(snapshot.data().count)\n })\n .catch(() => {\n // Left null, which withholds the split. See the block comment.\n })\n return () => {\n active = false\n }\n }, [\n attributions,\n firestore,\n kind,\n campaignId,\n totalCollectionPath,\n totalCapturedHere,\n hostId,\n ])\n\n const coverage = campaignConversionsCoverage({\n kind,\n attributed: attributedCount,\n total: totalCount,\n crossHostTotal: totalCrossesHosts,\n })\n\n /*==========================================\n * LANDING PAGES — a join, and an EXPENSIVE one, so it is asked for.\n *\n * The attribution record carries `refId`; a form submission carries the\n * `path` it was submitted from. Joining the two is the only landing-page\n * answer the stored data supports, and the notice below says plainly what\n * it is not: `path` is where the FORM was, not where the campaign link\n * landed, so a visitor who arrived on one page and submitted on another is\n * grouped under the second.\n *\n * Two reads deep and both bounded: a ceiling over the attributions, then\n * their submissions by document name in chunks of thirty. `getDocs` rather\n * than a listener — a grouping nobody is watching does not need to be kept\n * live, and a listener would go on costing after the reader looked away.\n *=========================================*/\n const [landingBusy, setLandingBusy] = useState(false)\n const [landingError, setLandingError] = useState<string | null>(null)\n const [landing, setLanding] = useState<{\n rows: { $id: string; path: string; conversions: number }[]\n /** Attributions read, which is the population the grouping describes. */\n read: number\n /** The ceiling bit: there are more credited submissions than were read. */\n truncated: boolean\n /** Of `read`, how many named a submission that could not be found. */\n missing: number\n } | null>(null)\n\n // A grouping computed for one kind describes that kind. Switching the mode\n // leaves it on screen labelled as something it is not.\n useEffect(() => {\n setLanding(null)\n setLandingError(null)\n }, [kind, campaignId])\n\n const groupByLandingPage = useCallback(async () => {\n if (landingBusy) return\n setLandingBusy(true)\n setLandingError(null)\n try {\n const credited = await getDocs(\n collectionCeiling(\n campaignId\n ? query(\n attributions(),\n where('kind', '==', 'form'),\n where('campaignId', '==', campaignId),\n )\n : query(attributions(), where('kind', '==', 'form')),\n LANDING_PAGE_CEILING,\n ),\n )\n const { rows: credits, truncated } = ceilingedWindow(\n credited.docs.map((entry) => entry.data() as CampaignConversionRecord),\n LANDING_PAGE_CEILING,\n )\n const refIds = Array.from(\n new Set(\n credits\n .map((record) => String(record.refId ?? ''))\n .filter((id) => id.length > 0),\n ),\n )\n\n const submissions = collection(\n firestore,\n 'hosts',\n hostId,\n 'formSubmissions',\n )\n const paths = new Map<string, string>()\n for (let index = 0; index < refIds.length; index += ID_CHUNK) {\n const chunk = refIds.slice(index, index + ID_CHUNK)\n const found = await getDocs(\n query(submissions, where(documentId(), 'in', chunk)),\n )\n found.docs.forEach((entry) => {\n const path = String((entry.data() as any)?.path ?? '').trim()\n if (path) paths.set(entry.id, path)\n })\n }\n\n const counts = new Map<string, number>()\n let missing = 0\n refIds.forEach((refId) => {\n const path = paths.get(refId)\n if (!path) {\n /*\n * The submission was deleted, or it carries no path — an older one,\n * or a form posted by an integration that sent none. Counted and\n * REPORTED rather than filed under a placeholder page: a bar\n * labelled \"/\" that is really \"we do not know\" is the kind of row a\n * reader acts on.\n */\n missing += 1\n return\n }\n counts.set(path, (counts.get(path) ?? 0) + 1)\n })\n\n setLanding({\n rows: Array.from(counts.entries())\n .map(([path, conversions]) => ({ $id: path, path, conversions }))\n .sort(\n (a, b) =>\n b.conversions - a.conversions || a.path.localeCompare(b.path),\n ),\n read: refIds.length,\n truncated,\n missing,\n })\n } catch (error) {\n console.error(error)\n setLandingError('The landing pages could not be read')\n } finally {\n setLandingBusy(false)\n }\n }, [attributions, campaignId, firestore, hostId, landingBusy])\n\n const campaignHref = (row: ConversionRow) =>\n row.campaignId ? `${basePath}/campaigns/${row.campaignId}` : undefined\n\n const columns = useMemo(\n () => [\n {\n field: 'refId',\n headerName: 'Record',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }: any) => (\n <Stack sx={{ minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {String(row.refId ?? row.$id)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\" noWrap>\n {CAMPAIGN_CONVERSION_KIND_COPY[kind].label}\n </Typography>\n </Stack>\n ),\n },\n {\n field: 'campaign',\n headerName: 'Credited to',\n flex: 1,\n minWidth: 180,\n renderCell: ({ row }: any) => {\n const label = campaignTouchLabel(row) || '—'\n const href = campaignHref(row)\n return href ? (\n <AppLink\n href={href}\n onClick={(event: { stopPropagation: () => void }) =>\n event.stopPropagation()\n }\n >\n {label}\n </AppLink>\n ) : (\n <Typography variant=\"body2\" noWrap>\n {label}\n </Typography>\n )\n },\n },\n {\n field: 'convertedAtMs',\n headerName: 'Converted',\n width: 190,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {day(row.convertedAtMs)}\n </Typography>\n ),\n },\n listActionsColumn((row: any) => (\n <ListRowActions\n label={String(row.refId ?? row.$id)}\n items={[\n {\n key: 'campaign',\n label: 'Open campaign',\n icon: <MdiIcon path={mdiEyeOutline.path} size={0.8} />,\n href: campaignHref(row),\n disabled: !campaignHref(row),\n /*\n * Shown disabled with the reason rather than hidden. A control\n * that vanishes and one that does not apply look identical from\n * the outside, and only one of them is honest — there is no\n * campaign document behind a `utm_` label to open.\n */\n disabledReason:\n 'This came from a tagged link rather than a campaign email, ' +\n 'so there is no campaign to open',\n },\n ]}\n />\n )),\n ],\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [kind, basePath],\n )\n\n const landingColumns = useMemo(\n () => [\n {\n field: 'path',\n headerName: 'Page the form was on',\n flex: 1,\n minWidth: 240,\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\" noWrap>\n {row.path}\n </Typography>\n ),\n },\n {\n field: 'conversions',\n headerName: 'Credited submissions',\n width: 190,\n align: 'right',\n headerAlign: 'right',\n renderCell: ({ row }: any) => (\n <Typography variant=\"body2\">\n {Number(row.conversions).toLocaleString()}\n </Typography>\n ),\n },\n ],\n [],\n )\n\n const kindCopy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n\n return (\n <CardDisplay\n header=\"Conversions\"\n subheader={\n campaignId\n ? 'Credited to this campaign'\n : 'What the campaigns caused, one kind at a time'\n }\n help={conversionsDocsHelp}\n HeaderProps={{\n action: campaignId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={`${basePath}/conversions`}\n size=\"small\"\n color=\"primary\"\n >\n {'All conversions'}\n </Button>\n ) : undefined,\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE KIND IS A MODE, NOT A COLUMN.\n\n One form submission by a new person writes a submission, a contact\n and a lead, so any view holding two kinds at once invites a total\n that counts one visit twice. Only one kind's rows can be on screen,\n which is what makes the addition unavailable rather than merely\n discouraged.\n */}\n <Stack spacing={1}>\n <ToggleButtonGroup\n exclusive\n size=\"small\"\n color=\"primary\"\n value={kind}\n onChange={(_event, next) => {\n if (next) setKind(next as CampaignConversionKind)\n }}\n aria-label=\"Conversion kind\"\n >\n {CAMPAIGN_CONVERSION_KINDS.map((one) => (\n <ToggleButton key={one} value={one}>\n {CAMPAIGN_CONVERSION_KIND_COPY[one].label}\n </ToggleButton>\n ))}\n </ToggleButtonGroup>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One kind at a time. The four count different things about the ' +\n 'same visits — one person filling in one form appears as a ' +\n 'submission, a contact and a lead — so they are never shown ' +\n 'together and never added.'}\n </Typography>\n </Stack>\n\n <Divider />\n\n {/*==========================================\n * WHAT IS NOT CREDITED, beside what is.\n *\n * The tables below list the successes. Without this strip the reader\n * has no way to see how much of their site's activity the campaigns\n * account for, and every campaign reads as more effective than it is.\n *=========================================*/}\n {campaignId ? (\n <Alert severity=\"info\">\n {'This is one campaign’s share. Conversions credited to no ' +\n 'campaign belong to no campaign, so they are not counted here ' +\n '— open all conversions for the site-wide figure.'}\n </Alert>\n ) : coverage ? (\n <Section title={`${kindCopy.label} on this site`}>\n <Stack\n direction=\"row\"\n spacing={4}\n useFlexGap\n sx={{ flexWrap: 'wrap' }}\n >\n <Figure\n label=\"Credited to a campaign\"\n value={coverage.attributed}\n note=\"from a campaign email or a tagged link\"\n />\n <Figure\n label=\"Not credited\"\n value={coverage.unattributed}\n note=\"arrived directly, or predate attribution\"\n />\n <Figure\n label={`${kindCopy.label} in total`}\n value={coverage.total}\n note={\n coverage.exact ? 'on this site' : 'on this site, all time'\n }\n />\n </Stack>\n {coverage.caveats.map((caveat) => (\n <Alert key={caveat.id} severity=\"info\">\n {caveat.message}\n </Alert>\n ))}\n </Section>\n ) : (\n <Alert severity=\"info\">\n {'How many of these were credited to no campaign could not be ' +\n 'counted, so it is not shown. The lists below are the credited ' +\n 'ones only and are not a count of everything that happened.'}\n </Alert>\n )}\n\n <Divider />\n\n <Section title=\"From campaign emails\">\n <ListTable\n rows={emailList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would order\n // only that page.\n disableColumnSorting\n onOpen={(_id, row) => {\n const href = campaignHref(row as ConversionRow)\n if (href) router.push(href)\n }}\n noRowsLabel=\"Nothing credited to a campaign email\"\n noRowsDescription={\n 'A conversion is credited when the visitor followed a link in ' +\n 'a campaign email before converting.'\n }\n />\n <ListPagination\n page={emailList.page}\n pageSize={emailList.pageSize}\n rowCount={emailList.rows.length}\n hasMore={emailList.hasMore}\n onPageChange={emailList.setPage}\n onPageSizeChange={emailList.setPageSize}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Listed in the order they are stored, which is not the order ' +\n 'they happened in — every row is reachable by paging, but the ' +\n 'first page is not the most recent conversions.'}\n </Typography>\n </Section>\n\n {/*==========================================\n * THE WEB CHANNEL — its own list, and no rollup.\n *\n * A `utm_` label is text somebody typed into a URL. There is no\n * document behind it, no id, and no bound on how many distinct\n * values exist, so it gets no rollup under a campaign and no summary\n * keyed on the label: a map anybody who can vary a query string can\n * grow is a map that grows. The records stand on their own and this\n * lists them.\n *\n * Absent entirely on a campaign-scoped view, because these belong to\n * no campaign.\n *=========================================*/}\n {campaignId ? null : (\n <Section title=\"From tagged web links\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Credited to the utm_ label on the link the visitor followed. ' +\n 'These are listed one by one rather than grouped: the label ' +\n 'is free text with no campaign behind it, so there is ' +\n 'nothing to open and no fixed set to total.'}\n </Typography>\n <ListTable\n rows={webList.rows}\n columns={columns as any}\n hideFooter\n // One page of a walk in stored order; a header sort would\n // order only that page.\n disableColumnSorting\n noRowsLabel=\"Nothing credited to a tagged link\"\n noRowsDescription={\n 'A conversion lands here when the visitor arrived on a URL ' +\n 'carrying utm_ parameters.'\n }\n />\n <ListPagination\n page={webList.page}\n pageSize={webList.pageSize}\n rowCount={webList.rows.length}\n hasMore={webList.hasMore}\n onPageChange={webList.setPage}\n onPageSizeChange={webList.setPageSize}\n />\n </Section>\n )}\n\n {/*==========================================\n * LANDING PAGES, on request.\n *\n * `refId` on the record, `path` on the submission: joining those is\n * the landing-page surface, and it is hundreds of reads, so it is a\n * button rather than something every reader of this page pays for.\n *=========================================*/}\n {kind === 'form' ? (\n <>\n <Divider />\n <Section title=\"Landing pages\">\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Groups credited form submissions by the page the form was ' +\n 'on. This reads up to ' +\n `${LANDING_PAGE_CEILING.toLocaleString()} attribution ` +\n 'records and their submissions, so it runs when you ask ' +\n 'for it rather than on every visit.'}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={landingBusy}\n startIcon={\n <MdiIcon path={mdiMapMarkerOutline.path} size={0.8} />\n }\n onClick={() => void groupByLandingPage()}\n >\n {landingBusy ? 'Reading…' : 'Group by landing page'}\n </Button>\n </Stack>\n {landingError ? (\n <Alert severity=\"warning\">{landingError}</Alert>\n ) : null}\n {landing ? (\n <Stack spacing={1}>\n {/*\n WHAT THIS CANNOT ANSWER, above the table rather than under\n it. Every line is a question a reader will otherwise\n assume it answered.\n */}\n <Alert severity=\"info\">\n {'This is the page each form sat on, not the page the ' +\n 'campaign link landed on — a visitor who arrived on ' +\n 'one page and submitted on another is counted under ' +\n 'the second. There is no conversion RATE here either: ' +\n 'the record carries no count of visits to a page from ' +\n 'a campaign, so there is no denominator to divide by. ' +\n 'Only form submissions carry a page at all, so leads, ' +\n 'contacts and bookings have no landing page.'}\n </Alert>\n {landing.truncated ? (\n <Alert severity=\"warning\">\n {`More than ${LANDING_PAGE_CEILING.toLocaleString()} ` +\n 'credited submissions exist. This groups the ' +\n `${landing.read.toLocaleString()} that were read, ` +\n 'which are not the most recent — they are the ones ' +\n 'that came first in storage order.'}\n </Alert>\n ) : null}\n {landing.missing ? (\n <Alert severity=\"info\">\n {`${landing.missing.toLocaleString()} of the ` +\n `${landing.read.toLocaleString()} credited ` +\n 'submissions are not grouped: the submission has ' +\n 'been deleted, or it recorded no page. They are ' +\n 'reported here rather than filed under a page they ' +\n 'may not have come from.'}\n </Alert>\n ) : null}\n <ListTable\n rows={landing.rows}\n columns={landingColumns as any}\n hideFooter\n noRowsLabel=\"No pages to group\"\n noRowsDescription=\"None of the credited submissions recorded a page.\"\n />\n <Stack direction=\"row\" spacing={1}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.read.toLocaleString()} credited submissions read`}\n />\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`${landing.rows.length.toLocaleString()} pages`}\n />\n </Stack>\n </Stack>\n ) : null}\n </Section>\n </>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nCampaignConversionsCard.displayName = 'CampaignConversionsCard'\n\nexport default CampaignConversionsCard\n"],"names":["CAPTURED_BY_HOST_FIELD","pluginDocsHelp","mdiEyeOutline","mdiMapMarkerOutline","AppLink","CardDisplay","MdiIcon","ListRowActions","ListTable","listActionsColumn","ListPagination","Figure","Section","Alert","Button","Chip","Divider","Stack","ToggleButton","ToggleButtonGroup","Typography","collection","documentId","getCountFromServer","getDocs","query","where","useCallback","useEffect","useMemo","useState","useRouter","useFirestore","useOrgDataScope","usePagedCollection","ceilingedWindow","collectionCeiling","collectionPage","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","campaignConversionsCoverage","campaignTouchLabel","LANDING_PAGE_CEILING","ID_CHUNK","conversionsDocsHelp","anchor","excerpt","day","ms","value","Number","isFinite","Date","toLocaleString","CampaignConversionsCard","props","hostId","basePath","campaignId","firestore","router","scope","dataScope","kind","setKind","attributions","emailList","pageLimit","idField","webList","totalCollectionPath","totalCapturedHere","totalCrossesHosts","attributedCount","setAttributedCount","totalCount","setTotalCount","active","then","snapshot","data","count","catch","segments","split","records","slice","coverage","attributed","total","crossHostTotal","landingBusy","setLandingBusy","landingError","setLandingError","landing","setLanding","groupByLandingPage","credited","rows","credits","truncated","docs","map","entry","refIds","Array","from","Set","record","String","refId","filter","id","length","submissions","paths","Map","index","chunk","found","forEach","path","trim","set","counts","missing","get","entries","conversions","$id","sort","a","b","localeCompare","read","error","console","campaignHref","row","undefined","columns","field","headerName","flex","minWidth","renderCell","sx","variant","noWrap","color","label","href","onClick","event","stopPropagation","width","convertedAtMs","items","key","icon","size","disabled","disabledReason","landingColumns","align","headerAlign","kindCopy","header","subheader","help","HeaderProps","action","component","componentVariant","nativeButton","contentGutterX","contentGutterY","spacing","exclusive","onChange","_event","next","aria-label","one","severity","title","direction","useFlexGap","flexWrap","note","unattributed","exact","caveats","caveat","message","hideFooter","disableColumnSorting","onOpen","_id","push","noRowsLabel","noRowsDescription","page","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","startIcon","displayName"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
18
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
19
|
+
import { pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
19
20
|
import { mdiDeleteOutline, mdiEyeOutline, mdiPaletteOutline, mdiPencilOutline } from "@aglyn/shared-data-mdi";
|
|
20
21
|
import { AppLink, CardDisplay, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
|
|
21
22
|
import { Figure, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
|
|
@@ -47,7 +48,7 @@ import CampaignReportCard from "./campaign-report-card.js";
|
|
|
47
48
|
import { campaignContainerDoc, campaignSendsCollection, campaignSendsQuery } from "./campaign-queries.js";
|
|
48
49
|
import { orgSiteHubPath, orgSiteName, orgSiteOptions, topicCatalogHostId, useMarketingOrgId, useMarketingOrgMount } from "./marketing-org-mount.js";
|
|
49
50
|
import { useCampaignManageApi } from "./use-campaign-send-api.js";
|
|
50
|
-
import {
|
|
51
|
+
import { siteRecordRouteContext, useRecordRouteContext } from "./record-route-context.js";
|
|
51
52
|
import { useCampaignTopicOptions } from "./use-campaign-topic-options.js";
|
|
52
53
|
/** How many of a campaign's emails the detail page enumerates. */ const CAMPAIGN_EMAIL_CEILING = 50;
|
|
53
54
|
const detailDocsHelp = pluginDocsHelp('emailCampaigns', {
|
|
@@ -137,9 +138,9 @@ const SEND_FILTER_HEADERS = {
|
|
|
137
138
|
var _placedSites_;
|
|
138
139
|
const { hostId, campaignId, basePath } = props;
|
|
139
140
|
const firestore = useFirestore();
|
|
140
|
-
// The
|
|
141
|
-
// each message's report and the template it was built from.
|
|
142
|
-
const
|
|
141
|
+
// The scope this page asks the owner of two records it links to but does
|
|
142
|
+
// not own: each message's report and the template it was built from.
|
|
143
|
+
const routeContext = useRecordRouteContext();
|
|
143
144
|
const router = useRouter();
|
|
144
145
|
const orgMount = useMarketingOrgMount();
|
|
145
146
|
const { orgId } = useMarketingOrgId(hostId);
|
|
@@ -366,7 +367,7 @@ const SEND_FILTER_HEADERS = {
|
|
|
366
367
|
*/ const unfinished = rollup.scheduled + rollup.sending;
|
|
367
368
|
const agreed = await confirm({
|
|
368
369
|
title: 'Delete this campaign?',
|
|
369
|
-
description: (sends.length ? `The ${sends.length.toLocaleString()} ` + `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` + 'each keeps its own report and its unsubscribe links, and they ' + 'appear in the campaigns list as single sends. ' : 'It holds no emails. ') + (unfinished ? `${unfinished.toLocaleString()} of them ${unfinished === 1 ? 'is' : 'are'} still going out or still due, and deleting the campaign does ` + 'not stop that — the sender picks each one up from its own ' + 'record. Stop a send from its own page. ' : '') + 'Any
|
|
370
|
+
description: (sends.length ? `The ${sends.length.toLocaleString()} ` + `${sends.length === 1 ? 'email' : 'emails'} in it are kept — ` + 'each keeps its own report and its unsubscribe links, and they ' + 'appear in the campaigns list as single sends. ' : 'It holds no emails. ') + (unfinished ? `${unfinished.toLocaleString()} of them ${unfinished === 1 ? 'is' : 'are'} still going out or still due, and deleting the campaign does ` + 'not stop that — the sender picks each one up from its own ' + 'record. Stop a send from its own page. ' : '') + 'Any pages, forms and contacts assigned to it come out of it and ' + 'are otherwise untouched. Only the campaign itself goes.',
|
|
370
371
|
confirmationText: 'Delete campaign'
|
|
371
372
|
}).then(()=>true).catch(()=>false);
|
|
372
373
|
if (!agreed) return;
|
|
@@ -453,19 +454,18 @@ const SEND_FILTER_HEADERS = {
|
|
|
453
454
|
*=========================================*/ /*
|
|
454
455
|
* A message opens on the Emails page: the site's under a site, and over the
|
|
455
456
|
* org the organization's own, which lists every site's messages.
|
|
456
|
-
*/ const
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return hub ? `${hub}/templates` : null;
|
|
457
|
+
*/ const sendHref = (send)=>routeContext && pluginRecordHref('emailMessage', routeContext, send.$id) || undefined;
|
|
458
|
+
/** A template is a site's design, so it opens on the site the send used. */ const templateHref = (send, templateScreenId)=>{
|
|
459
|
+
const siteContext = siteRecordRouteContext(routeContext, orgMount, send.hostId);
|
|
460
|
+
return siteContext ? pluginRecordHref('emailTemplate', siteContext, templateScreenId) : null;
|
|
461
461
|
};
|
|
462
462
|
/**
|
|
463
463
|
* What one of this campaign's emails can be opened into.
|
|
464
464
|
*
|
|
465
465
|
* The same two the Emails hub offers, less the campaign — this page IS the
|
|
466
|
-
* campaign. Both destinations are records the Emails
|
|
467
|
-
* are
|
|
468
|
-
* own `basePath`.
|
|
466
|
+
* campaign. Both destinations are records the Emails page owns, so they
|
|
467
|
+
* are asked of its record routes ({@link useRecordRouteContext}) rather than
|
|
468
|
+
* built from this surface's own `basePath`.
|
|
469
469
|
*
|
|
470
470
|
* A message composed inline was built from no template, so that entry is
|
|
471
471
|
* shown DISABLED with the reason rather than hidden: an absent control and
|
|
@@ -473,7 +473,7 @@ const SEND_FILTER_HEADERS = {
|
|
|
473
473
|
*/ const sendActions = (send)=>{
|
|
474
474
|
var _send_templateScreenId;
|
|
475
475
|
const templateScreenId = String((_send_templateScreenId = send.templateScreenId) != null ? _send_templateScreenId : '');
|
|
476
|
-
const templates =
|
|
476
|
+
const templates = templateScreenId ? templateHref(send, templateScreenId) : null;
|
|
477
477
|
return [
|
|
478
478
|
{
|
|
479
479
|
key: 'details',
|
|
@@ -493,8 +493,8 @@ const SEND_FILTER_HEADERS = {
|
|
|
493
493
|
path: mdiPaletteOutline.path,
|
|
494
494
|
size: 0.8
|
|
495
495
|
}),
|
|
496
|
-
href:
|
|
497
|
-
disabled: !
|
|
496
|
+
href: templates != null ? templates : undefined,
|
|
497
|
+
disabled: !templates,
|
|
498
498
|
disabledReason: templateScreenId ? 'This site’s console URL has not resolved yet' : 'This message was not built from a template'
|
|
499
499
|
}
|
|
500
500
|
];
|