@qmusic/email-shell 0.0.0-stage → 1.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/README.md +74 -2
- package/dist/index.d.ts +217 -0
- package/dist/index.js +465 -0
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @qmusic/email-shell
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Q Music transactional email shell: the warm light card that every client and
|
|
4
|
+
artist email renders in ("Family A" in Q Music App's `EMAIL_STYLE_GUIDE.md`).
|
|
5
|
+
|
|
6
|
+
This is the one copy. Three repos render through it:
|
|
7
|
+
|
|
8
|
+
| Repo | How it imports | Bumped by |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| qmusic-events-portal | `src/lib/email-shell.js` re-exports the package | Renovate PR |
|
|
11
|
+
| Q Music App (edge functions) | `supabase/functions/_shared/email-shell.ts` is `export * from "npm:@qmusic/email-shell@<version>"` | Renovate PR (regex manager) |
|
|
12
|
+
| gig-bot | `src/lib/email-shell.ts` re-exports the package | Renovate PR |
|
|
13
|
+
|
|
14
|
+
Each repo pins an exact version, so a new version reaches a repo only when that
|
|
15
|
+
repo merges its bump.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import {
|
|
21
|
+
renderEmailShell, statusBanner, introBlock, eventDetailCard, noteBlock,
|
|
22
|
+
ctaButton, scheduleCallLink, footerBlock,
|
|
23
|
+
} from '@qmusic/email-shell';
|
|
24
|
+
|
|
25
|
+
const html = renderEmailShell({
|
|
26
|
+
subject: 'Your bids are in',
|
|
27
|
+
preheader: 'Three artists bid on your event.',
|
|
28
|
+
eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
|
|
29
|
+
body: [
|
|
30
|
+
introBlock('Hi Jordan, three artists bid on your event.'),
|
|
31
|
+
eventDetailCard({ month: 'JUL', day: '18', weekday: 'SAT', title: 'Jordan Lee', position: 'The Ivy Room', time: '8:00 PM - 11:00 PM' }),
|
|
32
|
+
ctaButton({ href: 'https://example.com', label: 'View Bids →' }),
|
|
33
|
+
footerBlock(),
|
|
34
|
+
].join(''),
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Exports: `renderEmailShell`, `statusBanner`, `introBlock`, `introBlockHtml`,
|
|
39
|
+
`detailInfoCell`, `lateBadgeRow`, `eventDetailCard`, `noteBlock`, `ctaButton`,
|
|
40
|
+
`secondaryLink`, `scheduleCallLink`, `centeredText`, `centeredStatCard`,
|
|
41
|
+
`footerBlock`, `esc`, `FONT`, `QM`, and the input types.
|
|
42
|
+
|
|
43
|
+
Date cards use the three-line form: pass `title` + `position` + `time` to
|
|
44
|
+
`eventDetailCard()`. Passing only `subtitle` gives the legacy single-line card
|
|
45
|
+
with no time on it.
|
|
46
|
+
|
|
47
|
+
## How to make a style change
|
|
48
|
+
|
|
49
|
+
1. Edit `src/index.ts`. Recipient-facing copy has no em dashes.
|
|
50
|
+
2. `npm run golden:write`, then read the HTML diff under `test/golden/`. That diff
|
|
51
|
+
is the change every email will get; review it like one.
|
|
52
|
+
3. The first style change after 1.0.0 also deletes `test/reference.test.mjs` and
|
|
53
|
+
`test/reference/`. They pin 1.0.0 to the hand-kept copies it replaced and fail
|
|
54
|
+
on any intentional change.
|
|
55
|
+
4. Bump the version (`npm version minor --no-git-tag-version`), add a
|
|
56
|
+
`CHANGELOG.md` entry, commit, and tag the commit `v<version>`.
|
|
57
|
+
5. `npm publish` from that clean, tagged commit. `prepublishOnly` rebuilds, runs
|
|
58
|
+
the tests, and refuses a dirty tree or an untagged HEAD. It needs an npm login
|
|
59
|
+
with publish rights on `@qmusic` and a 2FA code.
|
|
60
|
+
6. Renovate opens a bump PR in each of the three repos. Merge each one and deploy
|
|
61
|
+
it the way that repo deploys:
|
|
62
|
+
- qmusic-events-portal: `npm run deploy` from main.
|
|
63
|
+
- Q Music App: redeploy every edge function that imports `_shared/email-shell.ts`
|
|
64
|
+
or any `_shared/*-emails.ts`, with the Supabase CLI.
|
|
65
|
+
- gig-bot: its normal deploy.
|
|
66
|
+
|
|
67
|
+
## Test
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
npm test
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Builds `dist/`, then checks that every case in `test/cases.mjs` renders exactly
|
|
74
|
+
its file in `test/golden/`, and that the pre-package copies in `test/reference/`
|
|
75
|
+
render the same goldens.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @qmusic/email-shell: the Q Music transactional email shell, the warm light card
|
|
3
|
+
* ("Family A" in Q Music App's EMAIL_STYLE_GUIDE.md).
|
|
4
|
+
*
|
|
5
|
+
* The one copy. The events portal (Cloudflare Worker), the Supabase edge
|
|
6
|
+
* functions (Deno, via an npm: import) and gig-bot all render through this
|
|
7
|
+
* package, so a style change is made here once, published, and reaches each repo
|
|
8
|
+
* when that repo merges its Renovate bump. 1.0.0 renders byte-for-byte what the
|
|
9
|
+
* three hand-kept copies rendered (qmusic-events-portal 3177b08, plus the Deno
|
|
10
|
+
* copy's footerBlock `note`); test/golden pins that.
|
|
11
|
+
*
|
|
12
|
+
* One family for every gated notification: an off-white page (#f0eee9), a white
|
|
13
|
+
* card, and a near-black (#161310) header band. The header is inline HTML
|
|
14
|
+
* (eyebrow, two-tone title, glow logo) when `title` is given, or the legacy
|
|
15
|
+
* FLATTENED PNG (logo + title baked together as pixels) when it is not. Either
|
|
16
|
+
* way it rides a locked dark cell that Gmail's Android dark mode cannot recolor,
|
|
17
|
+
* and the CSS locks below hold the rest of the surfaces (card, insets, CTA) light.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* renderEmailShell({
|
|
21
|
+
* subject, preheader,
|
|
22
|
+
* eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
|
|
23
|
+
* body: [
|
|
24
|
+
* introBlock('Hi Jordan, ...'),
|
|
25
|
+
* eventDetailCard({ month:'JUL', day:'18', weekday:'SAT', title:'Jordan Lee', position:'The Ivy Room', time:'8:00 PM - 11:00 PM' }),
|
|
26
|
+
* noteBlock(`<strong>These bids expire in two weeks.</strong> Review them ...`),
|
|
27
|
+
* ctaButton({ href:'https://app.qmusic.co/?dltoken=...', label:'View Bids →' }),
|
|
28
|
+
* scheduleCallLink(),
|
|
29
|
+
* footerBlock(),
|
|
30
|
+
* ].join(''),
|
|
31
|
+
* })
|
|
32
|
+
*/
|
|
33
|
+
/** HTML-escape for safe interpolation into the template. */
|
|
34
|
+
export declare function esc(s: unknown): string;
|
|
35
|
+
export declare const FONT = "'Poppins','Helvetica Neue',Helvetica,Arial,sans-serif";
|
|
36
|
+
export declare const QM: {
|
|
37
|
+
readonly page: "#f0eee9";
|
|
38
|
+
readonly card: "#ffffff";
|
|
39
|
+
readonly ink: "#161310";
|
|
40
|
+
readonly bodyInk: "#1a1a1a";
|
|
41
|
+
readonly body: "#5f5a52";
|
|
42
|
+
readonly muted: "#9a948a";
|
|
43
|
+
readonly inset: "#faf9f6";
|
|
44
|
+
readonly insetBorder: "#ebe8e2";
|
|
45
|
+
readonly cream: "#FFFEED";
|
|
46
|
+
readonly coralDark: "#c0392b";
|
|
47
|
+
readonly coralRule: "#e2503f";
|
|
48
|
+
readonly amber: "#FF9800";
|
|
49
|
+
readonly urgent: "#ba0101";
|
|
50
|
+
};
|
|
51
|
+
export interface ShellInput {
|
|
52
|
+
/** The document <title>. */
|
|
53
|
+
subject: string;
|
|
54
|
+
/** Hidden inbox-preview line. */
|
|
55
|
+
preheader: string;
|
|
56
|
+
/** Bulleted kicker on the dark band (e.g. "Artist Bids"). */
|
|
57
|
+
eyebrow?: string;
|
|
58
|
+
/** Cream lead of the two-tone title. Giving a title selects the inline HTML header. */
|
|
59
|
+
title?: string;
|
|
60
|
+
/** Coloured accent word(s) of the title. */
|
|
61
|
+
accent?: string;
|
|
62
|
+
/** Accent/eyebrow colour on the dark band. Default #FF933B. */
|
|
63
|
+
accentColor?: string;
|
|
64
|
+
/** Render the coloured accent BEFORE the plain title ("**We've got it** from
|
|
65
|
+
* here." rather than "Cover in **progress.**"). Default false. */
|
|
66
|
+
accentFirst?: boolean;
|
|
67
|
+
/** LEGACY flattened header PNG; used only when `title` is absent. */
|
|
68
|
+
headerImageUrl?: string;
|
|
69
|
+
/** Logo/img alt text. */
|
|
70
|
+
headerAlt?: string;
|
|
71
|
+
/** Pre-joined HTML from the component helpers below. */
|
|
72
|
+
body: string;
|
|
73
|
+
}
|
|
74
|
+
/** Render a complete email document. */
|
|
75
|
+
export declare function renderEmailShell({ subject, preheader, headerImageUrl, headerAlt, eyebrow, title, accent, accentColor, accentFirst, body }: ShellInput): string;
|
|
76
|
+
export interface StatusBannerInput {
|
|
77
|
+
/** e.g. 'These bids expire in 3 days'. */
|
|
78
|
+
text: string;
|
|
79
|
+
/** amber = deadline approaching, red = imminent. */
|
|
80
|
+
variant?: 'amber' | 'red';
|
|
81
|
+
/** Leading HTML entity (raw), default a stopwatch. */
|
|
82
|
+
glyph?: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Full-bleed time-pressure strip, placed directly under the header (pass it as
|
|
86
|
+
* the FIRST body component). Dark label on amber, white label on red.
|
|
87
|
+
*/
|
|
88
|
+
export declare function statusBanner({ text, variant, glyph }: StatusBannerInput): string;
|
|
89
|
+
/** Lead paragraph, addressed to the recipient. Pass plain text (escaped here). */
|
|
90
|
+
export declare function introBlock(text: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* Same as introBlock but `html` is interpolated raw, so callers can emphasize
|
|
93
|
+
* a word (<strong>…</strong>). Only pass trusted/escaped content.
|
|
94
|
+
*/
|
|
95
|
+
export declare function introBlockHtml(html: string): string;
|
|
96
|
+
export interface DetailInfoInput {
|
|
97
|
+
/** Line 1, the identifier: client name for client/admin mail, venue for artist mail. */
|
|
98
|
+
title: string;
|
|
99
|
+
/** Line 2, the muted uppercase label (venue, or position/event type).
|
|
100
|
+
* Supplying this or a time selects the three-line house layout. */
|
|
101
|
+
position?: string;
|
|
102
|
+
/** Line 3, the times on their own larger line, e.g. '8:00 PM - 11:45 PM'. */
|
|
103
|
+
time?: string;
|
|
104
|
+
/** Pre-rendered time markup (raw, trusted). Wins over `time` when both are given. */
|
|
105
|
+
timeHtml?: string;
|
|
106
|
+
/** Legacy single second line, used only when there is no position or time. */
|
|
107
|
+
subtitle?: string;
|
|
108
|
+
/** Pre-built subtitle HTML (raw, trusted). Overrides `subtitle` when set. */
|
|
109
|
+
subtitleHtml?: string;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The text column of every date card: location, then position, then times.
|
|
113
|
+
*
|
|
114
|
+
* One builder so a card cannot read differently depending on which email drew
|
|
115
|
+
* it. The three-line form is the house style; the single-subtitle fallback
|
|
116
|
+
* exists for callers whose second line is not a position at all (an invoice
|
|
117
|
+
* number, a cancellation reason), which would read wrong in the position slot.
|
|
118
|
+
*/
|
|
119
|
+
export declare function detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml }: DetailInfoInput): string;
|
|
120
|
+
/**
|
|
121
|
+
* The LATE strip across the bottom of a date badge (Saam, 2026-09-30). The
|
|
122
|
+
* badge names the service night (OCT 23 FRI); the strip says the set is the
|
|
123
|
+
* late end of it. A full-width row inside the badge's table rather than a tag
|
|
124
|
+
* laid over its border, because email clients do not position one element over
|
|
125
|
+
* another reliably. `radius` is the badge's inner corner (its radius minus its
|
|
126
|
+
* 2px border). White on coralDark is 5.4:1.
|
|
127
|
+
*/
|
|
128
|
+
export declare function lateBadgeRow(late: boolean | undefined, radius: number, fontSize: number): string;
|
|
129
|
+
export interface DetailCardInput extends DetailInfoInput {
|
|
130
|
+
/** e.g. 'JUL'. */
|
|
131
|
+
month: string;
|
|
132
|
+
/** e.g. '18'. */
|
|
133
|
+
day: string;
|
|
134
|
+
/** e.g. 'SAT'. */
|
|
135
|
+
weekday: string;
|
|
136
|
+
/** e.g. '2027'. REPLACES `weekday` on the badge's bottom row; the badge stays
|
|
137
|
+
* three rows. Set only when the event is far enough out that a bare month/day
|
|
138
|
+
* would be ambiguous (showEventYear in each repo). */
|
|
139
|
+
year?: string;
|
|
140
|
+
/** e.g. '$1,200' (omit for no rate zone). For artist emails this MUST be the
|
|
141
|
+
* artist's own bid/payout, never the client-facing fee-inclusive price. */
|
|
142
|
+
rate?: string;
|
|
143
|
+
/** Rate zone styling. 'coral' matches the month accent; 'ink' is the default
|
|
144
|
+
* payout look. The portal's Balance Due card is the only caller that
|
|
145
|
+
* overrides any of these four. */
|
|
146
|
+
rateColor?: 'ink' | 'coral';
|
|
147
|
+
rateValign?: string;
|
|
148
|
+
rateSize?: string;
|
|
149
|
+
rateWidth?: string;
|
|
150
|
+
/** Translucent diagonal stamp PNG laid behind the card (e.g. CANCELLED).
|
|
151
|
+
* Degrades to no-op + struck date. */
|
|
152
|
+
stampUrl?: string;
|
|
153
|
+
/** Strike through the date number (pairs with a stamp). */
|
|
154
|
+
struck?: boolean;
|
|
155
|
+
/** Optional full-width caution line under the row, inside the card (raw HTML,
|
|
156
|
+
* trusted), e.g. the weather-dependent note. */
|
|
157
|
+
footnoteHtml?: string;
|
|
158
|
+
/** A late-night set (starts 12:00-4:59 AM, filed under the night before):
|
|
159
|
+
* draws the LATE strip across the bottom of the date badge. See lateBadgeRow. */
|
|
160
|
+
late?: boolean;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Event/shift detail inset: date badge + the detailInfoCell text column, with an
|
|
164
|
+
* optional rate pinned to the right (the gig-alert shift-card layout). On mobile
|
|
165
|
+
* the rate drops to its own right-aligned line under a hairline divider.
|
|
166
|
+
*/
|
|
167
|
+
export declare function eventDetailCard({ month, day, weekday, year, title, subtitle, subtitleHtml, rate, rateColor, rateValign, rateSize, rateWidth, stampUrl, struck, footnoteHtml, position, time, timeHtml, late }: DetailCardInput): string;
|
|
168
|
+
/**
|
|
169
|
+
* Coral left-rule callout. `html` is interpolated raw so callers can pass
|
|
170
|
+
* <strong>…</strong> — only pass trusted/escaped content. Optional `signoff`
|
|
171
|
+
* adds a small right-aligned italic "- Name" line inside the same callout
|
|
172
|
+
* (hyphen, never an em dash).
|
|
173
|
+
*/
|
|
174
|
+
export declare function noteBlock(html: string, signoff?: string): string;
|
|
175
|
+
export interface CtaInput {
|
|
176
|
+
href: string;
|
|
177
|
+
label: string;
|
|
178
|
+
/** Cell colour; default ink. Pass QM.urgent for within-24h, etc. */
|
|
179
|
+
bg?: string;
|
|
180
|
+
}
|
|
181
|
+
/** Single bulletproof CTA. The whole cell is the hit target. */
|
|
182
|
+
export declare function ctaButton({ href, label, bg }: CtaInput): string;
|
|
183
|
+
export interface SecondaryLinkInput {
|
|
184
|
+
/** Muted lead sentence. */
|
|
185
|
+
lead: string;
|
|
186
|
+
/** The link text. */
|
|
187
|
+
label: string;
|
|
188
|
+
href: string;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A subtle centered secondary action: lead text plus a coral text link, never a
|
|
192
|
+
* button. Use under the primary CTA for the "or do this instead" path
|
|
193
|
+
* (decline, talk to us, etc.).
|
|
194
|
+
*/
|
|
195
|
+
export declare function secondaryLink({ lead, label, href }: SecondaryLinkInput): string;
|
|
196
|
+
/** The subtle "Schedule a call" secondary — a text link, never a button. */
|
|
197
|
+
export declare function scheduleCallLink({ href, lead, label, }?: Partial<SecondaryLinkInput>): string;
|
|
198
|
+
/** Centered body paragraph. `html` is raw (pass trusted/escaped content). */
|
|
199
|
+
export declare function centeredText(html: string): string;
|
|
200
|
+
/** Centered inset stat card: small uppercase label over a large value. */
|
|
201
|
+
export declare function centeredStatCard({ label, value }: {
|
|
202
|
+
label: string;
|
|
203
|
+
value: string;
|
|
204
|
+
}): string;
|
|
205
|
+
export interface FooterInput {
|
|
206
|
+
/** Support address for the mailto. Default support@qmusic.co. */
|
|
207
|
+
email?: string;
|
|
208
|
+
/** Renders a "📅 Subscribe to your Q Music Calendar" link line above the
|
|
209
|
+
* "Questions?" line. */
|
|
210
|
+
calendarUrl?: string | null;
|
|
211
|
+
/** One trailing sentence appended to the Questions line, for a renderer with a
|
|
212
|
+
* short caveat that belongs with the sign-off rather than floating as its own
|
|
213
|
+
* block (the payment receipt's ePayment timing, say). */
|
|
214
|
+
note?: string;
|
|
215
|
+
}
|
|
216
|
+
/** Standard sign-off: the support mailto, plus the optional calendar line and note. */
|
|
217
|
+
export declare function footerBlock({ email, calendarUrl, note }?: FooterInput): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @qmusic/email-shell: the Q Music transactional email shell, the warm light card
|
|
3
|
+
* ("Family A" in Q Music App's EMAIL_STYLE_GUIDE.md).
|
|
4
|
+
*
|
|
5
|
+
* The one copy. The events portal (Cloudflare Worker), the Supabase edge
|
|
6
|
+
* functions (Deno, via an npm: import) and gig-bot all render through this
|
|
7
|
+
* package, so a style change is made here once, published, and reaches each repo
|
|
8
|
+
* when that repo merges its Renovate bump. 1.0.0 renders byte-for-byte what the
|
|
9
|
+
* three hand-kept copies rendered (qmusic-events-portal 3177b08, plus the Deno
|
|
10
|
+
* copy's footerBlock `note`); test/golden pins that.
|
|
11
|
+
*
|
|
12
|
+
* One family for every gated notification: an off-white page (#f0eee9), a white
|
|
13
|
+
* card, and a near-black (#161310) header band. The header is inline HTML
|
|
14
|
+
* (eyebrow, two-tone title, glow logo) when `title` is given, or the legacy
|
|
15
|
+
* FLATTENED PNG (logo + title baked together as pixels) when it is not. Either
|
|
16
|
+
* way it rides a locked dark cell that Gmail's Android dark mode cannot recolor,
|
|
17
|
+
* and the CSS locks below hold the rest of the surfaces (card, insets, CTA) light.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* renderEmailShell({
|
|
21
|
+
* subject, preheader,
|
|
22
|
+
* eyebrow: 'Artist Bids', title: 'Your bids are', accent: 'in.',
|
|
23
|
+
* body: [
|
|
24
|
+
* introBlock('Hi Jordan, ...'),
|
|
25
|
+
* eventDetailCard({ month:'JUL', day:'18', weekday:'SAT', title:'Jordan Lee', position:'The Ivy Room', time:'8:00 PM - 11:00 PM' }),
|
|
26
|
+
* noteBlock(`<strong>These bids expire in two weeks.</strong> Review them ...`),
|
|
27
|
+
* ctaButton({ href:'https://app.qmusic.co/?dltoken=...', label:'View Bids →' }),
|
|
28
|
+
* scheduleCallLink(),
|
|
29
|
+
* footerBlock(),
|
|
30
|
+
* ].join(''),
|
|
31
|
+
* })
|
|
32
|
+
*/
|
|
33
|
+
/** HTML-escape for safe interpolation into the template. */
|
|
34
|
+
export function esc(s) {
|
|
35
|
+
if (s === null || s === undefined)
|
|
36
|
+
return '';
|
|
37
|
+
return String(s)
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
43
|
+
}
|
|
44
|
+
export const FONT = "'Poppins','Helvetica Neue',Helvetica,Arial,sans-serif";
|
|
45
|
+
// Header title/eyebrow stack: geometric, heavy fallbacks for the clients that
|
|
46
|
+
// strip the Poppins @font-face (Gmail). Apple Mail/Outlook.com get real Poppins;
|
|
47
|
+
// Gmail iOS -> Futura; Gmail Android -> the device sans (Roboto Black) @ wt 900.
|
|
48
|
+
const HEADER_FONT = "'Poppins',Futura,'Century Gothic','Trebuchet MS',Arial,sans-serif";
|
|
49
|
+
// White-with-dark-glow wordmark: reads on the dark band AND on Gmail's inverted
|
|
50
|
+
// light band (images aren't recolored). Hosted in the shared logos/email bucket.
|
|
51
|
+
const HEADER_LOGO = 'https://bptygbjvxxerlkhiputf.supabase.co/storage/v1/object/public/logos/email/qm-logo-glow.png';
|
|
52
|
+
// Palette — single source of truth, matches EMAIL_STYLE_GUIDE.md §1.
|
|
53
|
+
export const QM = {
|
|
54
|
+
page: '#f0eee9',
|
|
55
|
+
card: '#ffffff',
|
|
56
|
+
ink: '#161310', // header band, CTA, date-badge border
|
|
57
|
+
bodyInk: '#1a1a1a',
|
|
58
|
+
body: '#5f5a52',
|
|
59
|
+
muted: '#9a948a',
|
|
60
|
+
inset: '#faf9f6',
|
|
61
|
+
insetBorder: '#ebe8e2',
|
|
62
|
+
cream: '#FFFEED',
|
|
63
|
+
coralDark: '#c0392b', // links / accents on light
|
|
64
|
+
coralRule: '#e2503f', // left-rule
|
|
65
|
+
amber: '#FF9800', // deadline status banner (brand AppColors.warning) — bright, pairs w/ dark ink
|
|
66
|
+
urgent: '#ba0101', // within-24h status banner + CTA — dark red, pairs w/ white text
|
|
67
|
+
};
|
|
68
|
+
/** Render a complete email document. */
|
|
69
|
+
export function renderEmailShell({ subject, preheader, headerImageUrl, headerAlt, eyebrow, title, accent, accentColor, accentFirst, body }) {
|
|
70
|
+
const accentCol = accentColor || '#FF933B';
|
|
71
|
+
const accentSpan = `<span style="color:${accentCol};font-weight:900">${esc(accent ?? '')}</span>`;
|
|
72
|
+
// Title and accent share a line and wrap naturally when they must — the
|
|
73
|
+
// colour already separates the two halves, so no forced phone-only break.
|
|
74
|
+
const titleHtml = accent
|
|
75
|
+
? (accentFirst
|
|
76
|
+
? `${accentSpan} ${esc(title)}`
|
|
77
|
+
: `${esc(title)} ${accentSpan}`)
|
|
78
|
+
: esc(title);
|
|
79
|
+
// Inline HTML header (band + eyebrow + two-tone title + glow logo) when a title
|
|
80
|
+
// is given; otherwise the legacy baked-PNG header. The HTML header paints with
|
|
81
|
+
// the body (no proxy-fetch flash) and the glow logo survives Gmail's inversion.
|
|
82
|
+
const headerCell = title
|
|
83
|
+
? `<td class="qm-head qm-headpad" bgcolor="${QM.ink}" style="background-color:${QM.ink};padding:30px 30px 28px">
|
|
84
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%">
|
|
85
|
+
<tr>
|
|
86
|
+
<td class="qm-heyebrow" align="left" valign="middle" style="font-family:${HEADER_FONT};font-weight:700;font-size:12px;line-height:1;letter-spacing:2px;color:${accentCol};text-transform:uppercase">• ${esc(eyebrow)}</td>
|
|
87
|
+
<td align="right" valign="middle"><img class="qm-hlogo" src="${HEADER_LOGO}" width="80" alt="${esc(headerAlt || 'Q Music')}" style="display:block;border:0;width:80px;height:auto"></td>
|
|
88
|
+
</tr>
|
|
89
|
+
</table>
|
|
90
|
+
<div class="qm-htitle" style="margin-top:20px;font-family:${HEADER_FONT};font-weight:900;font-size:33px;line-height:1.14;letter-spacing:-0.6px;color:${QM.cream}">${titleHtml}</div>
|
|
91
|
+
</td>`
|
|
92
|
+
: `<td class="qm-head" bgcolor="${QM.ink}" style="background-color:${QM.ink};font-size:0;line-height:0">
|
|
93
|
+
<img src="${esc(headerImageUrl)}" width="600" alt="${esc(headerAlt)}" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none">
|
|
94
|
+
</td>`;
|
|
95
|
+
return `<!DOCTYPE html>
|
|
96
|
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
97
|
+
<head>
|
|
98
|
+
<meta charset="UTF-8">
|
|
99
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
100
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
101
|
+
<meta name="color-scheme" content="light">
|
|
102
|
+
<meta name="supported-color-schemes" content="light">
|
|
103
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
104
|
+
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
|
105
|
+
<title>${esc(subject)}</title>
|
|
106
|
+
<!--[if mso]>
|
|
107
|
+
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
|
108
|
+
<![endif]-->
|
|
109
|
+
<style>
|
|
110
|
+
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800;900&display=swap');
|
|
111
|
+
:root { color-scheme: light; supported-color-schemes: light; }
|
|
112
|
+
|
|
113
|
+
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
|
|
114
|
+
u + #body a { color: inherit; text-decoration: none; }
|
|
115
|
+
|
|
116
|
+
/* This email is LIGHT on purpose. The flattened header image rides a locked
|
|
117
|
+
#161310 cell; these rules hold the remaining surfaces against forced-dark. */
|
|
118
|
+
[data-ogsc] .qm-page { background-color:${QM.page} !important; }
|
|
119
|
+
[data-ogsc] .qm-card { background-color:${QM.card} !important; }
|
|
120
|
+
[data-ogsc] .qm-head { background-color:${QM.ink} !important; }
|
|
121
|
+
[data-ogsc] .qm-inset{ background-color:${QM.inset} !important; }
|
|
122
|
+
[data-ogsc] .qm-ink { color:${QM.bodyInk} !important; }
|
|
123
|
+
[data-ogsc] .qm-btn { background-color:${QM.ink} !important; }
|
|
124
|
+
[data-ogsc] .qm-btn a{ color:${QM.cream} !important; }
|
|
125
|
+
/* Banners pair bg + text so dark-mode inversion can't strand them:
|
|
126
|
+
bright amber -> dark ink; dark red -> white. Lock both bg AND text. */
|
|
127
|
+
[data-ogsc] .qm-bann-amber { background-color:${QM.amber} !important; }
|
|
128
|
+
[data-ogsc] .qm-bann-amber * { color:${QM.ink} !important; }
|
|
129
|
+
[data-ogsc] .qm-bann-red { background-color:${QM.urgent} !important; }
|
|
130
|
+
[data-ogsc] .qm-bann-red * { color:#ffffff !important; }
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
.qm-page { background-color:${QM.page} !important; }
|
|
133
|
+
.qm-card { background-color:${QM.card} !important; }
|
|
134
|
+
.qm-head { background-color:${QM.ink} !important; }
|
|
135
|
+
.qm-inset{ background-color:${QM.inset} !important; }
|
|
136
|
+
.qm-bann-amber { background-color:${QM.amber} !important; }
|
|
137
|
+
.qm-bann-amber * { color:${QM.ink} !important; }
|
|
138
|
+
.qm-bann-red { background-color:${QM.urgent} !important; }
|
|
139
|
+
.qm-bann-red * { color:#ffffff !important; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Phone tightening. Every rule here is mobile-only, so desktop rendering is
|
|
143
|
+
byte-identical to before — the goal is purely to keep the CTA on the
|
|
144
|
+
first screen (the header band + page gutter alone cost ~200px). */
|
|
145
|
+
@media (max-width:600px) {
|
|
146
|
+
.qm-htitle { font-size:21px !important; letter-spacing:-0.3px !important; margin-top:12px !important; }
|
|
147
|
+
.qm-heyebrow { font-size:10.5px !important; letter-spacing:1.4px !important; }
|
|
148
|
+
.qm-headpad { padding:16px 20px 15px !important; }
|
|
149
|
+
.qm-hlogo { width:56px !important; }
|
|
150
|
+
.qm-shellpad { padding:16px 12px 28px !important; }
|
|
151
|
+
.qm-pad { padding-left:22px !important; padding-right:22px !important; }
|
|
152
|
+
.qm-ctapad{ padding-top:16px !important; }
|
|
153
|
+
.qm-datecell { width:64px !important; }
|
|
154
|
+
.qm-datenum { font-size:26px !important; }
|
|
155
|
+
.qm-detailcell { display:block !important; width:100% !important; }
|
|
156
|
+
.qm-ratecell { display:block !important; width:100% !important; text-align:right !important; padding:8px 0 0 0 !important; border-top:1px solid ${QM.insetBorder} !important; margin-top:9px !important; }
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body id="body" class="qm-page" style="margin:0;padding:0;width:100%;background-color:${QM.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:${FONT}">
|
|
161
|
+
|
|
162
|
+
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${QM.page};opacity:0">
|
|
163
|
+
${esc(preheader)}
|
|
164
|
+
‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<table role="presentation" class="qm-page" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.page}" style="background-color:${QM.page}">
|
|
168
|
+
<tr>
|
|
169
|
+
<td align="center" class="qm-shellpad" style="padding:36px 14px 44px">
|
|
170
|
+
|
|
171
|
+
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
|
|
172
|
+
<table role="presentation" class="qm-card" width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.card}" style="background-color:${QM.card};border-radius:16px;overflow:hidden;max-width:600px;width:100%;border:1px solid rgba(20,16,12,0.08);box-shadow:0 16px 44px rgba(20,16,12,0.16)">
|
|
173
|
+
|
|
174
|
+
<!-- HEADER : inline HTML (eyebrow + two-tone title + glow logo) when title given; legacy flattened PNG otherwise -->
|
|
175
|
+
<tr>
|
|
176
|
+
${headerCell}
|
|
177
|
+
</tr>
|
|
178
|
+
|
|
179
|
+
${body}
|
|
180
|
+
|
|
181
|
+
</table>
|
|
182
|
+
<!--[if mso]></td></tr></table><![endif]-->
|
|
183
|
+
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
</table>
|
|
187
|
+
|
|
188
|
+
</body>
|
|
189
|
+
</html>`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Full-bleed time-pressure strip, placed directly under the header (pass it as
|
|
193
|
+
* the FIRST body component). Dark label on amber, white label on red.
|
|
194
|
+
*/
|
|
195
|
+
export function statusBanner({ text, variant = 'amber', glyph = '⏱' }) {
|
|
196
|
+
const isRed = variant === 'red';
|
|
197
|
+
const bg = isRed ? QM.urgent : QM.amber;
|
|
198
|
+
const cls = isRed ? 'qm-bann-red' : 'qm-bann-amber';
|
|
199
|
+
// Bright amber wants dark ink; dark red wants white. Either way bg+text are
|
|
200
|
+
// a legible pair so dark-mode recolor can't produce dark-on-dark.
|
|
201
|
+
const fg = isRed ? '#ffffff' : QM.ink;
|
|
202
|
+
return `
|
|
203
|
+
<tr>
|
|
204
|
+
<td class="${cls}" bgcolor="${bg}" style="background-color:${bg};padding:11px 26px" align="center">
|
|
205
|
+
<p style="margin:0;font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:${fg};font-family:${FONT}">${glyph} ${esc(text)}</p>
|
|
206
|
+
</td>
|
|
207
|
+
</tr>`;
|
|
208
|
+
}
|
|
209
|
+
/** Lead paragraph, addressed to the recipient. Pass plain text (escaped here). */
|
|
210
|
+
export function introBlock(text) {
|
|
211
|
+
return introBlockHtml(esc(text));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Same as introBlock but `html` is interpolated raw, so callers can emphasize
|
|
215
|
+
* a word (<strong>…</strong>). Only pass trusted/escaped content.
|
|
216
|
+
*/
|
|
217
|
+
export function introBlockHtml(html) {
|
|
218
|
+
return `
|
|
219
|
+
<tr>
|
|
220
|
+
<td class="qm-pad" style="padding:14px 26px 6px">
|
|
221
|
+
<p style="margin:0;font-size:14.5px;line-height:1.6;color:${QM.body};font-family:${FONT}">${html}</p>
|
|
222
|
+
</td>
|
|
223
|
+
</tr>`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The text column of every date card: location, then position, then times.
|
|
227
|
+
*
|
|
228
|
+
* One builder so a card cannot read differently depending on which email drew
|
|
229
|
+
* it. The three-line form is the house style; the single-subtitle fallback
|
|
230
|
+
* exists for callers whose second line is not a position at all (an invoice
|
|
231
|
+
* number, a cancellation reason), which would read wrong in the position slot.
|
|
232
|
+
*/
|
|
233
|
+
export function detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml }) {
|
|
234
|
+
const location = `
|
|
235
|
+
<p class="qm-ink" style="margin:0 0 4px;font-size:19px;font-weight:700;letter-spacing:-0.3px;line-height:1.2;color:${QM.bodyInk};font-family:${FONT}">${esc(title)}</p>`;
|
|
236
|
+
const pos = (position ?? '').trim();
|
|
237
|
+
const t = (time ?? '').trim();
|
|
238
|
+
// Pre-rendered time markup, for callers that need part of the range emphasised
|
|
239
|
+
// — the amendment emails colour the CHANGED token ("12:00 AM - <hl>3:00 AM</hl>"),
|
|
240
|
+
// which is the entire point of those emails and cannot survive esc(). Trusted
|
|
241
|
+
// HTML, and it wins over `time` when both are supplied.
|
|
242
|
+
const tHtml = (timeHtml ?? '').trim();
|
|
243
|
+
if (!pos && !t && !tHtml) {
|
|
244
|
+
const sub = subtitleHtml ?? esc(subtitle ?? '');
|
|
245
|
+
return `
|
|
246
|
+
<td valign="middle" style="padding-left:18px">${location}` +
|
|
247
|
+
(sub
|
|
248
|
+
? `
|
|
249
|
+
<p style="margin:0;line-height:1.3;font-size:11px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${sub}</p>`
|
|
250
|
+
: '') + `
|
|
251
|
+
</td>`;
|
|
252
|
+
}
|
|
253
|
+
return `
|
|
254
|
+
<td valign="middle" style="padding-left:18px">${location}` +
|
|
255
|
+
(pos
|
|
256
|
+
? `
|
|
257
|
+
<p style="margin:0 0 5px;line-height:1.25;font-size:13px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${esc(pos)}</p>`
|
|
258
|
+
: '') +
|
|
259
|
+
(t || tHtml
|
|
260
|
+
? `
|
|
261
|
+
<p class="qm-ink" style="margin:0;font-size:16.5px;font-weight:700;line-height:1.15;color:${QM.bodyInk};font-family:${FONT}">${tHtml || esc(t)}</p>`
|
|
262
|
+
: '') + `
|
|
263
|
+
</td>`;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The LATE strip across the bottom of a date badge (Saam, 2026-09-30). The
|
|
267
|
+
* badge names the service night (OCT 23 FRI); the strip says the set is the
|
|
268
|
+
* late end of it. A full-width row inside the badge's table rather than a tag
|
|
269
|
+
* laid over its border, because email clients do not position one element over
|
|
270
|
+
* another reliably. `radius` is the badge's inner corner (its radius minus its
|
|
271
|
+
* 2px border). White on coralDark is 5.4:1.
|
|
272
|
+
*/
|
|
273
|
+
export function lateBadgeRow(late, radius, fontSize) {
|
|
274
|
+
if (!late)
|
|
275
|
+
return '';
|
|
276
|
+
return `
|
|
277
|
+
<tr><td align="center" bgcolor="${QM.coralDark}" style="background-color:${QM.coralDark};padding:3px 4px 4px;border-radius:0 0 ${radius}px ${radius}px">
|
|
278
|
+
<p style="margin:0;font-size:${fontSize}px;font-weight:800;letter-spacing:1.6px;line-height:1.2;color:#ffffff;font-family:${FONT}">LATE</p>
|
|
279
|
+
</td></tr>`;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Event/shift detail inset: date badge + the detailInfoCell text column, with an
|
|
283
|
+
* optional rate pinned to the right (the gig-alert shift-card layout). On mobile
|
|
284
|
+
* the rate drops to its own right-aligned line under a hairline divider.
|
|
285
|
+
*/
|
|
286
|
+
export function eventDetailCard({ month, day, weekday, year, title, subtitle, subtitleHtml, rate, rateColor = 'ink', rateValign = 'middle', rateSize = '24px', rateWidth = '100', stampUrl, struck, footnoteHtml, position, time, timeHtml, late }) {
|
|
287
|
+
const dayStyle = `margin:0;font-size:34px;font-weight:800;line-height:1;color:${QM.ink};font-family:${FONT}` +
|
|
288
|
+
(struck ? `;text-decoration:line-through;text-decoration-color:${QM.coralDark};text-decoration-thickness:3px` : '');
|
|
289
|
+
// Rate zone styling. 'coral' matches the month accent (drop qm-ink so Outlook
|
|
290
|
+
// dark-mode doesn't force it back to ink); 'ink' is the default payout look.
|
|
291
|
+
const rateHex = rateColor === 'coral' ? QM.coralDark : QM.bodyInk;
|
|
292
|
+
const rateClass = rateColor === 'coral' ? '' : 'qm-ink';
|
|
293
|
+
// Bottom badge row: the weekday normally, the YEAR in its place for events far
|
|
294
|
+
// enough out that a bare "FEB 14" would be read as the coming February (see
|
|
295
|
+
// showEventYear in lib/dates.js for the horizon).
|
|
296
|
+
//
|
|
297
|
+
// A swap rather than a 4th row: past the horizon the year is the more
|
|
298
|
+
// load-bearing of the two -- an artist checking a date 14 months out needs to
|
|
299
|
+
// know which February before they need to know it is a Saturday -- and the
|
|
300
|
+
// badge keeps its exact three-row geometry, which matters because
|
|
301
|
+
// .qm-datecell narrows to 64px on mobile. Coral rather than the weekday's
|
|
302
|
+
// muted grey so it brackets the day with the month line and actually gets
|
|
303
|
+
// read; that grey is exactly where a year would be missed.
|
|
304
|
+
const tail = year || weekday;
|
|
305
|
+
const tailStyle = year
|
|
306
|
+
? `margin:0;font-size:10px;font-weight:800;letter-spacing:1px;color:${QM.coralDark};font-family:${FONT}`
|
|
307
|
+
: `margin:0;font-size:10px;font-weight:700;letter-spacing:1px;color:${QM.muted};font-family:${FONT}`;
|
|
308
|
+
const badge = `
|
|
309
|
+
<td class="qm-datecell" width="84" valign="top" style="width:84px">
|
|
310
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:2px solid ${QM.ink};border-radius:11px">
|
|
311
|
+
<tr><td align="center" style="padding:9px 6px 1px">
|
|
312
|
+
<p style="margin:0;font-size:11px;font-weight:800;letter-spacing:1.5px;color:${QM.coralDark};font-family:${FONT}">${esc(month)}</p>
|
|
313
|
+
</td></tr>
|
|
314
|
+
<tr><td align="center" style="padding:0 6px">
|
|
315
|
+
<p class="qm-datenum qm-ink" style="${dayStyle}">${esc(day)}</p>
|
|
316
|
+
</td></tr>
|
|
317
|
+
<tr><td align="center" style="padding:2px 6px 9px">
|
|
318
|
+
<p style="${tailStyle}">${esc(tail)}</p>
|
|
319
|
+
</td></tr>${lateBadgeRow(late, 9, 9.5)}
|
|
320
|
+
</table>
|
|
321
|
+
</td>`;
|
|
322
|
+
const venue = detailInfoCell({ title, position, time, timeHtml, subtitle, subtitleHtml });
|
|
323
|
+
// No rate -> simple two-zone row. Rate -> wrap date+venue in qm-detailcell and
|
|
324
|
+
// pin the amount in qm-ratecell (stacks on mobile via the responsive rules).
|
|
325
|
+
const row = rate
|
|
326
|
+
? `
|
|
327
|
+
<tr>
|
|
328
|
+
<td class="qm-detailcell" valign="middle">
|
|
329
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>${badge}${venue}</tr></table>
|
|
330
|
+
</td>
|
|
331
|
+
<td class="qm-ratecell" valign="${rateValign}" align="right" width="${rateWidth}" style="width:${rateWidth}px;padding-left:10px">
|
|
332
|
+
<p class="${rateClass}" style="margin:0;font-size:${rateSize};font-weight:800;letter-spacing:-0.4px;line-height:1;white-space:nowrap;color:${rateHex};font-family:${FONT}">${esc(rate)}</p>
|
|
333
|
+
</td>
|
|
334
|
+
</tr>`
|
|
335
|
+
: `
|
|
336
|
+
<tr>${badge}${venue}</tr>`;
|
|
337
|
+
// Stamp rides as a no-repeat centered background on the inset cell so the
|
|
338
|
+
// date/venue text sits on top. Clients that drop background-images (older
|
|
339
|
+
// Outlook) still get the struck-through date — the meaning survives.
|
|
340
|
+
const stampBg = stampUrl
|
|
341
|
+
? `;background-image:url(${esc(stampUrl)});background-repeat:no-repeat;background-position:center center;background-size:contain`
|
|
342
|
+
: '';
|
|
343
|
+
// Optional caution footnote row (weather note) spanning both card columns.
|
|
344
|
+
const footnote = footnoteHtml
|
|
345
|
+
? `
|
|
346
|
+
<tr>
|
|
347
|
+
<td colspan="2" style="padding:14px 0 0">
|
|
348
|
+
<p style="margin:0;font-size:12.5px;font-weight:600;line-height:1.45;color:${QM.coralDark};font-family:${FONT}">${footnoteHtml}</p>
|
|
349
|
+
</td>
|
|
350
|
+
</tr>`
|
|
351
|
+
: '';
|
|
352
|
+
return `
|
|
353
|
+
<tr>
|
|
354
|
+
<td class="qm-pad" style="padding:20px 26px 0">
|
|
355
|
+
<table role="presentation" class="qm-inset" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.inset}" style="background-color:${QM.inset};border:1px solid ${QM.insetBorder};border-radius:14px">
|
|
356
|
+
<tr>
|
|
357
|
+
<td style="padding:18px 20px${stampBg}">
|
|
358
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${row}${footnote}
|
|
359
|
+
</table>
|
|
360
|
+
</td>
|
|
361
|
+
</tr>
|
|
362
|
+
</table>
|
|
363
|
+
</td>
|
|
364
|
+
</tr>`;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Coral left-rule callout. `html` is interpolated raw so callers can pass
|
|
368
|
+
* <strong>…</strong> — only pass trusted/escaped content. Optional `signoff`
|
|
369
|
+
* adds a small right-aligned italic "- Name" line inside the same callout
|
|
370
|
+
* (hyphen, never an em dash).
|
|
371
|
+
*/
|
|
372
|
+
export function noteBlock(html, signoff) {
|
|
373
|
+
const sign = signoff
|
|
374
|
+
? `
|
|
375
|
+
<p style="margin:9px 0 0;font-size:12.5px;font-style:italic;text-align:right;color:${QM.muted};font-family:${FONT}">- ${esc(signoff)}</p>`
|
|
376
|
+
: '';
|
|
377
|
+
return `
|
|
378
|
+
<tr>
|
|
379
|
+
<td class="qm-pad" style="padding:16px 26px 0">
|
|
380
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
381
|
+
<tr>
|
|
382
|
+
<td width="3" bgcolor="${QM.coralRule}" style="width:3px;background-color:${QM.coralRule};border-radius:3px"></td>
|
|
383
|
+
<td style="padding-left:14px">
|
|
384
|
+
<p class="qm-ink" style="margin:0;font-size:14px;font-weight:500;line-height:1.55;color:#3f3a33;font-family:${FONT}">${html}</p>${sign}
|
|
385
|
+
</td>
|
|
386
|
+
</tr>
|
|
387
|
+
</table>
|
|
388
|
+
</td>
|
|
389
|
+
</tr>`;
|
|
390
|
+
}
|
|
391
|
+
/** Single bulletproof CTA. The whole cell is the hit target. */
|
|
392
|
+
export function ctaButton({ href, label, bg = QM.ink }) {
|
|
393
|
+
return `
|
|
394
|
+
<tr>
|
|
395
|
+
<td class="qm-pad qm-ctapad" style="padding:24px 26px 4px">
|
|
396
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:100%">
|
|
397
|
+
<tr>
|
|
398
|
+
<td class="qm-btn" bgcolor="${bg}" style="background-color:${bg};border-radius:12px" align="center">
|
|
399
|
+
<a href="${esc(href)}" style="display:block;padding:15px 24px;font-size:15px;font-weight:700;letter-spacing:0.2px;color:${QM.cream};text-decoration:none;font-family:${FONT}">${esc(label)}</a>
|
|
400
|
+
</td>
|
|
401
|
+
</tr>
|
|
402
|
+
</table>
|
|
403
|
+
</td>
|
|
404
|
+
</tr>`;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A subtle centered secondary action: lead text plus a coral text link, never a
|
|
408
|
+
* button. Use under the primary CTA for the "or do this instead" path
|
|
409
|
+
* (decline, talk to us, etc.).
|
|
410
|
+
*/
|
|
411
|
+
export function secondaryLink({ lead, label, href }) {
|
|
412
|
+
return `
|
|
413
|
+
<tr>
|
|
414
|
+
<td class="qm-pad" style="padding:14px 26px 0;text-align:center">
|
|
415
|
+
<p style="margin:0;font-size:13px;line-height:1.5;color:${QM.muted};font-family:${FONT}">${esc(lead)} <a href="${esc(href)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">${esc(label)}</a></p>
|
|
416
|
+
</td>
|
|
417
|
+
</tr>`;
|
|
418
|
+
}
|
|
419
|
+
/** The subtle "Schedule a call" secondary — a text link, never a button. */
|
|
420
|
+
export function scheduleCallLink({ href = 'https://calendly.com/qmusic/30min', lead = 'Prefer to talk it through?', label = 'Schedule a call →', } = {}) {
|
|
421
|
+
return secondaryLink({ lead, label, href });
|
|
422
|
+
}
|
|
423
|
+
/* ----------------------------------------------------------------------------
|
|
424
|
+
* Result-page components — for browser confirmation pages (e.g. the bid
|
|
425
|
+
* extension accept/decline landing). renderEmailShell doubles as the page
|
|
426
|
+
* shell; these are the centered body pieces.
|
|
427
|
+
* -------------------------------------------------------------------------- */
|
|
428
|
+
/** Centered body paragraph. `html` is raw (pass trusted/escaped content). */
|
|
429
|
+
export function centeredText(html) {
|
|
430
|
+
return `
|
|
431
|
+
<tr>
|
|
432
|
+
<td class="qm-pad" style="padding:22px 26px 6px;text-align:center">
|
|
433
|
+
<p style="margin:0;font-size:14.5px;line-height:1.6;color:${QM.body};font-family:${FONT}">${html}</p>
|
|
434
|
+
</td>
|
|
435
|
+
</tr>`;
|
|
436
|
+
}
|
|
437
|
+
/** Centered inset stat card: small uppercase label over a large value. */
|
|
438
|
+
export function centeredStatCard({ label, value }) {
|
|
439
|
+
return `
|
|
440
|
+
<tr>
|
|
441
|
+
<td class="qm-pad" style="padding:22px 26px 0">
|
|
442
|
+
<table role="presentation" class="qm-inset" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${QM.inset}" style="background-color:${QM.inset};border:1px solid ${QM.insetBorder};border-radius:14px">
|
|
443
|
+
<tr>
|
|
444
|
+
<td align="center" style="padding:26px 24px">
|
|
445
|
+
<p style="margin:0 0 5px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:${QM.muted};font-family:${FONT}">${esc(label)}</p>
|
|
446
|
+
<p class="qm-ink" style="margin:0;font-size:23px;font-weight:800;color:${QM.bodyInk};font-family:${FONT}">${esc(value)}</p>
|
|
447
|
+
</td>
|
|
448
|
+
</tr>
|
|
449
|
+
</table>
|
|
450
|
+
</td>
|
|
451
|
+
</tr>`;
|
|
452
|
+
}
|
|
453
|
+
/** Standard sign-off: the support mailto, plus the optional calendar line and note. */
|
|
454
|
+
export function footerBlock({ email = 'support@qmusic.co', calendarUrl, note } = {}) {
|
|
455
|
+
const cal = (calendarUrl ?? '').trim()
|
|
456
|
+
? `<p style="margin:0 0 8px;font-size:12px;line-height:1.55;color:${QM.muted};font-family:${FONT}">📅 <a href="${esc(calendarUrl)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">Subscribe to your Q Music Calendar</a></p>
|
|
457
|
+
`
|
|
458
|
+
: '';
|
|
459
|
+
return `
|
|
460
|
+
<tr>
|
|
461
|
+
<td class="qm-pad" style="padding:18px 26px 28px">
|
|
462
|
+
${cal}<p style="margin:0;font-size:12px;line-height:1.55;color:${QM.muted};font-family:${FONT}">Questions? Reach the team at <a href="mailto:${esc(email)}" style="color:${QM.coralDark};text-decoration:none;font-weight:600">${esc(email)}</a>.${(note ?? '').trim() ? ` ${esc(note.trim())}` : ''}</p>
|
|
463
|
+
</td>
|
|
464
|
+
</tr>`;
|
|
465
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qmusic/email-shell",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Q Music transactional email shell: the warm light card every client and artist email renders in.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/qmusic-ai/email-shell.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
25
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
26
|
+
"golden:write": "npm run build && node scripts/write-golden.mjs",
|
|
27
|
+
"prepublishOnly": "npm test && node scripts/assert-release.mjs"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "6.0.3"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|