@12-apps/notifications 4.8.0 → 4.10.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/ADOPTING.md +173 -1
- package/README.md +138 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
- package/dist/chunk-5Y7QRORV.js.map +1 -0
- package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
- package/dist/chunk-6W7INOYQ.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/chunk-JCVRQ42B.js +226 -0
- package/dist/chunk-JCVRQ42B.js.map +1 -0
- package/dist/chunk-RTURLH5U.js +33 -0
- package/dist/chunk-RTURLH5U.js.map +1 -0
- package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
- package/dist/{chunk-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
- package/dist/chunk-WVRODNXQ.js.map +1 -0
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
- package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
- package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +5 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +15 -5
- package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
- package/dist/live-DYxEFO49.d.ts +158 -0
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +16 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +19 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
- package/dist/panel-T36JEMO3.js.map +1 -0
- package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +55 -6
- package/dist/react/index.js +16 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +6 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
- package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
- package/package.json +22 -2
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +10 -0
- package/src/live.ts +179 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/react/create-web-notifications.tsx +15 -1
- package/src/react/index.ts +29 -0
- package/src/react/live-card.tsx +279 -0
- package/src/react/live-config.ts +79 -0
- package/src/react/live-section.tsx +141 -0
- package/src/react/panel-lazy.tsx +3 -0
- package/src/react/panel.tsx +57 -10
- package/src/react/relative-time.ts +14 -2
- package/src/server/transports/email.ts +62 -0
- package/src/server/transports/web-push.ts +22 -0
- package/src/types.ts +10 -1
- package/dist/chunk-7234ROOP.js.map +0 -1
- package/dist/chunk-BGI4BQ66.js.map +0 -1
- package/dist/chunk-M5XTROYM.js.map +0 -1
- package/dist/chunk-O5BVUXPO.js +0 -22
- package/dist/chunk-O5BVUXPO.js.map +0 -1
- package/dist/panel-UFXNO4AF.js.map +0 -1
- /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
- /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
- /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/notifications/email` — ONE transactional-mail layout.
|
|
3
|
+
*
|
|
4
|
+
* ## Why it lives in this package
|
|
5
|
+
*
|
|
6
|
+
* This package already owned the delivery half: the `EmailDriver` port, the
|
|
7
|
+
* vendor table (`EMAIL_DRIVERS`) and, in `server/transports/email.ts`, a
|
|
8
|
+
* `formatEmail` that built a mail out of three `<p>` tags. That formatter is
|
|
9
|
+
* one of the three ad-hoc renderers this layout replaces — so shipping the
|
|
10
|
+
* layout anywhere else would have left the replacement in a package this one
|
|
11
|
+
* does not depend on, and the estate's own lesson is that adoption without
|
|
12
|
+
* deletion is the worst of both: two ways to render a mail, both maintained.
|
|
13
|
+
*
|
|
14
|
+
* Here, `formatEmail` renders THROUGH the layout the moment a host supplies a
|
|
15
|
+
* brand and a copy pack, and the ad-hoc path is what remains when it does not.
|
|
16
|
+
*
|
|
17
|
+
* Framework-free and dependency-free: safe in a browser, in a job, in a
|
|
18
|
+
* webhook — which is why it is its own subpath rather than part of `.`, whose
|
|
19
|
+
* consumers are the inbox and the preference matrix.
|
|
20
|
+
*
|
|
21
|
+
* The preview catalogue and its routes are `./email/previews`; the operator
|
|
22
|
+
* screen is `./email/previews/react`; the layout's own copy packs are
|
|
23
|
+
* `./email/locales`.
|
|
24
|
+
*/
|
|
25
|
+
export {
|
|
26
|
+
renderEmail,
|
|
27
|
+
renderEmailHtml,
|
|
28
|
+
safeHref,
|
|
29
|
+
type EmailAction,
|
|
30
|
+
type EmailChromeCopy,
|
|
31
|
+
type EmailDocument,
|
|
32
|
+
type EmailFact,
|
|
33
|
+
type RenderedEmail,
|
|
34
|
+
} from './template';
|
|
35
|
+
|
|
36
|
+
export { renderEmailText } from './text';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
EMAIL_CONTENT_WIDTH,
|
|
40
|
+
EMAIL_FONT_STACK,
|
|
41
|
+
NEUTRAL_EMAIL_THEME,
|
|
42
|
+
type EmailTheme,
|
|
43
|
+
} from './theme';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EN_US_EMAIL_CHROME } from './chrome.en-US';
|
|
2
|
+
import { PT_BR_EMAIL_CHROME } from './chrome.pt-BR';
|
|
3
|
+
import type { EmailChromeCopy } from './template';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The layout's own words, in every language this package ships.
|
|
7
|
+
*
|
|
8
|
+
* Its own subpath (`@12-apps/notifications/email/locales`) for the reason every
|
|
9
|
+
* copy-shipping package here uses one: keeping the packs off the layout entry
|
|
10
|
+
* is what lets that entry stay the framework-free mechanism a browser can
|
|
11
|
+
* import, and it is what makes "which language" a decision a host takes by
|
|
12
|
+
* name rather than one this package takes by default.
|
|
13
|
+
*
|
|
14
|
+
* Shaped as `Record<tag, pack>` rather than importing `@12-apps/i18n`'s
|
|
15
|
+
* `LocalePack`: a package must stay liftable into a repo that has never heard
|
|
16
|
+
* of that one, so the type is a local structural mirror. The two agree by tag,
|
|
17
|
+
* and `scripts/locale-coverage-gate.mjs` is what checks that they do.
|
|
18
|
+
*
|
|
19
|
+
* A host with ONE audience passes a pack by name. A host whose readers do not
|
|
20
|
+
* share a language passes `localeCopy(EMAIL_CHROME)` and each message is
|
|
21
|
+
* written in its own recipient's language — which is the whole reason
|
|
22
|
+
* `EmailDocument.chrome` is resolved per message rather than at a mount.
|
|
23
|
+
*/
|
|
24
|
+
export const EMAIL_CHROME: Readonly<Record<string, EmailChromeCopy>> = {
|
|
25
|
+
'pt-BR': PT_BR_EMAIL_CHROME,
|
|
26
|
+
'en-US': EN_US_EMAIL_CHROME,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export { EN_US_EMAIL_CHROME, PT_BR_EMAIL_CHROME };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { RenderedEmail } from '../template';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The preview catalogue: every message a host can send, as a navigable set.
|
|
5
|
+
*
|
|
6
|
+
* ## The problem it exists for
|
|
7
|
+
*
|
|
8
|
+
* There is normally no way to SEE a transactional mail without triggering the
|
|
9
|
+
* event that sends it — signing up with a throwaway address to look at the
|
|
10
|
+
* verification mail, settling an order to look at the receipt, exhausting a
|
|
11
|
+
* quota to look at that alert. So nobody looks, and a host that renders mail in
|
|
12
|
+
* three different ways does not find out.
|
|
13
|
+
*
|
|
14
|
+
* A layout nobody can see is one release from being three layouts again, which
|
|
15
|
+
* is why this ships in the same package as the layout rather than beside it.
|
|
16
|
+
*
|
|
17
|
+
* ## What is the package's and what is the host's
|
|
18
|
+
*
|
|
19
|
+
* The MECHANISM is here: group by owner, render one message for one reader,
|
|
20
|
+
* report what cannot be shown. WHICH messages exist is the host's, and it
|
|
21
|
+
* cannot be otherwise — a package cannot know that a host sends a
|
|
22
|
+
* "your quota is exhausted" notice, let alone what data that notice is
|
|
23
|
+
* rendered from.
|
|
24
|
+
*
|
|
25
|
+
* So a host declares SOURCES. Each names an owner and answers with its
|
|
26
|
+
* messages, and the catalogue is the union. That inverts the obvious design,
|
|
27
|
+
* where the package would hold a registry a host writes into: a source is
|
|
28
|
+
* asked, per request, so a source backed by a registry that fills at import
|
|
29
|
+
* time answers with whatever is registered NOW rather than with whatever had
|
|
30
|
+
* been registered when the mount was built.
|
|
31
|
+
*
|
|
32
|
+
* ## Why coverage is a first-class output
|
|
33
|
+
*
|
|
34
|
+
* A catalogue that quietly omits a message looks exactly like a product that
|
|
35
|
+
* does not send it. So a source may report what it knows it cannot show —
|
|
36
|
+
* a message with no sample data, or sample data for a message that no longer
|
|
37
|
+
* exists — and the surface returns that beside the list rather than swallowing
|
|
38
|
+
* it. A gap that is visible is a gap somebody closes.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** One previewable message. */
|
|
42
|
+
export interface EmailPreviewMessage {
|
|
43
|
+
/**
|
|
44
|
+
* Stable id, and the path segment the console navigates by.
|
|
45
|
+
*
|
|
46
|
+
* The host chooses the spelling; the surface only requires it to be unique
|
|
47
|
+
* across every source. Ids that survive a rename are what make a link to one
|
|
48
|
+
* mail worth sending to a colleague.
|
|
49
|
+
*/
|
|
50
|
+
readonly id: string;
|
|
51
|
+
/** The wire identity — an event type, a template key. Shown beside the subject. */
|
|
52
|
+
readonly key: string;
|
|
53
|
+
/** Which mail path this belongs to, for grouping inside one owner. */
|
|
54
|
+
readonly family: string;
|
|
55
|
+
/** Render it for one reader. MUST be pure — nothing may be sent. */
|
|
56
|
+
render(locale: string): RenderedEmail;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a source knows it cannot show. Both directions are worth reporting. */
|
|
60
|
+
export interface EmailPreviewCoverage {
|
|
61
|
+
/** Messages that exist but have no sample data, so no preview. */
|
|
62
|
+
readonly missing: readonly string[];
|
|
63
|
+
/** Sample data for a message nothing produces any more. */
|
|
64
|
+
readonly orphan: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One group of messages, owned by whichever code words them. */
|
|
68
|
+
export interface EmailPreviewSource {
|
|
69
|
+
/**
|
|
70
|
+
* Who owns these messages — a package name, or the host's own module.
|
|
71
|
+
*
|
|
72
|
+
* This is the console's grouping, and it is the answer to "which parts of
|
|
73
|
+
* this system send mail", which is a question most hosts cannot otherwise
|
|
74
|
+
* answer at all.
|
|
75
|
+
*/
|
|
76
|
+
readonly owner: string;
|
|
77
|
+
/** Called PER REQUEST — see the docblock above for why that matters. */
|
|
78
|
+
list(): readonly EmailPreviewMessage[];
|
|
79
|
+
/** Optional: what this source cannot show. Absent means "nothing missing". */
|
|
80
|
+
coverage?(): EmailPreviewCoverage;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One catalogue row: everything the sidebar needs, and no rendered bytes. */
|
|
84
|
+
export interface EmailPreviewRow {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly key: string;
|
|
87
|
+
readonly family: string;
|
|
88
|
+
readonly owner: string;
|
|
89
|
+
/** The subject in the requested language, so the list reads as an inbox. */
|
|
90
|
+
readonly subject: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One rendered message: what the vendor would be handed, verbatim. */
|
|
94
|
+
export interface EmailPreviewDetail extends EmailPreviewRow {
|
|
95
|
+
readonly locale: string;
|
|
96
|
+
readonly html: string;
|
|
97
|
+
readonly text: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface EmailPreviewIndex {
|
|
101
|
+
readonly locale: string;
|
|
102
|
+
readonly locales: readonly string[];
|
|
103
|
+
readonly items: readonly EmailPreviewRow[];
|
|
104
|
+
readonly coverage: EmailPreviewCoverage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface EmailPreviewsConfig {
|
|
108
|
+
/** The host's sources, in the order the console should list their owners. */
|
|
109
|
+
readonly sources: readonly EmailPreviewSource[];
|
|
110
|
+
/**
|
|
111
|
+
* The languages a preview may be asked for.
|
|
112
|
+
*
|
|
113
|
+
* REQUIRED, and not derived from the shipped packs: a host's own messages may
|
|
114
|
+
* be written in languages this package has never heard of, and a preview that
|
|
115
|
+
* silently refused one of them would look exactly like a product with no
|
|
116
|
+
* translation.
|
|
117
|
+
*/
|
|
118
|
+
readonly locales: readonly string[];
|
|
119
|
+
/** The language a preview opens in when the caller names none. */
|
|
120
|
+
readonly defaultLocale: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** An id that appears in more than one source — the one thing that must not happen. */
|
|
124
|
+
export class DuplicateEmailPreviewIdError extends Error {
|
|
125
|
+
constructor(id: string) {
|
|
126
|
+
super(`@12-apps/notifications: two e-mail preview messages share the id "${id}".`);
|
|
127
|
+
this.name = 'DuplicateEmailPreviewIdError';
|
|
128
|
+
Object.setPrototypeOf(this, DuplicateEmailPreviewIdError.prototype);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The catalogue, assembled from every source. */
|
|
133
|
+
export interface ApiEmailPreviews {
|
|
134
|
+
/** Every message, with each subject rendered in `locale`. */
|
|
135
|
+
index(locale?: string): EmailPreviewIndex;
|
|
136
|
+
/** One rendered message, or `null` when no source owns that id. */
|
|
137
|
+
render(id: string, locale?: string): EmailPreviewDetail | null;
|
|
138
|
+
/** The union of every source's gaps. */
|
|
139
|
+
coverage(): EmailPreviewCoverage;
|
|
140
|
+
/** Is this a language the host said it writes in? */
|
|
141
|
+
supportsLocale(locale: string): boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One source's messages, tagged with their owner. */
|
|
145
|
+
interface OwnedMessage {
|
|
146
|
+
readonly owner: string;
|
|
147
|
+
readonly message: EmailPreviewMessage;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collect(sources: readonly EmailPreviewSource[]): OwnedMessage[] {
|
|
151
|
+
const owned = sources.flatMap((source) =>
|
|
152
|
+
source.list().map((message) => ({ owner: source.owner, message })),
|
|
153
|
+
);
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
for (const { message } of owned) {
|
|
156
|
+
// A duplicate id makes one message unreachable and the other ambiguous —
|
|
157
|
+
// and silently, since the console would simply show whichever came first.
|
|
158
|
+
// Throwing here surfaces it on the request that would have hidden it.
|
|
159
|
+
if (seen.has(message.id)) throw new DuplicateEmailPreviewIdError(message.id);
|
|
160
|
+
seen.add(message.id);
|
|
161
|
+
}
|
|
162
|
+
return owned;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createEmailPreviews(config: EmailPreviewsConfig): ApiEmailPreviews {
|
|
166
|
+
const { sources, locales, defaultLocale } = config;
|
|
167
|
+
const localeOf = (locale?: string): string =>
|
|
168
|
+
locale && locales.includes(locale) ? locale : defaultLocale;
|
|
169
|
+
|
|
170
|
+
const rowOf = ({ owner, message }: OwnedMessage, locale: string): EmailPreviewRow => ({
|
|
171
|
+
id: message.id,
|
|
172
|
+
key: message.key,
|
|
173
|
+
family: message.family,
|
|
174
|
+
owner,
|
|
175
|
+
subject: message.render(locale).subject,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
index(locale) {
|
|
180
|
+
const tag = localeOf(locale);
|
|
181
|
+
return {
|
|
182
|
+
locale: tag,
|
|
183
|
+
locales,
|
|
184
|
+
items: collect(sources).map((owned) => rowOf(owned, tag)),
|
|
185
|
+
coverage: this.coverage(),
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
render(id, locale) {
|
|
189
|
+
const owned = collect(sources).find((entry) => entry.message.id === id);
|
|
190
|
+
if (!owned) return null;
|
|
191
|
+
const tag = localeOf(locale);
|
|
192
|
+
const message = owned.message.render(tag);
|
|
193
|
+
return { ...rowOf(owned, tag), locale: tag, html: message.html, text: message.text };
|
|
194
|
+
},
|
|
195
|
+
coverage() {
|
|
196
|
+
const missing: string[] = [];
|
|
197
|
+
const orphan: string[] = [];
|
|
198
|
+
for (const source of sources) {
|
|
199
|
+
const gap = source.coverage?.();
|
|
200
|
+
if (!gap) continue;
|
|
201
|
+
missing.push(...gap.missing);
|
|
202
|
+
orphan.push(...gap.orphan);
|
|
203
|
+
}
|
|
204
|
+
return { missing: missing.sort(), orphan: orphan.sort() };
|
|
205
|
+
},
|
|
206
|
+
supportsLocale: (locale) => locales.includes(locale),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
|
|
4
|
+
import { emailPreviewRoutes } from './routes';
|
|
5
|
+
import type { EmailPreviewsConfig } from './catalog';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@12-apps/notifications/email/previews/hono` — the catalogue as a router.
|
|
9
|
+
*
|
|
10
|
+
* The framework-neutral descriptors in `./server` are the contract; this is the
|
|
11
|
+
* adapter for the framework we happen to use, behind its own subpath with
|
|
12
|
+
* `hono` as an OPTIONAL peer — a host on Express, or one that only wants the
|
|
13
|
+
* layout, never resolves it.
|
|
14
|
+
*
|
|
15
|
+
* A host writes:
|
|
16
|
+
*
|
|
17
|
+
* const previews = emailPreviewsRouter({ sources, locales, defaultLocale });
|
|
18
|
+
* app.use('/api/platform/email-previews/*', requirePlatformOperator);
|
|
19
|
+
* app.route('/api/platform/email-previews', previews.router);
|
|
20
|
+
*
|
|
21
|
+
* ## The `use` line above is not decoration
|
|
22
|
+
*
|
|
23
|
+
* This surface publishes a host's whole transactional-mail inventory and the
|
|
24
|
+
* exact wording of its verification and reset mails. The routes declare no
|
|
25
|
+
* session of their own because a package cannot know who a host lets look — so
|
|
26
|
+
* the gate is the host's, it is REQUIRED, and it is written here rather than
|
|
27
|
+
* left to be inferred, because the failure is silent: an ungated mount answers
|
|
28
|
+
* every stranger and nothing about it looks wrong.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface EmailPreviewsHonoConfig extends EmailPreviewsConfig {
|
|
32
|
+
/**
|
|
33
|
+
* Optional last-resort refusal, for a host that would rather state the gate
|
|
34
|
+
* once here than mount middleware around the router.
|
|
35
|
+
*
|
|
36
|
+
* Returning `false` answers 403 before any handler runs. Absent means the
|
|
37
|
+
* host has gated the mount itself — which is the ordinary case, and why this
|
|
38
|
+
* is not required: a package that demanded its own guard would be a second
|
|
39
|
+
* authorization system beside the one the host already runs.
|
|
40
|
+
*/
|
|
41
|
+
readonly allow?: (c: Context) => Promise<boolean> | boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The mounted router, plus the descriptors it was built from. */
|
|
45
|
+
export interface EmailPreviewsRouter {
|
|
46
|
+
router: Hono;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function emailPreviewsRouter(config: EmailPreviewsHonoConfig): EmailPreviewsRouter {
|
|
50
|
+
const router = new Hono();
|
|
51
|
+
|
|
52
|
+
for (const route of emailPreviewRoutes(config)) {
|
|
53
|
+
router.get(route.path, async (c) => {
|
|
54
|
+
if (config.allow && !(await config.allow(c))) {
|
|
55
|
+
return c.json({ error: 'Forbidden.' }, 403);
|
|
56
|
+
}
|
|
57
|
+
const response = await route.handle({
|
|
58
|
+
params: c.req.param() as Record<string, string | undefined>,
|
|
59
|
+
// `Object.fromEntries` over the parsed query rather than the raw string:
|
|
60
|
+
// a repeated `?locale=` is a caller error, and taking the last value is
|
|
61
|
+
// the same answer every framework here gives.
|
|
62
|
+
query: c.req.query() as Record<string, string | undefined>,
|
|
63
|
+
});
|
|
64
|
+
return c.json(response.body as Record<string, unknown>, response.status as 200 | 400 | 404);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { router };
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/notifications/email/previews` — the preview catalogue and the
|
|
3
|
+
* routes over it.
|
|
4
|
+
*
|
|
5
|
+
* Behind its own subpath so a web bundle importing `./email` (the layout) or
|
|
6
|
+
* `./react` (the inbox) never resolves the server half.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
createEmailPreviews,
|
|
10
|
+
DuplicateEmailPreviewIdError,
|
|
11
|
+
type ApiEmailPreviews,
|
|
12
|
+
type EmailPreviewCoverage,
|
|
13
|
+
type EmailPreviewDetail,
|
|
14
|
+
type EmailPreviewIndex,
|
|
15
|
+
type EmailPreviewMessage,
|
|
16
|
+
type EmailPreviewRow,
|
|
17
|
+
type EmailPreviewSource,
|
|
18
|
+
type EmailPreviewsConfig,
|
|
19
|
+
} from './catalog';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
emailPreviewRoutes,
|
|
23
|
+
type EmailPreviewRequest,
|
|
24
|
+
type EmailPreviewResponse,
|
|
25
|
+
type EmailPreviewRoute,
|
|
26
|
+
} from './routes';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EmailPreviewScreenCopy } from './copy';
|
|
2
|
+
|
|
3
|
+
/** US English — the twin that makes {@link PT_BR_EMAIL_PREVIEW_COPY} a choice. */
|
|
4
|
+
export const EN_US_EMAIL_PREVIEW_COPY: EmailPreviewScreenCopy = {
|
|
5
|
+
title: 'Email previews',
|
|
6
|
+
description: 'Every email this system sends, grouped by the package that owns it. Nothing is sent from here.',
|
|
7
|
+
searchLabel: 'Filter',
|
|
8
|
+
searchPlaceholder: 'subject, event or package',
|
|
9
|
+
noMatches: 'No message matches the filter.',
|
|
10
|
+
pickOne: 'Pick a message from the list to see its preview.',
|
|
11
|
+
tabHtml: 'HTML',
|
|
12
|
+
tabText: 'Text',
|
|
13
|
+
tabSource: 'Source',
|
|
14
|
+
widthDesktop: 'Desktop',
|
|
15
|
+
widthMobile: 'Mobile',
|
|
16
|
+
subjectLabel: 'Subject',
|
|
17
|
+
frameTitle: 'Email preview',
|
|
18
|
+
coverageTitle: 'Incomplete coverage',
|
|
19
|
+
missingSamples: (keys) => `No sample data, so no preview: ${keys}.`,
|
|
20
|
+
orphanSamples: (keys) => `A sample exists but nothing produces this message any more: ${keys}.`,
|
|
21
|
+
loading: 'Loading...',
|
|
22
|
+
loadError: 'The previews could not be loaded.',
|
|
23
|
+
retry: 'Try again',
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EmailPreviewScreenCopy } from './copy';
|
|
2
|
+
|
|
3
|
+
/** Brazilian Portuguese, as a NAMED pack — never a default. */
|
|
4
|
+
export const PT_BR_EMAIL_PREVIEW_COPY: EmailPreviewScreenCopy = {
|
|
5
|
+
title: 'Prévia de e-mails',
|
|
6
|
+
description: 'Todos os e-mails que este sistema envia, agrupados por pacote. Nada é enviado a partir daqui.',
|
|
7
|
+
searchLabel: 'Filtrar',
|
|
8
|
+
searchPlaceholder: 'assunto, evento ou pacote',
|
|
9
|
+
noMatches: 'Nenhuma mensagem corresponde ao filtro.',
|
|
10
|
+
pickOne: 'Escolha uma mensagem na lista para ver a prévia.',
|
|
11
|
+
tabHtml: 'HTML',
|
|
12
|
+
tabText: 'Texto',
|
|
13
|
+
tabSource: 'Código',
|
|
14
|
+
widthDesktop: 'Computador',
|
|
15
|
+
widthMobile: 'Celular',
|
|
16
|
+
subjectLabel: 'Assunto',
|
|
17
|
+
frameTitle: 'Prévia do e-mail',
|
|
18
|
+
coverageTitle: 'Cobertura incompleta',
|
|
19
|
+
missingSamples: (keys) => `Sem dados de exemplo, então não há prévia: ${keys}.`,
|
|
20
|
+
orphanSamples: (keys) => `Há exemplo, mas nada mais produz esta mensagem: ${keys}.`,
|
|
21
|
+
loading: 'Carregando...',
|
|
22
|
+
loadError: 'Não foi possível carregar as prévias.',
|
|
23
|
+
retry: 'Tentar novamente',
|
|
24
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SHAPE of the operator screen's words — no words.
|
|
3
|
+
*
|
|
4
|
+
* REQUIRED config with no default in any language, the copy-portability
|
|
5
|
+
* doctrine. `./copy.pt-BR` and `./copy.en-US` ship packs a host passes BY NAME.
|
|
6
|
+
*
|
|
7
|
+
* What is NOT here is anything from the mails themselves: a subject, a body and
|
|
8
|
+
* a button label all arrive from the server already rendered in whichever
|
|
9
|
+
* language the preview was asked for. That separation is the point of the
|
|
10
|
+
* language switch — the CHROME follows the operator, the MESSAGE follows its
|
|
11
|
+
* own recipient.
|
|
12
|
+
*/
|
|
13
|
+
export interface EmailPreviewScreenCopy {
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly description: string;
|
|
16
|
+
/** The sidebar's filter field. */
|
|
17
|
+
readonly searchLabel: string;
|
|
18
|
+
readonly searchPlaceholder: string;
|
|
19
|
+
/** Nothing matched the filter. */
|
|
20
|
+
readonly noMatches: string;
|
|
21
|
+
/** No row is selected yet. */
|
|
22
|
+
readonly pickOne: string;
|
|
23
|
+
/** The three views of one message. */
|
|
24
|
+
readonly tabHtml: string;
|
|
25
|
+
readonly tabText: string;
|
|
26
|
+
readonly tabSource: string;
|
|
27
|
+
/** The two widths the HTML view renders at. */
|
|
28
|
+
readonly widthDesktop: string;
|
|
29
|
+
readonly widthMobile: string;
|
|
30
|
+
/** The subject line's label above the preview. */
|
|
31
|
+
readonly subjectLabel: string;
|
|
32
|
+
/** The sandboxed frame's title, for screen readers. */
|
|
33
|
+
readonly frameTitle: string;
|
|
34
|
+
/** The coverage strip, when the surface reports a gap. */
|
|
35
|
+
readonly coverageTitle: string;
|
|
36
|
+
readonly missingSamples: (keys: string) => string;
|
|
37
|
+
readonly orphanSamples: (keys: string) => string;
|
|
38
|
+
/** Loading and failure states. */
|
|
39
|
+
readonly loading: string;
|
|
40
|
+
readonly loadError: string;
|
|
41
|
+
readonly retry: string;
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/notifications/email/previews/react` — the operator screen over the
|
|
3
|
+
* preview catalogue.
|
|
4
|
+
*
|
|
5
|
+
* Its own subpath rather than a member of `./react`, which is the INBOX: the
|
|
6
|
+
* bell and the preference matrix ship to every signed-in user, and this screen
|
|
7
|
+
* is a platform-staff diagnostic. A host that mounts one has no reason to
|
|
8
|
+
* resolve the other.
|
|
9
|
+
*/
|
|
10
|
+
export { createEmailPreviewScreen, type EmailPreviewScreenConfig } from './preview-screen';
|
|
11
|
+
export { type EmailPreviewScreenCopy } from './copy';
|
|
12
|
+
export { EN_US_EMAIL_PREVIEW_COPY } from './copy.en-US';
|
|
13
|
+
export { PT_BR_EMAIL_PREVIEW_COPY } from './copy.pt-BR';
|
|
14
|
+
export { type PreviewTab, type PreviewWidth } from './message-view';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Alert } from '@12-apps/ui/data-display/Alert';
|
|
5
|
+
import { Button } from '@12-apps/ui/form/Button';
|
|
6
|
+
import { Box } from '@12-apps/ui/mui/Box';
|
|
7
|
+
import { Text } from '@12-apps/ui/typography/Text';
|
|
8
|
+
|
|
9
|
+
import type { EmailPreviewScreenCopy } from './copy';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The screen's async plumbing: one hook, and the state it cannot render itself.
|
|
13
|
+
*
|
|
14
|
+
* Its own module because it is the half with no opinion about e-mail. What is
|
|
15
|
+
* left in `preview-screen.tsx` is composition — which columns, which panes,
|
|
16
|
+
* what the URL says — and this is the machinery underneath all of it.
|
|
17
|
+
*
|
|
18
|
+
* `keepPrevious` is the load-bearing option and its docblock says why: a
|
|
19
|
+
* consumer that renders children only while `data` is non-null gets those
|
|
20
|
+
* children UNMOUNTED by a blanking reload, which silently discards whatever
|
|
21
|
+
* state they held.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A load that can fail, in the two states a screen has to render. */
|
|
25
|
+
interface Loadable<T> {
|
|
26
|
+
data: T | null;
|
|
27
|
+
error: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useLoadable<T>(
|
|
31
|
+
load: () => Promise<T>,
|
|
32
|
+
options: { keepPrevious?: boolean } = {},
|
|
33
|
+
): Loadable<T> & { reload: () => void } {
|
|
34
|
+
const [state, setState] = useState<Loadable<T>>({ data: null, error: null });
|
|
35
|
+
const [nonce, setNonce] = useState(0);
|
|
36
|
+
const { keepPrevious = false } = options;
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let live = true;
|
|
39
|
+
// `keepPrevious` holds the last good answer on screen while the next one is
|
|
40
|
+
// in flight, and it is not a nicety. The consumer of this hook renders its
|
|
41
|
+
// children only while `data` is non-null, so blanking here UNMOUNTS them —
|
|
42
|
+
// taking the filter text, the open tab and the chosen width with it. On a
|
|
43
|
+
// fast connection the refetch lands before anyone notices; on a slow one
|
|
44
|
+
// the operator watches what they just typed disappear.
|
|
45
|
+
setState((previous) => (keepPrevious ? { ...previous, error: null } : { data: null, error: null }));
|
|
46
|
+
load()
|
|
47
|
+
.then((data) => live && setState({ data, error: null }))
|
|
48
|
+
.catch(
|
|
49
|
+
(error: unknown) =>
|
|
50
|
+
live &&
|
|
51
|
+
setState({ data: null, error: error instanceof Error ? error.message : String(error) }),
|
|
52
|
+
);
|
|
53
|
+
return () => {
|
|
54
|
+
// A language switched twice in a second must not let the FIRST answer
|
|
55
|
+
// land last — the screen would show a document the operator did not ask
|
|
56
|
+
// for, with the toggle disagreeing.
|
|
57
|
+
live = false;
|
|
58
|
+
};
|
|
59
|
+
}, [load, nonce]);
|
|
60
|
+
return { ...state, reload: () => setNonce((n) => n + 1) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Failure({
|
|
64
|
+
message,
|
|
65
|
+
copy,
|
|
66
|
+
onRetry,
|
|
67
|
+
}: {
|
|
68
|
+
message: string;
|
|
69
|
+
copy: EmailPreviewScreenCopy;
|
|
70
|
+
onRetry: () => void;
|
|
71
|
+
}): JSX.Element {
|
|
72
|
+
return (
|
|
73
|
+
<Box data-testid="email-preview-error" sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>
|
|
74
|
+
<Alert severity="error">
|
|
75
|
+
<Text as="p" size="sm">{copy.loadError}</Text>
|
|
76
|
+
<Text as="p" size="sm">{message}</Text>
|
|
77
|
+
</Alert>
|
|
78
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
79
|
+
{copy.retry}
|
|
80
|
+
</Button>
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|