@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.
- package/package.json +4 -4
- package/src/content/schemas.ts +18 -1
- package/src/email/__tests__/registry.test.ts +3 -3
- package/src/email/registry.ts +6 -4
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +1141 -454
- package/src/email/render/__tests__/render-snapshot.test.ts +11 -7
- package/src/email/render/auth-otp.ts +12 -7
- package/src/email/render/blocks.ts +79 -178
- package/src/email/render/chat-shared.ts +15 -21
- package/src/email/render/chat.ts +331 -0
- package/src/email/render/company-md-access-approved.ts +15 -10
- package/src/email/render/company-md-access-denied.ts +11 -10
- package/src/email/render/company-md-access-requested.ts +19 -21
- package/src/email/render/index.ts +13 -3
- package/src/email/render/org-invite.ts +14 -7
- package/src/email/render/ownership-transfer-completed.ts +19 -11
- package/src/email/render/ownership-transfer.ts +20 -23
- package/src/email/render/render-email.ts +39 -1
- package/src/email/render/security-alert.ts +11 -12
- package/src/email/render/share-granted.ts +11 -17
- package/src/email/render/unit-owner-granted.ts +36 -25
- package/src/email/types.ts +10 -3
- package/src/org/company-md.ts +15 -1
- package/src/permissions/share-api.ts +22 -1
|
@@ -58,41 +58,45 @@ add("org.invite", "Admin", {
|
|
|
58
58
|
orgName: "Acme Corp",
|
|
59
59
|
role: "admin",
|
|
60
60
|
acceptUrl: `${APP}/invite/abc123`,
|
|
61
|
-
|
|
61
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
62
62
|
});
|
|
63
63
|
add("org.invite", "Member", {
|
|
64
64
|
inviterName: "Alex Rivera",
|
|
65
65
|
orgName: "Acme Corp",
|
|
66
66
|
role: "member",
|
|
67
67
|
acceptUrl: `${APP}/invite/def456`,
|
|
68
|
-
|
|
68
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
add("org.unit_owner_granted", "Unit owner + message", {
|
|
72
72
|
granterName: "Jordan Lee",
|
|
73
73
|
recipientName: "Sam Chen",
|
|
74
|
+
orgName: "Acme Corp",
|
|
74
75
|
unitName: "Platform Engineering",
|
|
75
76
|
roleLabel: "Unit owner",
|
|
76
77
|
ctaUrl: `${APP}/org/unit/42`,
|
|
77
78
|
message: "Welcome aboard — glad to have you owning this team.",
|
|
78
79
|
});
|
|
79
|
-
add("org.unit_owner_granted", "Delegate
|
|
80
|
+
add("org.unit_owner_granted", "Delegate with expiry", {
|
|
80
81
|
granterName: "Jordan Lee",
|
|
82
|
+
orgName: "Acme Corp",
|
|
81
83
|
unitName: "Platform Engineering",
|
|
82
84
|
roleLabel: "Delegate",
|
|
83
85
|
ctaUrl: `${APP}/org/unit/42`,
|
|
86
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
84
87
|
});
|
|
85
88
|
|
|
86
|
-
add("org.ownership_transfer", "With note", {
|
|
89
|
+
add("org.ownership_transfer", "With note + from", {
|
|
87
90
|
orgName: "Acme Corp",
|
|
88
91
|
acceptUrl: `${APP}/owner-transfer/accept/tok123`,
|
|
89
|
-
|
|
92
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
90
93
|
note: "Handing this over as I move to an advisory role.",
|
|
94
|
+
fromName: "Jordan Lee",
|
|
91
95
|
});
|
|
92
|
-
add("org.ownership_transfer", "No note", {
|
|
96
|
+
add("org.ownership_transfer", "No note (admin)", {
|
|
93
97
|
orgName: "Acme Corp",
|
|
94
98
|
acceptUrl: `${APP}/owner-transfer/accept/tok456`,
|
|
95
|
-
|
|
99
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
96
100
|
});
|
|
97
101
|
|
|
98
102
|
add("org.ownership_transfer_completed", "Default", {
|
|
@@ -10,12 +10,13 @@ import type { EmailPayloads } from "../types";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
type Block,
|
|
13
|
-
ctaBox,
|
|
14
13
|
footer,
|
|
14
|
+
greeting,
|
|
15
15
|
keyValue,
|
|
16
16
|
paragraph,
|
|
17
17
|
signature,
|
|
18
18
|
} from "./blocks";
|
|
19
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
19
20
|
import { COMPANY_NAME } from "./constants";
|
|
20
21
|
|
|
21
22
|
export type AuthOtpPayload = EmailPayloads["auth.otp"];
|
|
@@ -32,9 +33,13 @@ export function renderAuthOtp(
|
|
|
32
33
|
const { otp, expiresInMinutes, requestIp, userAgent } = payload;
|
|
33
34
|
|
|
34
35
|
const blocks: Block[] = [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
greeting(),
|
|
37
|
+
paragraph("A login code was requested."),
|
|
38
|
+
chatUnit(
|
|
39
|
+
chatAssistant("Copy + paste this code in the login form."),
|
|
40
|
+
chatCta({ label: otp }),
|
|
41
|
+
),
|
|
42
|
+
keyValue("Status", "Valid"),
|
|
38
43
|
keyValue(
|
|
39
44
|
"Expires in",
|
|
40
45
|
`${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
|
|
@@ -55,10 +60,10 @@ export function renderAuthOtp(
|
|
|
55
60
|
|
|
56
61
|
blocks.push(
|
|
57
62
|
footer(
|
|
58
|
-
`This code was
|
|
59
|
-
"
|
|
63
|
+
`This login code was sent via ${COMPANY_NAME}.`,
|
|
64
|
+
"If this wasn't you, no action is required.",
|
|
65
|
+
"none",
|
|
60
66
|
),
|
|
61
|
-
paragraph("If this wasn't you, no action is required."),
|
|
62
67
|
signature(),
|
|
63
68
|
);
|
|
64
69
|
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
* UI is the components.** Editing a block restyles every email, HTML and plain
|
|
8
8
|
* text, across the backend (real sends) and the app (Ladle preview).
|
|
9
9
|
*
|
|
10
|
+
* This module holds the primitives — shells, paragraphs, and the CTA box. The
|
|
11
|
+
* chat unit builds on them from `./chat`; both are re-exported together from
|
|
12
|
+
* `./index`, which is the surface templates and consumers import.
|
|
13
|
+
*
|
|
10
14
|
* INVARIANTS:
|
|
11
|
-
* - Pure functions, no side effects
|
|
15
|
+
* - Pure functions, no side effects — except `signature()`, which reads the
|
|
16
|
+
* current year (`new Date().getFullYear()`) for the copyright line.
|
|
12
17
|
* - Components escape their own content; templates pass raw text (+ `bold(...)`
|
|
13
18
|
* for inline emphasis). No template hand-writes markup or raw strings.
|
|
14
19
|
*/
|
|
@@ -16,11 +21,14 @@
|
|
|
16
21
|
import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
|
|
17
22
|
import { escapeHtml } from "./escape-html";
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
/** Shared type styling. Exported for `./chat` only — not part of the package
|
|
25
|
+
* surface (`./index` does not re-export these), so components stay the one
|
|
26
|
+
* place styling is decided. */
|
|
27
|
+
export const MONO = `font-family: ${MONO_FONT_STACK};`;
|
|
20
28
|
|
|
21
29
|
/** The single font size for every email element (HTML). Plain text is monospace
|
|
22
30
|
* so it carries no size — this keeps one visual size across both surfaces. */
|
|
23
|
-
const FONT_SIZE = "13px";
|
|
31
|
+
export const FONT_SIZE = "13px";
|
|
24
32
|
|
|
25
33
|
// =============================================================================
|
|
26
34
|
// Core types
|
|
@@ -43,9 +51,17 @@ export interface Inline {
|
|
|
43
51
|
/** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
|
|
44
52
|
export type InlineContent = string | Inline | Array<string | Inline>;
|
|
45
53
|
|
|
46
|
-
/**
|
|
54
|
+
/** Inline emphasis segment. Emphasis is currently visually flat — it renders
|
|
55
|
+
* plain (no bold) in both surfaces; the seam is kept so all inline emphasis can
|
|
56
|
+
* be restyled in one place. */
|
|
47
57
|
export function bold(s: string): Inline {
|
|
48
|
-
return { html:
|
|
58
|
+
return { html: escapeHtml(s), text: s };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Title-case a display value — capitalize the first letter of each word, e.g. a
|
|
62
|
+
* role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
|
|
63
|
+
export function titleCase(s: string): string {
|
|
64
|
+
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
|
|
@@ -83,7 +99,9 @@ export function htmlShell(blocks: Block[]): string {
|
|
|
83
99
|
<html lang="en">
|
|
84
100
|
<head><meta charset="UTF-8"></head>
|
|
85
101
|
<body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
|
|
102
|
+
<div style="max-width: 520px; margin: 0 auto;">
|
|
86
103
|
${inner}
|
|
104
|
+
</div>
|
|
87
105
|
</body>
|
|
88
106
|
</html>`;
|
|
89
107
|
}
|
|
@@ -120,9 +138,30 @@ export function paragraph(
|
|
|
120
138
|
};
|
|
121
139
|
}
|
|
122
140
|
|
|
123
|
-
/** "Hi Name,"
|
|
141
|
+
/** "Hi Name," greeting; falls back to "Hi there," when the name is unknown. */
|
|
124
142
|
export function greeting(recipientName?: string): Block {
|
|
125
|
-
return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
|
|
143
|
+
return paragraph(recipientName ? `Hi ${recipientName},` : "Hi there,");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Security banner — the "WARNING" wordmark, sits atop security emails. */
|
|
147
|
+
export function security(): Block {
|
|
148
|
+
return paragraph("🆆🅰🆁🅽🅸🅽🅶");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
|
|
153
|
+
* output is locale-/timezone-independent and render snapshots stay
|
|
154
|
+
* deterministic. Returns the raw input unchanged if it is not a parseable date.
|
|
155
|
+
*/
|
|
156
|
+
export function formatExpiry(iso: string): string {
|
|
157
|
+
const d = new Date(iso);
|
|
158
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
159
|
+
return d.toLocaleDateString("en-US", {
|
|
160
|
+
month: "short",
|
|
161
|
+
day: "numeric",
|
|
162
|
+
year: "numeric",
|
|
163
|
+
timeZone: "UTC",
|
|
164
|
+
});
|
|
126
165
|
}
|
|
127
166
|
|
|
128
167
|
/** "Label: **value**" key/value row (value bold in HTML; tight by default). */
|
|
@@ -152,12 +191,16 @@ export function footer(
|
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
/**
|
|
155
|
-
* Trailing sign-off — the
|
|
194
|
+
* Trailing sign-off — a leading blank line, the `---` rule, then the company
|
|
195
|
+
* (or custom `signer`) name. Owns the blank above the rule so every sign-off is
|
|
196
|
+
* spaced identically; the block before it carries no trailing gap.
|
|
156
197
|
*/
|
|
157
198
|
export function signature(signer: string = COMPANY_NAME): Block {
|
|
199
|
+
const line = `ⓒ ${new Date().getFullYear()} • ${signer}`;
|
|
200
|
+
const url = "https://companysemantics.ai";
|
|
158
201
|
return {
|
|
159
|
-
html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};">${escapeHtml(
|
|
160
|
-
text:
|
|
202
|
+
html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(url)}</a></p>`,
|
|
203
|
+
text: `\n\n/* EOM */\n${line}\n${url}`,
|
|
161
204
|
spacing: "none",
|
|
162
205
|
};
|
|
163
206
|
}
|
|
@@ -173,56 +216,19 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
|
|
|
173
216
|
viewer: "can view",
|
|
174
217
|
};
|
|
175
218
|
|
|
176
|
-
/** Centered continuation dots separator (chat-shared). */
|
|
177
|
-
export function chatDots(): Block {
|
|
178
|
-
return {
|
|
179
|
-
html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: 0 0 16px 0; max-width: 320px; text-align: center;">⋮</p>`,
|
|
180
|
-
text: " ⋮",
|
|
181
|
-
spacing: "normal",
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
219
|
// =============================================================================
|
|
186
220
|
// CTA box
|
|
187
221
|
// =============================================================================
|
|
188
222
|
|
|
189
|
-
/** Options for the `>> LABEL <<` call-to-action box.
|
|
223
|
+
/** Options for the `>> LABEL <<` call-to-action box. Styling is component-owned
|
|
224
|
+
* (the only allowable UI is the component) — callers supply only content. */
|
|
190
225
|
export interface CtaBoxOptions {
|
|
191
226
|
/** Text between the chevrons (already display-safe). */
|
|
192
227
|
label: string;
|
|
193
228
|
/** When present, the label links to this URL (and the URL rides under the
|
|
194
229
|
* plain-text box). */
|
|
195
230
|
href?: string;
|
|
196
|
-
/** Visual kind. `"action"` = link CTA (default); `"code"` = copy/paste code
|
|
197
|
-
* box (square corners, letter-spaced). */
|
|
198
|
-
variant?: "action" | "code";
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Component-owned styling per CTA variant. Templates supply only `label`/`href`/
|
|
202
|
-
* `variant` — the only allowable UI is the component, so all CSS lives here,
|
|
203
|
-
* never at the call site. */
|
|
204
|
-
interface CtaVariantStyle {
|
|
205
|
-
padding: string;
|
|
206
|
-
fontSize: string;
|
|
207
|
-
maxWidth?: string;
|
|
208
|
-
square: boolean;
|
|
209
|
-
letterSpacing?: string;
|
|
210
231
|
}
|
|
211
|
-
const CTA_VARIANTS: Record<"action" | "code", CtaVariantStyle> = {
|
|
212
|
-
action: {
|
|
213
|
-
padding: "16px 24px",
|
|
214
|
-
fontSize: FONT_SIZE,
|
|
215
|
-
maxWidth: "220px",
|
|
216
|
-
square: false,
|
|
217
|
-
},
|
|
218
|
-
code: {
|
|
219
|
-
padding: "16px 24px",
|
|
220
|
-
fontSize: FONT_SIZE,
|
|
221
|
-
maxWidth: "220px",
|
|
222
|
-
square: true,
|
|
223
|
-
letterSpacing: "4px",
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
232
|
|
|
227
233
|
/** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
|
|
228
234
|
const CTA_BOX_PAD = 3;
|
|
@@ -235,26 +241,25 @@ function asciiCtaBox(label: string): string {
|
|
|
235
241
|
return [border, `|${inner}|`, border].join("\n");
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
/** The bordered `>> LABEL <<`
|
|
239
|
-
* `
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const chevrons = `<b>>> ${escapeHtml(label)} <<</b>`;
|
|
244
|
+
/** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
|
|
245
|
+
* the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
|
|
246
|
+
* (hence the export — internal to the render layer, not re-exported by `./index`). */
|
|
247
|
+
export function ctaButton(
|
|
248
|
+
opts: CtaBoxOptions,
|
|
249
|
+
margin: string,
|
|
250
|
+
): { html: string; text: string } {
|
|
251
|
+
const { label, href } = opts;
|
|
252
|
+
|
|
253
|
+
const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
|
|
254
|
+
const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
|
|
255
|
+
|
|
256
|
+
// Underline only the label text (not the chevrons/spaces), and only when linked.
|
|
257
|
+
const labelHtml = href
|
|
258
|
+
? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
|
|
259
|
+
: escapeHtml(label);
|
|
260
|
+
const chevrons = `>> ${labelHtml} <<`;
|
|
256
261
|
const inner = href
|
|
257
|
-
? `<a href="${href}" style="color: #0047FF; text-decoration:
|
|
262
|
+
? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
|
|
258
263
|
: chevrons;
|
|
259
264
|
|
|
260
265
|
const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
|
|
@@ -265,116 +270,12 @@ export function ctaBox(opts: CtaBoxOptions): Block {
|
|
|
265
270
|
|
|
266
271
|
const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
|
|
267
272
|
|
|
268
|
-
return { html, text
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// =============================================================================
|
|
272
|
-
// Chat bubbles — one dual-output block per role
|
|
273
|
-
// =============================================================================
|
|
274
|
-
|
|
275
|
-
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
276
|
-
function wrapText(text: string, width: number): string[] {
|
|
277
|
-
const lines: string[] = [];
|
|
278
|
-
let cur = "";
|
|
279
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
280
|
-
let w = word;
|
|
281
|
-
while (w.length > width) {
|
|
282
|
-
if (cur) {
|
|
283
|
-
lines.push(cur);
|
|
284
|
-
cur = "";
|
|
285
|
-
}
|
|
286
|
-
lines.push(w.slice(0, width));
|
|
287
|
-
w = w.slice(width);
|
|
288
|
-
}
|
|
289
|
-
if (!cur) cur = w;
|
|
290
|
-
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
|
|
291
|
-
else {
|
|
292
|
-
lines.push(cur);
|
|
293
|
-
cur = w;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
if (cur) lines.push(cur);
|
|
297
|
-
return lines.length ? lines : [""];
|
|
273
|
+
return { html, text };
|
|
298
274
|
}
|
|
299
275
|
|
|
300
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
306
|
-
const lines = wrapText(text, width);
|
|
307
|
-
if (lines.length <= maxLines) return lines;
|
|
308
|
-
const kept = lines.slice(0, maxLines);
|
|
309
|
-
const last = kept[maxLines - 1];
|
|
310
|
-
kept[maxLines - 1] =
|
|
311
|
-
(last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
|
|
312
|
-
"...";
|
|
313
|
-
return kept;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
318
|
-
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
|
|
319
|
-
* this, so HTML and plain text truncate at exactly the same point.
|
|
320
|
-
*/
|
|
321
|
-
function clampMessage(text: string): string {
|
|
322
|
-
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Right-aligned user chat message. Truncated by clampMessage. In plain text the
|
|
327
|
-
* face sits beside the last message line. When `from` is given (the sender's
|
|
328
|
-
* name) it renders as an attribution below, right-aligned to the message's right
|
|
329
|
-
* edge.
|
|
330
|
-
*/
|
|
331
|
-
export function chatUser(content: string, from?: string): Block {
|
|
332
|
-
const clamped = clampMessage(content);
|
|
333
|
-
|
|
334
|
-
const attributionRow = from
|
|
335
|
-
? `
|
|
336
|
-
<tr>
|
|
337
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
|
|
338
|
-
<td></td>
|
|
339
|
-
</tr>`
|
|
340
|
-
: "";
|
|
341
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
|
|
342
|
-
<tr>
|
|
343
|
-
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
|
|
344
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
|
|
345
|
-
</tr>${attributionRow}
|
|
346
|
-
</table>`;
|
|
347
|
-
|
|
348
|
-
const border = "─".repeat(MESSAGE_WIDTH + 1);
|
|
349
|
-
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
350
|
-
const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
|
|
351
|
-
// Face beside the last message line (one row up from the bottom border).
|
|
352
|
-
body[body.length - 1] += " (•̀_ರ╮)";
|
|
353
|
-
const box = [`┌${border}┐`, ...body, `└${border}┘`];
|
|
354
|
-
if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
|
|
355
|
-
|
|
356
|
-
return { html, text: box.join("\n"), spacing: "normal" };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Left-aligned assistant chat message with `[c_S]` avatar; in plain text the
|
|
360
|
-
* avatar sits beside the last message line (one row up from the bottom border). */
|
|
361
|
-
export function chatAssistant(content: string): Block {
|
|
362
|
-
const clamped = clampMessage(content);
|
|
363
|
-
|
|
364
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
|
|
365
|
-
<tr>
|
|
366
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
|
|
367
|
-
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
|
|
368
|
-
</tr>
|
|
369
|
-
</table>`;
|
|
370
|
-
|
|
371
|
-
const lineWidth = 36;
|
|
372
|
-
const lines = wrapText(clamped, lineWidth);
|
|
373
|
-
const top = " ┌──────────────────────────────────────┐";
|
|
374
|
-
const bot = " └──────────────────────────────────────┘";
|
|
375
|
-
const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
|
|
376
|
-
// Avatar beside the last message line (one row up from the bottom border).
|
|
377
|
-
boxLines[boxLines.length - 1] = `[c_S] ${boxLines[boxLines.length - 1].slice(7)}`;
|
|
378
|
-
|
|
379
|
-
return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
|
|
276
|
+
/** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
|
|
277
|
+
* `href` is given, HTML links the label and plain text prints the URL below. */
|
|
278
|
+
export function ctaBox(opts: CtaBoxOptions): Block {
|
|
279
|
+
const { html, text } = ctaButton(opts, "0 0 20px 0");
|
|
280
|
+
return { html, text, spacing: "normal" };
|
|
380
281
|
}
|
|
@@ -6,36 +6,30 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatDots,
|
|
11
|
-
chatUser,
|
|
12
|
-
ctaBox,
|
|
13
9
|
footer,
|
|
10
|
+
greeting,
|
|
14
11
|
NOTICE,
|
|
15
12
|
paragraph,
|
|
16
13
|
signature,
|
|
17
14
|
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatCta, chatDots, chatUnit, chatUser } from "./chat";
|
|
18
16
|
import { COMPANY_NAME } from "./constants";
|
|
19
17
|
|
|
20
18
|
export type ChatSharedPayload = EmailPayloads["chat.shared"];
|
|
21
19
|
|
|
22
20
|
export function renderChatShared(payload: ChatSharedPayload): Block[] {
|
|
23
|
-
const { chatTitle, shareUrl, previewText } = payload;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
footer(`This share was sent via ${COMPANY_NAME}.`),
|
|
36
|
-
paragraph(NOTICE, "tight"),
|
|
21
|
+
const { chatTitle, shareUrl, previewText, sharedByName } = payload;
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
greeting(),
|
|
25
|
+
paragraph("A chat was shared with you."),
|
|
26
|
+
chatUnit(
|
|
27
|
+
chatUser(chatTitle, sharedByName),
|
|
28
|
+
...(previewText ? [chatAssistant(previewText)] : []),
|
|
29
|
+
chatDots(),
|
|
30
|
+
chatCta({ label: "SEE MORE", href: shareUrl }),
|
|
31
|
+
),
|
|
32
|
+
footer(`This share was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
37
33
|
signature(),
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return blocks;
|
|
34
|
+
];
|
|
41
35
|
}
|