@company-semantics/contracts 31.0.0 → 33.0.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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Chat unit — the dual-output chat transcript block.
3
+ *
4
+ * The one component with real layout of its own: message bubbles, an in-stream
5
+ * CTA, and continuation dots, laid out as a bracketed "unit". Same contract as
6
+ * every other component in `blocks.ts` (returns a `Block` = `{ html, text }`, so
7
+ * both surfaces derive from one source); it lives apart because bubble geometry
8
+ * — wrapping, truncation, avatar gutters, alignment — is a self-contained
9
+ * concern that the paragraph/CTA primitives next door do not share.
10
+ *
11
+ * INVARIANTS:
12
+ * - Pure functions, no side effects.
13
+ * - Components escape their own content; templates pass raw text.
14
+ * - HTML and plain text truncate at exactly the same point (`clampMessage` is
15
+ * the single truncation authority).
16
+ */
17
+
18
+ import {
19
+ type Block,
20
+ type CtaBoxOptions,
21
+ ctaButton,
22
+ FONT_SIZE,
23
+ MONO,
24
+ } from "./blocks";
25
+ import { escapeHtml } from "./escape-html";
26
+
27
+ // =============================================================================
28
+ // Text geometry
29
+ // =============================================================================
30
+
31
+ /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
32
+ function wrapText(text: string, width: number): string[] {
33
+ const lines: string[] = [];
34
+ let cur = "";
35
+ for (const word of text.split(/\s+/).filter(Boolean)) {
36
+ let w = word;
37
+ while (w.length > width) {
38
+ if (cur) {
39
+ lines.push(cur);
40
+ cur = "";
41
+ }
42
+ lines.push(w.slice(0, width));
43
+ w = w.slice(width);
44
+ }
45
+ if (!cur) cur = w;
46
+ else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
47
+ else {
48
+ lines.push(cur);
49
+ cur = w;
50
+ }
51
+ }
52
+ if (cur) lines.push(cur);
53
+ return lines.length ? lines : [""];
54
+ }
55
+
56
+ /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
57
+ const MAX_MESSAGE_LINES = 3;
58
+ const MESSAGE_WIDTH = 36;
59
+ /** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
60
+ * user box aligns with the assistant box. */
61
+ const CHAT_INDENT = " ";
62
+
63
+ /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
64
+ function wrapClamped(text: string, width: number, maxLines: number): string[] {
65
+ const lines = wrapText(text, width);
66
+ if (lines.length <= maxLines) return lines;
67
+ const kept = lines.slice(0, maxLines);
68
+ const last = kept[maxLines - 1];
69
+ kept[maxLines - 1] =
70
+ (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
71
+ "...";
72
+ return kept;
73
+ }
74
+
75
+ /**
76
+ * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
77
+ * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
78
+ * this, so HTML and plain text truncate at exactly the same point.
79
+ */
80
+ function clampMessage(text: string): string {
81
+ return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
82
+ }
83
+
84
+ // =============================================================================
85
+ // Chat items
86
+ // =============================================================================
87
+
88
+ /** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
89
+ * lays them out together. `from` is the user attribution (sender name). */
90
+ export interface ChatMessage {
91
+ role: "user" | "assistant";
92
+ text: string;
93
+ from?: string;
94
+ }
95
+
96
+ /** A user (right-aligned) chat message with an optional `from` attribution. */
97
+ export function chatUser(text: string, from?: string): ChatMessage {
98
+ return { role: "user", text, from };
99
+ }
100
+
101
+ /** An assistant (left-aligned) chat message. */
102
+ export function chatAssistant(text: string): ChatMessage {
103
+ return { role: "assistant", text };
104
+ }
105
+
106
+ /** A CTA button placed inside a chat unit (below a message). */
107
+ export interface ChatCta {
108
+ role: "cta";
109
+ cta: CtaBoxOptions;
110
+ }
111
+
112
+ /** Centered continuation dots inside a chat unit — a "conversation continues"
113
+ * separator placed between a message bubble and a following CTA. */
114
+ export interface ChatDots {
115
+ role: "dots";
116
+ }
117
+
118
+ /** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
119
+ export type ChatItem = ChatMessage | ChatCta | ChatDots;
120
+
121
+ /** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
122
+ export function chatCta(cta: CtaBoxOptions): ChatCta {
123
+ return { role: "cta", cta };
124
+ }
125
+
126
+ /** Continuation dots for a chat unit — pass it to `chatUnit` where the
127
+ * conversation should read as continuing (e.g. between the preview and CTA). */
128
+ export function chatDots(): ChatDots {
129
+ return { role: "dots" };
130
+ }
131
+
132
+ // =============================================================================
133
+ // Item rendering
134
+ // =============================================================================
135
+
136
+ /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
137
+ * outer side. */
138
+ function chatRuleHtml(position: "top" | "bottom"): string {
139
+ const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
140
+ return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
141
+ }
142
+
143
+ /**
144
+ * Render one message to its HTML `<table>` and plain-text box lines. Both roles
145
+ * share the 3-column skeleton: a fixed avatar column on each side (the visible
146
+ * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
147
+ * width, so bubbles stay bounded and aligned), a middle cell that right/left-
148
+ * aligns the bubble, and — for a user `from` — an attribution row below.
149
+ */
150
+ function renderBubble(
151
+ msg: ChatMessage,
152
+ margin: string,
153
+ ): { html: string; text: string[] } {
154
+ const clamped = clampMessage(msg.text);
155
+ const isUser = msg.role === "user";
156
+
157
+ const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
158
+ const bubbleAlign = isUser ? " text-align: right;" : "";
159
+ const cellAlign = isUser ? "right" : "left";
160
+ const csHidden = isUser ? "visibility: hidden; " : "";
161
+ const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
162
+
163
+ const attributionRow =
164
+ isUser && msg.from
165
+ ? `
166
+ <tr>
167
+ <td></td>
168
+ <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
169
+ <td></td>
170
+ </tr>`
171
+ : "";
172
+
173
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
174
+ <tr>
175
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
176
+ <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
177
+ <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
178
+ </table></td>
179
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
180
+ </tr>${attributionRow}
181
+ </table>`;
182
+
183
+ const border = "─".repeat(MESSAGE_WIDTH + 2);
184
+ const lines = wrapText(clamped, MESSAGE_WIDTH);
185
+ const body = lines.map(
186
+ (line) =>
187
+ `${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
188
+ );
189
+ // Avatar beside the last message line (one row up from the bottom border).
190
+ const last = body.length - 1;
191
+ if (isUser) body[last] += " (•̀_ರ╮)";
192
+ else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
193
+
194
+ const box = [
195
+ `${CHAT_INDENT}┌${border}┐`,
196
+ ...body,
197
+ `${CHAT_INDENT}└${border}┘`,
198
+ ];
199
+ if (isUser && msg.from) {
200
+ box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
201
+ }
202
+ return { html, text: box };
203
+ }
204
+
205
+ /** The right-edge column the plain-text CTA/dots align to under a user bubble. */
206
+ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
207
+
208
+ /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
209
+ * the same inline-block, so the dots span exactly the button's width). */
210
+ function dotsOverCtaHtml(): string {
211
+ return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
212
+ }
213
+
214
+ /**
215
+ * A CTA button inside the chat stream — aligned to the side of the message it
216
+ * follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
217
+ * reserve both columns, so it sits in the message channel and never enters the
218
+ * kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
219
+ * When `withDots`, continuation "⋮" render just above the button, centered over it.
220
+ */
221
+ function renderChatCta(
222
+ cta: CtaBoxOptions,
223
+ align: "left" | "right",
224
+ withDots: boolean,
225
+ ): { html: string; text: string[] } {
226
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
227
+ // Dots + button share one inline-block so the dots center over the button's
228
+ // exact width regardless of label length.
229
+ const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
230
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
231
+ <tr>
232
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
233
+ <td style="width: 100%; text-align: ${align};">${stack}</td>
234
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
235
+ </tr>
236
+ </table>`;
237
+
238
+ // Right-align to the message box's right edge (under a user bubble); else the
239
+ // left avatar gutter.
240
+ const btnLines = btnText
241
+ .split("\n")
242
+ .map((l) =>
243
+ !l
244
+ ? l
245
+ : align === "right"
246
+ ? l.padStart(CHAT_RIGHT_EDGE)
247
+ : `${CHAT_INDENT}${l}`,
248
+ );
249
+ if (!withDots) return { html, text: btnLines };
250
+
251
+ // Center "⋮" over the ascii box (its first line spans the full box width).
252
+ const boxWidth = btnText.split("\n")[0].length;
253
+ const dotsCol =
254
+ align === "right"
255
+ ? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
256
+ : CHAT_INDENT.length + Math.ceil(boxWidth / 2);
257
+ return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
258
+ }
259
+
260
+ /**
261
+ * Standalone continuation dots — centered in the message channel. Used only when
262
+ * `chatDots()` is NOT immediately followed by a CTA (the common case folds the
263
+ * dots into the CTA via `renderChatCta`, centered over the box).
264
+ */
265
+ function renderChatDots(): { html: string; text: string[] } {
266
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
267
+ <tr>
268
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
269
+ <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
270
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
271
+ </tr>
272
+ </table>`;
273
+ // Center the "⋮" over the message box (avatar gutter + box width + borders).
274
+ const center = Math.round(CHAT_RIGHT_EDGE / 2);
275
+ return { html, text: ["⋮".padStart(center)] };
276
+ }
277
+
278
+ // =============================================================================
279
+ // Chat unit
280
+ // =============================================================================
281
+
282
+ /**
283
+ * Lay out one or more chat items — message bubbles and/or CTA buttons — as a
284
+ * single block: a rule above and below (the real-chat "unit"), the items
285
+ * between. The plain-text rule spans the widest line; a blank line follows the
286
+ * top rule and the last item hugs the bottom rule. Continuation dots
287
+ * (`chatDots`) sit above the CTA they precede, centered over the box.
288
+ */
289
+ export function chatUnit(...items: ChatItem[]): Block {
290
+ const parts: { html: string; text: string[] }[] = [];
291
+ items.forEach((item, i) => {
292
+ if (item.role === "dots") {
293
+ // Dots immediately before a CTA render with it (centered over the box);
294
+ // otherwise fall back to channel-centered standalone dots.
295
+ if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
296
+ return;
297
+ }
298
+ if (item.role === "cta") {
299
+ // Mirror the side of the nearest preceding message (skip any dots between),
300
+ // so the CTA sits under the bubble it belongs to — right under a user.
301
+ let j = i - 1;
302
+ while (j >= 0 && items[j].role === "dots") j--;
303
+ const align = items[j]?.role === "user" ? "right" : "left";
304
+ parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
305
+ return;
306
+ }
307
+ // A bubble directly above a CTA or continuation dots gets a tighter 16px
308
+ // gap; else 24px.
309
+ const next = items[i + 1]?.role;
310
+ const margin =
311
+ next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
312
+ parts.push(renderBubble(item, margin));
313
+ });
314
+ const width = parts
315
+ .flatMap((b) => b.text)
316
+ .reduce((w, l) => Math.max(w, l.length), 0);
317
+ const rule = "_".repeat(width);
318
+
319
+ const html = [
320
+ chatRuleHtml("top"),
321
+ ...parts.map((b) => b.html),
322
+ chatRuleHtml("bottom"),
323
+ ].join("\n");
324
+
325
+ const text =
326
+ `${rule}\n\n` +
327
+ parts.map((b) => b.text.join("\n")).join("\n\n") +
328
+ `\n${rule}`;
329
+
330
+ return { html, text, spacing: "normal" };
331
+ }
@@ -7,13 +7,13 @@ import type { EmailPayloads } from "../types";
7
7
  import {
8
8
  ACCESS_PHRASE,
9
9
  type Block,
10
- bold,
11
- ctaBox,
12
10
  footer,
13
11
  greeting,
12
+ keyValue,
14
13
  paragraph,
15
14
  signature,
16
15
  } from "./blocks";
16
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
17
17
  import { COMPANY_NAME } from "./constants";
18
18
 
19
19
  export type AccessApprovedPayload =
@@ -24,14 +24,19 @@ export function renderAccessApproved(payload: AccessApprovedPayload): Block[] {
24
24
 
25
25
  return [
26
26
  greeting(),
27
- paragraph([
28
- bold(approverName),
29
- " approved your request to access ",
30
- bold(`"${docTitle}"`),
31
- ` — you ${ACCESS_PHRASE[accessLevel]}.`,
32
- ]),
33
- ctaBox({ label: "OPEN", href: docUrl }),
34
- footer(`This notification was sent via ${COMPANY_NAME}.`),
27
+ paragraph("Your access request was approved."),
28
+ chatUnit(
29
+ chatAssistant("Open to view."),
30
+ chatCta({ label: "OPEN", href: docUrl }),
31
+ ),
32
+ keyValue("Document", `"${docTitle}"`),
33
+ keyValue("Access", ACCESS_PHRASE[accessLevel]),
34
+ keyValue("Approved by", approverName, "normal"),
35
+ footer(
36
+ `This notification was sent via ${COMPANY_NAME}.`,
37
+ "Access is now active.",
38
+ "none",
39
+ ),
35
40
  signature(),
36
41
  ];
37
42
  }
@@ -6,13 +6,13 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- bold,
10
- chatUser,
11
9
  footer,
12
10
  greeting,
11
+ keyValue,
13
12
  paragraph,
14
13
  signature,
15
14
  } from "./blocks";
15
+ import { chatUnit, chatUser } from "./chat";
16
16
  import { COMPANY_NAME } from "./constants";
17
17
 
18
18
  export type AccessDeniedPayload =
@@ -23,18 +23,19 @@ export function renderAccessDenied(payload: AccessDeniedPayload): Block[] {
23
23
 
24
24
  const blocks: Block[] = [
25
25
  greeting(),
26
- paragraph([
27
- bold(approverName),
28
- " declined your request to access ",
29
- bold(`"${docTitle}"`),
30
- ".",
31
- ]),
26
+ paragraph("Your access request was declined."),
32
27
  ];
33
28
 
34
- if (reason) blocks.push(chatUser(reason, `Reason from ${approverName}`));
29
+ if (reason) blocks.push(chatUnit(chatUser(reason, approverName)));
35
30
 
36
31
  blocks.push(
37
- footer(`This notification was sent via ${COMPANY_NAME}.`),
32
+ keyValue("Document", `"${docTitle}"`),
33
+ keyValue("Declined by", approverName, "normal"),
34
+ footer(
35
+ `This notification was sent via ${COMPANY_NAME}.`,
36
+ "This request is now closed.",
37
+ "none",
38
+ ),
38
39
  signature(),
39
40
  );
40
41
 
@@ -6,43 +6,41 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- bold,
10
- chatUser,
11
- ctaBox,
12
9
  footer,
13
10
  greeting,
11
+ keyValue,
14
12
  paragraph,
15
13
  signature,
16
14
  } from "./blocks";
15
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
17
16
  import { COMPANY_NAME } from "./constants";
18
17
 
19
18
  export type AccessRequestedPayload =
20
19
  EmailPayloads["companyMd.access_requested"];
21
20
 
22
- const OWNER_NOTE = "You are receiving this because you own this document.";
21
+ const OWNER_NOTE = "You own this document.";
23
22
 
24
23
  export function renderAccessRequested(
25
24
  payload: AccessRequestedPayload,
26
25
  ): Block[] {
27
26
  const { requesterName, docTitle, message, reviewUrl } = payload;
28
27
 
29
- const blocks: Block[] = [
28
+ return [
30
29
  greeting(),
31
- paragraph([
32
- bold(requesterName),
33
- " is requesting access to ",
34
- bold(`"${docTitle}"`),
35
- ".",
36
- ]),
37
- ];
38
-
39
- if (message) blocks.push(chatUser(message, requesterName));
40
-
41
- blocks.push(
42
- ctaBox({ label: "REVIEW", href: reviewUrl }),
43
- footer(`This notification was sent via ${COMPANY_NAME}.`, OWNER_NOTE),
30
+ paragraph("Someone requested document access."),
31
+ chatUnit(
32
+ message
33
+ ? chatUser(message, requesterName)
34
+ : chatAssistant("Review the request."),
35
+ chatCta({ label: "REVIEW", href: reviewUrl }),
36
+ ),
37
+ keyValue("From", requesterName),
38
+ keyValue("Document", `"${docTitle}"`, "normal"),
39
+ footer(
40
+ `This notification was sent via ${COMPANY_NAME}.`,
41
+ OWNER_NOTE,
42
+ "none",
43
+ ),
44
44
  signature(),
45
- );
46
-
47
- return blocks;
45
+ ];
48
46
  }
@@ -17,15 +17,13 @@ export {
17
17
  textShell,
18
18
  paragraph,
19
19
  greeting,
20
+ security,
20
21
  keyValue,
21
22
  footer,
22
23
  signature,
23
24
  NOTICE,
24
25
  ACCESS_PHRASE,
25
- chatDots,
26
26
  ctaBox,
27
- chatUser,
28
- chatAssistant,
29
27
  bold,
30
28
  type Block,
31
29
  type Inline,
@@ -34,6 +32,18 @@ export {
34
32
  type CtaBoxOptions,
35
33
  } from "./blocks";
36
34
 
35
+ // The chat unit — same `Block` contract, its own module (bubble geometry)
36
+ export {
37
+ chatDots,
38
+ chatUser,
39
+ chatAssistant,
40
+ chatCta,
41
+ chatUnit,
42
+ type ChatMessage,
43
+ type ChatCta,
44
+ type ChatItem,
45
+ } from "./chat";
46
+
37
47
  // Dispatcher
38
48
  export {
39
49
  renderEmail,
@@ -6,28 +6,35 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- ctaBox,
10
9
  footer,
10
+ formatExpiry,
11
+ greeting,
11
12
  keyValue,
12
13
  NOTICE,
13
14
  paragraph,
14
15
  signature,
16
+ titleCase,
15
17
  } from "./blocks";
18
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
16
19
  import { COMPANY_NAME } from "./constants";
17
20
 
18
21
  export type OrgInvitePayload = EmailPayloads["org.invite"];
19
22
 
20
23
  export function renderOrgInvite(payload: OrgInvitePayload): Block[] {
21
- const { inviterName, orgName, role, acceptUrl, expiresInDays } = payload;
24
+ const { inviterName, orgName, role, acceptUrl, expiresAt } = payload;
22
25
 
23
26
  return [
24
- paragraph("Workspace invitation."),
25
- ctaBox({ label: "JOIN", href: acceptUrl }),
27
+ greeting(),
28
+ paragraph(`${orgName} uses Company Semantics.`),
29
+ chatUnit(
30
+ chatAssistant("Join to accept the invitation."),
31
+ chatCta({ label: "JOIN", href: acceptUrl }),
32
+ ),
26
33
  keyValue("From", inviterName),
27
34
  keyValue("Workspace", orgName),
28
- keyValue("Role", role),
29
- keyValue("Expires in", `${expiresInDays} days`, "normal"),
30
- footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE),
35
+ keyValue("Role", titleCase(role)),
36
+ keyValue("Expires", formatExpiry(expiresAt), "normal"),
37
+ footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
31
38
  signature(),
32
39
  ];
33
40
  }
@@ -4,7 +4,16 @@
4
4
 
5
5
  import type { EmailPayloads } from "../types";
6
6
 
7
- import { type Block, bold, footer, paragraph, signature } from "./blocks";
7
+ import {
8
+ type Block,
9
+ footer,
10
+ greeting,
11
+ keyValue,
12
+ paragraph,
13
+ signature,
14
+ } from "./blocks";
15
+ import { chatAssistant, chatUnit } from "./chat";
16
+ import { COMPANY_NAME } from "./constants";
8
17
 
9
18
  export type OwnershipTransferCompletedPayload =
10
19
  EmailPayloads["org.ownership_transfer_completed"];
@@ -15,18 +24,17 @@ export function renderOwnershipTransferCompleted(
15
24
  const { orgName, newOwnerEmail } = payload;
16
25
 
17
26
  return [
18
- paragraph([
19
- "The ownership of ",
20
- bold(`"${orgName}"`),
21
- " has been transferred to ",
22
- bold(newOwnerEmail),
23
- ".",
24
- ]),
25
- paragraph(
26
- "You are no longer the owner of this workspace, but you remain an admin.",
27
+ greeting(),
28
+ paragraph("Ownership was transferred."),
29
+ chatUnit(
30
+ chatAssistant("You're no longer the owner, but you remain an admin."),
27
31
  ),
32
+ keyValue("Workspace", orgName),
33
+ keyValue("New owner", newOwnerEmail, "normal"),
28
34
  footer(
29
- "If you did not authorize this transfer, please contact support immediately.",
35
+ `This confirmation was sent via ${COMPANY_NAME}.`,
36
+ "If you did not authorize this, contact support immediately.",
37
+ "none",
30
38
  ),
31
39
  signature(),
32
40
  ];
@@ -6,40 +6,37 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- bold,
10
- chatUser,
11
- ctaBox,
12
9
  footer,
10
+ formatExpiry,
11
+ greeting,
13
12
  keyValue,
13
+ NOTICE,
14
14
  paragraph,
15
15
  signature,
16
16
  } from "./blocks";
17
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
18
+ import { COMPANY_NAME } from "./constants";
17
19
 
18
20
  export type OwnershipTransferPayload = EmailPayloads["org.ownership_transfer"];
19
21
 
20
22
  export function renderOwnershipTransfer(
21
23
  payload: OwnershipTransferPayload,
22
24
  ): Block[] {
23
- const { orgName, acceptUrl, expiresInDays, note } = payload;
24
-
25
- const blocks: Block[] = [
26
- paragraph([
27
- "You have been invited to become the owner of ",
28
- bold(`"${orgName}"`),
29
- ".",
30
- ]),
31
- ];
32
-
33
- if (note) blocks.push(chatUser(note, "the current owner"));
34
-
35
- blocks.push(
36
- ctaBox({ label: "ACCEPT", href: acceptUrl }),
37
- keyValue("Expires in", `${expiresInDays} days`, "normal"),
38
- footer(
39
- "If you did not expect this invitation, you can safely ignore this email.",
25
+ const { orgName, acceptUrl, expiresAt, note, fromName } = payload;
26
+
27
+ return [
28
+ greeting(),
29
+ paragraph("You've been invited to take ownership."),
30
+ chatUnit(
31
+ note
32
+ ? chatUser(note, fromName)
33
+ : chatAssistant("Accept to take ownership."),
34
+ chatCta({ label: "ACCEPT", href: acceptUrl }),
40
35
  ),
36
+ ...(fromName ? [keyValue("From", fromName)] : []),
37
+ keyValue("Workspace", orgName),
38
+ keyValue("Expires", formatExpiry(expiresAt), "normal"),
39
+ footer(`This transfer was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
41
40
  signature(),
42
- );
43
-
44
- return blocks;
41
+ ];
45
42
  }