145 lines
7.0 KiB
TypeScript
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, "&").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: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)} — tap your choice below</div>
|
|
<div class="label">${session.options.length} option${session.options.length === 1 ? "" : "s"} · ${session.type} vote</div>
|
|
${optionButtons}
|
|
<div class="ft">rChoices · rspace.online · 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: "✓", no: "✗", maybe: "?" };
|
|
|
|
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 · ${startDate}</p>`
|
|
: `<p class="meta">${startDate}${startTime ? ` · ${startTime}` : ""}${endTime ? ` – ${endTime}` : ""}</p>`;
|
|
|
|
const location = event.locationName
|
|
? `<p class="meta">${esc(event.locationName)}</p>`
|
|
: event.isVirtual && event.virtualUrl
|
|
? `<p class="meta">Virtual · <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)} — will you attend?</div>
|
|
<div class="rsvp-row">
|
|
${buttons}
|
|
</div>
|
|
<div class="ft">rCal · rspace.online · 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 · rspace.online</div></div></body></html>`;
|
|
}
|