@aglyn/plugins-forms 1.0.0-beta.143

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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +35 -0
  3. package/package.json +52 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/form-design-preview.component.d.ts +68 -0
  8. package/src/lib/components/form-design-preview.component.js +238 -0
  9. package/src/lib/components/form-design-preview.component.js.map +1 -0
  10. package/src/lib/components/form-detail-card.d.ts +53 -0
  11. package/src/lib/components/form-detail-card.js +827 -0
  12. package/src/lib/components/form-detail-card.js.map +1 -0
  13. package/src/lib/components/form-metrics-card.component.d.ts +67 -0
  14. package/src/lib/components/form-metrics-card.component.js +298 -0
  15. package/src/lib/components/form-metrics-card.component.js.map +1 -0
  16. package/src/lib/components/form-submissions-card.component.d.ts +40 -0
  17. package/src/lib/components/form-submissions-card.component.js +104 -0
  18. package/src/lib/components/form-submissions-card.component.js.map +1 -0
  19. package/src/lib/components/form-zones.d.ts +52 -0
  20. package/src/lib/components/form-zones.js +20 -0
  21. package/src/lib/components/form-zones.js.map +1 -0
  22. package/src/lib/components/form.d.ts +186 -0
  23. package/src/lib/components/form.js +1014 -0
  24. package/src/lib/components/form.js.map +1 -0
  25. package/src/lib/components/forms-console-page.d.ts +22 -0
  26. package/src/lib/components/forms-console-page.js +55 -0
  27. package/src/lib/components/forms-console-page.js.map +1 -0
  28. package/src/lib/components/host-forms-card.component.d.ts +56 -0
  29. package/src/lib/components/host-forms-card.component.js +533 -0
  30. package/src/lib/components/host-forms-card.component.js.map +1 -0
  31. package/src/lib/components/use-form-promote-api.d.ts +48 -0
  32. package/src/lib/components/use-form-promote-api.js +63 -0
  33. package/src/lib/components/use-form-promote-api.js.map +1 -0
  34. package/src/lib/constants/bundle-common.d.ts +31 -0
  35. package/src/lib/constants/bundle-common.js +31 -0
  36. package/src/lib/constants/bundle-common.js.map +1 -0
  37. package/src/lib/plugin.d.ts +25 -0
  38. package/src/lib/plugin.js +76 -0
  39. package/src/lib/plugin.js.map +1 -0
  40. package/src/lib/site.d.ts +53 -0
  41. package/src/lib/site.js +83 -0
  42. package/src/lib/site.js.map +1 -0
  43. package/src/lib/utils/generate-preset-id.d.ts +25 -0
  44. package/src/lib/utils/generate-preset-id.js +30 -0
  45. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-detail-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 buildRoute,\n type FormFieldDecl,\n formFieldsCanYieldAnEmail,\n formFieldsCaptureConsent,\n PageHeaderActions,\n PageHeaderRecord,\n listConsoleWidgets,\n pluginDocsHelp,\n Route,\n} from '@aglyn/aglyn'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\n// The CRM's route builder by its leaf path, not the plugin barrel: the barrel\n// is the entry point the tenant's loader imports to activate the plugin's\n// site half, and a console page named there would ship to every published\n// page. The same import the Inbox makes for the same reason.\nimport { pluginRecordFilteredHref } from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { ICON_VARIANT_BESIGNER } from '@aglyn/shared-data-enums'\nimport { AppLink, CardDisplay, GridItems, MdiIcon, useLoading } from '@aglyn/shared-ui-jsx'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n Alert,\n AlertTitle,\n Button,\n Chip,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n useConsoleHostRoute,\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useHostCampaigns,\n useHostVersionApi,\n} from '@aglyn/tenant-feature-instance'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { collection, doc, limit, query, updateDoc } from 'firebase/firestore'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport { FORM_CONTACT_FIELDS_ZONE } from './form-zones'\nimport FormDesignPreview from './form-design-preview.component'\nimport FormMetricsCard from './form-metrics-card.component'\nimport FormSubmissionsCard from './form-submissions-card.component'\nimport useFormPromoteApi from './use-form-promote-api'\n\nexport interface FormDetailCardProps {\n hostId: string\n formId: string\n /**\n * The Forms surface's own absolute console path.\n *\n * Only the not-found branch links it. The trail carries the way back on a\n * form that exists, so a link beside the heading would be the breadcrumb\n * written twice; a form that does NOT exist has no heading of its own for\n * the trail to end on, and the reader needs somewhere to go.\n */\n basePath?: string\n /** Whether this viewer's role on the site may make a version live. */\n canPublish?: boolean\n /** False while the role is still being read; see the disabled reasons. */\n hostRoleLoaded?: boolean\n}\n\n/**\n * One form — the component detail surface, for a document that is also a\n * contract.\n *\n * The split this enforces is the reason it exists rather than sending a row\n * straight into the besigner. A form has two halves:\n *\n * - the DESIGN, which is what an author draws, and which lives in the\n * besigner reached from here;\n * - the DECLARATION — where a submission is routed and which field carries\n * marketing consent — which is edited here.\n *\n * They are edited apart because the declaration is what the design is then\n * checked AGAINST. `checkFormContract` compares the two at publish, so a\n * surface that let an author change both in one motion would let them satisfy\n * the check by moving whichever side happened to be easier — which is how a\n * form ends up with lead routing pointed at a field nobody fills in.\n *\n * ## Promotion lives here, not only in the besigner\n *\n * A component's version history offers Publish on any version: promotion is\n * how you go back, not only how you go forward, and an author restoring last\n * week's design should not have to open a canvas to do it.\n *\n * It rides `/api/hosts/forms/promote` rather than an `updateDoc` here, because\n * a form's promotion has to run `checkFormContract` on the tree it is about to\n * write and REFUSE. A check in this component would be advice a determined\n * client could skip; the route reads the stored version itself, so nothing\n * about the design crosses the wire inbound and there is no version of this\n * surface that can publish a design the check has not seen.\n */\nexport function FormDetailCard(props: FormDetailCardProps) {\n const { hostId, formId, basePath, canPublish, hostRoleLoaded } = props\n const firestore = useFirestore()\n const ContactFieldsZone = useConsoleWidgetSlot()\n // Read at render, after the shell has loaded its plugins.\n const hasContactFieldsWidget =\n listConsoleWidgets(FORM_CONTACT_FIELDS_ZONE.id).length > 0\n /**\n * The write a widget in the contact-fields zone asks for. The declaration\n * is one array on the form document, and the document is this plugin's, so\n * the page writes what the widget decided.\n */\n const saveFields = useCallback(\n async (fields: FormFieldDecl[]) => {\n await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {\n fields,\n updatedAt: Timestamp.now(),\n })\n },\n [firestore, hostId, formId],\n )\n const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId)\n const createHostVersion = useHostVersionApi()\n const promoteForm = useFormPromoteApi()\n const router = useRouter()\n const { enqueueSnackbar } = useSnackbar()\n const { queueLoading } = useLoading()\n\n const { data: form, status } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'forms', formId),\n [firestore, hostId, formId],\n { idField: '$id' },\n )\n // Three states, not two (AGL-706): loading and absent both arrive as\n // `undefined`, and an editable form over a mistyped id reads as data loss.\n const notFound = status !== 'loading' && !form\n\n /**\n * The published design, in BOTH stored forms (AGL-1151).\n *\n * The read above is converter-less, so `nodes` arrives exactly as stored —\n * msgpack for a design promoted since forms were compressed, a plain map\n * for everything older. Decoded once here because two surfaces below need\n * it and both fail silently on the raw value: `checkFormContract` reports\n * bogus violations against a tree it cannot walk, and the AGL-753 empty\n * guard passes on a `Bytes` because `Object.keys` counts the wrapper.\n */\n const formNodes = useMemo(\n () => Aglyn.decodeStoredNodes<Record<string, any>>(form?.nodes),\n [form?.nodes],\n )\n\n // No orderBy: the oldest version docs predate `createdAt`, and Firestore\n // drops documents missing the ordered field.\n const { data: versionDocs } = useFirestoreCollection<any>(\n () =>\n query(\n collection(firestore, 'hosts', hostId, 'forms', formId, 'versions'),\n limit(100),\n ),\n [firestore, hostId, formId],\n { idField: '$id' },\n )\n const versions = [...(versionDocs ?? [])].sort(\n (a: any, b: any) =>\n (b.createdAt?.seconds ?? 0) - (a.createdAt?.seconds ?? 0),\n )\n const publishedVersionId = form?.versionId as string | undefined\n\n const [name, setName] = useState<string | null>(null)\n const [lead, setLead] = useState<boolean | null>(null)\n const [consentField, setConsentField] = useState<string | null>(null)\n /**\n * The campaigns picked on screen, or null while the stored value stands.\n *\n * Null and `[]` are different: null means \"not touched\", `[]` means \"take\n * this form out of every campaign\". Collapsing them would make clearing the\n * last campaign a no-op, which is the half of set-and-clear that is easy to\n * ship broken.\n */\n const [campaignIds, setCampaignIds] = useState<string[] | null>(null)\n const [opening, setOpening] = useState(false)\n const [promoting, setPromoting] = useState<string | null>(null)\n /**\n * What a REFUSED promotion said, kept on the surface rather than in a toast.\n *\n * A contract violation is a list of things to go and fix in the besigner,\n * and a snackbar is gone by the time the author has read the second one.\n * The besigner's own refusal persists for the same reason.\n */\n const [promoteRefusal, setPromoteRefusal] = useState<{\n message: string\n violations: Aglyn.FormContractViolation[]\n } | null>(null)\n\n /** The fields the PUBLISHED design draws, which is what a submission can carry. */\n const declaredFields: Aglyn.FormFieldDecl[] = useMemo(\n () => (Array.isArray(form?.fields) ? form.fields : []),\n [form],\n )\n\n const effectiveLead = lead ?? form?.routing?.lead === true\n const effectiveConsent = consentField ?? String(form?.consentFieldName ?? '')\n /*\n * Whether lead routing COULD work on this design — the two preconditions\n * `checkFormContract` applies at publish, asked here so the switch says so\n * before it is flipped rather than after (AGL-2612). Consent is read as\n * it is being edited: picking a field below is what satisfies it.\n */\n const hasEmailField = formFieldsCanYieldAnEmail(declaredFields)\n const capturesConsent = formFieldsCaptureConsent(declaredFields, effectiveConsent)\n /*\n * Where this form's people are: the contacts list narrowed to this form,\n * asked of the plugin that keeps people (`null`, and no link, without one). Built from the resolved org slug and\n * subdomain the besigner link already waits on, so the link appears when\n * they land and never points at a half-built address.\n */\n const contactsHref =\n orgSlug && host\n ? pluginRecordFilteredHref('contact', { orgSlug, host }, 'form', formId)\n : null\n /*\n * WHICH CAMPAIGNS THIS FORM IS PART OF.\n *\n * The site's campaigns are read while this page is open because the field\n * is DISPLAYED here as well as edited here: the document stores ids, and a\n * page that drew them until a button was pressed would be showing a\n * merchant raw storage. Bounded at the ceiling the campaigns table itself\n * draws, so it can offer nothing that list does not.\n */\n const siteCampaigns = useHostCampaigns(hostId, { enabled: Boolean(form) })\n const storedCampaigns = useMemo(\n () => Aglyn.readCampaignIds(form as Record<string, unknown>),\n [form],\n )\n const effectiveCampaigns = campaignIds ?? storedCampaigns\n\n /**\n * What the published design would break, judged against the declaration as\n * it is being edited here.\n *\n * Shown here as well as at publish because the two halves are edited in\n * different places: turning lead routing on for a form whose design has no\n * email field is a break the author causes HERE, on a design they are not\n * looking at, and the besigner would not tell them until they next opened\n * it.\n */\n const violations = useMemo(\n () =>\n form\n ? Aglyn.checkFormContract({\n form: {\n routing: { ...(form.routing ?? {}), lead: effectiveLead },\n ...(effectiveConsent\n ? { consentFieldName: effectiveConsent }\n : {}),\n },\n formId,\n nodes: formNodes as never,\n formNodeId: findFormNodeId(formNodes as never),\n })\n : [],\n [form, formId, effectiveLead, effectiveConsent],\n )\n\n const handleSave = useCallback(async () => {\n const dequeue = queueLoading()\n try {\n await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {\n ...(name != null ? { displayName: name.trim() } : {}),\n ...(lead != null ? { routing: { ...(form?.routing ?? {}), lead } } : {}),\n ...(consentField != null\n ? { consentFieldName: consentField.trim() }\n : {}),\n // An empty selection is stored as an empty array rather than removing\n // the field: one shape for \"in no campaign\", which is what keeps this\n // writer and the campaign's own detach agreeing.\n ...(campaignIds != null\n ? {\n [Aglyn.CAMPAIGN_MEMBERSHIP_FIELD]:\n Aglyn.campaignMembershipValue(campaignIds),\n }\n : {}),\n updatedAt: Timestamp.now(),\n })\n setName(null)\n setLead(null)\n setConsentField(null)\n setCampaignIds(null)\n enqueueSnackbar('Form saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n dequeue()\n }\n }, [\n firestore,\n hostId,\n formId,\n form,\n name,\n lead,\n consentField,\n campaignIds,\n queueLoading,\n enqueueSnackbar,\n ])\n\n /**\n * Makes one version the version the live sites serve.\n *\n * The route decides, not this handler: it re-reads the stored version, runs\n * `checkFormContract` on the tree it is about to write, and answers 422 with\n * the violations when a publish would stop the submissions arriving. So the\n * only thing here is what to do with each answer — and a refusal is put on\n * the page rather than in a toast, because it is a list of things to fix.\n */\n const handlePromote = useCallback(\n (targetVersionId: string) => async () => {\n if (promoting) return\n setPromoting(targetVersionId)\n setPromoteRefusal(null)\n try {\n const result = await promoteForm({ hostId, formId, versionId: targetVersionId })\n if (!result.ok) {\n const message = result.message ?? 'Publish failed'\n setPromoteRefusal({\n message,\n violations: result.violations ?? [],\n })\n // `persist` because this is a refusal an author has to act on: an\n // auto-dismissed warning is how someone walks away believing the\n // form shipped.\n enqueueSnackbar(message, {\n variant: 'warning',\n allowDuplicate: true,\n persist: true,\n })\n return\n }\n enqueueSnackbar(\n 'Published. The live sites now serve this version, and the form’s ' +\n 'declared fields match it.',\n { variant: 'success', persist: false },\n )\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Could not publish that version', {\n variant: 'error',\n allowDuplicate: true,\n persist: true,\n })\n } finally {\n setPromoting(null)\n }\n },\n [promoting, promoteForm, hostId, formId, enqueueSnackbar],\n )\n\n /**\n * Opens a version in the besigner, minting the first one when the form has\n * none.\n *\n * Forms adopted from a page carry a declared field list and a `nodes` map\n * seeded from the page they were adopted from — so the first version opens\n * on the form the site was ALREADY submitting rather than a blank canvas.\n */\n const handleOpen = useCallback(\n (targetVersionId?: string) => async () => {\n if (opening) return\n setOpening(true)\n try {\n let versionId = targetVersionId ?? publishedVersionId ?? versions[0]?.$id\n if (!versionId) {\n versionId = Aglyn.createResourceUid()\n // Rides /api/hosts/versions: rules deny the client create, and the\n // route allows a resource's FIRST version on every plan — which\n // this always is, since the form reached here with no `versionId`.\n await createHostVersion({\n hostId,\n kind: 'form',\n parentId: formId,\n id: versionId,\n data: {\n formId,\n hostId,\n displayName: 'Initial version',\n // Emptiness, not just absence: a form that holds no `nodes` and\n // `??` would pass an empty map straight through to a version\n // that opens uneditable (AGL-753). Measured on the DECODED\n // tree — `Object.keys` over a stored `Bytes` is non-empty, so\n // the raw value passes this guard and mints exactly the\n // uneditable version it exists to stop (AGL-1151).\n nodes: Object.keys(formNodes ?? {}).length\n ? formNodes\n : {\n [Aglyn.CANVAS_ROOT_ELEMENT_ID]: {\n $id: Aglyn.CANVAS_ROOT_ELEMENT_ID,\n componentId: 'div',\n nodes: [],\n },\n },\n ...(form?.rootId ? { rootId: form.rootId } : {}),\n },\n })\n await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {\n versionId,\n updatedAt: Timestamp.now(),\n })\n }\n // The editor's URL needs the resolved org slug and subdomain, which\n // `useConsoleHostRoute` reads. Until they land the button below is\n // disabled, so this branch cannot be reached without them.\n if (orgSlug && host) {\n router.push(\n buildRoute(Route.FORM_BESIGNER, { orgSlug, host, formId, versionId }),\n )\n }\n } catch (error) {\n console.error(error)\n enqueueSnackbar('Could not open the besigner', {\n variant: 'error',\n allowDuplicate: true,\n })\n setOpening(false)\n }\n },\n [\n opening,\n createHostVersion,\n publishedVersionId,\n versions,\n firestore,\n hostId,\n formId,\n form,\n router,\n orgSlug,\n host,\n enqueueSnackbar,\n ],\n )\n\n const dirty =\n name != null ||\n lead != null ||\n consentField != null ||\n (campaignIds != null &&\n !Aglyn.campaignMembershipUnchanged(storedCampaigns, campaignIds))\n // Named before it is used on every row, so a role denial reads as a reason\n // rather than as a control that does nothing.\n const publishBlock = hostRoleLoaded\n ? 'Your role on this site can edit content but not publish it'\n : 'Checking what your role can do…'\n\n if (notFound) {\n return (\n <CardDisplay\n header=\"Form not found\"\n help={pluginDocsHelp('forms', {\n anchor: '#build-a-form',\n excerpt:\n 'A form is created from the Forms list and keeps the id every ' +\n 'submission is filed under.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2} sx={{ alignItems: 'flex-start' }}>\n <Alert severity=\"info\">\n {`No form on this site has the id ${formId}. It may have been deleted, or the link may be from another site.`}\n </Alert>\n {basePath ? <AppLink href={basePath}>{'Back to forms'}</AppLink> : null}\n </Stack>\n </CardDisplay>\n )\n }\n\n /* The cards, named so the page chrome above them is a plain list of\n what this surface publishes upward. */\n const cards = (\n <GridItems\n spacing={3}\n items={[\n {\n size: { xs: 12, lg: 5 },\n children: (\n <CardDisplay\n header=\"Details\"\n help={pluginDocsHelp('forms', {\n anchor: '#build-a-form',\n excerpt:\n 'The id is what every submission is filed under, so ' +\n 'renaming a form never splits its history.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <TextField\n label=\"Display name\"\n size=\"small\"\n value={name ?? form?.displayName ?? ''}\n onChange={(event) => setName(event.target.value)}\n fullWidth\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`ID ${formId} — every submission is filed under this, so it never changes`}\n </Typography>\n {/*\n A FORM IS PART OF A CAMPAIGN, and may be part of several.\n The same signup form is placed by one push and re-placed by\n the next, so this is a multi-select — a single value would\n make filing it under this quarter's campaign silently\n un-file it from last quarter's.\n\n It is an assignment and nothing else: it does not decide who\n a campaign mails, and it does not credit a submission to\n one. Attribution is recorded from the link a visitor\n followed, on its own evidence, and it goes on saying what it\n says whatever is picked here.\n */}\n <CampaignPicker\n options={siteCampaigns.options}\n value={effectiveCampaigns}\n onChange={setCampaignIds}\n helperText=\"The campaigns this form is part of. It does not change who a campaign mails.\"\n empty={siteCampaigns.ready && !siteCampaigns.options.length}\n />\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n size=\"small\"\n disabled={!dirty}\n onClick={handleSave}\n >\n {'Save'}\n </Button>\n </Stack>\n </Stack>\n </CardDisplay>\n ),\n },\n {\n size: { xs: 12, lg: 7 },\n children: (\n <CardDisplay\n header=\"CRM routing\"\n help={pluginDocsHelp('forms', {\n anchor: '#where-submissions-go',\n excerpt:\n 'Every submission reaches the Inbox and updates the ' +\n 'contact. Lead routing and the consent field decide ' +\n 'what else happens to it.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {/*\n THE PART THAT IS NOT A SWITCH (AGL-2612). Every capture\n lands in Contacts whatever is set below; the switch decides\n only whether a LEAD is filed beside the contact. Said\n first, because the switch on its own read as the only way\n a submission reached the CRM.\n */}\n <Typography variant=\"body2\">\n {'Every submission with an email address updates the person ' +\n 'in Contacts at stage Lead — a new contact when the ' +\n 'address is new, an interaction on their timeline when it ' +\n 'is not. A customer stays a customer.'}\n </Typography>\n <Stack spacing={0.5}>\n <FormControlLabel\n control={\n <Switch\n checked={effectiveLead}\n onChange={(event) => setLead(event.target.checked)}\n />\n }\n label=\"Also create a lead from the address someone gives this form\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {!hasEmailField\n ? 'Needs an email field in the published design — a ' +\n 'submission without an address cannot key a lead, and ' +\n 'publishing with this on would be refused until one exists.'\n : !capturesConsent\n ? 'Needs a marketing consent field — a lead nobody opted ' +\n 'in with is one the team cannot email, and publishing ' +\n 'with this on would be refused until the field below ' +\n 'names one.'\n : 'Files a lead in CRM → Leads for the sales team to work, ' +\n 'carrying the opt-in the consent field records.'}\n </Typography>\n </Stack>\n <TextField\n select\n label=\"Marketing consent field\"\n size=\"small\"\n value={effectiveConsent}\n onChange={(event) => setConsentField(event.target.value)}\n fullWidth\n helperText=\"The one field that IS the opt-in. Submitting a form is never itself consent.\"\n >\n <MenuItem value=\"\">\n {'None — this form collects no opt-in'}\n </MenuItem>\n {declaredFields.map((field) => (\n <MenuItem key={field.fieldName} value={field.fieldName}>\n {field.label\n ? `${field.label} (${field.fieldName})`\n : field.fieldName}\n </MenuItem>\n ))}\n </TextField>\n {violations.length ? (\n <Alert severity=\"warning\">\n <AlertTitle>{'This form would stop collecting'}</AlertTitle>\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }}>\n {violations.map((violation) => (\n <Typography\n component=\"li\"\n variant=\"body2\"\n key={`${violation.code}:${violation.nodeId ?? violation.fieldName ?? ''}`}\n >\n {violation.message}\n </Typography>\n ))}\n </Stack>\n </Alert>\n ) : null}\n {/*\n The people this form captured, on the list that holds them.\n Withheld until the site's address resolves, for the reason\n the besigner button is.\n */}\n {contactsHref ? (\n <Typography variant=\"body2\">\n <AppLink href={contactsHref}>\n {'See the contacts this form captured in the CRM'}\n </AppLink>\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n ),\n },\n // Where each field saves on the person: another plugin's card, in a\n // zone this page hosts. No item at all where nothing fills it, so the\n // grid keeps no empty cell.\n ...(ContactFieldsZone && hasContactFieldsWidget\n ? [\n {\n size: { xs: 12 },\n children: (\n <ContactFieldsZone\n slot={FORM_CONTACT_FIELDS_ZONE.id}\n hostId={hostId}\n formId={formId}\n fields={declaredFields}\n loading={status === 'loading'}\n saveFields={saveFields}\n />\n ),\n },\n ]\n : []),\n {\n size: { xs: 12, lg: 5 },\n children: (\n <FormMetricsCard\n stats={form?.stats}\n fields={declaredFields}\n leadRouting={effectiveLead}\n loading={status === 'loading'}\n />\n ),\n },\n {\n size: { xs: 12, lg: 7 },\n children: (\n <CardDisplay\n header=\"Versions\"\n help={pluginDocsHelp('forms', {\n anchor: '#build-a-form',\n excerpt:\n 'Publishing a form version updates every page that ' +\n 'places it. Saving does not — a save is not a publish.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {promoteRefusal ? (\n <Alert\n severity=\"warning\"\n onClose={() => setPromoteRefusal(null)}\n >\n <AlertTitle>{promoteRefusal.message}</AlertTitle>\n {promoteRefusal.violations.length ? (\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }}>\n {promoteRefusal.violations.map((violation) => (\n <Typography\n component=\"li\"\n variant=\"body2\"\n key={`${violation.code}:${violation.nodeId ?? violation.fieldName ?? ''}`}\n >\n {violation.message}\n </Typography>\n ))}\n </Stack>\n ) : null}\n </Alert>\n ) : null}\n {versions.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No versions yet — opening the besigner creates the first one.'}\n </Typography>\n ) : (\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>{'Version'}</TableCell>\n <TableCell>{'Created'}</TableCell>\n <TableCell>{'Updated'}</TableCell>\n <TableCell align=\"right\">{'Actions'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {versions.map((version: any) => (\n <TableRow key={version.$id} hover>\n <TableCell>\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography variant=\"body2\">\n {version.displayName ?? version.$id}\n </Typography>\n {version.$id === publishedVersionId ? (\n <Chip\n label=\"Current\"\n color=\"success\"\n size=\"small\"\n />\n ) : null}\n </Stack>\n </TableCell>\n <TableCell>\n {version.createdAt?.toDate?.().toLocaleString() ??\n '--'}\n </TableCell>\n <TableCell>\n {version.updatedAt?.toDate?.().toLocaleString() ??\n '--'}\n </TableCell>\n <TableCell align=\"right\">\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ justifyContent: 'flex-end' }}\n >\n <Button\n size=\"small\"\n disabled={opening || !orgSlug || !host}\n onClick={handleOpen(version.$id)}\n >\n {'Open'}\n </Button>\n {/*\n The version the sites already serve has nothing\n to promote to, so the control is disabled\n rather than absent — an absent control and an\n inapplicable one look identical, and only one\n of them is honest.\n */}\n <Button\n size=\"small\"\n variant=\"outlined\"\n title={canPublish ? undefined : publishBlock}\n disabled={\n !canPublish ||\n promoting !== null ||\n version.$id === publishedVersionId\n }\n onClick={handlePromote(version.$id)}\n >\n {promoting === version.$id\n ? 'Publishing…'\n : version.$id === publishedVersionId\n ? 'Published'\n : 'Publish'}\n </Button>\n </Stack>\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n )}\n </Stack>\n </CardDisplay>\n ),\n },\n {\n /*\n THE MESSAGES THEMSELVES, under the numbers that describe them.\n Full width because it is a table of people and what they wrote,\n and it sits below the metrics because those are read at a glance\n and this is read a row at a time.\n */\n size: { xs: 12 },\n children: <FormSubmissionsCard hostId={hostId} formId={formId} />,\n },\n {\n // Full width: the frame is a document, and a document in a\n // five-column card is a column of one-word lines.\n size: { xs: 12 },\n children: (\n <CardDisplay\n header=\"What a submission will carry\"\n help={pluginDocsHelp('forms', {\n anchor: '#field-types',\n excerpt:\n 'Each field arrives under its own name. Two fields ' +\n 'sharing one name arrive as a single answer.',\n })}\n contentGutterX\n contentGutterY\n >\n <FormDesignPreview\n formId={formId}\n nodes={form?.nodes}\n consentFieldName={\n effectiveConsent ? effectiveConsent : undefined\n }\n loading={status === 'loading'}\n />\n </CardDisplay>\n ),\n },\n ]}\n />\n )\n\n return (\n <>\n {/*\n The page chrome this surface cannot set for itself. The console shell\n owns the layout and builds its heading from the Forms nav item, so\n without these the page about one form is headed `Forms` and its trail\n stops on the list — the same on every row of it.\n */}\n <PageHeaderRecord\n title={form ? form.displayName || formId : undefined}\n />\n {/*\n The besigner is what this surface exists to reach, so it leads — in\n the PAGE header, which is where an action belongs on a surface with\n no section rail beneath it. Withheld when there is no form: it would\n mint a version document under an id that has none (AGL-706).\n */}\n <PageHeaderActions>\n <Button\n size=\"small\"\n variant=\"contained\"\n disabled={opening || !orgSlug || !host}\n title={\n orgSlug && host ? undefined : 'Resolving this site’s address…'\n }\n onClick={handleOpen()}\n startIcon={\n <MdiIcon color=\"inherit\" path={ICON_VARIANT_BESIGNER.path} />\n }\n >\n {opening ? 'Opening…' : 'Edit in besigner'}\n </Button>\n </PageHeaderActions>\n {cards}\n </>\n )\n}\nFormDetailCard.displayName = 'FormDetailCard'\n\n/**\n * The `form` node inside a published design.\n *\n * A form document's tree holds exactly one, because the document IS that\n * form — but the tree is a flat map with a synthetic root, so the node has to\n * be found rather than assumed to be the root itself.\n */\nfunction findFormNodeId(\n nodes: Record<string, { componentId?: string }> | undefined,\n): string | undefined {\n return Object.keys(nodes ?? {}).find(\n (id) => nodes?.[id]?.componentId === 'form',\n )\n}\n\nexport default FormDetailCard\n"],"names":["Aglyn","buildRoute","formFieldsCanYieldAnEmail","formFieldsCaptureConsent","PageHeaderActions","PageHeaderRecord","listConsoleWidgets","pluginDocsHelp","Route","useConsoleWidgetSlot","pluginRecordFilteredHref","ICON_VARIANT_BESIGNER","AppLink","CardDisplay","GridItems","MdiIcon","useLoading","ScrollTable","useSnackbar","Timestamp","Alert","AlertTitle","Button","Chip","FormControlLabel","MenuItem","Stack","Switch","TableBody","TableCell","TableHead","TableRow","TextField","Typography","useConsoleHostRoute","useFirestore","useFirestoreCollection","useFirestoreDoc","useHostCampaigns","useHostVersionApi","CampaignPicker","collection","doc","limit","query","updateDoc","useRouter","useCallback","useMemo","useState","FORM_CONTACT_FIELDS_ZONE","FormDesignPreview","FormMetricsCard","FormSubmissionsCard","useFormPromoteApi","FormDetailCard","props","name","form","hostId","formId","basePath","canPublish","hostRoleLoaded","firestore","ContactFieldsZone","hasContactFieldsWidget","id","length","saveFields","fields","updatedAt","now","orgSlug","subdomain","host","createHostVersion","promoteForm","router","enqueueSnackbar","queueLoading","data","status","idField","notFound","formNodes","decodeStoredNodes","nodes","versionDocs","versions","sort","a","b","createdAt","seconds","publishedVersionId","versionId","setName","lead","setLead","consentField","setConsentField","campaignIds","setCampaignIds","opening","setOpening","promoting","setPromoting","promoteRefusal","setPromoteRefusal","declaredFields","Array","isArray","effectiveLead","routing","effectiveConsent","String","consentFieldName","hasEmailField","capturesConsent","contactsHref","siteCampaigns","enabled","Boolean","storedCampaigns","readCampaignIds","effectiveCampaigns","violations","checkFormContract","formNodeId","findFormNodeId","handleSave","dequeue","displayName","trim","CAMPAIGN_MEMBERSHIP_FIELD","campaignMembershipValue","variant","persist","error","console","allowDuplicate","handlePromote","targetVersionId","result","ok","message","handleOpen","$id","createResourceUid","kind","parentId","Object","keys","CANVAS_ROOT_ELEMENT_ID","componentId","rootId","push","FORM_BESIGNER","dirty","campaignMembershipUnchanged","publishBlock","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","sx","alignItems","severity","href","cards","items","size","xs","lg","children","label","value","onChange","event","target","fullWidth","color","options","helperText","empty","ready","direction","disabled","onClick","control","checked","select","map","field","fieldName","component","pl","m","violation","code","nodeId","slot","loading","stats","leadRouting","onClose","align","version","hover","toDate","toLocaleString","justifyContent","title","undefined","startIcon","path","find"],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import type { FormFieldDecl, FormStats } from '@aglyn/aglyn';
2
+ export interface FormMetricsCardProps {
3
+ /** The stored counters, or `undefined` while the document is in flight. */
4
+ stats?: FormStats;
5
+ /** The PUBLISHED declaration — what a submission to this form can carry. */
6
+ fields: FormFieldDecl[];
7
+ /** Whether this form routes a submission to a lead. */
8
+ leadRouting: boolean;
9
+ /** The form document has not arrived yet, as against having no counters. */
10
+ loading?: boolean;
11
+ }
12
+ /**
13
+ * ONE FORM, MEASURED — and every rate naming its denominator.
14
+ *
15
+ * ## Where every number here comes from
16
+ *
17
+ * Counters on the form document, each incremented on a write that was
18
+ * happening anyway. The alternative — counting `formSubmissions` when a
19
+ * console surface renders — reads the one collection that grows without bound
20
+ * and the one the customer is billed on, and it is the shape this product has
21
+ * created repeatedly.
22
+ *
23
+ * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single
24
+ * `update` it already issues per submission. `leads` counts submissions
25
+ * this form FILED to the site's Leads; `addHostLead` keys one person to
26
+ * one document, so a returning visitor's second submission is a second
27
+ * capture and not a second person.
28
+ * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the
29
+ * form's own beacon, in the same shape the overlay counters use.
30
+ * - `stats.periods` — all four again, keyed by month, riding the same
31
+ * writes. This is the series drawn below AND the reason the rates can be
32
+ * taken at all (see next).
33
+ *
34
+ * ## Why the rates are not lifetime over lifetime
35
+ *
36
+ * `submissions` has counted since the form entity existed; `views` only since
37
+ * the beacon shipped. Dividing the lifetime totals would answer "submissions
38
+ * ever, over views since the day we started looking" — arbitrarily wrong, and
39
+ * wrong in the flattering direction. `formStatsWindow` sums both counters
40
+ * over exactly the months the denominator was live for, and answers zero
41
+ * periods when there are none, which renders as a dash.
42
+ *
43
+ * ## The view count is a CLIENT count, and this card says so
44
+ *
45
+ * A view is a beacon; a submission is a server write. A blocked beacon, a
46
+ * browser that runs no script, a visitor who leaves before the request goes
47
+ * out — each is a view that is missing from the denominator and a submission
48
+ * that is present in the numerator. So a completion rate here can exceed
49
+ * 100%, and it is printed as measured rather than clamped: a rate pinned at a
50
+ * tidy 100% would read as a form everybody finishes.
51
+ *
52
+ * ## What is still NOT measured, and why
53
+ *
54
+ * An absence with a reason is a smaller lie than a number with none.
55
+ *
56
+ * - **Per-field answer rates.** The values live on the submission documents
57
+ * in the Inbox, and counting them is the unbounded read every counter here
58
+ * exists to avoid.
59
+ * - **Anything before a counter started.** The series begins at the first
60
+ * month with a record rather than padding backwards with zeros, and the
61
+ * rates cover only the months their denominator was live.
62
+ */
63
+ export declare function FormMetricsCard(props: FormMetricsCardProps): import("react").JSX.Element;
64
+ export declare namespace FormMetricsCard {
65
+ var displayName: string;
66
+ }
67
+ export default FormMetricsCard;
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
+ import { CardDisplay } from "@aglyn/shared-ui-jsx";
19
+ import { Figure, measuredRate, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
20
+ import { Divider, Stack, Tooltip, Typography } from "@mui/material";
21
+ import { formPeriodSeries, formStatsWindow, pluginDocsHelp } from "@aglyn/aglyn";
22
+ import { useMemo } from "react";
23
+ /**
24
+ * A number, or `null` when the field carries no number at all.
25
+ *
26
+ * `?? 0` is the defect this exists to avoid: a counter that has never been
27
+ * written and a counter standing at zero are different facts, and only one of
28
+ * them is a measurement. Everything on this card goes through here.
29
+ */ function recorded(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
31
+ }
32
+ /**
33
+ * ONE FORM, MEASURED — and every rate naming its denominator.
34
+ *
35
+ * ## Where every number here comes from
36
+ *
37
+ * Counters on the form document, each incremented on a write that was
38
+ * happening anyway. The alternative — counting `formSubmissions` when a
39
+ * console surface renders — reads the one collection that grows without bound
40
+ * and the one the customer is billed on, and it is the shape this product has
41
+ * created repeatedly.
42
+ *
43
+ * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single
44
+ * `update` it already issues per submission. `leads` counts submissions
45
+ * this form FILED to the site's Leads; `addHostLead` keys one person to
46
+ * one document, so a returning visitor's second submission is a second
47
+ * capture and not a second person.
48
+ * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the
49
+ * form's own beacon, in the same shape the overlay counters use.
50
+ * - `stats.periods` — all four again, keyed by month, riding the same
51
+ * writes. This is the series drawn below AND the reason the rates can be
52
+ * taken at all (see next).
53
+ *
54
+ * ## Why the rates are not lifetime over lifetime
55
+ *
56
+ * `submissions` has counted since the form entity existed; `views` only since
57
+ * the beacon shipped. Dividing the lifetime totals would answer "submissions
58
+ * ever, over views since the day we started looking" — arbitrarily wrong, and
59
+ * wrong in the flattering direction. `formStatsWindow` sums both counters
60
+ * over exactly the months the denominator was live for, and answers zero
61
+ * periods when there are none, which renders as a dash.
62
+ *
63
+ * ## The view count is a CLIENT count, and this card says so
64
+ *
65
+ * A view is a beacon; a submission is a server write. A blocked beacon, a
66
+ * browser that runs no script, a visitor who leaves before the request goes
67
+ * out — each is a view that is missing from the denominator and a submission
68
+ * that is present in the numerator. So a completion rate here can exceed
69
+ * 100%, and it is printed as measured rather than clamped: a rate pinned at a
70
+ * tidy 100% would read as a form everybody finishes.
71
+ *
72
+ * ## What is still NOT measured, and why
73
+ *
74
+ * An absence with a reason is a smaller lie than a number with none.
75
+ *
76
+ * - **Per-field answer rates.** The values live on the submission documents
77
+ * in the Inbox, and counting them is the unbounded read every counter here
78
+ * exists to avoid.
79
+ * - **Anything before a counter started.** The series begins at the first
80
+ * month with a record rather than padding backwards with zeros, and the
81
+ * rates cover only the months their denominator was live.
82
+ */ export function FormMetricsCard(props) {
83
+ const { stats, fields, leadRouting, loading } = props;
84
+ const submissions = recorded(stats == null ? void 0 : stats.submissions);
85
+ const leads = recorded(stats == null ? void 0 : stats.leads);
86
+ const views = recorded(stats == null ? void 0 : stats.views);
87
+ const lastAt = recorded(stats == null ? void 0 : stats.lastSubmissionAtMs);
88
+ const series = useMemo(()=>formPeriodSeries(stats), [
89
+ stats
90
+ ]);
91
+ /** The tallest month, which every bar below is drawn as a fraction of. */ const peak = useMemo(()=>series.reduce((high, point)=>Math.max(high, point.submissions), 0), [
92
+ series
93
+ ]);
94
+ /*
95
+ * `measuredRate` returns `null` on an unrecorded numerator OR a zero
96
+ * denominator, which is both halves of the rule at once: a form nobody has
97
+ * submitted to has no rate, and a form whose leads were never counted has
98
+ * no rate either. Neither is `0%`.
99
+ *
100
+ * The denominator is named `submissions` and not "people" on purpose. A
101
+ * lead is filed only from a submission that carried an address, so a rate
102
+ * over every submission and a rate over the addressed ones are different
103
+ * numbers — and this is the one the counter actually produces.
104
+ */ const leadRate = measuredRate(leads, submissions, 'submissions');
105
+ /*
106
+ * COMPLETION AND ABANDONMENT, each over the months its own denominator was
107
+ * recorded in — never over the lifetime totals, and never over each
108
+ * other's window. Views and starts began being counted on the same deploy
109
+ * but they do not stay in step: a form nobody typed into has views and no
110
+ * starts, so a shared window would silently take the abandonment rate over
111
+ * months that recorded no start at all.
112
+ */ const viewWindow = formStatsWindow(stats, 'views', 'submissions');
113
+ const startWindow = formStatsWindow(stats, 'starts', 'submissions');
114
+ const completionRate = viewWindow.periods ? measuredRate(viewWindow.of, viewWindow.over, 'views') : null;
115
+ /*
116
+ * Abandonment is the starts that did NOT arrive, and it is withheld — not
117
+ * clamped — when there are more submissions than starts.
118
+ *
119
+ * That happens for a real reason rather than as an arithmetic edge: the
120
+ * start is a beacon and the submission is a server write, so a visitor
121
+ * whose beacon was blocked submits without ever having started. Clamping
122
+ * the difference at zero would publish "nobody abandons this form" out of a
123
+ * measurement that had gone incoherent, which is the loudest possible
124
+ * version of the mistake this whole card is written against.
125
+ */ const abandonRate = startWindow.periods && startWindow.over >= startWindow.of ? measuredRate(startWindow.over - startWindow.of, startWindow.over, 'starts') : null;
126
+ return /*#__PURE__*/ _jsx(CardDisplay, {
127
+ header: "What this form has collected",
128
+ help: pluginDocsHelp('forms', {
129
+ anchor: '#one-forms-own-page',
130
+ excerpt: 'Views and starts are counted in the visitor’s browser and ' + 'submissions on the server, and each rate covers only the months ' + 'its denominator was recorded.'
131
+ }),
132
+ contentGutterX: true,
133
+ contentGutterY: true,
134
+ children: /*#__PURE__*/ _jsxs(Stack, {
135
+ spacing: 2,
136
+ children: [
137
+ /*#__PURE__*/ _jsxs(Stack, {
138
+ direction: "row",
139
+ spacing: 3,
140
+ sx: {
141
+ flexWrap: 'wrap',
142
+ rowGap: 2
143
+ },
144
+ children: [
145
+ /*#__PURE__*/ _jsx(Figure, {
146
+ label: "Views",
147
+ value: loading ? null : views,
148
+ note: "times this form was rendered"
149
+ }),
150
+ /*#__PURE__*/ _jsx(Figure, {
151
+ label: "Submissions",
152
+ value: loading ? null : submissions,
153
+ note: "since this form was created"
154
+ }),
155
+ /*#__PURE__*/ _jsx(Figure, {
156
+ label: "Lead captures",
157
+ value: leads,
158
+ note: "submissions filed to Leads"
159
+ }),
160
+ /*#__PURE__*/ _jsx(Figure, {
161
+ label: "Declared fields",
162
+ value: fields.length,
163
+ note: "on the published design"
164
+ })
165
+ ]
166
+ }),
167
+ /*#__PURE__*/ _jsx(Divider, {}),
168
+ /*#__PURE__*/ _jsxs(Section, {
169
+ title: "Rates",
170
+ children: [
171
+ /*#__PURE__*/ _jsx(RateRow, {
172
+ label: "Views that became a submission",
173
+ rate: completionRate
174
+ }),
175
+ /*#__PURE__*/ _jsx(RateRow, {
176
+ label: "Started and never submitted",
177
+ rate: abandonRate
178
+ }),
179
+ /*#__PURE__*/ _jsx(RateRow, {
180
+ label: "Submissions that became a lead",
181
+ rate: leadRate
182
+ }),
183
+ !leadRouting ? /*#__PURE__*/ _jsx(Typography, {
184
+ variant: "caption",
185
+ color: "text.secondary",
186
+ children: 'This form does not route to leads, so there is nothing for ' + 'that rate to be over.'
187
+ }) : null,
188
+ /*#__PURE__*/ _jsx(Typography, {
189
+ variant: "caption",
190
+ color: "text.secondary",
191
+ children: 'Views and starts are counted in the visitor’s browser and ' + 'submissions on the server, so a blocked request is a view ' + 'this misses and a submission it does not. Each rate covers ' + 'only the months its denominator was being recorded.'
192
+ })
193
+ ]
194
+ }),
195
+ /*#__PURE__*/ _jsx(Divider, {}),
196
+ /*#__PURE__*/ _jsx(Section, {
197
+ title: "Submissions by month",
198
+ children: series.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
199
+ variant: "body2",
200
+ color: "text.secondary",
201
+ children: 'Nothing recorded yet. The series starts at the first month ' + 'this form collects something.'
202
+ }) : /*
203
+ A bar per month, drawn from the counters themselves.
204
+ Deliberately starting at the first RECORDED month rather than a
205
+ rolling twelve: a month before the counter existed would draw as
206
+ a confident zero, which is a claim nobody measured.
207
+ */ /*#__PURE__*/ _jsx(Stack, {
208
+ spacing: 0.5,
209
+ children: series.map((point)=>/*#__PURE__*/ _jsxs(Stack, {
210
+ direction: "row",
211
+ spacing: 1,
212
+ sx: {
213
+ alignItems: 'center'
214
+ },
215
+ children: [
216
+ /*#__PURE__*/ _jsx(Typography, {
217
+ variant: "caption",
218
+ color: "text.secondary",
219
+ sx: {
220
+ minWidth: 64
221
+ },
222
+ children: point.period
223
+ }),
224
+ /*#__PURE__*/ _jsx(Tooltip, {
225
+ title: `${point.submissions.toLocaleString()} submissions${point.views ? `, ${point.views.toLocaleString()} views` : ''}`,
226
+ children: /*#__PURE__*/ _jsx(Stack, {
227
+ sx: {
228
+ height: 8,
229
+ borderRadius: 1,
230
+ bgcolor: 'primary.main',
231
+ // Zero keeps a hairline rather than vanishing: a row
232
+ // with no bar at all reads as a missing month, and a
233
+ // missing month is the one thing this series must
234
+ // never be confused with.
235
+ minWidth: 2,
236
+ width: peak ? `${Math.round(point.submissions / peak * 100)}%` : 2
237
+ }
238
+ })
239
+ }),
240
+ /*#__PURE__*/ _jsx(Typography, {
241
+ variant: "caption",
242
+ sx: {
243
+ minWidth: 40
244
+ },
245
+ children: point.submissions.toLocaleString()
246
+ })
247
+ ]
248
+ }, point.period))
249
+ })
250
+ }),
251
+ /*#__PURE__*/ _jsx(Divider, {}),
252
+ /*#__PURE__*/ _jsx(Section, {
253
+ title: "Most recent",
254
+ children: /*#__PURE__*/ _jsx(Typography, {
255
+ variant: "body2",
256
+ children: lastAt === null ? 'No submission has been recorded for this form.' : new Date(lastAt).toLocaleString()
257
+ })
258
+ }),
259
+ /*#__PURE__*/ _jsx(Divider, {}),
260
+ /*#__PURE__*/ _jsx(Section, {
261
+ title: "Not measured",
262
+ children: /*#__PURE__*/ _jsxs(Stack, {
263
+ component: "ul",
264
+ sx: {
265
+ pl: 2,
266
+ m: 0
267
+ },
268
+ spacing: 0.5,
269
+ children: [
270
+ /*#__PURE__*/ _jsx(Typography, {
271
+ component: "li",
272
+ variant: "caption",
273
+ color: "text.secondary",
274
+ children: 'Per-field answer rates — which questions people skip. The ' + 'values live on the submission documents, and counting them ' + 'is a read of the whole collection every time this page ' + 'opens.'
275
+ }),
276
+ /*#__PURE__*/ _jsx(Typography, {
277
+ component: "li",
278
+ variant: "caption",
279
+ color: "text.secondary",
280
+ children: 'Anything before a counter started. The month series begins ' + 'where the record begins rather than padding backwards with ' + 'zeros, and a rate is taken only over the months its ' + 'denominator was live.'
281
+ }),
282
+ /*#__PURE__*/ _jsx(Typography, {
283
+ component: "li",
284
+ variant: "caption",
285
+ color: "text.secondary",
286
+ children: 'Who abandoned, or where they stopped. A start is one signal ' + 'per form, not per field, so this says how often a form was ' + 'left and never which question lost the visitor.'
287
+ })
288
+ ]
289
+ })
290
+ })
291
+ ]
292
+ })
293
+ });
294
+ }
295
+ FormMetricsCard.displayName = 'FormMetricsCard';
296
+ export default FormMetricsCard;
297
+
298
+ //# sourceMappingURL=form-metrics-card.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/components/form-metrics-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { FormFieldDecl, FormStats } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n Figure,\n measuredRate,\n RateRow,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { Divider, Stack, Tooltip, Typography } from '@mui/material'\nimport { formPeriodSeries, formStatsWindow, pluginDocsHelp } from '@aglyn/aglyn'\nimport { useMemo } from 'react'\n\nexport interface FormMetricsCardProps {\n /** The stored counters, or `undefined` while the document is in flight. */\n stats?: FormStats\n /** The PUBLISHED declaration — what a submission to this form can carry. */\n fields: FormFieldDecl[]\n /** Whether this form routes a submission to a lead. */\n leadRouting: boolean\n /** The form document has not arrived yet, as against having no counters. */\n loading?: boolean\n}\n\n/**\n * A number, or `null` when the field carries no number at all.\n *\n * `?? 0` is the defect this exists to avoid: a counter that has never been\n * written and a counter standing at zero are different facts, and only one of\n * them is a measurement. Everything on this card goes through here.\n */\nfunction recorded(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * ONE FORM, MEASURED — and every rate naming its denominator.\n *\n * ## Where every number here comes from\n *\n * Counters on the form document, each incremented on a write that was\n * happening anyway. The alternative — counting `formSubmissions` when a\n * console surface renders — reads the one collection that grows without bound\n * and the one the customer is billed on, and it is the shape this product has\n * created repeatedly.\n *\n * - `stats.submissions` / `stats.leads` — `/api/forms/submit`, on the single\n * `update` it already issues per submission. `leads` counts submissions\n * this form FILED to the site's Leads; `addHostLead` keys one person to\n * one document, so a returning visitor's second submission is a second\n * capture and not a second person.\n * - `stats.views` / `stats.starts` — `/api/analytics/collect`, from the\n * form's own beacon, in the same shape the overlay counters use.\n * - `stats.periods` — all four again, keyed by month, riding the same\n * writes. This is the series drawn below AND the reason the rates can be\n * taken at all (see next).\n *\n * ## Why the rates are not lifetime over lifetime\n *\n * `submissions` has counted since the form entity existed; `views` only since\n * the beacon shipped. Dividing the lifetime totals would answer \"submissions\n * ever, over views since the day we started looking\" — arbitrarily wrong, and\n * wrong in the flattering direction. `formStatsWindow` sums both counters\n * over exactly the months the denominator was live for, and answers zero\n * periods when there are none, which renders as a dash.\n *\n * ## The view count is a CLIENT count, and this card says so\n *\n * A view is a beacon; a submission is a server write. A blocked beacon, a\n * browser that runs no script, a visitor who leaves before the request goes\n * out — each is a view that is missing from the denominator and a submission\n * that is present in the numerator. So a completion rate here can exceed\n * 100%, and it is printed as measured rather than clamped: a rate pinned at a\n * tidy 100% would read as a form everybody finishes.\n *\n * ## What is still NOT measured, and why\n *\n * An absence with a reason is a smaller lie than a number with none.\n *\n * - **Per-field answer rates.** The values live on the submission documents\n * in the Inbox, and counting them is the unbounded read every counter here\n * exists to avoid.\n * - **Anything before a counter started.** The series begins at the first\n * month with a record rather than padding backwards with zeros, and the\n * rates cover only the months their denominator was live.\n */\nexport function FormMetricsCard(props: FormMetricsCardProps) {\n const { stats, fields, leadRouting, loading } = props\n const submissions = recorded(stats?.submissions)\n const leads = recorded(stats?.leads)\n const views = recorded(stats?.views)\n const lastAt = recorded(stats?.lastSubmissionAtMs)\n\n const series = useMemo(() => formPeriodSeries(stats), [stats])\n /** The tallest month, which every bar below is drawn as a fraction of. */\n const peak = useMemo(\n () => series.reduce((high, point) => Math.max(high, point.submissions), 0),\n [series],\n )\n\n /*\n * `measuredRate` returns `null` on an unrecorded numerator OR a zero\n * denominator, which is both halves of the rule at once: a form nobody has\n * submitted to has no rate, and a form whose leads were never counted has\n * no rate either. Neither is `0%`.\n *\n * The denominator is named `submissions` and not \"people\" on purpose. A\n * lead is filed only from a submission that carried an address, so a rate\n * over every submission and a rate over the addressed ones are different\n * numbers — and this is the one the counter actually produces.\n */\n const leadRate = measuredRate(leads, submissions, 'submissions')\n /*\n * COMPLETION AND ABANDONMENT, each over the months its own denominator was\n * recorded in — never over the lifetime totals, and never over each\n * other's window. Views and starts began being counted on the same deploy\n * but they do not stay in step: a form nobody typed into has views and no\n * starts, so a shared window would silently take the abandonment rate over\n * months that recorded no start at all.\n */\n const viewWindow = formStatsWindow(stats, 'views', 'submissions')\n const startWindow = formStatsWindow(stats, 'starts', 'submissions')\n const completionRate = viewWindow.periods\n ? measuredRate(viewWindow.of, viewWindow.over, 'views')\n : null\n /*\n * Abandonment is the starts that did NOT arrive, and it is withheld — not\n * clamped — when there are more submissions than starts.\n *\n * That happens for a real reason rather than as an arithmetic edge: the\n * start is a beacon and the submission is a server write, so a visitor\n * whose beacon was blocked submits without ever having started. Clamping\n * the difference at zero would publish \"nobody abandons this form\" out of a\n * measurement that had gone incoherent, which is the loudest possible\n * version of the mistake this whole card is written against.\n */\n const abandonRate =\n startWindow.periods && startWindow.over >= startWindow.of\n ? measuredRate(\n startWindow.over - startWindow.of,\n startWindow.over,\n 'starts',\n )\n : null\n\n return (\n <CardDisplay\n header=\"What this form has collected\"\n help={pluginDocsHelp('forms', {\n anchor: '#one-forms-own-page',\n excerpt:\n 'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, and each rate covers only the months ' +\n 'its denominator was recorded.',\n })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure\n label=\"Views\"\n value={loading ? null : views}\n note=\"times this form was rendered\"\n />\n <Figure\n label=\"Submissions\"\n value={loading ? null : submissions}\n note=\"since this form was created\"\n />\n <Figure\n label=\"Lead captures\"\n value={leads}\n note=\"submissions filed to Leads\"\n />\n <Figure\n label=\"Declared fields\"\n value={fields.length}\n note=\"on the published design\"\n />\n </Stack>\n <Divider />\n <Section title=\"Rates\">\n <RateRow label=\"Views that became a submission\" rate={completionRate} />\n <RateRow label=\"Started and never submitted\" rate={abandonRate} />\n <RateRow label=\"Submissions that became a lead\" rate={leadRate} />\n {!leadRouting ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'This form does not route to leads, so there is nothing for ' +\n 'that rate to be over.'}\n </Typography>\n ) : null}\n {/*\n The measurement's own shape, on the surface rather than in a\n docblock only a maintainer reads. Both facts change how the\n numbers above should be read: which months they cover, and that\n one side of them is counted in a browser.\n */}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Views and starts are counted in the visitor’s browser and ' +\n 'submissions on the server, so a blocked request is a view ' +\n 'this misses and a submission it does not. Each rate covers ' +\n 'only the months its denominator was being recorded.'}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Submissions by month\">\n {series.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing recorded yet. The series starts at the first month ' +\n 'this form collects something.'}\n </Typography>\n ) : (\n /*\n A bar per month, drawn from the counters themselves.\n Deliberately starting at the first RECORDED month rather than a\n rolling twelve: a month before the counter existed would draw as\n a confident zero, which is a claim nobody measured.\n */\n <Stack spacing={0.5}>\n {series.map((point) => (\n <Stack\n key={point.period}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ minWidth: 64 }}\n >\n {point.period}\n </Typography>\n <Tooltip\n title={`${point.submissions.toLocaleString()} submissions${\n point.views\n ? `, ${point.views.toLocaleString()} views`\n : ''\n }`}\n >\n <Stack\n sx={{\n height: 8,\n borderRadius: 1,\n bgcolor: 'primary.main',\n // Zero keeps a hairline rather than vanishing: a row\n // with no bar at all reads as a missing month, and a\n // missing month is the one thing this series must\n // never be confused with.\n minWidth: 2,\n width: peak\n ? `${Math.round((point.submissions / peak) * 100)}%`\n : 2,\n }}\n />\n </Tooltip>\n <Typography variant=\"caption\" sx={{ minWidth: 40 }}>\n {point.submissions.toLocaleString()}\n </Typography>\n </Stack>\n ))}\n </Stack>\n )}\n </Section>\n <Divider />\n <Section title=\"Most recent\">\n <Typography variant=\"body2\">\n {lastAt === null\n ? 'No submission has been recorded for this form.'\n : new Date(lastAt).toLocaleString()}\n </Typography>\n </Section>\n <Divider />\n <Section title=\"Not measured\">\n {/*\n Named rather than omitted. A report that quietly leaves out the\n question a reader came with is indistinguishable from one that\n answered it badly.\n */}\n <Stack component=\"ul\" sx={{ pl: 2, m: 0 }} spacing={0.5}>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Per-field answer rates — which questions people skip. The ' +\n 'values live on the submission documents, and counting them ' +\n 'is a read of the whole collection every time this page ' +\n 'opens.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Anything before a counter started. The month series begins ' +\n 'where the record begins rather than padding backwards with ' +\n 'zeros, and a rate is taken only over the months its ' +\n 'denominator was live.'}\n </Typography>\n <Typography component=\"li\" variant=\"caption\" color=\"text.secondary\">\n {'Who abandoned, or where they stopped. A start is one signal ' +\n 'per form, not per field, so this says how often a form was ' +\n 'left and never which question lost the visitor.'}\n </Typography>\n </Stack>\n </Section>\n </Stack>\n </CardDisplay>\n )\n}\nFormMetricsCard.displayName = 'FormMetricsCard'\n\nexport default FormMetricsCard\n"],"names":["CardDisplay","Figure","measuredRate","RateRow","Section","Divider","Stack","Tooltip","Typography","formPeriodSeries","formStatsWindow","pluginDocsHelp","useMemo","recorded","value","Number","isFinite","FormMetricsCard","props","stats","fields","leadRouting","loading","submissions","leads","views","lastAt","lastSubmissionAtMs","series","peak","reduce","high","point","Math","max","leadRate","viewWindow","startWindow","completionRate","periods","of","over","abandonRate","header","help","anchor","excerpt","contentGutterX","contentGutterY","spacing","direction","sx","flexWrap","rowGap","label","note","length","title","rate","variant","color","map","alignItems","minWidth","period","toLocaleString","height","borderRadius","bgcolor","width","round","Date","component","pl","m","displayName"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ export interface FormSubmissionsCardProps {
2
+ hostId: string;
3
+ formId: string;
4
+ }
5
+ /**
6
+ * THE SUBMISSIONS TO ONE FORM — behind an ask, not on mount.
7
+ *
8
+ * ## Why the reader has to press something
9
+ *
10
+ * `formSubmissions` is the collection that grows without bound and the one
11
+ * the customer is billed on. Every other number on this page is a counter
12
+ * that rode a write which was happening anyway, so opening a form costs the
13
+ * form document and its versions and nothing else — a ceiling
14
+ * `forms-console-read-cost.spec.tsx` asserts in documents.
15
+ *
16
+ * A table that mounted its own paged listener would put a page of that
17
+ * collection on every visit to the surface, including the many visits that
18
+ * are about renaming a form, checking where its submissions route, or
19
+ * publishing a version. None of those asked to read anybody's messages. The
20
+ * ask is one click and it buys a live, paged reader; not asking would buy the
21
+ * same reader for every visit that did not want it.
22
+ *
23
+ * ## Why the reader is a zone, not a table written here
24
+ *
25
+ * The reader this page wants already exists: it walks the collection with
26
+ * `orderBy('createdAt')` and a page window, opens a submission, marks it read,
27
+ * deletes, replies and shows attribution. A second implementation would be a
28
+ * second place for the unordered `limit()` defect to come back. It belongs to
29
+ * the plugin that reads submissions, so this card hosts the
30
+ * `formSubmissions` zone and that plugin draws its reader there, scoped to
31
+ * this form. Neither plugin imports the other.
32
+ *
33
+ * A workspace with no such plugin loaded has nothing to read them with, and
34
+ * the card says so rather than offering a button that opens an empty space.
35
+ */
36
+ export declare function FormSubmissionsCard(props: FormSubmissionsCardProps): import("react").JSX.Element;
37
+ export declare namespace FormSubmissionsCard {
38
+ var displayName: string;
39
+ }
40
+ export default FormSubmissionsCard;