mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Add unsubscribe endpoint and email footers
This commit is contained in:
102
app/pages/unsubscribe.vue
Normal file
102
app/pages/unsubscribe.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen bg-[#050914] text-white">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(34,211,238,0.12),transparent_55%)]"/>
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_bottom,_rgba(123,77,255,0.12),transparent_60%)]"/>
|
||||
|
||||
<main class="relative z-10 mx-auto flex max-w-4xl flex-col gap-12 px-6 py-16 sm:px-8 md:px-10 lg:py-20">
|
||||
<header class="space-y-4 text-center">
|
||||
<span class="chip inline-flex items-center justify-center px-4 py-2 text-xs font-semibold uppercase tracking-[0.32em] text-white">
|
||||
Updates
|
||||
</span>
|
||||
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl md:text-5xl">Bleibe oder melde dich ab</h1>
|
||||
<p class="mx-auto max-w-3xl text-base text-white/75 sm:text-lg">
|
||||
Wir sind auf ehrliches Feedback angewiesen, um OpenSquawk besser zu machen. Es wäre schade, dich zu verlieren –
|
||||
hinter <a class="font-semibold text-cyan-200 hover:text-cyan-100" href="mailto:info@opensquawk.de">info@opensquawk.de</a>
|
||||
kannst du uns jederzeit schreiben, was du dir wünschst. Deine Nachrichten landen direkt bei den Menschen, die OpenSquawk
|
||||
entwickeln, damit wir gemeinsam eine richtig starke Community aufbauen können.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_30px_80px_rgba(5,10,35,0.45)] backdrop-blur-2xl sm:p-8">
|
||||
<div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-lg font-semibold text-white">E-Mail abmelden</p>
|
||||
<p class="text-sm text-white/70">Wir entfernen deine Adresse aus der Warteliste und den Produktupdates.</p>
|
||||
</div>
|
||||
<div v-if="status === 'success'" class="inline-flex items-center gap-2 rounded-full border border-emerald-400/50 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">
|
||||
<v-icon icon="mdi-check" size="18" class="text-emerald-200" /> Erfolgreich abgemeldet
|
||||
</div>
|
||||
<div v-else-if="status === 'error'" class="inline-flex items-center gap-2 rounded-full border border-rose-400/50 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">
|
||||
<v-icon icon="mdi-alert" size="18" class="text-rose-200" /> {{ errorMessage || 'Abmeldung fehlgeschlagen' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-[1fr_auto]" @submit.prevent="handleSubmit" novalidate>
|
||||
<label class="space-y-2 sm:col-span-1">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.3em] text-white/60">E-Mail-Adresse</span>
|
||||
<input
|
||||
v-model.trim="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="dein.name@email.de"
|
||||
class="w-full rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-sm text-white placeholder:text-white/35 focus:border-cyan-400 focus:outline-none focus-visible:ring-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl border border-cyan-400/70 bg-cyan-400/10 px-5 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-cyan-50 transition hover:border-cyan-300 hover:bg-cyan-300/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="status === 'loading'"
|
||||
>
|
||||
<v-icon v-if="status === 'loading'" icon="mdi-loading" size="18" class="animate-spin text-cyan-100" />
|
||||
<span>{{ status === 'success' ? 'Erledigt' : 'Abmelden' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-xs text-white/60">
|
||||
Wir schicken keine weiteren Mails an diese Adresse, sobald du dich abgemeldet hast.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const email = ref<string>((route.query.email as string) || '')
|
||||
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const errorMessage = ref('')
|
||||
|
||||
useHead({
|
||||
title: 'Newsletter abmelden · OpenSquawk',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Entferne deine E-Mail aus der Warteliste und den Update-Benachrichtigungen von OpenSquawk.' },
|
||||
],
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email.value) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = 'Bitte gib deine E-Mail-Adresse an.'
|
||||
return
|
||||
}
|
||||
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/service/updates', {
|
||||
method: 'DELETE',
|
||||
body: { email: email.value },
|
||||
})
|
||||
status.value = 'success'
|
||||
} catch (error: unknown) {
|
||||
status.value = 'error'
|
||||
if (error && typeof error === 'object' && 'statusMessage' in (error as Record<string, unknown>)) {
|
||||
errorMessage.value = (error as Record<string, string>).statusMessage || 'Abmeldung fehlgeschlagen.'
|
||||
} else {
|
||||
errorMessage.value = 'Abmeldung fehlgeschlagen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -112,7 +112,11 @@
|
||||
<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>
|
||||
<small>
|
||||
<a href="https://opensquawk.de/feedback" target="_blank" rel="noopener">Feedback</a> ·
|
||||
<a href="mailto:info@opensquawk.de">info@opensquawk.de</a>
|
||||
</small><br>
|
||||
<small>If you prefer no more emails: <a href="{{UNSUBSCRIBE_URL}}" target="_blank" rel="noopener">Unsubscribe</a></small>
|
||||
</td>
|
||||
<td align="right">
|
||||
<small class="muted">Built with an open stack</small>
|
||||
|
||||
@@ -147,7 +147,11 @@
|
||||
<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>
|
||||
<small>
|
||||
<a href="https://opensquawk.de/feedback" target="_blank" rel="noopener">Feedback</a> ·
|
||||
<a href="mailto:info@opensquawk.de">info@opensquawk.de</a>
|
||||
</small><br>
|
||||
<small>If you prefer no more emails: <a href="{{UNSUBSCRIBE_URL}}" target="_blank" rel="noopener">Unsubscribe</a></small>
|
||||
</td>
|
||||
<td align="right">
|
||||
<small class="muted">Built with an open stack</small>
|
||||
|
||||
@@ -54,8 +54,8 @@ export default defineEventHandler(async (event) => {
|
||||
entry.invitationSentAt = now
|
||||
await entry.save()
|
||||
|
||||
const html = await renderInvitationEmail(invitation.code)
|
||||
const text = renderInvitationText(invitation.code)
|
||||
const html = await renderInvitationEmail(invitation.code, entry.email)
|
||||
const text = renderInvitationText(invitation.code, entry.email)
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
|
||||
@@ -57,8 +57,8 @@ export default defineEventHandler(async () => {
|
||||
entry.invitationSentAt = sentAt
|
||||
await entry.save()
|
||||
|
||||
const html = await renderInvitationEmail(invitation.code)
|
||||
const text = renderInvitationText(invitation.code)
|
||||
const html = await renderInvitationEmail(invitation.code, email)
|
||||
const text = renderInvitationText(invitation.code, email)
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
@@ -88,8 +88,8 @@ export default defineEventHandler(async () => {
|
||||
|
||||
const requestedAt = new Date()
|
||||
|
||||
const text = renderFeedbackText()
|
||||
const html = await renderFeedbackEmail()
|
||||
const text = renderFeedbackText(email)
|
||||
const html = await renderFeedbackEmail(email)
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
|
||||
33
server/api/service/updates.delete.ts
Normal file
33
server/api/service/updates.delete.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { UpdateSubscriber } from '../../models/UpdateSubscriber'
|
||||
import { WaitlistEntry } from '../../models/WaitlistEntry'
|
||||
|
||||
interface UnsubscribeRequestBody {
|
||||
email?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<UnsubscribeRequestBody>(event)
|
||||
const email = body.email?.trim().toLowerCase()
|
||||
|
||||
if (!email) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
|
||||
}
|
||||
|
||||
const [updateResult, waitlistResult] = await Promise.all([
|
||||
UpdateSubscriber.deleteOne({ email }),
|
||||
WaitlistEntry.deleteOne({ email }),
|
||||
])
|
||||
|
||||
const removed = Boolean(updateResult.deletedCount || waitlistResult.deletedCount)
|
||||
|
||||
if (!removed) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'No matching email found' })
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
removedFromUpdates: updateResult.deletedCount > 0,
|
||||
removedFromWaitlist: waitlistResult.deletedCount > 0,
|
||||
}
|
||||
})
|
||||
@@ -16,6 +16,16 @@ export function generateInvitationCode() {
|
||||
return randomBytes(4).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
function buildUnsubscribeUrl(email?: string) {
|
||||
const baseUrl = 'https://opensquawk.de/unsubscribe'
|
||||
if (!email) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ email })
|
||||
return `${baseUrl}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallback: string) {
|
||||
if (cachedTemplates[kind]) {
|
||||
return cachedTemplates[kind] as string
|
||||
@@ -34,17 +44,19 @@ async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallb
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderInvitationEmail(code: string) {
|
||||
export async function renderInvitationEmail(code: string, email?: string) {
|
||||
const template = await loadTemplate(
|
||||
'invitations',
|
||||
'invites.html',
|
||||
`<!DOCTYPE html><html><body><p>Your OpenSquawk invite code: <strong>{{INVITE_CODE}}</strong></p></body></html>`
|
||||
`<!DOCTYPE html><html><body><p>Your OpenSquawk invite code: <strong>{{INVITE_CODE}}</strong></p><p><a href="{{UNSUBSCRIBE_URL}}">Unsubscribe</a> from updates</p></body></html>`
|
||||
)
|
||||
|
||||
return template.replace(/{{INVITE_CODE}}/g, code)
|
||||
return template
|
||||
.replace(/{{INVITE_CODE}}/g, code)
|
||||
.replace(/{{UNSUBSCRIBE_URL}}/g, buildUnsubscribeUrl(email))
|
||||
}
|
||||
|
||||
export function renderInvitationText(code: string) {
|
||||
export function renderInvitationText(code: string, email?: string) {
|
||||
const lines = [
|
||||
'Welcome to OpenSquawk!',
|
||||
'',
|
||||
@@ -56,22 +68,25 @@ export function renderInvitationText(code: string) {
|
||||
'',
|
||||
'Blue skies,',
|
||||
'Your OpenSquawk crew',
|
||||
'',
|
||||
'If you would rather not receive emails from us, you can opt out here:',
|
||||
buildUnsubscribeUrl(email),
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function renderFeedbackEmail() {
|
||||
export async function renderFeedbackEmail(email?: string) {
|
||||
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>`
|
||||
`<!DOCTYPE html><html><body><p>We'd love your feedback: <a href="https://opensquawk.de/feedback">opensquawk.de/feedback</a></p><p><a href="{{UNSUBSCRIBE_URL}}">Unsubscribe</a> from future emails</p></body></html>`
|
||||
)
|
||||
|
||||
return template
|
||||
return template.replace(/{{UNSUBSCRIBE_URL}}/g, buildUnsubscribeUrl(email))
|
||||
}
|
||||
|
||||
export function renderFeedbackText() {
|
||||
export function renderFeedbackText(email?: string) {
|
||||
const lines = [
|
||||
'Hi there,',
|
||||
'',
|
||||
@@ -81,6 +96,9 @@ export function renderFeedbackText() {
|
||||
'https://opensquawk.de/feedback',
|
||||
'',
|
||||
'Thank you for helping us improve OpenSquawk!',
|
||||
'',
|
||||
'If you do not want any more emails, you can unsubscribe here:',
|
||||
buildUnsubscribeUrl(email),
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
|
||||
Reference in New Issue
Block a user