@aglyn/plugins-crm 1.0.0-beta.167 → 1.0.0-beta.169
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/package.json +13 -13
- package/src/lib/components/contact-associations-card.js +1 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/crm-console-page.js +5 -4
- package/src/lib/components/crm-console-page.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +5 -3
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +7 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +11 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +14 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +7 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-section.js +7 -0
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/server/erase-person.js +3 -11
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/record-email-state.js +4 -14
- package/src/lib/server/record-email-state.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.169",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.169",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.169",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.169",
|
|
31
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.169",
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.169",
|
|
33
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.169",
|
|
34
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.169",
|
|
35
|
+
"@aglyn/shared-util-email": "1.0.0-beta.169",
|
|
36
|
+
"@aglyn/shared-util-http": "1.0.0-beta.169",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.169",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.169",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.169",
|
|
40
40
|
"@dnd-kit/core": "^6.3.1",
|
|
41
41
|
"@dnd-kit/utilities": "^3.2.2",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -89,7 +89,7 @@ import { useCrmScope } from "../hooks/use-crm-scope.js";
|
|
|
89
89
|
useEffect(()=>{
|
|
90
90
|
let active = true;
|
|
91
91
|
setLeadKey(null);
|
|
92
|
-
if (!hostId) return undefined;
|
|
92
|
+
if (!hostId || !orgId) return undefined;
|
|
93
93
|
void (async ()=>{
|
|
94
94
|
const key = await personKeyInBrowser(email);
|
|
95
95
|
if (!key || !active) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-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 * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useHostCampaigns,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own Save, through\n * the same stale-seed guard the properties card uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'orgs', orgId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The site's campaigns, read because this page IS the ask: one picker on\n * one record, and nothing in the list ever paid for the campaign list.\n */\n const siteCampaigns = useHostCampaigns(hostId ?? undefined, { enabled: Boolean(hostId) })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.campaignMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <Stack spacing={0.5}>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n {/* Attribution is the campaign that brought the person; the\n picker below is the team's own filing (AGL-3274). */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <CampaignPicker\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText=\"This site has no campaigns yet.\"\n />\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","CampaignPicker","AppLink","CardDisplay","useSnackbar","useFirestore","useHostCampaigns","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","orgId","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","Boolean","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","campaignMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","spacing","length","direction","sx","flexWrap","rowGap","map","source","label","size","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","options","value","onChange","helperText","empty","ready","emptyText","disabled","onClick","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AACrC,SACEC,qBAAqB,EAGrBC,sBAAsB,EACtBC,kBAAkB,QACb,eAAc;AACrB,SAASC,kBAAkB,QAAQ,6BAAyB;AAC5D;;;;CAIC,GACD,SAASC,wBAAwB,QAAQ,4BAAwB;AACjE,OAAOC,oBAAoB,wEAAuE;AAClG,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,YAAY,EACZC,gBAAgB,EAChBC,kBAAkB,QACb,iCAAgC;AACvC,SAASC,MAAM,EAAEC,IAAI,EAAEC,OAAO,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACxE,SAASC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAChD,SAASC,WAAW,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACjE,SAASC,gBAAgB,QAAQ,iCAA6B;AAE9D,SAASC,SAAS,QAAQ,yBAAqB;AAC/C,SAASC,WAAW,QAAQ,4BAAwB;AA0BpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GACD,OAAO,SAASC,wBAAwBC,KAAmC;QAmF7CC;IAlF5B,MAAM,EAAEC,MAAM,EAAED,MAAM,EAAEE,GAAG,EAAEC,YAAY,EAAEC,IAAI,EAAEC,QAAQ,EAAEC,YAAY,EAAE,GAAGP;IAC5E,0EAA0E;IAC1E,4DAA4D;IAC5D,MAAM,EAAEQ,KAAK,EAAE,GAAGV,YAAY;QAAEI;IAAO;IACvC,MAAMO,YAAY3B;IAClB,MAAM,EAAE4B,eAAe,EAAE,GAAG7B;IAC5B,MAAM8B,gBAAgBf,iBAAiBM;IACvC,MAAMU,SAASf,UAAUS;IAEzB,MAAM,CAACO,SAASC,WAAW,GAAGnB,SAAwB;IACtD,MAAMoB,QAAQd,OAAOc,KAAK;IAC1BtB,UAAU;QACR,IAAIuB,SAAS;QACbF,WAAW;QACX,IAAI,CAACZ,QAAQ,OAAOe;QACpB,KAAK,AAAC,CAAA;YACJ,MAAMC,MAAM,MAAM1C,mBAAmBuC;YACrC,IAAI,CAACG,OAAO,CAACF,QAAQ;YACrB,MAAMG,WAAW,MAAM5B,OACrBD,IAAImB,WAAW,QAAQD,OAAO,SAASU,MACvCE,KAAK,CAAC,IAAM;YACd,IAAIJ,WAAUG,4BAAAA,SAAUE,MAAM,KAAIP,WAAWI;QAC/C,CAAA,IAAKE,KAAK,CAAC,IAAMH;QACjB,OAAO;YACLD,SAAS;QACX;IACF,GAAG;QAACP;QAAWP;QAAQa;KAAM;IAE7B,MAAM,CAACO,WAAWC,aAAa,GAAG5B,SAAmBM,OAAOuB,WAAW;IACvE,MAAM,CAACC,QAAQC,UAAU,GAAG/B,SAAS;IACrC,MAAMgC,WAAW1B,OAAO2B,GAAG;IAC3BnC,UAAU;QACR8B,aAAatB,OAAOuB,WAAW;IAC/B,uDAAuD;IACzD,GAAG;QAACG;KAAS;IAEb;;;GAGC,GACD,MAAME,gBAAgB9C,iBAAiBmB,iBAAAA,SAAUe,WAAW;QAAEa,SAASC,QAAQ7B;IAAQ;IAEvF,MAAM8B,UAAUtC,QACd,IAAMnB,mBAAmB4B,KAAKC,eAC9B;QAACD;QAAKC;KAAa;IAGrB,MAAM6B,mBAAmBzC,YAAY;QACnCkC,UAAU;QACV,IAAI;YACF,MAAMQ,UAAU,MAAMlD,mBACpB;gBACEmD,SAAS;gBACTC,YAAY/B,KAAKgC,MAAM,KAAK;gBAC5BC,WAAWjC,KAAKiC,SAAS;YAC3B,GACA,oEAAoE;YACpE,kEAAkE;YAClE,oCAAoC;YACpC,IACE3B,cAAc4B,SAAS,CAACtC,OAAO2B,GAAG,EAAE;oBAClCJ,aAAapD,MAAMoE,uBAAuB,CAAClB;gBAC7C;YAEJ,IAAI,CAACY,QAAQO,EAAE,EAAE;gBACf,OAAO,KAAK/B,gBAAgBwB,QAAQQ,OAAO,EAAE;oBAC3CC,SAAS;oBACTC,SAAS;gBACX;YACF;YACAlC,gBAAgB,gBAAgB;gBAAEiC,SAAS;gBAAWC,SAAS;YAAM;QACvE,EAAE,OAAOC,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdnC,gBACEmC,iBAAiBE,SAASF,MAAMH,OAAO,GAAGG,MAAMH,OAAO,GAAG,yBAC1D;gBAAEC,SAAS;gBAASK,gBAAgB;YAAK;QAE7C,SAAU;YACRtB,UAAU;QACZ;IACF,GAAG;QAACJ;QAAWX;QAAeD;QAAiBT,OAAO2B,GAAG;QAAEvB,KAAKiC,SAAS;QAAEjC,KAAKgC,MAAM;KAAC;IAEvF,MAAMY,UAAUC,OAAOC,IAAI,EAAClD,kBAAAA,OAAOgD,OAAO,YAAdhD,kBAAkB,CAAC;IAC/C,MAAMmD,gBAAgB;QACpBpB,QAAQqB,SAAS,GACb,CAAC,SAAS,EAAE,IAAIC,KAAKtB,QAAQqB,SAAS,EAAEE,kBAAkB,IAAI,GAC9D;QACJvB,QAAQwB,UAAU,KAAK,aAAa,yBAAyB;QAC7DxB,QAAQyB,UAAU,KAAK,eACnB,+CACA;KACL,CAACC,MAAM,CAAC3B;IAET,qBACE,KAACnD;QACC+E,QAAQ;QACRC,MAAMxF,MAAMyF,cAAc,CAAC,iBAAiB;YAAEC,QAAQ;QAAkC;QACxFC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC5E;YAAM6E,SAAS;;8BACd,MAAC7E;oBAAM6E,SAAS;;sCACd,KAAC5E;4BAAWsD,SAAQ;sCAAa;;wBAChCM,QAAQiB,MAAM,IAAIrD,wBACjB,MAACzB;4BAAM+E,WAAU;4BAAMF,SAAS;4BAAKG,IAAI;gCAAEC,UAAU;gCAAQC,QAAQ;4BAAI;;gCACtErB,QAAQsB,GAAG,CAAC,CAACC;wCAGHnG;yDAFT,KAACa;wCAECuF,KAAK,GAAEpG,gCAAAA,qBAAqB,CAACmG,OAAO,YAA7BnG,gCAAiCmG;wCACxCE,MAAK;uCAFAF;;gCASR3D,wBACC,KAAC5B;oCACC0F,WAAWhG;mCACN;oCAAEiG,kBAAkB;oCAASC,cAAc;gCAAM;oCACtDC,MAAMlE,OAAOmE,IAAI,CAAClE;oCAClB6D,MAAK;oCACL/B,SAAQ;oCACRyB,IAAI;wCAAEY,IAAI;wCAAGC,WAAW;wCAAIC,YAAY;oCAAI;8CAE3C;sCAED;;2CAGN,KAAC7F;4BAAWsD,SAAQ;4BAAQwC,OAAM;sCAC/B;;;;gBAUN5E,6BACC,MAACnB;oBAAM6E,SAAS;;sCACd,KAAC5E;4BAAWsD,SAAQ;sCAAa;;sCACjC,KAACtD;4BAAWsD,SAAQ;sCAClB,cAAA,KAAChE;gCAAQmG,MAAMvE;0CAAe;;;sCAEhC,KAAClB;4BAAWsD,SAAQ;4BAAUwC,OAAM;sCACjC;;;qBAGH;gBACHjF,uBACC,MAACd;oBAAM6E,SAAS;;sCACd,KAACxF;4BACCyB,QAAQA;4BACRkF,YAAW;4BACXzD,UAAU1B,OAAO2B,GAAG;;sCAItB,KAACvC;4BAAWsD,SAAQ;4BAAUwC,OAAM;sCACjC;;;qBAGH;8BACJ,MAAC/F;oBAAM6E,SAAS;;sCACd,KAAC5E;4BAAWsD,SAAQ;sCAAa;;sCACjC,MAACtD;4BAAWsD,SAAQ;;gCACjBrE,sBAAsB,CAAC0D,QAAQqD,KAAK,CAAC;gCACrCjC,cAAcc,MAAM,GAAG,CAAC,GAAG,EAAEd,cAAckC,IAAI,CAAC,QAAQ,GAAG;;;sCAE9D,KAACjG;4BAAWsD,SAAQ;4BAAUwC,OAAM;sCACjCnD,QAAQqD,KAAK,KAAK,eACf,yEACA,CAAC,eAAe,EACdjF,aAAamF,QAAQ,GAAG,UAAU,OACnC,oDAAoD,CAAC;;;;8BAG9D,KAACpG;8BACD,KAACT;oBACC8G,SAAS3D,cAAc2D,OAAO;oBAC9BC,OAAOnE;oBACPoE,UAAUnE;oBACVkD,OAAM;oBACNkB,YAAW;oBACXC,OAAO/D,cAAcgE,KAAK,IAAI,CAAChE,cAAc2D,OAAO,CAACtB,MAAM;oBAC3D4B,WAAU;;8BAEZ,KAAC1G;oBAAM+E,WAAU;8BACf,cAAA,KAAClF;wBACCyF,MAAK;wBACL/B,SAAQ;wBACRoD,UAAUtE,UAAWI,cAAcgE,KAAK,IAAI,CAAChE,cAAc2D,OAAO,CAACtB,MAAM;wBACzE8B,SAAS,IAAM,KAAK/D;kCAEnBR,SAAS,YAAY;;;;;;AAMlC;AACA1B,wBAAwBkG,WAAW,GAAG;AAEtC,eAAelG,wBAAuB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-associations-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 * as Aglyn from '@aglyn/aglyn'\nimport {\n CONTACT_SOURCE_LABELS,\n type ConsentGroup,\n type ContactSource,\n MARKETING_BASIS_LABELS,\n readMarketingBasis,\n} from '@aglyn/aglyn'\nimport { personKeyInBrowser } from '../model/person-key-web'\n/*\n * The component path and NOT the marketing barrel: that barrel is the entry\n * point the tenant's loader imports to activate the plugin's SITE half, so a\n * console card named there ships to every published page.\n */\nimport { CrmRecordAttributionZone } from './crm-attribution-zone'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useHostCampaigns,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Chip, Divider, Stack, Typography } from '@mui/material'\nimport { doc, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport type { ContactRecord } from '../model/contact-record'\nimport { crmRoutes } from '../model/crm-routes'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\nexport interface ContactAssociationsCardProps {\n /**\n * The site whose lead, campaigns and attribution are shown — the mounted\n * site, or at the organization level the holder's own; `null` for a row\n * no site has captured, when none of the three has an address.\n */\n hostId: string | null\n /** The row, flattened through the viewing group's facet. */\n record: ContactRecord\n /** The document as read, for the consent fields the projection leaves out. */\n row: Record<string, unknown>\n consentGroup: ConsentGroup\n /** The listener's verdict on the row, for the stale-seed guard. */\n seed: { status: 'loading' | 'success' | 'error'; fromCache: boolean }\n /** The hub's own path, for the link to this person's lead. */\n basePath: string\n /**\n * The site's Bookings page narrowed to this person's address (AGL-2660),\n * or nothing where the site has no booking door — the page decides, and\n * the row is simply not drawn.\n */\n bookingsHref?: string | null\n}\n\n/**\n * WHERE THIS PERSON CAME FROM AND WHAT THEY ARE FILED UNDER (AGL-2596).\n *\n * Four facts that were in the v1 drawer and are not properties of the\n * person so much as of the RELATIONSHIP:\n *\n * - The SOURCES say which mechanism created the record — a form, a\n * checkout, an import.\n * - The ATTRIBUTION says which campaign led to it, recorded from the link\n * they followed and never editable. One keyed read, paid on opening the\n * page rather than per row of the list.\n * - The CONSENT is the basis this controller holds for marketing email:\n * opted in, opted out, or no record — three states that never collapse\n * into two, because \"no record\" is the commonest and is not a refusal.\n * - The FILING is which campaigns the team has PUT them in, a working set\n * kept by hand. It adds nobody to a send: a campaign's audience is its\n * lists and each email's own picker, and the helper says so where a\n * reader would otherwise assume the opposite.\n *\n * The filing is the one editable thing here and has its own Save, through\n * the same stale-seed guard the properties card uses: a campaign membership\n * written over a cached read could revert somebody else's filing. The save\n * goes to `crm/contact-update` (AGL-2804), because the filing is this\n * holder's facet and a facet is the server's to write; like every field that\n * route writes, it is refused to a plan without the CRM (AGL-2851).\n *\n * ## The lead (AGL-2612)\n *\n * A LEAD is the sales record a lead surface — a sign-up, a booking, a form\n * with lead routing — files beside the contact, under the capturing site at\n * `hosts/{hostId}/leads/{personKey}`. The lead's page links the contact\n * once converted; nothing linked back, so the two halves of one person\n * read as unrelated. One `getDoc` by the key the capture derives — the\n * address, normalized and hashed, computed here on WebCrypto — is what\n * answers \"does this site hold a lead for this person\", and it is a read\n * this page pays because opening the record IS the ask. Absence is a fact\n * too: a contact captured by an order or an import has no lead, and the\n * chip is simply not there.\n */\nexport function ContactAssociationsCard(props: ContactAssociationsCardProps) {\n const { hostId, record, row, consentGroup, seed, basePath, bookingsHref } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId)\n const routes = crmRoutes(basePath)\n\n const [leadKey, setLeadKey] = useState<string | null>(null)\n const email = record.email\n useEffect(() => {\n let active = true\n setLeadKey(null)\n if (!hostId || !orgId) return undefined\n void (async () => {\n const key = await personKeyInBrowser(email)\n if (!key || !active) return\n const snapshot = await getDoc(\n doc(firestore, 'orgs', orgId, 'leads', key),\n ).catch(() => null)\n if (active && snapshot?.exists()) setLeadKey(key)\n })().catch(() => undefined)\n return () => {\n active = false\n }\n }, [firestore, hostId, email])\n\n const [campaigns, setCampaigns] = useState<string[]>(record.campaignIds)\n const [saving, setSaving] = useState(false)\n const recordId = record.$id\n useEffect(() => {\n setCampaigns(record.campaignIds)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [recordId])\n\n /*\n * The site's campaigns, read because this page IS the ask: one picker on\n * one record, and nothing in the list ever paid for the campaign list.\n */\n const siteCampaigns = useHostCampaigns(hostId ?? undefined, { enabled: Boolean(hostId) })\n\n const consent = useMemo(\n () => readMarketingBasis(row, consentGroup),\n [row, consentGroup],\n )\n\n const handleSaveFiling = useCallback(async () => {\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: seed.status === 'error',\n fromCache: seed.fromCache,\n },\n // An empty selection is sent as an empty list rather than left out,\n // so \"filed under no campaign\" has one shape here and in the pass\n // that detaches a deleted campaign.\n () =>\n contactUpdate.updateOne(record.$id, {\n campaignIds: Aglyn.campaignMembershipValue(campaigns),\n }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [campaigns, contactUpdate, enqueueSnackbar, record.$id, seed.fromCache, seed.status])\n\n const sources = Object.keys(record.sources ?? {}) as ContactSource[]\n const consentDetail = [\n consent.basisAtMs\n ? `recorded ${new Date(consent.basisAtMs).toLocaleDateString()}`\n : null,\n consent.assertedBy === 'operator' ? 'entered by your team' : null,\n consent.otherGrant === 'other-host'\n ? 'opted in to another site in this workspace'\n : null,\n ].filter(Boolean)\n\n return (\n <CardDisplay\n header={'Relationship'}\n help={Aglyn.pluginDocsHelp('contactRecord', { anchor: '#what-each-site-keeps-to-itself' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Sources'}</Typography>\n {sources.length || leadKey ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source] ?? source}\n size=\"small\"\n />\n ))}\n {/*\n This site's lead for the person, when it holds one — a link,\n in the shape the lead's page uses to point back here.\n */}\n {leadKey ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.lead(leadKey)}\n size=\"small\"\n variant=\"outlined\"\n sx={{ py: 0, minHeight: 24, lineHeight: 1.5 }}\n >\n {'Lead on this site'}\n </Button>\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No capture recorded.'}\n </Typography>\n )}\n </Stack>\n {/*\n The person's bookings on this site (AGL-2660): every booking\n taken with their address, past ones too, on the Bookings page —\n the appointments are that page's records, and this card only\n points at them.\n */}\n {bookingsHref ? (\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Bookings'}</Typography>\n <Typography variant=\"body2\">\n <AppLink href={bookingsHref}>{'Bookings on this site'}</AppLink>\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Every booking made with this address, upcoming and past.'}\n </Typography>\n </Stack>\n ) : null}\n {hostId ? (\n <Stack spacing={0.5}>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"contact\"\n recordId={record.$id}\n />\n {/* Attribution is the campaign that brought the person; the\n picker below is the team's own filing (AGL-3274). */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Filing is separate — see Filed under campaigns.'}\n </Typography>\n </Stack>\n ) : null}\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Marketing email'}</Typography>\n <Typography variant=\"body2\">\n {MARKETING_BASIS_LABELS[consent.basis]}\n {consentDetail.length ? ` · ${consentDetail.join(' · ')}` : ''}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {consent.basis === 'unrecorded'\n ? 'Nothing was recorded either way — not a refusal, and not permission.'\n : `The basis this ${\n consentGroup.declared ? 'group' : 'site'\n } holds. Other sites in the workspace keep their own.`}\n </Typography>\n </Stack>\n <Divider />\n <CampaignPicker\n options={siteCampaigns.options}\n value={campaigns}\n onChange={setCampaigns}\n label=\"Filed under campaigns\"\n helperText=\"Your own filing. It never adds anyone to a send — a campaign mails its lists.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n emptyText=\"This site has no campaigns yet.\"\n />\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || (siteCampaigns.ready && !siteCampaigns.options.length)}\n onClick={() => void handleSaveFiling()}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nContactAssociationsCard.displayName = 'ContactAssociationsCard'\n\nexport default ContactAssociationsCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","MARKETING_BASIS_LABELS","readMarketingBasis","personKeyInBrowser","CrmRecordAttributionZone","CampaignPicker","AppLink","CardDisplay","useSnackbar","useFirestore","useHostCampaigns","writeGuardedBySeed","Button","Chip","Divider","Stack","Typography","doc","getDoc","useCallback","useEffect","useMemo","useState","useContactUpdate","crmRoutes","useCrmScope","ContactAssociationsCard","props","record","hostId","row","consentGroup","seed","basePath","bookingsHref","orgId","firestore","enqueueSnackbar","contactUpdate","routes","leadKey","setLeadKey","email","active","undefined","key","snapshot","catch","exists","campaigns","setCampaigns","campaignIds","saving","setSaving","recordId","$id","siteCampaigns","enabled","Boolean","consent","handleSaveFiling","verdict","subject","unreadable","status","fromCache","updateOne","campaignMembershipValue","ok","message","variant","persist","error","console","Error","allowDuplicate","sources","Object","keys","consentDetail","basisAtMs","Date","toLocaleDateString","assertedBy","otherGrant","filter","header","help","pluginDocsHelp","anchor","contentGutterX","contentGutterY","spacing","length","direction","sx","flexWrap","rowGap","map","source","label","size","component","componentVariant","nativeButton","href","lead","py","minHeight","lineHeight","color","recordKind","basis","join","declared","options","value","onChange","helperText","empty","ready","emptyText","disabled","onClick","displayName"],"mappings":"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"}
|
|
@@ -68,10 +68,11 @@ import TasksSection from "./tasks-section.js";
|
|
|
68
68
|
* them, so the id alone could not say which record was meant. A lead is
|
|
69
69
|
* one org row now, and addresses like every other CRM record.
|
|
70
70
|
*
|
|
71
|
-
* The two-segment form is
|
|
72
|
-
*
|
|
73
|
-
* and the id
|
|
74
|
-
*
|
|
71
|
+
* The two-segment form is STILL ACCEPTED, and stays accepted past
|
|
72
|
+
* AGL-3277: links were shared, bookmarked and mailed while the old
|
|
73
|
+
* shape was live, and the id they carry is the same person key. What
|
|
74
|
+
* AGL-3277 removed is the storage behind the old shape, not the
|
|
75
|
+
* courtesy of honoring a link somebody already has.
|
|
75
76
|
*/ return record ? !props.hostId && detail.length >= 2 ? /*#__PURE__*/ _jsx(LeadDetailPage, _extends({}, recordProps, {
|
|
76
77
|
id: detail[1]
|
|
77
78
|
})) : /*#__PURE__*/ _jsx(LeadDetailPage, _extends({}, recordProps, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-console-page.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 type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport type { ReactNode } from 'react'\nimport { CrmOrgMountProvider } from '../hooks/use-crm-org-mount'\nimport CompaniesSection from './companies-section'\nimport CompanyDetailPage from './company-detail-page'\nimport ContactDetailPage from './contact-detail-page'\nimport type { CrmConsoleSectionId } from './crm-console-sections'\nimport DealDetailPage from './deal-detail-page'\nimport DealsSection from './deals-section'\nimport FieldsSection from './fields-section'\nimport LeadDetailPage from './lead-detail-page'\nimport LeadsSection from './leads-section'\nimport ContactsSection from './contacts-section'\nimport ReportsSection from './reports-section'\nimport CrmSettingsSection from './settings-section'\nimport TasksSection from './tasks-section'\n\n/**\n * The body of one Contacts section, built only when that section is the one\n * being read (AGL-2595).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and the people list alone\n * opens four Firestore listens and an aggregate read on mount. Only the\n * returned branch is ever built.\n *\n * Three sections own a deeper route — a record beneath the list — and read\n * it off `detail`, the segments after the section id. A list section with\n * nothing after it is the list; one segment is the record; the shell has\n * already 404'd anything it could not resolve to this surface.\n */\nfunction sectionBody(\n section: CrmConsoleSectionId,\n props: ConsolePluginPageProps,\n /** The section's OWN segments — `segments[1]` onward. */\n detail: readonly string[],\n basePath: string,\n): ReactNode {\n const record = detail[0]\n const recordProps = {\n hostId: props.hostId,\n org: props.org,\n permissions: props.permissions,\n releaseFlag: props.releaseFlag,\n hostRole: props.hostRole,\n basePath,\n }\n switch (section) {\n case 'contacts':\n return record ? (\n <ContactDetailPage {...recordProps} id={record} />\n ) : (\n <ContactsSection {...props} />\n )\n case 'leads':\n /*\n * ONE SEGMENT, at both levels (AGL-3275).\n *\n * The organization level used to name the site before the id —\n * `leads/{hostId}/{leadId}` (AGL-2630) — because a lead lived under its\n * site and a person key was the same id under each site that had met\n * them, so the id alone could not say which record was meant. A lead is\n * one org row now, and addresses like every other CRM record.\n *\n * The two-segment form is
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-console-page.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 type { ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { HubSections } from '@aglyn/shared-ui-next'\nimport type { ReactNode } from 'react'\nimport { CrmOrgMountProvider } from '../hooks/use-crm-org-mount'\nimport CompaniesSection from './companies-section'\nimport CompanyDetailPage from './company-detail-page'\nimport ContactDetailPage from './contact-detail-page'\nimport type { CrmConsoleSectionId } from './crm-console-sections'\nimport DealDetailPage from './deal-detail-page'\nimport DealsSection from './deals-section'\nimport FieldsSection from './fields-section'\nimport LeadDetailPage from './lead-detail-page'\nimport LeadsSection from './leads-section'\nimport ContactsSection from './contacts-section'\nimport ReportsSection from './reports-section'\nimport CrmSettingsSection from './settings-section'\nimport TasksSection from './tasks-section'\n\n/**\n * The body of one Contacts section, built only when that section is the one\n * being read (AGL-2595).\n *\n * A function rather than a map of nodes on purpose: a `Record<id, ReactNode>`\n * would CONSTRUCT every section on every render, and the people list alone\n * opens four Firestore listens and an aggregate read on mount. Only the\n * returned branch is ever built.\n *\n * Three sections own a deeper route — a record beneath the list — and read\n * it off `detail`, the segments after the section id. A list section with\n * nothing after it is the list; one segment is the record; the shell has\n * already 404'd anything it could not resolve to this surface.\n */\nfunction sectionBody(\n section: CrmConsoleSectionId,\n props: ConsolePluginPageProps,\n /** The section's OWN segments — `segments[1]` onward. */\n detail: readonly string[],\n basePath: string,\n): ReactNode {\n const record = detail[0]\n const recordProps = {\n hostId: props.hostId,\n org: props.org,\n permissions: props.permissions,\n releaseFlag: props.releaseFlag,\n hostRole: props.hostRole,\n basePath,\n }\n switch (section) {\n case 'contacts':\n return record ? (\n <ContactDetailPage {...recordProps} id={record} />\n ) : (\n <ContactsSection {...props} />\n )\n case 'leads':\n /*\n * ONE SEGMENT, at both levels (AGL-3275).\n *\n * The organization level used to name the site before the id —\n * `leads/{hostId}/{leadId}` (AGL-2630) — because a lead lived under its\n * site and a person key was the same id under each site that had met\n * them, so the id alone could not say which record was meant. A lead is\n * one org row now, and addresses like every other CRM record.\n *\n * The two-segment form is STILL ACCEPTED, and stays accepted past\n * AGL-3277: links were shared, bookmarked and mailed while the old\n * shape was live, and the id they carry is the same person key. What\n * AGL-3277 removed is the storage behind the old shape, not the\n * courtesy of honoring a link somebody already has.\n */\n return record ? (\n !props.hostId && detail.length >= 2 ? (\n <LeadDetailPage {...recordProps} id={detail[1]} />\n ) : (\n <LeadDetailPage {...recordProps} id={record} />\n )\n ) : (\n <LeadsSection {...props} />\n )\n case 'companies':\n return record ? (\n <CompanyDetailPage {...recordProps} id={record} />\n ) : (\n <CompaniesSection\n hostId={props.hostId}\n org={props.org}\n basePath={basePath}\n />\n )\n case 'deals':\n return record ? (\n <DealDetailPage {...recordProps} id={record} />\n ) : (\n <DealsSection {...props} />\n )\n case 'tasks':\n return <TasksSection {...props} />\n case 'reports':\n // The shell's props whole, like the contacts list: the reports resolve\n // the org scope from `hostId`, the consent group from `org`, and their\n // drill-down links from `basePath` (AGL-2604).\n return <ReportsSection {...props} />\n case 'fields':\n return <FieldsSection hostId={props.hostId} org={props.org} />\n case 'settings':\n // The org-wide switches (AGL-2613): what the CRM does on its own for\n // every site in the workspace, written to the org document.\n return <CrmSettingsSection hostId={props.hostId} org={props.org} />\n default:\n return null\n }\n}\n\n/**\n * Contacts (AGL-109 → AGL-395 → AGL-2595): the CRM hub — people, companies,\n * deals, tasks, reports and fields — owned by the contacts plugin and\n * rendered by the shell's generic plugin route. The shell applies the\n * `release_crm` gate (via the nav tab) and passes the resolved `org`\n * doc, which the people section reads for the `contactsPerHost` quota.\n *\n * Sections are ROUTES, following the hubs that migrated before it (AGL-2501):\n * `/contacts/people` is the v1 list, and every other section is a URL of its\n * own, so the page builds one section's body and the others do not exist to\n * subscribe. The v1 body moved to `contacts-section.tsx` untouched; what this\n * file adds is the rail and the switch.\n */\nexport function CrmConsolePage(props: ConsolePluginPageProps) {\n const { section, sections, basePath, segments, orgMount } = props\n\n /*\n * Nothing until the URL names a section. The shell redirects a bare\n * `/contacts` to the landing section and holds a spinner while it does, so\n * this state is transient — and rendering the people list here instead\n * would pay for its listens on a URL that is already being replaced.\n */\n if (!section || !sections?.length || !basePath) return null\n\n const hub = (\n <HubSections sections={sections}>\n {sectionBody(\n section as CrmConsoleSectionId,\n props,\n // `segments[0]` IS the section — the shell resolved it into `section`\n // already — so what a section owns is everything after it.\n (segments ?? []).slice(1),\n basePath,\n )}\n </HubSections>\n )\n /*\n * THE ORGANIZATION-LEVEL MOUNT (AGL-2630). The shell hands an `orgMount`\n * only from `/[orgSlug]/crm`, where there is no site: the hub publishes it\n * so every section, record page and drawer beneath resolves its scope from\n * the org rather than from the `null` host it was handed. Under a site\n * there is no provider, and `useCrmOrgMount` answering `null` is how a\n * surface knows it is on one.\n */\n return orgMount ? (\n <CrmOrgMountProvider mount={orgMount}>{hub}</CrmOrgMountProvider>\n ) : (\n hub\n )\n}\nCrmConsolePage.displayName = 'CrmConsolePage'\n\nexport default CrmConsolePage\n"],"names":["HubSections","CrmOrgMountProvider","CompaniesSection","CompanyDetailPage","ContactDetailPage","DealDetailPage","DealsSection","FieldsSection","LeadDetailPage","LeadsSection","ContactsSection","ReportsSection","CrmSettingsSection","TasksSection","sectionBody","section","props","detail","basePath","record","recordProps","hostId","org","permissions","releaseFlag","hostRole","id","length","CrmConsolePage","sections","segments","orgMount","hub","slice","mount","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAGA,SAASA,WAAW,QAAQ,wBAAuB;AAEnD,SAASC,mBAAmB,QAAQ,gCAA4B;AAChE,OAAOC,sBAAsB,yBAAqB;AAClD,OAAOC,uBAAuB,2BAAuB;AACrD,OAAOC,uBAAuB,2BAAuB;AAErD,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,mBAAmB,sBAAkB;AAC5C,OAAOC,oBAAoB,wBAAoB;AAC/C,OAAOC,kBAAkB,qBAAiB;AAC1C,OAAOC,qBAAqB,wBAAoB;AAChD,OAAOC,oBAAoB,uBAAmB;AAC9C,OAAOC,wBAAwB,wBAAoB;AACnD,OAAOC,kBAAkB,qBAAiB;AAE1C;;;;;;;;;;;;;CAaC,GACD,SAASC,YACPC,OAA4B,EAC5BC,KAA6B,EAC7B,uDAAuD,GACvDC,MAAyB,EACzBC,QAAgB;IAEhB,MAAMC,SAASF,MAAM,CAAC,EAAE;IACxB,MAAMG,cAAc;QAClBC,QAAQL,MAAMK,MAAM;QACpBC,KAAKN,MAAMM,GAAG;QACdC,aAAaP,MAAMO,WAAW;QAC9BC,aAAaR,MAAMQ,WAAW;QAC9BC,UAAUT,MAAMS,QAAQ;QACxBP;IACF;IACA,OAAQH;QACN,KAAK;YACH,OAAOI,uBACL,KAACf,gCAAsBgB;gBAAaM,IAAIP;gCAExC,KAACT,8BAAoBM;QAEzB,KAAK;YACH;;;;;;;;;;;;;;OAcC,GACD,OAAOG,SACL,CAACH,MAAMK,MAAM,IAAIJ,OAAOU,MAAM,IAAI,kBAChC,KAACnB,6BAAmBY;gBAAaM,IAAIT,MAAM,CAAC,EAAE;gCAE9C,KAACT,6BAAmBY;gBAAaM,IAAIP;gCAGvC,KAACV,2BAAiBO;QAEtB,KAAK;YACH,OAAOG,uBACL,KAAChB,gCAAsBiB;gBAAaM,IAAIP;gCAExC,KAACjB;gBACCmB,QAAQL,MAAMK,MAAM;gBACpBC,KAAKN,MAAMM,GAAG;gBACdJ,UAAUA;;QAGhB,KAAK;YACH,OAAOC,uBACL,KAACd,6BAAmBe;gBAAaM,IAAIP;gCAErC,KAACb,2BAAiBU;QAEtB,KAAK;YACH,qBAAO,KAACH,2BAAiBG;QAC3B,KAAK;YACH,uEAAuE;YACvE,uEAAuE;YACvE,+CAA+C;YAC/C,qBAAO,KAACL,6BAAmBK;QAC7B,KAAK;YACH,qBAAO,KAACT;gBAAcc,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QAC5D,KAAK;YACH,qEAAqE;YACrE,4DAA4D;YAC5D,qBAAO,KAACV;gBAAmBS,QAAQL,MAAMK,MAAM;gBAAEC,KAAKN,MAAMM,GAAG;;QACjE;YACE,OAAO;IACX;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASM,eAAeZ,KAA6B;IAC1D,MAAM,EAAED,OAAO,EAAEc,QAAQ,EAAEX,QAAQ,EAAEY,QAAQ,EAAEC,QAAQ,EAAE,GAAGf;IAE5D;;;;;GAKC,GACD,IAAI,CAACD,WAAW,EAACc,4BAAAA,SAAUF,MAAM,KAAI,CAACT,UAAU,OAAO;IAEvD,MAAMc,oBACJ,KAAChC;QAAY6B,UAAUA;kBACpBf,YACCC,SACAC,OAGA,CAFA,sEAAsE;QACtE,2DAA2D;QAC1Dc,mBAAAA,WAAY,EAAE,EAAEG,KAAK,CAAC,IACvBf;;IAIN;;;;;;;GAOC,GACD,OAAOa,yBACL,KAAC9B;QAAoBiC,OAAOH;kBAAWC;SAEvCA;AAEJ;AACAJ,eAAeO,WAAW,GAAG;AAE7B,eAAeP,eAAc"}
|
|
@@ -259,9 +259,11 @@ const NO_MERGE_RECORDS = {
|
|
|
259
259
|
CRM_COLLECTIONS.deals,
|
|
260
260
|
dealId
|
|
261
261
|
] : null),
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
262
|
+
// One org row (AGL-3275); the site still decides WHICH lead is offered,
|
|
263
|
+
// but not where it lives.
|
|
264
|
+
read(leadId && orgId ? [
|
|
265
|
+
'orgs',
|
|
266
|
+
orgId,
|
|
265
267
|
'leads',
|
|
266
268
|
leadId
|
|
267
269
|
] : null),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-send-email-dialog.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 type AglynOrgBilling,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_SUBJECT_MAX,\n normalizeContactEmail,\n consentGroupForHost,\n CRM_COLLECTIONS,\n createResourceUid,\n type CrmEmailTemplateRow,\n type CrmMergeContext,\n type CrmMergeFieldGroup,\n crmMergeUnresolvedMessage,\n hasCrmMergeFields,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport type { ConsoleRecordEmailDraft } from '@aglyn/aglyn/plugin-manager/record-zone-props'\nimport { useSendingApi } from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\nimport { AppLink, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n InputAdornment,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, doc, getDoc, setDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { CrmRecordBookingZone } from './crm-booking-zone'\nimport { insertLinkAtCaret } from './insert-link-at-caret'\nimport {\n CAPTURE_ADDRESS_HELPER,\n CopyCaptureAddressButton,\n} from './copy-capture-address-button'\nimport { CrmSitePicker } from './crm-site-picker'\nimport { useCrmApi } from './use-crm-api'\nimport { useCrmInboundAddress } from './use-crm-inbound-address'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport {\n useCrmEmailTemplates,\n useCrmEmailTemplateScope,\n} from '../hooks/use-crm-email-templates'\nimport {\n CrmInsertMenu,\n CrmSaveAsTemplateDialog,\n CrmTemplatePicker,\n} from './crm-email-template-tools'\n\n/**\n * The documents a merge-field preview reads (AGL-2658), fetched once per\n * open and only once the draft names a field: a letter with no fields\n * costs no reads. `site` is the sending site's name; `loaded` says the\n * fetch has answered, so the helper text can tell \"no value\" from \"not\n * yet read\".\n */\ninterface MergeRecords {\n contact: Record<string, unknown> | null\n deal: Record<string, unknown> | null\n lead: Record<string, unknown> | null\n site: string | null\n loaded: boolean\n}\n\nconst NO_MERGE_RECORDS: MergeRecords = {\n contact: null,\n deal: null,\n lead: null,\n site: null,\n loaded: false,\n}\n\n/**\n * What the sending-identity route said about this site, as the dialog\n * needs it: an address to print, or the reason there is none — or, at the\n * organization level, that no site has been picked to ask about.\n */\ntype IdentityState =\n | { status: 'loading' }\n | { status: 'site' }\n | { status: 'ready'; from: string }\n | { status: 'refused'; message: string; canManage: boolean }\n | { status: 'error'; message: string }\n\nexport interface CrmSendEmailDialogProps {\n open: boolean\n onClose: () => void\n /**\n * The site the message leaves from — passed in, never assumed from the\n * URL. At the organization level the record's own capturing site, or\n * `null` for a record no site has captured (AGL-2634): the dialog then\n * offers the org's sites to send from, defaulting to the reader's pick.\n */\n hostId: string | null\n /** The org the shell passed; lets a deal's contact be read without a lookup. */\n org?: Partial<AglynOrgBilling> | null\n contactId?: string\n leadId?: string\n dealId?: string\n /**\n * The recipient as the record shows it. A deal's page holds no address,\n * so a caller with only a `contactId` may leave this out and the dialog\n * reads it off the contact when it opens.\n */\n email?: string | null\n /** What the dialog says it is writing to, beside the address. */\n name?: string | null\n /** Told once the route has accepted the message. */\n onSent?: (result: { activityId: string }) => void\n}\n\n/**\n * One email to one person, from their record (AGL-2615).\n *\n * ## What the reader sees, and what they cannot change\n *\n * To is the record's address and is read-only: the route resolves the\n * recipient off the record again, so an editable field here would be a\n * promise the server does not keep. From is the site's sending identity as\n * the sending-identity route reports it — the same answer the campaign\n * composer prints — and when that route refuses, the dialog says why and\n * points at **Emails › Sending** rather than offering a Send button that\n * 409s. Reply-to is the signed-in user's own address, because a reply to a\n * relationship email belongs in the inbox of the person who wrote it, not\n * the site's shared mailbox. Subject and Body are the whole of what a\n * person writes; the body is plain paragraphs, and the email design system\n * is deliberately not here — this is a letter, not a campaign.\n *\n * ## A refusal stays on screen\n *\n * The route's refusals — the daily cap, a suppressed address, a stated\n * refusal, the ceiling — are answers the rep should read, so they render\n * inside the dialog with the draft intact rather than as a toast over a\n * closed one. Only a send the provider accepted closes it.\n *\n * Mounted on open by its button, so the identity request and the contact\n * read happen when somebody asks and not on every record page paint.\n */\nexport function CrmSendEmailDialog(props: CrmSendEmailDialogProps) {\n const { open, onClose, hostId, org, contactId, leadId, dealId, onSent } = props\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: org?.$id ?? mount?.orgId,\n })\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const sendingApi = useSendingApi()\n /*\n * The site the message LEAVES FROM: the record's own, or at the\n * organization level — for a record no site captured — the site the\n * reader picked (AGL-2634). The identity, the suppression list and the\n * person's stated refusal are each that site's, which is why the org\n * level cannot do without one.\n */\n const sendHostId = hostId ?? mount?.createHostId ?? null\n const crmApi = useCrmApi(sendHostId)\n const emailsHub = useEmailsHubPath(sendHostId)\n // The capture address (AGL-2657), for the reply this send will get: the\n // console logs what IT sends, so the address is shown to be forwarded\n // to or copied from a mailbox, never put on this message.\n const capture = useCrmInboundAddress(sendHostId, { enabled: open })\n // The Sending section of the Emails console — the page that fixes a\n // missing identity. `null` on a surface that cannot name the site's hub,\n // so the refusal prints the section's name instead of a link to nowhere.\n const sendingPath = emailsHub ? `${emailsHub}/sending` : null\n\n const [identity, setIdentity] = useState<IdentityState>({ status: 'loading' })\n const [recipient, setRecipient] = useState<string | null>(\n normalizeContactEmail(props.email) ?? null,\n )\n const [subject, setSubject] = useState('')\n const [body, setBody] = useState('')\n const [sending, setSending] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n /*\n * TEMPLATES, SNIPPETS AND MERGE FIELDS (AGL-2658). The listener is the\n * dialog's own scope — the mounted site's tokens, or none at the org\n * level — and what a saved template is stamped with follows the same\n * rule. The message field's element is held so a snippet lands at the\n * caret rather than at the end.\n */\n const { confirm } = useConfirmationContext()\n const templateScope = useCrmEmailTemplateScope({ hostId, org })\n const { templates } = useCrmEmailTemplates({\n scope: templateScope.scope,\n visibleTo: templateScope.visibleTo,\n uid: user?.uid,\n })\n const pickable = useMemo(() => templates.filter((row) => row.kind === 'template'), [templates])\n const snippets = useMemo(() => templates.filter((row) => row.kind === 'snippet'), [templates])\n const [appliedTemplate, setAppliedTemplate] = useState<CrmEmailTemplateRow | null>(null)\n const [saveOpen, setSaveOpen] = useState(false)\n const [records, setRecords] = useState<MergeRecords>(NO_MERGE_RECORDS)\n const messageRef = useRef<HTMLTextAreaElement | null>(null)\n const draftHasFields = hasCrmMergeFields(`${subject}\\n${body}`)\n // The groups the Insert menu offers: what the record can answer.\n const fieldGroups = useMemo<CrmMergeFieldGroup[]>(\n () => [\n ...(contactId || dealId ? (['Contact'] as const) : []),\n ...(leadId && !contactId ? (['Lead'] as const) : []),\n ...(dealId ? (['Deal'] as const) : []),\n 'You',\n 'Site',\n ],\n [contactId, dealId, leadId],\n )\n\n /*\n * The identity, asked for on open. The route is the send path's own\n * resolver, so what it prints here is what the message will leave as; a\n * dialog that derived \"verified\" from the domain list would be a second\n * opinion that drifts from the send.\n */\n useEffect(() => {\n if (!open) return\n let cancelled = false\n setError(null)\n // The draft survives a change of sending site — the site is not the\n // letter — and is cleared only when the dialog opens.\n if (!sendHostId) {\n setIdentity({ status: 'site' })\n return undefined\n }\n setIdentity({ status: 'loading' })\n void (async () => {\n const { response, payload } = await sendingApi({\n path: 'sending-identity',\n method: 'GET',\n query: { hostId: sendHostId },\n })\n if (cancelled) return\n if (!response.ok) {\n setIdentity({\n status: 'error',\n message: String(payload['error'] ?? 'The sending identity could not be read.'),\n })\n return\n }\n const refusal = payload['refusal'] as { message?: string } | null\n if (refusal) {\n setIdentity({\n status: 'refused',\n message: String(refusal.message ?? 'This site has no verified sending identity.'),\n canManage: payload['canManage'] === true,\n })\n return\n }\n const senders = Array.isArray(payload['senders'])\n ? (payload['senders'] as { isDefault?: boolean; from?: string | null }[])\n : []\n const from =\n senders.find((sender) => sender.isDefault && sender.from)?.from ??\n senders.find((sender) => sender.from)?.from ??\n null\n setIdentity(\n from\n ? { status: 'ready', from }\n : {\n status: 'error',\n message: String(payload['identity'] ?? 'No sending address is in effect.'),\n },\n )\n })()\n return () => {\n cancelled = true\n }\n }, [open, sendHostId, sendingApi])\n useEffect(() => {\n if (!open) return\n setSubject('')\n setBody('')\n setAppliedTemplate(null)\n setSaveOpen(false)\n setRecords(NO_MERGE_RECORDS)\n }, [open])\n\n /*\n * The documents a preview reads, fetched the first time the draft names\n * a merge field and kept for the rest of the open: the contact, the\n * deal, the lead, and the sending site's name — off the org mount when\n * it knows the site, off the host document otherwise. The route reads\n * the same documents again for the send; these are what the rep sees.\n */\n useEffect(() => {\n if (!open || !draftHasFields || records.loaded) return\n let cancelled = false\n const read = async (path: string[] | null): Promise<Record<string, unknown> | null> => {\n if (!path) return null\n const snapshot = await getDoc(doc(firestore, path[0], ...path.slice(1)))\n return snapshot.exists() ? ((snapshot.data() ?? null) as Record<string, unknown> | null) : null\n }\n const mountedSite = sendHostId\n ? (mount?.hosts ?? []).find((host) => host.id === sendHostId)\n : undefined\n void Promise.all([\n read(contactId && orgId ? ['orgs', orgId, 'contacts', contactId] : null),\n read(dealId && orgId ? ['orgs', orgId, CRM_COLLECTIONS.deals, dealId] : null),\n read(leadId && sendHostId ? ['hosts', sendHostId, 'leads', leadId] : null),\n mountedSite || !sendHostId ? null : read(['hosts', sendHostId]),\n ])\n .then(([contact, deal, lead, host]) => {\n if (cancelled) return\n // A deal's contact, when the page named only the deal.\n const dealContactId = String(deal?.['contactId'] ?? '').trim()\n const withContact =\n !contact && dealContactId && orgId\n ? read(['orgs', orgId, 'contacts', dealContactId])\n : Promise.resolve(contact)\n return withContact.then((resolvedContact) => {\n if (cancelled) return\n setRecords({\n contact: resolvedContact,\n deal,\n lead,\n site: mountedSite?.name ?? (host ? String(host['displayName'] ?? '') : null),\n loaded: true,\n })\n })\n })\n .catch((cause) => {\n console.error(cause)\n if (!cancelled) setRecords({ ...NO_MERGE_RECORDS, loaded: true })\n })\n return () => {\n cancelled = true\n }\n }, [\n open,\n draftHasFields,\n records.loaded,\n firestore,\n orgId,\n contactId,\n dealId,\n leadId,\n sendHostId,\n mount?.hosts,\n ])\n\n const mergeContext = useMemo<CrmMergeContext>(\n () => ({\n contact: records.contact,\n contactGroupId: sendHostId ? consentGroupForHost(org ?? null, sendHostId).groupId : null,\n deal: records.deal,\n lead: records.lead,\n sender: { name: user?.displayName ?? '', email: user?.email ?? '' },\n site: { name: records.site ?? '' },\n }),\n [records, sendHostId, org, user?.displayName, user?.email],\n )\n const preview = useMemo(\n () =>\n draftHasFields && records.loaded\n ? {\n subject: resolveCrmMergeFields(subject, mergeContext),\n body: resolveCrmMergeFields(body, mergeContext),\n }\n : null,\n [draftHasFields, records.loaded, subject, body, mergeContext],\n )\n const unresolved = useMemo(\n () =>\n preview\n ? [...new Set([...preview.subject.unresolved, ...preview.body.unresolved])]\n : [],\n [preview],\n )\n\n /** A template into both fields — asking first when a message is already written. */\n const applyTemplate = useCallback(\n async (template: CrmEmailTemplateRow | null) => {\n if (!template) {\n setAppliedTemplate(null)\n return\n }\n const written = body.trim().length > 0 && body !== appliedTemplate?.body\n if (written) {\n const accepted = await confirm({\n title: 'Replace the message?',\n description: `\"${template.name}\" replaces what you have written in the subject and the message.`,\n confirmationText: 'Replace',\n })\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n if (template.subject) setSubject(template.subject)\n setBody(template.body)\n setAppliedTemplate(template)\n },\n [body, appliedTemplate, confirm],\n )\n\n /*\n * The `recordEmail` zone (AGL-2917), hosted through the shell's renderer: a\n * widget there proposes a subject and a message, which land in the fields\n * above like a template does, asking first when a message is already\n * written. Send is still the only write, and the zone reads the record the\n * message is written from: the deal over its contact, a lead on its own site,\n * and at the organization level a contact or a deal the org reads.\n */\n const EmailDraftSlot = useConsoleWidgetSlot()\n const proposeDraft = useCallback(\n async (draft: ConsoleRecordEmailDraft) => {\n const next = { subject: draft.subject.slice(0, CRM_EMAIL_SUBJECT_MAX), body: draft.body.slice(0, CRM_EMAIL_BODY_MAX) }\n if (body.trim() && body !== next.body) {\n const accepted = await confirm({\n title: 'Replace the message?',\n description: 'The draft replaces what you have written in the subject and the message.',\n confirmationText: 'Replace',\n })\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setSubject(next.subject)\n setBody(next.body)\n setAppliedTemplate(null)\n },\n [body, confirm],\n )\n const draftRecord = dealId\n ? { kind: 'deal', id: dealId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : contactId\n ? { kind: 'contact', id: contactId }\n : null\n\n /** A snippet or a field where the caret is, the caret moved past it. */\n const insertAtCaret = useCallback(\n (text: string) => {\n const element = messageRef.current\n const start = element?.selectionStart ?? body.length\n const end = element?.selectionEnd ?? body.length\n const next = `${body.slice(0, start)}${text}${body.slice(end)}`.slice(0, CRM_EMAIL_BODY_MAX)\n setBody(next)\n const caret = Math.min(start + text.length, next.length)\n window.setTimeout(() => {\n element?.focus()\n element?.setSelectionRange(caret, caret)\n }, 0)\n },\n [body],\n )\n\n /**\n * The draft kept as a template, stamped as this surface's scope stamps\n * every CRM record — the site's tokens under a site, the org token from\n * the organization's hub — and, when personal, owned by the writer.\n */\n const saveAsTemplate = useCallback(\n async (draft: { name: string; visibility: 'shared' | 'personal' }) => {\n const scope = templateScope.scope\n const uid = user?.uid\n if (!scope || !uid || templateScope.createTokens.length === 0) {\n throw new Error('a template needs an organization and a signed-in writer')\n }\n const now = Date.now()\n await setDoc(\n doc(collection(firestore, scope[0], scope[1], CRM_COLLECTIONS.emailTemplates), createResourceUid()),\n {\n name: draft.name,\n subject: subject.trim(),\n body: body.trim(),\n kind: 'template',\n visibility: draft.visibility,\n ...(draft.visibility === 'personal' ? { ownerUid: uid } : {}),\n createdByUid: uid,\n createdAtMs: now,\n updatedAtMs: now,\n hostId: templateScope.createHostId,\n visibleTo: [...templateScope.createTokens],\n createdAt: new Date(now),\n updatedAt: new Date(now),\n },\n )\n enqueueSnackbar(`Template \"${draft.name}\" saved`, { variant: 'success', persist: false })\n setSaveOpen(false)\n },\n [templateScope, user?.uid, firestore, subject, body, enqueueSnackbar],\n )\n\n /*\n * The address, when the caller had none: one read of the contact, on\n * open. The route reads it again for itself; this one is what the To\n * field prints, and a field that printed nothing until the send answered\n * would ask the rep to write to somebody unnamed.\n */\n useEffect(() => {\n if (!open) return\n const given = normalizeContactEmail(props.email)\n if (given) {\n setRecipient(given)\n return\n }\n if (!contactId || !orgId) return\n let cancelled = false\n void getDoc(doc(firestore, 'orgs', orgId, 'contacts', contactId))\n .then((snapshot) => {\n if (cancelled) return\n setRecipient(normalizeContactEmail(snapshot.get('email')) ?? null)\n })\n .catch((cause) => {\n console.error(cause)\n if (!cancelled) setRecipient(null)\n })\n return () => {\n cancelled = true\n }\n }, [open, props.email, contactId, orgId, firestore])\n\n const replyTo = normalizeContactEmail(user?.email) ?? ''\n const canSend =\n identity.status === 'ready' &&\n Boolean(recipient) &&\n subject.trim().length > 0 &&\n body.trim().length > 0 &&\n !sending\n\n const handleSend = useCallback(async () => {\n if (!canSend) return\n setSending(true)\n setError(null)\n try {\n const { response, payload } = await crmApi('email-send', {\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n ...(dealId ? { dealId } : {}),\n subject: subject.trim(),\n body: body.trim(),\n })\n if (!response.ok) {\n setError(String(payload['error'] ?? 'The email could not be sent.'))\n return\n }\n enqueueSnackbar('Email sent', { variant: 'success', persist: false })\n onSent?.({ activityId: String(payload['activityId'] ?? '') })\n onClose()\n } catch (cause) {\n console.error(cause)\n setError('The email could not be sent.')\n } finally {\n setSending(false)\n }\n }, [canSend, crmApi, contactId, leadId, dealId, subject, body, enqueueSnackbar, onSent, onClose])\n\n const toLabel = props.name && recipient ? `${props.name} <${recipient}>` : (recipient ?? '')\n\n /*\n * The booking door (AGL-2660): a link to one of the sending site's\n * services, dropped where the caret is. The link carries the most\n * specific record this message is about — the deal over its contact, so\n * the booking lands on the deal's timeline as the email does.\n */\n const bookingRef = dealId\n ? { kind: 'deal' as const, id: dealId }\n : leadId\n ? { kind: 'lead' as const, id: leadId }\n : contactId\n ? { kind: 'contact' as const, id: contactId }\n : null\n const handleInsertLink = useCallback((link: string) => {\n const input = messageRef.current\n const current = input?.value ?? ''\n const start = input?.selectionStart ?? current.length\n const end = input?.selectionEnd ?? start\n const next = insertLinkAtCaret(current, link, start, end)\n setBody(next.text)\n // Once the new value has painted, typing continues after the link.\n window.setTimeout(() => {\n input?.focus()\n input?.setSelectionRange(next.caret, next.caret)\n }, 0)\n }, [])\n\n return (\n <Dialog open={open} onClose={sending ? undefined : onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{'Send email'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {!hostId ? (\n <CrmSitePicker\n hostId={hostId}\n label=\"Send from\"\n helperText=\"The site whose sending address the email leaves on.\"\n disabled={sending}\n />\n ) : null}\n {identity.status === 'site' ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {'Pick the site the email leaves from.'}\n </Alert>\n ) : identity.status === 'refused' ? (\n <Alert severity=\"warning\" sx={{ mt: 1 }}>\n <Typography variant=\"body2\">{identity.message}</Typography>\n {sendingPath ? (\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n <AppLink href={sendingPath}>\n {identity.canManage ? 'Set up sending' : 'See sending'}\n </AppLink>\n </Typography>\n ) : (\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n {'An admin sets this up under Emails › Sending.'}\n </Typography>\n )}\n </Alert>\n ) : identity.status === 'error' ? (\n <Alert severity=\"error\" sx={{ mt: 1 }}>\n {identity.message}\n </Alert>\n ) : null}\n {open && !recipient && (identity.status === 'ready' || identity.status === 'loading') ? (\n <Alert severity=\"warning\" sx={{ mt: 1 }}>\n {'This record has no email address.'}\n </Alert>\n ) : null}\n <TextField\n size=\"small\"\n label=\"To\"\n value={toLabel}\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n sx={{ mt: identity.status === 'ready' || identity.status === 'loading' ? 1 : 0 }}\n />\n <TextField\n size=\"small\"\n label=\"From\"\n value={\n identity.status === 'ready'\n ? identity.from\n : identity.status === 'loading'\n ? 'Resolving the site’s sending address…'\n : identity.status === 'site'\n ? 'Pick a site to send from'\n : ''\n }\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n />\n <TextField\n size=\"small\"\n label=\"Reply-to\"\n value={replyTo}\n helperText=\"Replies come to you, not to the site's mailbox.\"\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n />\n {capture.status === 'ready' ? (\n <TextField\n size=\"small\"\n label=\"Log replies\"\n value={capture.address}\n helperText={CAPTURE_ADDRESS_HELPER}\n slotProps={{\n input: {\n readOnly: true,\n endAdornment: (\n <InputAdornment position=\"end\">\n <CopyCaptureAddressButton address={capture.address} />\n </InputAdornment>\n ),\n },\n inputLabel: { shrink: true },\n }}\n />\n ) : null}\n {pickable.length ? (\n <CrmTemplatePicker\n templates={pickable}\n value={appliedTemplate}\n onChange={(template) => void applyTemplate(template)}\n disabled={sending}\n />\n ) : null}\n <TextField\n size=\"small\"\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n autoFocus\n slotProps={{ htmlInput: { maxLength: CRM_EMAIL_SUBJECT_MAX } }}\n />\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <CrmInsertMenu\n snippets={snippets}\n groups={fieldGroups}\n disabled={sending}\n onInsert={insertAtCaret}\n />\n <Button\n size=\"small\"\n disabled={sending || !templateScope.scope || (!subject.trim() && !body.trim())}\n onClick={() => setSaveOpen(true)}\n >\n {'Save as template…'}\n </Button>\n </Stack>\n <TextField\n size=\"small\"\n label=\"Message\"\n value={body}\n onChange={(event) => setBody(event.target.value)}\n multiline\n minRows={6}\n inputRef={messageRef}\n helperText={\n unresolved.length\n ? crmMergeUnresolvedMessage(unresolved)\n : draftHasFields\n ? 'Plain text. Merge fields are filled in from this record when the email is sent.'\n : 'Plain text. A blank line starts a new paragraph.'\n }\n slotProps={{\n htmlInput: { maxLength: CRM_EMAIL_BODY_MAX },\n formHelperText: { sx: unresolved.length ? { color: 'warning.main' } : undefined },\n }}\n />\n {EmailDraftSlot && draftRecord ? (\n <EmailDraftSlot\n slot=\"recordEmail\"\n // A lead is read on its own site; a contact or a deal at the level\n // the composer was opened at.\n hostId={draftRecord.kind === 'lead' || !mount ? hostId : null}\n orgId={orgId ?? undefined}\n record={{ ...draftRecord, name: String(props.name ?? '') }}\n subject={subject}\n body={body}\n proposeDraft={(draft: ConsoleRecordEmailDraft) => void proposeDraft(draft)}\n />\n ) : null}\n {preview ? (\n <Stack spacing={0.5} data-testid=\"crm-email-preview\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Preview — as it will be sent'}\n </Typography>\n <Typography variant=\"subtitle2\">{preview.subject.text}</Typography>\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap' }}>\n {preview.body.text}\n </Typography>\n </Stack>\n ) : null}\n {bookingRef ? (\n <CrmRecordBookingZone\n variant=\"chip\"\n hostId={sendHostId}\n org={org}\n kind={bookingRef.kind}\n recordId={bookingRef.id}\n onInsert={handleInsertLink}\n />\n ) : null}\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n </DialogContent>\n <CrmSaveAsTemplateDialog\n open={saveOpen}\n onClose={() => setSaveOpen(false)}\n onSave={saveAsTemplate}\n />\n <DialogActions>\n <Button onClick={onClose} disabled={sending}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" color=\"primary\" disabled={!canSend} onClick={handleSend}>\n {sending ? 'Sending…' : 'Send'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nCrmSendEmailDialog.displayName = 'CrmSendEmailDialog'\n\nexport default CrmSendEmailDialog\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","normalizeContactEmail","consentGroupForHost","CRM_COLLECTIONS","createResourceUid","crmMergeUnresolvedMessage","hasCrmMergeFields","resolveCrmMergeFields","useConsoleWidgetSlot","useSendingApi","AppLink","useConfirmationContext","useSnackbar","useFirestore","useOrgDataScope","useUser","Alert","Button","Dialog","DialogActions","DialogContent","DialogTitle","InputAdornment","Stack","TextField","Typography","collection","doc","getDoc","setDoc","useCallback","useEffect","useMemo","useRef","useState","useCrmOrgMount","CrmRecordBookingZone","insertLinkAtCaret","CAPTURE_ADDRESS_HELPER","CopyCaptureAddressButton","CrmSitePicker","useCrmApi","useCrmInboundAddress","useEmailsHubPath","useCrmEmailTemplates","useCrmEmailTemplateScope","CrmInsertMenu","CrmSaveAsTemplateDialog","CrmTemplatePicker","NO_MERGE_RECORDS","contact","deal","lead","site","loaded","CrmSendEmailDialog","props","hostId","open","onClose","org","contactId","leadId","dealId","onSent","firestore","mount","orgId","undefined","$id","data","user","enqueueSnackbar","sendingApi","sendHostId","createHostId","crmApi","emailsHub","capture","enabled","sendingPath","identity","setIdentity","status","recipient","setRecipient","email","subject","setSubject","body","setBody","sending","setSending","error","setError","confirm","templateScope","templates","scope","visibleTo","uid","pickable","filter","row","kind","snippets","appliedTemplate","setAppliedTemplate","saveOpen","setSaveOpen","records","setRecords","messageRef","draftHasFields","fieldGroups","cancelled","senders","payload","response","path","method","query","ok","message","String","refusal","canManage","Array","isArray","from","find","sender","isDefault","read","snapshot","slice","exists","mountedSite","hosts","host","id","Promise","all","deals","then","dealContactId","trim","withContact","resolve","resolvedContact","name","catch","cause","console","mergeContext","contactGroupId","groupId","displayName","preview","unresolved","Set","applyTemplate","template","written","length","accepted","title","description","confirmationText","EmailDraftSlot","proposeDraft","draft","next","draftRecord","insertAtCaret","text","element","current","start","selectionStart","end","selectionEnd","caret","Math","min","window","setTimeout","focus","setSelectionRange","saveAsTemplate","createTokens","Error","now","Date","emailTemplates","visibility","ownerUid","createdByUid","createdAtMs","updatedAtMs","createdAt","updatedAt","variant","persist","given","get","replyTo","canSend","Boolean","handleSend","activityId","toLabel","bookingRef","handleInsertLink","link","input","value","maxWidth","fullWidth","sx","display","flexDirection","gap","label","helperText","disabled","severity","mt","href","size","slotProps","readOnly","inputLabel","shrink","address","endAdornment","position","onChange","event","target","autoFocus","htmlInput","maxLength","direction","spacing","justifyContent","groups","onInsert","onClick","multiline","minRows","inputRef","formHelperText","color","slot","record","data-testid","whiteSpace","recordId","onSave"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/crm-send-email-dialog.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 type AglynOrgBilling,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_SUBJECT_MAX,\n normalizeContactEmail,\n consentGroupForHost,\n CRM_COLLECTIONS,\n createResourceUid,\n type CrmEmailTemplateRow,\n type CrmMergeContext,\n type CrmMergeFieldGroup,\n crmMergeUnresolvedMessage,\n hasCrmMergeFields,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport type { ConsoleRecordEmailDraft } from '@aglyn/aglyn/plugin-manager/record-zone-props'\nimport { useSendingApi } from '@aglyn/tenant-feature-instance/hooks/use-sending-identity-api'\nimport { AppLink, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useOrgDataScope,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n InputAdornment,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, doc, getDoc, setDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { CrmRecordBookingZone } from './crm-booking-zone'\nimport { insertLinkAtCaret } from './insert-link-at-caret'\nimport {\n CAPTURE_ADDRESS_HELPER,\n CopyCaptureAddressButton,\n} from './copy-capture-address-button'\nimport { CrmSitePicker } from './crm-site-picker'\nimport { useCrmApi } from './use-crm-api'\nimport { useCrmInboundAddress } from './use-crm-inbound-address'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport {\n useCrmEmailTemplates,\n useCrmEmailTemplateScope,\n} from '../hooks/use-crm-email-templates'\nimport {\n CrmInsertMenu,\n CrmSaveAsTemplateDialog,\n CrmTemplatePicker,\n} from './crm-email-template-tools'\n\n/**\n * The documents a merge-field preview reads (AGL-2658), fetched once per\n * open and only once the draft names a field: a letter with no fields\n * costs no reads. `site` is the sending site's name; `loaded` says the\n * fetch has answered, so the helper text can tell \"no value\" from \"not\n * yet read\".\n */\ninterface MergeRecords {\n contact: Record<string, unknown> | null\n deal: Record<string, unknown> | null\n lead: Record<string, unknown> | null\n site: string | null\n loaded: boolean\n}\n\nconst NO_MERGE_RECORDS: MergeRecords = {\n contact: null,\n deal: null,\n lead: null,\n site: null,\n loaded: false,\n}\n\n/**\n * What the sending-identity route said about this site, as the dialog\n * needs it: an address to print, or the reason there is none — or, at the\n * organization level, that no site has been picked to ask about.\n */\ntype IdentityState =\n | { status: 'loading' }\n | { status: 'site' }\n | { status: 'ready'; from: string }\n | { status: 'refused'; message: string; canManage: boolean }\n | { status: 'error'; message: string }\n\nexport interface CrmSendEmailDialogProps {\n open: boolean\n onClose: () => void\n /**\n * The site the message leaves from — passed in, never assumed from the\n * URL. At the organization level the record's own capturing site, or\n * `null` for a record no site has captured (AGL-2634): the dialog then\n * offers the org's sites to send from, defaulting to the reader's pick.\n */\n hostId: string | null\n /** The org the shell passed; lets a deal's contact be read without a lookup. */\n org?: Partial<AglynOrgBilling> | null\n contactId?: string\n leadId?: string\n dealId?: string\n /**\n * The recipient as the record shows it. A deal's page holds no address,\n * so a caller with only a `contactId` may leave this out and the dialog\n * reads it off the contact when it opens.\n */\n email?: string | null\n /** What the dialog says it is writing to, beside the address. */\n name?: string | null\n /** Told once the route has accepted the message. */\n onSent?: (result: { activityId: string }) => void\n}\n\n/**\n * One email to one person, from their record (AGL-2615).\n *\n * ## What the reader sees, and what they cannot change\n *\n * To is the record's address and is read-only: the route resolves the\n * recipient off the record again, so an editable field here would be a\n * promise the server does not keep. From is the site's sending identity as\n * the sending-identity route reports it — the same answer the campaign\n * composer prints — and when that route refuses, the dialog says why and\n * points at **Emails › Sending** rather than offering a Send button that\n * 409s. Reply-to is the signed-in user's own address, because a reply to a\n * relationship email belongs in the inbox of the person who wrote it, not\n * the site's shared mailbox. Subject and Body are the whole of what a\n * person writes; the body is plain paragraphs, and the email design system\n * is deliberately not here — this is a letter, not a campaign.\n *\n * ## A refusal stays on screen\n *\n * The route's refusals — the daily cap, a suppressed address, a stated\n * refusal, the ceiling — are answers the rep should read, so they render\n * inside the dialog with the draft intact rather than as a toast over a\n * closed one. Only a send the provider accepted closes it.\n *\n * Mounted on open by its button, so the identity request and the contact\n * read happen when somebody asks and not on every record page paint.\n */\nexport function CrmSendEmailDialog(props: CrmSendEmailDialogProps) {\n const { open, onClose, hostId, org, contactId, leadId, dealId, onSent } = props\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const { orgId } = useOrgDataScope({\n hostId: hostId ?? undefined,\n orgId: org?.$id ?? mount?.orgId,\n })\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const sendingApi = useSendingApi()\n /*\n * The site the message LEAVES FROM: the record's own, or at the\n * organization level — for a record no site captured — the site the\n * reader picked (AGL-2634). The identity, the suppression list and the\n * person's stated refusal are each that site's, which is why the org\n * level cannot do without one.\n */\n const sendHostId = hostId ?? mount?.createHostId ?? null\n const crmApi = useCrmApi(sendHostId)\n const emailsHub = useEmailsHubPath(sendHostId)\n // The capture address (AGL-2657), for the reply this send will get: the\n // console logs what IT sends, so the address is shown to be forwarded\n // to or copied from a mailbox, never put on this message.\n const capture = useCrmInboundAddress(sendHostId, { enabled: open })\n // The Sending section of the Emails console — the page that fixes a\n // missing identity. `null` on a surface that cannot name the site's hub,\n // so the refusal prints the section's name instead of a link to nowhere.\n const sendingPath = emailsHub ? `${emailsHub}/sending` : null\n\n const [identity, setIdentity] = useState<IdentityState>({ status: 'loading' })\n const [recipient, setRecipient] = useState<string | null>(\n normalizeContactEmail(props.email) ?? null,\n )\n const [subject, setSubject] = useState('')\n const [body, setBody] = useState('')\n const [sending, setSending] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n /*\n * TEMPLATES, SNIPPETS AND MERGE FIELDS (AGL-2658). The listener is the\n * dialog's own scope — the mounted site's tokens, or none at the org\n * level — and what a saved template is stamped with follows the same\n * rule. The message field's element is held so a snippet lands at the\n * caret rather than at the end.\n */\n const { confirm } = useConfirmationContext()\n const templateScope = useCrmEmailTemplateScope({ hostId, org })\n const { templates } = useCrmEmailTemplates({\n scope: templateScope.scope,\n visibleTo: templateScope.visibleTo,\n uid: user?.uid,\n })\n const pickable = useMemo(() => templates.filter((row) => row.kind === 'template'), [templates])\n const snippets = useMemo(() => templates.filter((row) => row.kind === 'snippet'), [templates])\n const [appliedTemplate, setAppliedTemplate] = useState<CrmEmailTemplateRow | null>(null)\n const [saveOpen, setSaveOpen] = useState(false)\n const [records, setRecords] = useState<MergeRecords>(NO_MERGE_RECORDS)\n const messageRef = useRef<HTMLTextAreaElement | null>(null)\n const draftHasFields = hasCrmMergeFields(`${subject}\\n${body}`)\n // The groups the Insert menu offers: what the record can answer.\n const fieldGroups = useMemo<CrmMergeFieldGroup[]>(\n () => [\n ...(contactId || dealId ? (['Contact'] as const) : []),\n ...(leadId && !contactId ? (['Lead'] as const) : []),\n ...(dealId ? (['Deal'] as const) : []),\n 'You',\n 'Site',\n ],\n [contactId, dealId, leadId],\n )\n\n /*\n * The identity, asked for on open. The route is the send path's own\n * resolver, so what it prints here is what the message will leave as; a\n * dialog that derived \"verified\" from the domain list would be a second\n * opinion that drifts from the send.\n */\n useEffect(() => {\n if (!open) return\n let cancelled = false\n setError(null)\n // The draft survives a change of sending site — the site is not the\n // letter — and is cleared only when the dialog opens.\n if (!sendHostId) {\n setIdentity({ status: 'site' })\n return undefined\n }\n setIdentity({ status: 'loading' })\n void (async () => {\n const { response, payload } = await sendingApi({\n path: 'sending-identity',\n method: 'GET',\n query: { hostId: sendHostId },\n })\n if (cancelled) return\n if (!response.ok) {\n setIdentity({\n status: 'error',\n message: String(payload['error'] ?? 'The sending identity could not be read.'),\n })\n return\n }\n const refusal = payload['refusal'] as { message?: string } | null\n if (refusal) {\n setIdentity({\n status: 'refused',\n message: String(refusal.message ?? 'This site has no verified sending identity.'),\n canManage: payload['canManage'] === true,\n })\n return\n }\n const senders = Array.isArray(payload['senders'])\n ? (payload['senders'] as { isDefault?: boolean; from?: string | null }[])\n : []\n const from =\n senders.find((sender) => sender.isDefault && sender.from)?.from ??\n senders.find((sender) => sender.from)?.from ??\n null\n setIdentity(\n from\n ? { status: 'ready', from }\n : {\n status: 'error',\n message: String(payload['identity'] ?? 'No sending address is in effect.'),\n },\n )\n })()\n return () => {\n cancelled = true\n }\n }, [open, sendHostId, sendingApi])\n useEffect(() => {\n if (!open) return\n setSubject('')\n setBody('')\n setAppliedTemplate(null)\n setSaveOpen(false)\n setRecords(NO_MERGE_RECORDS)\n }, [open])\n\n /*\n * The documents a preview reads, fetched the first time the draft names\n * a merge field and kept for the rest of the open: the contact, the\n * deal, the lead, and the sending site's name — off the org mount when\n * it knows the site, off the host document otherwise. The route reads\n * the same documents again for the send; these are what the rep sees.\n */\n useEffect(() => {\n if (!open || !draftHasFields || records.loaded) return\n let cancelled = false\n const read = async (path: string[] | null): Promise<Record<string, unknown> | null> => {\n if (!path) return null\n const snapshot = await getDoc(doc(firestore, path[0], ...path.slice(1)))\n return snapshot.exists() ? ((snapshot.data() ?? null) as Record<string, unknown> | null) : null\n }\n const mountedSite = sendHostId\n ? (mount?.hosts ?? []).find((host) => host.id === sendHostId)\n : undefined\n void Promise.all([\n read(contactId && orgId ? ['orgs', orgId, 'contacts', contactId] : null),\n read(dealId && orgId ? ['orgs', orgId, CRM_COLLECTIONS.deals, dealId] : null),\n // One org row (AGL-3275); the site still decides WHICH lead is offered,\n // but not where it lives.\n read(leadId && orgId ? ['orgs', orgId, 'leads', leadId] : null),\n mountedSite || !sendHostId ? null : read(['hosts', sendHostId]),\n ])\n .then(([contact, deal, lead, host]) => {\n if (cancelled) return\n // A deal's contact, when the page named only the deal.\n const dealContactId = String(deal?.['contactId'] ?? '').trim()\n const withContact =\n !contact && dealContactId && orgId\n ? read(['orgs', orgId, 'contacts', dealContactId])\n : Promise.resolve(contact)\n return withContact.then((resolvedContact) => {\n if (cancelled) return\n setRecords({\n contact: resolvedContact,\n deal,\n lead,\n site: mountedSite?.name ?? (host ? String(host['displayName'] ?? '') : null),\n loaded: true,\n })\n })\n })\n .catch((cause) => {\n console.error(cause)\n if (!cancelled) setRecords({ ...NO_MERGE_RECORDS, loaded: true })\n })\n return () => {\n cancelled = true\n }\n }, [\n open,\n draftHasFields,\n records.loaded,\n firestore,\n orgId,\n contactId,\n dealId,\n leadId,\n sendHostId,\n mount?.hosts,\n ])\n\n const mergeContext = useMemo<CrmMergeContext>(\n () => ({\n contact: records.contact,\n contactGroupId: sendHostId ? consentGroupForHost(org ?? null, sendHostId).groupId : null,\n deal: records.deal,\n lead: records.lead,\n sender: { name: user?.displayName ?? '', email: user?.email ?? '' },\n site: { name: records.site ?? '' },\n }),\n [records, sendHostId, org, user?.displayName, user?.email],\n )\n const preview = useMemo(\n () =>\n draftHasFields && records.loaded\n ? {\n subject: resolveCrmMergeFields(subject, mergeContext),\n body: resolveCrmMergeFields(body, mergeContext),\n }\n : null,\n [draftHasFields, records.loaded, subject, body, mergeContext],\n )\n const unresolved = useMemo(\n () =>\n preview\n ? [...new Set([...preview.subject.unresolved, ...preview.body.unresolved])]\n : [],\n [preview],\n )\n\n /** A template into both fields — asking first when a message is already written. */\n const applyTemplate = useCallback(\n async (template: CrmEmailTemplateRow | null) => {\n if (!template) {\n setAppliedTemplate(null)\n return\n }\n const written = body.trim().length > 0 && body !== appliedTemplate?.body\n if (written) {\n const accepted = await confirm({\n title: 'Replace the message?',\n description: `\"${template.name}\" replaces what you have written in the subject and the message.`,\n confirmationText: 'Replace',\n })\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n if (template.subject) setSubject(template.subject)\n setBody(template.body)\n setAppliedTemplate(template)\n },\n [body, appliedTemplate, confirm],\n )\n\n /*\n * The `recordEmail` zone (AGL-2917), hosted through the shell's renderer: a\n * widget there proposes a subject and a message, which land in the fields\n * above like a template does, asking first when a message is already\n * written. Send is still the only write, and the zone reads the record the\n * message is written from: the deal over its contact, a lead on its own site,\n * and at the organization level a contact or a deal the org reads.\n */\n const EmailDraftSlot = useConsoleWidgetSlot()\n const proposeDraft = useCallback(\n async (draft: ConsoleRecordEmailDraft) => {\n const next = { subject: draft.subject.slice(0, CRM_EMAIL_SUBJECT_MAX), body: draft.body.slice(0, CRM_EMAIL_BODY_MAX) }\n if (body.trim() && body !== next.body) {\n const accepted = await confirm({\n title: 'Replace the message?',\n description: 'The draft replaces what you have written in the subject and the message.',\n confirmationText: 'Replace',\n })\n .then(() => true)\n .catch(() => false)\n if (!accepted) return\n }\n setSubject(next.subject)\n setBody(next.body)\n setAppliedTemplate(null)\n },\n [body, confirm],\n )\n const draftRecord = dealId\n ? { kind: 'deal', id: dealId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : contactId\n ? { kind: 'contact', id: contactId }\n : null\n\n /** A snippet or a field where the caret is, the caret moved past it. */\n const insertAtCaret = useCallback(\n (text: string) => {\n const element = messageRef.current\n const start = element?.selectionStart ?? body.length\n const end = element?.selectionEnd ?? body.length\n const next = `${body.slice(0, start)}${text}${body.slice(end)}`.slice(0, CRM_EMAIL_BODY_MAX)\n setBody(next)\n const caret = Math.min(start + text.length, next.length)\n window.setTimeout(() => {\n element?.focus()\n element?.setSelectionRange(caret, caret)\n }, 0)\n },\n [body],\n )\n\n /**\n * The draft kept as a template, stamped as this surface's scope stamps\n * every CRM record — the site's tokens under a site, the org token from\n * the organization's hub — and, when personal, owned by the writer.\n */\n const saveAsTemplate = useCallback(\n async (draft: { name: string; visibility: 'shared' | 'personal' }) => {\n const scope = templateScope.scope\n const uid = user?.uid\n if (!scope || !uid || templateScope.createTokens.length === 0) {\n throw new Error('a template needs an organization and a signed-in writer')\n }\n const now = Date.now()\n await setDoc(\n doc(collection(firestore, scope[0], scope[1], CRM_COLLECTIONS.emailTemplates), createResourceUid()),\n {\n name: draft.name,\n subject: subject.trim(),\n body: body.trim(),\n kind: 'template',\n visibility: draft.visibility,\n ...(draft.visibility === 'personal' ? { ownerUid: uid } : {}),\n createdByUid: uid,\n createdAtMs: now,\n updatedAtMs: now,\n hostId: templateScope.createHostId,\n visibleTo: [...templateScope.createTokens],\n createdAt: new Date(now),\n updatedAt: new Date(now),\n },\n )\n enqueueSnackbar(`Template \"${draft.name}\" saved`, { variant: 'success', persist: false })\n setSaveOpen(false)\n },\n [templateScope, user?.uid, firestore, subject, body, enqueueSnackbar],\n )\n\n /*\n * The address, when the caller had none: one read of the contact, on\n * open. The route reads it again for itself; this one is what the To\n * field prints, and a field that printed nothing until the send answered\n * would ask the rep to write to somebody unnamed.\n */\n useEffect(() => {\n if (!open) return\n const given = normalizeContactEmail(props.email)\n if (given) {\n setRecipient(given)\n return\n }\n if (!contactId || !orgId) return\n let cancelled = false\n void getDoc(doc(firestore, 'orgs', orgId, 'contacts', contactId))\n .then((snapshot) => {\n if (cancelled) return\n setRecipient(normalizeContactEmail(snapshot.get('email')) ?? null)\n })\n .catch((cause) => {\n console.error(cause)\n if (!cancelled) setRecipient(null)\n })\n return () => {\n cancelled = true\n }\n }, [open, props.email, contactId, orgId, firestore])\n\n const replyTo = normalizeContactEmail(user?.email) ?? ''\n const canSend =\n identity.status === 'ready' &&\n Boolean(recipient) &&\n subject.trim().length > 0 &&\n body.trim().length > 0 &&\n !sending\n\n const handleSend = useCallback(async () => {\n if (!canSend) return\n setSending(true)\n setError(null)\n try {\n const { response, payload } = await crmApi('email-send', {\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n ...(dealId ? { dealId } : {}),\n subject: subject.trim(),\n body: body.trim(),\n })\n if (!response.ok) {\n setError(String(payload['error'] ?? 'The email could not be sent.'))\n return\n }\n enqueueSnackbar('Email sent', { variant: 'success', persist: false })\n onSent?.({ activityId: String(payload['activityId'] ?? '') })\n onClose()\n } catch (cause) {\n console.error(cause)\n setError('The email could not be sent.')\n } finally {\n setSending(false)\n }\n }, [canSend, crmApi, contactId, leadId, dealId, subject, body, enqueueSnackbar, onSent, onClose])\n\n const toLabel = props.name && recipient ? `${props.name} <${recipient}>` : (recipient ?? '')\n\n /*\n * The booking door (AGL-2660): a link to one of the sending site's\n * services, dropped where the caret is. The link carries the most\n * specific record this message is about — the deal over its contact, so\n * the booking lands on the deal's timeline as the email does.\n */\n const bookingRef = dealId\n ? { kind: 'deal' as const, id: dealId }\n : leadId\n ? { kind: 'lead' as const, id: leadId }\n : contactId\n ? { kind: 'contact' as const, id: contactId }\n : null\n const handleInsertLink = useCallback((link: string) => {\n const input = messageRef.current\n const current = input?.value ?? ''\n const start = input?.selectionStart ?? current.length\n const end = input?.selectionEnd ?? start\n const next = insertLinkAtCaret(current, link, start, end)\n setBody(next.text)\n // Once the new value has painted, typing continues after the link.\n window.setTimeout(() => {\n input?.focus()\n input?.setSelectionRange(next.caret, next.caret)\n }, 0)\n }, [])\n\n return (\n <Dialog open={open} onClose={sending ? undefined : onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{'Send email'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {!hostId ? (\n <CrmSitePicker\n hostId={hostId}\n label=\"Send from\"\n helperText=\"The site whose sending address the email leaves on.\"\n disabled={sending}\n />\n ) : null}\n {identity.status === 'site' ? (\n <Alert severity=\"info\" sx={{ mt: 1 }}>\n {'Pick the site the email leaves from.'}\n </Alert>\n ) : identity.status === 'refused' ? (\n <Alert severity=\"warning\" sx={{ mt: 1 }}>\n <Typography variant=\"body2\">{identity.message}</Typography>\n {sendingPath ? (\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n <AppLink href={sendingPath}>\n {identity.canManage ? 'Set up sending' : 'See sending'}\n </AppLink>\n </Typography>\n ) : (\n <Typography variant=\"body2\" sx={{ mt: 0.5 }}>\n {'An admin sets this up under Emails › Sending.'}\n </Typography>\n )}\n </Alert>\n ) : identity.status === 'error' ? (\n <Alert severity=\"error\" sx={{ mt: 1 }}>\n {identity.message}\n </Alert>\n ) : null}\n {open && !recipient && (identity.status === 'ready' || identity.status === 'loading') ? (\n <Alert severity=\"warning\" sx={{ mt: 1 }}>\n {'This record has no email address.'}\n </Alert>\n ) : null}\n <TextField\n size=\"small\"\n label=\"To\"\n value={toLabel}\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n sx={{ mt: identity.status === 'ready' || identity.status === 'loading' ? 1 : 0 }}\n />\n <TextField\n size=\"small\"\n label=\"From\"\n value={\n identity.status === 'ready'\n ? identity.from\n : identity.status === 'loading'\n ? 'Resolving the site’s sending address…'\n : identity.status === 'site'\n ? 'Pick a site to send from'\n : ''\n }\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n />\n <TextField\n size=\"small\"\n label=\"Reply-to\"\n value={replyTo}\n helperText=\"Replies come to you, not to the site's mailbox.\"\n slotProps={{ input: { readOnly: true }, inputLabel: { shrink: true } }}\n />\n {capture.status === 'ready' ? (\n <TextField\n size=\"small\"\n label=\"Log replies\"\n value={capture.address}\n helperText={CAPTURE_ADDRESS_HELPER}\n slotProps={{\n input: {\n readOnly: true,\n endAdornment: (\n <InputAdornment position=\"end\">\n <CopyCaptureAddressButton address={capture.address} />\n </InputAdornment>\n ),\n },\n inputLabel: { shrink: true },\n }}\n />\n ) : null}\n {pickable.length ? (\n <CrmTemplatePicker\n templates={pickable}\n value={appliedTemplate}\n onChange={(template) => void applyTemplate(template)}\n disabled={sending}\n />\n ) : null}\n <TextField\n size=\"small\"\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n autoFocus\n slotProps={{ htmlInput: { maxLength: CRM_EMAIL_SUBJECT_MAX } }}\n />\n <Stack direction=\"row\" spacing={1} sx={{ justifyContent: 'flex-end' }}>\n <CrmInsertMenu\n snippets={snippets}\n groups={fieldGroups}\n disabled={sending}\n onInsert={insertAtCaret}\n />\n <Button\n size=\"small\"\n disabled={sending || !templateScope.scope || (!subject.trim() && !body.trim())}\n onClick={() => setSaveOpen(true)}\n >\n {'Save as template…'}\n </Button>\n </Stack>\n <TextField\n size=\"small\"\n label=\"Message\"\n value={body}\n onChange={(event) => setBody(event.target.value)}\n multiline\n minRows={6}\n inputRef={messageRef}\n helperText={\n unresolved.length\n ? crmMergeUnresolvedMessage(unresolved)\n : draftHasFields\n ? 'Plain text. Merge fields are filled in from this record when the email is sent.'\n : 'Plain text. A blank line starts a new paragraph.'\n }\n slotProps={{\n htmlInput: { maxLength: CRM_EMAIL_BODY_MAX },\n formHelperText: { sx: unresolved.length ? { color: 'warning.main' } : undefined },\n }}\n />\n {EmailDraftSlot && draftRecord ? (\n <EmailDraftSlot\n slot=\"recordEmail\"\n // A lead is read on its own site; a contact or a deal at the level\n // the composer was opened at.\n hostId={draftRecord.kind === 'lead' || !mount ? hostId : null}\n orgId={orgId ?? undefined}\n record={{ ...draftRecord, name: String(props.name ?? '') }}\n subject={subject}\n body={body}\n proposeDraft={(draft: ConsoleRecordEmailDraft) => void proposeDraft(draft)}\n />\n ) : null}\n {preview ? (\n <Stack spacing={0.5} data-testid=\"crm-email-preview\">\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Preview — as it will be sent'}\n </Typography>\n <Typography variant=\"subtitle2\">{preview.subject.text}</Typography>\n <Typography variant=\"body2\" sx={{ whiteSpace: 'pre-wrap' }}>\n {preview.body.text}\n </Typography>\n </Stack>\n ) : null}\n {bookingRef ? (\n <CrmRecordBookingZone\n variant=\"chip\"\n hostId={sendHostId}\n org={org}\n kind={bookingRef.kind}\n recordId={bookingRef.id}\n onInsert={handleInsertLink}\n />\n ) : null}\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n </DialogContent>\n <CrmSaveAsTemplateDialog\n open={saveOpen}\n onClose={() => setSaveOpen(false)}\n onSave={saveAsTemplate}\n />\n <DialogActions>\n <Button onClick={onClose} disabled={sending}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" color=\"primary\" disabled={!canSend} onClick={handleSend}>\n {sending ? 'Sending…' : 'Send'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nCrmSendEmailDialog.displayName = 'CrmSendEmailDialog'\n\nexport default CrmSendEmailDialog\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","normalizeContactEmail","consentGroupForHost","CRM_COLLECTIONS","createResourceUid","crmMergeUnresolvedMessage","hasCrmMergeFields","resolveCrmMergeFields","useConsoleWidgetSlot","useSendingApi","AppLink","useConfirmationContext","useSnackbar","useFirestore","useOrgDataScope","useUser","Alert","Button","Dialog","DialogActions","DialogContent","DialogTitle","InputAdornment","Stack","TextField","Typography","collection","doc","getDoc","setDoc","useCallback","useEffect","useMemo","useRef","useState","useCrmOrgMount","CrmRecordBookingZone","insertLinkAtCaret","CAPTURE_ADDRESS_HELPER","CopyCaptureAddressButton","CrmSitePicker","useCrmApi","useCrmInboundAddress","useEmailsHubPath","useCrmEmailTemplates","useCrmEmailTemplateScope","CrmInsertMenu","CrmSaveAsTemplateDialog","CrmTemplatePicker","NO_MERGE_RECORDS","contact","deal","lead","site","loaded","CrmSendEmailDialog","props","hostId","open","onClose","org","contactId","leadId","dealId","onSent","firestore","mount","orgId","undefined","$id","data","user","enqueueSnackbar","sendingApi","sendHostId","createHostId","crmApi","emailsHub","capture","enabled","sendingPath","identity","setIdentity","status","recipient","setRecipient","email","subject","setSubject","body","setBody","sending","setSending","error","setError","confirm","templateScope","templates","scope","visibleTo","uid","pickable","filter","row","kind","snippets","appliedTemplate","setAppliedTemplate","saveOpen","setSaveOpen","records","setRecords","messageRef","draftHasFields","fieldGroups","cancelled","senders","payload","response","path","method","query","ok","message","String","refusal","canManage","Array","isArray","from","find","sender","isDefault","read","snapshot","slice","exists","mountedSite","hosts","host","id","Promise","all","deals","then","dealContactId","trim","withContact","resolve","resolvedContact","name","catch","cause","console","mergeContext","contactGroupId","groupId","displayName","preview","unresolved","Set","applyTemplate","template","written","length","accepted","title","description","confirmationText","EmailDraftSlot","proposeDraft","draft","next","draftRecord","insertAtCaret","text","element","current","start","selectionStart","end","selectionEnd","caret","Math","min","window","setTimeout","focus","setSelectionRange","saveAsTemplate","createTokens","Error","now","Date","emailTemplates","visibility","ownerUid","createdByUid","createdAtMs","updatedAtMs","createdAt","updatedAt","variant","persist","given","get","replyTo","canSend","Boolean","handleSend","activityId","toLabel","bookingRef","handleInsertLink","link","input","value","maxWidth","fullWidth","sx","display","flexDirection","gap","label","helperText","disabled","severity","mt","href","size","slotProps","readOnly","inputLabel","shrink","address","endAdornment","position","onChange","event","target","autoFocus","htmlInput","maxLength","direction","spacing","justifyContent","groups","onInsert","onClick","multiline","minRows","inputRef","formHelperText","color","slot","record","data-testid","whiteSpace","recordId","onSave"],"mappings":"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"}
|
|
@@ -86,6 +86,13 @@ import { useCrmScope } from "../hooks/use-crm-scope.js";
|
|
|
86
86
|
const empty = optionsReady && !options.length;
|
|
87
87
|
const save = async ()=>{
|
|
88
88
|
if (unchanged) return;
|
|
89
|
+
if (!orgId) {
|
|
90
|
+
enqueueSnackbar('Still loading this workspace — try again in a moment.', {
|
|
91
|
+
variant: 'warning',
|
|
92
|
+
persist: false
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
89
96
|
setSaving(true);
|
|
90
97
|
const next = campaignMembershipValue(selected);
|
|
91
98
|
const verdict = await writeGuardedBySeed({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-campaigns-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 {\n campaignMembershipUnchanged,\n campaignMembershipValue,\n pluginDocsHelp,\n readCampaignIds,\n} from '@aglyn/aglyn'\nimport CampaignPicker, {\n type CampaignPickerOption,\n} from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Stack } from '@mui/material'\nimport { doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\n/** The helper line under the picker — the contact card's, word for word. */\nexport const LEAD_CAMPAIGNS_HELPER_TEXT =\n 'Your own filing. It never adds anyone to a send — a campaign mails its lists.'\n\n/**\n * The campaigns a lead is filed under, as NAMES for the record header.\n *\n * Resolved from the site's containers; an id with no live container — a\n * campaign the console deleted, or one still loading — is left out rather\n * than shown, because a document id on a chip tells a reader nothing and\n * looks like a fault. The picker below keeps such an id visible as itself,\n * so the filing is never silently lost; the header only ever names.\n */\nexport function leadCampaignNames(\n lead: Record<string, unknown> | null | undefined,\n options: readonly CampaignPickerOption[],\n): string[] {\n const labels = new Map(options.map((option) => [option.value, option.label]))\n return readCampaignIds(lead)\n .map((id) => labels.get(id))\n .filter((name): name is string => Boolean(name))\n}\n\nexport interface LeadCampaignsCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n /** The site's campaigns, read once by the page for this card and the header. */\n options: readonly CampaignPickerOption[]\n /** The campaign read has answered — false while it settles. */\n optionsReady: boolean\n /**\n * What a saved filing changed, once it has landed: the ids added to and\n * removed from the lead's membership. The page files the activity\n * entries (AGL-3274); the card only knows the document.\n */\n onFiled?: (change: { added: string[]; removed: string[] }) => void\n}\n\n/**\n * WHICH CAMPAIGNS THIS LEAD IS FILED UNDER (AGL-3274).\n *\n * The contact's Relationship card has carried this picker since AGL-2596;\n * the lead's page had none, so a lead filed under a campaign by the New\n * lead drawer, the bulk bar or a sequence's enroll showed nothing of it —\n * the one field on the record the reader had no way to see or change.\n *\n * The same control, the same value helper, the same helper line. The save\n * is the lead's own client door — a one-field `update` on\n * `hosts/{hostId}/leads`, as the properties card writes status and notes —\n * guarded by the seed like every draft edited over a cached read, and\n * `campaignMembershipUnchanged` keeps Save quiet while the picker only\n * reorders what is stored.\n *\n * It is FILING, not audience: the helper says so, in the sentence the\n * contact card uses, because a reader who finds a campaign picker on a\n * person's page assumes the opposite.\n */\nexport function LeadCampaignsCard(props: LeadCampaignsCardProps) {\n const { hostId, leadId, lead, leadStatus, fromCache, options, optionsReady, onFiled } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const stored = readCampaignIds(lead)\n const [selected, setSelected] = useState<string[]>(stored)\n const [dirty, setDirty] = useState(false)\n const [saving, setSaving] = useState(false)\n // A newer membership from the server replaces an UNEDITED selection — a\n // sequence's enroll or a bulk add lands while the page is open — and an\n // edited one is the reader's, decided by the guard on save.\n const storedKey = stored.join(',')\n useEffect(() => {\n if (!dirty) setSelected(stored)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [storedKey, dirty])\n\n const unchanged = campaignMembershipUnchanged(stored, selected)\n const empty = optionsReady && !options.length\n\n const save = async () => {\n if (unchanged) return\n setSaving(true)\n const next = campaignMembershipValue(selected)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n async () => {\n await updateDoc(doc(firestore, 'orgs', orgId, 'leads', leadId), {\n campaignIds: next,\n updatedAt: serverTimestamp(),\n })\n },\n )\n setSaving(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The filing could not be saved.', { variant: 'warning' })\n return\n }\n setDirty(false)\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n onFiled?.({\n added: next.filter((id) => !stored.includes(id)),\n removed: stored.filter((id) => !next.includes(id)),\n })\n }\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <CampaignPicker\n options={options}\n value={selected}\n onChange={(next) => {\n setSelected(next)\n setDirty(true)\n }}\n label=\"Filed under campaigns\"\n helperText={LEAD_CAMPAIGNS_HELPER_TEXT}\n disabled={saving}\n empty={empty}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n {empty ? null : (\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || unchanged}\n onClick={() => void save().catch((error: unknown) => {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n setSaving(false)\n })}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nLeadCampaignsCard.displayName = 'LeadCampaignsCard'\n\nexport default LeadCampaignsCard\n"],"names":["campaignMembershipUnchanged","campaignMembershipValue","pluginDocsHelp","readCampaignIds","CampaignPicker","CardDisplay","useSnackbar","useFirestore","writeGuardedBySeed","Button","Stack","doc","serverTimestamp","updateDoc","useEffect","useState","useCrmScope","LEAD_CAMPAIGNS_HELPER_TEXT","leadCampaignNames","lead","options","labels","Map","map","option","value","label","id","get","filter","name","Boolean","LeadCampaignsCard","props","hostId","leadId","leadStatus","fromCache","optionsReady","onFiled","orgId","firestore","enqueueSnackbar","stored","selected","setSelected","dirty","setDirty","saving","setSaving","storedKey","join","unchanged","empty","length","save","next","verdict","subject","unreadable","campaignIds","updatedAt","ok","message","variant","persist","added","includes","removed","header","help","anchor","contentGutterX","contentGutterY","spacing","onChange","helperText","disabled","emptyText","direction","size","onClick","catch","error","console","Error","allowDuplicate","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-campaigns-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 {\n campaignMembershipUnchanged,\n campaignMembershipValue,\n pluginDocsHelp,\n readCampaignIds,\n} from '@aglyn/aglyn'\nimport CampaignPicker, {\n type CampaignPickerOption,\n} from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Stack } from '@mui/material'\nimport { doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { useCrmScope } from '../hooks/use-crm-scope'\n\n/** The helper line under the picker — the contact card's, word for word. */\nexport const LEAD_CAMPAIGNS_HELPER_TEXT =\n 'Your own filing. It never adds anyone to a send — a campaign mails its lists.'\n\n/**\n * The campaigns a lead is filed under, as NAMES for the record header.\n *\n * Resolved from the site's containers; an id with no live container — a\n * campaign the console deleted, or one still loading — is left out rather\n * than shown, because a document id on a chip tells a reader nothing and\n * looks like a fault. The picker below keeps such an id visible as itself,\n * so the filing is never silently lost; the header only ever names.\n */\nexport function leadCampaignNames(\n lead: Record<string, unknown> | null | undefined,\n options: readonly CampaignPickerOption[],\n): string[] {\n const labels = new Map(options.map((option) => [option.value, option.label]))\n return readCampaignIds(lead)\n .map((id) => labels.get(id))\n .filter((name): name is string => Boolean(name))\n}\n\nexport interface LeadCampaignsCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n /** The site's campaigns, read once by the page for this card and the header. */\n options: readonly CampaignPickerOption[]\n /** The campaign read has answered — false while it settles. */\n optionsReady: boolean\n /**\n * What a saved filing changed, once it has landed: the ids added to and\n * removed from the lead's membership. The page files the activity\n * entries (AGL-3274); the card only knows the document.\n */\n onFiled?: (change: { added: string[]; removed: string[] }) => void\n}\n\n/**\n * WHICH CAMPAIGNS THIS LEAD IS FILED UNDER (AGL-3274).\n *\n * The contact's Relationship card has carried this picker since AGL-2596;\n * the lead's page had none, so a lead filed under a campaign by the New\n * lead drawer, the bulk bar or a sequence's enroll showed nothing of it —\n * the one field on the record the reader had no way to see or change.\n *\n * The same control, the same value helper, the same helper line. The save\n * is the lead's own client door — a one-field `update` on\n * `hosts/{hostId}/leads`, as the properties card writes status and notes —\n * guarded by the seed like every draft edited over a cached read, and\n * `campaignMembershipUnchanged` keeps Save quiet while the picker only\n * reorders what is stored.\n *\n * It is FILING, not audience: the helper says so, in the sentence the\n * contact card uses, because a reader who finds a campaign picker on a\n * person's page assumes the opposite.\n */\nexport function LeadCampaignsCard(props: LeadCampaignsCardProps) {\n const { hostId, leadId, lead, leadStatus, fromCache, options, optionsReady, onFiled } = props\n // The lead's collection is the org's now (AGL-3275); the site still names\n // the surface, and the scope hook resolves the org from it.\n const { orgId } = useCrmScope({ hostId })\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const stored = readCampaignIds(lead)\n const [selected, setSelected] = useState<string[]>(stored)\n const [dirty, setDirty] = useState(false)\n const [saving, setSaving] = useState(false)\n // A newer membership from the server replaces an UNEDITED selection — a\n // sequence's enroll or a bulk add lands while the page is open — and an\n // edited one is the reader's, decided by the guard on save.\n const storedKey = stored.join(',')\n useEffect(() => {\n if (!dirty) setSelected(stored)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [storedKey, dirty])\n\n const unchanged = campaignMembershipUnchanged(stored, selected)\n const empty = optionsReady && !options.length\n\n const save = async () => {\n if (unchanged) return\n if (!orgId) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return\n }\n setSaving(true)\n const next = campaignMembershipValue(selected)\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n async () => {\n await updateDoc(doc(firestore, 'orgs', orgId, 'leads', leadId), {\n campaignIds: next,\n updatedAt: serverTimestamp(),\n })\n },\n )\n setSaving(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The filing could not be saved.', { variant: 'warning' })\n return\n }\n setDirty(false)\n enqueueSnackbar('Filing saved', { variant: 'success', persist: false })\n onFiled?.({\n added: next.filter((id) => !stored.includes(id)),\n removed: stored.filter((id) => !next.includes(id)),\n })\n }\n\n return (\n <CardDisplay\n header={'Campaigns'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <CampaignPicker\n options={options}\n value={selected}\n onChange={(next) => {\n setSelected(next)\n setDirty(true)\n }}\n label=\"Filed under campaigns\"\n helperText={LEAD_CAMPAIGNS_HELPER_TEXT}\n disabled={saving}\n empty={empty}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n {empty ? null : (\n <Stack direction=\"row\">\n <Button\n size=\"small\"\n variant=\"outlined\"\n disabled={saving || unchanged}\n onClick={() => void save().catch((error: unknown) => {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n setSaving(false)\n })}\n >\n {saving ? 'Saving…' : 'Save filing'}\n </Button>\n </Stack>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nLeadCampaignsCard.displayName = 'LeadCampaignsCard'\n\nexport default LeadCampaignsCard\n"],"names":["campaignMembershipUnchanged","campaignMembershipValue","pluginDocsHelp","readCampaignIds","CampaignPicker","CardDisplay","useSnackbar","useFirestore","writeGuardedBySeed","Button","Stack","doc","serverTimestamp","updateDoc","useEffect","useState","useCrmScope","LEAD_CAMPAIGNS_HELPER_TEXT","leadCampaignNames","lead","options","labels","Map","map","option","value","label","id","get","filter","name","Boolean","LeadCampaignsCard","props","hostId","leadId","leadStatus","fromCache","optionsReady","onFiled","orgId","firestore","enqueueSnackbar","stored","selected","setSelected","dirty","setDirty","saving","setSaving","storedKey","join","unchanged","empty","length","save","variant","persist","next","verdict","subject","unreadable","campaignIds","updatedAt","ok","message","added","includes","removed","header","help","anchor","contentGutterX","contentGutterY","spacing","onChange","helperText","disabled","emptyText","direction","size","onClick","catch","error","console","Error","allowDuplicate","displayName"],"mappings":"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"}
|