rspace-online/server/magic-link/render.ts

145 lines
7.0 KiB
TypeScript

/** Standalone HTML rendering for magic link response pages. */
import { magicLinkConfig } from "./config";
import type { ChoiceSession, ChoiceOption } from "../../modules/rchoices/schemas";
import type { CalendarEvent } from "../../modules/rcal/schemas";
function esc(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
const styles = `
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px}
.card{max-width:500px;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}
.opt{display:block;width:100%;padding:14px 20px;margin:8px 0;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:16px;text-align:left;color:#1e293b;text-decoration:none;box-sizing:border-box;transition:border-color .15s,background .15s}
.opt:hover{border-color:#6366f1;background:#eef2ff}
.opt.sel{border-color:#22c55e;background:#f0fdf4}
.rsvp-row{display:flex;gap:10px;margin:16px 0}
.rsvp-btn{flex:1;padding:14px;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:15px;text-align:center;text-decoration:none;color:#1e293b;transition:border-color .15s,background .15s}
.rsvp-btn:hover{border-color:#6366f1;background:#eef2ff}
.rsvp-btn.sel{border-color:#22c55e;background:#f0fdf4}
.banner{padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px}
.banner-ok{background:#22c55e;color:#fff}
.banner-info{background:#3b82f6;color:#fff}
.meta{font-size:13px;color:#64748b;margin:4px 0}
.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px}
.label{font-size:13px;color:#94a3b8;margin-top:16px;margin-bottom:4px}
`;
// ── Poll page ──
export function renderPollPage(
session: ChoiceSession,
token: string,
respondentName: string,
existingChoice?: string,
justVoted?: string,
): string {
let banner = "";
if (justVoted) {
const opt = session.options.find((o) => o.id === justVoted);
banner = `<div class="banner banner-ok">Your vote for <strong>${esc(opt?.label || justVoted)}</strong> has been recorded.</div>`;
} else if (existingChoice) {
const opt = session.options.find((o) => o.id === existingChoice);
banner = `<div class="banner banner-info">You previously voted for <strong>${esc(opt?.label || existingChoice)}</strong>. Tap another option to change your vote.</div>`;
}
const optionButtons = session.options
.map((opt) => {
const selected = (existingChoice === opt.id || justVoted === opt.id) ? " sel" : "";
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0">
<input type="hidden" name="choice" value="${esc(opt.id)}">
<button type="submit" class="opt${selected}" style="border-left:4px solid ${esc(opt.color || '#6366f1')}">${esc(opt.label)}</button>
</form>`;
})
.join("\n");
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(session.title)} - rChoices</title><style>${styles}</style></head>
<body><div class="card">
${banner}
<h1>${esc(session.title)}</h1>
<div class="sub">Hi ${esc(respondentName)} &mdash; tap your choice below</div>
<div class="label">${session.options.length} option${session.options.length === 1 ? "" : "s"} &middot; ${session.type} vote</div>
${optionButtons}
<div class="ft">rChoices &middot; rspace.online &middot; Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
// ── RSVP page ──
const rsvpLabels: Record<string, string> = { yes: "Going", no: "Not Going", maybe: "Maybe" };
const rsvpEmoji: Record<string, string> = { yes: "&#10003;", no: "&#10007;", maybe: "&#63;" };
export function renderRsvpPage(
event: CalendarEvent,
token: string,
respondentName: string,
currentStatus?: string,
justResponded?: string,
): string {
let banner = "";
if (justResponded) {
banner = `<div class="banner banner-ok">You responded <strong>${esc(rsvpLabels[justResponded] || justResponded)}</strong>.</div>`;
} else if (currentStatus && currentStatus !== "pending") {
banner = `<div class="banner banner-info">You previously responded <strong>${esc(rsvpLabels[currentStatus] || currentStatus)}</strong>. Tap to change.</div>`;
}
const startDate = event.startTime
? new Date(event.startTime).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
: "";
const startTime = event.startTime && !event.allDay
? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
: "";
const endTime = event.endTime && !event.allDay
? new Date(event.endTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
: "";
const timeLine = event.allDay
? `<p class="meta">All day &middot; ${startDate}</p>`
: `<p class="meta">${startDate}${startTime ? ` &middot; ${startTime}` : ""}${endTime ? ` &ndash; ${endTime}` : ""}</p>`;
const location = event.locationName
? `<p class="meta">${esc(event.locationName)}</p>`
: event.isVirtual && event.virtualUrl
? `<p class="meta">Virtual &middot; <a href="${esc(event.virtualUrl)}">${esc(event.virtualPlatform || "Join")}</a></p>`
: "";
const buttons = ["yes", "no", "maybe"]
.map((status) => {
const selected = (currentStatus === status || justResponded === status) ? " sel" : "";
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0;flex:1">
<input type="hidden" name="status" value="${status}">
<button type="submit" class="rsvp-btn${selected}">${rsvpEmoji[status]} ${rsvpLabels[status]}</button>
</form>`;
})
.join("\n");
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>RSVP: ${esc(event.title)} - rCal</title><style>${styles}</style></head>
<body><div class="card">
${banner}
<h1>${esc(event.title)}</h1>
${timeLine}
${location}
${event.description ? `<p style="font-size:14px;color:#475569;margin-top:12px">${esc(event.description)}</p>` : ""}
<div class="sub" style="margin-top:16px">Hi ${esc(respondentName)} &mdash; will you attend?</div>
<div class="rsvp-row">
${buttons}
</div>
<div class="ft">rCal &middot; rspace.online &middot; Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
// ── Error page ──
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 - rSpace</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">rSpace &middot; rspace.online</div></div></body></html>`;
}