@sonordev/contracts 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,26 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2.17.0
4
4
 
5
- - `llms` holds the rules for what llms.txt says about each page, so the Sonor API and site-kit's local generator build page lines the same way. New: `pickLlmsPageDescription` and `resolveLlmsPageDescription` (summary, curated meta, served meta, schema description, then a cleaned scrape), `llmsPageListNote`, `llmsPageTitle`, `isLlmsListablePage`, `selectLlmsPages`, `groupLlmsPages`, `llmsBlockquoteSummary`, `llmsContactLines`, `llmsAbsoluteUrl`, `cleanScrapedPageText`, `stripContactDetails`, `stripPageFiller` and `LLMS_DESCRIPTION_MAX_LENGTH`.
6
- - `sanitizeLlmsPublicSummary` removes a phone number or email address, with the "call" or "email us at" in front of it, and a "This page covers" lead-in, and keeps the rest. It used to return `null` for any summary that mentioned a phone or email, and a page with no summary is described by its scrape.
5
+ A minor release: one new entry, `@sonordev/contracts/crm-disposition`. Every other entry is unchanged.
6
+
7
+ - **What a rep's call outcome changes.** `SALES_DISPOSITIONS` is the three outcomes a rep taps after a call (`positive`, `negative`, `voicemail`), `NEGATIVE_REASONS` the five reasons a negative one carries (`not_a_fit`, `no_budget`, `has_agency`, `wrong_number`, `do_not_call`) and `SALES_DISPOSITION_LABELS` / `NEGATIVE_REASON_LABELS` what each button says. `dispositionTransitions(disposition, { reason? })` returns what the outcome does to the call (`outcomeType`, `sentiment`), the contact (`contactStage`), the company (`companyStatus`), today's reminder (`reminder`) and outreach (`stopsOutreach`, `suppresses`). A caller makes the moves; the contract only names them, so the API, the dashboard and the sales extension read one map. `dispositionProblem(disposition, { reason? })` says in a sentence why an outcome can't be applied, such as a negative one with no reason. `isSalesDisposition`, `isNegativeReason` and `CRM_DISPOSITION_CONTRACT_VERSION` (1) complete it.
8
+
9
+ ## 2.16.0
10
+
11
+ A minor release: `@sonordev/contracts/portfolio` gains the verification, a proof-stage section for builds a Lighthouse run can't honestly score. Every other entry is unchanged. Nothing was removed or renamed, but three things behave differently. `PortfolioSectionType` has a new member, so a `Record<PortfolioSectionType, ...>` you keep needs an entry for `portfolioVerification` before it type-checks. `caseStudyStageStatus` reads each section's `data`. A reported number's date has to be a real day.
12
+
13
+ - **A new section type, `portfolioVerification`.** An app behind a sign-in has a public door and little else a browser can reach, and a Lighthouse score for the door reads as a score for the app. A verification is a short list of checks instead (one to eight). Each says what it's called (`label`), what came out (`result`), what was measured (`measured`), where (`where`: a URL, a screen or a file), with which tool and settings (`method`), on what day (`date`, YYYY-MM-DD) and, required, what it doesn't cover (`scope`). A check that can't state its limits isn't a check. Every check is something you ran yourself; a figure a client gave you stays a reported number in Results.
14
+ - **It sits in the proof stage**, after `portfolioPerformance` and `portfolioSpeedComparison`. `PORTFOLIO_SECTION_TYPES` grows by one entry, so the `PORTFOLIO_SECTION_ORDER` value of each outcome section and the CTA rises by one. The reading order itself is unchanged.
15
+ - **It completes the proof stage on its own, once it has something to show.** `caseStudyStageStatus` counts a verification toward the proof stage only when its `data` holds at least one check a page may show (`readVerificationChecks`). An empty verification, which is what an editor's Add button creates, leaves the stage incomplete and says so, instead of reading as a finished proof stage over an empty one. `SectionLike` gains an optional `data`; pass each section's `data`, because a caller that passes only `sectionType` sees a verification as empty. `STAGE_CORE_SECTION` still names Performance as the section to offer when a stage is empty; `STAGE_CORE_ALTERNATES` lists the sections that stand in for a stage's core and `stageCoreSections(stage)` returns the core and its stand-ins together. Nothing is gated on the kind of project, because the status doesn't know it. An empty proof stage now says a verification is the other way to fill it.
16
+ - **One rule for what a day is.** `isRealDay(value)` says whether a string is a real calendar day written YYYY-MM-DD. A verification's `date` and a reported number's `reportedBy.date` both ask it. The reported number's date used to go through `Date.parse`, which rolls `2026-02-31` into March and let it pass; it's now refused, so a number can't be attributed to a day that doesn't exist.
17
+ - **Validating and reading one.** `PortfolioVerificationData` and `VerificationCheck` are the shapes. `verificationProblems(data, { latestDay? })` lists what's wrong, one sentence each, with the check's position: a missing field, a field over its limit (`VERIFICATION_FIELD_MAX_CHARS`) or not one line of plain text (a control character, a zero-width or direction mark), more than `VERIFICATION_MAX_CHECKS` checks, a day that doesn't exist, a day before `VERIFICATION_EARLIEST_DAY` or after `latestVerificationDay()` (tomorrow in UTC, so a check run today in any time zone passes and one that hasn't happened doesn't; `readVerificationChecks` applies the same bound), a `where` that says nothing ("n/a", one plain word), carries a username or password, or names localhost or a private network, a field the check doesn't have, and a scope that doesn't state a limit (`isVacuousScope`): it has to be a sentence with a word that marks where coverage stops, such as "only", "not" or "without", so placeholders ("N/A", "None at all"), coverage claims ("All screens") and denied limits ("No restrictions") are refused. It reads English, and it can see that a limit was written, not that it's true, so a person still reads the scope. `isPlaceholderText(text)` is the test for a line with nothing in it ("n/a", "see above"), shared by `where` and `scope`. `readVerificationChecks(data)` returns the checks that can be shown, trimmed and in order, and leaves out any that fail, so a check with no scope line, or a placeholder for one, doesn't print. `verificationLink(where)` returns a plain `http` or `https` address with a public host as a link and everything else, localhost included, as null. `PORTFOLIO_VERIFICATION_JSON_SCHEMA` is the same shape as a schema.
7
18
 
8
19
  ## 2.15.0
9
20
 
21
+ - `llms` holds the rules for what llms.txt says about each page, so the Sonor API and site-kit's local generator build page lines the same way. New: `pickLlmsPageDescription` and `resolveLlmsPageDescription` (summary, curated meta, served meta, schema description, then a cleaned scrape), `llmsPageListNote`, `llmsPageTitle`, `isLlmsListablePage`, `selectLlmsPages`, `groupLlmsPages`, `llmsBlockquoteSummary`, `llmsContactLines`, `llmsAbsoluteUrl`, `cleanScrapedPageText`, `stripContactDetails`, `stripPageFiller` and `LLMS_DESCRIPTION_MAX_LENGTH`.
22
+ - `sanitizeLlmsPublicSummary` removes a phone number or email address, with the "call" or "email us at" in front of it, and a "This page covers" lead-in, and keeps the rest. It used to return `null` for any summary that mentioned a phone or email, and a page with no summary is described by its scrape.
23
+
10
24
  - Add `@sonordev/contracts/funnels` with bounded incomplete drafts, launch-ready definitions, command envelopes, brand presentation overrides, readiness and public page validation. Runtime parsers and JSON schemas share the same shapes.
11
25
  - Add the canonical funnel content cache tag and campaign normalization used by managed landing pages. Existing publication logo roles and inbox behavior remain available.
12
26
 
package/README.md CHANGED
@@ -25,7 +25,7 @@ pnpm add @sonordev/contracts
25
25
  | `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it | the Sonor API, site-kit, the dashboard editor |
26
26
  | `@sonordev/contracts/site-cache` | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping | site-kit tags and serves the route, the Sonor API calls it |
27
27
  | `@sonordev/contracts/site-edit` | Edit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the page | site-kit's overlay, the dashboard's visual editor |
28
- | `@sonordev/contracts/portfolio` | The case study arc: every section type, the stage each belongs to, the reading order, the narrative that opens each stage and the thread from a challenge to the phase that cleared it and the result that proves it. Also where a portfolio number comes from and which may headline, and the live-frame message | the Sonor API, Signal's case-study writer, the dashboard's editor, agency-site-kit |
28
+ | `@sonordev/contracts/portfolio` | The case study arc: every section type, the stage each belongs to, the reading order, the narrative that opens each stage and the thread from a challenge to the phase that cleared it and the result that proves it. The verification, the proof for a build a Lighthouse run can't score: checks that each state what they don't cover. Also where a portfolio number comes from and which may headline, and the live-frame message | the Sonor API, Signal's case-study writer, the dashboard's editor, agency-site-kit |
29
29
  | `@sonordev/contracts/proposal-sitemap` | How a proposal's site plan counts its pages, when its before-and-after comparison may show, and whether the page counts stated elsewhere in the proposal add up | the Sonor API, Signal's proposal writer, the dashboard's proposal pages |
30
30
  | `@sonordev/contracts/voice` | The house voice's contraction rule. `findUncontracted` and `contractText` find and apply full forms that should be contractions; `findContracted` finds the contractions a question-form heading should expand | anything that checks or rewrites copy: Sonor's APIs and dashboard |
31
31
  | `@sonordev/contracts/error-page` | Whether stored text is a rendered error page rather than a page's content, and whether generated copy says the page itself is missing | anything that stores page text or writes page copy from it: the Sonor API's page-view and sitemap writers, Signal's metadata generators |
@@ -40,6 +40,7 @@ pnpm add @sonordev/contracts
40
40
  | `@sonordev/contracts/public-url-host` | Which literal hosts are refused as public fetch destinations (`isBlockedPublicUrlHost`) | the Sonor API and Signal's URL validators and network fetchers |
41
41
  | `@sonordev/contracts/funnels` | Incomplete drafts, launch definitions, command/readiness/receipt schemas, bounded public pages and presentation-only brand overrides | Sites, APIs, UI and agent adapters share the same rules |
42
42
  | `@sonordev/contracts/inbox` | Which inbox cards are decisions a bulk action leaves alone (`isDecisionCard`, `DECISION_INSIGHT_TYPES`) | Signal's inbox decides what "Mark all read" and "Dismiss all" may touch, the dashboard previews the same result before the write |
43
+ | `@sonordev/contracts/crm-disposition` | What a rep's call outcome (positive, negative or left voicemail) changes on a prospect: the call's outcome and sentiment, the contact's stage, the company's status, today's reminder and outreach (`dispositionTransitions`), and the reasons a negative outcome carries | the Sonor API applies an outcome, the dashboard's calling screen offers the same three buttons and reasons, the sales extension stops flipping a company on dial |
43
44
 
44
45
  ## Focus closing artwork
45
46
 
@@ -90,6 +91,64 @@ list (a database filter that leaves them out of a bulk write, for one).
90
91
  key, a JSON `null` and a `null` `data` mean no proposal, and any other value,
91
92
  an empty string included, means one.
92
93
 
94
+ ## Call dispositions
95
+
96
+ After a sales call a rep taps one of three outcomes. What each one changes on
97
+ the prospect is written here once, so the API that applies it, the calling
98
+ screen that offers it and the browser extension that dials from a list can't
99
+ disagree about what "negative" moves.
100
+
101
+ ```ts
102
+ import { dispositionProblem, dispositionTransitions } from '@sonordev/contracts/crm-disposition'
103
+
104
+ dispositionProblem('negative', { reason: undefined })
105
+ // 'A negative outcome needs a reason: not_a_fit, no_budget, has_agency, wrong_number, do_not_call.'
106
+
107
+ dispositionTransitions('negative', { reason: 'do_not_call' })
108
+ // {
109
+ // outcomeType: 'connected', sentiment: 'negative',
110
+ // contactStage: 'lost', companyStatus: 'not_a_fit',
111
+ // reminder: 'complete', stopsOutreach: false, suppresses: true,
112
+ // }
113
+ ```
114
+
115
+ | Outcome | Call | Contact stage | Company status | Today's reminder | Outreach |
116
+ |---|---|---|---|---|---|
117
+ | `positive` | `connected`, positive | `contacted` | `contacted` | `complete` | the drip stops (`stopsOutreach`) |
118
+ | `negative` | `connected`, negative | `lost` | `not_a_fit` | `complete` | nothing, unless the reason is `do_not_call` (`suppresses`) |
119
+ | `voicemail` | `left_voicemail`, no sentiment | unchanged | unchanged | `snooze_next_business_day` | nothing |
120
+
121
+ - **The contract names the moves; the caller makes them.** `contactStage` is a
122
+ meaning (`contacted` or `lost`), not a project's stage key. `reminder:
123
+ 'snooze_next_business_day'` leaves the instant to the caller, who knows the
124
+ project's time zone. A `null` in any field means leave that thing as it is.
125
+ A stage a rep picks by hand afterwards, such as `qualified`, is a separate
126
+ move that no outcome makes.
127
+ - **A voicemail isn't a conversation.** It leaves the contact and company
128
+ alone, and a company only moves to `contacted` when someone answered. Dialing
129
+ a number moves nothing either; the outcome does.
130
+ - **A negative outcome needs a reason.** `NEGATIVE_REASONS` is the five, in the
131
+ order a screen offers them: `not_a_fit`, `no_budget`, `has_agency`,
132
+ `wrong_number` and `do_not_call`. `NEGATIVE_REASON_LABELS` and
133
+ `SALES_DISPOSITION_LABELS` say what each button reads.
134
+ - **`suppresses` is for `do_not_call` only.** It means write a suppression for
135
+ the prospect, which blocks every send on its own. It's separate from
136
+ `stopsOutreach`, which stops a drip the prospect is enrolled in, so a caller
137
+ that wants both for an "asked not to call" outcome stops the drip as well.
138
+ - **Check an untrusted outcome first.** `dispositionProblem(disposition,
139
+ { reason })` returns one sentence about why an outcome can't be applied, or
140
+ `null`: a word that isn't one of the three, a negative outcome with no reason
141
+ or one that isn't a listed reason, or a reason on an outcome that takes none.
142
+ `isSalesDisposition` and `isNegativeReason` are the plain guards.
143
+ `dispositionTransitions` throws a `RangeError` for something that isn't a
144
+ disposition, and ignores a reason on any outcome but a negative one.
145
+ - **Fresh each time.** `dispositionTransitions` returns a new object per call,
146
+ so changing one caller's copy never changes the next.
147
+
148
+ `CRM_DISPOSITION_CONTRACT_VERSION` is bumped when an outcome's transitions
149
+ change incompatibly. The wire values (the three outcomes and five reasons) are
150
+ stored and sent, so they don't change.
151
+
93
152
  ## Schema placeholders
94
153
 
95
154
  `schema-placeholders` finds the parts of a JSON-LD value that a template left
@@ -871,8 +930,12 @@ the dashboard that edits it and every site that renders it.
871
930
  with its sections; a stage with nothing in it is left out.
872
931
  - **`caseStudyStageStatus(sections)`** reports all four stages, complete
873
932
  only when the stage's core section (`STAGE_CORE_SECTION`: challenges,
874
- strategy, performance, results) is present, with one sentence on what's
875
- missing. The dashboard shows it beside the editor.
933
+ strategy, performance, results) or a stand-in for it
934
+ (`STAGE_CORE_ALTERNATES`: a verification completes the proof stage) is
935
+ present, with one sentence on what's missing. A stand-in counts only when
936
+ its `data` holds something a page can show, so pass each section's `data`
937
+ along with its `sectionType`. `stageCoreSections(stage)` lists the core and
938
+ its stand-ins. The dashboard shows the status beside the editor.
876
939
  - **Stage names.** The ids are fixed; what people see isn't. A site names
877
940
  the four stages in the dashboard ("Heat", "Shape", "Temper", "Finish"),
878
941
  per project like the rest of its branding, and `CaseStudyStageNames`
@@ -891,6 +954,100 @@ the dashboard that edits it and every site that renders it.
891
954
  phases, results })` names every challenge nothing resolves or proves and
892
955
  every dangling reference. It judges links only where ids were written, so
893
956
  content from before the thread passes.
957
+ - **`isRealDay(value)`** is the one rule for whether a string is a day: a real
958
+ calendar day written YYYY-MM-DD. `2026-02-31` isn't one, and neither is a
959
+ year under 100. A verification's `date` and a reported number's
960
+ `reportedBy.date` both ask it, and so can anything else that stores a day.
961
+
962
+ ### The verification
963
+
964
+ A Lighthouse run can only score what a browser reaches without signing in. For
965
+ an app whose screens sit behind a login, that's the sign-in page, and a score
966
+ for the door reads as a score for the app. The `portfolioVerification` section
967
+ is the proof stage for that build: a short list of checks (one to
968
+ `VERIFICATION_MAX_CHECKS`, eight), each stating what it covers and, required,
969
+ what it doesn't.
970
+
971
+ ```ts
972
+ import {
973
+ readVerificationChecks,
974
+ verificationLink,
975
+ verificationProblems,
976
+ type PortfolioVerificationData,
977
+ } from '@sonordev/contracts/portfolio'
978
+
979
+ const data: PortfolioVerificationData = {
980
+ checks: [
981
+ {
982
+ label: 'Sign-in page, mobile speed',
983
+ result: 'Performance 89',
984
+ measured: 'The Lighthouse performance score of the page a visitor sees before signing in',
985
+ where: 'https://example.com/login',
986
+ method: 'Lighthouse 13, mobile preset, median of 3 runs',
987
+ date: '2026-10-20',
988
+ scope: 'The sign-in page only; the screens behind it were not measured.',
989
+ },
990
+ ],
991
+ }
992
+
993
+ verificationProblems(data) // []
994
+ readVerificationChecks(data) // the checks a page may print
995
+ verificationLink(data.checks[0].where) // 'https://example.com/login'
996
+ ```
997
+
998
+ - **Every field is required**, and each is one line of plain text within its
999
+ limit (`VERIFICATION_FIELD_MAX_CHARS`): no line break, tab or other control
1000
+ character, and no zero-width or direction mark. `date` is a real day, YYYY-MM-DD, no
1001
+ earlier than `VERIFICATION_EARLIEST_DAY` (2000-01-01) and no later than
1002
+ `latestVerificationDay()`, which is tomorrow in UTC. That lets a check run
1003
+ today in a time zone ahead of your server through and refuses one that hasn't
1004
+ happened. `verificationProblems` and `readVerificationChecks` both apply it
1005
+ without being asked; pass `{ latestDay }` to fix the clock.
1006
+ - **`where` points somewhere a reader can go or look.** A public web address,
1007
+ or a screen or file named in words ("the migrations in the repository") or as
1008
+ a path. A placeholder ("n/a", "here"; `isPlaceholderText` is the test), one
1009
+ plain word, a username or password
1010
+ in an address, and localhost or a private network are refused. One thing no
1011
+ rule can catch is a view link with a token in its path or query: if the link
1012
+ works for whoever holds it, don't put it here.
1013
+ - **`scope` states a limit.** It's what the check does _not_ cover, written as a
1014
+ sentence with a word that marks where coverage stops: "only", "not", "except",
1015
+ "without", or a contraction like "weren't". A placeholder ("N/A", "none",
1016
+ "TBD"), a coverage claim ("All screens", "Fully tested") and a denied limit
1017
+ ("No restrictions") are refused (`isVacuousScope`). "Desktop only" and
1018
+ "Counted, not exercised" pass. Write what the check leaves out: "Counted from
1019
+ the migrations; no query ran as a signed-in user." The test reads English, and
1020
+ it can see that a limit was written, not that it's true, so a person still
1021
+ reads the scope before a case study goes live.
1022
+ - **`verificationProblems(data, options)`** names every problem with the check's
1023
+ position (`checks[1].scope is required: say what the check does not cover`),
1024
+ and refuses a field a check doesn't have. Whatever stores a verification
1025
+ should run it first. Nothing calls it for you, and until a write path does,
1026
+ stored data can hold anything, which is why `readVerificationChecks` checks
1027
+ again before a page prints.
1028
+ - **`readVerificationChecks(data)`** is what a page prints: the checks that pass,
1029
+ trimmed and in order, up to the limit. A check that fails is left out, so one
1030
+ with no scope line, or a placeholder for one, doesn't print, and a field a
1031
+ newer writer added is ignored.
1032
+ - **`verificationLink(where)`** returns the address when `where` is exactly one
1033
+ `http` or `https` URL with no credentials in it and a public host, and null
1034
+ for a screen name, a file path, localhost or anything else, so only a real
1035
+ public address becomes a link.
1036
+ - **Every check is something you ran.** There's no `source` on a check. A figure
1037
+ the client gave you stays in Results as a reported number, with the person
1038
+ behind it.
1039
+ - **`PORTFOLIO_VERIFICATION_JSON_SCHEMA`** is the same shape for agents. What a
1040
+ `scope` or a `where` says, and whether a `date` is a real day in range, are
1041
+ `verificationProblems`' job; a schema can't express them.
1042
+
1043
+ It completes the proof stage on its own (`STAGE_CORE_ALTERNATES`), once it has
1044
+ at least one check `readVerificationChecks` would show. An empty verification,
1045
+ which is what an editor's Add button creates, leaves the stage incomplete and
1046
+ says why; `caseStudyStageStatus` reads that from each section's `data`, and a
1047
+ caller that passes only `sectionType` sees a verification as empty. Any project
1048
+ can use it. The status doesn't know the kind of project, so nothing is gated on
1049
+ it; a site that wants a Lighthouse score for a public site keeps writing a
1050
+ Performance section.
894
1051
 
895
1052
  ## Why it exists
896
1053
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What a rep's three call outcomes do to a prospect. Single source of truth for
3
+ * the Sales Mode disposition: the Sonor API applies it when a rep taps an
4
+ * outcome, the dashboard shows the same three buttons and reasons, and the
5
+ * sales extension stops guessing at 'contacted' when a number is dialed. One
6
+ * map, so the three can't disagree about what "negative" moves.
7
+ *
8
+ * positive a connected call that went well. The contact is `contacted`,
9
+ * the company is `contacted`, today's reminder is done and the
10
+ * prospect's drip stops, since they're now in a conversation.
11
+ * negative a connected call that ended the lead. The contact is `lost`
12
+ * (a reason is required), the company is `not_a_fit`, today's
13
+ * reminder is done. Only "asked not to call" suppresses outreach.
14
+ * voicemail nobody picked up. Nothing about the contact or company changes,
15
+ * because a voicemail isn't a conversation, and today's reminder
16
+ * moves to the next business day.
17
+ *
18
+ * The contract names the moves; the caller makes them. Which stage key
19
+ * `contacted` or `lost` is on a given project, which instant "the next business
20
+ * day" is in the project's time zone, and how a suppression row is written all
21
+ * stay with the Sonor API. A stage the rep picks by hand afterwards (qualified,
22
+ * say) is a separate move that no disposition makes.
23
+ *
24
+ * Pure data and a few small functions, no dependencies. The wire values (the
25
+ * three dispositions, the five reasons) are stored and sent, so they never
26
+ * change; a different map is a new CRM_DISPOSITION_CONTRACT_VERSION.
27
+ */
28
+ /** Bumped when a disposition's transitions change incompatibly. */
29
+ export declare const CRM_DISPOSITION_CONTRACT_VERSION = 1;
30
+ /** The three words a rep taps after a call, in button order. */
31
+ export declare const SALES_DISPOSITIONS: readonly ['positive', 'negative', 'voicemail'];
32
+ export type SalesDisposition = (typeof SALES_DISPOSITIONS)[number];
33
+ /** What each button says. */
34
+ export declare const SALES_DISPOSITION_LABELS: Record<SalesDisposition, string>;
35
+ /** The one-tap reasons a negative outcome must carry, in the order they're offered. */
36
+ export declare const NEGATIVE_REASONS: readonly ['not_a_fit', 'no_budget', 'has_agency', 'wrong_number', 'do_not_call'];
37
+ export type NegativeReason = (typeof NEGATIVE_REASONS)[number];
38
+ /** What each reason says on its button. `do_not_call` is the one that suppresses outreach. */
39
+ export declare const NEGATIVE_REASON_LABELS: Record<NegativeReason, string>;
40
+ /** Whether a value is one of the three dispositions, spelled exactly. */
41
+ export declare function isSalesDisposition(value: unknown): value is SalesDisposition;
42
+ /** Whether a value is one of the five negative reasons, spelled exactly. */
43
+ export declare function isNegativeReason(value: unknown): value is NegativeReason;
44
+ /** What a disposition changes. A null leaves that thing as it is. */
45
+ export interface DispositionTransitions {
46
+ /** The call row's `outcome_type`. */
47
+ outcomeType: 'connected' | 'left_voicemail';
48
+ /** The call row's `sentiment`. */
49
+ sentiment: 'positive' | 'negative' | null;
50
+ /** The contact's stage, by meaning rather than by a project's stage key. */
51
+ contactStage: 'contacted' | 'lost' | null;
52
+ /** The target company's `status`. */
53
+ companyStatus: 'contacted' | 'not_a_fit' | null;
54
+ /**
55
+ * What happens to the prospect's reminder due today: finish it, or push it to
56
+ * 09:00 on the project's next business day. No disposition leaves it alone
57
+ * today; a caller still handles null so that never has to be a breaking change.
58
+ */
59
+ reminder: 'complete' | 'snooze_next_business_day' | null;
60
+ /** Stop the prospect's enrolled drip or sequence. */
61
+ stopsOutreach: boolean;
62
+ /**
63
+ * Write an outreach suppression for the prospect. That blocks every send on
64
+ * its own, so it isn't also a `stopsOutreach`. Only a negative outcome whose
65
+ * reason is `do_not_call` suppresses.
66
+ */
67
+ suppresses: boolean;
68
+ }
69
+ export interface DispositionOptions {
70
+ /** The negative outcome's reason. Read for a negative outcome only. */
71
+ reason?: NegativeReason | null;
72
+ }
73
+ /**
74
+ * What a disposition changes. Returns a fresh object each call, so a caller can
75
+ * adjust its copy without touching the next caller's. Throws a RangeError for a
76
+ * value that isn't a disposition: check an untrusted one with
77
+ * `dispositionProblem` first.
78
+ */
79
+ export declare function dispositionTransitions(disposition: SalesDisposition, opts?: DispositionOptions): DispositionTransitions;
80
+ /**
81
+ * Why a disposition and its reason can't be applied, in one sentence, or null
82
+ * when they can. A negative outcome needs a reason and no other outcome takes
83
+ * one. A null or undefined reason means none was given.
84
+ */
85
+ export declare function dispositionProblem(disposition: unknown, opts?: {
86
+ reason?: unknown;
87
+ }): string | null;
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/crm-disposition.ts
4
+ var CRM_DISPOSITION_CONTRACT_VERSION = 1;
5
+ var SALES_DISPOSITIONS = ["positive", "negative", "voicemail"];
6
+ var SALES_DISPOSITION_LABELS = {
7
+ positive: "Positive",
8
+ negative: "Negative",
9
+ voicemail: "Left voicemail"
10
+ };
11
+ var NEGATIVE_REASONS = ["not_a_fit", "no_budget", "has_agency", "wrong_number", "do_not_call"];
12
+ var NEGATIVE_REASON_LABELS = {
13
+ not_a_fit: "Not a fit",
14
+ no_budget: "No budget",
15
+ has_agency: "Has an agency",
16
+ wrong_number: "Wrong number",
17
+ do_not_call: "Asked not to call"
18
+ };
19
+ function isSalesDisposition(value) {
20
+ return typeof value === "string" && SALES_DISPOSITIONS.includes(value);
21
+ }
22
+ function isNegativeReason(value) {
23
+ return typeof value === "string" && NEGATIVE_REASONS.includes(value);
24
+ }
25
+ var TRANSITIONS = {
26
+ positive: {
27
+ outcomeType: "connected",
28
+ sentiment: "positive",
29
+ contactStage: "contacted",
30
+ companyStatus: "contacted",
31
+ reminder: "complete",
32
+ stopsOutreach: true
33
+ },
34
+ negative: {
35
+ outcomeType: "connected",
36
+ sentiment: "negative",
37
+ contactStage: "lost",
38
+ companyStatus: "not_a_fit",
39
+ reminder: "complete",
40
+ stopsOutreach: false
41
+ },
42
+ voicemail: {
43
+ outcomeType: "left_voicemail",
44
+ sentiment: null,
45
+ contactStage: null,
46
+ companyStatus: null,
47
+ reminder: "snooze_next_business_day",
48
+ stopsOutreach: false
49
+ }
50
+ };
51
+ function dispositionTransitions(disposition, opts = {}) {
52
+ if (!isSalesDisposition(disposition)) {
53
+ throw new RangeError(`Not a sales disposition: ${String(disposition)}`);
54
+ }
55
+ return { ...TRANSITIONS[disposition], suppresses: disposition === "negative" && opts.reason === "do_not_call" };
56
+ }
57
+ function dispositionProblem(disposition, opts = {}) {
58
+ if (!isSalesDisposition(disposition)) {
59
+ return `The outcome has to be one of ${SALES_DISPOSITIONS.join(", ")}.`;
60
+ }
61
+ const reason = opts.reason;
62
+ const given = reason !== null && reason !== void 0;
63
+ if (disposition === "negative") {
64
+ if (!given) return `A negative outcome needs a reason: ${NEGATIVE_REASONS.join(", ")}.`;
65
+ if (!isNegativeReason(reason)) return `The reason has to be one of ${NEGATIVE_REASONS.join(", ")}.`;
66
+ return null;
67
+ }
68
+ return given ? `Only a negative outcome takes a reason, and this one is ${disposition}.` : null;
69
+ }
70
+
71
+ exports.CRM_DISPOSITION_CONTRACT_VERSION = CRM_DISPOSITION_CONTRACT_VERSION;
72
+ exports.NEGATIVE_REASONS = NEGATIVE_REASONS;
73
+ exports.NEGATIVE_REASON_LABELS = NEGATIVE_REASON_LABELS;
74
+ exports.SALES_DISPOSITIONS = SALES_DISPOSITIONS;
75
+ exports.SALES_DISPOSITION_LABELS = SALES_DISPOSITION_LABELS;
76
+ exports.dispositionProblem = dispositionProblem;
77
+ exports.dispositionTransitions = dispositionTransitions;
78
+ exports.isNegativeReason = isNegativeReason;
79
+ exports.isSalesDisposition = isSalesDisposition;
@@ -0,0 +1,69 @@
1
+ // src/contracts/crm-disposition.ts
2
+ var CRM_DISPOSITION_CONTRACT_VERSION = 1;
3
+ var SALES_DISPOSITIONS = ["positive", "negative", "voicemail"];
4
+ var SALES_DISPOSITION_LABELS = {
5
+ positive: "Positive",
6
+ negative: "Negative",
7
+ voicemail: "Left voicemail"
8
+ };
9
+ var NEGATIVE_REASONS = ["not_a_fit", "no_budget", "has_agency", "wrong_number", "do_not_call"];
10
+ var NEGATIVE_REASON_LABELS = {
11
+ not_a_fit: "Not a fit",
12
+ no_budget: "No budget",
13
+ has_agency: "Has an agency",
14
+ wrong_number: "Wrong number",
15
+ do_not_call: "Asked not to call"
16
+ };
17
+ function isSalesDisposition(value) {
18
+ return typeof value === "string" && SALES_DISPOSITIONS.includes(value);
19
+ }
20
+ function isNegativeReason(value) {
21
+ return typeof value === "string" && NEGATIVE_REASONS.includes(value);
22
+ }
23
+ var TRANSITIONS = {
24
+ positive: {
25
+ outcomeType: "connected",
26
+ sentiment: "positive",
27
+ contactStage: "contacted",
28
+ companyStatus: "contacted",
29
+ reminder: "complete",
30
+ stopsOutreach: true
31
+ },
32
+ negative: {
33
+ outcomeType: "connected",
34
+ sentiment: "negative",
35
+ contactStage: "lost",
36
+ companyStatus: "not_a_fit",
37
+ reminder: "complete",
38
+ stopsOutreach: false
39
+ },
40
+ voicemail: {
41
+ outcomeType: "left_voicemail",
42
+ sentiment: null,
43
+ contactStage: null,
44
+ companyStatus: null,
45
+ reminder: "snooze_next_business_day",
46
+ stopsOutreach: false
47
+ }
48
+ };
49
+ function dispositionTransitions(disposition, opts = {}) {
50
+ if (!isSalesDisposition(disposition)) {
51
+ throw new RangeError(`Not a sales disposition: ${String(disposition)}`);
52
+ }
53
+ return { ...TRANSITIONS[disposition], suppresses: disposition === "negative" && opts.reason === "do_not_call" };
54
+ }
55
+ function dispositionProblem(disposition, opts = {}) {
56
+ if (!isSalesDisposition(disposition)) {
57
+ return `The outcome has to be one of ${SALES_DISPOSITIONS.join(", ")}.`;
58
+ }
59
+ const reason = opts.reason;
60
+ const given = reason !== null && reason !== void 0;
61
+ if (disposition === "negative") {
62
+ if (!given) return `A negative outcome needs a reason: ${NEGATIVE_REASONS.join(", ")}.`;
63
+ if (!isNegativeReason(reason)) return `The reason has to be one of ${NEGATIVE_REASONS.join(", ")}.`;
64
+ return null;
65
+ }
66
+ return given ? `Only a negative outcome takes a reason, and this one is ${disposition}.` : null;
67
+ }
68
+
69
+ export { CRM_DISPOSITION_CONTRACT_VERSION, NEGATIVE_REASONS, NEGATIVE_REASON_LABELS, SALES_DISPOSITIONS, SALES_DISPOSITION_LABELS, dispositionProblem, dispositionTransitions, isNegativeReason, isSalesDisposition };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The live-frame handshake, on its own so it stays small.
3
+ *
4
+ * A site-kit page framed by another origin posts FRAME_READY_MESSAGE to its
5
+ * embedder once it has painted (shared/frame.ts, announceFrameReady); a case
6
+ * study's device frames (the portfolio renderers) are the ones listening.
7
+ *
8
+ * This is a leaf module on purpose. shared/frame.ts runs in every page's
9
+ * analytics and layout graph, and `./portfolio` is a large module of case
10
+ * study rules: importing the message from there put all of it in every
11
+ * page's eager bundle. Single source of truth for the message: `./portfolio`
12
+ * re-exports it (so `@sonordev/contracts/portfolio` and
13
+ * `@sonordev/site-kit/portfolio/contract` still carry it) and shared/frame.ts
14
+ * imports it from here. Keep it free of other imports.
15
+ */
16
+ /** The message a cross-origin-framed site-kit page posts to its embedder once it has painted. */
17
+ export declare const FRAME_READY_MESSAGE: 'sonor:frame-ready';
18
+ export interface FrameReadyMessage {
19
+ type: typeof FRAME_READY_MESSAGE;
20
+ v: 1;
21
+ }
22
+ /** True for a {@link FrameReadyMessage}, for the embedder's message handler. */
23
+ export declare function isFrameReadyMessage(data: unknown): data is FrameReadyMessage;