feat(landing): Add kontakt, datenschutz pages, integrate brevo newsletter and contact, add error-handler
This commit is contained in:
@@ -20,12 +20,12 @@
|
||||
<template #title>
|
||||
<NuxtLink to="/" class="flex items-center gap-2 sm:gap-3 group">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-primary-500/25 group-hover:shadow-primary-500/40 transition-shadow"
|
||||
class="w-10 h-10 rounded-xl bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-primary-500/25 group-hover:shadow-primary-500/40 transition-shadow"
|
||||
>
|
||||
<UIcon name="i-lucide-scale" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span
|
||||
class="hidden sm:inline font-heading text-xl font-bold bg-gradient-to-r from-primary-600 to-cyan-600 bg-clip-text text-transparent"
|
||||
class="hidden sm:inline font-heading text-xl font-bold bg-linear-to-r from-primary-600 to-cyan-600 bg-clip-text text-transparent"
|
||||
>
|
||||
GremiumHub
|
||||
</span>
|
||||
@@ -62,7 +62,7 @@
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
to="#newsletter"
|
||||
to="/#newsletter"
|
||||
class="hidden sm:flex btn-gradient px-5 py-2.5 rounded-xl font-semibold shadow-lg shadow-primary-500/25 hover:shadow-primary-500/40 transition-shadow"
|
||||
>
|
||||
<span>{{ $t('common.stayInformed') }}</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<div class="md:col-span-2">
|
||||
<NuxtLink to="/" class="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center"
|
||||
class="w-10 h-10 rounded-xl bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-scale" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -190,12 +190,14 @@ const { t, locale, locales, setLocale } = useI18n()
|
||||
|
||||
// Locale dropdown menu items
|
||||
const localeMenuItems = computed<DropdownMenuItem[]>(() =>
|
||||
(locales.value as Array<{ code: string; name: string }>).map((l) => ({
|
||||
label: l.name,
|
||||
icon: locale.value === l.code ? 'i-lucide-check' : undefined,
|
||||
active: locale.value === l.code,
|
||||
onSelect: () => setLocale(l.code as 'en' | 'de')
|
||||
}))
|
||||
(locales.value as Array<{ code: string; name: string }>)
|
||||
.map((l) => ({
|
||||
label: l.name,
|
||||
icon: locale.value === l.code ? 'i-lucide-check' : undefined,
|
||||
active: locale.value === l.code,
|
||||
onSelect: () => setLocale(l.code as 'en' | 'de')
|
||||
}))
|
||||
.reverse()
|
||||
)
|
||||
|
||||
// Track scroll position for header styling
|
||||
@@ -237,7 +239,7 @@ const navigationItems = computed<NavigationMenuItem[]>(() => [
|
||||
},
|
||||
{
|
||||
label: t('nav.contact'),
|
||||
to: '/#kontakt',
|
||||
to: '/kontakt',
|
||||
active: false
|
||||
}
|
||||
])
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
>
|
||||
<!-- Gradient border on hover -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl bg-gradient-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-0 group-hover:opacity-100 transition-opacity -z-10"
|
||||
class="absolute inset-0 rounded-2xl bg-linear-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-0 group-hover:opacity-100 transition-opacity -z-10"
|
||||
/>
|
||||
<div class="absolute inset-[2px] rounded-[14px] bg-white dark:bg-gray-900 -z-10" />
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- Icon with gradient background -->
|
||||
<div class="relative shrink-0">
|
||||
<div
|
||||
class="w-14 h-14 rounded-2xl bg-gradient-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
class="w-14 h-14 rounded-2xl bg-linear-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<UIcon :name="feature.icon" class="w-7 h-7 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-warning-500 to-orange-500 flex items-center justify-center shadow-lg shadow-warning-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-warning-500 to-orange-500 flex items-center justify-center shadow-lg shadow-warning-500/25"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-success-500 to-emerald-500 flex items-center justify-center shadow-lg shadow-success-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-success-500 to-emerald-500 flex items-center justify-center shadow-lg shadow-success-500/25"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@
|
||||
class="h-full p-6 rounded-2xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-xl hover:shadow-accent-500/10 hover:border-accent-200 dark:hover:border-accent-700 transition-all"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-500 to-violet-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform shadow-lg shadow-accent-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-accent-500 to-violet-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform shadow-lg shadow-accent-500/25"
|
||||
>
|
||||
<UIcon :name="highlight.icon" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-warning-500 to-orange-500 flex items-center justify-center shadow-lg shadow-warning-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-warning-500 to-orange-500 flex items-center justify-center shadow-lg shadow-warning-500/25"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-success-500 to-emerald-500 flex items-center justify-center shadow-lg shadow-success-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-success-500 to-emerald-500 flex items-center justify-center shadow-lg shadow-success-500/25"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@
|
||||
class="h-full p-6 rounded-2xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-xl hover:shadow-primary-500/10 hover:border-primary-200 dark:hover:border-primary-700 transition-all"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform shadow-lg shadow-primary-500/25"
|
||||
class="w-12 h-12 rounded-2xl bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform shadow-lg shadow-primary-500/25"
|
||||
>
|
||||
<UIcon :name="highlight.icon" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="absolute inset-0 mesh-gradient-cta" />
|
||||
|
||||
<!-- Animated gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-600/90 via-cyan-600/90 to-accent-600/90" />
|
||||
<div class="absolute inset-0 bg-linear-to-br from-primary-600/90 via-cyan-600/90 to-accent-600/90" />
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<div class="absolute inset-0 grid-pattern opacity-10" />
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
:style="{ animationDelay: `${index * 100}ms` }"
|
||||
>
|
||||
<div
|
||||
class="mt-1 w-6 h-6 rounded-full bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center shrink-0"
|
||||
class="mt-1 w-6 h-6 rounded-full bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
@@ -44,11 +44,11 @@
|
||||
|
||||
<!-- Price info -->
|
||||
<div
|
||||
class="p-4 rounded-xl bg-gradient-to-r from-primary-50 to-cyan-50 dark:from-primary-950/50 dark:to-cyan-950/50 border border-primary-200 dark:border-primary-800"
|
||||
class="p-4 rounded-xl bg-linear-to-r from-primary-50 to-cyan-50 dark:from-primary-950/50 dark:to-cyan-950/50 border border-primary-200 dark:border-primary-800"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center shrink-0"
|
||||
class="w-10 h-10 rounded-xl bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<UIcon name="i-lucide-tag" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@
|
||||
class="relative bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-2xl overflow-hidden w-full max-w-[320px] sm:max-w-sm"
|
||||
>
|
||||
<!-- Document header -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-cyan-600 px-6 py-5">
|
||||
<div class="bg-linear-to-r from-primary-600 to-cyan-600 px-6 py-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
||||
<UIcon name="i-lucide-file-text" class="w-6 h-6 text-white" />
|
||||
@@ -123,7 +123,7 @@
|
||||
:style="{ animationDelay: `${index * 100}ms` }"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center text-sm font-bold text-primary-600 dark:text-primary-400 group-hover:scale-110 transition-transform"
|
||||
class="w-8 h-8 rounded-lg bg-linear-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center text-sm font-bold text-primary-600 dark:text-primary-400 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@
|
||||
<!-- Floating badge - hidden on very small screens to prevent overflow -->
|
||||
<div class="absolute -top-4 right-0 sm:-right-4 z-10 animate-float-fast">
|
||||
<div
|
||||
class="bg-gradient-to-r from-accent-500 to-violet-500 text-white px-3 sm:px-4 py-2 rounded-full shadow-lg text-xs sm:text-sm font-semibold flex items-center gap-2"
|
||||
class="bg-linear-to-r from-accent-500 to-violet-500 text-white px-3 sm:px-4 py-2 rounded-full shadow-lg text-xs sm:text-sm font-semibold flex items-center gap-2"
|
||||
>
|
||||
<UIcon name="i-lucide-download" class="w-4 h-4" />
|
||||
{{ $t('common.onRequest') }}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
>
|
||||
<div class="glass rounded-xl px-4 py-3 shadow-lg flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-success-400 to-success-500 flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-full bg-linear-to-br from-success-400 to-success-500 flex items-center justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-sparkles" class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section id="newsletter" class="relative py-16 lg:py-20 overflow-hidden scroll-mt-20">
|
||||
<!-- Background with gradient and particles -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-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"
|
||||
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"
|
||||
/>
|
||||
|
||||
<!-- Animated particles -->
|
||||
@@ -31,7 +31,7 @@
|
||||
<!-- Icon -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center animate-float-slow shadow-lg shadow-primary-500/25"
|
||||
class="w-16 h-16 rounded-2xl bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center animate-float-slow shadow-lg shadow-primary-500/25"
|
||||
>
|
||||
<UIcon name="i-lucide-mail" class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<section
|
||||
class="py-10 lg:py-12 bg-gradient-to-r from-primary-50 via-cyan-50 to-accent-50 dark:from-primary-950/30 dark:via-cyan-950/30 dark:to-accent-950/30"
|
||||
class="py-10 lg:py-12 bg-linear-to-r from-primary-50 via-cyan-50 to-accent-50 dark:from-primary-950/30 dark:via-cyan-950/30 dark:to-accent-950/30"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Stats grid -->
|
||||
@@ -39,7 +39,7 @@
|
||||
:style="{ animationDelay: `${(index + 4) * 100}ms` }"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-gradient-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-full bg-linear-to-br from-primary-100 to-cyan-100 dark:from-primary-900/50 dark:to-cyan-900/50 flex items-center justify-center"
|
||||
>
|
||||
<UIcon :name="badge.icon" class="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<!-- Profile image with gradient border -->
|
||||
<div class="relative mb-6">
|
||||
<div
|
||||
class="absolute -inset-1 rounded-full bg-gradient-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-75 group-hover:opacity-100 blur-sm transition-opacity"
|
||||
class="absolute -inset-1 rounded-full bg-linear-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-75 group-hover:opacity-100 blur-sm transition-opacity"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
@@ -105,7 +105,7 @@
|
||||
<!-- Profile image with gradient border -->
|
||||
<div class="relative mb-6">
|
||||
<div
|
||||
class="absolute -inset-1 rounded-full bg-gradient-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-75 group-hover:opacity-100 blur-sm transition-opacity"
|
||||
class="absolute -inset-1 rounded-full bg-linear-to-br from-primary-500 via-cyan-500 to-accent-500 opacity-75 group-hover:opacity-100 blur-sm transition-opacity"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-cyan-500 flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-lg bg-linear-to-br from-primary-500 to-cyan-500 flex items-center justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-file-text" class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
<span class="text-primary-600 dark:text-primary-400 font-semibold">67%</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full w-[67%] bg-gradient-to-r from-primary-500 to-cyan-500 rounded-full" />
|
||||
<div class="h-full w-[67%] bg-linear-to-r from-primary-500 to-cyan-500 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
<!-- Action button -->
|
||||
<button
|
||||
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-primary-500 to-cyan-500 text-white font-semibold text-sm hover:from-primary-600 hover:to-cyan-600 transition-all shadow-lg shadow-primary-500/25"
|
||||
class="w-full py-3 px-4 rounded-xl bg-linear-to-r from-primary-500 to-cyan-500 text-white font-semibold text-sm hover:from-primary-600 hover:to-cyan-600 transition-all shadow-lg shadow-primary-500/25"
|
||||
>
|
||||
{{ $t('hero.cards.continueEditing') }}
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<div :class="compact ? 'flex items-center gap-2 mb-3' : 'flex items-center gap-3 mb-4'">
|
||||
<div
|
||||
class="bg-gradient-to-br from-success-400 to-success-500 flex items-center justify-center shrink-0"
|
||||
class="bg-linear-to-br from-success-400 to-success-500 flex items-center justify-center shrink-0"
|
||||
:class="compact ? 'w-8 h-8 rounded-lg' : 'w-10 h-10 rounded-xl'"
|
||||
>
|
||||
<UIcon name="i-lucide-check-circle" :class="compact ? 'w-4 h-4' : 'w-5 h-5'" class="text-white" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<div :class="compact ? 'flex items-center gap-2 mb-3' : 'flex items-center gap-3 mb-4'">
|
||||
<div
|
||||
class="bg-gradient-to-br from-warning-400 to-warning-500 flex items-center justify-center shrink-0"
|
||||
class="bg-linear-to-br from-warning-400 to-warning-500 flex items-center justify-center shrink-0"
|
||||
:class="compact ? 'w-8 h-8 rounded-lg' : 'w-10 h-10 rounded-xl'"
|
||||
>
|
||||
<UIcon name="i-lucide-alert-triangle" :class="compact ? 'w-4 h-4' : 'w-5 h-5'" class="text-white" />
|
||||
|
||||
46
landing/app/composables/useContactForm.ts
Normal file
46
landing/app/composables/useContactForm.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface ContactFormData {
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function useContactForm() {
|
||||
const { t } = useI18n()
|
||||
const isLoading = ref(false)
|
||||
const isSuccess = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const submitForm = async (data: ContactFormData) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await $fetch('/api/contact/send', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
|
||||
isSuccess.value = true
|
||||
} catch (e: unknown) {
|
||||
const fetchError = e as { statusMessage?: string }
|
||||
error.value = fetchError.statusMessage || t('errors.generic')
|
||||
throw e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
isLoading.value = false
|
||||
isSuccess.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isSuccess,
|
||||
error,
|
||||
submitForm,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,20 @@ export function useNewsletterSignup() {
|
||||
const isSuccess = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const submitEmail = async (_email: string) => {
|
||||
const submitEmail = async (email: string) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Simulate API call - replace with actual newsletter service integration
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// TODO: Integrate with external newsletter service (e.g., Mailchimp, ConvertKit, etc.)
|
||||
// Example:
|
||||
// await $fetch('/api/newsletter/subscribe', {
|
||||
// method: 'POST',
|
||||
// body: { email }
|
||||
// })
|
||||
await $fetch('/api/newsletter/subscribe', {
|
||||
method: 'POST',
|
||||
body: { email }
|
||||
})
|
||||
|
||||
isSuccess.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : t('errors.generic')
|
||||
const fetchError = e as { statusMessage?: string }
|
||||
error.value = fetchError.statusMessage || t('errors.generic')
|
||||
throw e
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
329
landing/app/pages/datenschutz.vue
Normal file
329
landing/app/pages/datenschutz.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="min-h-screen pt-32 pb-16">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-linear-to-br from-primary-50/50 via-transparent to-cyan-50/50 dark:from-primary-950/30 dark:to-cyan-950/30" />
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="font-heading text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Datenschutzerklärung
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||
Datenschutzerklärung (DSGVO) für gremiumhub.de
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
Stand: 10.01.2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="prose prose-gray dark:prose-invert max-w-none">
|
||||
<!-- 1. Verantwortlicher -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
1. Verantwortlicher
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Verantwortlicher im Sinne der Datenschutz-Grundverordnung (DSGVO) ist:
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-0">
|
||||
<strong class="text-gray-900 dark:text-white">Raphael Lugowski und Denis Lugowski – GremiumHub GbR</strong><br />
|
||||
Brooksheide 4a, 22549 Hamburg, Deutschland<br />
|
||||
E-Mail: <a href="mailto:kontakt@gremiumhub.de" class="text-primary-600 dark:text-primary-400 hover:underline">kontakt@gremiumhub.de</a><br />
|
||||
Telefon: <a href="tel:+4917647028443" class="text-primary-600 dark:text-primary-400 hover:underline">+49 176 47028443</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Allgemeine Hinweise -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
2. Allgemeine Hinweise zur Datenverarbeitung
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Wir verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung einer funktionsfähigen Website, unserer Inhalte sowie zur Bearbeitung von Anfragen bzw. zur Durchführung des Newsletter-Versands erforderlich ist.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Rechtsgrundlagen</strong> (je nach Verarbeitung):
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Art. 6 Abs. 1 lit. <strong>b</strong> DSGVO (Vertrag / vorvertragliche Maßnahmen)</li>
|
||||
<li>Art. 6 Abs. 1 lit. <strong>f</strong> DSGVO (berechtigtes Interesse, z. B. technische Bereitstellung, IT-Sicherheit)</li>
|
||||
<li>Art. 6 Abs. 1 lit. <strong>a</strong> DSGVO (Einwilligung, z. B. Newsletter)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 3. Hosting -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
3. Hosting (netcup) & Server-Logfiles
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Unsere Website wird bei <strong class="text-gray-900 dark:text-white">netcup</strong> gehostet. Der Hosting-Anbieter verarbeitet personenbezogene Daten in unserem Auftrag als <strong class="text-gray-900 dark:text-white">Auftragsverarbeiter</strong>. Wir haben mit netcup einen Vertrag zur Auftragsverarbeitung (Art. 28 DSGVO) abgeschlossen.
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700 mb-6">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-0">
|
||||
<strong class="text-gray-900 dark:text-white">Hosting-Anbieter:</strong><br />
|
||||
netcup GmbH, Emmy-Noether-Straße 10, 76131 Karlsruhe, Deutschland
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
3.1 Server-Logfiles
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Beim Aufruf unserer Website werden durch den Hosting-Anbieter in unserem Auftrag sogenannte <strong class="text-gray-900 dark:text-white">Server-Logfiles</strong> verarbeitet. Diese Daten fallen technisch bedingt an und können z. B. enthalten:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1 mb-4">
|
||||
<li>IP-Adresse</li>
|
||||
<li>Datum und Uhrzeit des Zugriffs</li>
|
||||
<li>aufgerufene Seite/Datei</li>
|
||||
<li>Referrer-URL</li>
|
||||
<li>User-Agent (Browser/Betriebssystem)</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Zweck:</strong> Auslieferung der Website, Stabilität, Sicherheit (z. B. Missbrauchs-/Angriffserkennung).
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherem, stabilem Betrieb).
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="text-gray-900 dark:text-white">Speicherdauer:</strong> Server-Logfiles werden in der Regel nur so lange gespeichert, wie dies zur Sicherstellung des Betriebs und der Sicherheit erforderlich ist (bei netcup typischerweise bis maximal 14 Tage, abhängig von Produkt/Setup).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 4. Kontaktaufnahme -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
4. Kontaktaufnahme (E-Mail, Kontaktformular)
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Wenn Sie uns per E-Mail oder über ein Kontaktformular kontaktieren, verarbeiten wir die von Ihnen übermittelten Daten (z. B. Name, E-Mail-Adresse, Nachrichteninhalt) zur Bearbeitung Ihrer Anfrage.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Zweck:</strong> Bearbeitung und Beantwortung Ihrer Anfrage.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Rechtsgrundlage:</strong>
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1 mb-4">
|
||||
<li>Art. 6 Abs. 1 lit. b DSGVO (wenn Ihre Anfrage auf einen Vertrag / vorvertragliche Maßnahmen abzielt), sonst</li>
|
||||
<li>Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an Kommunikation).</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="text-gray-900 dark:text-white">Speicherdauer:</strong> so lange erforderlich zur Bearbeitung; anschließend Löschung nach Löschkonzept bzw. gesetzlichen Aufbewahrungspflichten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 5. Newsletter -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
5. Newsletter (Versand über Brevo) – inkl. Statistik
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Auf unserer Website können Sie sich zu unserem Newsletter anmelden, um Updates zur Entwicklung der Web-App zu erhalten.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Für die technische Umsetzung des Newsletters (Versand und Verwaltung) greifen wir auf die Lösungen des Anbieters Sendinblue GmbH (<strong class="text-gray-900 dark:text-white">Brevo</strong>) zurück. Brevo stellt die Einhaltung der EU-DSGVO bei der Verarbeitung von personenbezogenen Daten sicher. Näheres, insbesondere zum Verarbeitungszweck, entnehmen Sie bitte den folgenden Ziffern sowie auch der <a href="https://www.brevo.com/de/legal/privacypolicy/" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-400 hover:underline">Datenschutzerklärung der Sendinblue GmbH</a>.
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.1 Dienstleister (Auftragsverarbeiter)
|
||||
</h3>
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700 mb-6">
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-0">
|
||||
<strong class="text-gray-900 dark:text-white">Brevo / Sendinblue GmbH</strong> (Marke „Brevo")<br />
|
||||
Köpenicker Straße 126, 10179 Berlin, Deutschland
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Brevo verarbeitet die Daten in unserem Auftrag als Auftragsverarbeiter. Wir haben mit Brevo eine Vereinbarung zur Auftragsverarbeitung nach Art. 28 DSGVO abgeschlossen (Data Processing Agreement, DPA).
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.2 Welche Daten werden verarbeitet?
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-3 mb-6">
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">Pflichtangabe:</strong> E-Mail-Adresse<br />
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm ml-5">Zweck: Die Angabe der E-Mail-Adresse ist erforderlich, damit die Ausgaben des Newsletters den jeweiligen Empfängern auch zugehen.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">Freiwillige Angabe:</strong> Name (optional)<br />
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm ml-5">Zweck: Die Angabe des Vornamens und Nachnamens ist rein freiwillig, damit wir den Abonnenten im Sinne einer persönlicheren Kommunikation namentlich ansprechen können.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="text-gray-900 dark:text-white">Nachweisdaten</strong> im Rahmen des Double-Opt-In (z. B. Zeitpunkt der Anmeldung und Bestätigung; IP-Adresse), um die Einwilligung zu dokumentieren.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.3 Double-Opt-In
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Nach der Anmeldung erhalten Sie eine E-Mail, in der Sie die Anmeldung bestätigen. Erst danach werden Sie in den Verteiler aufgenommen.
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.4 Statistik/Tracking im Newsletter (Öffnungen & Klicks)
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Wir werten Newsletter-Kampagnen statistisch aus (z. B. Öffnungs- und Klickraten), um Inhalte zu optimieren. Diesbezüglich gilt Folgendes:
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Die Zahl der E-Mail-Öffnungen sowie der Klicks wird IP-bezogen durch unseren Anbieter Sendinblue GmbH (Brevo) nachverfolgt, damit wir beurteilen können, ob die versandten Newsletter von den eingetragenen Personen geöffnet bzw. weitergehende Inhalte angeklickt werden. Sollten wir feststellen, dass unsere Newsletter von Personen mehrfach nicht (mehr) geöffnet werden, ist es uns bzw. unserem Anbieter Sendinblue GmbH (Brevo) nur über die IP möglich, dies zu erkennen, sodass wir die entsprechenden Personen gemäß dem Grundsatz der Speicherbegrenzung aus dem Newsletter entfernen und ihre Daten löschen können. Weiterhin ist die Erfassung der IP notwendig, um feststellen zu können, ob die in dem Newsletter veröffentlichten Inhalte für unsere Abonnenten inhaltlich auch wirklich interessant sind und von ihnen eingesehen werden. Anhand der Öffnungsrate oder auch Klickrate, sofern weitere Inhalte in den Newslettern platziert werden, können wir die für unsere Abonnenten interessanten Themen bestimmen und unser Angebot entsprechend fortlaufend anpassen und verbessern.
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.5 Rechtsgrundlage, Widerruf
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
||||
<strong class="text-gray-900 dark:text-white">Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. a, 7 DSGVO (Einwilligung).
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
<strong class="text-gray-900 dark:text-white">Widerruf:</strong> Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen, z. B. über den Abmeldelink in jedem Newsletter.
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.6 Speicherort / Datenverarbeitung in der EU
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Brevo gibt an, dass die Hosting-Server und Datenbanken innerhalb der Europäischen Union betrieben werden.
|
||||
</p>
|
||||
|
||||
<h3 class="font-heading text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
5.7 Unterauftragsverarbeiter & mögliche Drittlandübermittlungen
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Brevo setzt Unterauftragsverarbeiter ein. Je nach eingesetzten Funktionen/Unterauftragsverarbeitern können Datenverarbeitungen auch außerhalb des EWR stattfinden. In solchen Fällen werden geeignete Garantien eingesetzt (z. B. EU-Standardvertragsklauseln und ggf. ergänzende Maßnahmen; für bestimmte US-Anbieter ggf. zusätzlich EU-US Data Privacy Framework, soweit anwendbar).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 6. Cookies -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
6. Cookies
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Wir verwenden <strong class="text-gray-900 dark:text-white">nur technisch notwendige Cookies</strong>, die für Betrieb und Sicherheit der Website erforderlich sind. Analyse- oder Marketing-Cookies setzen wir nicht ein.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 7. Download der Web-App -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
7. Download der Web-App
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Die Web-App wird nicht direkt über die Website betrieben, sondern gesondert heruntergeladen und installiert. Beim Herunterladen können – wie beim normalen Seitenaufruf – technische Zugriffsdaten (Server-Logfiles) anfallen (siehe Abschnitt „Hosting & Server-Logfiles").
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 8. Empfänger -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
8. Empfänger / Weitergabe von Daten
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Wir geben personenbezogene Daten nur weiter, wenn dies erforderlich ist, z. B. an:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li><strong class="text-gray-900 dark:text-white">netcup</strong> (Hosting als Auftragsverarbeiter)</li>
|
||||
<li><strong class="text-gray-900 dark:text-white">Brevo</strong> (Newsletter-Versand als Auftragsverarbeiter)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 9. Ihre Rechte -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
9. Ihre Rechte
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Sie haben das Recht auf:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Auskunft (Art. 15 DSGVO)</li>
|
||||
<li>Berichtigung (Art. 16 DSGVO)</li>
|
||||
<li>Löschung (Art. 17 DSGVO)</li>
|
||||
<li>Einschränkung der Verarbeitung (Art. 18 DSGVO)</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
|
||||
<li>Widerspruch gegen bestimmte Verarbeitungen (Art. 21 DSGVO)</li>
|
||||
<li>Widerruf erteilter Einwilligungen (Art. 7 Abs. 3 DSGVO)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 10. Beschwerderecht -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
10. Beschwerderecht bei der Aufsichtsbehörde
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (z. B. in dem Bundesland Ihres Aufenthalts oder unseres Unternehmenssitzes).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 11. SSL/TLS -->
|
||||
<section class="mb-10">
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
11. SSL-/TLS-Verschlüsselung
|
||||
</h2>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-xl p-4 border border-green-200 dark:border-green-800">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-shield-check" class="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 shrink-0" />
|
||||
<p class="text-green-800 dark:text-green-200 text-sm mb-0">
|
||||
Diese Website nutzt aus Sicherheitsgründen eine SSL-/TLS-Verschlüsselung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// SEO Meta
|
||||
useSeoMeta({
|
||||
title: 'Datenschutzerklärung – GremiumHub',
|
||||
description: 'Datenschutzerklärung (DSGVO) für gremiumhub.de – Informationen zur Verarbeitung Ihrer personenbezogenen Daten.',
|
||||
ogTitle: 'Datenschutzerklärung – GremiumHub',
|
||||
ogDescription: 'Datenschutzerklärung (DSGVO) für gremiumhub.de – Informationen zur Verarbeitung Ihrer personenbezogenen Daten.',
|
||||
ogImage: '/og-image.png',
|
||||
ogType: 'website',
|
||||
twitterCard: 'summary_large_image',
|
||||
robots: 'noindex, nofollow'
|
||||
})
|
||||
|
||||
// Structured data for SEO
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: 'de'
|
||||
},
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Datenschutzerklärung – GremiumHub',
|
||||
description: 'Datenschutzerklärung (DSGVO) für gremiumhub.de',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'GremiumHub GbR',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Brooksheide 4a',
|
||||
addressLocality: 'Hamburg',
|
||||
postalCode: '22549',
|
||||
addressCountry: 'DE'
|
||||
},
|
||||
email: 'kontakt@gremiumhub.de',
|
||||
telephone: '+4917647028443'
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="min-h-screen pt-32 pb-16">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-50/50 via-transparent to-cyan-50/50 dark:from-primary-950/30 dark:to-cyan-950/30" />
|
||||
<div class="absolute inset-0 bg-linear-to-br from-primary-50/50 via-transparent to-cyan-50/50 dark:from-primary-950/30 dark:to-cyan-950/30" />
|
||||
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="font-heading text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Impressum
|
||||
@@ -144,7 +144,7 @@
|
||||
</p>
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 rounded-xl p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-info" class="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<UIcon name="i-lucide-info" class="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p class="text-amber-800 dark:text-amber-200 text-sm mb-0">
|
||||
Hinweis EU-OS/ODR-Plattform: Die EU-Online-Streitbeilegungsplattform wurde zum 20.07.2025 eingestellt (Verordnung (EU) 2024/3228). Ein Link darauf ist daher nicht mehr erforderlich.
|
||||
</p>
|
||||
|
||||
299
landing/app/pages/kontakt.vue
Normal file
299
landing/app/pages/kontakt.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="min-h-screen pt-24 pb-16">
|
||||
<!-- 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-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header section -->
|
||||
<div class="text-center mb-12">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-sm font-medium mb-6"
|
||||
>
|
||||
<UIcon name="i-lucide-message-circle" class="w-4 h-4" />
|
||||
{{ $t('contact.badge') }}
|
||||
</span>
|
||||
|
||||
<!-- Title -->
|
||||
<h1
|
||||
class="font-heading text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white mb-6 animate-fade-in-up"
|
||||
>
|
||||
{{ $t('contact.title', { highlight: '' })
|
||||
}}<span class="gradient-text">{{ $t('contact.titleHighlight') }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto animate-fade-in-up"
|
||||
style="animation-delay: 100ms"
|
||||
>
|
||||
{{ $t('contact.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form section -->
|
||||
<div class="animate-fade-in-up" style="animation-delay: 150ms">
|
||||
<div class="glass rounded-3xl p-8 sm:p-10 shadow-2xl">
|
||||
<!-- Success state -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="isSuccess" class="text-center py-8">
|
||||
<div
|
||||
class="w-20 h-20 mx-auto mb-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-check-circle" class="w-10 h-10 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<h2 class="font-heading text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{{ $t('contact.success.title') }}
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-8">
|
||||
{{ $t('contact.success.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<UButton to="/" variant="outline" class="btn-outline-gradient">
|
||||
<UIcon name="i-lucide-home" class="w-4 h-4 mr-2" />
|
||||
{{ $t('contact.success.backToHome') }}
|
||||
</UButton>
|
||||
<UButton class="btn-gradient" @click="resetForm">
|
||||
<UIcon name="i-lucide-mail-plus" class="w-4 h-4 mr-2" />
|
||||
{{ $t('contact.success.sendAnother') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Form -->
|
||||
<UForm v-if="!isSuccess" :state="formState" :schema="schema" class="space-y-6" @submit="onSubmit">
|
||||
<!-- Name field -->
|
||||
<UFormField :label="$t('contact.form.name')" name="name" required class="w-full">
|
||||
<UInput
|
||||
v-model="formState.name"
|
||||
size="xl"
|
||||
:disabled="isLoading"
|
||||
autocomplete="name"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'w-full rounded-xl bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 focus:ring-2 focus:ring-primary-500 focus:border-transparent'
|
||||
}"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-user" class="w-5 h-5 text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<!-- Email field -->
|
||||
<UFormField :label="$t('contact.form.email')" name="email" required class="w-full">
|
||||
<UInput
|
||||
v-model="formState.email"
|
||||
type="email"
|
||||
size="xl"
|
||||
:disabled="isLoading"
|
||||
autocomplete="email"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'w-full rounded-xl bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 focus:ring-2 focus:ring-primary-500 focus:border-transparent'
|
||||
}"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-mail" class="w-5 h-5 text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<!-- Message field -->
|
||||
<UFormField :label="$t('contact.form.message')" name="message" required class="w-full">
|
||||
<UTextarea
|
||||
v-model="formState.message"
|
||||
:rows="6"
|
||||
:disabled="isLoading"
|
||||
class="w-full"
|
||||
size="xl"
|
||||
:ui="{
|
||||
base: 'w-full rounded-xl bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none'
|
||||
}"
|
||||
/>
|
||||
<template #hint>
|
||||
<span class="text-xs text-gray-400"> {{ formState.message.length }} / 5000 </span>
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<!-- Error message -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 rounded-xl bg-error-100 dark:bg-error-900/30 border border-error-200 dark:border-error-800"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-error-700 dark:text-error-300">
|
||||
<UIcon name="i-lucide-alert-circle" class="w-5 h-5 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Submit button -->
|
||||
<UButton
|
||||
type="submit"
|
||||
size="xl"
|
||||
block
|
||||
:loading="isLoading"
|
||||
class="btn-gradient rounded-xl font-semibold shadow-lg shadow-primary-500/25"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<span>{{ $t('contact.form.sending') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UIcon name="i-lucide-send" class="w-5 h-5 mr-2" />
|
||||
<span>{{ $t('contact.form.submit') }}</span>
|
||||
</template>
|
||||
</UButton>
|
||||
</UForm>
|
||||
</div>
|
||||
|
||||
<!-- Trust indicators -->
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-6 animate-fade-in-up" style="animation-delay: 300ms">
|
||||
<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('contact.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-lock" class="w-3.5 h-3.5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<span>{{ $t('contact.trust.encrypted') }}</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-clock" class="w-3.5 h-3.5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<span>{{ $t('contact.trust.responseTime') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import type { ContactFormData } from '~/composables/useContactForm'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { isLoading, isSuccess, error, submitForm, reset } = useContactForm()
|
||||
|
||||
// SEO Meta
|
||||
useSeoMeta({
|
||||
title: () => t('contact.meta.title'),
|
||||
description: () => t('contact.meta.description'),
|
||||
ogTitle: () => t('contact.meta.title'),
|
||||
ogDescription: () => t('contact.meta.description'),
|
||||
ogImage: '/og-image.png',
|
||||
ogType: 'website',
|
||||
twitterCard: 'summary_large_image'
|
||||
})
|
||||
|
||||
// Structured data
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: () => locale.value
|
||||
},
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ContactPage',
|
||||
name: t('contact.meta.title'),
|
||||
description: t('contact.meta.description')
|
||||
})
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Form validation schema
|
||||
const schema = computed(() =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('contact.validation.nameRequired')).max(100, t('contact.validation.nameMax')),
|
||||
email: z.string().min(1, t('contact.validation.emailRequired')).email(t('contact.validation.emailInvalid')),
|
||||
message: z.string().min(10, t('contact.validation.messageMin')).max(5000, t('contact.validation.messageMax'))
|
||||
})
|
||||
)
|
||||
|
||||
// Form state
|
||||
const formState = reactive<ContactFormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
// Submit handler
|
||||
async function onSubmit(event: FormSubmitEvent<ContactFormData>) {
|
||||
await submitForm(event.data)
|
||||
}
|
||||
|
||||
// Reset form
|
||||
function resetForm() {
|
||||
formState.name = ''
|
||||
formState.email = ''
|
||||
formState.message = ''
|
||||
reset()
|
||||
}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -329,6 +329,48 @@
|
||||
"errors": {
|
||||
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut."
|
||||
},
|
||||
"contact": {
|
||||
"meta": {
|
||||
"title": "Kontakt – GremiumHub",
|
||||
"description": "Nehmen Sie Kontakt mit uns auf. Wir freuen uns auf Ihre Nachricht und antworten zeitnah."
|
||||
},
|
||||
"badge": "Kontakt",
|
||||
"title": "Sprechen Sie mit {highlight}",
|
||||
"titleHighlight": "uns",
|
||||
"description": "Haben Sie Fragen zu GremiumHub oder möchten Sie mehr über unsere Lösung erfahren? Schreiben Sie uns – wir melden uns zeitnah bei Ihnen.",
|
||||
"form": {
|
||||
"name": "Ihr Name",
|
||||
"email": "E-Mail-Adresse",
|
||||
"message": "Ihre Nachricht",
|
||||
"submit": "Nachricht senden",
|
||||
"sending": "Wird gesendet..."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||
"nameMax": "Der Name darf maximal 100 Zeichen lang sein",
|
||||
"emailRequired": "Bitte geben Sie eine E-Mail-Adresse ein",
|
||||
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"messageRequired": "Bitte geben Sie eine Nachricht ein",
|
||||
"messageMin": "Die Nachricht muss mindestens 10 Zeichen lang sein",
|
||||
"messageMax": "Die Nachricht darf maximal 5000 Zeichen lang sein"
|
||||
},
|
||||
"success": {
|
||||
"title": "Nachricht gesendet!",
|
||||
"message": "Vielen Dank für Ihre Nachricht. Wir werden uns schnellstmöglich bei Ihnen melden.",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"sendAnother": "Weitere Nachricht senden"
|
||||
},
|
||||
"trust": {
|
||||
"gdpr": "DSGVO-konform",
|
||||
"encrypted": "Verschlüsselt",
|
||||
"responseTime": "Antwort innerhalb von 24h"
|
||||
},
|
||||
"alternativeContact": {
|
||||
"title": "Oder direkt erreichen",
|
||||
"email": "kontakt{'@'}gremiumhub.de",
|
||||
"phone": "+49 176 47028443"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"meta": {
|
||||
"title": "Unser Team – GremiumHub",
|
||||
|
||||
@@ -329,6 +329,48 @@
|
||||
"errors": {
|
||||
"generic": "An error occurred. Please try again later."
|
||||
},
|
||||
"contact": {
|
||||
"meta": {
|
||||
"title": "Contact – GremiumHub",
|
||||
"description": "Get in touch with us. We look forward to hearing from you and will respond promptly."
|
||||
},
|
||||
"badge": "Contact",
|
||||
"title": "Get in touch with {highlight}",
|
||||
"titleHighlight": "us",
|
||||
"description": "Have questions about GremiumHub or want to learn more about our solution? Write to us – we'll get back to you promptly.",
|
||||
"form": {
|
||||
"name": "Your name",
|
||||
"email": "Email address",
|
||||
"message": "Your message",
|
||||
"submit": "Send message",
|
||||
"sending": "Sending..."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Please enter your name",
|
||||
"nameMax": "Name must be at most 100 characters",
|
||||
"emailRequired": "Please enter an email address",
|
||||
"emailInvalid": "Please enter a valid email address",
|
||||
"messageRequired": "Please enter a message",
|
||||
"messageMin": "Message must be at least 10 characters",
|
||||
"messageMax": "Message must be at most 5000 characters"
|
||||
},
|
||||
"success": {
|
||||
"title": "Message sent!",
|
||||
"message": "Thank you for your message. We will get back to you as soon as possible.",
|
||||
"backToHome": "Back to home",
|
||||
"sendAnother": "Send another message"
|
||||
},
|
||||
"trust": {
|
||||
"gdpr": "GDPR compliant",
|
||||
"encrypted": "Encrypted",
|
||||
"responseTime": "Response within 24h"
|
||||
},
|
||||
"alternativeContact": {
|
||||
"title": "Or reach us directly",
|
||||
"email": "kontakt{'@'}gremiumhub.de",
|
||||
"phone": "+49 176 47028443"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"meta": {
|
||||
"title": "Our Team – GremiumHub",
|
||||
|
||||
@@ -3,10 +3,17 @@ export default defineNuxtConfig({
|
||||
modules: ['@nuxt/ui', '@nuxt/eslint', '@nuxt/fonts', '@nuxtjs/i18n'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
devtools: { enabled: true },
|
||||
|
||||
// SSR enabled by default for performance
|
||||
ssr: true,
|
||||
|
||||
// Runtime configuration for server-side API keys
|
||||
runtimeConfig: {
|
||||
brevoApiKey: 'NOT_SET',
|
||||
brevoSenderEmail: 'NOT_SET',
|
||||
brevoSenderName: 'NOT_SET',
|
||||
brevoContactEmail: 'NOT_SET',
|
||||
brevoNewsletterListId: 'NOT_SET'
|
||||
},
|
||||
|
||||
// Font configuration - Bricolage Grotesque for headings, DM Sans for body
|
||||
fonts: {
|
||||
families: [
|
||||
|
||||
15
landing/plugins/error-handler.ts
Normal file
15
landing/plugins/error-handler.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { NuxtError } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('vue:error', (error: unknown, _instance, info) => {
|
||||
const statusCode = (error as NuxtError)?.statusCode
|
||||
if (statusCode && statusCode >= 500) {
|
||||
console.error(`[${statusCode}] Vue Error:`, error, info)
|
||||
}
|
||||
|
||||
// Print out all errors that are not HTTP errors
|
||||
if (!statusCode) {
|
||||
console.error('[unknown] Vue Error:', error, info)
|
||||
}
|
||||
})
|
||||
})
|
||||
135
landing/server/api/contact/send.post.ts
Normal file
135
landing/server/api/contact/send.post.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(10).max(5000)
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Validate request body
|
||||
const body = await readBody(event)
|
||||
const result = contactSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid form data',
|
||||
data: result.error.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
const { name, email, message } = result.data
|
||||
|
||||
// Check if API key is configured
|
||||
if (!config.brevoApiKey || config.brevoApiKey === 'NOT_SET') {
|
||||
console.error('BREVO_API_KEY is not configured')
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Email service is not configured'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Build HTML email content
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #0ea5e9, #06b6d4); color: white; padding: 24px; border-radius: 12px 12px 0 0; }
|
||||
.content { background: #f8fafc; padding: 24px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 12px 12px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.label { font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.value { margin-top: 4px; padding: 12px; background: white; border-radius: 8px; border: 1px solid #e2e8f0; }
|
||||
.message-value { white-space: pre-wrap; }
|
||||
.footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 24px;">Neue Kontaktanfrage</h1>
|
||||
<p style="margin: 8px 0 0; opacity: 0.9;">über GremiumHub Website</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="field">
|
||||
<div class="label">Name</div>
|
||||
<div class="value">${escapeHtml(name)}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label">E-Mail</div>
|
||||
<div class="value"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label">Nachricht</div>
|
||||
<div class="value message-value">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Diese Nachricht wurde über das Kontaktformular auf gremiumhub.de gesendet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim()
|
||||
|
||||
// Send email via Brevo SMTP API
|
||||
const response = await $fetch<{ messageId?: string }>('https://api.brevo.com/v3/smtp/email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'api-key': config.brevoApiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
sender: {
|
||||
name: config.brevoSenderName,
|
||||
email: config.brevoSenderEmail
|
||||
},
|
||||
to: [
|
||||
{
|
||||
email: config.brevoContactEmail,
|
||||
name: 'GremiumHub Team'
|
||||
}
|
||||
],
|
||||
replyTo: {
|
||||
email,
|
||||
name
|
||||
},
|
||||
subject: `Kontaktanfrage von ${name}`,
|
||||
htmlContent
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: response.messageId
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const fetchError = error as { statusCode?: number; data?: { code?: string; message?: string } }
|
||||
|
||||
console.error('Brevo SMTP API error:', fetchError.data || error)
|
||||
|
||||
throw createError({
|
||||
statusCode: fetchError.statusCode || 500,
|
||||
statusMessage: fetchError.data?.message || 'Failed to send message'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text: string): string {
|
||||
const htmlEntities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => htmlEntities[char] ?? char)
|
||||
}
|
||||
84
landing/server/api/newsletter/subscribe.post.ts
Normal file
84
landing/server/api/newsletter/subscribe.post.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
email: z.string().email()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Validate request body
|
||||
const body = await readBody(event)
|
||||
const result = subscribeSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email address'
|
||||
})
|
||||
}
|
||||
|
||||
const { email } = result.data
|
||||
|
||||
// Check if API key is configured
|
||||
if (!config.brevoApiKey) {
|
||||
console.error('BREVO_API_KEY is not configured')
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Newsletter service is not configured'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Build request body for Brevo Contacts API
|
||||
const brevoBody: {
|
||||
email: string
|
||||
updateEnabled: boolean
|
||||
listIds?: number[]
|
||||
} = {
|
||||
email,
|
||||
updateEnabled: true
|
||||
}
|
||||
|
||||
// Add list ID if configured
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-key': config.brevoApiKey,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: brevoBody
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: (response as { id?: number })?.id
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Handle Brevo API errors
|
||||
const fetchError = error as { statusCode?: number; data?: { code?: string; message?: string } }
|
||||
|
||||
// Contact already exists (duplicate_parameter error) - treat as success
|
||||
if (fetchError.data?.code === 'duplicate_parameter') {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Already subscribed'
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Brevo API error:', fetchError.data || error)
|
||||
|
||||
throw createError({
|
||||
statusCode: fetchError.statusCode || 500,
|
||||
statusMessage: fetchError.data?.message || 'Failed to subscribe to newsletter'
|
||||
})
|
||||
}
|
||||
})
|
||||
16
landing/server/plugins/error-handler.ts
Normal file
16
landing/server/plugins/error-handler.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { H3Error } from 'h3'
|
||||
|
||||
// Due to a bug in nitro, the stack trace is missing on the server side (https://github.com/nuxt/nuxt/issues/30102)
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('error', (error, { event }) => {
|
||||
const statusCode = (error as H3Error)?.statusCode
|
||||
if (statusCode && statusCode >= 500) {
|
||||
console.error(`[${statusCode}] ${event?.path}`, error)
|
||||
}
|
||||
|
||||
// Print out all errors that are not HTTP errors
|
||||
if (!statusCode) {
|
||||
console.error(`[Error] ${event?.path}`, error)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user