@aglyn/plugins-crm 1.0.0-beta.152 → 1.0.0-beta.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/lead-convert-dialog.js +4 -2
  3. package/src/lib/components/lead-convert-dialog.js.map +1 -1
  4. package/src/lib/components/lead-history-card.js +3 -0
  5. package/src/lib/components/lead-history-card.js.map +1 -1
  6. package/src/lib/components/lead-properties-card.js +209 -1
  7. package/src/lib/components/lead-properties-card.js.map +1 -1
  8. package/src/lib/components/lead-surfaces-note.d.ts +7 -3
  9. package/src/lib/components/lead-surfaces-note.js +6 -2
  10. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  11. package/src/lib/components/leads-section.js +114 -2
  12. package/src/lib/components/leads-section.js.map +1 -1
  13. package/src/lib/components/new-lead-drawer.d.ts +56 -0
  14. package/src/lib/components/new-lead-drawer.js +327 -0
  15. package/src/lib/components/new-lead-drawer.js.map +1 -0
  16. package/src/lib/components/use-crm-api.d.ts +1 -1
  17. package/src/lib/components/use-crm-api.js.map +1 -1
  18. package/src/lib/model/crm-lead-import.d.ts +19 -3
  19. package/src/lib/model/crm-lead-import.js +159 -6
  20. package/src/lib/model/crm-lead-import.js.map +1 -1
  21. package/src/lib/model/lead-company-suggestion.d.ts +18 -3
  22. package/src/lib/model/lead-company-suggestion.js +41 -11
  23. package/src/lib/model/lead-company-suggestion.js.map +1 -1
  24. package/src/lib/server/capture-contact.d.ts +28 -0
  25. package/src/lib/server/capture-contact.js +139 -0
  26. package/src/lib/server/capture-contact.js.map +1 -1
  27. package/src/lib/server/convert-open-lead.d.ts +32 -0
  28. package/src/lib/server/convert-open-lead.js +91 -0
  29. package/src/lib/server/convert-open-lead.js.map +1 -0
  30. package/src/lib/server/lead-create.d.ts +76 -0
  31. package/src/lib/server/lead-create.js +227 -0
  32. package/src/lib/server/lead-create.js.map +1 -0
  33. package/src/lib/server/leads-import.js +7 -2
  34. package/src/lib/server/leads-import.js.map +1 -1
  35. package/src/lib/server/record-timeline.js +4 -2
  36. package/src/lib/server/record-timeline.js.map +1 -1
  37. package/src/lib/server.js +3 -0
  38. package/src/lib/server.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.152",
3
+ "version": "1.0.0-beta.153",
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.152",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.152",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.152",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.152",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.152",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.152",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.152",
35
- "@aglyn/shared-util-email": "1.0.0-beta.152",
36
- "@aglyn/shared-util-http": "1.0.0-beta.152",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.152",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.152",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.152",
28
+ "@aglyn/aglyn": "1.0.0-beta.153",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.153",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.153",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.153",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.153",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.153",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.153",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.153",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.153",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.153",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.153",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.153",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -151,7 +151,8 @@ import { LeadOwnerSelect } from "./lead-owner-select.js";
151
151
  if (!open || companiesStatus !== 'success') return;
152
152
  if (seededFor.current === leadId) return;
153
153
  seededFor.current = leadId;
154
- const suggestion = suggestCompanyForLead(email, companies);
154
+ // The lead's own company text first (AGL-3233), then the domain.
155
+ const suggestion = suggestCompanyForLead(email, companies, lead.company);
155
156
  setCompanyMode(suggestion.mode);
156
157
  if (suggestion.mode === 'existing') setCompanyId(suggestion.companyId);
157
158
  if (suggestion.mode === 'new') {
@@ -163,7 +164,8 @@ import { LeadOwnerSelect } from "./lead-owner-select.js";
163
164
  companiesStatus,
164
165
  companies,
165
166
  email,
166
- leadId
167
+ leadId,
168
+ lead.company
167
169
  ]);
168
170
  const amountCents = dollarsToCents(dealAmount);
169
171
  const amountInvalid = amountCents === undefined;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-convert-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 * as Aglyn from '@aglyn/aglyn'\nimport type { CrmDealStage, CrmLeadFields } from '@aglyn/aglyn'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Checkbox,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n FormControl,\n FormControlLabel,\n InputLabel,\n MenuItem,\n Radio,\n RadioGroup,\n Select,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useId, useMemo, useRef, useState } from 'react'\nimport {\n dollarsToCents,\n suggestCompanyForLead,\n} from '../model/lead-company-suggestion'\nimport { crmRoutes } from '../model/crm-routes'\nimport type { LeadConvertRequest, LeadConvertResponse } from '../server/lead-convert'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\n\n/**\n * How many companies the picker loads. Ordered by name, so a bigger org sees\n * its first two hundred alphabetically and the domain match still finds the\n * right one when it is among them; past that the converter creates and the\n * route dedupes by domain server-side, which is the backstop this ceiling\n * relies on.\n */\nconst COMPANY_OPTIONS_CEILING = 200\n\ntype CompanyMode = 'none' | 'existing' | 'new'\n\nexport interface LeadConvertDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null | undefined\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n basePath: string\n roster: OrgMemberOptions\n}\n\n/**\n * Convert a lead into a contact, and optionally a company and a deal\n * (AGL-2608).\n *\n * The dialog collects the decisions and posts ONE request to\n * `crm/lead-convert`; it writes nothing itself, because the contact can only\n * come from the server's dedupe door and the rest of the conversion has to\n * follow it in order. What it does own is the suggestion: the company the\n * lead's email domain implies, matched against the companies this reader can\n * see, so the common case is a glance and a click.\n *\n * Both reads open only while the dialog is — a converter who never opens it\n * pays for neither the company list nor the pipeline.\n */\nexport function LeadConvertDialog(props: LeadConvertDialogProps) {\n const { open, onClose, hostId, orgId, org, leadId, lead, basePath, roster } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n /*\n * The scope this reader may see — the same tokens every CRM listener\n * filters on, and the rules refuse a list without them. The Leads list\n * mounts the dialog closed at the organization level with no site at all\n * (the row it converts brings one), and a consent group must name a site,\n * so until then the scope is the organization's token alone — nothing reads\n * it while the dialog is closed.\n */\n const visibleToTokens = useMemo(() => {\n const group = hostId ? Aglyn.consentGroupForHost(org ?? {}, hostId) : null\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...(group?.hostIds ?? []).map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, hostId])\n const tokensKey = visibleToTokens.join(',')\n\n const { data: companies, status: companiesStatus } = useFirestoreCollection<\n Record<string, unknown> & { $id: string; name?: string; domain?: string }\n >(\n () =>\n open && orgId\n ? query(\n collection(firestore, 'orgs', orgId, Aglyn.CRM_COLLECTIONS.companies),\n where('visibleTo', 'array-contains-any', visibleToTokens),\n orderBy('nameLower'),\n limit(COMPANY_OPTIONS_CEILING),\n )\n : null,\n [firestore, orgId, open, tokensKey],\n { idField: '$id' },\n )\n const { data: pipelines } = useFirestoreCollection<\n Record<string, unknown> & { $id: string; isDefault?: boolean; stages?: CrmDealStage[] }\n >(\n () =>\n open && orgId\n ? query(\n collection(firestore, 'orgs', orgId, Aglyn.CRM_COLLECTIONS.pipelines),\n where('visibleTo', 'array-contains-any', visibleToTokens),\n orderBy(documentId()),\n limit(20),\n )\n : null,\n [firestore, orgId, open, tokensKey],\n { idField: '$id' },\n )\n /*\n * The pipeline the deal will open in — the route's own choice, mirrored so\n * the stage picker offers the stages the deal will really have: the flagged\n * default, else the first by id, else the stages the route will seed.\n */\n const pipeline = pipelines.find((entry) => entry.isDefault) ?? pipelines[0]\n const stages = useMemo(\n () =>\n [...(pipeline?.stages ?? Aglyn.DEFAULT_DEAL_STAGES)]\n .filter((stage) => stage.kind === 'open')\n .sort((a, b) => a.order - b.order),\n [pipeline],\n )\n\n const email = String(lead['email'] ?? '')\n const leadLabel = String(lead['name'] || email || leadId)\n\n const [ownerUid, setOwnerUid] = useState('')\n const [companyMode, setCompanyMode] = useState<CompanyMode>('none')\n const [companyId, setCompanyId] = useState('')\n const [companyName, setCompanyName] = useState('')\n const [companyDomain, setCompanyDomain] = useState('')\n const [dealOn, setDealOn] = useState(false)\n const [dealTitle, setDealTitle] = useState('')\n const [dealAmount, setDealAmount] = useState('')\n const [dealCurrency, setDealCurrency] = useState('usd')\n const [dealStageId, setDealStageId] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n // The labels' ids, so the two comboboxes are named \"Company\" and \"Stage\"\n // rather than after whatever they show — see `LeadOwnerSelect`.\n const companyLabelId = useId()\n const stageLabelId = useId()\n\n /*\n * Reset on every open, and seed the company step ONCE per open when the\n * company list has answered — not on every snapshot, or a converter who\n * changed their mind would have the suggestion put back under them.\n */\n const seededFor = useRef<string | null>(null)\n useEffect(() => {\n if (!open) {\n seededFor.current = null\n return\n }\n setOwnerUid(String(lead.ownerUid ?? user?.uid ?? ''))\n setDealOn(false)\n setDealTitle(leadLabel)\n setDealAmount('')\n setDealCurrency('usd')\n setDealStageId('')\n setError(null)\n setCompanyMode('none')\n setCompanyId('')\n setCompanyName('')\n setCompanyDomain('')\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open, leadId])\n useEffect(() => {\n if (!open || companiesStatus !== 'success') return\n if (seededFor.current === leadId) return\n seededFor.current = leadId\n const suggestion = suggestCompanyForLead(email, companies)\n setCompanyMode(suggestion.mode)\n if (suggestion.mode === 'existing') setCompanyId(suggestion.companyId)\n if (suggestion.mode === 'new') {\n setCompanyName(suggestion.name)\n setCompanyDomain(suggestion.domain)\n }\n }, [open, companiesStatus, companies, email, leadId])\n\n const amountCents = dollarsToCents(dealAmount)\n const amountInvalid = amountCents === undefined\n const submittable =\n !busy &&\n (companyMode !== 'new' || companyName.trim().length > 0) &&\n (companyMode !== 'existing' || Boolean(companyId)) &&\n (!dealOn || (dealTitle.trim().length > 0 && !amountInvalid))\n\n const submit = async () => {\n if (!submittable) return\n setBusy(true)\n setError(null)\n const body: LeadConvertRequest = {\n hostId,\n leadId,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyMode === 'existing' ? { companyId } : {}),\n ...(companyMode === 'new'\n ? {\n createCompany: {\n name: companyName.trim(),\n ...(companyDomain.trim() ? { domain: companyDomain.trim() } : {}),\n },\n }\n : {}),\n ...(dealOn\n ? {\n deal: {\n title: dealTitle.trim(),\n ...(typeof amountCents === 'number' ? { amountCents } : {}),\n currency: dealCurrency.trim().toLowerCase() || 'usd',\n ...(dealStageId ? { stageId: dealStageId } : {}),\n },\n }\n : {}),\n }\n try {\n const response = await authorizedFetch(user, '/api/crm/lead-convert', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n })\n const answer = (await response.json().catch(() => ({}))) as\n | LeadConvertResponse\n | { error?: string }\n if (!response.ok || !('ok' in answer)) {\n setError(\n String((answer as { error?: string }).error ?? 'The lead could not be converted.'),\n )\n return\n }\n enqueueSnackbar(\n answer.alreadyConverted\n ? 'This lead was already converted — opening the contact'\n : 'Lead converted',\n { variant: 'success', persist: false },\n )\n onClose()\n router.push(crmRoutes(basePath).contact(answer.contactId))\n } catch (cause) {\n setError(cause instanceof Error ? cause.message : 'The lead could not be converted.')\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <Dialog open={open} onClose={busy ? undefined : onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{`Convert ${leadLabel}`}</DialogTitle>\n <DialogContent>\n <Stack spacing={3} sx={{ pt: 1 }}>\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Contact'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`${email} becomes a contact at the Sales qualified stage — or ` +\n 'joins the one this address already has.'}\n </Typography>\n <LeadOwnerSelect value={ownerUid} onChange={setOwnerUid} roster={roster} />\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Company'}</Typography>\n <RadioGroup\n value={companyMode}\n onChange={(event) => setCompanyMode(event.target.value as CompanyMode)}\n >\n <FormControlLabel value=\"none\" control={<Radio size=\"small\" />} label=\"No company\" />\n <FormControlLabel\n value=\"existing\"\n control={<Radio size=\"small\" />}\n label=\"Link an existing company\"\n disabled={companies.length === 0}\n />\n <FormControlLabel\n value=\"new\"\n control={<Radio size=\"small\" />}\n label=\"Create a company\"\n />\n </RadioGroup>\n {companyMode === 'existing' ? (\n <FormControl size=\"small\" fullWidth>\n <InputLabel id={companyLabelId}>{'Company'}</InputLabel>\n <Select\n labelId={companyLabelId}\n label=\"Company\"\n value={companyId}\n onChange={(event) => setCompanyId(String(event.target.value))}\n >\n {companies.map((company) => (\n <MenuItem key={company.$id} value={company.$id}>\n {String(company.name ?? company.$id)}\n {company.domain ? (\n <Typography\n component=\"span\"\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ ml: 1 }}\n >\n {String(company.domain)}\n </Typography>\n ) : null}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n ) : null}\n {companyMode === 'new' ? (\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>\n <TextField\n size=\"small\"\n label=\"Company name\"\n value={companyName}\n onChange={(event) => setCompanyName(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 120 } }}\n />\n <TextField\n size=\"small\"\n label=\"Domain\"\n value={companyDomain}\n onChange={(event) => setCompanyDomain(event.target.value)}\n helperText=\"Used to match future contacts to this company\"\n fullWidth\n />\n </Stack>\n ) : null}\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <FormControlLabel\n control={\n <Checkbox\n checked={dealOn}\n onChange={(event) => setDealOn(event.target.checked)}\n />\n }\n label={<Typography variant=\"subtitle2\">{'Open a deal'}</Typography>}\n />\n {dealOn ? (\n <Stack spacing={1}>\n <TextField\n size=\"small\"\n label=\"Deal title\"\n value={dealTitle}\n onChange={(event) => setDealTitle(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>\n <TextField\n size=\"small\"\n label=\"Amount\"\n value={dealAmount}\n onChange={(event) => setDealAmount(event.target.value)}\n error={amountInvalid}\n helperText={amountInvalid ? 'Enter an amount like 1200 or 1,200.50' : ' '}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Currency\"\n value={dealCurrency}\n onChange={(event) => setDealCurrency(event.target.value)}\n helperText=\" \"\n sx={{ width: { sm: 140 } }}\n slotProps={{ htmlInput: { maxLength: 3 } }}\n />\n </Stack>\n <FormControl size=\"small\" fullWidth>\n {/* `shrink`, because `displayEmpty` below draws the \"First\n stage\" placeholder where an empty value would leave the\n field blank — and a label that has not shrunk sits on\n top of it. */}\n <InputLabel id={stageLabelId} shrink>\n {'Stage'}\n </InputLabel>\n <Select\n labelId={stageLabelId}\n label=\"Stage\"\n notched\n value={dealStageId}\n onChange={(event) => setDealStageId(String(event.target.value))}\n displayEmpty\n >\n <MenuItem value=\"\">\n <em>{stages[0] ? `First stage (${stages[0].name})` : 'First stage'}</em>\n </MenuItem>\n {stages.map((stage) => (\n <MenuItem key={stage.id} value={stage.id}>\n {stage.name}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {!pipeline ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This workspace has no pipeline yet — a Sales pipeline ' +\n 'with the default stages is created with the deal.'}\n </Typography>\n ) : null}\n </Stack>\n ) : null}\n </Stack>\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" onClick={() => void submit()} disabled={!submittable}>\n {busy ? 'Converting…' : 'Convert'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLeadConvertDialog.displayName = 'LeadConvertDialog'\n\nexport default LeadConvertDialog\n"],"names":["Aglyn","authorizedFetch","useSnackbar","useFirestore","useFirestoreCollection","useUser","Alert","Button","Checkbox","Dialog","DialogActions","DialogContent","DialogTitle","Divider","FormControl","FormControlLabel","InputLabel","MenuItem","Radio","RadioGroup","Select","Stack","TextField","Typography","collection","documentId","limit","orderBy","query","where","useRouter","useEffect","useId","useMemo","useRef","useState","dollarsToCents","suggestCompanyForLead","crmRoutes","LeadOwnerSelect","COMPANY_OPTIONS_CEILING","LeadConvertDialog","props","pipelines","lead","open","onClose","hostId","orgId","org","leadId","basePath","roster","firestore","router","data","user","enqueueSnackbar","visibleToTokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","tokensKey","join","companies","status","companiesStatus","CRM_COLLECTIONS","idField","pipeline","find","entry","isDefault","stages","DEFAULT_DEAL_STAGES","filter","stage","kind","sort","a","b","order","email","String","leadLabel","ownerUid","setOwnerUid","companyMode","setCompanyMode","companyId","setCompanyId","companyName","setCompanyName","companyDomain","setCompanyDomain","dealOn","setDealOn","dealTitle","setDealTitle","dealAmount","setDealAmount","dealCurrency","setDealCurrency","dealStageId","setDealStageId","busy","setBusy","error","setError","companyLabelId","stageLabelId","seededFor","current","uid","suggestion","mode","name","domain","amountCents","amountInvalid","undefined","submittable","trim","length","Boolean","submit","body","createCompany","deal","title","currency","toLowerCase","stageId","response","method","headers","JSON","stringify","answer","json","catch","ok","alreadyConverted","variant","persist","push","contact","contactId","cause","Error","message","maxWidth","fullWidth","spacing","sx","pt","color","value","onChange","event","target","control","size","label","disabled","labelId","company","$id","component","ml","direction","xs","sm","slotProps","htmlInput","maxLength","helperText","checked","width","shrink","notched","displayEmpty","em","severity","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-convert-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 * as Aglyn from '@aglyn/aglyn'\nimport type { CrmDealStage, CrmLeadFields } from '@aglyn/aglyn'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n Checkbox,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Divider,\n FormControl,\n FormControlLabel,\n InputLabel,\n MenuItem,\n Radio,\n RadioGroup,\n Select,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n documentId,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useId, useMemo, useRef, useState } from 'react'\nimport {\n dollarsToCents,\n suggestCompanyForLead,\n} from '../model/lead-company-suggestion'\nimport { crmRoutes } from '../model/crm-routes'\nimport type { LeadConvertRequest, LeadConvertResponse } from '../server/lead-convert'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\n\n/**\n * How many companies the picker loads. Ordered by name, so a bigger org sees\n * its first two hundred alphabetically and the domain match still finds the\n * right one when it is among them; past that the converter creates and the\n * route dedupes by domain server-side, which is the backstop this ceiling\n * relies on.\n */\nconst COMPANY_OPTIONS_CEILING = 200\n\ntype CompanyMode = 'none' | 'existing' | 'new'\n\nexport interface LeadConvertDialogProps {\n open: boolean\n onClose: () => void\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null | undefined\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n basePath: string\n roster: OrgMemberOptions\n}\n\n/**\n * Convert a lead into a contact, and optionally a company and a deal\n * (AGL-2608).\n *\n * The dialog collects the decisions and posts ONE request to\n * `crm/lead-convert`; it writes nothing itself, because the contact can only\n * come from the server's dedupe door and the rest of the conversion has to\n * follow it in order. What it does own is the suggestion: the company the\n * lead's email domain implies, matched against the companies this reader can\n * see, so the common case is a glance and a click.\n *\n * Both reads open only while the dialog is — a converter who never opens it\n * pays for neither the company list nor the pipeline.\n */\nexport function LeadConvertDialog(props: LeadConvertDialogProps) {\n const { open, onClose, hostId, orgId, org, leadId, lead, basePath, roster } = props\n const firestore = useFirestore()\n const router = useRouter()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n\n /*\n * The scope this reader may see — the same tokens every CRM listener\n * filters on, and the rules refuse a list without them. The Leads list\n * mounts the dialog closed at the organization level with no site at all\n * (the row it converts brings one), and a consent group must name a site,\n * so until then the scope is the organization's token alone — nothing reads\n * it while the dialog is closed.\n */\n const visibleToTokens = useMemo(() => {\n const group = hostId ? Aglyn.consentGroupForHost(org ?? {}, hostId) : null\n return [\n Aglyn.ORG_SCOPE_TOKEN,\n ...(group?.hostIds ?? []).map((id) => Aglyn.hostScopeToken(id)),\n ].slice(0, Aglyn.MAX_SCOPE_HOSTS)\n }, [org, hostId])\n const tokensKey = visibleToTokens.join(',')\n\n const { data: companies, status: companiesStatus } = useFirestoreCollection<\n Record<string, unknown> & { $id: string; name?: string; domain?: string }\n >(\n () =>\n open && orgId\n ? query(\n collection(firestore, 'orgs', orgId, Aglyn.CRM_COLLECTIONS.companies),\n where('visibleTo', 'array-contains-any', visibleToTokens),\n orderBy('nameLower'),\n limit(COMPANY_OPTIONS_CEILING),\n )\n : null,\n [firestore, orgId, open, tokensKey],\n { idField: '$id' },\n )\n const { data: pipelines } = useFirestoreCollection<\n Record<string, unknown> & { $id: string; isDefault?: boolean; stages?: CrmDealStage[] }\n >(\n () =>\n open && orgId\n ? query(\n collection(firestore, 'orgs', orgId, Aglyn.CRM_COLLECTIONS.pipelines),\n where('visibleTo', 'array-contains-any', visibleToTokens),\n orderBy(documentId()),\n limit(20),\n )\n : null,\n [firestore, orgId, open, tokensKey],\n { idField: '$id' },\n )\n /*\n * The pipeline the deal will open in — the route's own choice, mirrored so\n * the stage picker offers the stages the deal will really have: the flagged\n * default, else the first by id, else the stages the route will seed.\n */\n const pipeline = pipelines.find((entry) => entry.isDefault) ?? pipelines[0]\n const stages = useMemo(\n () =>\n [...(pipeline?.stages ?? Aglyn.DEFAULT_DEAL_STAGES)]\n .filter((stage) => stage.kind === 'open')\n .sort((a, b) => a.order - b.order),\n [pipeline],\n )\n\n const email = String(lead['email'] ?? '')\n const leadLabel = String(lead['name'] || email || leadId)\n\n const [ownerUid, setOwnerUid] = useState('')\n const [companyMode, setCompanyMode] = useState<CompanyMode>('none')\n const [companyId, setCompanyId] = useState('')\n const [companyName, setCompanyName] = useState('')\n const [companyDomain, setCompanyDomain] = useState('')\n const [dealOn, setDealOn] = useState(false)\n const [dealTitle, setDealTitle] = useState('')\n const [dealAmount, setDealAmount] = useState('')\n const [dealCurrency, setDealCurrency] = useState('usd')\n const [dealStageId, setDealStageId] = useState('')\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n // The labels' ids, so the two comboboxes are named \"Company\" and \"Stage\"\n // rather than after whatever they show — see `LeadOwnerSelect`.\n const companyLabelId = useId()\n const stageLabelId = useId()\n\n /*\n * Reset on every open, and seed the company step ONCE per open when the\n * company list has answered — not on every snapshot, or a converter who\n * changed their mind would have the suggestion put back under them.\n */\n const seededFor = useRef<string | null>(null)\n useEffect(() => {\n if (!open) {\n seededFor.current = null\n return\n }\n setOwnerUid(String(lead.ownerUid ?? user?.uid ?? ''))\n setDealOn(false)\n setDealTitle(leadLabel)\n setDealAmount('')\n setDealCurrency('usd')\n setDealStageId('')\n setError(null)\n setCompanyMode('none')\n setCompanyId('')\n setCompanyName('')\n setCompanyDomain('')\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open, leadId])\n useEffect(() => {\n if (!open || companiesStatus !== 'success') return\n if (seededFor.current === leadId) return\n seededFor.current = leadId\n // The lead's own company text first (AGL-3233), then the domain.\n const suggestion = suggestCompanyForLead(email, companies, lead.company)\n setCompanyMode(suggestion.mode)\n if (suggestion.mode === 'existing') setCompanyId(suggestion.companyId)\n if (suggestion.mode === 'new') {\n setCompanyName(suggestion.name)\n setCompanyDomain(suggestion.domain)\n }\n }, [open, companiesStatus, companies, email, leadId, lead.company])\n\n const amountCents = dollarsToCents(dealAmount)\n const amountInvalid = amountCents === undefined\n const submittable =\n !busy &&\n (companyMode !== 'new' || companyName.trim().length > 0) &&\n (companyMode !== 'existing' || Boolean(companyId)) &&\n (!dealOn || (dealTitle.trim().length > 0 && !amountInvalid))\n\n const submit = async () => {\n if (!submittable) return\n setBusy(true)\n setError(null)\n const body: LeadConvertRequest = {\n hostId,\n leadId,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyMode === 'existing' ? { companyId } : {}),\n ...(companyMode === 'new'\n ? {\n createCompany: {\n name: companyName.trim(),\n ...(companyDomain.trim() ? { domain: companyDomain.trim() } : {}),\n },\n }\n : {}),\n ...(dealOn\n ? {\n deal: {\n title: dealTitle.trim(),\n ...(typeof amountCents === 'number' ? { amountCents } : {}),\n currency: dealCurrency.trim().toLowerCase() || 'usd',\n ...(dealStageId ? { stageId: dealStageId } : {}),\n },\n }\n : {}),\n }\n try {\n const response = await authorizedFetch(user, '/api/crm/lead-convert', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n })\n const answer = (await response.json().catch(() => ({}))) as\n | LeadConvertResponse\n | { error?: string }\n if (!response.ok || !('ok' in answer)) {\n setError(\n String((answer as { error?: string }).error ?? 'The lead could not be converted.'),\n )\n return\n }\n enqueueSnackbar(\n answer.alreadyConverted\n ? 'This lead was already converted — opening the contact'\n : 'Lead converted',\n { variant: 'success', persist: false },\n )\n onClose()\n router.push(crmRoutes(basePath).contact(answer.contactId))\n } catch (cause) {\n setError(cause instanceof Error ? cause.message : 'The lead could not be converted.')\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <Dialog open={open} onClose={busy ? undefined : onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{`Convert ${leadLabel}`}</DialogTitle>\n <DialogContent>\n <Stack spacing={3} sx={{ pt: 1 }}>\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Contact'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`${email} becomes a contact at the Sales qualified stage — or ` +\n 'joins the one this address already has.'}\n </Typography>\n <LeadOwnerSelect value={ownerUid} onChange={setOwnerUid} roster={roster} />\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <Typography variant=\"subtitle2\">{'Company'}</Typography>\n <RadioGroup\n value={companyMode}\n onChange={(event) => setCompanyMode(event.target.value as CompanyMode)}\n >\n <FormControlLabel value=\"none\" control={<Radio size=\"small\" />} label=\"No company\" />\n <FormControlLabel\n value=\"existing\"\n control={<Radio size=\"small\" />}\n label=\"Link an existing company\"\n disabled={companies.length === 0}\n />\n <FormControlLabel\n value=\"new\"\n control={<Radio size=\"small\" />}\n label=\"Create a company\"\n />\n </RadioGroup>\n {companyMode === 'existing' ? (\n <FormControl size=\"small\" fullWidth>\n <InputLabel id={companyLabelId}>{'Company'}</InputLabel>\n <Select\n labelId={companyLabelId}\n label=\"Company\"\n value={companyId}\n onChange={(event) => setCompanyId(String(event.target.value))}\n >\n {companies.map((company) => (\n <MenuItem key={company.$id} value={company.$id}>\n {String(company.name ?? company.$id)}\n {company.domain ? (\n <Typography\n component=\"span\"\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ ml: 1 }}\n >\n {String(company.domain)}\n </Typography>\n ) : null}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n ) : null}\n {companyMode === 'new' ? (\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>\n <TextField\n size=\"small\"\n label=\"Company name\"\n value={companyName}\n onChange={(event) => setCompanyName(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 120 } }}\n />\n <TextField\n size=\"small\"\n label=\"Domain\"\n value={companyDomain}\n onChange={(event) => setCompanyDomain(event.target.value)}\n helperText=\"Used to match future contacts to this company\"\n fullWidth\n />\n </Stack>\n ) : null}\n </Stack>\n <Divider />\n <Stack spacing={1}>\n <FormControlLabel\n control={\n <Checkbox\n checked={dealOn}\n onChange={(event) => setDealOn(event.target.checked)}\n />\n }\n label={<Typography variant=\"subtitle2\">{'Open a deal'}</Typography>}\n />\n {dealOn ? (\n <Stack spacing={1}>\n <TextField\n size=\"small\"\n label=\"Deal title\"\n value={dealTitle}\n onChange={(event) => setDealTitle(event.target.value)}\n fullWidth\n slotProps={{ htmlInput: { maxLength: 200 } }}\n />\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>\n <TextField\n size=\"small\"\n label=\"Amount\"\n value={dealAmount}\n onChange={(event) => setDealAmount(event.target.value)}\n error={amountInvalid}\n helperText={amountInvalid ? 'Enter an amount like 1200 or 1,200.50' : ' '}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Currency\"\n value={dealCurrency}\n onChange={(event) => setDealCurrency(event.target.value)}\n helperText=\" \"\n sx={{ width: { sm: 140 } }}\n slotProps={{ htmlInput: { maxLength: 3 } }}\n />\n </Stack>\n <FormControl size=\"small\" fullWidth>\n {/* `shrink`, because `displayEmpty` below draws the \"First\n stage\" placeholder where an empty value would leave the\n field blank — and a label that has not shrunk sits on\n top of it. */}\n <InputLabel id={stageLabelId} shrink>\n {'Stage'}\n </InputLabel>\n <Select\n labelId={stageLabelId}\n label=\"Stage\"\n notched\n value={dealStageId}\n onChange={(event) => setDealStageId(String(event.target.value))}\n displayEmpty\n >\n <MenuItem value=\"\">\n <em>{stages[0] ? `First stage (${stages[0].name})` : 'First stage'}</em>\n </MenuItem>\n {stages.map((stage) => (\n <MenuItem key={stage.id} value={stage.id}>\n {stage.name}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n {!pipeline ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This workspace has no pipeline yet — a Sales pipeline ' +\n 'with the default stages is created with the deal.'}\n </Typography>\n ) : null}\n </Stack>\n ) : null}\n </Stack>\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" onClick={() => void submit()} disabled={!submittable}>\n {busy ? 'Converting…' : 'Convert'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLeadConvertDialog.displayName = 'LeadConvertDialog'\n\nexport default LeadConvertDialog\n"],"names":["Aglyn","authorizedFetch","useSnackbar","useFirestore","useFirestoreCollection","useUser","Alert","Button","Checkbox","Dialog","DialogActions","DialogContent","DialogTitle","Divider","FormControl","FormControlLabel","InputLabel","MenuItem","Radio","RadioGroup","Select","Stack","TextField","Typography","collection","documentId","limit","orderBy","query","where","useRouter","useEffect","useId","useMemo","useRef","useState","dollarsToCents","suggestCompanyForLead","crmRoutes","LeadOwnerSelect","COMPANY_OPTIONS_CEILING","LeadConvertDialog","props","pipelines","lead","open","onClose","hostId","orgId","org","leadId","basePath","roster","firestore","router","data","user","enqueueSnackbar","visibleToTokens","group","consentGroupForHost","ORG_SCOPE_TOKEN","hostIds","map","id","hostScopeToken","slice","MAX_SCOPE_HOSTS","tokensKey","join","companies","status","companiesStatus","CRM_COLLECTIONS","idField","pipeline","find","entry","isDefault","stages","DEFAULT_DEAL_STAGES","filter","stage","kind","sort","a","b","order","email","String","leadLabel","ownerUid","setOwnerUid","companyMode","setCompanyMode","companyId","setCompanyId","companyName","setCompanyName","companyDomain","setCompanyDomain","dealOn","setDealOn","dealTitle","setDealTitle","dealAmount","setDealAmount","dealCurrency","setDealCurrency","dealStageId","setDealStageId","busy","setBusy","error","setError","companyLabelId","stageLabelId","seededFor","current","uid","suggestion","company","mode","name","domain","amountCents","amountInvalid","undefined","submittable","trim","length","Boolean","submit","body","createCompany","deal","title","currency","toLowerCase","stageId","response","method","headers","JSON","stringify","answer","json","catch","ok","alreadyConverted","variant","persist","push","contact","contactId","cause","Error","message","maxWidth","fullWidth","spacing","sx","pt","color","value","onChange","event","target","control","size","label","disabled","labelId","$id","component","ml","direction","xs","sm","slotProps","htmlInput","maxLength","helperText","checked","width","shrink","notched","displayEmpty","em","severity","onClick","displayName"],"mappings":"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"}
@@ -35,6 +35,9 @@ import { Chip, Stack, Typography } from "@mui/material";
35
35
  if (source === 'booking') return 'Booking';
36
36
  if (source === 'import') return CONTACT_SOURCE_LABELS.import;
37
37
  if (source === 'form') return CONTACT_SOURCE_LABELS.form;
38
+ // A lead entered by hand or over the REST API (AGL-3231).
39
+ if (source === 'manual') return 'Added by hand';
40
+ if (source === 'api') return 'API';
38
41
  if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`;
39
42
  return source;
40
43
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-history-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CAPTURED_BY_HOST_FIELD, CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\n// The component path and NOT the marketing barrel, for the reason the Inbox\n// and the contacts list give: the barrel is the tenant loader's entry point\n// for the plugin's SITE half, and a console card named there ships to every\n// published page.\nimport {\n CrmRecordAttributionZone,\n useHasCrmRecordAttribution,\n} from './crm-attribution-zone'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\n\n/**\n * The surfaces `addHostLead` names — `signup`, `booking`, `form:{formId}`,\n * `import` — as they read on screen. A form's id is not a name, so it is\n * shown as the kind with the id beside it rather than as an opaque token.\n * The bare kind `form` is the lifecycle backfill's spelling for a person\n * whose timeline kept no form id (AGL-2631), and reads as the kind, the\n * way the contact's own source chip does.\n */\nexport function leadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function leadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** Epoch millis or a Firestore timestamp, as a local date-time, or a dash. */\nexport function leadTimeLabel(value: unknown): string {\n if (typeof value === 'number' && Number.isFinite(value)) {\n return new Date(value).toLocaleString()\n }\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toLocaleString() : '—'\n}\n\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadHistoryCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n}\n\n/**\n * What the capture door recorded about this person (AGL-2608): every\n * surface that produced a capture, how many times, and when the first and\n * the latest happened — the fields `addHostLead` keeps on the one document\n * per person — plus the campaign the first capture is credited to.\n *\n * Read-only by construction. Nothing here is the team's to edit: it is what\n * the visitor did, and the CRM's working state lives on the card above it.\n */\nexport function LeadHistoryCard(props: LeadHistoryCardProps) {\n const { hostId, leadId, lead } = props\n // The caption introduces what another plugin draws; with none loaded it\n // would introduce nothing.\n const hasAttribution = useHasCrmRecordAttribution()\n const sources = leadSources(lead)\n const capturedBy = lead[CAPTURED_BY_HOST_FIELD]\n const count = Number(lead['submissionCount'] ?? 0) || (sources.length ? 1 : 0)\n return (\n <CardDisplay\n header={'Captured history'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={3}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>\n <Fact label=\"First seen\">\n {leadTimeLabel(lead['firstSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Last seen\">\n {leadTimeLabel(lead['lastSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Captures\">{count ? String(count) : '—'}</Fact>\n <Fact label=\"Captured on\">\n {Array.isArray(capturedBy) && capturedBy.length\n ? capturedBy.map(String).join(', ')\n : hostId}\n </Fact>\n </Stack>\n <Fact label=\"Sources\">\n {sources.length ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip key={source} size=\"small\" variant=\"outlined\" label={leadSourceLabel(source)} />\n ))}\n </Stack>\n ) : (\n '—'\n )}\n </Fact>\n {hasAttribution ? (\n <Stack spacing={1}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Where this lead came from'}\n </Typography>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={leadId}\n />\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadHistoryCard.displayName = 'LeadHistoryCard'\n\nexport default LeadHistoryCard\n"],"names":["CAPTURED_BY_HOST_FIELD","CONTACT_SOURCE_LABELS","pluginDocsHelp","CrmRecordAttributionZone","useHasCrmRecordAttribution","CardDisplay","Chip","Stack","Typography","leadSourceLabel","source","import","form","startsWith","slice","length","leadSources","lead","sources","Array","isArray","map","String","leadTimeLabel","value","Number","isFinite","Date","toLocaleString","asDate","toDate","Fact","props","spacing","variant","color","label","component","children","LeadHistoryCard","hostId","leadId","hasAttribution","capturedBy","count","header","help","anchor","contentGutterX","contentGutterY","direction","xs","md","join","sx","flexWrap","rowGap","size","recordKind","recordId","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-history-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { CAPTURED_BY_HOST_FIELD, CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\n// The component path and NOT the marketing barrel, for the reason the Inbox\n// and the contacts list give: the barrel is the tenant loader's entry point\n// for the plugin's SITE half, and a console card named there ships to every\n// published page.\nimport {\n CrmRecordAttributionZone,\n useHasCrmRecordAttribution,\n} from './crm-attribution-zone'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\n\n/**\n * The surfaces `addHostLead` names — `signup`, `booking`, `form:{formId}`,\n * `import` — as they read on screen. A form's id is not a name, so it is\n * shown as the kind with the id beside it rather than as an opaque token.\n * The bare kind `form` is the lifecycle backfill's spelling for a person\n * whose timeline kept no form id (AGL-2631), and reads as the kind, the\n * way the contact's own source chip does.\n */\nexport function leadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n // A lead entered by hand or over the REST API (AGL-3231).\n if (source === 'manual') return 'Added by hand'\n if (source === 'api') return 'API'\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function leadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** Epoch millis or a Firestore timestamp, as a local date-time, or a dash. */\nexport function leadTimeLabel(value: unknown): string {\n if (typeof value === 'number' && Number.isFinite(value)) {\n return new Date(value).toLocaleString()\n }\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toLocaleString() : '—'\n}\n\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadHistoryCardProps {\n hostId: string\n leadId: string\n lead: Record<string, unknown>\n}\n\n/**\n * What the capture door recorded about this person (AGL-2608): every\n * surface that produced a capture, how many times, and when the first and\n * the latest happened — the fields `addHostLead` keeps on the one document\n * per person — plus the campaign the first capture is credited to.\n *\n * Read-only by construction. Nothing here is the team's to edit: it is what\n * the visitor did, and the CRM's working state lives on the card above it.\n */\nexport function LeadHistoryCard(props: LeadHistoryCardProps) {\n const { hostId, leadId, lead } = props\n // The caption introduces what another plugin draws; with none loaded it\n // would introduce nothing.\n const hasAttribution = useHasCrmRecordAttribution()\n const sources = leadSources(lead)\n const capturedBy = lead[CAPTURED_BY_HOST_FIELD]\n const count = Number(lead['submissionCount'] ?? 0) || (sources.length ? 1 : 0)\n return (\n <CardDisplay\n header={'Captured history'}\n help={pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={3}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={3}>\n <Fact label=\"First seen\">\n {leadTimeLabel(lead['firstSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Last seen\">\n {leadTimeLabel(lead['lastSeenAtMs'] ?? lead['createdAt'])}\n </Fact>\n <Fact label=\"Captures\">{count ? String(count) : '—'}</Fact>\n <Fact label=\"Captured on\">\n {Array.isArray(capturedBy) && capturedBy.length\n ? capturedBy.map(String).join(', ')\n : hostId}\n </Fact>\n </Stack>\n <Fact label=\"Sources\">\n {sources.length ? (\n <Stack direction=\"row\" spacing={0.5} sx={{ flexWrap: 'wrap', rowGap: 0.5 }}>\n {sources.map((source) => (\n <Chip key={source} size=\"small\" variant=\"outlined\" label={leadSourceLabel(source)} />\n ))}\n </Stack>\n ) : (\n '—'\n )}\n </Fact>\n {hasAttribution ? (\n <Stack spacing={1}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Where this lead came from'}\n </Typography>\n <CrmRecordAttributionZone\n hostId={hostId}\n recordKind=\"lead\"\n recordId={leadId}\n />\n </Stack>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nLeadHistoryCard.displayName = 'LeadHistoryCard'\n\nexport default LeadHistoryCard\n"],"names":["CAPTURED_BY_HOST_FIELD","CONTACT_SOURCE_LABELS","pluginDocsHelp","CrmRecordAttributionZone","useHasCrmRecordAttribution","CardDisplay","Chip","Stack","Typography","leadSourceLabel","source","import","form","startsWith","slice","length","leadSources","lead","sources","Array","isArray","map","String","leadTimeLabel","value","Number","isFinite","Date","toLocaleString","asDate","toDate","Fact","props","spacing","variant","color","label","component","children","LeadHistoryCard","hostId","leadId","hasAttribution","capturedBy","count","header","help","anchor","contentGutterX","contentGutterY","direction","xs","md","join","sx","flexWrap","rowGap","size","recordKind","recordId","displayName"],"mappings":"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"}
@@ -25,12 +25,34 @@ import { Alert, Button, FormControl, InputLabel, MenuItem, Select, Stack, TextFi
25
25
  import { deleteField, doc, serverTimestamp, updateDoc } from "firebase/firestore";
26
26
  import { useEffect, useId, useState } from "react";
27
27
  import { crmRoutes } from "../model/crm-routes.js";
28
+ import { addressDraftFrom, ContactAddressFields } from "./contact-address-fields.js";
28
29
  import { CrmCallButton, CrmPhoneLink } from "./crm-call-actions.js";
29
30
  import { CrmRecordChip, CrmRecordHeader } from "./crm-record-header.js";
30
31
  import { CrmSendEmailButton } from "./crm-send-email-button.js";
31
32
  import { LeadOwnerSelect } from "./lead-owner-select.js";
32
33
  import { LeadStatusChip } from "./lead-status-chip.js";
33
- const NOTES_MAX = 4000;
34
+ const NOTES_MAX = Aglyn.CRM_LEAD_NOTES_MAX;
35
+ const TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX;
36
+ /** The stored profile as a draft the fields can edit. */ function profileDraftFrom(lead) {
37
+ var _lead_company, _lead_jobTitle, _ref, _lead_phone, _lead_website, _lead_leadSource, _lead_tags, _lead_address;
38
+ return {
39
+ company: String((_lead_company = lead.company) != null ? _lead_company : ''),
40
+ jobTitle: String((_lead_jobTitle = lead.jobTitle) != null ? _lead_jobTitle : ''),
41
+ phone: String((_ref = (_lead_phone = lead.phone) != null ? _lead_phone : lead['phone']) != null ? _ref : ''),
42
+ website: String((_lead_website = lead.website) != null ? _lead_website : ''),
43
+ leadSource: String((_lead_leadSource = lead.leadSource) != null ? _lead_leadSource : ''),
44
+ tags: ((_lead_tags = lead.tags) != null ? _lead_tags : []).join(', '),
45
+ address: addressDraftFrom((_lead_address = lead.address) != null ? _lead_address : null)
46
+ };
47
+ }
48
+ /** The patch as the document takes it: a cleared field is deleted, not blanked. */ function profileWrite(patch) {
49
+ const write = {};
50
+ for (const [key, value] of Object.entries(patch)){
51
+ if (value === undefined) continue;
52
+ write[key] = value === null ? deleteField() : value;
53
+ }
54
+ return write;
55
+ }
34
56
  /**
35
57
  * Why Convert is refused while an erasure waits on the person — the same
36
58
  * sentence shape the overflow's items carry, so the two read as one state.
@@ -82,6 +104,36 @@ const NOTES_MAX = 4000;
82
104
  const statusLabelId = useId();
83
105
  const [notesDirty, setNotesDirty] = useState(false);
84
106
  const [savingNotes, setSavingNotes] = useState(false);
107
+ /*
108
+ * THE LEAD'S OWN PROFILE (AGL-3231) — company, title, phone, website,
109
+ * address, tags, lead source — edited as one block with one Save, the
110
+ * way the contact's Properties card saves. Seeded from the document and
111
+ * guarded on save like the notes: a draft edited over a cached read must
112
+ * not overwrite a newer profile with an older one.
113
+ */ const [profile, setProfile] = useState(()=>profileDraftFrom(lead));
114
+ const [profileDirty, setProfileDirty] = useState(false);
115
+ const [savingProfile, setSavingProfile] = useState(false);
116
+ const [profileErrors, setProfileErrors] = useState({});
117
+ useEffect(()=>{
118
+ if (!profileDirty) setProfile(profileDraftFrom(lead));
119
+ // The draft follows the document until it is edited; the fields are
120
+ // read one by one so a change to any of them reseeds.
121
+ }, [
122
+ profileDirty,
123
+ lead.company,
124
+ lead.jobTitle,
125
+ lead.phone,
126
+ lead.website,
127
+ lead.leadSource,
128
+ lead.tags,
129
+ lead.address
130
+ ]);
131
+ const editProfile = (key, value)=>{
132
+ setProfile((current)=>_extends({}, current, {
133
+ [key]: value
134
+ }));
135
+ setProfileDirty(true);
136
+ };
85
137
  // A newer note from the server replaces an UNEDITED draft; an edited one is
86
138
  // the reader's, and the guard on save decides whether it may land.
87
139
  useEffect(()=>{
@@ -125,6 +177,34 @@ const NOTES_MAX = 4000;
125
177
  }
126
178
  setNotesDirty(false);
127
179
  };
180
+ const saveProfile = async ()=>{
181
+ const { patch, errors } = Aglyn.normalizeCrmLeadProfile({
182
+ company: profile.company,
183
+ jobTitle: profile.jobTitle,
184
+ phone: profile.phone,
185
+ website: profile.website,
186
+ leadSource: profile.leadSource,
187
+ tags: profile.tags,
188
+ address: profile.address
189
+ });
190
+ setProfileErrors(errors);
191
+ if (Object.keys(errors).length) return;
192
+ setSavingProfile(true);
193
+ const verdict = await writeGuardedBySeed({
194
+ subject: 'lead',
195
+ fromCache,
196
+ unreadable: leadStatus === 'error'
197
+ }, ()=>write(profileWrite(patch), 'Lead saved'));
198
+ setSavingProfile(false);
199
+ if (!verdict.ok) {
200
+ var _verdict_message;
201
+ enqueueSnackbar((_verdict_message = verdict.message) != null ? _verdict_message : 'The lead could not be saved.', {
202
+ variant: 'warning'
203
+ });
204
+ return;
205
+ }
206
+ setProfileDirty(false);
207
+ };
128
208
  const consent = Aglyn.readMarketingBasis(lead, Aglyn.soloConsentGroup(hostId));
129
209
  const consentLine = consent.basis === 'granted' ? `Opted in to marketing${consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''}` : consent.basis === 'declined' ? 'Declined marketing' : 'No marketing consent recorded — this lead cannot be emailed marketing';
130
210
  return /*#__PURE__*/ _jsx(CrmRecordHeader, {
@@ -349,6 +429,134 @@ const NOTES_MAX = 4000;
349
429
  })) : null
350
430
  ]
351
431
  }) : null,
432
+ /*#__PURE__*/ _jsxs(Stack, {
433
+ spacing: 2,
434
+ children: [
435
+ /*#__PURE__*/ _jsx(Typography, {
436
+ variant: "subtitle2",
437
+ children: 'Profile'
438
+ }),
439
+ /*#__PURE__*/ _jsxs(Stack, {
440
+ direction: {
441
+ xs: 'column',
442
+ md: 'row'
443
+ },
444
+ spacing: 2,
445
+ children: [
446
+ /*#__PURE__*/ _jsx(TextField, {
447
+ size: "small",
448
+ label: "Company",
449
+ value: profile.company,
450
+ onChange: (event)=>editProfile('company', event.target.value),
451
+ disabled: converted,
452
+ slotProps: {
453
+ htmlInput: {
454
+ maxLength: TEXT_MAX
455
+ }
456
+ },
457
+ fullWidth: true
458
+ }),
459
+ /*#__PURE__*/ _jsx(TextField, {
460
+ size: "small",
461
+ label: "Job title",
462
+ value: profile.jobTitle,
463
+ onChange: (event)=>editProfile('jobTitle', event.target.value),
464
+ disabled: converted,
465
+ slotProps: {
466
+ htmlInput: {
467
+ maxLength: TEXT_MAX
468
+ }
469
+ },
470
+ fullWidth: true
471
+ })
472
+ ]
473
+ }),
474
+ /*#__PURE__*/ _jsxs(Stack, {
475
+ direction: {
476
+ xs: 'column',
477
+ md: 'row'
478
+ },
479
+ spacing: 2,
480
+ children: [
481
+ /*#__PURE__*/ _jsx(TextField, {
482
+ size: "small",
483
+ label: "Phone",
484
+ value: profile.phone,
485
+ onChange: (event)=>editProfile('phone', event.target.value),
486
+ disabled: converted,
487
+ error: Boolean(profileErrors['phone']),
488
+ helperText: profileErrors['phone'] || 'With the country code, like +1 512 555 0107',
489
+ fullWidth: true
490
+ }),
491
+ /*#__PURE__*/ _jsx(TextField, {
492
+ size: "small",
493
+ label: "Website",
494
+ value: profile.website,
495
+ onChange: (event)=>editProfile('website', event.target.value),
496
+ disabled: converted,
497
+ error: Boolean(profileErrors['website']),
498
+ helperText: profileErrors['website'] || 'Like acme.com',
499
+ fullWidth: true
500
+ })
501
+ ]
502
+ }),
503
+ /*#__PURE__*/ _jsxs(Stack, {
504
+ direction: {
505
+ xs: 'column',
506
+ md: 'row'
507
+ },
508
+ spacing: 2,
509
+ children: [
510
+ /*#__PURE__*/ _jsx(TextField, {
511
+ size: "small",
512
+ label: "Lead source",
513
+ value: profile.leadSource,
514
+ onChange: (event)=>editProfile('leadSource', event.target.value),
515
+ disabled: converted,
516
+ slotProps: {
517
+ htmlInput: {
518
+ maxLength: TEXT_MAX
519
+ }
520
+ },
521
+ fullWidth: true
522
+ }),
523
+ /*#__PURE__*/ _jsx(TextField, {
524
+ size: "small",
525
+ label: "Tags",
526
+ value: profile.tags,
527
+ onChange: (event)=>editProfile('tags', event.target.value),
528
+ disabled: converted,
529
+ helperText: "Comma-separated",
530
+ fullWidth: true
531
+ })
532
+ ]
533
+ }),
534
+ /*#__PURE__*/ _jsx(Typography, {
535
+ variant: "caption",
536
+ color: "text.secondary",
537
+ children: 'Address'
538
+ }),
539
+ /*#__PURE__*/ _jsx(ContactAddressFields, {
540
+ value: profile.address,
541
+ onChange: (next)=>editProfile('address', next),
542
+ disabled: converted
543
+ }),
544
+ converted ? null : /*#__PURE__*/ _jsx(Stack, {
545
+ direction: "row",
546
+ spacing: 1,
547
+ sx: {
548
+ justifyContent: 'flex-end'
549
+ },
550
+ children: /*#__PURE__*/ _jsx(Button, {
551
+ size: "small",
552
+ variant: "contained",
553
+ onClick: ()=>void saveProfile(),
554
+ disabled: !profileDirty || savingProfile,
555
+ children: 'Save'
556
+ })
557
+ })
558
+ ]
559
+ }),
352
560
  /*#__PURE__*/ _jsxs(Stack, {
353
561
  spacing: 1,
354
562
  children: [