mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
Style feedback drip email like invites
This commit is contained in:
127
public/docs/feedback-request.html
Normal file
127
public/docs/feedback-request.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" style="margin:0;padding:0;background:#0b1020;">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="supported-color-schemes" content="dark light">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html,body{margin:0!important;padding:0!important;background:#0b1020; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; }
|
||||
img{border:0;outline:none;text-decoration:none;max-width:100%;height:auto;display:block;}
|
||||
table{border-collapse:collapse!important;}
|
||||
a{color:#22d3ee;text-decoration:none;}
|
||||
.wrapper{width:100%;background:#0b1020;}
|
||||
.container{max-width:640px;margin:0 auto;}
|
||||
.card{
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius:16px;
|
||||
box-shadow:0 30px 100px rgba(5,12,32,.45);
|
||||
}
|
||||
.text{color:#e5f2ff;}
|
||||
.muted{color:rgba(255,255,255,.65);}
|
||||
.chip{
|
||||
display:inline-block;
|
||||
padding:6px 10px;
|
||||
border-radius:9999px;
|
||||
font-size:12px; letter-spacing:.12em; text-transform:uppercase;
|
||||
color:#e6fbff; border:1px solid rgba(255,255,255,.15);
|
||||
background:rgba(255,255,255,.08);
|
||||
}
|
||||
h1,h2,h3,p{margin:0;}
|
||||
h1{color:#fff;font-size:28px;line-height:1.2;font-weight:700;}
|
||||
h2{color:#22d3ee;font-size:18px;line-height:1.4;font-weight:600;}
|
||||
p{color:#dbe7ff;font-size:16px;line-height:1.6;}
|
||||
small{font-size:12px;color:rgba(255,255,255,.55);}
|
||||
.btn{
|
||||
display:inline-block; padding:14px 20px; border-radius:14px;
|
||||
background:#06b6d4; color:#fff!important; font-weight:700; text-align:center;
|
||||
box-shadow:0 0 40px rgba(34,211,238,.25);
|
||||
}
|
||||
.btn:hover{background:#22d3ee;}
|
||||
.px{padding-left:24px;padding-right:24px;}
|
||||
.py{padding-top:24px;padding-bottom:24px;}
|
||||
.mt8{margin-top:8px;} .mt12{margin-top:12px;} .mt16{margin-top:16px;}
|
||||
.mt20{margin-top:20px;} .mt24{margin-top:24px;} .mt28{margin-top:28px;}
|
||||
.mb0{margin-bottom:0;}
|
||||
@media (max-width: 600px){
|
||||
.px{padding-left:18px;padding-right:18px;}
|
||||
h1{font-size:24px;}
|
||||
p{font-size:15px;}
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style>
|
||||
.card{background:#12182d;border:1px solid #1f2a44;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="wrapper">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||
Quick check-in on your OpenSquawk invite — we would love your feedback.
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" bgcolor="#0b1020">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" class="container" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr><td class="px" style="padding-top:32px;padding-bottom:16px;">
|
||||
<table role="presentation" width="100%">
|
||||
<tr>
|
||||
<td align="left">
|
||||
<div class="chip">Feedback · Classroom</div>
|
||||
<div style="height:20px;"></div>
|
||||
<h1>OpenSquawk</h1>
|
||||
<p class="muted" style="margin-top:6px;">Live AI ATC · in development</p>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:top;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td class="px">
|
||||
<table role="presentation" width="100%" class="card">
|
||||
<tr><td class="py px">
|
||||
<h2>How is your OpenSquawk experience?</h2>
|
||||
<p class="mt12">It’s been a little while since we sent your invite, and we would love to hear how things are going.</p>
|
||||
|
||||
<p class="mt16">Your feedback helps us improve the simulator experience and plan the next steps for Live ATC.</p>
|
||||
|
||||
<div class="mt20" align="center">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="https://opensquawk.de/feedback" style="height:48px;v-text-anchor:middle;width:320px;" arcsize="20%" stroke="f" fillcolor="#06b6d4">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:Segoe UI, Arial, sans-serif;font-size:16px;font-weight:bold;">
|
||||
Share feedback
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<a class="btn" href="https://opensquawk.de/feedback" target="_blank" rel="noopener">Share feedback</a>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
|
||||
<p class="mt24">Prefer email? Just reply with your thoughts — every note helps.</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td class="px" style="padding-top:18px;padding-bottom:38px;">
|
||||
<table role="presentation" width="100%">
|
||||
<tr>
|
||||
<td align="left">
|
||||
<small>© OpenSquawk · Not for real-world aviation.</small><br>
|
||||
<small><a href="https://opensquawk.de/feedback" target="_blank" rel="noopener">Feedback</a> · <a href="mailto:info@opensquawk.de">info@opensquawk.de</a></small>
|
||||
</td>
|
||||
<td align="right">
|
||||
<small class="muted">Built with an open stack</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
114
server/api/service/cron/waitlist-drip.get.ts
Normal file
114
server/api/service/cron/waitlist-drip.get.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { InvitationCode } from '../../../models/InvitationCode'
|
||||
import { WaitlistEntry } from '../../../models/WaitlistEntry'
|
||||
import { sendMail } from '../../../utils/notifications'
|
||||
import {
|
||||
generateInvitationCode,
|
||||
renderFeedbackEmail,
|
||||
renderFeedbackText,
|
||||
renderInvitationEmail,
|
||||
renderInvitationText,
|
||||
} from '../../../utils/invitations'
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24
|
||||
const INVITATION_DELAY_DAYS = 5
|
||||
const FEEDBACK_DELAY_DAYS = 14
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const now = new Date()
|
||||
const invitationCutoff = new Date(now.getTime() - INVITATION_DELAY_DAYS * DAY_MS)
|
||||
const feedbackCutoff = new Date(now.getTime() - FEEDBACK_DELAY_DAYS * DAY_MS)
|
||||
|
||||
const invitationCandidates = await WaitlistEntry.find({
|
||||
joinedAt: { $lte: invitationCutoff },
|
||||
$and: [
|
||||
{ $or: [{ invitationSentAt: { $exists: false } }, { invitationSentAt: null }] },
|
||||
{ $or: [{ activatedAt: { $exists: false } }, { activatedAt: null }] },
|
||||
],
|
||||
}).exec()
|
||||
|
||||
let invitationsSent = 0
|
||||
|
||||
for (const entry of invitationCandidates) {
|
||||
const email = entry.email?.trim()
|
||||
if (!email) {
|
||||
continue
|
||||
}
|
||||
|
||||
let invitation = entry.invitationCode ? await InvitationCode.findById(entry.invitationCode) : null
|
||||
|
||||
if (invitation?.usedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sentAt = new Date()
|
||||
|
||||
if (!invitation) {
|
||||
const code = generateInvitationCode()
|
||||
invitation = await InvitationCode.create({
|
||||
code,
|
||||
createdAt: sentAt,
|
||||
channel: 'admin',
|
||||
label: `Waitlist: ${email}`,
|
||||
})
|
||||
entry.invitationCode = invitation._id
|
||||
}
|
||||
|
||||
entry.invitationSentAt = sentAt
|
||||
await entry.save()
|
||||
|
||||
const html = await renderInvitationEmail(invitation.code)
|
||||
const text = renderInvitationText(invitation.code)
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Your OpenSquawk invite code',
|
||||
text,
|
||||
html,
|
||||
})
|
||||
|
||||
invitationsSent += 1
|
||||
}
|
||||
|
||||
const feedbackCandidates = await WaitlistEntry.find({
|
||||
invitationSentAt: { $lte: feedbackCutoff },
|
||||
$or: [
|
||||
{ feedbackRequestedAt: { $exists: false } },
|
||||
{ feedbackRequestedAt: null },
|
||||
],
|
||||
}).exec()
|
||||
|
||||
let feedbackRequests = 0
|
||||
|
||||
for (const entry of feedbackCandidates) {
|
||||
const email = entry.email?.trim()
|
||||
if (!email) {
|
||||
continue
|
||||
}
|
||||
|
||||
const requestedAt = new Date()
|
||||
|
||||
const text = renderFeedbackText()
|
||||
const html = await renderFeedbackEmail()
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'How is your OpenSquawk experience?',
|
||||
text,
|
||||
html,
|
||||
})
|
||||
|
||||
entry.feedbackRequestedAt = requestedAt
|
||||
await entry.save()
|
||||
|
||||
feedbackRequests += 1
|
||||
}
|
||||
|
||||
console.log(`[waitlist-drip] invitations sent: ${invitationsSent}`)
|
||||
console.log(`[waitlist-drip] feedback requests sent: ${feedbackRequests}`)
|
||||
|
||||
return {
|
||||
invitationsSent,
|
||||
feedbackRequests,
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,7 @@ export interface WaitlistEntryDocument extends mongoose.Document {
|
||||
updatesOptedInAt?: Date
|
||||
invitationCode?: mongoose.Types.ObjectId
|
||||
invitationSentAt?: Date
|
||||
feedbackRequestedAt?: Date
|
||||
}
|
||||
|
||||
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
@@ -30,6 +31,7 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
updatesOptedInAt: { type: Date },
|
||||
invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' },
|
||||
invitationSentAt: { type: Date },
|
||||
feedbackRequestedAt: { type: Date },
|
||||
})
|
||||
|
||||
export const WaitlistEntry =
|
||||
|
||||
@@ -2,33 +2,45 @@ import { randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
let cachedHtmlTemplate: string | null = null
|
||||
type CachedTemplates = {
|
||||
invitations: string | null
|
||||
feedback: string | null
|
||||
}
|
||||
|
||||
const cachedTemplates: CachedTemplates = {
|
||||
invitations: null,
|
||||
feedback: null,
|
||||
}
|
||||
|
||||
export function generateInvitationCode() {
|
||||
return randomBytes(4).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
async function loadInvitationTemplate() {
|
||||
if (cachedHtmlTemplate) {
|
||||
return cachedHtmlTemplate
|
||||
async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallback: string) {
|
||||
if (cachedTemplates[kind]) {
|
||||
return cachedTemplates[kind] as string
|
||||
}
|
||||
|
||||
const templatePath = join(process.cwd(), 'public', 'docs', 'invites.html')
|
||||
const templatePath = join(process.cwd(), 'public', 'docs', filename)
|
||||
|
||||
try {
|
||||
const file = await readFile(templatePath, 'utf8')
|
||||
cachedHtmlTemplate = file
|
||||
cachedTemplates[kind] = file
|
||||
return file
|
||||
} catch (error) {
|
||||
console.warn('Could not load invitation email template, falling back to plain HTML.', error)
|
||||
const fallback = `<!DOCTYPE html><html><body><p>Your OpenSquawk invite code: <strong>{{INVITE_CODE}}</strong></p></body></html>`
|
||||
cachedHtmlTemplate = fallback
|
||||
console.warn(`Could not load ${kind} email template, falling back to plain HTML.`, error)
|
||||
cachedTemplates[kind] = fallback
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderInvitationEmail(code: string) {
|
||||
const template = await loadInvitationTemplate()
|
||||
const template = await loadTemplate(
|
||||
'invitations',
|
||||
'invites.html',
|
||||
`<!DOCTYPE html><html><body><p>Your OpenSquawk invite code: <strong>{{INVITE_CODE}}</strong></p></body></html>`
|
||||
)
|
||||
|
||||
return template.replace(/{{INVITE_CODE}}/g, code)
|
||||
}
|
||||
|
||||
@@ -48,3 +60,28 @@ export function renderInvitationText(code: string) {
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function renderFeedbackEmail() {
|
||||
const template = await loadTemplate(
|
||||
'feedback',
|
||||
'feedback-request.html',
|
||||
`<!DOCTYPE html><html><body><p>We'd love your feedback: <a href="https://opensquawk.de/feedback">opensquawk.de/feedback</a></p></body></html>`
|
||||
)
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
export function renderFeedbackText() {
|
||||
const lines = [
|
||||
'Hi there,',
|
||||
'',
|
||||
"It's been a little while since we sent your invite, and we would love to hear your thoughts.",
|
||||
'',
|
||||
'Share your feedback here:',
|
||||
'https://opensquawk.de/feedback',
|
||||
'',
|
||||
'Thank you for helping us improve OpenSquawk!',
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user