109 lines
5.5 KiB
TypeScript
109 lines
5.5 KiB
TypeScript
/** HTML rendering for email checklist and web confirmation page. */
|
|
|
|
import type { AcceptanceCriterion } from "./backlog";
|
|
import { checklistConfig } from "./checklist-config";
|
|
|
|
const SUCCESS_COLOR = "#22c55e";
|
|
|
|
function esc(str: string): string {
|
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
const styles = `
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px}
|
|
.card{max-width:600px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px}
|
|
h1{font-size:20px;margin:0 0 4px;color:#0f172a}
|
|
.sub{font-size:14px;color:#64748b;margin-bottom:24px}
|
|
.row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9}
|
|
.row:last-child{border-bottom:none}
|
|
.cb{width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;text-decoration:none;flex-shrink:0;margin-top:1px}
|
|
.uc{border:2px solid #cbd5e1;color:#cbd5e1;background:#fff}
|
|
.uc:hover{border-color:#6366f1;color:#6366f1}
|
|
.ck{border:2px solid ${SUCCESS_COLOR};background:${SUCCESS_COLOR};color:#fff}
|
|
.txt{font-size:15px;line-height:1.5}
|
|
.done{color:#94a3b8;text-decoration:line-through}
|
|
.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px}
|
|
.pb{height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;margin:16px 0 8px}
|
|
.pf{height:100%;background:${SUCCESS_COLOR};border-radius:3px}
|
|
.pt{font-size:13px;color:#64748b;margin-bottom:24px}
|
|
.hi{background:#f0fdf4;border-radius:8px;padding:2px 0}
|
|
`;
|
|
|
|
function progress(criteria: AcceptanceCriterion[]): string {
|
|
const total = criteria.length;
|
|
const done = criteria.filter((c) => c.checked).length;
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
return `<div class="pb"><div class="pf" style="width:${pct}%"></div></div><div class="pt">${done} of ${total} complete (${pct}%)</div>`;
|
|
}
|
|
|
|
function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>, justToggled?: number): string {
|
|
return criteria
|
|
.map((ac) => {
|
|
const hi = ac.index === justToggled ? " hi" : "";
|
|
const token = tokens.get(ac.index);
|
|
const url = token ? `${checklistConfig.baseUrl}/rtasks/check/${token}` : "#";
|
|
if (ac.checked) {
|
|
return `<div class="row${hi}"><a href="${url}" class="cb ck" title="Click to uncheck">✓</a><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
|
|
}
|
|
return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off"> </a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
export function renderEmailHTML(
|
|
title: string,
|
|
taskId: string,
|
|
criteria: AcceptanceCriterion[],
|
|
tokens: Map<number, string>,
|
|
): string {
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${styles}</style></head>
|
|
<body><div class="card">
|
|
<h1>${esc(title)}</h1>
|
|
<div class="sub">${esc(taskId)} · Click a checkbox to mark it done</div>
|
|
${progress(criteria)}
|
|
${checklist(criteria, tokens)}
|
|
<div class="ft">Sent by rTasks · Links expire in ${checklistConfig.tokenExpiryDays} days</div>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
export function renderWebPage(
|
|
title: string,
|
|
taskId: string,
|
|
criteria: AcceptanceCriterion[],
|
|
tokens: Map<number, string>,
|
|
justToggled?: number,
|
|
wasChecked?: boolean,
|
|
): string {
|
|
const justAC = justToggled !== undefined ? criteria.find((c) => c.index === justToggled) : undefined;
|
|
let banner = "";
|
|
if (justAC && wasChecked) {
|
|
banner = `<div style="background:${SUCCESS_COLOR};color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">✓ Checked off: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>`;
|
|
} else if (justAC && !wasChecked) {
|
|
banner = `<div style="background:#f59e0b;color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">↻ Unchecked: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>`;
|
|
}
|
|
const allDone = criteria.every((c) => c.checked);
|
|
const doneMsg = allDone ? `<div style="text-align:center;padding:24px;font-size:18px;color:${SUCCESS_COLOR};">🎉 All items complete!</div>` : "";
|
|
|
|
const rtasksUrl = `${checklistConfig.baseUrl}/demo/rtasks`;
|
|
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} - rTasks</title><style>${styles}
|
|
.btn{display:inline-block;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;margin-top:16px}
|
|
.btn-primary{background:#6366f1;color:#fff}.btn-primary:hover{background:#4f46e5}
|
|
</style></head>
|
|
<body><div class="card">
|
|
${banner}
|
|
<h1>${esc(title)}</h1>
|
|
<div class="sub">${esc(taskId)}</div>
|
|
${progress(criteria)}
|
|
${checklist(criteria, tokens, justToggled)}
|
|
${doneMsg}
|
|
<div style="text-align:center;margin-top:20px"><a href="${rtasksUrl}" class="btn btn-primary">Open in rTasks</a></div>
|
|
<div class="ft">rTasks · rspace.online</div>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
export function renderError(title: string, message: string): string {
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error - rTasks</title><style>${styles} .err{color:#ef4444}</style></head>
|
|
<body><div class="card" style="text-align:center"><h1 class="err">${esc(title)}</h1><p style="color:#64748b">${esc(message)}</p><div class="ft">rTasks · rspace.online</div></div></body></html>`;
|
|
}
|