feat(landing): Add newsletter double-opt-in
This commit is contained in:
170
landing/app/pages/newsletter-bestaetigt.vue
Normal file
170
landing/app/pages/newsletter-bestaetigt.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen pt-24 pb-16 flex items-center">
|
||||||
|
<!-- Background with gradient -->
|
||||||
|
<div class="fixed inset-0 -z-10">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-linear-to-br from-primary-50 via-cyan-50 to-accent-50 dark:from-gray-950 dark:via-primary-950/30 dark:to-accent-950/30"
|
||||||
|
/>
|
||||||
|
<!-- Gradient orbs -->
|
||||||
|
<div class="absolute top-20 left-1/4 w-96 h-96 bg-primary-400/20 rounded-full blur-3xl animate-orb-float" />
|
||||||
|
<div
|
||||||
|
class="absolute bottom-20 right-1/4 w-80 h-80 bg-accent-400/20 rounded-full blur-3xl animate-orb-float"
|
||||||
|
style="animation-delay: 3s"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<!-- Success card -->
|
||||||
|
<div class="glass rounded-3xl p-10 sm:p-14 shadow-2xl animate-fade-in-up">
|
||||||
|
<!-- Checkmark icon with animation -->
|
||||||
|
<div class="relative mb-8">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 mx-auto rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center animate-scale-in"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-check-circle" class="w-14 h-14 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<!-- Decorative rings -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 w-24 h-24 mx-auto rounded-full border-2 border-success-400/30 animate-ping-slow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1
|
||||||
|
class="font-heading text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4 animate-fade-in-up"
|
||||||
|
style="animation-delay: 200ms"
|
||||||
|
>
|
||||||
|
{{ $t('newsletterConfirmed.title') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p
|
||||||
|
class="text-lg text-gray-600 dark:text-gray-300 mb-8 animate-fade-in-up"
|
||||||
|
style="animation-delay: 300ms"
|
||||||
|
>
|
||||||
|
{{ $t('newsletterConfirmed.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<div class="animate-fade-in-up" style="animation-delay: 400ms">
|
||||||
|
<UButton
|
||||||
|
to="/"
|
||||||
|
size="xl"
|
||||||
|
class="btn-gradient rounded-xl font-semibold shadow-lg shadow-primary-500/25 hover:shadow-primary-500/40 transition-shadow"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-home" class="w-5 h-5 mr-2" />
|
||||||
|
{{ $t('newsletterConfirmed.backToHome') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trust indicators -->
|
||||||
|
<div class="mt-8 flex flex-wrap justify-center gap-6 animate-fade-in-up" style="animation-delay: 500ms">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-shield-check" class="w-3.5 h-3.5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<span>{{ $t('newsletter.trust.gdpr') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-bell-off" class="w-3.5 h-3.5 text-success-600 dark:text-success-400" />
|
||||||
|
</div>
|
||||||
|
<span>{{ $t('newsletter.trust.noSpam') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
// SEO Meta
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('newsletterConfirmed.meta.title'),
|
||||||
|
description: () => t('newsletterConfirmed.meta.description'),
|
||||||
|
ogTitle: () => t('newsletterConfirmed.meta.title'),
|
||||||
|
ogDescription: () => t('newsletterConfirmed.meta.description'),
|
||||||
|
ogImage: '/og-image.png',
|
||||||
|
ogType: 'website',
|
||||||
|
twitterCard: 'summary_large_image',
|
||||||
|
robots: 'noindex, nofollow'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set language
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: () => locale.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Orb float animation */
|
||||||
|
@keyframes orb-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-orb-float {
|
||||||
|
animation: orb-float 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in up animation */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.6s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale in animation for checkmark */
|
||||||
|
@keyframes scale-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scale-in 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slow ping animation for decorative ring */
|
||||||
|
@keyframes ping-slow {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-ping-slow {
|
||||||
|
animation: ping-slow 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -289,8 +289,8 @@
|
|||||||
"description": "Erhalten Sie Updates zur Entwicklung der Web-App für die IT-Mitbestimmung und seien Sie unter den Ersten, die vom Release oder neuen Funktionen erfahren.",
|
"description": "Erhalten Sie Updates zur Entwicklung der Web-App für die IT-Mitbestimmung und seien Sie unter den Ersten, die vom Release oder neuen Funktionen erfahren.",
|
||||||
"placeholder": "Ihre E-Mail-Adresse",
|
"placeholder": "Ihre E-Mail-Adresse",
|
||||||
"submit": "Anmelden",
|
"submit": "Anmelden",
|
||||||
"submitted": "Angemeldet!",
|
"submitted": "E-Mail gesendet!",
|
||||||
"success": "Vielen Dank! Wir halten Sie auf dem Laufenden.",
|
"success": "Bitte bestätigen Sie Ihr Abonnement über den Link in der E-Mail, die wir Ihnen soeben gesendet haben.",
|
||||||
"privacyNote": "Mit der Anmeldung stimmen Sie unserer {link} zu. Wir versenden keinen Spam.",
|
"privacyNote": "Mit der Anmeldung stimmen Sie unserer {link} zu. Wir versenden keinen Spam.",
|
||||||
"privacyLink": "Datenschutzerklärung",
|
"privacyLink": "Datenschutzerklärung",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -303,6 +303,15 @@
|
|||||||
"noSpam": "Kein Spam"
|
"noSpam": "Kein Spam"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"newsletterConfirmed": {
|
||||||
|
"meta": {
|
||||||
|
"title": "Newsletter bestätigt – GremiumHub",
|
||||||
|
"description": "Vielen Dank für die Bestätigung Ihres Newsletter-Abonnements."
|
||||||
|
},
|
||||||
|
"title": "Abonnement bestätigt!",
|
||||||
|
"description": "Vielen Dank! Sie sind jetzt für unseren Newsletter angemeldet und erhalten Updates zur Entwicklung von GremiumHub.",
|
||||||
|
"backToHome": "Zur Startseite"
|
||||||
|
},
|
||||||
"expertAccess": {
|
"expertAccess": {
|
||||||
"badge": "Expertennetzwerk",
|
"badge": "Expertennetzwerk",
|
||||||
"title": "Externer Sachverstand – Kooperation mit Spezialkanzlei für Betriebsräte",
|
"title": "Externer Sachverstand – Kooperation mit Spezialkanzlei für Betriebsräte",
|
||||||
|
|||||||
@@ -289,8 +289,8 @@
|
|||||||
"description": "Receive updates on the development of GremiumHub and be among the first to learn about new features.",
|
"description": "Receive updates on the development of GremiumHub and be among the first to learn about new features.",
|
||||||
"placeholder": "Your email address",
|
"placeholder": "Your email address",
|
||||||
"submit": "Subscribe",
|
"submit": "Subscribe",
|
||||||
"submitted": "Subscribed!",
|
"submitted": "Email sent!",
|
||||||
"success": "Thank you! We'll keep you updated.",
|
"success": "Please confirm your subscription via the link in the email we just sent you.",
|
||||||
"privacyNote": "By subscribing, you agree to our {link}. We don't send spam.",
|
"privacyNote": "By subscribing, you agree to our {link}. We don't send spam.",
|
||||||
"privacyLink": "Privacy Policy",
|
"privacyLink": "Privacy Policy",
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -303,6 +303,15 @@
|
|||||||
"noSpam": "No spam"
|
"noSpam": "No spam"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"newsletterConfirmed": {
|
||||||
|
"meta": {
|
||||||
|
"title": "Newsletter Confirmed – GremiumHub",
|
||||||
|
"description": "Thank you for confirming your newsletter subscription."
|
||||||
|
},
|
||||||
|
"title": "Subscription Confirmed!",
|
||||||
|
"description": "Thank you! You are now subscribed to our newsletter and will receive updates on the development of GremiumHub.",
|
||||||
|
"backToHome": "Back to Home"
|
||||||
|
},
|
||||||
"expertAccess": {
|
"expertAccess": {
|
||||||
"badge": "Expert Network",
|
"badge": "Expert Network",
|
||||||
"title": "External Expertise – Directly from the Process",
|
"title": "External Expertise – Directly from the Process",
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ export default defineNuxtConfig({
|
|||||||
brevoSenderEmail: 'NOT_SET',
|
brevoSenderEmail: 'NOT_SET',
|
||||||
brevoSenderName: 'NOT_SET',
|
brevoSenderName: 'NOT_SET',
|
||||||
brevoContactEmail: 'NOT_SET',
|
brevoContactEmail: 'NOT_SET',
|
||||||
brevoNewsletterListId: 'NOT_SET'
|
brevoNewsletterListId: 'NOT_SET',
|
||||||
|
brevoDoiTemplateId: 'NOT_SET',
|
||||||
|
public: {
|
||||||
|
siteUrl: 'NOT_SET'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Font configuration - Bricolage Grotesque for headings, DM Sans for body
|
// Font configuration - Bricolage Grotesque for headings, DM Sans for body
|
||||||
|
|||||||
@@ -29,29 +29,71 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if DOI template ID is configured
|
||||||
|
if (!config.brevoDoiTemplateId) {
|
||||||
|
console.error('BREVO_DOI_TEMPLATE_ID is not configured')
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Newsletter service is not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DOI_TEMPLATE_ID', config.brevoDoiTemplateId)
|
||||||
|
|
||||||
|
// Check if public site URL is configured (for DOI redirect)
|
||||||
|
if (!config.public.siteUrl) {
|
||||||
|
console.error('NUXT_PUBLIC_SITE_URL is not configured')
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Newsletter service is not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if newsletter list ID is configured
|
||||||
|
if (!config.brevoNewsletterListId) {
|
||||||
|
console.error('BREVO_NEWSLETTER_LIST_ID is not configured')
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Newsletter service is not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = parseInt(config.brevoDoiTemplateId, 10)
|
||||||
|
if (isNaN(templateId)) {
|
||||||
|
console.error('BREVO_DOI_TEMPLATE_ID is not a valid number')
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Newsletter service is not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const listId = parseInt(config.brevoNewsletterListId, 10)
|
||||||
|
if (isNaN(listId)) {
|
||||||
|
console.error('BREVO_NEWSLETTER_LIST_ID is not a valid number')
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Newsletter service is not configured'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the redirection URL to the confirmation page
|
||||||
|
const redirectionUrl = `${config.public.siteUrl}/newsletter-bestaetigt`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build request body for Brevo Contacts API
|
// Build request body for Brevo Double Opt-In API
|
||||||
const brevoBody: {
|
const brevoBody = {
|
||||||
email: string
|
|
||||||
updateEnabled: boolean
|
|
||||||
listIds?: number[]
|
|
||||||
} = {
|
|
||||||
email,
|
email,
|
||||||
updateEnabled: true
|
templateId,
|
||||||
|
redirectionUrl,
|
||||||
|
includeListIds: [listId]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add list ID if configured
|
console.log('Brevo DOI request body:', brevoBody)
|
||||||
if (config.brevoNewsletterListId) {
|
|
||||||
const listId = parseInt(config.brevoNewsletterListId, 10)
|
|
||||||
if (!isNaN(listId)) {
|
|
||||||
brevoBody.listIds = [listId]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await $fetch('https://api.brevo.com/v3/contacts', {
|
await $fetch('https://api.brevo.com/v3/contacts/doubleOptinConfirmation', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
accept: 'application/json',
|
||||||
'api-key': config.brevoApiKey,
|
'api-key': config.brevoApiKey,
|
||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
},
|
},
|
||||||
@@ -59,18 +101,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true
|
||||||
id: (response as { id?: number })?.id
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle Brevo API errors
|
// Handle Brevo API errors
|
||||||
const fetchError = error as { statusCode?: number; data?: { code?: string; message?: string } }
|
const fetchError = error as { statusCode?: number; data?: { code?: string; message?: string } }
|
||||||
|
|
||||||
// Contact already exists (duplicate_parameter error) - treat as success
|
// Contact already exists (duplicate_parameter error) - treat as success
|
||||||
|
// User will receive another confirmation email
|
||||||
if (fetchError.data?.code === 'duplicate_parameter') {
|
if (fetchError.data?.code === 'duplicate_parameter') {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Already subscribed'
|
message: 'Confirmation email sent'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user