Compare commits
38 Commits
hotfix/dou
...
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 | |||
| 6d77fb92c3 | |||
| cb165b06e5 | |||
| ca1b19cc0f |
@@ -13,6 +13,10 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
@@ -89,12 +93,12 @@ jobs:
|
|||||||
DOMAIN="e-tib.com"
|
DOMAIN="e-tib.com"
|
||||||
PRJ="etib"
|
PRJ="etib"
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||||
TARGET="testing"
|
TARGET="testing"
|
||||||
IMAGE_TAG="main-${SHORT_SHA}"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.testing"
|
ENV_FILE=".env.testing"
|
||||||
TRAEFIK_HOST="test.${DOMAIN}"
|
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
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$REF"
|
IMAGE_TAG="$REF"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ pnpm-debug.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
ENV CI=true
|
||||||
|
|
||||||
# Copy lockfile and manifest for dependency installation caching
|
# 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/
|
COPY patches* ./patches/
|
||||||
|
|
||||||
ARG NPM_DOMAIN=git.infra.mintel.me
|
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 '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
|
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
@@ -37,7 +38,7 @@ export async function generateMetadata(props: {
|
|||||||
template: '%s | E-TIB',
|
template: '%s | E-TIB',
|
||||||
default: 'E-TIB | Die Experten für Kabelnetzbau',
|
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),
|
metadataBase: new URL(baseUrl),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
alternates: {
|
alternates: {
|
||||||
@@ -214,6 +215,7 @@ export default async function Layout(props: {
|
|||||||
<Footer companyInfo={companyInfo} />
|
<Footer companyInfo={companyInfo} />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
|
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
|
||||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||||
</TransitionProvider>
|
</TransitionProvider>
|
||||||
</NextIntlClientProvider>
|
</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 { HeroSection } from '@/components/blocks/HeroSection';
|
||||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
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 {
|
interface PageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
locale: string;
|
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">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
{references.map((ref) => (
|
{references.map((ref) => (
|
||||||
<TrackedLink
|
<div
|
||||||
key={ref.slug}
|
key={ref.slug}
|
||||||
href={`/${locale}/referenzen/${ref.slug}`}
|
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="flex flex-col h-full relative">
|
||||||
{/* Image Section */}
|
{/* 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" />
|
<Image src="/assets/logo.png" alt="E-TIB Logo" width={80} height={80} className="opacity-20 grayscale" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/5 transition-colors duration-500" />
|
<div className="absolute inset-0 bg-black/5" />
|
||||||
|
|
||||||
{/* Location Badge */}
|
{/* Location Badge */}
|
||||||
<div className="absolute top-4 left-4 z-10">
|
<div className="absolute top-4 left-4 z-10">
|
||||||
@@ -124,10 +130,17 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
|
|
||||||
{/* Content Section */}
|
{/* Content Section */}
|
||||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
<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}
|
{ref.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* Project Details / Content */}
|
||||||
|
{ref.content && (
|
||||||
|
<div className="flex-grow mb-6">
|
||||||
|
<MDXRemote source={ref.content} components={mdxComponents} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Technical Meta Data */}
|
{/* Technical Meta Data */}
|
||||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -147,7 +160,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TrackedLink>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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) {
|
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
|
||||||
return (
|
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="container px-4 max-w-7xl mx-auto relative z-10">
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto text-center mb-16">
|
<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) {
|
if (!isMounted) {
|
||||||
return (
|
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>
|
<Container>
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<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}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="show"
|
whileInView="show"
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, amount: 0.1 }}
|
||||||
>
|
>
|
||||||
{certificates.map((cert, index) => {
|
{certificates.map((cert, index) => {
|
||||||
const isIso = cert.type === 'iso';
|
const isIso = cert.type === 'iso';
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function CompanyTimeline({
|
|||||||
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 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" />
|
<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
|
// Static fallback for SSR
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
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>
|
<Container>
|
||||||
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
@@ -81,12 +81,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
<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">
|
<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">
|
<div className="max-w-2xl text-left">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<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>
|
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||||
</div>
|
</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}
|
{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>
|
<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>
|
</Button>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
const { showForm, showMap } = props;
|
const { showForm, showMap } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-12 md:py-28 relative overflow-hidden">
|
<Section className="bg-neutral-light relative overflow-hidden">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
|||||||
if (!questions || questions.length === 0) return null;
|
if (!questions || questions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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="max-w-3xl mx-auto">
|
||||||
<div className="mb-10 text-center">
|
<div className="mb-10 text-center">
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 Image from 'next/image';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
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 { standorteLocations } from '@/lib/standorte-data';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
|
||||||
@@ -28,6 +28,59 @@ interface InteractiveGermanyMapProps {
|
|||||||
hideStandorte?: boolean;
|
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({
|
export function InteractiveGermanyMap({
|
||||||
badge,
|
badge,
|
||||||
title,
|
title,
|
||||||
@@ -38,21 +91,23 @@ export function InteractiveGermanyMap({
|
|||||||
hideStandorte = false
|
hideStandorte = false
|
||||||
}: InteractiveGermanyMapProps) {
|
}: InteractiveGermanyMapProps) {
|
||||||
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
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) => {
|
const handleMouseEnter = useCallback((loc: Location) => {
|
||||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
hoverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
setActiveLocation(loc);
|
setActiveLocation(loc);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setActiveLocation(null);
|
setActiveLocation(null);
|
||||||
}, 200);
|
}, 200);
|
||||||
setHoverTimeout(timeout);
|
hoverTimeoutRef.current = timeout;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations('InteractiveGermanyMap');
|
const t = useTranslations('InteractiveGermanyMap');
|
||||||
@@ -64,14 +119,11 @@ export function InteractiveGermanyMap({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const finalBadge = badge || tStandard('badge');
|
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 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 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;
|
if (hideStandorte) return locations;
|
||||||
|
|
||||||
// Ensure the 3 standorte are always included, without duplicating if already present by ID
|
|
||||||
const base = [...locations];
|
const base = [...locations];
|
||||||
standorteLocations.forEach(sl => {
|
standorteLocations.forEach(sl => {
|
||||||
if (!base.some(l => l.id === sl.id)) {
|
if (!base.some(l => l.id === sl.id)) {
|
||||||
@@ -81,19 +133,28 @@ export function InteractiveGermanyMap({
|
|||||||
return base;
|
return base;
|
||||||
}, [locations, hideStandorte]);
|
}, [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 (
|
return (
|
||||||
<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 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`}>
|
<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 */}
|
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
||||||
<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 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="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'}`}>
|
<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="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">
|
<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" />
|
<MapPin className="w-4 h-4" />
|
||||||
@@ -114,7 +175,6 @@ export function InteractiveGermanyMap({
|
|||||||
{finalDescription}
|
{finalDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Industrial Stats Grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{finalStats.map((stat, i) => {
|
{finalStats.map((stat, i) => {
|
||||||
const StatCard = () => (
|
const StatCard = () => (
|
||||||
@@ -145,81 +205,32 @@ export function InteractiveGermanyMap({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
|
||||||
|
|
||||||
{/* Map SVG */}
|
{mapBackground}
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Minor Location Markers (The 100+ generic projects) */}
|
|
||||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||||
<div
|
<MinorNode
|
||||||
key={`minor-${idx}`}
|
key={`minor-${loc.id || idx}`}
|
||||||
className="absolute z-10 group/minor cursor-pointer w-10 h-10 flex items-center justify-center"
|
loc={loc}
|
||||||
style={{
|
isActive={activeLocation === loc}
|
||||||
left: `${loc.x}%`,
|
onEnter={handleMouseEnter}
|
||||||
top: `${loc.y}%`,
|
onLeave={handleMouseLeave}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Major Location Markers (HQ, Branch, Project) */}
|
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
<MajorNode
|
||||||
const isHQ = loc.type === 'hq';
|
key={loc.id}
|
||||||
const isBranch = loc.type === 'branch';
|
loc={loc}
|
||||||
const isActive = activeLocation?.id === loc.id;
|
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>
|
<AnimatePresence>
|
||||||
{activeLocation && (
|
{activeLocation && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -228,17 +239,16 @@ export function InteractiveGermanyMap({
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
transition={{ duration: 0.2 }}
|
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={{
|
style={{
|
||||||
left: `${activeLocation.x}%`,
|
'--md-left': `${activeLocation.x}%`,
|
||||||
top: `${activeLocation.y}%`,
|
'--md-top': `${activeLocation.y}%`,
|
||||||
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||||
}}
|
} as React.CSSProperties}
|
||||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||||
onMouseLeave={handleMouseLeave}
|
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 && (
|
{activeLocation.featuredImage && (
|
||||||
<div className="relative w-full h-32 bg-neutral-900">
|
<div className="relative w-full h-32 bg-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -94,15 +94,15 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
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="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 className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||||
<div>
|
<div className="text-left">
|
||||||
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<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>
|
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
|
||||||
</div>
|
</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}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,16 +131,8 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
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"
|
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
href={`/${locale}/referenzen/${ref.slug}`}
|
|
||||||
data-testid="reference-tile"
|
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"
|
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -161,7 +153,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
</span>
|
</span>
|
||||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -84,19 +84,19 @@ export function ScaleOfImpact() {
|
|||||||
{/* Static Data Grid (No jumping text) */}
|
{/* Static Data Grid (No jumping text) */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200 <span className="text-primary">+</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-primary">+</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974 <span className="text-primary text-xl md:text-2xl">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300 <span className="text-primary text-xl md:text-2xl">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160 <span className="text-primary text-xl md:text-2xl">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function ServiceDetailGrid({
|
|||||||
panels
|
panels
|
||||||
}: ServiceDetailGridProps) {
|
}: ServiceDetailGridProps) {
|
||||||
return (
|
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="container relative z-10 px-4 max-w-7xl mx-auto">
|
||||||
<div className="max-w-3xl mb-12 md:mb-20 text-left">
|
<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 */}
|
{/* On a white card, a very subtle primary/grey shine works best */}
|
||||||
<HoverShineOverlay shineColor="via-primary/5" />
|
<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">
|
<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>
|
<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>
|
<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;
|
})) || defaultCompanies;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="container relative z-10 px-4">
|
||||||
|
|
||||||
{(badge || title) && (
|
{(badge || title) && (
|
||||||
@@ -85,7 +85,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true, amount: 0.1 }}
|
||||||
>
|
>
|
||||||
{companiesData.map((company: any, index: number) => {
|
{companiesData.map((company: any, index: number) => {
|
||||||
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
if (!members || members.length === 0) return null;
|
if (!members || members.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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="container relative z-10">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function EUFundingBadge() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text overlaid on the flag */}
|
{/* Text overlaid on the flag */}
|
||||||
<div className="absolute top-0 right-0 p-5 md:p-8 flex flex-col items-end text-right z-10 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
|
<div className="absolute top-0 right-0 pt-8 pr-8 md:pt-12 md:pr-12 flex flex-col items-end text-right z-10 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
|
||||||
<span className="block text-[9px] md:text-[11px] font-bold text-white/90 tracking-wide leading-snug drop-shadow-md">
|
<span className="block text-[9px] md:text-[11px] font-bold text-white/90 tracking-wide leading-snug drop-shadow-md">
|
||||||
Kofinanziert von der
|
Kofinanziert von der
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
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 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" />
|
<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');
|
const t = useTranslations('Home.experience');
|
||||||
|
|
||||||
return (
|
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">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
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;
|
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-16 md:py-32">
|
<Section className="bg-white text-white">
|
||||||
<Container>
|
<Container>
|
||||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||||
{data?.title || t('title')}
|
{data?.title || t('title')}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative py-16 md:py-48 overflow-hidden">
|
<Section className="relative overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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" />
|
<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">
|
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
||||||
{locale === 'de'
|
{locale === 'de'
|
||||||
? 'Die Experten für Kabelnetzbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProp
|
|||||||
}, [currentLocale, navLinks]);
|
}, [currentLocale, navLinks]);
|
||||||
|
|
||||||
return (
|
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">
|
<nav className="flex justify-around items-center max-w-md mx-auto">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
// Determine if active
|
// Determine if active
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(href);
|
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(() => {
|
setTimeout(() => {
|
||||||
setIsTransitioning((current) => {
|
setIsTransitioning((current) => {
|
||||||
if (current) {
|
if (current) {
|
||||||
@@ -69,7 +69,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
}, 5000);
|
}, 60000);
|
||||||
}, 700);
|
}, 700);
|
||||||
}, [isTransitioning, router]);
|
}, [isTransitioning, router]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function Gallery() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-primary-dark py-16 md:py-32">
|
<Section className="bg-primary-dark">
|
||||||
<Container>
|
<Container>
|
||||||
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
<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>
|
<span className="text-white">{t('gallery.title')}</span>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ interface AnimatedGlossyBorderProps {
|
|||||||
borderWidth?: 1 | 2;
|
borderWidth?: 1 | 2;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
color?: 'white' | 'primary';
|
color?: 'white' | 'primary';
|
||||||
|
disableAnimation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimatedGlossyBorder({
|
export function AnimatedGlossyBorder({
|
||||||
className,
|
className,
|
||||||
borderWidth = 1,
|
borderWidth = 1,
|
||||||
color = 'white'
|
color = 'white',
|
||||||
|
disableAnimation = false
|
||||||
}: AnimatedGlossyBorderProps) {
|
}: AnimatedGlossyBorderProps) {
|
||||||
const isWhite = color === 'white';
|
const isWhite = color === 'white';
|
||||||
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
||||||
@@ -35,7 +37,10 @@ export function AnimatedGlossyBorder({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: `conic-gradient(from 0deg, ${gradientColors})`
|
background: `conic-gradient(from 0deg, ${gradientColors})`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cn } from './utils';
|
|||||||
|
|
||||||
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</section>
|
</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"
|
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"
|
layout: "fullBleed"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
|
|||||||
ctaHref="/de/kompetenzen"
|
ctaHref="/de/kompetenzen"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
title: "Kabelleitungstiefbau",
|
title: "Kabelleitungsnetzbau",
|
||||||
description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
|
||||||
tag: "Energie",
|
tag: "Energie",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/de/kabelnetzbau",
|
href: "/de/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Bohrtechnik",
|
title: "Bohrtechnik",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: "Kabelleitungstiefbau"
|
title: "Kabelleitungsnetzbau"
|
||||||
date: "2024-03-20"
|
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"
|
layout: "fullBleed"
|
||||||
---
|
---
|
||||||
|
|
||||||
<HeroSection
|
<HeroSection
|
||||||
badge="Kernkompetenz"
|
badge="Kernkompetenz"
|
||||||
title="Kabelleitungstiefbau"
|
title="Kabelleitungsnetzbau"
|
||||||
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
|
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
|
||||||
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
|
||||||
alignment="center"
|
alignment="center"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ layout: "fullBleed"
|
|||||||
<HomeHero
|
<HomeHero
|
||||||
badge="Unsere Leistungen"
|
badge="Unsere Leistungen"
|
||||||
title="Alles aus einer Hand"
|
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"
|
videoUrl="/assets/videos/web/hero-bohrung.mp4"
|
||||||
linkText="Kontakt aufnehmen"
|
linkText="Kontakt aufnehmen"
|
||||||
linkHref="/de/kontakt"
|
linkHref="/de/kontakt"
|
||||||
@@ -23,12 +23,12 @@ layout: "fullBleed"
|
|||||||
ctaHref="/de/kontakt"
|
ctaHref="/de/kontakt"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
title: "Kabelleitungstiefbau",
|
title: "Kabelleitungsnetzbau",
|
||||||
description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||||
tag: "Energie",
|
tag: "Energie",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/de/kabelnetzbau",
|
href: "/de/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungstiefbau" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Bohrtechnik",
|
title: "Bohrtechnik",
|
||||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
|||||||
tag: "Innovation",
|
tag: "Innovation",
|
||||||
size: "medium",
|
size: "medium",
|
||||||
href: "/de/bohrtechnik",
|
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",
|
title: "Glasfaser",
|
||||||
@@ -70,7 +70,7 @@ layout: "fullBleed"
|
|||||||
panels={[
|
panels={[
|
||||||
{
|
{
|
||||||
id: "energy",
|
id: "energy",
|
||||||
title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)",
|
title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)",
|
||||||
icon: "energy",
|
icon: "energy",
|
||||||
lists: [
|
lists: [
|
||||||
[
|
[
|
||||||
@@ -86,7 +86,7 @@ layout: "fullBleed"
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fiber",
|
id: "fiber",
|
||||||
title: "Kabelleitungstiefbau (Telekommunikation)",
|
title: "Kabelleitungsnetzbau (Telekommunikation)",
|
||||||
icon: "fiber",
|
icon: "fiber",
|
||||||
lists: [
|
lists: [
|
||||||
[
|
[
|
||||||
@@ -143,7 +143,7 @@ layout: "fullBleed"
|
|||||||
<JsonLd data={[
|
<JsonLd data={[
|
||||||
{
|
{
|
||||||
"@type": "Service",
|
"@type": "Service",
|
||||||
"name": "Kabelleitungstiefbau",
|
"name": "Kabelleitungsnetzbau",
|
||||||
"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
|
||||||
"provider": { "@type": "Organization", "name": "E-TIB GmbH" }
|
"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">
|
<div className="container px-4 max-w-7xl mx-auto py-16">
|
||||||
<blockquote>
|
<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>
|
</blockquote>
|
||||||
|
|
||||||
## Fundament für den Projekterfolg
|
## Fundament für den Projekterfolg
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
|||||||
date: "2026-06-01"
|
date: "2026-06-01"
|
||||||
dateString: "01/2025 bis 06/2026"
|
dateString: "01/2025 bis 06/2026"
|
||||||
location: "18334 Dettmannsdorf"
|
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
|
- 12.300 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "wpd GmbH"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "seit 03/2026"
|
dateString: "seit 03/2026"
|
||||||
location: "24808 Jevenstedt"
|
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
|
- 500 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
|||||||
date: "2025-06-01"
|
date: "2025-06-01"
|
||||||
dateString: "01/2025 bis 06/2025"
|
dateString: "01/2025 bis 06/2025"
|
||||||
location: "14669 Ketzin"
|
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
|
- 1.600 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "02/2025 bis 03/2026"
|
dateString: "02/2025 bis 03/2026"
|
||||||
location: "48346 Ostbevern"
|
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.100 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
|||||||
date: "2025-02-01"
|
date: "2025-02-01"
|
||||||
dateString: "04/2024 bis 02/2025"
|
dateString: "04/2024 bis 02/2025"
|
||||||
location: "15326 Petershagen"
|
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
|
- 12.000 m Kabelgraben ausheben und verfüllen
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
|||||||
date: "2025-08-01"
|
date: "2025-08-01"
|
||||||
dateString: "02/2025 bis 08/2025"
|
dateString: "02/2025 bis 08/2025"
|
||||||
location: "15345 Prötzel"
|
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
|
- 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."
|
description: "E-TIB GmbH is your reliable partner for complex cable routes, horizontal directional drilling, surveying, and telecommunications infrastructure in Germany."
|
||||||
---
|
---
|
||||||
|
|
||||||
<HomeHero
|
<HomeHero
|
||||||
|
|
||||||
title={"The Experts for\nCable Construction"}
|
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 construction and drilling technology."
|
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"
|
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
|
||||||
linkText="Our Services"
|
linkText="Our Services"
|
||||||
linkHref="/en/competencies"
|
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",
|
description: "Cable civil engineering (high, medium, low voltage) and cable assembly up to 110 kV",
|
||||||
tag: "Energy",
|
tag: "Energy",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/en/cable-civil-engineering",
|
href: "/en/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Drilling Technology",
|
title: "Drilling Technology",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Competencies - Cable Civil Engineering & Horizontal Directional Drilling"
|
title: "Competencies - Cable Civil Engineering & Horizontal Directional Drilling"
|
||||||
date: "2024-03-20"
|
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"
|
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.",
|
description: "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV.",
|
||||||
tag: "Energy",
|
tag: "Energy",
|
||||||
size: "large",
|
size: "large",
|
||||||
href: "/en/cable-civil-engineering",
|
href: "/en/kabeltiefbau",
|
||||||
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Civil Engineering" }
|
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Drilling Technology",
|
title: "Drilling Technology",
|
||||||
@@ -36,7 +36,7 @@ layout: "fullBleed"
|
|||||||
tag: "Innovation",
|
tag: "Innovation",
|
||||||
size: "medium",
|
size: "medium",
|
||||||
href: "/en/drilling-technology",
|
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",
|
title: "Fiber Optics",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Energiekontor AG"
|
|||||||
date: "2026-06-01"
|
date: "2026-06-01"
|
||||||
dateString: "01/2025 to 06/2026"
|
dateString: "01/2025 to 06/2026"
|
||||||
location: "18334 Dettmannsdorf"
|
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
|
- Excavation and backfilling of 12,300 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "wpd GmbH"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "since 03/2026"
|
dateString: "since 03/2026"
|
||||||
location: "24808 Jevenstedt"
|
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
|
- Excavation and backfilling of 500 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "E.DIS Netz GmbH"
|
|||||||
date: "2025-06-01"
|
date: "2025-06-01"
|
||||||
dateString: "01/2025 to 06/2025"
|
dateString: "01/2025 to 06/2025"
|
||||||
location: "14669 Ketzin"
|
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
|
- Excavation and backfilling of 1,600 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "GP Joule Anlagenbau GmbH & Co. KG"
|
|||||||
date: "2026-03-01"
|
date: "2026-03-01"
|
||||||
dateString: "02/2025 to 03/2026"
|
dateString: "02/2025 to 03/2026"
|
||||||
location: "48346 Ostbevern"
|
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
|
- Excavation and backfilling of 4,100 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "NaturStromAnlagen GmbH"
|
|||||||
date: "2025-02-01"
|
date: "2025-02-01"
|
||||||
dateString: "04/2024 to 02/2025"
|
dateString: "04/2024 to 02/2025"
|
||||||
location: "15326 Petershagen"
|
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
|
- Excavation and backfilling of 12,000 m cable trench
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ client: "Trianel GmbH"
|
|||||||
date: "2025-08-01"
|
date: "2025-08-01"
|
||||||
dateString: "02/2025 to 08/2025"
|
dateString: "02/2025 to 08/2025"
|
||||||
location: "15345 Prötzel"
|
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
|
- 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();
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user