Compare commits
11 Commits
fix/transl
...
fix/header
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc2fe8a25 | |||
| 5977ebf23d | |||
| ee2bcea42b | |||
| 2f2fcfdf13 | |||
| 070f97dd6f | |||
| 2a46015d0d | |||
| 5329d96e3b | |||
| 0c01aa799d | |||
| 75c15fce43 | |||
| 3b20d4087c | |||
| 2dbafec633 |
@@ -162,7 +162,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
...loc,
|
||||
featuredImage: ref.frontmatter.featuredImage,
|
||||
details: [
|
||||
ref.frontmatter.client || 'Kunde',
|
||||
ref.frontmatter.client || t('client'),
|
||||
ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear().toString(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -123,6 +123,13 @@ export default async function Layout(props: {
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
'JobListingBlock',
|
||||
'CallToAction',
|
||||
'InteractiveGermanyMap',
|
||||
'ReferencesSlider',
|
||||
'CompanyTimeline',
|
||||
'TeamGrid',
|
||||
'AISearch',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
|
||||
38
app/[locale]/referenzen/[slug]/page.test.tsx
Normal file
38
app/[locale]/referenzen/[slug]/page.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import ReferenceDetail from './page';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next-intl/server
|
||||
vi.mock('next-intl/server', () => ({
|
||||
setRequestLocale: vi.fn(),
|
||||
getTranslations: vi.fn().mockResolvedValue(() => 'translated text'),
|
||||
}));
|
||||
|
||||
// Mock @/lib/references
|
||||
vi.mock('@/lib/references', () => ({
|
||||
getReferenceBySlug: vi.fn().mockResolvedValue({
|
||||
slug: 'test-slug',
|
||||
frontmatter: {
|
||||
title: 'Test Title',
|
||||
location: 'Test Location',
|
||||
category: 'Test Category',
|
||||
},
|
||||
content: 'Test content',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ReferenceDetail Page TDD', () => {
|
||||
it('renders reference detail correctly', async () => {
|
||||
const props = {
|
||||
params: Promise.resolve({ locale: 'de', slug: 'test-slug' })
|
||||
};
|
||||
|
||||
// We expect the execution to fail if getTranslations is not defined/imported inside page.tsx
|
||||
const component = await ReferenceDetail(props);
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getReferenceBySlug, getAllReferences } from '@/lib/references';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
@@ -57,6 +57,7 @@ export default async function ReferenceDetail(props: { params: Promise<{ locale:
|
||||
|
||||
setRequestLocale(locale);
|
||||
const reference = await getReferenceBySlug(slug, locale);
|
||||
const t = await getTranslations('ReferenceDetail');
|
||||
|
||||
if (!reference) {
|
||||
notFound();
|
||||
@@ -119,12 +120,12 @@ export default async function ReferenceDetail(props: { params: Promise<{ locale:
|
||||
eventProperties={{ location: 'reference_back_btn' }}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Zurück zur Übersicht
|
||||
{t('backToOverview')}
|
||||
</TrackedLink>
|
||||
</div>
|
||||
<div className="max-w-4xl">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
Projektreferenz
|
||||
{t('projectReference')}
|
||||
</Badge>
|
||||
<Heading level={1} variant="white" className="mb-8 leading-tight">
|
||||
{reference.frontmatter.title}
|
||||
@@ -134,21 +135,21 @@ export default async function ReferenceDetail(props: { params: Promise<{ locale:
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<MapPin className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Ort</p>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">{t('location')}</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<Briefcase className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Auftraggeber</p>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">{t('client')}</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.client}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<Calendar className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Zeitraum</p>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">{t('period')}</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.dateString || new Date(reference.frontmatter.date).getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +161,7 @@ export default async function ReferenceDetail(props: { params: Promise<{ locale:
|
||||
{/* Main Content Area */}
|
||||
<Container className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-neutral-dark mb-8">Leistungsumfang & Projektbeschreibung</h2>
|
||||
<h2 className="text-3xl font-bold text-neutral-dark mb-8">{t('scopeTitle')}</h2>
|
||||
|
||||
<div className="w-full">
|
||||
<MDXRemote source={reference.content} components={mdxComponents} />
|
||||
@@ -173,7 +174,7 @@ export default async function ReferenceDetail(props: { params: Promise<{ locale:
|
||||
eventProperties={{ location: 'reference_bottom_back_btn' }}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
Alle Referenzen ansehen
|
||||
{t('viewAll')}
|
||||
</span>
|
||||
<ButtonOverlay variant="primary" />
|
||||
</TrackedLink>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
<InteractiveGermanyMap
|
||||
isHero={true}
|
||||
badge={locale === 'en' ? 'Our References' : 'Unsere Referenzen'}
|
||||
title={locale === 'en' ? <>Successfully realized<br/>projects.</> : <>Erfolgreich umgesetzte<br/>Projekte.</>}
|
||||
title={locale === 'en' ? 'Successfully realized projects.' : 'Erfolgreich umgesetzte Projekte.'}
|
||||
description={locale === 'en' ? 'From broadband expansion to complex 110kV lines: A selection of our nationwide projects where we have created infrastructure for the future. Discover our locations.' : 'Vom Breitbandausbau bis zur komplexen 110kV-Trasse: Ein Auszug unserer bundesweiten Projekte, bei denen wir Infrastruktur für die Zukunft geschaffen haben. Entdecken Sie unsere Standorte.'}
|
||||
locations={enrichedLocations}
|
||||
/>
|
||||
@@ -129,14 +129,14 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
{/* Technical Meta Data */}
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 pr-16 md:pr-20 relative z-10">
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">Kunde</span>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">{t('client')}</span>
|
||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||
<Briefcase className="w-3.5 h-3.5 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="line-clamp-2">{ref.frontmatter.client}</span>
|
||||
<span className="line-clamp-2">{ref.frontmatter.client || t('client')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">Zeitraum</span>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">{t('period')}</span>
|
||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="line-clamp-2">{ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear()}</span>
|
||||
@@ -158,8 +158,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">Ihr nächstes Projekt?</h3>
|
||||
<p className="text-lg text-white/70 mb-8">Lassen Sie uns gemeinsam herausragende Infrastruktur bauen.</p>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className={getButtonClasses('accent', 'lg')}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function LeafletMap({ locations }: LeafletMapProps) {
|
||||
const firstLoc = locations[0];
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [firstLoc.lat, firstLoc.lng],
|
||||
zoom: 6, // Zoom out to see both or more
|
||||
zoom: locations.length === 1 ? 16 : 6,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export interface CallToActionProps {
|
||||
title?: string;
|
||||
@@ -32,14 +33,15 @@ const itemVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CallToAction: React.FC<CallToActionProps> = ({
|
||||
title = 'Bereit für Ihr Projekt?',
|
||||
description = 'Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.',
|
||||
ctaLabel = 'Jetzt Kontakt aufnehmen',
|
||||
ctaHref = '/de/kontakt',
|
||||
theme = 'light'
|
||||
}) => {
|
||||
export const CallToAction: React.FC<CallToActionProps> = (props) => {
|
||||
const { theme = 'light' } = props;
|
||||
const isDark = theme === 'dark';
|
||||
const t = useTranslations('CallToAction');
|
||||
|
||||
const title = props.title || t('title');
|
||||
const description = props.description || t('description');
|
||||
const ctaLabel = props.ctaLabel || t('ctaLabel');
|
||||
const ctaHref = props.ctaHref || t('ctaHref');
|
||||
|
||||
return (
|
||||
<div className={`py-24 text-center ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-50 border-t border-neutral-100'}`}>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { LogoArcs } from '@/components/ui/LogoArcs';
|
||||
interface Certificate {
|
||||
title: string;
|
||||
description: string;
|
||||
pdfUrl: string;
|
||||
pdfUrl?: string;
|
||||
type: 'iso' | 'tax' | 'general';
|
||||
date?: string;
|
||||
}
|
||||
@@ -29,45 +29,70 @@ const defaultCertificates: Certificate[] = [
|
||||
{
|
||||
title: 'ISO 14001:2015',
|
||||
description: 'Umweltmanagementsystem',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 14001 Umweltmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso14001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'ISO 9001:2015',
|
||||
description: 'Qualitätsmanagementsystem',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 9001 Qualitätsmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso9001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'DIN EN ISO 45001:2018',
|
||||
description: 'Arbeits- und Gesundheitsschutz',
|
||||
pdfUrl: '/assets/certificates/Zertifizierung DIN EN ISO 45001 bis 05122028.pdf',
|
||||
pdfUrl: '/assets/certificates/iso45001.pdf',
|
||||
type: 'iso',
|
||||
date: '05.12.2025', // Assuming valid till
|
||||
date: '05.12.2025',
|
||||
},
|
||||
{
|
||||
title: 'Freistellungsbescheinigung',
|
||||
description: 'Nach § 48 b EStG',
|
||||
pdfUrl: '/assets/certificates/240209_Freistellungsbescheinigung § 48 b.pdf',
|
||||
pdfUrl: '/assets/certificates/freistellung.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Nachweis § 13b UStG',
|
||||
description: 'Steuerschuldnerschaft des Leistungsempfängers',
|
||||
pdfUrl: '/assets/certificates/240209_Nachweis § 13 b.pdf',
|
||||
pdfUrl: '/assets/certificates/nachweis13b.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Bescheinigung in Steuersachen',
|
||||
description: 'Zertifikat des Finanzamtes',
|
||||
pdfUrl: '/assets/certificates/250213_Bescheinigung in Steuersachen.pdf',
|
||||
pdfUrl: '/assets/certificates/bescheinigung.pdf',
|
||||
type: 'tax',
|
||||
date: '13.02.2025',
|
||||
},
|
||||
{
|
||||
title: 'Unbedenklichkeit IHK',
|
||||
description: 'Industrie- und Handelskammer',
|
||||
type: 'general',
|
||||
},
|
||||
{
|
||||
title: 'Unbedenklichkeit HWK',
|
||||
description: 'Handwerkskammer',
|
||||
type: 'general',
|
||||
},
|
||||
{
|
||||
title: 'Handelsregisterauszug',
|
||||
description: 'Amtsgericht Cottbus',
|
||||
type: 'general',
|
||||
},
|
||||
{
|
||||
title: 'Gewerbe-Anmeldung',
|
||||
description: 'Stadt Guben',
|
||||
type: 'general',
|
||||
},
|
||||
{
|
||||
title: 'Gewerbezentralregister',
|
||||
description: 'Auskunft aus dem Register',
|
||||
type: 'general',
|
||||
},
|
||||
];
|
||||
|
||||
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
||||
@@ -143,13 +168,17 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
>
|
||||
{certificates.map((cert, index) => {
|
||||
const isIso = cert.type === 'iso';
|
||||
const Wrapper = cert.pdfUrl ? motion.a : motion.div;
|
||||
const wrapperProps = cert.pdfUrl ? {
|
||||
href: encodeURI(cert.pdfUrl),
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
<Wrapper
|
||||
key={index}
|
||||
href={cert.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...wrapperProps as any}
|
||||
variants={itemVariants}
|
||||
className={`group relative rounded-2xl p-8 flex flex-col justify-between overflow-hidden transition-all duration-500 hover:shadow-xl hover:-translate-y-1 border ${
|
||||
isIso
|
||||
@@ -196,14 +225,22 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider group-hover:gap-3 transition-all ${
|
||||
<div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider transition-all ${
|
||||
cert.pdfUrl ? 'group-hover:gap-3' : ''
|
||||
} ${
|
||||
isIso ? 'text-primary-light' : 'text-primary'
|
||||
}`}>
|
||||
<span>Download PDF</span>
|
||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
||||
{cert.pdfUrl ? (
|
||||
<>
|
||||
<span>Download PDF</span>
|
||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-400">Nachweis liegt vor</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
@@ -30,10 +30,7 @@ export function InteractiveGermanyMap({
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
stats = [
|
||||
{ value: '100', suffix: '%', label: 'Überregionale Reichweite' },
|
||||
{ value: '2', suffix: '+', label: 'Operative Standorte' },
|
||||
],
|
||||
stats,
|
||||
locations = allLocations,
|
||||
isHero = false
|
||||
}: InteractiveGermanyMapProps) {
|
||||
@@ -43,6 +40,11 @@ export function InteractiveGermanyMap({
|
||||
const t = useTranslations('InteractiveGermanyMap');
|
||||
const tStandard = useTranslations('StandardPage');
|
||||
|
||||
const finalStats = stats || [
|
||||
{ value: '100', suffix: '%', label: locale === 'en' ? 'Nationwide Reach' : 'Überregionale Reichweite' },
|
||||
{ value: '2', suffix: '+', label: locale === 'en' ? 'Operational Locations' : 'Operative Standorte' },
|
||||
];
|
||||
|
||||
const finalBadge = badge || tStandard('badge');
|
||||
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
|
||||
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
|
||||
@@ -73,11 +75,11 @@ export function InteractiveGermanyMap({
|
||||
</div>
|
||||
|
||||
{isHero ? (
|
||||
<h1 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 break-words [hyphens:auto]">
|
||||
<h1 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
||||
{finalTitle}
|
||||
</h1>
|
||||
) : (
|
||||
<h3 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 break-words [hyphens:auto]">
|
||||
<h3 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
||||
{finalTitle}
|
||||
</h3>
|
||||
)}
|
||||
@@ -88,7 +90,7 @@ export function InteractiveGermanyMap({
|
||||
|
||||
{/* Industrial Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{stats.map((stat, i) => (
|
||||
{finalStats.map((stat, i) => (
|
||||
<div key={i} className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="relative z-10">
|
||||
@@ -150,7 +152,10 @@ export function InteractiveGermanyMap({
|
||||
onMouseEnter={() => setActiveLocation(loc)}
|
||||
onMouseLeave={() => setActiveLocation(null)}
|
||||
onClick={() => {
|
||||
if (loc.href) router.push(loc.href);
|
||||
if (loc.href) {
|
||||
const href = loc.href.startsWith('/') ? `/${locale}${loc.href}` : loc.href;
|
||||
router.push(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Ping Animation for HQ / Branch */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export interface JobListingBlockProps {
|
||||
title?: string;
|
||||
@@ -45,20 +46,23 @@ const itemVariants = {
|
||||
};
|
||||
|
||||
export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
const t = useTranslations('JobListingBlock');
|
||||
|
||||
const {
|
||||
title,
|
||||
showJobs = true,
|
||||
showFairs,
|
||||
fairsTitle = 'Nächste Messetermine',
|
||||
fairsTitle = t('fairsTitle'),
|
||||
fairs = [
|
||||
{ name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
|
||||
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
||||
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||
],
|
||||
emptyStateMessage = 'Aktuell sind alle Positionen besetzt. Senden Sie uns gerne eine Initiativbewerbung!',
|
||||
emptyStateLinkText = 'Jetzt Kontakt aufnehmen',
|
||||
emptyStateLinkHref = '/kontakt'
|
||||
emptyStateMessage = t('emptyStateMessage'),
|
||||
emptyStateLinkText = t('emptyStateLinkText'),
|
||||
emptyStateLinkHref = t('emptyStateLinkHref')
|
||||
} = props;
|
||||
|
||||
const title = props.title || t('title');
|
||||
|
||||
// Since Payload is removed, we use an empty array or static data
|
||||
const jobs: any[] = [];
|
||||
@@ -118,7 +122,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Datum</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('date')}</p>
|
||||
<p className="text-white/90 font-medium">{messe.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +133,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Location</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('location')}</p>
|
||||
<p className="text-white/90 font-medium">{messe.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +145,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Stand</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('booth')}</p>
|
||||
<p className="text-primary-light font-bold text-sm">{messe.booth}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +184,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="font-heading font-extrabold text-4xl text-neutral-dark mb-8"
|
||||
>
|
||||
{title || 'Aktuelle Stellenangebote'}
|
||||
{title}
|
||||
</motion.h2>
|
||||
|
||||
{jobs.length > 0 ? (
|
||||
@@ -206,7 +210,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<p className="text-text-secondary mt-1">{job.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center text-primary font-bold group-hover:translate-x-2 transition-transform">
|
||||
Details ansehen
|
||||
{t('viewDetails')}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
@@ -22,16 +23,18 @@ interface TeamGridProps {
|
||||
}
|
||||
|
||||
export function TeamGrid({ members }: TeamGridProps) {
|
||||
const t = useTranslations('TeamGrid');
|
||||
|
||||
if (!members || members.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="container relative z-10">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Persönliche Beratung</h2>
|
||||
<h3 className="font-heading text-4xl font-extrabold text-neutral-dark mb-4">Ihre Ansprechpartner</h3>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
||||
<h3 className="font-heading text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
|
||||
<p className="text-text-secondary max-w-2xl text-lg">
|
||||
Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +59,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Geschäftsführung
|
||||
{t('management')}
|
||||
</div>
|
||||
<h3 className="font-heading font-black text-5xl md:text-6xl lg:text-7xl mb-4 leading-[1.1] tracking-tight">
|
||||
{members[0].name}
|
||||
@@ -145,7 +148,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
||||
{member.branch && (
|
||||
<div className="mb-6">
|
||||
<span className="inline-block px-3 py-1 bg-neutral-100 text-neutral-600 rounded-md text-xs uppercase tracking-widest font-semibold border border-neutral-200/60">
|
||||
{member.branch === 'e-tib' ? 'E-TIB GmbH' : member.branch === 'ing' ? 'Ingenieurgesellschaft' : member.branch === 'bohrtechnik' ? 'Bohrtechnik' : member.branch}
|
||||
{member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
|
||||
export function ContactForm() {
|
||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
@@ -11,10 +12,18 @@ export function ContactForm() {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
|
||||
// Simulate server action using @mintel/mail or API route
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
}, 1500);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -41,13 +50,31 @@ export function ContactForm() {
|
||||
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
) : status === 'error' ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-red-500/10 border border-red-500 text-red-600 p-6 rounded-xl flex items-start gap-4 mb-6"
|
||||
>
|
||||
<div className="bg-red-500 text-white rounded-full p-1 mt-0.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-1">Fehler beim Senden</h4>
|
||||
<p>Leider ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie uns direkt per E-Mail oder Telefon.</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{status !== 'success' && status !== 'error' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="firstName" className="block text-sm font-semibold text-neutral-dark">Vorname</label>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-neutral-dark">Vorname</label>
|
||||
<input
|
||||
id="firstName"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
@@ -57,7 +84,8 @@ export function ContactForm() {
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="lastName" className="block text-sm font-semibold text-neutral-dark">Nachname</label>
|
||||
<input
|
||||
id="lastName"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
@@ -70,6 +98,7 @@ export function ContactForm() {
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-neutral-dark">E-Mail Adresse</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
@@ -82,6 +111,7 @@ export function ContactForm() {
|
||||
<label htmlFor="company" className="block text-sm font-semibold text-neutral-dark">Unternehmen (Optional)</label>
|
||||
<input
|
||||
id="company"
|
||||
name="company"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="Firma GmbH"
|
||||
@@ -92,6 +122,7 @@ export function ContactForm() {
|
||||
<label htmlFor="message" className="block text-sm font-semibold text-neutral-dark">Ihre Nachricht</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
rows={5}
|
||||
@@ -100,6 +131,8 @@ export function ContactForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
|
||||
@@ -20,23 +20,23 @@ export default function Hero({ data }: { data?: any }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
||||
'Projekt beschreiben oder Kabel suchen...',
|
||||
t('searchPlaceholder')
|
||||
);
|
||||
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const HERO_PLACEHOLDERS = [
|
||||
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
||||
'Wie schwer ist NAYY 4x150?',
|
||||
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
||||
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
||||
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
||||
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
||||
t('placeholder1'),
|
||||
t('placeholder2'),
|
||||
t('placeholder3'),
|
||||
t('placeholder4'),
|
||||
t('placeholder5'),
|
||||
t('placeholder6'),
|
||||
];
|
||||
|
||||
// Typing animation for the hero search placeholder
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
||||
setHeroPlaceholder(t('searchPlaceholder'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
size="lg"
|
||||
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
||||
>
|
||||
Fragen
|
||||
{t('ask')}
|
||||
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||
</Button>
|
||||
</form>
|
||||
@@ -160,7 +160,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||
<div>
|
||||
<Button
|
||||
href="/contact"
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
||||
|
||||
@@ -104,12 +104,12 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/kompetenzen` },
|
||||
{ label: locale === 'de' ? 'Über uns' : 'About us', href: `/${locale}/ueber-uns` },
|
||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||
{ label: locale === 'de' ? 'Über uns' : 'About us', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/karriere` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/messen` },
|
||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||
{ label: locale === 'de' ? 'Kontakt' : 'Contact', href: `/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
@@ -131,8 +131,8 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/impressum` },
|
||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/datenschutz` },
|
||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/${locale === 'de' ? 'impressum' : 'imprint'}` },
|
||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/${locale === 'de' ? 'datenschutz' : 'privacy-policy'}` },
|
||||
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
|
||||
86
components/layout/Header.test.tsx
Normal file
86
components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { Header, NavLink } from './Header';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock TransitionLink to render standard anchor tag and forward all props
|
||||
vi.mock('@/components/ui/TransitionLink', () => ({
|
||||
TransitionLink: ({ children, href, onClick, ...props }: any) => (
|
||||
<a href={href} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: any) => <img src={src} alt={alt} {...props} />,
|
||||
}));
|
||||
|
||||
// Mock LanguageSwitcher
|
||||
vi.mock('./LanguageSwitcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="language-switcher" />,
|
||||
}));
|
||||
|
||||
describe('Header Dropdown Navigation TDD', () => {
|
||||
const mockNavLinks: NavLink[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
label: 'Leistungen',
|
||||
url: '/leistungen',
|
||||
children: [
|
||||
{ label: 'Kabelbau', url: '/leistungen/kabelbau' },
|
||||
{ label: 'Bohrtechnik', url: '/leistungen/bohrtechnik' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('closes the dropdown menu immediately after a page navigation occurs', async () => {
|
||||
// 1. Set initial pathname
|
||||
let currentPath = '/de';
|
||||
vi.mocked(usePathname).mockImplementation(() => currentPath);
|
||||
|
||||
// 2. Render Header
|
||||
const { rerender } = render(<Header navLinks={mockNavLinks} />);
|
||||
|
||||
// 3. Hover over "Leistungen" menu item to open dropdown
|
||||
const leistungenNavItem = screen.getByText('Leistungen');
|
||||
const navItemContainer = leistungenNavItem.closest('.group');
|
||||
expect(navItemContainer).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(navItemContainer!);
|
||||
});
|
||||
|
||||
// 4. Assert dropdown is open and children are rendered
|
||||
expect(screen.getByText('Kabelbau')).toBeTruthy();
|
||||
expect(screen.getByText('Bohrtechnik')).toBeTruthy();
|
||||
|
||||
// 5. Simulate page navigation by changing the path
|
||||
currentPath = '/de/leistungen/kabelbau';
|
||||
|
||||
act(() => {
|
||||
// Trigger pathname hook change and force rerender
|
||||
rerender(<Header navLinks={mockNavLinks} />);
|
||||
});
|
||||
|
||||
// 6. Assert dropdown is closed (children are not in the document)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Kabelbau')).toBeNull();
|
||||
expect(screen.queryByText('Bohrtechnik')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -164,6 +164,10 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink, currentLocale: string, pathname: string, isSolidMode: boolean }) {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsHovered(false);
|
||||
}, [pathname]);
|
||||
|
||||
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${link.url}`
|
||||
: link.url;
|
||||
|
||||
61
components/search/AISearchResults.test.tsx
Normal file
61
components/search/AISearchResults.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AISearchResults } from './AISearchResults';
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock('lucide-react', () => ({
|
||||
ArrowUp: () => <div data-testid="arrow-up" />,
|
||||
X: () => <div data-testid="close-icon" />,
|
||||
Sparkles: () => <div data-testid="sparkles" />,
|
||||
ChevronRight: () => <div data-testid="chevron-right" />,
|
||||
RotateCcw: () => <div data-testid="rotate-ccw" />,
|
||||
Copy: () => <div data-testid="copy" />,
|
||||
Check: () => <div data-testid="check" />,
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
// Mock useAnalytics
|
||||
vi.mock('../analytics/useAnalytics', () => ({
|
||||
useAnalytics: () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-markdown
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock remark-gfm
|
||||
vi.mock('remark-gfm', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => {
|
||||
const translate = (key: string) => key;
|
||||
translate.raw = (key: string) => {
|
||||
if (key === 'loadingTexts') return ['Lade...', 'Denke nach...'];
|
||||
if (key === 'prompts') return ['Prompt 1', 'Prompt 2'];
|
||||
return [];
|
||||
};
|
||||
return translate;
|
||||
},
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
describe('AISearchResults Component Test (TDD)', () => {
|
||||
it('renders correctly when open', () => {
|
||||
const handleClose = vi.fn();
|
||||
const { container } = render(
|
||||
<AISearchResults isOpen={true} onClose={handleClose} />
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,9 @@ import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTranslations } from 'next-intl';
|
||||
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
||||
|
||||
const LOADING_TEXTS = [
|
||||
'Durchsuche das Kabelhandbuch... 📖',
|
||||
'Frage den Senior-Ingenieur... 👴🔧',
|
||||
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
||||
];
|
||||
|
||||
interface ProductMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -51,7 +46,13 @@ export function AISearchResults({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
|
||||
const t = useTranslations('AISearch');
|
||||
|
||||
// We load texts dynamically from translations
|
||||
const loadingTexts = t.raw('loadingTexts') as string[];
|
||||
const prompts = t.raw('prompts') as string[];
|
||||
|
||||
const [loadingText, setLoadingText] = useState(loadingTexts[0]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -141,11 +142,11 @@ export function AISearchResults({
|
||||
setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
// Start rotating loading texts
|
||||
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
let textIdx = Math.floor(Math.random() * loadingTexts.length);
|
||||
setLoadingText(loadingTexts[textIdx]);
|
||||
loadingIntervalRef.current = setInterval(() => {
|
||||
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
textIdx = (textIdx + 1) % loadingTexts.length;
|
||||
setLoadingText(loadingTexts[textIdx]);
|
||||
}, 2500);
|
||||
}, 400);
|
||||
|
||||
@@ -191,8 +192,8 @@ export function AISearchResults({
|
||||
console.error(err);
|
||||
const msg =
|
||||
err.name === 'AbortError'
|
||||
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
||||
: err.message || 'Ein Fehler ist aufgetreten.';
|
||||
? t('timeoutError')
|
||||
: err.message || t('genericError');
|
||||
|
||||
// Show error as a system message in the chat instead of a separate error banner
|
||||
setMessages((prev) => [
|
||||
@@ -248,7 +249,7 @@ export function AISearchResults({
|
||||
|
||||
const handleCopyChat = () => {
|
||||
const fullChat = messages
|
||||
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
|
||||
.map((m) => `${m.role === 'user' ? t('you') : 'Ohm'}:\n${m.content}`)
|
||||
.join('\n\n');
|
||||
handleCopy(fullChat);
|
||||
};
|
||||
@@ -294,7 +295,7 @@ export function AISearchResults({
|
||||
<div>
|
||||
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
||||
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
||||
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
|
||||
{isLoading ? t('thinking') : error ? t('errorStatus') : t('online')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,14 +304,14 @@ export function AISearchResults({
|
||||
<button
|
||||
onClick={handleCopyChat}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
|
||||
title="gesamten Chat kopieren"
|
||||
title={t('copyChatTitle')}
|
||||
>
|
||||
{copiedAll ? (
|
||||
<Check className="w-3.5 h-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
||||
<span>{copiedAll ? t('copied') : t('copyChat')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -336,16 +337,15 @@ export function AISearchResults({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl md:text-2xl font-bold text-white/80">
|
||||
Wie kann ich helfen?
|
||||
{t('howCanIHelp')}
|
||||
</p>
|
||||
<p className="text-sm text-white/30 mt-2 max-w-md">
|
||||
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
||||
Anforderungen.
|
||||
{t('helpDescription')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick prompts */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
||||
{prompts.map(
|
||||
(prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
@@ -384,7 +384,7 @@ export function AISearchResults({
|
||||
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
|
||||
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
|
||||
}`}
|
||||
title="Nachricht kopieren"
|
||||
title={t('copyMessage')}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
@@ -431,7 +431,7 @@ export function AISearchResults({
|
||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
||||
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
||||
Empfohlene Produkte
|
||||
{t('recommendedProducts')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{msg.products.map((product, idx) => (
|
||||
@@ -509,7 +509,7 @@ export function AISearchResults({
|
||||
<AIOrb isThinking={false} hasError={true} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
|
||||
<h3 className="text-sm font-bold text-red-300">{t('errorTitle')}</h3>
|
||||
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -519,7 +519,7 @@ export function AISearchResults({
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Nochmal versuchen
|
||||
{t('tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,7 +544,7 @@ export function AISearchResults({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Nachricht eingeben..."
|
||||
placeholder={t('placeholder')}
|
||||
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
|
||||
disabled={isLoading}
|
||||
tabIndex={1}
|
||||
@@ -567,20 +567,20 @@ export function AISearchResults({
|
||||
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
||||
: 'bg-white/5 text-white/20'
|
||||
}`}
|
||||
aria-label="Nachricht senden"
|
||||
aria-label={t('send')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mt-2.5">
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
Enter zum Senden · Esc zum Schließen
|
||||
{t('footerShortcuts')}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
·
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
||||
🛡️ DSGVO-konform · EU-Server
|
||||
{t('footerPrivacy')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ layout: "fullBleed"
|
||||
showFairs={true}
|
||||
showJobs={false}
|
||||
fairs={[
|
||||
{ name: 'Intersolar München', date: '2024 / 2025', type: 'Messe', location: 'München' },
|
||||
{ name: 'Windenergietage Linstow', date: '2024 / 2025', type: 'Fachkongress', location: 'Linstow' },
|
||||
{ name: 'Intersolar München', date: '2024 / 2025', type: 'Messe', location: 'München', url: 'https://www.intersolar.de/' },
|
||||
{ name: 'Windenergietage Linstow', date: '2024 / 2025', type: 'Fachkongress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||
{ name: 'Kabel-Workshop Wiesbaden', date: '2024 / 2025', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -19,8 +19,8 @@ layout: "fullBleed"
|
||||
showFairs={true}
|
||||
showJobs={false}
|
||||
fairs={[
|
||||
{ name: 'Intersolar Munich', date: '2024 / 2025', type: 'Fair', location: 'Munich' },
|
||||
{ name: 'Wind Energy Days Linstow', date: '2024 / 2025', type: 'Specialist Congress', location: 'Linstow' },
|
||||
{ name: 'Intersolar Munich', date: '2024 / 2025', type: 'Fair', location: 'Munich', url: 'https://www.intersolar.de/' },
|
||||
{ name: 'Wind Energy Days Linstow', date: '2024 / 2025', type: 'Specialist Congress', location: 'Linstow', url: 'https://www.windenergietage.de/' },
|
||||
{ name: 'Cable Workshop Wiesbaden', date: '2024 / 2025', type: 'Trade Fair', location: 'Wiesbaden' }
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ layout: "fullBleed"
|
||||
{
|
||||
id: "danny-joseph",
|
||||
name: "Danny Joseph",
|
||||
position: "Management",
|
||||
position: "Managing Director",
|
||||
email: "d.joseph@e-tib.com",
|
||||
phone: "+49 1520 7230518",
|
||||
image: "/assets/photos/team/danny.jpg"
|
||||
|
||||
@@ -21,42 +21,42 @@ layout: "fullBleed"
|
||||
{
|
||||
title: 'ISO 14001:2015',
|
||||
description: 'Environmental Management System',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 14001 Umweltmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso14001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'ISO 9001:2015',
|
||||
description: 'Quality Management System',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 9001 Qualitätsmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso9001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'DIN EN ISO 45001:2018',
|
||||
description: 'Occupational Health and Safety',
|
||||
pdfUrl: '/assets/certificates/Zertifizierung DIN EN ISO 45001 bis 05122028.pdf',
|
||||
pdfUrl: '/assets/certificates/iso45001.pdf',
|
||||
type: 'iso',
|
||||
date: '05.12.2025',
|
||||
},
|
||||
{
|
||||
title: 'Exemption Certificate',
|
||||
description: 'According to § 48 b EStG',
|
||||
pdfUrl: '/assets/certificates/240209_Freistellungsbescheinigung § 48 b.pdf',
|
||||
pdfUrl: '/assets/certificates/freistellung.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Proof § 13b UStG',
|
||||
description: 'Tax liability of the service recipient',
|
||||
pdfUrl: '/assets/certificates/240209_Nachweis § 13 b.pdf',
|
||||
pdfUrl: '/assets/certificates/nachweis13b.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Certificate in Tax Matters',
|
||||
description: 'Tax Office Certificate',
|
||||
pdfUrl: '/assets/certificates/250213_Bescheinigung in Steuersachen.pdf',
|
||||
pdfUrl: '/assets/certificates/bescheinigung.pdf',
|
||||
type: 'tax',
|
||||
date: '13.02.2025',
|
||||
}
|
||||
|
||||
BIN
data/Mengen Homepage.docx
Normal file
BIN
data/Mengen Homepage.docx
Normal file
Binary file not shown.
BIN
data/Orte 2016 - 2022.docx
Normal file
BIN
data/Orte 2016 - 2022.docx
Normal file
Binary file not shown.
BIN
data/Orte 2023.docx
Normal file
BIN
data/Orte 2023.docx
Normal file
Binary file not shown.
BIN
data/Orte 2024.docx
Normal file
BIN
data/Orte 2024.docx
Normal file
Binary file not shown.
14
data/Orte 2024.txt
Normal file
14
data/Orte 2024.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
Windpark, 48366 Laer
|
||||
PV-Anlage, 89434 Blindheim OT Wolpertstetten
|
||||
Windpark, 24893 Taarstedt
|
||||
Windpark, 24891 Schnarup-Thumby
|
||||
Windpark, 23818 Neuengörs
|
||||
PV-Anlage, 15326 Petershagen
|
||||
Windpark, 16866 Gumtow OT Schrepkow
|
||||
PV-Anlage, 14641 Nauen
|
||||
PV-Anlage, 23619 Badendorf
|
||||
PV-Anlage, 14959 Wiesenhagen
|
||||
PV-Anlage, 17089 Bartow
|
||||
PV-Anlage, 15345 Prötzel OT Sternebeck
|
||||
Windpark, 15848 Beeskow
|
||||
PV-Anlage, 15328 Gorgast
|
||||
BIN
data/Orte 2025.docx
Normal file
BIN
data/Orte 2025.docx
Normal file
Binary file not shown.
9
data/Orte 2025.txt
Normal file
9
data/Orte 2025.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
PV-Anlage, 18334 Dettmannsdorf
|
||||
PV-Anlage, 59320 Ennigerloh-Oelde
|
||||
PV-Anlage, 93176 Beratzhausen
|
||||
Windpark, 16845 Neustadt Dosse
|
||||
PV-Anlage, 16845 Stüdenitz-Schönermark
|
||||
Windpark, 48346 Ostbevern
|
||||
Windpark, 27804 Berne
|
||||
Windpark, 17166 Dalkendorf
|
||||
Windpark, 29575 Altenmedingen
|
||||
BIN
data/Orte 2026.docx
Normal file
BIN
data/Orte 2026.docx
Normal file
Binary file not shown.
@@ -97,7 +97,7 @@
|
||||
"office": "Hauptsitz Guben",
|
||||
"address": "Gewerbestraße 22\n03172 Guben\nDeutschland",
|
||||
"phone": "+49 (0) 3561 / 68577 33",
|
||||
"email": "d.joseph@e-tib.com"
|
||||
"email": "info@e-tib.com"
|
||||
},
|
||||
"hours": {
|
||||
"title": "Öffnungszeiten",
|
||||
@@ -146,7 +146,15 @@
|
||||
"hero": {
|
||||
"title": "DIE EXPERTEN FÜR <green>KABELTIEFBAU</green>",
|
||||
"subtitle": "Wir helfen beim Ausbau der Energiekabelnetze für eine grüne Zukunft.",
|
||||
"cta": "Jetzt anfragen"
|
||||
"cta": "Jetzt anfragen",
|
||||
"searchPlaceholder": "Projekt beschreiben oder Kabel suchen...",
|
||||
"ask": "Fragen",
|
||||
"placeholder1": "Querschnittsberechnung für 110kV Trasse",
|
||||
"placeholder2": "Wie schwer ist NAYY 4x150?",
|
||||
"placeholder3": "Ich plane einen Solarpark, was brauche ich?",
|
||||
"placeholder4": "Unterschied zwischen N2XSY und NAY2XSY?",
|
||||
"placeholder5": "Mittelspannungskabel für Windkraftanlage",
|
||||
"placeholder6": "Welches Aluminiumkabel für 20kV?"
|
||||
},
|
||||
"video": {
|
||||
"title": "Vom ersten Spatenstich bis zum Netzanschluss – wir bauen die Infrastruktur von morgen."
|
||||
@@ -157,7 +165,28 @@
|
||||
"overview": "Übersicht",
|
||||
"needHelp": "Brauchen Sie Unterstützung?",
|
||||
"supportTeamAvailable": "Unser Expertenteam steht Ihnen für alle Fragen rund um Ihr Infrastrukturprojekt zur Verfügung.",
|
||||
"contactUs": "Kontaktieren Sie uns"
|
||||
"contactUs": "Kontaktieren Sie uns",
|
||||
"nextProjectTitle": "Ihr nächstes Projekt?",
|
||||
"nextProjectDesc": "Lassen Sie uns gemeinsam herausragende Infrastruktur bauen.",
|
||||
"client": "Kunde",
|
||||
"period": "Zeitraum"
|
||||
},
|
||||
"CallToAction": {
|
||||
"title": "Bereit für Ihr Projekt?",
|
||||
"description": "Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.",
|
||||
"ctaLabel": "Jetzt Kontakt aufnehmen",
|
||||
"ctaHref": "/de/kontakt"
|
||||
},
|
||||
"JobListingBlock": {
|
||||
"fairsTitle": "Nächste Messetermine",
|
||||
"emptyStateMessage": "Aktuell sind alle Positionen besetzt. Senden Sie uns gerne eine Initiativbewerbung!",
|
||||
"emptyStateLinkText": "Jetzt Kontakt aufnehmen",
|
||||
"emptyStateLinkHref": "/de/kontakt",
|
||||
"date": "Datum",
|
||||
"location": "Location",
|
||||
"booth": "Stand",
|
||||
"viewDetails": "Details ansehen",
|
||||
"title": "Aktuelle Stellenangebote"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Hoppla!",
|
||||
@@ -203,6 +232,10 @@
|
||||
"title": "Gründung E-TIB GmbH",
|
||||
"desc": "Ausführung elektrischer Infrastrukturprojekte, Kabeltiefbau und Horizontalspülbohrungen."
|
||||
},
|
||||
"2025": {
|
||||
"title": "Gründung E-TIB Bohrtechnik GmbH",
|
||||
"desc": "Spezialisierung auf präzise Horizontalspülbohrungen in allen Bodenklassen."
|
||||
},
|
||||
"2019_ing": {
|
||||
"title": "Gründung E-TIB Ingenieurgesellschaft",
|
||||
"desc": "Genehmigungs- und Ausführungsplanung, komplexe Querungen sowie Netzanschlussplanung."
|
||||
@@ -210,10 +243,6 @@
|
||||
"2019_verw": {
|
||||
"title": "Gründung E-TIB Verwaltung GmbH",
|
||||
"desc": "Zentrale Dienste, Erwerb, Vermietung, Verpachtung und Verwaltung von Immobilien und Maschinen."
|
||||
},
|
||||
"2025": {
|
||||
"title": "Gründung E-TIB Bohrtechnik GmbH",
|
||||
"desc": "Spezialisierung auf präzise Horizontalspülbohrungen in allen Bodenklassen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -242,5 +271,55 @@
|
||||
"method": "Tiefbau & Spülbohrung (HDD)"
|
||||
},
|
||||
"learnMore": "Zum Projekt"
|
||||
},
|
||||
"TeamGrid": {
|
||||
"badge": "Persönliche Beratung",
|
||||
"title": "Ihre Ansprechpartner",
|
||||
"subtitle": "Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.",
|
||||
"management": "Geschäftsführung",
|
||||
"branchETIB": "E-TIB GmbH",
|
||||
"branchIng": "Ingenieurgesellschaft",
|
||||
"branchBohr": "Bohrtechnik"
|
||||
},
|
||||
"AISearch": {
|
||||
"loadingTexts": [
|
||||
"Durchsuche das Kabelhandbuch... 📖",
|
||||
"Frage den Senior-Ingenieur... 👴🔧",
|
||||
"Frage ChatGPTs Cousin 2. Grades... 🤖"
|
||||
],
|
||||
"thinking": "Denkt nach...",
|
||||
"errorStatus": "Fehler aufgetreten",
|
||||
"online": "Online",
|
||||
"copyChat": "Chat kopieren",
|
||||
"copyChatTitle": "gesamten Chat kopieren",
|
||||
"copied": "Kopiert",
|
||||
"close": "Schließen",
|
||||
"howCanIHelp": "Wie kann ich helfen?",
|
||||
"helpDescription": "Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine Anforderungen.",
|
||||
"prompts": [
|
||||
"Windpark 33kV Verkabelung",
|
||||
"NYCWY 4x185",
|
||||
"Erdkabel für Solarpark"
|
||||
],
|
||||
"you": "Du",
|
||||
"copyMessage": "Nachricht kopieren",
|
||||
"recommendedProducts": "Empfohlene Produkte",
|
||||
"errorTitle": "Da ist was schiefgelaufen 😬",
|
||||
"tryAgain": "Nochmal versuchen",
|
||||
"placeholder": "Nachricht eingeben...",
|
||||
"send": "Nachricht senden",
|
||||
"footerShortcuts": "Enter zum Senden · Esc zum Schließen",
|
||||
"footerPrivacy": "🛡️ DSGVO-konform · EU-Server",
|
||||
"timeoutError": "Anfrage hat zu lange gedauert. Bitte versuche es erneut.",
|
||||
"genericError": "Ein Fehler ist aufgetreten."
|
||||
},
|
||||
"ReferenceDetail": {
|
||||
"backToOverview": "Zurück zur Übersicht",
|
||||
"projectReference": "Projektreferenz",
|
||||
"location": "Ort",
|
||||
"client": "Auftraggeber",
|
||||
"period": "Zeitraum",
|
||||
"scopeTitle": "Leistungsumfang & Projektbeschreibung",
|
||||
"viewAll": "Alle Referenzen ansehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@
|
||||
"office": "Headquarters Guben",
|
||||
"address": "Gewerbestraße 22\n03172 Guben\nGermany",
|
||||
"phone": "+49 (0) 3561 / 68577 33",
|
||||
"email": "d.joseph@e-tib.com"
|
||||
"email": "info@e-tib.com"
|
||||
},
|
||||
"hours": {
|
||||
"title": "Opening Hours",
|
||||
@@ -146,7 +146,15 @@
|
||||
"hero": {
|
||||
"title": "THE EXPERTS FOR <green>UNDERGROUND CABLE ENGINEERING</green>",
|
||||
"subtitle": "We help expanding the energy cable networks for a green future.",
|
||||
"cta": "Request now"
|
||||
"cta": "Request now",
|
||||
"searchPlaceholder": "Describe project or search for cables...",
|
||||
"ask": "Ask",
|
||||
"placeholder1": "Cross-section calculation for 110kV route",
|
||||
"placeholder2": "How heavy is NAYY 4x150?",
|
||||
"placeholder3": "I'm planning a solar park, what do I need?",
|
||||
"placeholder4": "Difference between N2XSY and NAY2XSY?",
|
||||
"placeholder5": "Medium voltage cable for wind turbine",
|
||||
"placeholder6": "Which aluminum cable for 20kV?"
|
||||
},
|
||||
"video": {
|
||||
"title": "From the first spade cut to the grid connection – we build the infrastructure of tomorrow."
|
||||
@@ -157,7 +165,28 @@
|
||||
"overview": "Overview",
|
||||
"needHelp": "Need support?",
|
||||
"supportTeamAvailable": "Our team of experts is available to answer all your questions regarding your infrastructure project.",
|
||||
"contactUs": "Contact Us"
|
||||
"contactUs": "Contact Us",
|
||||
"nextProjectTitle": "Your next project?",
|
||||
"nextProjectDesc": "Let's build outstanding infrastructure together.",
|
||||
"client": "Client",
|
||||
"period": "Period"
|
||||
},
|
||||
"CallToAction": {
|
||||
"title": "Ready for your project?",
|
||||
"description": "We are always looking for new challenges and strong partners. Contact us for a non-binding consultation about your project.",
|
||||
"ctaLabel": "Contact us now",
|
||||
"ctaHref": "/en/contact"
|
||||
},
|
||||
"JobListingBlock": {
|
||||
"fairsTitle": "Upcoming Trade Fairs",
|
||||
"emptyStateMessage": "All positions are currently filled. Feel free to send us an unsolicited application!",
|
||||
"emptyStateLinkText": "Contact us now",
|
||||
"emptyStateLinkHref": "/en/contact",
|
||||
"date": "Date",
|
||||
"location": "Location",
|
||||
"booth": "Booth",
|
||||
"viewDetails": "View details",
|
||||
"title": "Current Job Openings"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Oops!",
|
||||
@@ -203,6 +232,10 @@
|
||||
"title": "Foundation of E-TIB GmbH",
|
||||
"desc": "Execution of electrical infrastructure projects, cable civil engineering, and horizontal directional drilling."
|
||||
},
|
||||
"2025": {
|
||||
"title": "Foundation of E-TIB Bohrtechnik GmbH",
|
||||
"desc": "Specialization in precise horizontal directional drilling in all soil classes."
|
||||
},
|
||||
"2019_ing": {
|
||||
"title": "Foundation of E-TIB Ingenieurgesellschaft",
|
||||
"desc": "Permit and execution planning, complex crossings, and grid connection planning."
|
||||
@@ -210,10 +243,6 @@
|
||||
"2019_verw": {
|
||||
"title": "Foundation of E-TIB Verwaltung GmbH",
|
||||
"desc": "Central services, acquisition, leasing, and management of real estate and machinery."
|
||||
},
|
||||
"2025": {
|
||||
"title": "Foundation of E-TIB Bohrtechnik GmbH",
|
||||
"desc": "Specialization in precise horizontal directional drilling in all soil classes."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -242,5 +271,55 @@
|
||||
"method": "Civil engineering & HDD"
|
||||
},
|
||||
"learnMore": "Learn more"
|
||||
},
|
||||
"TeamGrid": {
|
||||
"badge": "Personal Consultation",
|
||||
"title": "Your Contacts",
|
||||
"subtitle": "Speak directly with our experts for your regional project.",
|
||||
"management": "Management",
|
||||
"branchETIB": "E-TIB GmbH",
|
||||
"branchIng": "Engineering Company",
|
||||
"branchBohr": "Drilling Technology"
|
||||
},
|
||||
"AISearch": {
|
||||
"loadingTexts": [
|
||||
"Searching the cable manual... 📖",
|
||||
"Asking the senior engineer... 👴🔧",
|
||||
"Asking ChatGPT's 2nd cousin... 🤖"
|
||||
],
|
||||
"thinking": "Thinking...",
|
||||
"errorStatus": "Error occurred",
|
||||
"online": "Online",
|
||||
"copyChat": "Copy chat",
|
||||
"copyChatTitle": "copy entire chat",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"howCanIHelp": "How can I help?",
|
||||
"helpDescription": "Describe your project, ask for specific cables, or tell me your requirements.",
|
||||
"prompts": [
|
||||
"Wind park 33kV cabling",
|
||||
"NYCWY 4x185",
|
||||
"Underground cable for solar park"
|
||||
],
|
||||
"you": "You",
|
||||
"copyMessage": "Copy message",
|
||||
"recommendedProducts": "Recommended Products",
|
||||
"errorTitle": "Something went wrong 😬",
|
||||
"tryAgain": "Try again",
|
||||
"placeholder": "Enter message...",
|
||||
"send": "Send message",
|
||||
"footerShortcuts": "Enter to send · Esc to close",
|
||||
"footerPrivacy": "🛡️ GDPR compliant · EU servers",
|
||||
"timeoutError": "Request took too long. Please try again.",
|
||||
"genericError": "An error occurred."
|
||||
},
|
||||
"ReferenceDetail": {
|
||||
"backToOverview": "Back to Overview",
|
||||
"projectReference": "Project Reference",
|
||||
"location": "Location",
|
||||
"client": "Client",
|
||||
"period": "Period",
|
||||
"scopeTitle": "Scope & Project Description",
|
||||
"viewAll": "View all References"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
scratch/test-slugs.ts
Normal file
6
scratch/test-slugs.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { mapSlugToFileSlug } from '../lib/slugs';
|
||||
async function test() {
|
||||
const result = await mapSlugToFileSlug('imprint', 'en');
|
||||
console.log("imprint maps to:", result);
|
||||
}
|
||||
test();
|
||||
Reference in New Issue
Block a user