Compare commits
3 Commits
1380d40b4d
...
a0cfa8ef62
| Author | SHA1 | Date | |
|---|---|---|---|
| a0cfa8ef62 | |||
| ae1e0ad8a9 | |||
| 5ba3afc393 |
8
.env
8
.env
@@ -10,3 +10,11 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
# GlitchTip (Sentry protocol)
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@klz-cables.com/errors/1
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=postmaster@mg.mintel.me
|
||||
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||
MAIL_FROM=KLZ Cables <postmaster@mg.mintel.me>
|
||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||
|
||||
@@ -3,7 +3,8 @@ import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
|
||||
import { Section, Container, Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||
import { Section, Container, Heading } from '@/components/ui';
|
||||
import ContactForm from '@/components/ContactForm';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: {
|
||||
@@ -184,54 +185,7 @@ export default function ContactPage() {
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7">
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
{t('form.title')}
|
||||
</Heading>
|
||||
<form className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button type="submit" variant="saturated" size="lg" className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform">
|
||||
{t('form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Container, Button } from '@/components/ui';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
@@ -19,14 +20,41 @@ export default function Error({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Container className="py-24 flex flex-col items-center justify-center text-center min-h-[60vh]">
|
||||
<h2 className="text-3xl font-bold mb-4">{t('title')}</h2>
|
||||
<p className="text-white/60 mb-8 max-w-md">
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">500</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
|
||||
500
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
||||
{t('description')}
|
||||
</p>
|
||||
<Button onClick={() => reset()}>
|
||||
{t('tryAgain')}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button onClick={() => reset()} variant="saturated" size="lg">
|
||||
{t('tryAgain')}
|
||||
</Button>
|
||||
<Button href="/" variant="outline" size="lg">
|
||||
{t('goHome')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Industrial Line */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-b from-saturated/50 to-transparent" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
import {NextIntlClientProvider} from 'next-intl';
|
||||
import {getMessages, getTranslations} from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import UmamiScript from '@/components/analytics/UmamiScript';
|
||||
import Header from '@/components/Header';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getOrganizationSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
|
||||
export async function generateMetadata({params: {locale}}: {params: {locale: string}}): Promise<Metadata> {
|
||||
const t = await getTranslations({locale, namespace: 'Index.meta'});
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: t('title'),
|
||||
template: `%s | KLZ Cables`
|
||||
},
|
||||
description: t('description'),
|
||||
metadataBase: new URL('https://klz-cables.com'),
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: {
|
||||
'de': '/de',
|
||||
'en': '/en',
|
||||
'x-default': '/en',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: locale === 'de' ? 'de_DE' : 'en_US',
|
||||
url: `https://klz-cables.com/${locale}`,
|
||||
siteName: 'KLZ Cables',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
|
||||
46
app/[locale]/not-found.tsx
Normal file
46
app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Container, Button, Heading } from '@/components/ui';
|
||||
import Scribble from '@/components/Scribble';
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('Error.notFound');
|
||||
|
||||
return (
|
||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||
{/* Industrial Background Element */}
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||
<span className="text-[20rem] font-bold select-none">404</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||
404
|
||||
</Heading>
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
|
||||
<p className="text-white/60 mb-10 max-w-md text-lg">
|
||||
{t('description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button href="/" variant="accent" size="lg">
|
||||
{t('cta')}
|
||||
</Button>
|
||||
<Button href="/contact" variant="outline" size="lg">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Decorative Industrial Line */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
34
app/actions/contact.ts
Normal file
34
app/actions/contact.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { sendEmail } from "@/lib/mail/mailer";
|
||||
import ContactEmail from "@/components/emails/ContactEmail";
|
||||
import React from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const name = formData.get("name") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const message = formData.get("message") as string;
|
||||
const productName = formData.get("productName") as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
return { success: false, error: "Missing required fields" };
|
||||
}
|
||||
|
||||
const subject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: "New Contact Form Submission";
|
||||
|
||||
const result = await sendEmail({
|
||||
subject,
|
||||
template: React.createElement(ContactEmail, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
subject,
|
||||
}),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
128
components/ContactForm.tsx
Normal file
128
components/ContactForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
|
||||
export default function ContactForm() {
|
||||
const t = useTranslations('Contact');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus('submitting');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'general',
|
||||
email: formData.get('email') as string,
|
||||
});
|
||||
setStatus('success');
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<Heading level={3} className="mb-4">
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<p className="text-text-secondary text-lg mb-8">
|
||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||
{t('form.sendAnother') || 'Send another message'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
|
||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||
{t('form.title')}
|
||||
</Heading>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{status === 'error' && (
|
||||
<div className="md:col-span-2 text-red-500 text-sm font-bold">
|
||||
{t('form.error') || 'An error occurred. Please try again later.'}
|
||||
</div>
|
||||
)}
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{t('form.submitting') || 'Sending...'}
|
||||
</span>
|
||||
) : t('form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Input, Textarea, Button } from '@/components/ui';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||
|
||||
interface RequestQuoteFormProps {
|
||||
productName: string;
|
||||
@@ -10,6 +12,7 @@ interface RequestQuoteFormProps {
|
||||
|
||||
export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) {
|
||||
const t = useTranslations('Products.form');
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [email, setEmail] = useState('');
|
||||
const [request, setRequest] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
@@ -18,15 +21,30 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
e.preventDefault();
|
||||
setStatus('submitting');
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const formData = new FormData();
|
||||
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
|
||||
formData.append('email', email);
|
||||
formData.append('message', request);
|
||||
formData.append('productName', productName);
|
||||
|
||||
// Here you would typically send the data to your backend
|
||||
console.log('Form submitted:', { productName, email, request });
|
||||
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'product_quote',
|
||||
product_name: productName,
|
||||
email: email,
|
||||
});
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
setRequest('');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'success') {
|
||||
|
||||
115
components/emails/ContactEmail.tsx
Normal file
115
components/emails/ContactEmail.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface ContactEmailProps {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
productName?: string;
|
||||
}
|
||||
|
||||
export const ContactEmail = ({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
subject = "New Contact Form Submission",
|
||||
productName,
|
||||
}: ContactEmailProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>{subject}</Heading>
|
||||
{productName && (
|
||||
<Text style={text}>
|
||||
<strong>Product Inquiry:</strong> {productName}
|
||||
</Text>
|
||||
)}
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>Name:</strong> {name}
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
<strong>Email:</strong> {email}
|
||||
</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={text}>
|
||||
<strong>Message:</strong>
|
||||
</Text>
|
||||
<Text style={messageText}>{message}</Text>
|
||||
</Section>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
This email was sent from the contact form on klz-cables.com
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
export default ContactEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: "0 48px",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
padding: "0 48px",
|
||||
margin: "30px 0",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
const messageText = {
|
||||
...text,
|
||||
backgroundColor: "#f4f4f4",
|
||||
padding: "15px",
|
||||
borderRadius: "4px",
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#e6ebf1",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: "#8898aa",
|
||||
fontSize: "12px",
|
||||
lineHeight: "16px",
|
||||
textAlign: "center" as const,
|
||||
marginTop: "20px",
|
||||
};
|
||||
41
lib/mail/mailer.ts
Normal file
41
lib/mail/mailer.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { render } from "@react-email/components";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: Number(process.env.MAIL_PORT),
|
||||
secure: Number(process.env.MAIL_PORT) === 465,
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
interface SendEmailOptions {
|
||||
to?: string | string[];
|
||||
subject: string;
|
||||
template: ReactElement;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
||||
const html = await render(template);
|
||||
|
||||
const recipients = to || process.env.MAIL_RECIPIENTS?.split(",") || [];
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.MAIL_FROM,
|
||||
to: recipients,
|
||||
subject,
|
||||
html,
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log("Email sent: %s", info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
console.error("Error sending email:", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
@@ -368,6 +368,13 @@
|
||||
"Error": {
|
||||
"title": "Etwas ist schief gelaufen!",
|
||||
"description": "Wir sind auf einen unerwarteten Fehler gestoßen. Unser Team wurde benachrichtigt und arbeitet an einer Lösung.",
|
||||
"tryAgain": "Erneut versuchen"
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"goHome": "Zurück zur Startseite",
|
||||
"notFound": {
|
||||
"title": "Verbindung unterbrochen",
|
||||
"subtitle": "404 - Seite nicht gefunden",
|
||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
||||
"cta": "Zurück zur Sicherheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +368,13 @@
|
||||
"Error": {
|
||||
"title": "Something went wrong!",
|
||||
"description": "We encountered an unexpected error. Our team has been notified and is working on a fix.",
|
||||
"tryAgain": "Try again"
|
||||
"tryAgain": "Try again",
|
||||
"goHome": "Back to Home",
|
||||
"notFound": {
|
||||
"title": "Connection Lost",
|
||||
"subtitle": "404 - Page Not Found",
|
||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3248
package-lock.json
generated
3248
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@react-email/components": "^1.0.6",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
@@ -17,9 +18,11 @@
|
||||
"next-i18next": "^15.4.3",
|
||||
"next-intl": "^4.6.1",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-email": "^5.2.5",
|
||||
"redis": "^4.7.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
@@ -32,6 +35,7 @@
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
|
||||
Reference in New Issue
Block a user