Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | 5x 5x 5x 111x 7x 7x 10x 10x 8x 7x 49x | import type { ReferralState } from "./state";
/**
* The partner-facing timeline — a promise tracker, not a chat log.
*
* ── The rule this module exists to enforce ───────────────────────────────
* FR-P-5.3 and FR-L-5: a partner may see the name and number THEY supplied,
* the society, the status, our own actions, and their own money. They may not
* see what the homeowner told us — budget, scope, quotes, designer notes, or
* any message content.
*
* That is not a nicety. The homeowner messaged US; they never agreed to have
* their conversation relayed to the person who introduced them. So the
* timeline is built ONLY from the referral's own state transitions, and this
* module never receives the homeowner's messages in the first place — the
* limit is structural rather than a filter someone can forget to apply.
*
* Written in the first person plural, because every line is something we did
* or something we are waiting on. "We are checking this contact" is a promise
* the partner can hold us to; "engaged" is a database value.
*/
export type TimelineEntry = {
/** The event id, so the client can key a list without an index. */
id: string;
at: string;
/** Plain language, already in the partner's voice. */
title: string;
detail?: string;
/** Drives the icon/colour. Not the internal state name. */
tone: "done" | "waiting" | "good" | "stopped";
};
export type ReferralEventInput = {
id: string;
type: string;
occurredAt: Date;
payload?: string | null;
};
/**
* What a partner is shown for each state. §6.3's vocabulary, which is the
* ONLY status string a partner ever sees — no internal enum reaches them.
*
* `{name}` is the contact name the partner themselves typed, so echoing it
* back reveals nothing they did not already know.
*/
export const PARTNER_STATUS_LABEL: Record<ReferralState, string> = {
draft: "Not sent yet",
forwarded: "Waiting for {name} to message us",
engaged: "{name} is talking to us",
verified: "Confirmed",
contacted: "With a professional",
quoted: "With a professional",
converted: "Converted",
duplicate: "Already with us",
rejected: "Not eligible",
expired: "Expired",
lost: "Not proceeding",
opted_out: "Not proceeding",
};
/** The one-line explanation under the chip. */
export const PARTNER_STATUS_HINT: Record<ReferralState, string> = {
draft: "Tap to send {name} their free estimate.",
forwarded: "We will let you know the moment they message us.",
engaged: "We are checking a few details with them.",
verified: "₹100 approved. It moves to your balance after the hold.",
contacted: "We have introduced them to a professional.",
quoted: "We have introduced them to a professional.",
converted: "₹1,000 approved.",
duplicate:
"This contact reached us earlier, so there is no payout on this one.",
rejected: "We could not take this one forward.",
expired: "This was not sent within 3 days. You can add them again.",
lost: "This one is not going ahead.",
opted_out: "This one is not going ahead.",
};
/**
* Event type → partner-facing line.
*
* An event with no entry here produces NOTHING rather than a fallback. That is
* deliberate: the internal event vocabulary grows for operational reasons, and
* a default of "show it" would eventually surface an operator note or a
* message id on a partner's screen. Silence is the safe default; adding a line
* is a decision someone makes on purpose.
*/
const LINES: Record<
string,
{ title: string; detail?: string; tone: TimelineEntry["tone"] }
> = {
"referral.created": {
title: "You added {name}",
tone: "done",
},
"referral.duplicate": {
title: "Already with us",
detail:
"This contact reached us through someone else earlier. There is no payout on this one, and it does not affect your other referrals.",
tone: "stopped",
},
"referral.forwarded": {
title: "You sent {name} their estimate",
detail: "We will tell you as soon as they message us.",
tone: "done",
},
"referral.nudged": {
title: "You sent {name} a reminder",
tone: "done",
},
"referral.link_opened": {
title: "{name} opened their estimate",
detail: "They have seen it. They have not messaged us yet.",
tone: "waiting",
},
"referral.expired": {
title: "Expired",
detail:
"This was not sent within 3 days, so we closed it. You can add {name} again.",
tone: "stopped",
},
"referral.engaged": {
title: "{name} messaged us",
detail: "We are checking a few details with them.",
tone: "waiting",
},
"referral.verified": {
title: "Confirmed — ₹100 approved",
detail: "It moves to your withdrawable balance after the 5-day hold.",
tone: "good",
},
"referral.rejected": {
title: "Not eligible",
tone: "stopped",
},
"referral.contacted": {
title: "Introduced to a professional",
tone: "done",
},
"referral.quoted": {
title: "A professional is working on their estimate",
tone: "done",
},
"referral.converted": {
title: "Converted — ₹1,000 approved",
tone: "good",
},
"referral.lost": {
title: "Not proceeding",
detail: "Anything already approved on this referral still stands.",
tone: "stopped",
},
"referral.opted_out": {
title: "Not proceeding",
tone: "stopped",
},
};
function fill(text: string, name: string): string {
return text.replaceAll("{name}", name);
}
/**
* Build the partner's timeline.
*
* @param contactName the name THE PARTNER typed. Never a name we learned.
*/
export function buildTimeline(
events: ReferralEventInput[],
contactName: string,
): TimelineEntry[] {
const out: TimelineEntry[] = [];
for (const event of events) {
const line = LINES[event.type];
// Unknown event types are dropped, not defaulted. See LINES.
if (!line) continue;
out.push({
id: event.id,
at: event.occurredAt.toISOString(),
title: fill(line.title, contactName),
...(line.detail ? { detail: fill(line.detail, contactName) } : {}),
tone: line.tone,
});
}
return out;
}
export function statusLabel(status: ReferralState, contactName: string) {
return {
label: fill(PARTNER_STATUS_LABEL[status], contactName),
hint: fill(PARTNER_STATUS_HINT[status], contactName),
};
}
|