Merge branch 'feature/excel' into feature-ai-search, resolve conflicts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m11s
Build & Deploy / 🚀 Deploy (push) Successful in 45s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m11s
Build & Deploy / 🚀 Deploy (push) Successful in 45s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
export default function AutoBrochureModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already seen or interacted with the modal
|
||||
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
|
||||
|
||||
if (!hasSeenModal) {
|
||||
// Auto-open after 5 seconds to not interrupt immediate page load
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
// Mark as seen so it doesn't bother them again on next page load
|
||||
localStorage.setItem('klz_brochure_modal_seen', 'true');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
|
||||
}
|
||||
88
components/BrochureCTA.tsx
Normal file
88
components/BrochureCTA.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrochureCTA — Shows a button that opens a modal asking for an email address.
|
||||
* The full-catalog PDF is ONLY revealed after email submission.
|
||||
* No direct download link is exposed anywhere.
|
||||
*/
|
||||
export default function BrochureCTA({ className, compact = false }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
|
||||
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
|
||||
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
|
||||
)}
|
||||
>
|
||||
{/* Green top accent */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Labels */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
|
||||
PDF Katalog
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
|
||||
compact ? 'text-base' : 'text-lg md:text-xl',
|
||||
)}
|
||||
>
|
||||
{t('ctaTitle')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Arrow */}
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
256
components/BrochureModal.tsx
Normal file
256
components/BrochureModal.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface BrochureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Close on escape + lock scroll + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Auto-focus input when opened
|
||||
const firstInput = document.getElementById('brochure-email');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 50);
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
|
||||
if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = modalRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
if (focusable.length > 0) {
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
last.focus();
|
||||
e.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
first.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Strict overflow lock on mobile as well
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
|
||||
setState('submitting');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const formData = new FormData(formRef.current);
|
||||
formData.set('locale', locale);
|
||||
|
||||
const result = await requestBrochureAction(formData);
|
||||
|
||||
if (result.success) {
|
||||
setState('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'brochure_modal',
|
||||
});
|
||||
} else {
|
||||
setState('error');
|
||||
setErrorMsg(result.error || 'Something went wrong');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
setErrorMsg('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState('idle');
|
||||
setErrorMsg('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
|
||||
>
|
||||
{/* Accent bar at top */}
|
||||
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-7">
|
||||
{/* Icon + Header */}
|
||||
<div className="mb-7">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{state === 'success' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-[#82ed20]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#82ed20]">
|
||||
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 mt-0.5">
|
||||
{locale === 'de'
|
||||
? 'Bitte prüfen Sie Ihren Posteingang.'
|
||||
: 'Please check your inbox.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="brochure-email"
|
||||
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
|
||||
>
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="brochure-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
|
||||
disabled={state === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === 'submitting'}
|
||||
className={cn(
|
||||
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
|
||||
state === 'submitting'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
|
||||
)}
|
||||
>
|
||||
{state === 'submitting' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
@@ -138,7 +138,20 @@ export default function ContactForm() {
|
||||
<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">
|
||||
<form
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||
<Input
|
||||
|
||||
@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||
PDF Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface ExcelDownloadProps {
|
||||
excelPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
|
||||
<a
|
||||
href={excelPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: excelPath.split('/').pop(),
|
||||
file_path: excelPath,
|
||||
file_type: 'excel',
|
||||
location: 'product_page',
|
||||
})
|
||||
}
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{/* Spreadsheet/Table Icon */}
|
||||
<svg
|
||||
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
|
||||
Excel Datasheet
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
|
||||
{t('downloadExcel')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadExcelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import FooterBrochureForm from './FooterBrochureForm';
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations('Footer');
|
||||
@@ -244,6 +245,10 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-12 md:mb-16">
|
||||
<FooterBrochureForm />
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
|
||||
<p>{t('copyright', { year: currentYear })}</p>
|
||||
<div className="flex gap-8">
|
||||
|
||||
134
components/FooterBrochureForm.tsx
Normal file
134
components/FooterBrochureForm.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { requestBrochureAction } from '@/app/actions/brochure';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FooterBrochureForm({ className }: Props) {
|
||||
const t = useTranslations('Brochure');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!formRef.current) return;
|
||||
setPhase('loading');
|
||||
|
||||
const fd = new FormData(formRef.current);
|
||||
fd.set('locale', locale);
|
||||
|
||||
try {
|
||||
const res = await requestBrochureAction(fd);
|
||||
if (res.success) {
|
||||
setPhase('success');
|
||||
trackEvent(AnalyticsEvents.DOWNLOAD, {
|
||||
file_name: `klz-product-catalog-${locale}.pdf`,
|
||||
file_type: 'brochure',
|
||||
location: 'footer_inline',
|
||||
});
|
||||
} else {
|
||||
setErr(res.error || 'Error');
|
||||
setPhase('error');
|
||||
}
|
||||
} catch {
|
||||
setErr('Network error');
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'success') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-bold mb-1">
|
||||
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">
|
||||
{locale === 'de'
|
||||
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
|
||||
: 'We have just sent the catalog to your email.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
|
||||
{t('ctaTitle')}
|
||||
</h4>
|
||||
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
|
||||
>
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t('emailPlaceholder')}
|
||||
disabled={phase === 'loading'}
|
||||
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={phase === 'loading'}
|
||||
className={cn(
|
||||
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
|
||||
phase === 'loading'
|
||||
? 'bg-white/10 text-white/40 cursor-wait'
|
||||
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{phase === 'loading' ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{phase === 'error' && err && (
|
||||
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export default function Header() {
|
||||
// Prevent scroll when mobile menu is open and handle focus trap
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus trap logic
|
||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||
@@ -83,7 +84,8 @@ export default function Header() {
|
||||
};
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (photoParam !== null) {
|
||||
const index = parseInt(photoParam, 10);
|
||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
}
|
||||
}, [searchParams, images.length]);
|
||||
@@ -125,13 +125,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
|
||||
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
|
||||
|
||||
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
document.documentElement.style.overflow = originalHtmlStyle;
|
||||
document.body.style.overflow = originalBodyStyle;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||
@@ -139,7 +143,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function ObfuscatedEmail({ email, className = '', children }: Obf
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function ObfuscatedPhone({ phone, className = '', children }: Obf
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,6 +37,7 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||
|
||||
/**
|
||||
* Splits a text string on \n and intersperses <br /> elements.
|
||||
@@ -429,6 +430,12 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||
</ProductTabs>
|
||||
),
|
||||
pdfDownload: ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
'block-pdfDownload': ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
// ─── New Page Blocks ───────────────────────────────────────────
|
||||
heroSection: ({ node }: any) => {
|
||||
const f = node.fields;
|
||||
@@ -786,8 +793,8 @@ const jsxConverters: JSXConverters = {
|
||||
</Section>
|
||||
);
|
||||
},
|
||||
imageGallery: ({ node }: any) => <Gallery />,
|
||||
'block-imageGallery': ({ node }: any) => <Gallery />,
|
||||
imageGallery: () => <Gallery />,
|
||||
'block-imageGallery': () => <Gallery />,
|
||||
categoryGrid: ({ node }: any) => {
|
||||
const cats = node.fields.categories || [];
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import ExcelDownload from '@/components/ExcelDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
||||
productName: string;
|
||||
productImage?: string;
|
||||
datasheetPath?: string | null;
|
||||
excelPath?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
||||
productName,
|
||||
productImage,
|
||||
datasheetPath,
|
||||
excelPath,
|
||||
className,
|
||||
}: ProductSidebarProps) {
|
||||
const t = useTranslations('Products');
|
||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||
|
||||
{/* Excel Download – right below datasheet */}
|
||||
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface KeyValueItem {
|
||||
label: string;
|
||||
@@ -45,22 +46,40 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||
General Data
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
|
||||
{technicalItems.map((item, idx) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div key={idx} className="flex flex-col group">
|
||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-text-primary">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{item.value}{' '}
|
||||
{item.unit && (
|
||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
||||
{item.unit}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -164,7 +164,17 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
||||
{/* Anti-spam Honeypot */}
|
||||
<input
|
||||
type="text"
|
||||
name="company_website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="space-y-2 !mt-0">
|
||||
<div className="space-y-1 !mt-0">
|
||||
<label htmlFor={emailId} className="sr-only">
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function TrackedLink({
|
||||
}: TrackedLinkProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const handleClick = () => {
|
||||
try {
|
||||
trackEvent(eventName, {
|
||||
href,
|
||||
...eventProperties,
|
||||
});
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||
}
|
||||
if (onClick) onClick();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||
|
||||
interface TechnicalGridItem {
|
||||
label: string;
|
||||
@@ -18,25 +18,44 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||
<span className="relative inline-block">
|
||||
{title}
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
<Scribble
|
||||
variant="underline"
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||
/>
|
||||
</span>
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const formatted = formatTechnicalValue(item.value);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
|
||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||
{formatted.isList ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formatted.parts.map((p, pIdx) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr KLZ Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your KLZ Cable Catalog',
|
||||
greeting: 'Thank you for your interest in KLZ Cables.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from klz-cables.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#000d26',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #4da612',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#4da612',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -75,7 +75,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale?.length === 2 ? locale : 'de',
|
||||
['en', 'de'].includes(locale) ? locale : 'de',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
Reference in New Issue
Block a user