Compare commits
35 Commits
v2.2.13-rc
...
feature/an
| Author | SHA1 | Date | |
|---|---|---|---|
| 58be922fb3 | |||
| 111672a614 | |||
| d1387d2685 | |||
| fda2944e5a | |||
| 8ae5ab73e3 | |||
| d8d35da574 | |||
| 792d91f9d5 | |||
| d10d038617 | |||
| 490ce4abb6 | |||
| bed9be1f81 | |||
| f52a3e3c46 | |||
| 71825724db | |||
| 1bb26e8db4 | |||
| 604b96afe0 | |||
| 59df260164 | |||
| 3449d2eaa4 | |||
| 1723be4008 | |||
| 830d425859 | |||
| d6b9aca50f | |||
| caefd5d525 | |||
| 8d8e1dd460 | |||
| c2a82ae9ae | |||
| 2ecbe9c63c | |||
| 255403c2e4 | |||
| 38a870bea8 | |||
| 2b8dc19032 | |||
| 5101b8bd1e | |||
| afe3565155 | |||
| e677089ff9 | |||
| b7d9a4b0d0 | |||
| 6c8614f147 | |||
| aa4e121fea | |||
| 6697703c91 | |||
| e779c6ac22 | |||
| 02c4f06108 |
@@ -13,6 +13,10 @@ on:
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||
@@ -89,12 +93,12 @@ jobs:
|
||||
DOMAIN="e-tib.com"
|
||||
PRJ="etib"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="test.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF"
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ pnpm-debug.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# turborepo
|
||||
.turbo
|
||||
.turbo-test
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-lint.log":{"size":11972,"mtime_nanos":1779970374061729177,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"0864e4a10f76dcaf","duration":6769,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-test.log":{"size":2776,"mtime_nanos":1781469605328811961,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"41698960411f6bf6","duration":4168,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1779970373499305257,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"7668a73054ba889d","duration":6206,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-typecheck.log":{"size":90,"mtime_nanos":1781782738306875884,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"7d27d7d8e3c30797","duration":14130,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-test.log":{"size":2150,"mtime_nanos":1779970370282819916,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"9c3fa4d9e1993aff","duration":2989,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-lint.log":{"size":12987,"mtime_nanos":1781469609469080321,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"bb46018d4a7bc516","duration":8307,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-lint.log":{"size":15001,"mtime_nanos":1781782739446599688,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"c3b4dbd60e2d475a","duration":15270,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||
Binary file not shown.
1
.turbo/cache/32905076da5f4894-manifest.json
vendored
1
.turbo/cache/32905076da5f4894-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-typecheck.log":{"size":92,"mtime_nanos":1779097674383861303,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||
1
.turbo/cache/32905076da5f4894-meta.json
vendored
1
.turbo/cache/32905076da5f4894-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"32905076da5f4894","duration":4492,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}
|
||||
BIN
.turbo/cache/32905076da5f4894.tar.zst
vendored
BIN
.turbo/cache/32905076da5f4894.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/7e2629942c881f80-manifest.json
vendored
1
.turbo/cache/7e2629942c881f80-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-test.log":{"size":818,"mtime_nanos":1779097671747078359,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/7e2629942c881f80-meta.json
vendored
1
.turbo/cache/7e2629942c881f80-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"7e2629942c881f80","duration":1855,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}
|
||||
BIN
.turbo/cache/7e2629942c881f80.tar.zst
vendored
BIN
.turbo/cache/7e2629942c881f80.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/85340b254e0f10f1-manifest.json
vendored
1
.turbo/cache/85340b254e0f10f1-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-lint.log":{"size":10746,"mtime_nanos":1779097675143794164,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||
1
.turbo/cache/85340b254e0f10f1-meta.json
vendored
1
.turbo/cache/85340b254e0f10f1-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"85340b254e0f10f1","duration":5252,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}
|
||||
BIN
.turbo/cache/85340b254e0f10f1.tar.zst
vendored
BIN
.turbo/cache/85340b254e0f10f1.tar.zst
vendored
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-test.log":{"size":3396,"mtime_nanos":1781782731048784172,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"ea9126c55a797120","duration":6873,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1781469608726900998,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||
@@ -1 +0,0 @@
|
||||
{"hash":"eb4eeeb717c1af0c","duration":7565,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
|
||||
> e-tib-nextjs@2.2.12 lint /Volumes/Alpha SSD/Coding/e-tib.com
|
||||
> eslint .
|
||||
|
||||
ELIFECYCLE Command failed.
|
||||
@@ -1,55 +0,0 @@
|
||||
|
||||
> e-tib-nextjs@2.2.12 test /Volumes/Alpha SSD/Coding/e-tib.com
|
||||
> vitest run --passWithNoTests
|
||||
|
||||
|
||||
[1m[46m RUN [49m[22m [36mv4.1.2 [39m[90m/Volumes/Alpha SSD/Coding/e-tib.com[39m
|
||||
|
||||
(node:64961) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
|
||||
(Use `node --trace-deprecation ...` to show where the warning was created)
|
||||
[32m✓[39m tests/mdx-links.test.ts [2m([22m[2m1 test[22m[2m)[22m[32m 29[2mms[22m[39m
|
||||
[32m✓[39m tests/homepage-and-competence-content.test.ts [2m([22m[2m4 tests[22m[2m)[22m[32m 14[2mms[22m[39m
|
||||
[31m❯[39m tests/legal-pages.test.ts [2m([22m[2m2 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 9[2mms[22m[39m
|
||||
[32m✓[39m should contain highly professional B2B sections in German AGB[32m 5[2mms[22m[39m
|
||||
[31m [31m×[31m should contain professional corresponding B2B sections in English Terms[39m[32m 2[2mms[22m[39m
|
||||
[32m✓[39m tests/task-12-links.test.ts [2m([22m[2m2 tests[22m[2m)[22m[32m 8[2mms[22m[39m
|
||||
[32m✓[39m tests/messen-content.test.ts [2m([22m[2m2 tests[22m[2m)[22m[32m 8[2mms[22m[39m
|
||||
[32m✓[39m lib/mail/mailer.test.ts [2m([22m[2m1 test[22m[2m)[22m[32m 7[2mms[22m[39m
|
||||
[90mstderr[2m | components/layout/LanguageSwitcher.test.tsx[2m > [22m[2mLanguageSwitcher TDD[2m > [22m[2mtranslates /en/trade-fairs to /de/messen when switching to German
|
||||
[22m[39mReact does not recognize the `transitionMessage` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `transitionmessage` instead. If you accidentally passed it from a parent component, remove it from the DOM element.
|
||||
|
||||
[32m✓[39m components/layout/LanguageSwitcher.test.tsx [2m([22m[2m5 tests[22m[2m)[22m[32m 25[2mms[22m[39m
|
||||
[32m✓[39m components/search/AISearchResults.test.tsx [2m([22m[2m1 test[22m[2m)[22m[32m 52[2mms[22m[39m
|
||||
[90mstderr[2m | components/blocks/ReferencesSlider.test.tsx[2m > [22m[2mReferencesSlider TDD[2m > [22m[2mrenders reference tiles correctly
|
||||
[22m[39mReceived `true` for a non-boolean attribute `fill`.
|
||||
|
||||
If you want to write it to the DOM, pass a string instead: fill="true" or fill={value.toString()}.
|
||||
|
||||
[32m✓[39m components/blocks/ReferencesSlider.test.tsx [2m([22m[2m2 tests[22m[2m)[22m[32m 43[2mms[22m[39m
|
||||
[32m✓[39m lib/env.test.ts [2m([22m[2m6 tests[22m[2m)[22m[32m 4[2mms[22m[39m
|
||||
[90mstdout[2m | app/[locale]/referenzen/[slug]/page.test.tsx
|
||||
[22m[39m[Config] Initializing Toggles: { feedbackEnabled: [33mfalse[39m }
|
||||
|
||||
[32m✓[39m app/[locale]/referenzen/[slug]/page.test.tsx [2m([22m[2m1 test[22m[2m)[22m[32m 26[2mms[22m[39m
|
||||
|
||||
[31m⎯⎯⎯⎯⎯⎯⎯[39m[1m[41m Failed Tests 1 [49m[22m[31m⎯⎯⎯⎯⎯⎯⎯[39m
|
||||
|
||||
[41m[1m FAIL [22m[49m tests/legal-pages.test.ts[2m > [22mLegal Pages Content Validation[2m > [22mshould contain professional corresponding B2B sections in English Terms
|
||||
[31m[1mError[22m: ENOENT: no such file or directory, open '/Volumes/Alpha SSD/Coding/e-tib.com/content/en/terms.mdx'[39m
|
||||
[36m [2m❯[22m tests/legal-pages.test.ts:[2m25:24[22m[39m
|
||||
[90m 23|[39m
|
||||
[90m 24|[39m it('should contain professional corresponding B2B sections in Englis…
|
||||
[90m 25|[39m [35mconst[39m content [33m=[39m fs[33m.[39m[34mreadFileSync[39m(enTermsPath[33m,[39m [32m'utf8'[39m)[33m;[39m
|
||||
[90m |[39m [31m^[39m
|
||||
[90m 26|[39m
|
||||
[90m 27|[39m [34mexpect[39m(content)[33m.[39m[34mtoContain[39m([32m'title: "Terms and Conditions"'[39m)[33m;[39m
|
||||
|
||||
[31m[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯[22m[39m
|
||||
|
||||
|
||||
[2m Test Files [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m10 passed[39m[22m[90m (11)[39m
|
||||
[2m Tests [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m26 passed[39m[22m[90m (27)[39m
|
||||
[2m Start at [22m 14:54:33
|
||||
[2m Duration [22m 5.94s[2m (transform 5.27s, setup 0ms, import 9.43s, tests 222ms, environment 13.80s)[22m
|
||||
|
||||
ELIFECYCLE Test failed. See above for more details.
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
> e-tib-nextjs@2.2.12 typecheck /Volumes/Alpha SSD/Coding/e-tib.com
|
||||
> tsc --noEmit
|
||||
|
||||
ELIFECYCLE Command failed.
|
||||
@@ -17,7 +17,7 @@ ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
COPY pnpm-lock.yaml package.json .npmrc* *.tgz ./
|
||||
COPY patches* ./patches/
|
||||
|
||||
ARG NPM_DOMAIN=git.infra.mintel.me
|
||||
|
||||
13
app/[locale]/errors/api/relay/route.ts
Normal file
13
app/[locale]/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rawText = await req.text();
|
||||
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
|
||||
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
|
||||
} catch (e) {
|
||||
console.log("Failed to parse relay body (NDJSON)", e);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getMessages } from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
@@ -37,7 +38,7 @@ export async function generateMetadata(props: {
|
||||
template: '%s | E-TIB',
|
||||
default: 'E-TIB | Die Experten für Kabelnetzbau',
|
||||
},
|
||||
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
@@ -214,6 +215,7 @@ export default async function Layout(props: {
|
||||
<Footer companyInfo={companyInfo} />
|
||||
<JsonLd />
|
||||
<AnalyticsShell />
|
||||
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
|
||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||
</TransitionProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,212 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getReferenceBySlug, getAllReferences } from '@/lib/references';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { MapPin, Calendar, Briefcase, ChevronLeft } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
if (locale !== 'de' && locale !== 'en') return {};
|
||||
|
||||
const reference = await getReferenceBySlug(slug, locale);
|
||||
if (!reference) return {};
|
||||
|
||||
return {
|
||||
title: `${reference.frontmatter.title} | Referenzen | E-TIB Gruppe`,
|
||||
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/referenzen/${slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${reference.frontmatter.title} | E-TIB`,
|
||||
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
|
||||
url: `${SITE_URL}/${locale}/referenzen/${slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams(props: any) {
|
||||
const params = await props.params;
|
||||
const locale = params?.locale || 'de'; // fallback to 'de' if undefined
|
||||
const references = await getAllReferences(locale);
|
||||
return references.map((ref) => ({
|
||||
slug: ref.slug,
|
||||
locale
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function ReferenceDetail(props: { params: Promise<{ locale: string; slug: string }> }) {
|
||||
const { locale, slug } = await props.params;
|
||||
|
||||
if (locale !== 'de' && locale !== 'en') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const reference = await getReferenceBySlug(slug, locale);
|
||||
const t = await getTranslations('ReferenceDetail');
|
||||
|
||||
if (!reference) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// MDX components mapping for industrial typography
|
||||
const mdxComponents = {
|
||||
Heading,
|
||||
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
||||
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4 text-neutral-dark" {...props} />,
|
||||
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
|
||||
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
|
||||
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl" {...props} />,
|
||||
ul: (props: any) => <ul className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 my-12" {...props} />,
|
||||
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-medium max-w-3xl" {...props} />,
|
||||
li: (props: any) => {
|
||||
// Smart Metric Card Logic
|
||||
let text = '';
|
||||
if (typeof props.children === 'string') {
|
||||
text = props.children;
|
||||
} else if (Array.isArray(props.children)) {
|
||||
text = props.children.map((c: any) => typeof c === 'string' ? c : '').join('');
|
||||
}
|
||||
|
||||
const match = text.match(/^([\d.,]+)\s*(m|Satz|Stück|km|kV|Fasern)\b(.*)/);
|
||||
|
||||
if (match && typeof props.children === 'string') {
|
||||
const [, value, unit, rest] = match;
|
||||
return (
|
||||
<li className="bg-white p-6 md:p-8 rounded-3xl border border-neutral-100 flex flex-col justify-center hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:border-primary/30 transition-all duration-500 group hover:-translate-y-1 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-neutral-50 to-transparent rounded-bl-full -z-10 group-hover:from-primary/5 transition-colors duration-500" />
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
<span className="text-4xl md:text-5xl font-bold font-heading text-primary tracking-tight">{value}</span>
|
||||
<span className="text-lg md:text-xl font-bold text-neutral-400">{unit}</span>
|
||||
</div>
|
||||
<span className="text-neutral-600 font-semibold text-sm md:text-base leading-snug">{rest.replace(/^[\s-]*\s/, '')}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="bg-neutral-50 p-6 rounded-3xl border border-neutral-100 flex items-start gap-4 hover:shadow-md transition-all duration-300 group hover:-translate-y-1">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5 group-hover:bg-primary/20 transition-colors">
|
||||
<svg className="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-neutral-700 font-semibold leading-relaxed">{props.children}</span>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
a: (props: any) => <a className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold" {...props} />,
|
||||
strong: (props: any) => <strong className="font-bold text-neutral-900" {...props} />,
|
||||
blockquote: (props: any) => (
|
||||
<blockquote className="border-l-4 border-primary pl-6 py-3 my-10 italic bg-neutral-50 rounded-r-2xl text-neutral-700 font-medium max-w-3xl shadow-sm" {...props} />
|
||||
),
|
||||
hr: (props: any) => <hr className="my-16 border-t-2 border-neutral-100 max-w-3xl" {...props} />,
|
||||
img: (props: any) => <img className="rounded-2xl shadow-2xl my-12 max-w-full h-auto border border-neutral-100" {...props} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-[#050B14] text-white pt-28 pb-10 md:pt-52 md:pb-24 min-h-[30vh] md:min-h-[45vh] flex flex-col justify-end relative overflow-hidden">
|
||||
{reference.frontmatter.featuredImage && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={reference.frontmatter.featuredImage}
|
||||
alt={reference.frontmatter.title}
|
||||
fill
|
||||
className="object-cover opacity-30 mix-blend-luminosity scale-105"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] via-[#050B14]/70 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050B14] via-[#050B14]/40 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 z-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/30 via-transparent to-transparent" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="mb-12">
|
||||
<TrackedLink
|
||||
href={`/${locale}/referenzen`}
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-xs font-bold"
|
||||
eventProperties={{ location: 'reference_back_btn' }}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t('backToOverview')}
|
||||
</TrackedLink>
|
||||
</div>
|
||||
<div className="max-w-5xl">
|
||||
<Badge variant="accent" className="mb-6">
|
||||
{t('projectReference')}
|
||||
</Badge>
|
||||
<Heading level={1} variant="white" className="mb-10 leading-[1.1] md:text-6xl lg:text-7xl drop-shadow-lg">
|
||||
{reference.frontmatter.title}
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 mt-12 md:mt-16">
|
||||
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
|
||||
<MapPin className="w-8 h-8 text-primary shrink-0 opacity-80" />
|
||||
<div>
|
||||
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('location')}</p>
|
||||
<p className="font-semibold text-lg md:text-xl text-white/90">{reference.frontmatter.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
|
||||
<Briefcase className="w-8 h-8 text-primary shrink-0 opacity-80" />
|
||||
<div>
|
||||
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('client')}</p>
|
||||
<p className="font-semibold text-lg md:text-xl text-white/90 line-clamp-2">{reference.frontmatter.client}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
|
||||
<Calendar className="w-8 h-8 text-primary shrink-0 opacity-80" />
|
||||
<div>
|
||||
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('period')}</p>
|
||||
<p className="font-semibold text-lg md:text-xl text-white/90">{reference.frontmatter.dateString || new Date(reference.frontmatter.date).getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* 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">{t('scopeTitle')}</h2>
|
||||
|
||||
<div className="w-full">
|
||||
<MDXRemote source={reference.content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex justify-center">
|
||||
<TrackedLink
|
||||
href={`/${locale}/referenzen`}
|
||||
className={getButtonClasses('primary', 'lg')}
|
||||
eventProperties={{ location: 'reference_bottom_back_btn' }}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{t('viewAll')}
|
||||
</span>
|
||||
<ButtonOverlay variant="primary" />
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,14 @@ import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
const mdxComponents = {
|
||||
ul: (props: any) => <ul className="list-disc pl-5 mb-6 text-neutral-600 text-sm space-y-2" {...props} />,
|
||||
li: (props: any) => <li className="pl-1" {...props} />,
|
||||
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
@@ -90,11 +98,9 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
<Container className="relative z-20 mt-16 md:mt-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{references.map((ref) => (
|
||||
<TrackedLink
|
||||
<div
|
||||
key={ref.slug}
|
||||
href={`/${locale}/referenzen/${ref.slug}`}
|
||||
eventProperties={{ location: 'reference_list_card', slug: ref.slug }}
|
||||
className="group flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer block"
|
||||
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block"
|
||||
>
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Image Section */}
|
||||
@@ -111,7 +117,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
<Image src="/assets/logo.png" alt="E-TIB Logo" width={80} height={80} className="opacity-20 grayscale" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/5 transition-colors duration-500" />
|
||||
<div className="absolute inset-0 bg-black/5" />
|
||||
|
||||
{/* Location Badge */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
@@ -124,10 +130,17 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 transition-colors line-clamp-3 leading-[1.2] group-hover:text-primary">
|
||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 leading-[1.2]">
|
||||
{ref.frontmatter.title}
|
||||
</h3>
|
||||
|
||||
{/* Project Details / Content */}
|
||||
{ref.content && (
|
||||
<div className="flex-grow mb-6">
|
||||
<MDXRemote source={ref.content} components={mdxComponents} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Meta Data */}
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||
<div className="min-w-0">
|
||||
@@ -147,7 +160,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TrackedLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS - Removed along with Payload CMS
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
_email: email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
36
app/actions/getAnnotatorAssets.ts
Normal file
36
app/actions/getAnnotatorAssets.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function getAnnotatorAssets() {
|
||||
// Try /app/public first (docker), then process.cwd()/public
|
||||
let publicDir = '/app/public';
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
publicDir = path.join(process.cwd(), 'public');
|
||||
}
|
||||
|
||||
const walk = async (dir: string): Promise<string[]> => {
|
||||
let results: string[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const list = await fs.promises.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
const nested = await walk(filePath);
|
||||
results = results.concat(nested);
|
||||
} else {
|
||||
if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) {
|
||||
results.push(filePath.replace(publicDir, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const results = await walk(publicDir);
|
||||
|
||||
// Console logs removed for absolute silence & speed
|
||||
return results;
|
||||
}
|
||||
52
app/actions/submitAnnotations.ts
Normal file
52
app/actions/submitAnnotations.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function submitAnnotations(annotations: any[]) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'submitAnnotations' });
|
||||
|
||||
if (!annotations || annotations.length === 0) {
|
||||
return { success: false, error: 'Keine Korrekturen vorhanden.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonContent = JSON.stringify(annotations, null, 2);
|
||||
const htmlContent = `
|
||||
<h2>Neue Website-Korrekturen (${annotations.length})</h2>
|
||||
<p>Es wurden neue Korrekturen über den Annotator eingereicht.</p>
|
||||
<ul>
|
||||
${annotations.map(ann => `
|
||||
<li>
|
||||
<b>${new URL(ann.url).pathname}</b> [${ann.type}]<br />
|
||||
Element: <code>${ann.selector}</code><br />
|
||||
<i>${ann.type === 'image' ? 'Neues Bild: ' : 'Text: '}${ann.newContent}</i>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
<p><i>Die genauen Daten (inkl. Scroll-Positionen, Viewport und Zeitstempel) befinden sich hier:</i></p>
|
||||
<pre style="background: #f4f4f4; padding: 10px; border-radius: 5px; font-size: 12px; overflow-x: auto;">
|
||||
${jsonContent}
|
||||
</pre>
|
||||
`;
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: 'info@e-tib.com', // Falls Rückfragen nötig
|
||||
subject: `[Korrekturen] ${annotations.length} neue Anmerkungen für E-TIB`,
|
||||
html: htmlContent
|
||||
});
|
||||
|
||||
if (!notificationResult.success) {
|
||||
logger.error('Annotation email FAILED', { error: notificationResult.error });
|
||||
return { success: false, error: notificationResult.error };
|
||||
}
|
||||
|
||||
logger.info('Annotation email sent successfully', { count: annotations.length });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to send annotations', { error: errorMsg });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>TEST PAGE WORKS</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/AnnotatorClientWrapper.tsx
Normal file
31
components/AnnotatorClientWrapper.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets';
|
||||
import { submitAnnotations } from '@/app/actions/submitAnnotations';
|
||||
|
||||
const Annotator = dynamic(() => import('@/components/annotator-local/Annotator').then(mod => mod.Annotator), { ssr: false });
|
||||
|
||||
export default function AnnotatorClientWrapper() {
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAnnotatorAssets()
|
||||
.then(fetchedAssets => {
|
||||
if (Array.isArray(fetchedAssets)) {
|
||||
setAssets(fetchedAssets);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[AnnotatorClientWrapper] Fetch failed:', err));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (annotations: any[]) => {
|
||||
const result = await submitAnnotations(annotations);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
return <Annotator assets={assets} onSubmit={handleSubmit} />;
|
||||
}
|
||||
626
components/annotator-local/Annotator.tsx
Normal file
626
components/annotator-local/Annotator.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { finder } from "@medv/finder";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
selector: string;
|
||||
type: "text" | "image" | "note";
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
url: string;
|
||||
elementRect: Record<string, number> | null;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnnotatorProps {
|
||||
assets?: string[]; // Array of image URLs for the media browser
|
||||
onSubmit?: (annotations: Annotation[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("mintel-annotations");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved annotations", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("mintel-annotations", JSON.stringify(annotations));
|
||||
}
|
||||
}, [annotations]);
|
||||
|
||||
// Modal state
|
||||
const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text");
|
||||
const [currentText, setCurrentText] = useState("");
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Disable overlays if needed (e.g. inside iframes)
|
||||
const isExcluded = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.self !== window.top;
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
try {
|
||||
return finder(el, {
|
||||
root: document.body,
|
||||
className: (name) =>
|
||||
!name.startsWith("annotator-") &&
|
||||
!name.includes("[") &&
|
||||
!name.includes("/") &&
|
||||
!name.match(/^[a-z]-[0-9]/),
|
||||
idName: (name) => !name.startsWith("__next") && !name.includes(":"),
|
||||
});
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedElement(target);
|
||||
setHoveredElement(null);
|
||||
|
||||
// Auto-detect type
|
||||
if (target.tagName.toLowerCase() === "img") {
|
||||
setCurrentType("image");
|
||||
} else {
|
||||
setCurrentType("text");
|
||||
setCurrentText(target.innerText || "");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("click", handleClick, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (selectedElement) {
|
||||
setSelectedElement(null);
|
||||
} else if (isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const saveAnnotation = () => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
const rect = selectedElement.getBoundingClientRect();
|
||||
const newContent = currentType === "image" ? selectedAsset || "" : currentText;
|
||||
|
||||
if (!newContent) return;
|
||||
|
||||
const annotation: Annotation = {
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
selector: getSelector(selectedElement),
|
||||
type: currentType,
|
||||
originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText,
|
||||
newContent,
|
||||
url: window.location.href,
|
||||
elementRect: rect ? {
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
||||
top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left
|
||||
} : null,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setAnnotations([...annotations, annotation]);
|
||||
setSelectedElement(null);
|
||||
setCurrentText("");
|
||||
setSelectedAsset(null);
|
||||
};
|
||||
|
||||
const exportJSON = () => {
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2));
|
||||
const downloadAnchorNode = document.createElement("a");
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", "corrections.json");
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!onSubmit || annotations.length === 0) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(annotations);
|
||||
setAnnotations([]);
|
||||
setShowList(false);
|
||||
} catch (error) {
|
||||
console.error("Submission failed", error);
|
||||
alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotation = (id: string) => {
|
||||
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const [updateTick, setUpdateTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive && !selectedElement) return;
|
||||
|
||||
let rafId: number;
|
||||
const handleScroll = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1));
|
||||
};
|
||||
|
||||
// Use capture phase to catch scrolls on any nested container
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]);
|
||||
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]);
|
||||
|
||||
if (isExcluded) return null;
|
||||
|
||||
return (
|
||||
<div className="annotator-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] w-max max-w-[95vw]">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-1 sm:gap-2 overflow-x-auto hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium",
|
||||
isActive
|
||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
||||
{isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
||||
>
|
||||
<List size={20} />
|
||||
{annotations.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
|
||||
{annotations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all font-medium"
|
||||
>
|
||||
<Download size={18} />
|
||||
JSON Export
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Highlights */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||
{hoveredRect && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
||||
style={{
|
||||
top: hoveredRect.top,
|
||||
left: hoveredRect.left,
|
||||
width: hoveredRect.width,
|
||||
height: hoveredRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRect && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||
style={{
|
||||
top: selectedRect.top,
|
||||
left: selectedRect.left,
|
||||
width: selectedRect.width,
|
||||
height: selectedRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Action Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedElement && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-4 sm:p-6 w-[95vw] md:w-[800px] shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-bold text-lg">Änderung erfassen</h3>
|
||||
<button
|
||||
onClick={() => setSelectedElement(null)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setCurrentType("text")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "text"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<Type size={16} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("image")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "image"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<ImageIcon size={16} /> Bild
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("note")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "note"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<StickyNote size={16} /> Notiz
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-[200px] mb-6 hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{currentType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neuer Text</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "note" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Anmerkung</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
placeholder="Was soll hier gemacht werden?"
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "image" && (
|
||||
<div className="space-y-2 h-full">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neues Bild wählen</label>
|
||||
{assets.length === 0 ? (
|
||||
<div className="text-sm text-white/40 p-4 bg-white/5 rounded-xl border border-white/5 text-center">
|
||||
Keine Assets übergeben. Bitte das prop <code>assets</code> befüllen.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<style>{`.hide-scrollbar::-webkit-scrollbar { display: none; }`}</style>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen nach Dateinamen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 h-[400px] overflow-y-auto hide-scrollbar"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{assets.filter(asset => asset.toLowerCase().includes(searchTerm.toLowerCase())).map((asset) => {
|
||||
const filename = asset.split('/').pop() || asset;
|
||||
return (
|
||||
<button
|
||||
key={asset}
|
||||
title={asset}
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center bg-black/20 rounded-xl overflow-hidden border-2 transition-all p-2 gap-2 w-full",
|
||||
selectedAsset === asset
|
||||
? "border-blue-500 shadow-[0_0_0_4px_rgba(59,130,246,0.3)] bg-blue-500/10"
|
||||
: "border-transparent hover:border-white/20 hover:bg-white/5"
|
||||
)}
|
||||
style={{ height: '130px' }}
|
||||
>
|
||||
<div className="w-full flex-1 rounded-lg overflow-hidden bg-black/40 flex items-center justify-center relative">
|
||||
{asset.match(/\.(mp4|webm)$/i) ? (
|
||||
<video src={asset} className="w-full h-full object-cover opacity-80" />
|
||||
) : (
|
||||
<img src={asset} loading="lazy" alt={filename} className="w-full h-full object-contain" />
|
||||
)}
|
||||
{selectedAsset === asset && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20 backdrop-blur-[2px]">
|
||||
<div className="bg-blue-500 text-white rounded-full p-1.5 shadow-lg">
|
||||
<Check size={18} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white/70 truncate w-full text-center shrink-0" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={(!currentText && currentType !== "image") || (currentType === "image" && !selectedAsset)}
|
||||
onClick={saveAnnotation}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 shrink-0"
|
||||
>
|
||||
<Check size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 4. Annotations List Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowList(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-full sm:w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Korrekturen</h2>
|
||||
<p className="text-white/40 text-sm">{annotations.length} erfasst</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowList(false)}
|
||||
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{annotations.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
||||
<List size={48} className="mb-4" />
|
||||
<p>Noch keine Korrekturen vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
annotations.map((ann) => (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="bg-white/5 border border-white/5 rounded-2xl p-4 flex flex-col gap-3 group relative"
|
||||
>
|
||||
<button
|
||||
onClick={() => deleteAnnotation(ann.id)}
|
||||
className="absolute top-4 right-4 text-white/20 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider flex items-center gap-1",
|
||||
ann.type === "text" ? "bg-blue-500/20 text-blue-400" :
|
||||
ann.type === "image" ? "bg-purple-500/20 text-purple-400" :
|
||||
"bg-orange-500/20 text-orange-400"
|
||||
)}>
|
||||
{ann.type === "text" && <Type size={12} />}
|
||||
{ann.type === "image" && <ImageIcon size={12} />}
|
||||
{ann.type === "note" && <StickyNote size={12} />}
|
||||
{ann.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ann.type === "image" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={ann.newContent} alt="New" className="w-16 h-16 object-cover rounded-lg bg-white/10" />
|
||||
<div className="text-xs text-white/60 flex-1 truncate">{ann.newContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white text-sm whitespace-pre-wrap">{ann.newContent}</p>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-white/30 truncate mt-2 font-mono flex items-center gap-2">
|
||||
<span>{ann.selector}</span>
|
||||
{ann.url && <span className="truncate max-w-[100px] text-blue-400" title={ann.url}>{new URL(ann.url).pathname}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-white/5">
|
||||
{ann.timestamp && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{new Date(ann.timestamp).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{ann.viewportWidth && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{ann.viewportWidth}x{ann.viewportHeight}
|
||||
</span>
|
||||
)}
|
||||
{ann.scrollX !== undefined && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
Scroll: {Math.round(ann.scrollX)},{Math.round(ann.scrollY)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<div className="p-6 border-t border-white/10 flex flex-col gap-3">
|
||||
{onSubmit && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Check size={20} />
|
||||
)}
|
||||
{isSubmitting ? "Wird gesendet..." : "Korrekturen an Entwickler senden"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className={cn(
|
||||
"w-full font-bold py-3 rounded-2xl flex items-center justify-center gap-2 transition-all",
|
||||
onSubmit ? "bg-white/5 text-white/70 hover:bg-white/10 hover:text-white" : "bg-blue-500 hover:bg-blue-400 text-white shadow-lg shadow-blue-500/20 py-4"
|
||||
)}
|
||||
>
|
||||
<Download size={onSubmit ? 18 : 20} />
|
||||
JSON {onSubmit && "lokal "}Exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Möchtest du wirklich alle erfassten Korrekturen löschen?")) {
|
||||
setAnnotations([]);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-red-500/10 text-red-500 hover:bg-red-500/20 font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-all mt-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Alle löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
components/annotator-local/index.ts
Normal file
1
components/annotator-local/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Annotator } from "./Annotator";
|
||||
@@ -75,7 +75,7 @@ const Icons = {
|
||||
|
||||
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
|
||||
return (
|
||||
<section className="bg-white py-12 md:py-24 relative overflow-hidden border-t border-neutral-100">
|
||||
<section className="bg-white py-16 md:py-24 lg:py-32 relative overflow-hidden border-t border-neutral-100">
|
||||
<div className="container px-4 max-w-7xl mx-auto relative z-10">
|
||||
|
||||
<div className="max-w-3xl mx-auto text-center mb-16">
|
||||
|
||||
@@ -99,7 +99,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<section className="py-12 md:py-24 bg-neutral-50 relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-neutral-50 relative overflow-hidden">
|
||||
<Container>
|
||||
{!hideHeader && (
|
||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||
@@ -140,7 +140,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
>
|
||||
{certificates.map((cert, index) => {
|
||||
const isIso = cert.type === 'iso';
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CompanyTimeline({
|
||||
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-32 bg-[#FAFAFA] relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-[#FAFAFA] relative overflow-hidden">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
|
||||
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
// Static fallback for SSR
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<section className="py-12 md:py-24 bg-neutral text-neutral-dark overflow-hidden relative">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-neutral text-neutral-dark overflow-hidden relative">
|
||||
<Container>
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
||||
<div className="max-w-2xl">
|
||||
@@ -81,12 +81,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
return (
|
||||
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
||||
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 px-4 md:px-0">
|
||||
<div className="flex flex-row justify-between items-end mb-12 px-4 md:px-0">
|
||||
<div className="max-w-2xl text-left">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||
</div>
|
||||
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
|
||||
<Button href={ctaHref} variant="ghost" className="bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0 whitespace-nowrap">
|
||||
{ctaLabel}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" 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>
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
||||
const { showForm, showMap } = props;
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-12 md:py-28 relative overflow-hidden">
|
||||
<Section className="bg-neutral-light relative overflow-hidden">
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||
<motion.div
|
||||
|
||||
@@ -21,7 +21,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-16">
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-10 text-center">
|
||||
{subtitle && (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||
import { Location, defaultLocations, minorLocations, projectLocations } from '@/lib/map-data';
|
||||
import { Location, projectLocations } from '@/lib/map-data';
|
||||
import { standorteLocations } from '@/lib/standorte-data';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
@@ -28,6 +28,59 @@ interface InteractiveGermanyMapProps {
|
||||
hideStandorte?: boolean;
|
||||
}
|
||||
|
||||
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
|
||||
<div
|
||||
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
||||
style={{
|
||||
left: `${loc.x}%`,
|
||||
top: `${loc.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||
</div>
|
||||
));
|
||||
MinorNode.displayName = 'MinorNode';
|
||||
|
||||
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
return (
|
||||
<div
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MajorNode.displayName = 'MajorNode';
|
||||
|
||||
export function InteractiveGermanyMap({
|
||||
badge,
|
||||
title,
|
||||
@@ -38,21 +91,24 @@ export function InteractiveGermanyMap({
|
||||
hideStandorte = false
|
||||
}: InteractiveGermanyMapProps) {
|
||||
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = (loc: Location) => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
const handleMouseEnter = useCallback((loc: Location) => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
setActiveLocation(loc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setActiveLocation(null);
|
||||
}, 200);
|
||||
setHoverTimeout(timeout);
|
||||
};
|
||||
|
||||
hoverTimeoutRef.current = timeout;
|
||||
}, []);
|
||||
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('InteractiveGermanyMap');
|
||||
const tStandard = useTranslations('StandardPage');
|
||||
@@ -63,14 +119,11 @@ export function InteractiveGermanyMap({
|
||||
];
|
||||
|
||||
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.</>);
|
||||
const finalDescription = description || (locale === 'en' ? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
|
||||
|
||||
const finalLocations = React.useMemo(() => {
|
||||
const finalLocations = useMemo(() => {
|
||||
if (hideStandorte) return locations;
|
||||
|
||||
// Ensure the 3 standorte are always included, without duplicating if already present by ID
|
||||
const base = [...locations];
|
||||
standorteLocations.forEach(sl => {
|
||||
if (!base.some(l => l.id === sl.id)) {
|
||||
@@ -80,20 +133,28 @@ export function InteractiveGermanyMap({
|
||||
return base;
|
||||
}, [locations, hideStandorte]);
|
||||
|
||||
const mapBackground = useMemo(() => (
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
), [locale]);
|
||||
|
||||
return (
|
||||
<div className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
||||
<div key={isHero ? `hero-map-${pathname}` : undefined} className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
||||
<div className={`bg-[#050B14] ${isHero ? 'pt-32 pb-16 md:pt-40 md:pb-24' : 'rounded-[2.5rem] md:rounded-[3.5rem] shadow-2xl border border-white/5'} overflow-visible relative group/map`}>
|
||||
{/* Animated Border Glow (disabled in hero due to performance lag on 100vw) */}
|
||||
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
||||
|
||||
{/* Background Effects */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero ? 'rounded-[2.5rem] md:rounded-[3.5rem]' : ''} overflow-hidden`}>
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3" />
|
||||
</div>
|
||||
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero && 'rounded-[2.5rem] md:rounded-[3.5rem]'} overflow-hidden`} />
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3 overflow-hidden" />
|
||||
|
||||
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-12 gap-12 items-center min-h-[600px] ${isHero ? 'max-w-7xl mx-auto px-4 sm:px-6' : 'p-8 md:p-12 lg:p-16'}`}>
|
||||
|
||||
{/* Content Left */}
|
||||
<div className="xl:col-span-5 text-white z-20">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider mb-8">
|
||||
<MapPin className="w-4 h-4" />
|
||||
@@ -114,7 +175,6 @@ export function InteractiveGermanyMap({
|
||||
{finalDescription}
|
||||
</p>
|
||||
|
||||
{/* Industrial Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{finalStats.map((stat, i) => {
|
||||
const StatCard = () => (
|
||||
@@ -145,81 +205,32 @@ export function InteractiveGermanyMap({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Right */}
|
||||
<div className="xl:col-span-7 relative flex items-center justify-center min-h-[500px] lg:min-h-[700px] z-10 pointer-events-none">
|
||||
{/* Map Container - Enforce strict aspect ratio matching the SVG (1024x1024) */}
|
||||
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
|
||||
|
||||
{/* Map SVG */}
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
{mapBackground}
|
||||
|
||||
{/* Minor Location Markers (The 100+ generic projects) */}
|
||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||
<div
|
||||
key={`minor-${idx}`}
|
||||
className="absolute z-10 group/minor cursor-pointer w-10 h-10 flex items-center justify-center"
|
||||
style={{
|
||||
left: `${loc.x}%`,
|
||||
top: `${loc.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onMouseEnter={() => handleMouseEnter(loc)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => handleMouseEnter(loc)}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 bg-primary/80 rounded-full group-hover/minor:bg-white group-hover/minor:scale-150 transition-all duration-300" />
|
||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||
</div>
|
||||
<MinorNode
|
||||
key={`minor-${loc.id || idx}`}
|
||||
loc={loc}
|
||||
isActive={activeLocation === loc}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Major Location Markers (HQ, Branch, Project) */}
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
const isActive = activeLocation?.id === loc.id;
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||
<MajorNode
|
||||
key={loc.id}
|
||||
loc={loc}
|
||||
idx={idx}
|
||||
isActive={activeLocation?.id === loc.id}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => handleMouseEnter(loc)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => handleMouseEnter(loc)}
|
||||
>
|
||||
{/* Ping Animation for HQ / Branch */}
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
|
||||
{/* Elegant Marker Dot */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Global Tooltip */}
|
||||
<AnimatePresence>
|
||||
{activeLocation && (
|
||||
<motion.div
|
||||
@@ -228,17 +239,16 @@ export function InteractiveGermanyMap({
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute z-[9999] pointer-events-auto"
|
||||
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw-2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
|
||||
style={{
|
||||
left: `${activeLocation.x}%`,
|
||||
top: `${activeLocation.y}%`,
|
||||
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||
}}
|
||||
'--md-left': `${activeLocation.x}%`,
|
||||
'--md-top': `${activeLocation.y}%`,
|
||||
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||
} as React.CSSProperties}
|
||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
|
||||
|
||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
||||
{activeLocation.featuredImage && (
|
||||
<div className="relative w-full h-32 bg-neutral-900">
|
||||
<Image
|
||||
|
||||
@@ -94,15 +94,15 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
|
||||
|
||||
return (
|
||||
<section id="referenzen" className="py-12 md:py-24 bg-neutral-dark text-white relative overflow-hidden">
|
||||
<section id="referenzen" className="py-16 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
|
||||
|
||||
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-end gap-6">
|
||||
<div>
|
||||
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div className="text-left">
|
||||
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
|
||||
</div>
|
||||
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,16 +131,8 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||
>
|
||||
<Link
|
||||
href={`/${locale}/referenzen/${ref.slug}`}
|
||||
<div
|
||||
data-testid="reference-tile"
|
||||
draggable={false}
|
||||
onClick={(e) => {
|
||||
if (dragDistanceRef.current > 15) {
|
||||
e.preventDefault();
|
||||
}
|
||||
dragDistanceRef.current = 0;
|
||||
}}
|
||||
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
||||
>
|
||||
<Image
|
||||
@@ -161,7 +153,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
</span>
|
||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ServiceDetailGrid({
|
||||
panels
|
||||
}: ServiceDetailGridProps) {
|
||||
return (
|
||||
<section className="bg-neutral-50 py-12 md:py-24 border-b border-neutral-100 relative overflow-hidden">
|
||||
<section className="bg-neutral-50 py-16 md:py-24 lg:py-32 border-b border-neutral-100 relative overflow-hidden">
|
||||
|
||||
<div className="container relative z-10 px-4 max-w-7xl mx-auto">
|
||||
<div className="max-w-3xl mb-12 md:mb-20 text-left">
|
||||
@@ -193,7 +193,7 @@ export function ServiceDetailGrid({
|
||||
{/* On a white card, a very subtle primary/grey shine works best */}
|
||||
<HoverShineOverlay shineColor="via-primary/5" />
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start mb-12 gap-6">
|
||||
<div className="relative z-10 flex flex-row justify-between items-start mb-12 gap-6">
|
||||
<div className="pt-2 flex-1 min-w-0 pr-4">
|
||||
<h4 className="text-lg md:text-2xl font-extrabold text-neutral-dark uppercase tracking-wider text-balance break-words" lang="de">{panel.title}</h4>
|
||||
<div className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
})) || defaultCompanies;
|
||||
|
||||
return (
|
||||
<section id="unternehmen" className="py-12 md:py-24 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||
<section id="unternehmen" className="py-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||
<div className="container relative z-10 px-4">
|
||||
|
||||
{(badge || title) && (
|
||||
@@ -85,7 +85,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
>
|
||||
{companiesData.map((company: any, index: number) => {
|
||||
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
||||
|
||||
@@ -28,7 +28,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
||||
if (!members || members.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-24 bg-white relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 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">{t('badge')}</h2>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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 E-TIB Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
|
||||
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 e-tib.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your E-TIB Cable Catalog',
|
||||
greeting: 'Thank you for your interest in E-TIB.',
|
||||
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 e-tib.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: '#111111',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #117c61',
|
||||
};
|
||||
|
||||
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: '#117c61',
|
||||
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',
|
||||
};
|
||||
@@ -7,7 +7,7 @@ export default function CTA({ data }: { data?: any }) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Section className="bg-primary text-white py-16 md:py-32 relative overflow-hidden">
|
||||
<Section className="bg-primary text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function Experience({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.experience');
|
||||
|
||||
return (
|
||||
<Section className="relative py-16 md:py-48 overflow-hidden text-white">
|
||||
<Section className="relative overflow-hidden text-white">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function GallerySection({ data }: { data?: any }) {
|
||||
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||
|
||||
return (
|
||||
<Section className="bg-white text-white py-16 md:py-32">
|
||||
<Section className="bg-white text-white">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||
{data?.title || t('title')}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Section className="relative py-16 md:py-48 overflow-hidden">
|
||||
<Section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<footer className="bg-[#050B14] text-neutral-light pt-40 pb-10 md:pt-16 md:pb-16 relative overflow-hidden border-t border-white/5">
|
||||
<footer className="bg-[#050B14] text-neutral-light pt-24 pb-28 md:pt-32 md:pb-16 relative overflow-hidden border-t border-white/5">
|
||||
{/* Subtle background tech grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
||||
{locale === 'de'
|
||||
? 'Die Experten für Kabelnetzbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
||||
: 'Experts in cable construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
: 'Experts in cable network construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
||||
}, [currentLocale, navLinks]);
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-6 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-2 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
|
||||
<nav className="flex justify-around items-center max-w-md mx-auto">
|
||||
{items.map((item) => {
|
||||
// Determine if active
|
||||
|
||||
@@ -60,7 +60,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
setTimeout(() => {
|
||||
router.push(href);
|
||||
|
||||
// Fallback: If for some reason the route doesn't change after 5 seconds, open the shutter anyway
|
||||
// Fallback: If for some reason the route doesn't change after 60 seconds (useful for slow dev servers), open the shutter anyway
|
||||
setTimeout(() => {
|
||||
setIsTransitioning((current) => {
|
||||
if (current) {
|
||||
@@ -69,7 +69,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 5000);
|
||||
}, 60000);
|
||||
}, 700);
|
||||
}, [isTransitioning, router]);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Gallery() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Section className="bg-primary-dark py-16 md:py-32">
|
||||
<Section className="bg-primary-dark">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
||||
<span className="text-white">{t('gallery.title')}</span>
|
||||
|
||||
@@ -6,12 +6,14 @@ interface AnimatedGlossyBorderProps {
|
||||
borderWidth?: 1 | 2;
|
||||
opacity?: number;
|
||||
color?: 'white' | 'primary';
|
||||
disableAnimation?: boolean;
|
||||
}
|
||||
|
||||
export function AnimatedGlossyBorder({
|
||||
className,
|
||||
borderWidth = 1,
|
||||
color = 'white'
|
||||
color = 'white',
|
||||
disableAnimation = false
|
||||
}: AnimatedGlossyBorderProps) {
|
||||
const isWhite = color === 'white';
|
||||
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
||||
@@ -35,7 +37,10 @@ export function AnimatedGlossyBorder({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square animate-[spin_8s_linear_infinite]"
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square",
|
||||
!disableAnimation && "animate-[spin_8s_linear_infinite]"
|
||||
)}
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, ${gradientColors})`
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from './utils';
|
||||
|
||||
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<section className={cn('py-12 md:py-28 lg:py-36 overflow-hidden content-visibility-auto', className)} {...props}>
|
||||
<section className={cn('py-16 md:py-24 lg:py-32 overflow-hidden content-visibility-auto', className)} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
BIN
contact_sheet.jpg
Normal file
BIN
contact_sheet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Kabelleitungstiefbau im Bereich Telekommunikation"
|
||||
title: "Kabelleitungsnetzbau im Bereich Telekommunikation"
|
||||
date: "2024-03-20"
|
||||
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungstiefbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
|
||||
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungsnetzbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
|
||||
@@ -117,12 +117,12 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
||||
ctaHref="/de/kompetenzen"
|
||||
items={[
|
||||
{
|
||||
title: "Kabelleitungstiefbau",
|
||||
description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
||||
title: "Kabelleitungsnetzbau",
|
||||
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
||||
tag: "Energie",
|
||||
size: "large",
|
||||
href: "/de/kabelnetzbau",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
|
||||
href: "/de/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||
},
|
||||
{
|
||||
title: "Bohrtechnik",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Kabelleitungstiefbau"
|
||||
title: "Kabelleitungsnetzbau"
|
||||
date: "2024-03-20"
|
||||
excerpt: "Professioneller Kabelleitungstiefbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
|
||||
excerpt: "Professioneller Kabelleitungsnetzbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
<HeroSection
|
||||
badge="Kernkompetenz"
|
||||
title="Kabelleitungstiefbau"
|
||||
title="Kabelleitungsnetzbau"
|
||||
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
|
||||
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
||||
alignment="center"
|
||||
|
||||
@@ -8,7 +8,7 @@ layout: "fullBleed"
|
||||
<HomeHero
|
||||
badge="Unsere Leistungen"
|
||||
title="Alles aus einer Hand"
|
||||
description="Als Full-Service-Partner im Bereich Kabelleitungstiefbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
|
||||
description="Als Full-Service-Partner im Bereich Kabelleitungsnetzbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
|
||||
videoUrl="/assets/videos/web/hero-bohrung.mp4"
|
||||
linkText="Kontakt aufnehmen"
|
||||
linkHref="/de/kontakt"
|
||||
@@ -23,12 +23,12 @@ layout: "fullBleed"
|
||||
ctaHref="/de/kontakt"
|
||||
items={[
|
||||
{
|
||||
title: "Kabelleitungstiefbau",
|
||||
title: "Kabelleitungsnetzbau",
|
||||
description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||
tag: "Energie",
|
||||
size: "large",
|
||||
href: "/de/kabelnetzbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungstiefbau" }
|
||||
href: "/de/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||
},
|
||||
{
|
||||
title: "Bohrtechnik",
|
||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
||||
tag: "Innovation",
|
||||
size: "medium",
|
||||
href: "/de/bohrtechnik",
|
||||
image: { url: "/assets/photos/DSC08653.JPG", alt: "Bohrtechnik" }
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Bohrtechnik" }
|
||||
},
|
||||
{
|
||||
title: "Glasfaser",
|
||||
@@ -70,7 +70,7 @@ layout: "fullBleed"
|
||||
panels={[
|
||||
{
|
||||
id: "energy",
|
||||
title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)",
|
||||
title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)",
|
||||
icon: "energy",
|
||||
lists: [
|
||||
[
|
||||
@@ -86,7 +86,7 @@ layout: "fullBleed"
|
||||
},
|
||||
{
|
||||
id: "fiber",
|
||||
title: "Kabelleitungstiefbau (Telekommunikation)",
|
||||
title: "Kabelleitungsnetzbau (Telekommunikation)",
|
||||
icon: "fiber",
|
||||
lists: [
|
||||
[
|
||||
@@ -143,7 +143,7 @@ layout: "fullBleed"
|
||||
<JsonLd data={[
|
||||
{
|
||||
"@type": "Service",
|
||||
"name": "Kabelleitungstiefbau",
|
||||
"name": "Kabelleitungsnetzbau",
|
||||
"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||
"provider": { "@type": "Organization", "name": "E-TIB GmbH" }
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ layout: "fullBleed"
|
||||
|
||||
<div className="container px-4 max-w-7xl mx-auto py-16">
|
||||
<blockquote>
|
||||
Wir bieten unseren Kunden alles aus einer Hand – von der Grobplanung Ihrer Projektidee über den Kabelleitungstiefbau bis zum Bau Ihres erneuerbaren Energieprojektes.
|
||||
Wir bieten unseren Kunden alles aus einer Hand – von der Grobplanung Ihrer Projektidee über den Kabelleitungsnetzbau bis zum Bau Ihres erneuerbaren Energieprojektes.
|
||||
</blockquote>
|
||||
|
||||
## Fundament für den Projekterfolg
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
||||
date: "2026-06-01"
|
||||
dateString: "01/2025 bis 06/2026"
|
||||
location: "18334 Dettmannsdorf"
|
||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0301.JPG"
|
||||
---
|
||||
|
||||
- 12.300 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "wpd GmbH"
|
||||
date: "2026-03-01"
|
||||
dateString: "seit 03/2026"
|
||||
location: "24808 Jevenstedt"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0037.JPG"
|
||||
---
|
||||
|
||||
- 500 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
||||
date: "2025-06-01"
|
||||
dateString: "01/2025 bis 06/2025"
|
||||
location: "14669 Ketzin"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
|
||||
featuredImage: "/assets/photos/DSC02643.JPG"
|
||||
---
|
||||
|
||||
- 1.600 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
||||
date: "2026-03-01"
|
||||
dateString: "02/2025 bis 03/2026"
|
||||
location: "48346 Ostbevern"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0048.JPG"
|
||||
---
|
||||
|
||||
- 4.100 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
||||
date: "2025-02-01"
|
||||
dateString: "04/2024 bis 02/2025"
|
||||
location: "15326 Petershagen"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
|
||||
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
|
||||
---
|
||||
|
||||
- 12.000 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
||||
date: "2025-08-01"
|
||||
dateString: "02/2025 bis 08/2025"
|
||||
location: "15345 Prötzel"
|
||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0243.JPG"
|
||||
---
|
||||
|
||||
- 2.600 m Kabelgraben ausheben und verfüllen
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: "Home - Experts for Cable Construction & Civil Engineering"
|
||||
title: "Home - Experts for cable network construction & Civil Engineering"
|
||||
description: "E-TIB GmbH is your reliable partner for complex cable routes, horizontal directional drilling, surveying, and telecommunications infrastructure in Germany."
|
||||
---
|
||||
|
||||
<HomeHero
|
||||
|
||||
title={"The Experts for\nCable Construction"}
|
||||
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable construction and drilling technology."
|
||||
title={"The Experts for\ncable network construction"}
|
||||
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable network construction and drilling technology."
|
||||
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
|
||||
linkText="Our Services"
|
||||
linkHref="/en/competencies"
|
||||
@@ -121,8 +121,8 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
|
||||
description: "Cable civil engineering (high, medium, low voltage) and cable assembly up to 110 kV",
|
||||
tag: "Energy",
|
||||
size: "large",
|
||||
href: "/en/cable-civil-engineering",
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
|
||||
href: "/en/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||
},
|
||||
{
|
||||
title: "Drilling Technology",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Competencies - Cable Civil Engineering & Horizontal Directional Drilling"
|
||||
date: "2024-03-20"
|
||||
excerpt: "From cable construction to electrical installation: We provide holistic solutions for modern network infrastructures."
|
||||
excerpt: "From cable network construction to electrical installation: We provide holistic solutions for modern network infrastructures."
|
||||
layout: "fullBleed"
|
||||
---
|
||||
|
||||
@@ -27,8 +27,8 @@ layout: "fullBleed"
|
||||
description: "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV.",
|
||||
tag: "Energy",
|
||||
size: "large",
|
||||
href: "/en/cable-civil-engineering",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Civil Engineering" }
|
||||
href: "/en/kabeltiefbau",
|
||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||
},
|
||||
{
|
||||
title: "Drilling Technology",
|
||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
||||
tag: "Innovation",
|
||||
size: "medium",
|
||||
href: "/en/drilling-technology",
|
||||
image: { url: "/assets/photos/DSC08653.JPG", alt: "Drilling Technology" }
|
||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Drilling Technology" }
|
||||
},
|
||||
{
|
||||
title: "Fiber Optics",
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
||||
date: "2026-06-01"
|
||||
dateString: "01/2025 to 06/2026"
|
||||
location: "18334 Dettmannsdorf"
|
||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-256.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0301.JPG"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 12,300 m cable trench
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "wpd GmbH"
|
||||
date: "2026-03-01"
|
||||
dateString: "since 03/2026"
|
||||
location: "24808 Jevenstedt"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-15.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0037.JPG"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 500 m cable trench
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
||||
date: "2025-06-01"
|
||||
dateString: "01/2025 to 06/2025"
|
||||
location: "14669 Ketzin"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-14.jpg"
|
||||
featuredImage: "/assets/photos/DSC02643.JPG"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 1,600 m cable trench
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
||||
date: "2026-03-01"
|
||||
dateString: "02/2025 to 03/2026"
|
||||
location: "48346 Ostbevern"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-100.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0048.JPG"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 4,100 m cable trench
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
||||
date: "2025-02-01"
|
||||
dateString: "04/2024 to 02/2025"
|
||||
location: "15326 Petershagen"
|
||||
featuredImage: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-12.jpg"
|
||||
featuredImage: "/assets/photos/Screenshot_2025-09-30_085736.png"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 12,000 m cable trench
|
||||
|
||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
||||
date: "2025-08-01"
|
||||
dateString: "02/2025 to 08/2025"
|
||||
location: "15345 Prötzel"
|
||||
featuredImage: "/assets/photos/etib_ingenieure_tiefbau_solar_pv_planung_vermessung_gis-294.jpg"
|
||||
featuredImage: "/assets/photos/DJI_0243.JPG"
|
||||
---
|
||||
|
||||
- Excavation and backfilling of 2,600 m cable trench
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
/**
|
||||
* Excel Products Module
|
||||
*
|
||||
* Provides typed access to technical product data from Excel source files.
|
||||
* Reuses the parsing logic from the PDF datasheet generator.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// Configuration
|
||||
const EXCEL_SOURCE_FILES = [
|
||||
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
|
||||
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
|
||||
];
|
||||
|
||||
// Types
|
||||
export type ExcelRow = Record<string, string | number | boolean | Date>;
|
||||
|
||||
export interface ExcelMatch {
|
||||
rows: ExcelRow[];
|
||||
units: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TechnicalData {
|
||||
configurations: string[];
|
||||
attributes: Array<{
|
||||
name: string;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductLookupParams {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
sku?: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
// Cache singleton
|
||||
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
|
||||
|
||||
/**
|
||||
* Normalize Excel key to match product identifiers
|
||||
* Examples:
|
||||
* - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
|
||||
* - "na2xsfl2y-3" -> "NA2XSFL2Y"
|
||||
*/
|
||||
function normalizeExcelKey(value: string): string {
|
||||
return String(value || '')
|
||||
.toUpperCase()
|
||||
.replace(/-\d+$/g, '')
|
||||
.replace(/[^A-Z0-9]+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize value (strip HTML, trim whitespace)
|
||||
*/
|
||||
function normalizeValue(value: string): string {
|
||||
if (!value) return '';
|
||||
return String(value)
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks numeric
|
||||
*/
|
||||
function looksNumeric(value: string): boolean {
|
||||
const v = normalizeValue(value).replace(/,/g, '.');
|
||||
return /^-?\d+(?:\.\d+)?$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Excel rows from a file using xlsx library
|
||||
*/
|
||||
function loadExcelRows(filePath: string): ExcelRow[] {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`[excel-products] File not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const workbook = XLSX.readFile(filePath, {
|
||||
cellDates: false,
|
||||
cellNF: false,
|
||||
cellText: false
|
||||
});
|
||||
|
||||
// Get the first sheet
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// Convert to JSON
|
||||
const rows = XLSX.utils.sheet_to_json(worksheet, {
|
||||
defval: '',
|
||||
raw: false
|
||||
}) as ExcelRow[];
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error(`[excel-products] Error reading ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Excel index from all source files
|
||||
*/
|
||||
export function getExcelIndex(): Map<string, ExcelMatch> {
|
||||
if (EXCEL_INDEX) return EXCEL_INDEX;
|
||||
|
||||
const idx = new Map<string, ExcelMatch>();
|
||||
|
||||
for (const file of EXCEL_SOURCE_FILES) {
|
||||
const rows = loadExcelRows(file);
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
// Find units row (if present)
|
||||
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
|
||||
const units: Record<string, string> = {};
|
||||
|
||||
if (unitsRow) {
|
||||
for (const [k, v] of Object.entries(unitsRow)) {
|
||||
if (k === 'Part Number') continue;
|
||||
const unit = normalizeValue(String(v ?? ''));
|
||||
if (unit) units[k] = unit;
|
||||
}
|
||||
}
|
||||
|
||||
// Index rows by Part Number
|
||||
for (const r of rows) {
|
||||
const pn = r?.['Part Number'];
|
||||
if (!pn || pn === 'Units') continue;
|
||||
|
||||
const key = normalizeExcelKey(String(pn));
|
||||
if (!key) continue;
|
||||
|
||||
const cur = idx.get(key);
|
||||
if (!cur) {
|
||||
idx.set(key, { rows: [r], units });
|
||||
} else {
|
||||
cur.rows.push(r);
|
||||
// Keep the most comprehensive units
|
||||
if (Object.keys(cur.units).length < Object.keys(units).length) {
|
||||
cur.units = units;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXCEL_INDEX = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Excel match for a product using various identifiers
|
||||
*/
|
||||
function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
|
||||
const idx = getExcelIndex();
|
||||
|
||||
const candidates = [
|
||||
params.name,
|
||||
params.slug ? params.slug.replace(/-\d+$/g, '') : '',
|
||||
params.sku,
|
||||
params.translationKey,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const c of candidates) {
|
||||
const key = normalizeExcelKey(c);
|
||||
const match = idx.get(key);
|
||||
if (match && match.rows.length) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess column key based on patterns
|
||||
*/
|
||||
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
|
||||
const keys = Object.keys(row || {});
|
||||
|
||||
for (const re of patterns) {
|
||||
const k = keys.find(x => {
|
||||
const key = String(x);
|
||||
|
||||
// Specific exclusions to prevent wrong matches
|
||||
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
|
||||
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
|
||||
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
|
||||
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
|
||||
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
|
||||
if (re.test('norm') && !/^norm$/i.test(key)) return false;
|
||||
|
||||
return re.test(key);
|
||||
});
|
||||
if (k) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique non-empty values from an array
|
||||
*/
|
||||
function getUniqueNonEmpty(options: string[]): string[] {
|
||||
const uniq: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const v of options.map(normalizeValue).filter(Boolean)) {
|
||||
const k = v.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
uniq.push(v);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get technical data for a product from Excel files
|
||||
*/
|
||||
export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
|
||||
const match = findExcelForProduct(params);
|
||||
if (!match || match.rows.length === 0) return null;
|
||||
|
||||
const rows = match.rows;
|
||||
const sample = rows[0];
|
||||
|
||||
// Find cross-section column
|
||||
const csKey = guessColumnKey(sample, [
|
||||
/number of cores and cross-section/i,
|
||||
/cross.?section/i,
|
||||
/ross section conductor/i,
|
||||
]);
|
||||
|
||||
if (!csKey) return null;
|
||||
|
||||
// Extract configurations
|
||||
const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
|
||||
|
||||
const configurations = rows
|
||||
.map(r => {
|
||||
const cs = normalizeValue(String(r?.[csKey] ?? ''));
|
||||
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
|
||||
if (!cs) return '';
|
||||
if (!v) return cs;
|
||||
const vHasUnit = /\bkv\b/i.test(v);
|
||||
const vText = vHasUnit ? v : `${v} kV`;
|
||||
return `${cs} - ${vText}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (configurations.length === 0) return null;
|
||||
|
||||
// Extract technical attributes
|
||||
const attributes: Array<{ name: string; options: string[] }> = [];
|
||||
|
||||
// Key technical columns
|
||||
const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
|
||||
const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
|
||||
const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
|
||||
const ratedVoltKey = voltageKey;
|
||||
const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]);
|
||||
const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
|
||||
const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
|
||||
const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
|
||||
const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
|
||||
const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
|
||||
const cprKey = guessColumnKey(sample, [/cpr class/i]);
|
||||
const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
|
||||
const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
|
||||
const flameKey = guessColumnKey(sample, [/flame retardant/i]);
|
||||
const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
|
||||
const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
|
||||
const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
|
||||
const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
|
||||
const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
|
||||
const reactanceKey = guessColumnKey(sample, [/reactance/i]);
|
||||
const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
|
||||
const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
|
||||
const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
|
||||
const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
|
||||
const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
|
||||
const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
|
||||
const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
|
||||
const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
|
||||
const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
|
||||
const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
|
||||
const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
|
||||
const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
|
||||
const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
|
||||
const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
|
||||
const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
|
||||
const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
|
||||
const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
|
||||
const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
|
||||
|
||||
// Helper to add attribute
|
||||
const addAttr = (name: string, key: string | null, unit?: string) => {
|
||||
if (!key) return;
|
||||
const options = rows
|
||||
.map(r => normalizeValue(String(r?.[key] ?? '')))
|
||||
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v))
|
||||
.filter(Boolean);
|
||||
|
||||
if (options.length === 0) return;
|
||||
|
||||
const uniqueOptions = getUniqueNonEmpty(options);
|
||||
attributes.push({ name, options: uniqueOptions });
|
||||
};
|
||||
|
||||
// Add attributes
|
||||
addAttr('Outer diameter', outerKey, 'mm');
|
||||
addAttr('Weight', weightKey, 'kg/km');
|
||||
addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km');
|
||||
addAttr('Rated voltage', ratedVoltKey, '');
|
||||
addAttr('Test voltage', testVoltKey, '');
|
||||
addAttr('Operating temperature range', tempRangeKey, '');
|
||||
addAttr('Minimal temperature for laying', minLayKey, '');
|
||||
addAttr('Minimal storage temperature', minStoreKey, '');
|
||||
addAttr('Maximal operating conductor temperature', maxOpKey, '');
|
||||
addAttr('Maximal short-circuit temperature', maxScKey, '');
|
||||
addAttr('Nominal insulation thickness', insThkKey, 'mm');
|
||||
addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
|
||||
addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km');
|
||||
addAttr('Conductor', conductorKey, '');
|
||||
addAttr('Insulation', insulationKey, '');
|
||||
addAttr('Sheath', sheathKey, '');
|
||||
addAttr('Standard', normKey, '');
|
||||
addAttr('Conductor diameter', diamCondKey, 'mm');
|
||||
addAttr('Insulation diameter', diamInsKey, 'mm');
|
||||
addAttr('Screen diameter', diamScreenKey, 'mm');
|
||||
addAttr('Metallic screen', metalScreenKey, '');
|
||||
addAttr('Max. pulling force', pullingForceKey, '');
|
||||
addAttr('Electrical stress conductor', electricalStressKey, '');
|
||||
addAttr('Electrical stress insulation', electricalStressKey, '');
|
||||
addAttr('Reactance', reactanceKey, '');
|
||||
addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
|
||||
addAttr('Heating time constant flat', heatingFlatKey, 's');
|
||||
addAttr('Flame retardant', flameKey, '');
|
||||
addAttr('CPR class', cprKey, '');
|
||||
addAttr('Packaging', packagingKey, '');
|
||||
addAttr('Bending radius', bendKey, 'mm');
|
||||
addAttr('Shape of conductor', shapeKey, '');
|
||||
addAttr('Capacitance', capacitanceKey, 'μF/km');
|
||||
addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
|
||||
addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
|
||||
addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
|
||||
addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
|
||||
addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
|
||||
addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
|
||||
|
||||
return {
|
||||
configurations,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw Excel rows for a product (for detailed inspection)
|
||||
*/
|
||||
export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
|
||||
const match = findExcelForProduct(params);
|
||||
return match?.rows || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Excel index cache (useful for development)
|
||||
*/
|
||||
export function clearExcelCache(): void {
|
||||
EXCEL_INDEX = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload Excel data on module initialization
|
||||
* This ensures the cache is built during build time
|
||||
*/
|
||||
export function preloadExcelData(): void {
|
||||
getExcelIndex();
|
||||
}
|
||||
|
||||
// Preload when imported
|
||||
if (require.main === module) {
|
||||
preloadExcelData();
|
||||
}
|
||||
2020
lib/map-data.ts
2020
lib/map-data.ts
File diff suppressed because it is too large
Load Diff
1436
lib/pdf-brochure.tsx
1436
lib/pdf-brochure.tsx
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user