@atelic-action/ui 0.14.0 → 0.15.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/README.md +42 -3
- package/dist/artifact/artifactConfig.d.ts +138 -0
- package/dist/artifact/artifactConfig.js +10 -0
- package/dist/artifact/readers.d.ts +33 -0
- package/dist/artifact/readers.js +117 -0
- package/dist/vite/private-chunks.d.ts +24 -0
- package/dist/vite/private-chunks.js +42 -0
- package/package.json +19 -3
- package/src/artifact/AppErrorBoundary.tsx +36 -0
- package/src/artifact/ArtifactShell.tsx +50 -0
- package/src/artifact/ErrorFallback.tsx +70 -0
- package/src/artifact/Gate.tsx +328 -0
- package/src/artifact/GradeChip.tsx +29 -0
- package/src/artifact/Report.tsx +131 -0
- package/src/artifact/SignOff.tsx +37 -0
- package/src/artifact/Writeup.tsx +791 -0
- package/src/artifact/artifactConfig.ts +134 -0
- package/src/artifact/index.ts +25 -0
- package/src/artifact/pageHead.ts +58 -0
- package/src/artifact/readers.ts +146 -0
- package/src/styles/artifact.css +1862 -0
- package/src/vite/private-chunks.ts +65 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
interface ErrorFallbackProps {
|
|
4
|
+
error: unknown;
|
|
5
|
+
resetError: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fallback UI displayed when the error boundary catches a render error.
|
|
10
|
+
* Deliberately dependency-free (inline styles, no component library) so it
|
|
11
|
+
* renders even when the styling pipeline is part of what broke.
|
|
12
|
+
*/
|
|
13
|
+
export function ErrorFallback({ error, resetError }: ErrorFallbackProps) {
|
|
14
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
style={{
|
|
19
|
+
minHeight: "100vh",
|
|
20
|
+
display: "flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
justifyContent: "center",
|
|
23
|
+
padding: 24,
|
|
24
|
+
fontFamily: "system-ui, sans-serif",
|
|
25
|
+
textAlign: "center",
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div style={{ maxWidth: 420 }}>
|
|
29
|
+
<h1 style={{ fontSize: 24, fontWeight: 600, margin: 0 }}>Something went wrong</h1>
|
|
30
|
+
<p style={{ marginTop: 8, fontSize: 14, color: "#666" }}>
|
|
31
|
+
An unexpected error occurred. Please try again.
|
|
32
|
+
</p>
|
|
33
|
+
{import.meta.env.DEV && (
|
|
34
|
+
<pre
|
|
35
|
+
style={{
|
|
36
|
+
marginTop: 16,
|
|
37
|
+
maxHeight: 160,
|
|
38
|
+
overflow: "auto",
|
|
39
|
+
borderRadius: 8,
|
|
40
|
+
background: "#f4f4f4",
|
|
41
|
+
padding: 12,
|
|
42
|
+
textAlign: "left",
|
|
43
|
+
fontSize: 12,
|
|
44
|
+
color: "#666",
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{errorMessage}
|
|
48
|
+
</pre>
|
|
49
|
+
)}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={resetError}
|
|
53
|
+
style={{
|
|
54
|
+
marginTop: 24,
|
|
55
|
+
padding: "10px 22px",
|
|
56
|
+
borderRadius: 999,
|
|
57
|
+
border: "none",
|
|
58
|
+
background: "#222",
|
|
59
|
+
color: "#fff",
|
|
60
|
+
fontSize: 14,
|
|
61
|
+
fontWeight: 600,
|
|
62
|
+
cursor: "pointer",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
Try Again
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { type FormEvent, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Button } from "../primitives/Button";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PBKDF2 work factor and hash. Mirrored by scripts/build-gate-payload.mjs;
|
|
6
|
+
* the two must agree exactly or every sealed payload stops opening.
|
|
7
|
+
*/
|
|
8
|
+
const PBKDF2_ITERATIONS = 200000;
|
|
9
|
+
const PBKDF2_HASH = "SHA-256";
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
interface Window {
|
|
13
|
+
/** HubSpot tracking queue; the loader drains anything pushed before it loads. */
|
|
14
|
+
_hsq?: unknown[];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GatePerson {
|
|
19
|
+
/** Stable handle for the reader, used in the payload and in `onUnlock`. */
|
|
20
|
+
id: string;
|
|
21
|
+
/**
|
|
22
|
+
* The reader's email address: the secret their key derives from, and,
|
|
23
|
+
* when it matches `identifyDomain`, the identity pushed to HubSpot.
|
|
24
|
+
*/
|
|
25
|
+
email: string;
|
|
26
|
+
/** Base64: this reader's PBKDF2 salt, GCM nonce, and wrapped content key. */
|
|
27
|
+
salt: string;
|
|
28
|
+
iv: string;
|
|
29
|
+
wrapped: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GatePayload {
|
|
33
|
+
/** Base64: the document's GCM nonce and ciphertext. */
|
|
34
|
+
civ: string;
|
|
35
|
+
ct: string;
|
|
36
|
+
people: GatePerson[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GateProps {
|
|
40
|
+
/** Built by scripts/build-gate-payload.mjs. Carries no plaintext. */
|
|
41
|
+
payload: GatePayload;
|
|
42
|
+
/** localStorage key holding the reader's email, so a return visit walks straight in. */
|
|
43
|
+
storageKey?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Query parameter a sent link may carry the reader's email in, so the
|
|
46
|
+
* link opens the document in one click. Checked once on mount and
|
|
47
|
+
* stripped from the address bar either way.
|
|
48
|
+
*/
|
|
49
|
+
queryParam?: string;
|
|
50
|
+
/**
|
|
51
|
+
* When set, a reader whose email ends with this domain is pushed to
|
|
52
|
+
* HubSpot on unlock, so the open lands on their contact timeline.
|
|
53
|
+
* Readers on another domain identify nobody. Leave it unset and the
|
|
54
|
+
* gate tracks no one.
|
|
55
|
+
*/
|
|
56
|
+
identifyDomain?: string;
|
|
57
|
+
/** Gate copy. Defaults are neutral; a client document overrides what it needs. */
|
|
58
|
+
kicker?: string;
|
|
59
|
+
heading?: string;
|
|
60
|
+
sub?: string;
|
|
61
|
+
placeholder?: string;
|
|
62
|
+
inputLabel?: string;
|
|
63
|
+
errorText?: string;
|
|
64
|
+
/** Fired once, after a successful unlock. */
|
|
65
|
+
onUnlock?: (person: GatePerson) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An email is its letters and digits, uppercased, so "Dana@Example.com",
|
|
70
|
+
* "dana@example.com" and even a stray space all open the same document.
|
|
71
|
+
* Mirrored in the build script.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeEmail(email: string): string {
|
|
74
|
+
return email.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fromBase64(value: string): Uint8Array {
|
|
78
|
+
const binary = atob(value);
|
|
79
|
+
const bytes = new Uint8Array(binary.length);
|
|
80
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
81
|
+
return bytes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Opened {
|
|
85
|
+
person: GatePerson;
|
|
86
|
+
html: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Try the entered email against every reader in the payload. An email that
|
|
91
|
+
* matches nobody fails the GCM tag check on each attempt and returns null,
|
|
92
|
+
* which is the only signal the gate ever gives: it never reveals who is on
|
|
93
|
+
* the list or how close a guess came.
|
|
94
|
+
*/
|
|
95
|
+
async function openDocument(payload: GatePayload, rawEmail: string): Promise<Opened | null> {
|
|
96
|
+
const subtle = globalThis.crypto?.subtle;
|
|
97
|
+
if (!subtle) return null;
|
|
98
|
+
const normalized = normalizeEmail(rawEmail);
|
|
99
|
+
if (!normalized) return null;
|
|
100
|
+
|
|
101
|
+
const material = await subtle.importKey(
|
|
102
|
+
"raw",
|
|
103
|
+
new TextEncoder().encode(normalized),
|
|
104
|
+
"PBKDF2",
|
|
105
|
+
false,
|
|
106
|
+
["deriveKey"],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const person of payload.people) {
|
|
110
|
+
try {
|
|
111
|
+
const wrapper = await subtle.deriveKey(
|
|
112
|
+
{
|
|
113
|
+
name: "PBKDF2",
|
|
114
|
+
salt: fromBase64(person.salt) as BufferSource,
|
|
115
|
+
iterations: PBKDF2_ITERATIONS,
|
|
116
|
+
hash: PBKDF2_HASH,
|
|
117
|
+
},
|
|
118
|
+
material,
|
|
119
|
+
{ name: "AES-GCM", length: 256 },
|
|
120
|
+
false,
|
|
121
|
+
["decrypt"],
|
|
122
|
+
);
|
|
123
|
+
const contentKeyBytes = await subtle.decrypt(
|
|
124
|
+
{ name: "AES-GCM", iv: fromBase64(person.iv) as BufferSource },
|
|
125
|
+
wrapper,
|
|
126
|
+
fromBase64(person.wrapped) as BufferSource,
|
|
127
|
+
);
|
|
128
|
+
const contentKey = await subtle.importKey(
|
|
129
|
+
"raw",
|
|
130
|
+
contentKeyBytes,
|
|
131
|
+
{ name: "AES-GCM" },
|
|
132
|
+
false,
|
|
133
|
+
["decrypt"],
|
|
134
|
+
);
|
|
135
|
+
const plain = await subtle.decrypt(
|
|
136
|
+
{ name: "AES-GCM", iv: fromBase64(payload.civ) as BufferSource },
|
|
137
|
+
contentKey,
|
|
138
|
+
fromBase64(payload.ct) as BufferSource,
|
|
139
|
+
);
|
|
140
|
+
return { person, html: new TextDecoder().decode(plain) };
|
|
141
|
+
} catch {
|
|
142
|
+
// Wrong email for this reader. Try the next one; say nothing.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function identifyReader(person: GatePerson, domain: string | undefined) {
|
|
149
|
+
if (!domain || !person.email) return;
|
|
150
|
+
if (!person.email.toLowerCase().endsWith(`@${domain.toLowerCase()}`)) return;
|
|
151
|
+
window._hsq = window._hsq || [];
|
|
152
|
+
window._hsq.push(["identify", { email: person.email }]);
|
|
153
|
+
window._hsq.push(["setPath", window.location.pathname]);
|
|
154
|
+
window._hsq.push(["trackPageView"]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readStored(key: string): string {
|
|
158
|
+
try {
|
|
159
|
+
return globalThis.localStorage?.getItem(key) ?? "";
|
|
160
|
+
} catch {
|
|
161
|
+
return ""; // Storage blocked (private mode); the reader just retypes their email.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeStored(key: string, value: string) {
|
|
166
|
+
try {
|
|
167
|
+
globalThis.localStorage?.setItem(key, value);
|
|
168
|
+
} catch {
|
|
169
|
+
// Same: persistence is a courtesy, never a requirement.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read and strip the gate's query parameter in one motion. */
|
|
174
|
+
function takeQueryParam(name: string): string {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(window.location.href);
|
|
177
|
+
const value = url.searchParams.get(name) ?? "";
|
|
178
|
+
if (value) {
|
|
179
|
+
url.searchParams.delete(name);
|
|
180
|
+
window.history.replaceState(window.history.state, "", url.toString());
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
} catch {
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The gate: an email field standing in front of a sealed document.
|
|
190
|
+
*
|
|
191
|
+
* The document ships as ciphertext inside the page, so there is no server to
|
|
192
|
+
* ask and nothing to gate at the edge. The reader's own email address derives
|
|
193
|
+
* the key that unwraps the content key, the document decrypts in the browser,
|
|
194
|
+
* and the gate replaces itself with it. Wrong emails get one line of copy and
|
|
195
|
+
* no hint. A sent link can carry the email in a query parameter (`?email=`)
|
|
196
|
+
* to open the document in one click.
|
|
197
|
+
*
|
|
198
|
+
* Sized for a document sent to named people: the reader types the one thing
|
|
199
|
+
* they always know, no sign in, no inbox round trip. The access gate
|
|
200
|
+
* (ACCESS-GATE.md, GateLogin in this folder) is the other shape, for a whole
|
|
201
|
+
* site preview behind an email tied session with inbox verified identity.
|
|
202
|
+
*/
|
|
203
|
+
export function Gate({
|
|
204
|
+
payload,
|
|
205
|
+
storageKey = "gate-email",
|
|
206
|
+
queryParam = "email",
|
|
207
|
+
identifyDomain,
|
|
208
|
+
kicker = "Private Document",
|
|
209
|
+
heading = "Enter Your Email",
|
|
210
|
+
sub = "This document opens with the email address it was sent to.",
|
|
211
|
+
placeholder = "you@company.com",
|
|
212
|
+
inputLabel = "Your email",
|
|
213
|
+
errorText = "That email does not open this document. Use the address it was sent to.",
|
|
214
|
+
onUnlock,
|
|
215
|
+
}: GateProps) {
|
|
216
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
217
|
+
const [failed, setFailed] = useState(false);
|
|
218
|
+
const [busy, setBusy] = useState(false);
|
|
219
|
+
const [value, setValue] = useState("");
|
|
220
|
+
|
|
221
|
+
// Held in a ref so a caller passing an inline function cannot re-run the
|
|
222
|
+
// auto unlock effect on every render.
|
|
223
|
+
const onUnlockRef = useRef(onUnlock);
|
|
224
|
+
onUnlockRef.current = onUnlock;
|
|
225
|
+
|
|
226
|
+
// Silent auto unlock, in order: a sent link's query parameter first, then
|
|
227
|
+
// the stored email of a reader who has been here before. A value that no
|
|
228
|
+
// longer opens anything (the payload was rebuilt) fails quietly; a failed
|
|
229
|
+
// query value pre fills the field and surfaces the error, since the
|
|
230
|
+
// reader arrived expecting the door to open.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
let cancelled = false;
|
|
233
|
+
|
|
234
|
+
async function attempt() {
|
|
235
|
+
const fromQuery = takeQueryParam(queryParam);
|
|
236
|
+
if (fromQuery) {
|
|
237
|
+
const opened = await openDocument(payload, fromQuery);
|
|
238
|
+
if (cancelled) return;
|
|
239
|
+
if (opened) {
|
|
240
|
+
writeStored(storageKey, fromQuery);
|
|
241
|
+
identifyReader(opened.person, identifyDomain);
|
|
242
|
+
setHtml(opened.html);
|
|
243
|
+
onUnlockRef.current?.(opened.person);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setValue(fromQuery);
|
|
247
|
+
setFailed(true);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const stored = readStored(storageKey);
|
|
252
|
+
if (!stored) return;
|
|
253
|
+
const opened = await openDocument(payload, stored);
|
|
254
|
+
if (cancelled || !opened) return;
|
|
255
|
+
identifyReader(opened.person, identifyDomain);
|
|
256
|
+
setHtml(opened.html);
|
|
257
|
+
onUnlockRef.current?.(opened.person);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
attempt();
|
|
261
|
+
return () => {
|
|
262
|
+
cancelled = true;
|
|
263
|
+
};
|
|
264
|
+
}, [payload, storageKey, identifyDomain, queryParam]);
|
|
265
|
+
|
|
266
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
if (busy) return;
|
|
269
|
+
setBusy(true);
|
|
270
|
+
setFailed(false);
|
|
271
|
+
const opened = await openDocument(payload, value);
|
|
272
|
+
setBusy(false);
|
|
273
|
+
if (!opened) {
|
|
274
|
+
setFailed(true);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
writeStored(storageKey, value);
|
|
278
|
+
identifyReader(opened.person, identifyDomain);
|
|
279
|
+
setHtml(opened.html);
|
|
280
|
+
onUnlockRef.current?.(opened.person);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (html !== null) {
|
|
284
|
+
return (
|
|
285
|
+
<div className="gate-doc">
|
|
286
|
+
<div className="wrap">
|
|
287
|
+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: the document is the practice's own HTML, sealed at build time and decrypted here. Injecting it is the whole point of the component. */}
|
|
288
|
+
<div className="gate-content" dangerouslySetInnerHTML={{ __html: html }} />
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className="gate-hall">
|
|
296
|
+
<div className="wrap">
|
|
297
|
+
<div className="gate-card">
|
|
298
|
+
{kicker && <p className="gate-eyebrow">{kicker}</p>}
|
|
299
|
+
<h1 className="gate-headline">{heading}</h1>
|
|
300
|
+
{sub && <p className="gate-lead">{sub}</p>}
|
|
301
|
+
<form className="gate-form" onSubmit={handleSubmit} noValidate>
|
|
302
|
+
<input
|
|
303
|
+
className="gate-input"
|
|
304
|
+
type="email"
|
|
305
|
+
inputMode="email"
|
|
306
|
+
name="email"
|
|
307
|
+
value={value}
|
|
308
|
+
onChange={(event) => setValue(event.target.value)}
|
|
309
|
+
placeholder={placeholder}
|
|
310
|
+
aria-label={inputLabel}
|
|
311
|
+
autoComplete="email"
|
|
312
|
+
autoCapitalize="none"
|
|
313
|
+
spellCheck={false}
|
|
314
|
+
/>
|
|
315
|
+
<Button type="submit" size="lg">
|
|
316
|
+
{busy ? "Opening…" : "Open Document"}
|
|
317
|
+
</Button>
|
|
318
|
+
</form>
|
|
319
|
+
{failed && (
|
|
320
|
+
<p className="gate-error" role="alert">
|
|
321
|
+
{errorText}
|
|
322
|
+
</p>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grade a section carries: A, B, C, or D, green, yellow, orange, red, so
|
|
3
|
+
* an owner reads it at a glance. The scale and the rule behind every letter
|
|
4
|
+
* are the practice's FOOTPRINT.md, never restated here; D is for something
|
|
5
|
+
* truly broken, and only where a section's rule names it. The monthly page
|
|
6
|
+
* wears the same letters, so a later writeup and every month after read
|
|
7
|
+
* against the first.
|
|
8
|
+
*/
|
|
9
|
+
export type WriteupGrade = "a" | "b" | "c" | "d";
|
|
10
|
+
|
|
11
|
+
export const gradeLabel: Record<WriteupGrade, string> = {
|
|
12
|
+
a: "A",
|
|
13
|
+
b: "B",
|
|
14
|
+
c: "C",
|
|
15
|
+
d: "D",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The letter as a chip in its grade's color. The writeup and the monthly page
|
|
20
|
+
* both wear it, so its styles live in the base sheet chain
|
|
21
|
+
* (styles/artifact.css), never in either component's stylesheet.
|
|
22
|
+
*/
|
|
23
|
+
export function GradeChip({ grade }: { grade: WriteupGrade }) {
|
|
24
|
+
return (
|
|
25
|
+
<span className="op-grade" data-grade={grade}>
|
|
26
|
+
{gradeLabel[grade]}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { GradeChip, type WriteupGrade } from "./GradeChip";
|
|
3
|
+
import { SignOff, type WriteupPerson } from "./SignOff";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The monthly page: what an Operate client gets each month, as one printed
|
|
7
|
+
* letter sheet and its hosted twin. Its shape is the practice's
|
|
8
|
+
* FOOTPRINT.md, under What the Owner Gets: the title and the period, then
|
|
9
|
+
* the leads block answering the four questions the Statement of Work
|
|
10
|
+
* promises, in that order, each a figure and one plain sentence; then the
|
|
11
|
+
* seven grades with what moved since last month and since the baseline; then
|
|
12
|
+
* the sign off. It wears the writeup's grade chip and sign off, and the
|
|
13
|
+
* artifact shell's header on screen; print drops the header and the credit
|
|
14
|
+
* band, so the sheet is the document alone.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ReportLead {
|
|
18
|
+
/** The figure, e.g. "31 leads" or "$1,240". */
|
|
19
|
+
figure: ReactNode;
|
|
20
|
+
/** One plain sentence on what the figure means to the owner. */
|
|
21
|
+
sentence: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The four questions, answered in this order on every monthly page. */
|
|
25
|
+
export interface ReportLeads {
|
|
26
|
+
cameIn: ReportLead;
|
|
27
|
+
fromWhere: ReportLead;
|
|
28
|
+
cost: ReportLead;
|
|
29
|
+
closed: ReportLead;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReportGradeRow {
|
|
33
|
+
/** The tier, e.g. "Health". */
|
|
34
|
+
tier: string;
|
|
35
|
+
/** The section's trade name, e.g. "Local SEO". */
|
|
36
|
+
section: string;
|
|
37
|
+
/** The grade this month: the one judgement, in full color. */
|
|
38
|
+
now: WriteupGrade;
|
|
39
|
+
/** The grade last month; omit on the first month. */
|
|
40
|
+
lastMonth?: WriteupGrade;
|
|
41
|
+
/** The grade in the baseline snapshot, the first taken on private sources. */
|
|
42
|
+
baseline?: WriteupGrade;
|
|
43
|
+
/** One line on what moved, in the owner's words. */
|
|
44
|
+
moved: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReportProps {
|
|
48
|
+
title: string;
|
|
49
|
+
/** The period as printed, e.g. "September 2026". */
|
|
50
|
+
period: string;
|
|
51
|
+
leads: ReportLeads;
|
|
52
|
+
/** The seven sections, in the footprint's order. */
|
|
53
|
+
grades: ReportGradeRow[];
|
|
54
|
+
/** The sign off, the same lockup the writeup closes on. */
|
|
55
|
+
close: { signOff: string; person: WriteupPerson };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The questions are the monthly page's contract with the Statement of Work,
|
|
60
|
+
* the same on every client's page, so they live here rather than in content.
|
|
61
|
+
*/
|
|
62
|
+
const QUESTIONS: { key: keyof ReportLeads; label: string }[] = [
|
|
63
|
+
{ key: "cameIn", label: "What Came In" },
|
|
64
|
+
{ key: "fromWhere", label: "From Where" },
|
|
65
|
+
{ key: "cost", label: "What It Cost" },
|
|
66
|
+
{ key: "closed", label: "What It Closed" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function Earlier({ grade, label }: { grade?: WriteupGrade; label: string }) {
|
|
70
|
+
return (
|
|
71
|
+
<td className="rp-earlier" data-label={label}>
|
|
72
|
+
{grade && <GradeChip grade={grade} />}
|
|
73
|
+
</td>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function Report({ title, period, leads, grades, close }: ReportProps) {
|
|
78
|
+
let lastTier = "";
|
|
79
|
+
return (
|
|
80
|
+
<div className="rp">
|
|
81
|
+
<article className="rp-sheet">
|
|
82
|
+
<header className="rp-head">
|
|
83
|
+
<div className="rp-period">{period}</div>
|
|
84
|
+
<h1>{title}</h1>
|
|
85
|
+
</header>
|
|
86
|
+
<div className="rp-leads">
|
|
87
|
+
{QUESTIONS.map(({ key, label }) => (
|
|
88
|
+
<section className="rp-lead" key={key}>
|
|
89
|
+
<h2 className="rp-q">{label}</h2>
|
|
90
|
+
<div className="rp-figure">{leads[key].figure}</div>
|
|
91
|
+
<p>{leads[key].sentence}</p>
|
|
92
|
+
</section>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
<table className="rp-grades">
|
|
96
|
+
<thead>
|
|
97
|
+
<tr>
|
|
98
|
+
<th scope="col">Tier</th>
|
|
99
|
+
<th scope="col">Section</th>
|
|
100
|
+
<th scope="col">Now</th>
|
|
101
|
+
<th scope="col">Last Month</th>
|
|
102
|
+
<th scope="col">Baseline</th>
|
|
103
|
+
<th scope="col">What Moved</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>
|
|
107
|
+
{grades.map((row) => {
|
|
108
|
+
const tier = row.tier === lastTier ? "" : row.tier;
|
|
109
|
+
lastTier = row.tier;
|
|
110
|
+
return (
|
|
111
|
+
<tr key={row.section} className={tier ? "is-tier-start" : undefined}>
|
|
112
|
+
<td className="rp-tier">{tier}</td>
|
|
113
|
+
<td className="rp-section">{row.section}</td>
|
|
114
|
+
<td className="rp-now" data-label="Now">
|
|
115
|
+
<GradeChip grade={row.now} />
|
|
116
|
+
</td>
|
|
117
|
+
<Earlier grade={row.lastMonth} label="Last Month" />
|
|
118
|
+
<Earlier grade={row.baseline} label="Baseline" />
|
|
119
|
+
<td className="rp-moved">{row.moved}</td>
|
|
120
|
+
</tr>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
<footer className="rp-close">
|
|
126
|
+
<SignOff signOff={close.signOff} person={close.person} />
|
|
127
|
+
</footer>
|
|
128
|
+
</article>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Mail } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface WriteupPerson {
|
|
5
|
+
photoSrc: string;
|
|
6
|
+
photoAlt: string;
|
|
7
|
+
name: string;
|
|
8
|
+
/** The line under the name, e.g. <>Founder, <a href="https://atelic.me">Atelic</a></>. */
|
|
9
|
+
role: ReactNode;
|
|
10
|
+
mailHref: string;
|
|
11
|
+
/** The address as printed, e.g. "matt@atelic.me". */
|
|
12
|
+
mailLabel: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The sign off a printed artifact closes on: the script line, then the person
|
|
17
|
+
* lockup, the photo beside the name, the role, and the address
|
|
18
|
+
* (Brand/voice.md, Pitch Surfaces 9). The writeup and the monthly page both
|
|
19
|
+
* wear it, so its styles live in the base sheet chain
|
|
20
|
+
* (styles/artifact.css); its container sets the type size and the color.
|
|
21
|
+
*/
|
|
22
|
+
export function SignOff({ signOff, person }: { signOff: string; person: WriteupPerson }) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="op-who">
|
|
25
|
+
<img src={person.photoSrc} alt={person.photoAlt} />
|
|
26
|
+
<div>
|
|
27
|
+
<div className="op-sign">{signOff}</div>
|
|
28
|
+
<b>{person.name}</b>
|
|
29
|
+
<br />
|
|
30
|
+
{person.role} ·{" "}
|
|
31
|
+
<a href={person.mailHref}>
|
|
32
|
+
<Mail size={11} aria-hidden="true" /> {person.mailLabel}
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|