Revise landing messaging and add news feed

This commit is contained in:
Remi
2025-09-17 15:44:41 +02:00
parent 6882a93417
commit e6755d2042
17 changed files with 1286 additions and 117 deletions

View File

@@ -0,0 +1,60 @@
import { readBody, createError } from 'h3'
import { RoadmapSuggestion } from '../../models/RoadmapSuggestion'
import { sendAdminNotification } from '../../utils/notifications'
interface RoadmapSuggestionBody {
title?: string
details?: string
email?: string
allowContact?: boolean
consentPrivacy?: boolean
}
const MIN_TITLE_LENGTH = 4
const MIN_DETAILS_LENGTH = 20
export default defineEventHandler(async (event) => {
const body = await readBody<RoadmapSuggestionBody>(event)
const title = body.title?.trim()
const details = body.details?.trim()
const email = body.email?.trim().toLowerCase()
const allowContact = Boolean(body.allowContact && email)
if (!title || title.length < MIN_TITLE_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
}
if (!details || details.length < MIN_DETAILS_LENGTH) {
throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
}
if (body.allowContact && !email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
}
const suggestion = await RoadmapSuggestion.create({
title,
details,
email,
allowContact,
consentPrivacy: true,
})
const lines = [
`Titel: ${title}`,
`Beschreibung: ${details}`,
`Kontakt: ${email || '—'}`,
allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht',
]
await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n'))
return {
success: true,
suggestionId: suggestion._id.toString(),
}
})

View File

@@ -0,0 +1,52 @@
import { readBody, createError } from 'h3'
import { registerUpdateSubscriber } from '../../utils/subscribers'
import { sendAdminNotification } from '../../utils/notifications'
interface UpdatesRequestBody {
email?: string
name?: string
consentPrivacy?: boolean
consentMarketing?: boolean
source?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<UpdatesRequestBody>(event)
const email = body.email?.trim().toLowerCase()
const name = body.name?.trim()
const source = body.source?.trim() || 'landing-updates'
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
}
if (!body.consentPrivacy) {
throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
}
if (!body.consentMarketing) {
throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
}
const result = await registerUpdateSubscriber({
email,
name,
source,
consentPrivacy: true,
consentMarketing: true,
})
if (result.created) {
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
`Quelle: ${source}`,
].filter(Boolean)
await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n'))
}
return {
success: true,
alreadySubscribed: !result.created,
}
})

View File

@@ -1,5 +1,7 @@
import { readBody, createError } from 'h3'
import { WaitlistEntry } from '../../models/WaitlistEntry'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
interface WaitlistRequestBody {
email?: string
@@ -8,6 +10,7 @@ interface WaitlistRequestBody {
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
wantsProductUpdates?: boolean
}
export default defineEventHandler(async (event) => {
@@ -16,6 +19,7 @@ export default defineEventHandler(async (event) => {
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
@@ -30,12 +34,41 @@ export default defineEventHandler(async (event) => {
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = source
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
existing.wantsProductUpdates = true
existing.updatesOptedInAt = now
}
await existing.save()
if (wantsProductUpdates) {
const updateResult = await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
if (!previouslyWantedUpdates && updateResult.created) {
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
notes ? `Notizen: ${notes}` : null,
`Quelle: ${source}`,
].filter(Boolean)
await sendAdminNotification(
'[OpenSquawk] Neue Updates-Liste (via Warteliste)',
`${lines.join('\n')}\nOpt-In: Produkt-Updates`,
)
}
}
return {
success: true,
alreadyJoined: true,
@@ -51,8 +84,29 @@ export default defineEventHandler(async (event) => {
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
wantsProductUpdates,
updatesOptedInAt: wantsProductUpdates ? now : undefined,
})
if (wantsProductUpdates) {
await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
}
const lines = [
`E-Mail: ${email}`,
name ? `Name: ${name}` : null,
notes ? `Notizen: ${notes}` : null,
`Quelle: ${source}`,
wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste',
].filter(Boolean)
await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n'))
return {
success: true,
alreadyJoined: false,

View File

@@ -0,0 +1,25 @@
import mongoose from 'mongoose'
export interface RoadmapSuggestionDocument extends mongoose.Document {
title: string
details: string
email?: string
allowContact: boolean
consentPrivacy: boolean
submittedAt: Date
}
const roadmapSuggestionSchema = new mongoose.Schema<RoadmapSuggestionDocument>({
title: { type: String, required: true, trim: true, maxlength: 160 },
details: { type: String, required: true, trim: true, maxlength: 4000 },
email: { type: String, trim: true, lowercase: true },
allowContact: { type: Boolean, default: false },
consentPrivacy: { type: Boolean, default: false },
submittedAt: { type: Date, default: () => new Date() },
})
roadmapSuggestionSchema.index({ submittedAt: -1 })
export const RoadmapSuggestion =
(mongoose.models.RoadmapSuggestion as mongoose.Model<RoadmapSuggestionDocument> | undefined) ||
mongoose.model<RoadmapSuggestionDocument>('RoadmapSuggestion', roadmapSuggestionSchema)

View File

@@ -0,0 +1,32 @@
import mongoose from 'mongoose'
export interface UpdateSubscriberDocument extends mongoose.Document {
email: string
name?: string
source: string
consentPrivacy: boolean
consentMarketing: boolean
subscribedAt: Date
lastUpdatedAt: Date
}
const updateSubscriberSchema = new mongoose.Schema<UpdateSubscriberDocument>({
email: { type: String, required: true, lowercase: true, trim: true, unique: true },
name: { type: String, trim: true },
source: { type: String, default: 'landing-updates' },
consentPrivacy: { type: Boolean, default: false },
consentMarketing: { type: Boolean, default: false },
subscribedAt: { type: Date, default: () => new Date() },
lastUpdatedAt: { type: Date, default: () => new Date() },
})
updateSubscriberSchema.pre('save', function updateTimestamp(this: UpdateSubscriberDocument, next) {
if (this.isModified()) {
this.set('lastUpdatedAt', new Date())
}
next()
})
export const UpdateSubscriber =
(mongoose.models.UpdateSubscriber as mongoose.Model<UpdateSubscriberDocument> | undefined) ||
mongoose.model<UpdateSubscriberDocument>('UpdateSubscriber', updateSubscriberSchema)

View File

@@ -9,6 +9,8 @@ export interface WaitlistEntryDocument extends mongoose.Document {
consentTerms: boolean
joinedAt: Date
activatedAt?: Date
wantsProductUpdates?: boolean
updatesOptedInAt?: Date
}
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
@@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
consentTerms: { type: Boolean, default: false },
joinedAt: { type: Date, default: () => new Date() },
activatedAt: { type: Date },
wantsProductUpdates: { type: Boolean, default: false },
updatesOptedInAt: { type: Date },
})
export const WaitlistEntry =

View File

@@ -0,0 +1,103 @@
const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
async function sendViaResend(subject: string, text: string) {
const apiKey = process.env.NOTIFY_RESEND_API_KEY
if (!apiKey) {
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const response = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to,
subject,
text,
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
console.error('Failed to send notification via Resend API', response.status, errorText)
return false
}
return true
} catch (error) {
console.error('Error while sending notification via Resend API', error)
return false
}
}
async function sendViaSmtp(subject: string, text: string) {
const host = process.env.NOTIFY_SMTP_HOST
if (!host) {
return false
}
const user = process.env.NOTIFY_SMTP_USER
const pass = process.env.NOTIFY_SMTP_PASS
if (!user || !pass) {
console.warn('SMTP notification is configured without credentials skipping send.')
return false
}
const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
const secure = process.env.NOTIFY_SMTP_SECURE === 'true'
let nodemailer: any = null
try {
const module = await import('nodemailer')
nodemailer = module.default ?? module
} catch (error) {
console.warn('nodemailer is not available. Skipping SMTP notification.', error)
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const transporter = nodemailer.createTransport({
host,
port: Number.isNaN(port) ? secure ? 465 : 587 : port,
secure,
auth: {
user,
pass,
},
})
await transporter.sendMail({ from, to, subject, text })
return true
} catch (error) {
console.error('Failed to send notification via SMTP', error)
return false
}
}
export async function sendAdminNotification(subject: string, text: string) {
const sentViaResend = await sendViaResend(subject, text)
if (sentViaResend) {
return true
}
const sentViaSmtp = await sendViaSmtp(subject, text)
if (sentViaSmtp) {
return true
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
return false
}

View File

@@ -0,0 +1,38 @@
import { UpdateSubscriber, UpdateSubscriberDocument } from '../models/UpdateSubscriber'
interface RegisterSubscriberOptions {
email: string
name?: string
source?: string
consentPrivacy: boolean
consentMarketing: boolean
}
export async function registerUpdateSubscriber(options: RegisterSubscriberOptions) {
const { email, name, source = 'landing-updates', consentPrivacy, consentMarketing } = options
const now = new Date()
const existing = await UpdateSubscriber.findOne({ email })
if (existing) {
existing.name = name || existing.name
existing.source = source || existing.source
existing.consentPrivacy = existing.consentPrivacy || consentPrivacy
existing.consentMarketing = existing.consentMarketing || consentMarketing
existing.lastUpdatedAt = now
await existing.save()
return { created: false, document: existing as UpdateSubscriberDocument }
}
const document = await UpdateSubscriber.create({
email,
name,
source,
consentPrivacy,
consentMarketing,
subscribedAt: now,
lastUpdatedAt: now,
})
return { created: true, document }
}