@aglyn/plugins-marketing 1.0.0-beta.222 → 1.0.0-beta.223

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.
Files changed (47) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-detail-card.js +17 -3
  3. package/src/lib/components/campaign-detail-card.js.map +1 -1
  4. package/src/lib/components/campaign-edit-drawer.d.ts +6 -0
  5. package/src/lib/components/campaign-edit-drawer.js +13 -2
  6. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  7. package/src/lib/components/campaign-queries.d.ts +5 -0
  8. package/src/lib/components/campaign-queries.js +7 -0
  9. package/src/lib/components/campaign-queries.js.map +1 -1
  10. package/src/lib/components/campaign-reach-sections.d.ts +28 -0
  11. package/src/lib/components/campaign-reach-sections.js +95 -4
  12. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  13. package/src/lib/components/conversion-attribution.component.d.ts +18 -0
  14. package/src/lib/components/conversion-attribution.component.js +177 -39
  15. package/src/lib/components/conversion-attribution.component.js.map +1 -1
  16. package/src/lib/components/record-attribution-widget.d.ts +5 -0
  17. package/src/lib/components/record-attribution-widget.js +6 -3
  18. package/src/lib/components/record-attribution-widget.js.map +1 -1
  19. package/src/lib/components/site-runtime.d.ts +5 -0
  20. package/src/lib/components/site-runtime.js +101 -2
  21. package/src/lib/components/site-runtime.js.map +1 -1
  22. package/src/lib/declarations.server.js +17 -1
  23. package/src/lib/declarations.server.js.map +1 -1
  24. package/src/lib/model/campaign-container.d.ts +28 -0
  25. package/src/lib/model/campaign-container.js +34 -0
  26. package/src/lib/model/campaign-container.js.map +1 -1
  27. package/src/lib/model/campaign-conversions.d.ts +47 -3
  28. package/src/lib/model/campaign-conversions.js +49 -4
  29. package/src/lib/model/campaign-conversions.js.map +1 -1
  30. package/src/lib/model/campaign-visits.d.ts +74 -0
  31. package/src/lib/model/campaign-visits.js +65 -0
  32. package/src/lib/model/campaign-visits.js.map +1 -0
  33. package/src/lib/server/campaign-conversion-attribution.d.ts +34 -3
  34. package/src/lib/server/campaign-conversion-attribution.js +126 -24
  35. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  36. package/src/lib/server/campaign-touch-targets.d.ts +61 -0
  37. package/src/lib/server/campaign-touch-targets.js +162 -0
  38. package/src/lib/server/campaign-touch-targets.js.map +1 -0
  39. package/src/lib/server/campaign-visit-beacon.d.ts +44 -0
  40. package/src/lib/server/campaign-visit-beacon.js +125 -0
  41. package/src/lib/server/campaign-visit-beacon.js.map +1 -0
  42. package/src/lib/server/conversion-creditor.js +109 -2
  43. package/src/lib/server/conversion-creditor.js.map +1 -1
  44. package/src/lib/server/email-events.js +24 -25
  45. package/src/lib/server/email-events.js.map +1 -1
  46. package/src/lib/server/site-page-enricher.js +18 -2
  47. package/src/lib/server/site-page-enricher.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/conversion-attribution.component.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\nimport {\n campaignConversionId,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../model/campaign-conversions'\n\n/**\n * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.\n *\n * The campaign's own page answers \"what did this campaign cause\". This\n * answers the question from the other end, which is the one a person standing\n * in front of a lead actually has: where did THIS come from. Without it the\n * join is invisible to everybody who is not already looking at a campaign.\n *\n * ## One keyed read, and only where the record is opened\n *\n * The record's id IS half the attribution's id — `{kind}:{refId}` — so this\n * is `getDoc` on a known path: no query, no index, nothing that can be\n * truncated, and no listener over a collection. Mounted on a DETAIL surface\n * (a reader dialog, a drawer) rather than in a table row, because a column\n * would multiply that one read by the page size and charge it to every reader\n * who opened the list for another reason entirely.\n *\n * ## Absence is a real answer and is rendered as one\n *\n * The join writes nothing at all for a conversion it cannot credit — no\n * `utm_source=direct` placeholder, no referrer inference, no fallback to\n * whichever campaign ran most recently. So a missing document is the ordinary\n * case and it means \"this person arrived without following a campaign link\",\n * which this says in words.\n *\n * What it must never render is a campaign with a zero beside it. \"Came from\n * no campaign\" and \"came from this campaign, which produced nothing\" are\n * opposite facts, and a component that reached for a default would print the\n * second when it means the first.\n *\n * ## The channels do not render the same, because they are not the same\n *\n * An EMAIL touch names a campaign document, so the label is a link to it. A\n * WEB touch names `utm_` text a marketer typed into a URL — there is no\n * document at the other end and no page to open — so it is rendered as text.\n * A link that resolves nowhere is worse than no link. A SEQUENCE touch\n * (AGL-3254) names the campaign a rep's sequence is in, a container with a\n * page, so it links like an email touch and says which door it came through.\n */\nexport interface ConversionAttributionProps {\n hostId: string\n /** Which identify moment this record is. */\n kind: CampaignConversionKind\n /** The record's own document id — the `refId` the writer credited. */\n refId: string\n /**\n * The marketing hub URL, when the caller already holds it.\n *\n * Resolved from the console route when it does not, which is the ordinary\n * case: the surfaces that render this belong to other plugins — the Inbox,\n * Contacts — and they are handed their OWN hub's path. Deriving one hub's\n * URL from another's by string surgery is the link that breaks silently\n * when a surface moves.\n */\n marketingBasePath?: string\n /** Rendered instead of the \"no campaign\" sentence, for a compact surface. */\n quiet?: boolean\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const firestore = useFirestore()\n /*\n * Free — the org slug and the subdomain are already in the URL this console\n * is on, so no host document is resolved to render a link. `null` until the\n * params settle, and the campaign is then NAMED but not linked, which is\n * the rule the web channel follows for the same reason: no link beats one\n * that resolves nowhere.\n */\n const resolvedHub = useMarketingHubPath()\n const marketingBasePath = props.marketingBasePath ?? resolvedHub\n\n /*\n * The id is BUILT rather than interpolated, so a ref carrying a slash or a\n * colon answers null here instead of naming a document in another\n * collection. A null builder is how this hook is told not to read at all.\n */\n const attributionId = campaignConversionId(kind, refId)\n const { data: record, status } = useFirestoreDoc<CampaignConversionRecord>(\n () =>\n attributionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n attributionId,\n )\n : null,\n [firestore, hostId, attributionId],\n )\n\n /*\n * An id that could not be built means the record's own id is unusable, so\n * the question was never asked. Drawing the \"not credited\" sentence here\n * would state a fact nothing checked — the one thing this component must\n * not do — so it draws nothing.\n */\n if (!attributionId) return null\n\n /*\n * Nothing while the read settles. Rendering \"no campaign\" first and\n * replacing it a tick later states the opposite of the truth for as long as\n * the reader's eye takes to reach it, and this sits inside a dialog that\n * opens with the read.\n */\n if (status === 'loading') return null\n\n if (!record) {\n if (quiet) return null\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says which\n fact this is: nobody was credited, and nothing was guessed.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not credited to a campaign — this arrived directly, or from a ' +\n 'link that carried no campaign. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most recent ' +\n 'one to run.'}\n </Typography>\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const touched = Number(record.touchedAtMs ?? 0)\n const isEmail = record.channel === 'email'\n const isSequence = record.channel === 'sequence'\n const campaignHref =\n (isEmail || isSequence) && marketingBasePath && record.campaignId\n ? `${marketingBasePath}/campaigns/${record.campaignId}`\n : undefined\n\n return (\n <Stack spacing={0.5}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The CHANNEL is on screen beside the label, because the two are read\n differently: one names a campaign whose report can be opened, the\n other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={isEmail || isSequence ? 'primary' : 'default'}\n variant={isEmail || isSequence ? 'filled' : 'outlined'}\n label={isEmail ? 'Campaign email' : isSequence ? 'Sequence' : 'Web link'}\n />\n {campaignHref ? (\n <AppLink href={campaignHref}>{label || record.campaignId}</AppLink>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot state\n is a credit they will use anyway, and the two things they need are\n which touch wins and how long one stays creditable. `model` and\n `windowDays` come off the RECORD, so a conversion credited under an\n older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(isSequence\n ? touched\n ? `Reached by a sequence email on ${new Date(touched).toLocaleString()}`\n : 'Reached by a sequence email'\n : touched\n ? `Followed the link on ${new Date(touched).toLocaleString()}`\n : 'Followed a campaign link') +\n (converted\n ? `, converted ${new Date(converted).toLocaleString()}`\n : '') +\n `. Credited ${\n record.model === 'last-click'\n ? 'to the last campaign whose link they clicked'\n : `under the ${String(record.model ?? 'recorded')} model`\n }, within ${Number(record.windowDays ?? 0) || 7} days of that click.`}\n </Typography>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","campaignConversionId","campaignTouchLabel","ConversionAttribution","props","record","hostId","kind","refId","quiet","firestore","resolvedHub","marketingBasePath","attributionId","data","status","spacing","variant","color","label","converted","Number","convertedAtMs","touched","touchedAtMs","isEmail","channel","isSequence","campaignHref","campaignId","undefined","direction","useFlexGap","sx","alignItems","flexWrap","size","href","fontWeight","Date","toLocaleString","model","String","windowDays","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/conversion-attribution.component.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 { AppLink } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { useMarketingHubPath } from './use-marketing-hub-path'\nimport { useMarketingOrgId } from './marketing-org-mount'\nimport { campaignContainerDoc, campaignSendDoc } from './campaign-queries'\nimport {\n campaignConversionId,\n campaignCreditRule,\n campaignTouchLabel,\n type CampaignConversionKind,\n type CampaignConversionRecord,\n} from '../model/campaign-conversions'\n\n/**\n * WHERE THIS RECORD CAME FROM — attribution on the converted record itself.\n *\n * The campaign's own page answers \"what did this campaign cause\". This\n * answers the question from the other end, which is the one a person standing\n * in front of a lead actually has: where did THIS come from. Without it the\n * join is invisible to everybody who is not already looking at a campaign.\n *\n * ## One keyed read, and only where the record is opened\n *\n * The record's id IS half the attribution's id — `{kind}:{refId}` — so this\n * is `getDoc` on a known path: no query, no index, nothing that can be\n * truncated, and no listener over a collection. Mounted on a DETAIL surface\n * (a reader dialog, a drawer) rather than in a table row, because a column\n * would multiply that one read by the page size and charge it to every reader\n * who opened the list for another reason entirely.\n *\n * ## Absence is a real answer and is rendered as one\n *\n * The join writes nothing at all for a conversion it cannot credit — no\n * `utm_source=direct` placeholder, no referrer inference, no fallback to\n * whichever campaign ran most recently. So a missing document is the ordinary\n * case and it means \"this person arrived without following a campaign link\",\n * which this says in words.\n *\n * What it must never render is a campaign with a zero beside it. \"Came from\n * no campaign\" and \"came from this campaign, which produced nothing\" are\n * opposite facts, and a component that reached for a default would print the\n * second when it means the first.\n *\n * ## The channels do not render the same, because they are not the same\n *\n * An EMAIL touch names a campaign document, so the label is a link to it. A\n * WEB touch names `utm_` text a marketer typed into a URL — there is no\n * document at the other end and no page to open — so it is rendered as text.\n * A link that resolves nowhere is worse than no link. A SEQUENCE touch\n * (AGL-3254) names the campaign a rep's sequence is in, a container with a\n * page, so it links like an email touch and says which door it came through.\n * A PAGE touch (AGL-3461) names the campaign the viewed page is filed under,\n * and a web touch whose label a campaign declares names that campaign: both\n * link to it.\n *\n * ## Filed under is not credited to\n *\n * A surface that knows what its record is filed under — the Inbox knows the\n * form's campaigns and the page's — hands them in as `filedUnder`, and they\n * are drawn ABOVE the credit under their own heading. The two answer\n * different questions: which campaigns the merchant put this form and page\n * in, and which campaign's touch the visitor arrived through. A submission can\n * be filed under one campaign and credited to another, or to none, and a\n * screen that ran the two together would hide exactly that.\n */\nexport interface ConversionAttributionProps {\n hostId: string\n /** Which identify moment this record is. */\n kind: CampaignConversionKind\n /** The record's own document id — the `refId` the writer credited. */\n refId: string\n /**\n * The marketing hub URL, when the caller already holds it.\n *\n * Resolved from the console route when it does not, which is the ordinary\n * case: the surfaces that render this belong to other plugins — the Inbox,\n * Contacts — and they are handed their OWN hub's path. Deriving one hub's\n * URL from another's by string surgery is the link that breaks silently\n * when a surface moves.\n */\n marketingBasePath?: string\n /** Rendered instead of the \"no campaign\" sentence, for a compact surface. */\n quiet?: boolean\n /**\n * The campaigns the record is FILED under, by container id (AGL-3461) —\n * drawn as \"Filed under\" above the credit. Absent: the heading is not drawn.\n */\n filedUnder?: readonly string[]\n}\n\n/** Which collection a campaign id on a record names. */\ntype CampaignIdKind = 'container' | 'send'\n\n/**\n * A campaign's name, read from its own document: the container's `name`, or\n * a send's `subject` — the id until the read answers or when the document is\n * gone, because an id is still the true value and a blank is not.\n */\nfunction CampaignName(props: {\n orgId: string | null\n id: string\n of: CampaignIdKind\n}) {\n const { orgId, id, of } = props\n const firestore = useFirestore()\n const { data } = useFirestoreDoc<{ name?: string; subject?: string }>(\n () =>\n orgId && id\n ? of === 'container'\n ? campaignContainerDoc(firestore, orgId, id)\n : campaignSendDoc(firestore, orgId, id)\n : null,\n [firestore, orgId, id, of],\n )\n const label = String((of === 'container' ? data?.name : data?.subject) ?? '').trim()\n return <>{label || id}</>\n}\n\n/** The chip a channel is drawn with, and whether its credit names a document. */\nfunction channelChip(record: CampaignConversionRecord): {\n label: string\n linked: boolean\n} {\n switch (record.channel) {\n case 'email':\n return { label: 'Campaign email', linked: true }\n case 'sequence':\n return { label: 'Sequence', linked: true }\n case 'page':\n return { label: 'Campaign page', linked: true }\n default:\n return record.campaignId\n ? { label: 'Campaign link', linked: true }\n : { label: 'Web link', linked: false }\n }\n}\n\n/** When and how the visitor was touched, in the sentence under the credit. */\nfunction touchSentence(record: CampaignConversionRecord): string {\n const touched = Number(record.touchedAtMs ?? 0)\n const at = touched ? ` on ${new Date(touched).toLocaleString()}` : ''\n switch (record.channel) {\n case 'sequence':\n return touched ? `Reached by a sequence email${at}` : 'Reached by a sequence email'\n case 'page':\n return `Viewed ${record.path ? record.path : 'a page filed under it'}${at}`\n default:\n return touched ? `Followed the link${at}` : 'Followed a campaign link'\n }\n}\n\nexport function ConversionAttribution(props: ConversionAttributionProps) {\n const { hostId, kind, refId, quiet } = props\n const filedUnder = props.filedUnder ?? []\n const firestore = useFirestore()\n // The org whose campaigns the names are read from; nothing is read until it\n // is known, and the ids stand in until then.\n const { orgId } = useMarketingOrgId(hostId)\n /*\n * Free — the org slug and the subdomain are already in the URL this console\n * is on, so no host document is resolved to render a link. `null` until the\n * params settle, and the campaign is then NAMED but not linked, which is\n * the rule the web channel follows for the same reason: no link beats one\n * that resolves nowhere.\n */\n const resolvedHub = useMarketingHubPath()\n const marketingBasePath = props.marketingBasePath ?? resolvedHub\n\n /*\n * The id is BUILT rather than interpolated, so a ref carrying a slash or a\n * colon answers null here instead of naming a document in another\n * collection. A null builder is how this hook is told not to read at all.\n */\n const attributionId = campaignConversionId(kind, refId)\n const { data: record, status } = useFirestoreDoc<CampaignConversionRecord>(\n () =>\n attributionId\n ? doc(\n firestore,\n 'hosts',\n hostId,\n 'campaignAttributions',\n attributionId,\n )\n : null,\n [firestore, hostId, attributionId],\n )\n\n /*\n * An id that could not be built means the record's own id is unusable, so\n * the question was never asked. Drawing the \"not credited\" sentence here\n * would state a fact nothing checked — the one thing this component must\n * not do — so it draws nothing.\n */\n if (!attributionId) return null\n\n /*\n * Nothing while the read settles. Rendering \"no campaign\" first and\n * replacing it a tick later states the opposite of the truth for as long as\n * the reader's eye takes to reach it, and this sits inside a dialog that\n * opens with the read.\n */\n if (status === 'loading') return null\n\n const campaignHref = (id: string | undefined) =>\n marketingBasePath && id ? `${marketingBasePath}/campaigns/${id}` : undefined\n\n /*\n * FILED UNDER — the merchant's declaration, drawn first and under its own\n * heading so it can never be read as the credit below it.\n */\n const filed = filedUnder.length ? (\n <Stack spacing={0.5}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filed under'}\n </Typography>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>\n {filedUnder.map((id) => {\n const href = campaignHref(id)\n const name = <CampaignName orgId={orgId} id={id} of=\"container\" />\n return href ? (\n <AppLink key={id} href={href}>\n {name}\n </AppLink>\n ) : (\n <Typography key={id} variant=\"body2\">\n {name}\n </Typography>\n )\n })}\n </Stack>\n </Stack>\n ) : null\n\n if (!record) {\n if (quiet && !filed) return null\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {filed}\n {quiet ? null : (\n <Stack spacing={0.5}>\n {filed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to'}\n </Typography>\n ) : null}\n {/*\n NOT a zero, and not an empty campaign chip. The sentence says\n which fact this is: nobody was credited, and nothing was guessed.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Not credited to a campaign — this arrived directly, or from a ' +\n 'link that carried no campaign, and no page filed under a ' +\n 'campaign was viewed before it. Nothing is inferred from a ' +\n 'referrer and no campaign is credited for being the most ' +\n 'recent one to run.'}\n </Typography>\n </Stack>\n )}\n </Stack>\n )\n }\n\n const label = campaignTouchLabel(record)\n const converted = Number(record.convertedAtMs ?? 0)\n const chip = channelChip(record)\n const creditedHref = chip.linked ? campaignHref(record.campaignId) : undefined\n const creditedName =\n chip.linked && record.campaignId ? (\n <CampaignName\n orgId={orgId}\n id={record.campaignId}\n of={record.channel === 'email' ? 'send' : 'container'}\n />\n ) : null\n\n return (\n <Stack spacing={1}>\n <Typography variant=\"overline\" color=\"text.secondary\">\n {'Campaign'}\n </Typography>\n {filed}\n <Stack spacing={0.5}>\n {filed ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Credited to'}\n </Typography>\n ) : null}\n <Stack\n direction=\"row\"\n spacing={1}\n useFlexGap\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n The CHANNEL is on screen beside the label, because the two are\n read differently: one names a campaign whose report can be opened,\n the other names text somebody typed into a URL.\n */}\n <Chip\n size=\"small\"\n color={chip.linked ? 'primary' : 'default'}\n variant={chip.linked ? 'filled' : 'outlined'}\n label={chip.label}\n />\n {creditedHref ? (\n <AppLink href={creditedHref}>{creditedName}</AppLink>\n ) : creditedName ? (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {creditedName}\n </Typography>\n ) : (\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label || 'unnamed'}\n </Typography>\n )}\n {/* A declared label keeps its text beside the campaign it names. */}\n {record.channel === 'web' && record.campaignId && label ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {label}\n </Typography>\n ) : null}\n </Stack>\n {/*\n THE RULE, under the claim. A credit whose rule the reader cannot\n state is a credit they will use anyway, and the two things they\n need are which touch wins and how long one stays creditable.\n `model` and `windowDays` come off the RECORD, so a conversion\n credited under an older rule prints the rule it was credited under.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {touchSentence(record) +\n (converted ? `, converted ${new Date(converted).toLocaleString()}` : '') +\n '. ' +\n campaignCreditRule(record.model, record.windowDays)}\n </Typography>\n </Stack>\n </Stack>\n )\n}\nConversionAttribution.displayName = 'ConversionAttribution'\n\nexport default ConversionAttribution\n"],"names":["AppLink","Chip","Stack","Typography","doc","useFirestore","useFirestoreDoc","useMarketingHubPath","useMarketingOrgId","campaignContainerDoc","campaignSendDoc","campaignConversionId","campaignCreditRule","campaignTouchLabel","CampaignName","props","of","orgId","id","firestore","data","label","String","name","subject","trim","channelChip","record","channel","linked","campaignId","touchSentence","touched","Number","touchedAtMs","at","Date","toLocaleString","path","ConversionAttribution","hostId","kind","refId","quiet","filedUnder","resolvedHub","marketingBasePath","attributionId","status","campaignHref","undefined","filed","length","spacing","variant","color","direction","useFlexGap","sx","flexWrap","map","href","converted","convertedAtMs","chip","creditedHref","creditedName","alignItems","size","fontWeight","model","windowDays","displayName"],"mappings":"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"}
@@ -27,6 +27,11 @@ export interface RecordAttributionWidgetProps {
27
27
  hostId?: string | null;
28
28
  recordKind?: string;
29
29
  recordId?: string;
30
+ /**
31
+ * The campaigns the host's record is filed under, when the host knows them
32
+ * (AGL-3461) — the Inbox hands a submission's form and page campaigns.
33
+ */
34
+ filedUnder?: readonly string[];
30
35
  }
31
36
  export declare function RecordAttributionWidget(props: RecordAttributionWidgetProps): import("react").JSX.Element;
32
37
  export declare namespace RecordAttributionWidget {
@@ -14,18 +14,21 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
17
18
  import { jsx as _jsx } from "react/jsx-runtime";
18
19
  import { CAMPAIGN_CONVERSION_KINDS } from "../model/campaign-conversions.js";
19
20
  import ConversionAttribution from "./conversion-attribution.component.js";
20
21
  const isConversionKind = (kind)=>CAMPAIGN_CONVERSION_KINDS.includes(kind);
21
22
  export function RecordAttributionWidget(props) {
22
- const { hostId, recordKind, recordId } = props;
23
+ const { hostId, recordKind, recordId, filedUnder } = props;
23
24
  if (!hostId || !recordId || !isConversionKind(recordKind)) return null;
24
- return /*#__PURE__*/ _jsx(ConversionAttribution, {
25
+ return /*#__PURE__*/ _jsx(ConversionAttribution, _extends({
25
26
  hostId: hostId,
26
27
  kind: recordKind,
27
28
  refId: recordId
28
- });
29
+ }, Array.isArray(filedUnder) && filedUnder.length ? {
30
+ filedUnder
31
+ } : {}));
29
32
  }
30
33
  RecordAttributionWidget.displayName = 'RecordAttributionWidget';
31
34
  export default RecordAttributionWidget;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/record-attribution-widget.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\n'use client'\n\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n type CampaignConversionKind,\n} from '../model/campaign-conversions'\nimport ConversionAttribution from './conversion-attribution.component'\n\n/**\n * The widget this plugin draws in another plugin's record-attribution zone.\n *\n * The CRM and the Inbox each host a zone on a record and hand it\n * `{ hostId, recordKind, recordId }` in their own words. Those words are the\n * identify moments this plugin credits a conversion at, so the widget reads\n * the kind as one — and draws nothing for a kind it never credits, rather\n * than reading a document that cannot exist.\n */\nexport interface RecordAttributionWidgetProps {\n hostId?: string | null\n recordKind?: string\n recordId?: string\n}\n\nconst isConversionKind = (kind: unknown): kind is CampaignConversionKind =>\n (CAMPAIGN_CONVERSION_KINDS as readonly unknown[]).includes(kind)\n\nexport function RecordAttributionWidget(props: RecordAttributionWidgetProps) {\n const { hostId, recordKind, recordId } = props\n if (!hostId || !recordId || !isConversionKind(recordKind)) return null\n return <ConversionAttribution hostId={hostId} kind={recordKind} refId={recordId} />\n}\nRecordAttributionWidget.displayName = 'RecordAttributionWidget'\n\nexport default RecordAttributionWidget\n"],"names":["CAMPAIGN_CONVERSION_KINDS","ConversionAttribution","isConversionKind","kind","includes","RecordAttributionWidget","props","hostId","recordKind","recordId","refId","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;AAEA,SACEA,yBAAyB,QAEpB,mCAA+B;AACtC,OAAOC,2BAA2B,wCAAoC;AAiBtE,MAAMC,mBAAmB,CAACC,OACxB,AAACH,0BAAiDI,QAAQ,CAACD;AAE7D,OAAO,SAASE,wBAAwBC,KAAmC;IACzE,MAAM,EAAEC,MAAM,EAAEC,UAAU,EAAEC,QAAQ,EAAE,GAAGH;IACzC,IAAI,CAACC,UAAU,CAACE,YAAY,CAACP,iBAAiBM,aAAa,OAAO;IAClE,qBAAO,KAACP;QAAsBM,QAAQA;QAAQJ,MAAMK;QAAYE,OAAOD;;AACzE;AACAJ,wBAAwBM,WAAW,GAAG;AAEtC,eAAeN,wBAAuB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/record-attribution-widget.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\n'use client'\n\nimport {\n CAMPAIGN_CONVERSION_KINDS,\n type CampaignConversionKind,\n} from '../model/campaign-conversions'\nimport ConversionAttribution from './conversion-attribution.component'\n\n/**\n * The widget this plugin draws in another plugin's record-attribution zone.\n *\n * The CRM and the Inbox each host a zone on a record and hand it\n * `{ hostId, recordKind, recordId }` in their own words. Those words are the\n * identify moments this plugin credits a conversion at, so the widget reads\n * the kind as one — and draws nothing for a kind it never credits, rather\n * than reading a document that cannot exist.\n */\nexport interface RecordAttributionWidgetProps {\n hostId?: string | null\n recordKind?: string\n recordId?: string\n /**\n * The campaigns the host's record is filed under, when the host knows them\n * (AGL-3461) — the Inbox hands a submission's form and page campaigns.\n */\n filedUnder?: readonly string[]\n}\n\nconst isConversionKind = (kind: unknown): kind is CampaignConversionKind =>\n (CAMPAIGN_CONVERSION_KINDS as readonly unknown[]).includes(kind)\n\nexport function RecordAttributionWidget(props: RecordAttributionWidgetProps) {\n const { hostId, recordKind, recordId, filedUnder } = props\n if (!hostId || !recordId || !isConversionKind(recordKind)) return null\n return (\n <ConversionAttribution\n hostId={hostId}\n kind={recordKind}\n refId={recordId}\n {...(Array.isArray(filedUnder) && filedUnder.length ? { filedUnder } : {})}\n />\n )\n}\nRecordAttributionWidget.displayName = 'RecordAttributionWidget'\n\nexport default RecordAttributionWidget\n"],"names":["CAMPAIGN_CONVERSION_KINDS","ConversionAttribution","isConversionKind","kind","includes","RecordAttributionWidget","props","hostId","recordKind","recordId","filedUnder","refId","Array","isArray","length","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;AAEA,SACEA,yBAAyB,QAEpB,mCAA+B;AACtC,OAAOC,2BAA2B,wCAAoC;AAsBtE,MAAMC,mBAAmB,CAACC,OACxB,AAACH,0BAAiDI,QAAQ,CAACD;AAE7D,OAAO,SAASE,wBAAwBC,KAAmC;IACzE,MAAM,EAAEC,MAAM,EAAEC,UAAU,EAAEC,QAAQ,EAAEC,UAAU,EAAE,GAAGJ;IACrD,IAAI,CAACC,UAAU,CAACE,YAAY,CAACP,iBAAiBM,aAAa,OAAO;IAClE,qBACE,KAACP;QACCM,QAAQA;QACRJ,MAAMK;QACNG,OAAOF;OACFG,MAAMC,OAAO,CAACH,eAAeA,WAAWI,MAAM,GAAG;QAAEJ;IAAW,IAAI,CAAC;AAG9E;AACAL,wBAAwBU,WAAW,GAAG;AAEtC,eAAeV,wBAAuB"}
@@ -1,4 +1,9 @@
1
1
  import type { SiteRuntimeProps } from '@aglyn/aglyn';
2
+ /** The page-props slice the server enricher writes for a page filed under a campaign. */
3
+ export interface CampaignPageData {
4
+ screenId: string;
5
+ campaignIds: string[];
6
+ }
2
7
  /**
3
8
  * The marketing plugin's site runtime (AGL-419), relocated verbatim from
4
9
  * the tenant catch-all: announcement bar + popup rendering (AGL-195/200),
@@ -19,7 +19,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
20
  import { sendAnalyticsBeacon } from "@aglyn/aglyn/app-utils/analytics-beacon";
21
21
  import { trackAuthoredEvent, trackEvent } from "@aglyn/aglyn/app-utils/analytics-events";
22
- import { utmTouchField } from "@aglyn/aglyn/app-utils/utm-touch";
22
+ import { claimFirstCampaignTouch, notePageCampaigns, utmTouchField, whenUtmTouchConsentSettles } from "@aglyn/aglyn/app-utils/utm-touch";
23
23
  import { useEffect, useRef, useState } from "react";
24
24
  import * as MarketingModel from "../model/index.js";
25
25
  /**
@@ -1017,6 +1017,100 @@ function AnnouncementBar(props) {
1017
1017
  })
1018
1018
  });
1019
1019
  }
1020
+ /**
1021
+ * Pageviews already reported to their campaigns, keyed by host and address.
1022
+ * Module scope for the reason the pageview beacon's guard is: a page remounts
1023
+ * on its own, and a remount is not a second visit.
1024
+ */ const campaignVisitsReported = new Set();
1025
+ /**
1026
+ * How long the visit report waits for the visitor's storage consent to
1027
+ * settle before it reports the view alone. Long enough for the region check
1028
+ * a consent posture runs on load; short enough that a stalled one still
1029
+ * counts the page view.
1030
+ */ const CAMPAIGN_VISIT_CONSENT_WAIT_MS = 10000;
1031
+ /**
1032
+ * A PAGE FILED UNDER A CAMPAIGN IS A CAMPAIGN TOUCH (AGL-3461).
1033
+ *
1034
+ * On every pageview this tells the core which campaigns the page is filed
1035
+ * under — the touch every conversion door on the page then reports, live and
1036
+ * with nothing written, and the one the device remembers under the visitor's
1037
+ * analytics grant (`utm-touch.ts`). A page filed under nothing clears the
1038
+ * live touch and keeps the remembered one.
1039
+ *
1040
+ * And it reports the visit to each campaign through the site collector's
1041
+ * `campaignVisit` beacon: a view of the campaign's page, and — when this is
1042
+ * the device's first campaign touch in the window — a first visit. A link
1043
+ * carrying a `utm_campaign` label is a touch too, credited as a first visit
1044
+ * to the campaign that declares it; the server answers which one. The
1045
+ * first-touch claim waits for consent to settle, because asked before it an
1046
+ * unresolved state would read as "may not remember", and the view would be
1047
+ * counted as nobody's first.
1048
+ *
1049
+ * Renders nothing.
1050
+ */ function CampaignPageTouch(props) {
1051
+ var _ref, _ref1;
1052
+ const { hostId, campaignPage } = props;
1053
+ const screenId = (_ref = campaignPage == null ? void 0 : campaignPage.screenId) != null ? _ref : '';
1054
+ const idsKey = ((_ref1 = campaignPage == null ? void 0 : campaignPage.campaignIds) != null ? _ref1 : []).join(',');
1055
+ useEffect(()=>{
1056
+ var _get;
1057
+ if (typeof window === 'undefined') return undefined;
1058
+ const ids = idsKey ? idsKey.split(',') : [];
1059
+ notePageCampaigns(ids.length ? {
1060
+ screenId,
1061
+ containerIds: ids,
1062
+ path: window.location.pathname
1063
+ } : null);
1064
+ if (!hostId) return undefined;
1065
+ const label = ((_get = new URLSearchParams(window.location.search).get('utm_campaign')) != null ? _get : '').trim().toLowerCase().slice(0, 100);
1066
+ if (!ids.length && !label) return undefined;
1067
+ const visitKey = `${hostId}\x00${window.location.pathname}${window.location.search}`;
1068
+ if (campaignVisitsReported.has(visitKey)) return undefined;
1069
+ let done = false;
1070
+ const report = (allowed)=>{
1071
+ if (done) return;
1072
+ done = true;
1073
+ campaignVisitsReported.add(visitKey);
1074
+ const claimed = allowed ? claimFirstCampaignTouch([
1075
+ ...ids.map((id)=>`c:${id}`),
1076
+ ...label ? [
1077
+ `u:${label}`
1078
+ ] : []
1079
+ ]) : [];
1080
+ const first = claimed.filter((key)=>key.startsWith('c:')).map((key)=>key.slice(2));
1081
+ const firstLabel = Boolean(label) && claimed.includes(`u:${label}`);
1082
+ // A label arrival on a page filed under nothing is only a first visit;
1083
+ // with nothing first about it there is nothing to count.
1084
+ if (!ids.length && !firstLabel) return;
1085
+ sendAnalyticsBeacon(_extends({
1086
+ hostId,
1087
+ campaignVisit: '1'
1088
+ }, ids.length ? {
1089
+ screenId,
1090
+ campaignIds: ids
1091
+ } : {}, first.length ? {
1092
+ first
1093
+ } : {}, firstLabel ? {
1094
+ utmCampaign: label,
1095
+ firstLabel: true
1096
+ } : {}));
1097
+ };
1098
+ const withdraw = whenUtmTouchConsentSettles(report);
1099
+ const timer = window.setTimeout(()=>{
1100
+ withdraw();
1101
+ report(false);
1102
+ }, CAMPAIGN_VISIT_CONSENT_WAIT_MS);
1103
+ return ()=>{
1104
+ withdraw();
1105
+ window.clearTimeout(timer);
1106
+ };
1107
+ }, [
1108
+ hostId,
1109
+ screenId,
1110
+ idsKey
1111
+ ]);
1112
+ return null;
1113
+ }
1020
1114
  /**
1021
1115
  * The marketing plugin's site runtime (AGL-419), relocated verbatim from
1022
1116
  * the tenant catch-all: announcement bar + popup rendering (AGL-195/200),
@@ -1024,7 +1118,7 @@ function AnnouncementBar(props) {
1024
1118
  * (AGL-256/257), and automation-triggered overlays. Reads back exactly the
1025
1119
  * page-props slices its server enricher wrote (site-contract shapes).
1026
1120
  */ export function MarketingSiteRuntime({ hostId, screens, page }) {
1027
- var _page_experiments, _page_clientAutomations, _automationOverlay_popup_frequencyDays;
1121
+ var _page_experiments, _page_clientAutomations, _page_campaignPage, _automationOverlay_popup_frequencyDays;
1028
1122
  var _page_data, _automationOverlay_bar, _automationOverlay_popup;
1029
1123
  const [automationOverlay, setAutomationOverlay] = useState(null);
1030
1124
  const announcementBar = page['announcementBar'];
@@ -1032,8 +1126,13 @@ function AnnouncementBar(props) {
1032
1126
  const experiments = (_page_experiments = page['experiments']) != null ? _page_experiments : [];
1033
1127
  const clientAutomations = (_page_clientAutomations = page['clientAutomations']) != null ? _page_clientAutomations : [];
1034
1128
  const automationOverlays = page['automationOverlays'];
1129
+ const campaignPage = (_page_campaignPage = page['campaignPage']) != null ? _page_campaignPage : null;
1035
1130
  return /*#__PURE__*/ _jsxs(_Fragment, {
1036
1131
  children: [
1132
+ /*#__PURE__*/ _jsx(CampaignPageTouch, {
1133
+ hostId: hostId,
1134
+ campaignPage: campaignPage
1135
+ }),
1037
1136
  announcementBar ? /*#__PURE__*/ _jsx(AnnouncementBar, {
1038
1137
  bar: announcementBar,
1039
1138
  hostId: hostId