Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f39ec3d35 | |||
| 7734440b90 | |||
| 42295c3c41 | |||
| 1e00690dd8 | |||
| 90e9f37849 | |||
| 9eaaa798a3 | |||
| f7685fdb2f | |||
| 609422b5b9 | |||
| 76cf6e7b62 | |||
| cc04b71327 | |||
| 1d5d86d07c | |||
| e2b7131adc | |||
| c2ced7185b | |||
| fd8f068594 | |||
| 00bafa761b |
@@ -29,6 +29,7 @@ jobs:
|
|||||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
|
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
|
||||||
|
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: 🔍 Debug Info
|
- name: 🔍 Debug Info
|
||||||
@@ -114,9 +115,11 @@ jobs:
|
|||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
|
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
|
||||||
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
|
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
|
||||||
|
TRAEFIK_MIDDLEWARES="compress"
|
||||||
else
|
else
|
||||||
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
|
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
|
||||||
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
|
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
|
||||||
|
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ jobs:
|
|||||||
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
|
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
|
||||||
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
|
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
|
||||||
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
|
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
|
||||||
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
|
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
|
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
|
||||||
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
|
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
|
||||||
@@ -196,6 +200,7 @@ jobs:
|
|||||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
||||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
||||||
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} \
|
||||||
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
|
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
@@ -228,6 +233,7 @@ jobs:
|
|||||||
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
|
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
|
||||||
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
||||||
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
|
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
|
||||||
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
|
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
|
||||||
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
|
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
|
||||||
@@ -246,14 +252,13 @@ jobs:
|
|||||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||||
|
|
||||||
# SMTP Config
|
# Mail
|
||||||
SMTP_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
MAIL_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
SMTP_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
MAIL_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
SMTP_SECURE=${{ secrets.SMTP_SECURE || vars.SMTP_SECURE || 'false' }}
|
MAIL_USERNAME=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
SMTP_USER=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
MAIL_PASSWORD=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
SMTP_PASS=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
MAIL_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
SMTP_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
MAIL_RECIPIENTS=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
CONTACT_RECIPIENT=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
@@ -265,6 +270,7 @@ jobs:
|
|||||||
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
|
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
|
||||||
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
|
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
@@ -284,7 +290,7 @@ jobs:
|
|||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --remove-orphans
|
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
|
||||||
docker system prune -f --filter "until=24h"
|
docker system prune -f --filter "until=24h"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -11,14 +11,17 @@ ARG NEXT_PUBLIC_BASE_URL
|
|||||||
ARG UMAMI_API_ENDPOINT
|
ARG UMAMI_API_ENDPOINT
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ARG NPM_TOKEN
|
ARG NPM_TOKEN
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NPM_TOKEN=$NPM_TOKEN
|
ENV NPM_TOKEN=$NPM_TOKEN
|
||||||
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
||||||
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
|
||||||
# Enable corepack
|
# Enable corepack
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
@@ -37,13 +40,19 @@ RUN pnpm build
|
|||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
# Production environment configuration
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Copy standalone output and static files
|
# Copy standalone output and static files
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Ensure the cache directory specifically is writeable (Mintel Standard #16)
|
||||||
|
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import "../globals.css";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { LazyMotion, domAnimation } from "framer-motion";
|
||||||
|
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -70,9 +73,9 @@ export default async function RootLayout({
|
|||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { locale: string };
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = params;
|
const { locale } = await params;
|
||||||
|
|
||||||
// Validate that the incoming `locale` is supported
|
// Validate that the incoming `locale` is supported
|
||||||
if (locale !== "de") {
|
if (locale !== "de") {
|
||||||
@@ -107,10 +110,26 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
// Track pageview on the server
|
// Track pageview on the server
|
||||||
// This is safe to call here because layout is a Server Component
|
// This is safe to call here because layout is a Server Component
|
||||||
const services = (
|
const serverServices = (
|
||||||
await import("@/lib/services/create-services.server")
|
await import("@/lib/services/create-services.server")
|
||||||
).getServerAppServices();
|
).getServerAppServices();
|
||||||
services.analytics.trackPageview();
|
|
||||||
|
// Populate analytics context with headers for high-fidelity server-side tracking
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if (serverServices.analytics.setServerContext) {
|
||||||
|
serverServices.analytics.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track server-side (initial load)
|
||||||
|
serverServices.analytics.trackPageview("/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className={`${inter.variable}`}>
|
<html lang={locale} className={`${inter.variable}`}>
|
||||||
@@ -122,7 +141,10 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<Layout>{children}</Layout>
|
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||||
|
<LazyMotion features={domAnimation}>
|
||||||
|
<Layout>{children}</Layout>
|
||||||
|
</LazyMotion>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
|||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.1em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Engineering Excellence
|
Technische Beratung
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
|||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.1em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Engineering Excellence
|
Technische Beratung
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ export async function POST(req: Request) {
|
|||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: "contact_submission" });
|
const logger = services.logger.child({ action: "contact_submission" });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
// This fulfills the "server-side via nextjs proxy" requirement
|
||||||
|
if (services.analytics.setServerContext) {
|
||||||
|
services.analytics.setServerContext({
|
||||||
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
|
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: req.headers.get("referer") || undefined,
|
||||||
|
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, email, company, message, website } = await req.json();
|
const { name, email, company, message, website } = await req.json();
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track("contact-form-attempt");
|
||||||
|
|
||||||
// Honeypot check
|
// Honeypot check
|
||||||
if (website) {
|
if (website) {
|
||||||
logger.info("Spam detected (honeypot)");
|
logger.info("Spam detected (honeypot)");
|
||||||
@@ -47,7 +61,14 @@ export async function POST(req: Request) {
|
|||||||
logger.info("Contact submission saved to Directus");
|
logger.info("Contact submission saved to Directus");
|
||||||
directusSaved = true;
|
directusSaved = true;
|
||||||
} catch (directusError) {
|
} catch (directusError) {
|
||||||
logger.error("Failed to save to Directus", { error: directusError });
|
const errorMessage =
|
||||||
|
directusError instanceof Error
|
||||||
|
? directusError.message
|
||||||
|
: String(directusError);
|
||||||
|
logger.error("Failed to save to Directus", {
|
||||||
|
error: errorMessage,
|
||||||
|
details: directusError,
|
||||||
|
});
|
||||||
services.errors.captureException(directusError, {
|
services.errors.captureException(directusError, {
|
||||||
phase: "directus_save",
|
phase: "directus_save",
|
||||||
});
|
});
|
||||||
@@ -56,19 +77,20 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// 2. Email sending
|
// 2. Email sending
|
||||||
try {
|
try {
|
||||||
|
const { config } = await import("@/lib/config");
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: config.mail.host,
|
||||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
port: config.mail.port,
|
||||||
secure: process.env.SMTP_SECURE === "true",
|
secure: config.mail.port === 465,
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.SMTP_USER,
|
user: config.mail.user,
|
||||||
pass: process.env.SMTP_PASS,
|
pass: config.mail.pass,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
from: config.mail.from,
|
||||||
to: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com",
|
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `Kontaktanfrage von ${name}`,
|
subject: `Kontaktanfrage von ${name}`,
|
||||||
text: `
|
text: `
|
||||||
@@ -110,6 +132,11 @@ ${message}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track("contact-form-success", {
|
||||||
|
has_company: Boolean(company),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ message: "Ok" });
|
return NextResponse.json({ message: "Ok" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Global API Error", { error });
|
logger.error("Global API Error", { error });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { m, LazyMotion, domAnimation } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
@@ -62,15 +62,13 @@ export const Button = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const spotlight = (
|
const spotlight = (
|
||||||
<LazyMotion features={domAnimation}>
|
<m.div
|
||||||
<m.div
|
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
||||||
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
style={{
|
||||||
style={{
|
opacity: isHovered ? 1 : 0,
|
||||||
opacity: isHovered ? 1 : 0,
|
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
||||||
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonProps = {
|
const buttonProps = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { m, LazyMotion, domAnimation } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -14,7 +14,6 @@ import { Button } from "./Button";
|
|||||||
import { Counter } from "./Counter";
|
import { Counter } from "./Counter";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
import { TechBackground } from "./TechBackground";
|
import { TechBackground } from "./TechBackground";
|
||||||
import { TileGrid } from "./TileGrid";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -80,7 +79,6 @@ export default function Home() {
|
|||||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
||||||
<TechBackground />
|
<TechBackground />
|
||||||
</div>
|
</div>
|
||||||
<TileGrid />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
<div className="container-custom relative z-10">
|
||||||
<div className="text-left relative">
|
<div className="text-left relative">
|
||||||
@@ -206,7 +204,7 @@ export default function Home() {
|
|||||||
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
||||||
<Image
|
<Image
|
||||||
src="/media/cables/hs-kabel.png"
|
src="/media/cables/hs-kabel.png"
|
||||||
alt="Technical Engineering"
|
alt="Technische Beratung"
|
||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
||||||
@@ -326,55 +324,53 @@ export default function Home() {
|
|||||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||||
|
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
||||||
<LazyMotion features={domAnimation}>
|
<svg
|
||||||
<svg
|
viewBox="0 0 400 400"
|
||||||
viewBox="0 0 400 400"
|
fill="none"
|
||||||
fill="none"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
>
|
<m.circle
|
||||||
<m.circle
|
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
transition={{
|
||||||
transition={{
|
duration: 5,
|
||||||
duration: 5,
|
repeat: Infinity,
|
||||||
repeat: Infinity,
|
ease: "easeInOut",
|
||||||
ease: "easeInOut",
|
}}
|
||||||
}}
|
cx="400"
|
||||||
cx="400"
|
cy="0"
|
||||||
cy="0"
|
r="400"
|
||||||
r="400"
|
stroke="white"
|
||||||
stroke="white"
|
strokeWidth="2"
|
||||||
strokeWidth="2"
|
/>
|
||||||
/>
|
<m.circle
|
||||||
<m.circle
|
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
transition={{
|
||||||
transition={{
|
duration: 4,
|
||||||
duration: 4,
|
repeat: Infinity,
|
||||||
repeat: Infinity,
|
ease: "easeInOut",
|
||||||
ease: "easeInOut",
|
delay: 0.5,
|
||||||
delay: 0.5,
|
}}
|
||||||
}}
|
cx="400"
|
||||||
cx="400"
|
cy="0"
|
||||||
cy="0"
|
r="300"
|
||||||
r="300"
|
stroke="white"
|
||||||
stroke="white"
|
strokeWidth="2"
|
||||||
strokeWidth="2"
|
/>
|
||||||
/>
|
<m.circle
|
||||||
<m.circle
|
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
transition={{
|
||||||
transition={{
|
duration: 3,
|
||||||
duration: 3,
|
repeat: Infinity,
|
||||||
repeat: Infinity,
|
ease: "easeInOut",
|
||||||
ease: "easeInOut",
|
delay: 1,
|
||||||
delay: 1,
|
}}
|
||||||
}}
|
cx="400"
|
||||||
cx="400"
|
cy="0"
|
||||||
cy="0"
|
r="200"
|
||||||
r="200"
|
stroke="white"
|
||||||
stroke="white"
|
strokeWidth="2"
|
||||||
strokeWidth="2"
|
/>
|
||||||
/>
|
</svg>
|
||||||
</svg>
|
|
||||||
</LazyMotion>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion";
|
import { AnimatePresence, m } from "framer-motion";
|
||||||
import { ArrowUp, Home, Info, Menu, X } from "lucide-react";
|
import { ArrowUp, Home, Info, Menu, X } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -116,38 +116,36 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<LazyMotion features={domAnimation}>
|
<AnimatePresence>
|
||||||
<AnimatePresence>
|
{isMobileMenuOpen && (
|
||||||
{isMobileMenuOpen && (
|
<m.div
|
||||||
<m.div
|
initial={{ opacity: 0, y: -20 }}
|
||||||
initial={{ opacity: 0, y: -20 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
|
||||||
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
|
>
|
||||||
>
|
<nav className="flex flex-col gap-4">
|
||||||
<nav className="flex flex-col gap-4">
|
{navLinks.map((link) => (
|
||||||
{navLinks.map((link) => (
|
<Link
|
||||||
<Link
|
key={link.href}
|
||||||
key={link.href}
|
href={link.href}
|
||||||
href={link.href}
|
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
||||||
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
isActive(link.href)
|
||||||
isActive(link.href)
|
? "text-accent bg-accent/5"
|
||||||
? "text-accent bg-accent/5"
|
: "text-slate-600 hover:text-primary hover:bg-slate-50"
|
||||||
: "text-slate-600 hover:text-primary hover:bg-slate-50"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<link.icon size={24} />
|
||||||
<link.icon size={24} />
|
{link.label}
|
||||||
{link.label}
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
))}
|
<Button href="/kontakt" className="mt-4 w-full">
|
||||||
<Button href="/kontakt" className="mt-4 w-full">
|
{t("nav.cta")}
|
||||||
{t("nav.cta")}
|
</Button>
|
||||||
</Button>
|
</nav>
|
||||||
</nav>
|
</m.div>
|
||||||
</m.div>
|
)}
|
||||||
)}
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
|
||||||
</LazyMotion>
|
|
||||||
|
|
||||||
<main className="flex-grow">{children}</main>
|
<main className="flex-grow">{children}</main>
|
||||||
|
|
||||||
@@ -168,18 +166,16 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
|
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
|
||||||
|
|
||||||
{/* Animated Tech Lines */}
|
{/* Animated Tech Lines */}
|
||||||
<LazyMotion features={domAnimation}>
|
<m.div
|
||||||
<m.div
|
animate={{ x: ["-100%", "100%"] }}
|
||||||
animate={{ x: ["-100%", "100%"] }}
|
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
||||||
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
|
||||||
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
|
/>
|
||||||
/>
|
<m.div
|
||||||
<m.div
|
animate={{ x: ["100%", "-100%"] }}
|
||||||
animate={{ x: ["100%", "-100%"] }}
|
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
|
||||||
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
|
/>
|
||||||
/>
|
|
||||||
</LazyMotion>
|
|
||||||
|
|
||||||
{/* Corner Accents */}
|
{/* Corner Accents */}
|
||||||
<div className="tech-corner top-8 left-8 border-t border-l border-white/10 group-hover:border-accent/30 transition-colors duration-700" />
|
<div className="tech-corner top-8 left-8 border-t border-l border-white/10 group-hover:border-accent/30 transition-colors duration-700" />
|
||||||
@@ -279,7 +275,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<div className="bg-slate-950 py-6 border-t border-white/5">
|
<div className="bg-slate-950 py-6 border-t border-white/5">
|
||||||
<div className="container-custom">
|
<div className="container-custom">
|
||||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
|
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
|
||||||
Website developed by{" "}
|
Website entwickelt von{" "}
|
||||||
<a
|
<a
|
||||||
href="https://mintel.me"
|
href="https://mintel.me"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from "framer-motion";
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
direction?: 'up' | 'down' | 'left' | 'right';
|
direction?: "up" | "down" | "left" | "right";
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
viewportMargin?: string;
|
viewportMargin?: string;
|
||||||
trigger?: 'inView' | 'mount';
|
trigger?: "inView" | "mount";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Reveal = ({
|
export const Reveal = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = "",
|
||||||
delay = 0,
|
delay = 0,
|
||||||
direction = 'up',
|
direction = "up",
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
viewportMargin = "-50px",
|
viewportMargin = "-50px",
|
||||||
trigger = 'inView'
|
trigger = "inView",
|
||||||
}: RevealProps) => {
|
}: RevealProps) => {
|
||||||
const directions = {
|
const directions = {
|
||||||
up: { y: 30 },
|
up: { y: 30 },
|
||||||
@@ -30,35 +30,45 @@ export const Reveal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
initial={{
|
initial={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
...directions[direction]
|
...directions[direction],
|
||||||
}}
|
}}
|
||||||
animate={trigger === 'mount' ? {
|
animate={
|
||||||
opacity: 1,
|
trigger === "mount"
|
||||||
x: 0,
|
? {
|
||||||
y: 0
|
opacity: 1,
|
||||||
} : undefined}
|
x: 0,
|
||||||
whileInView={trigger === 'inView' ? {
|
y: 0,
|
||||||
opacity: 1,
|
}
|
||||||
x: 0,
|
: undefined
|
||||||
y: 0
|
}
|
||||||
} : undefined}
|
whileInView={
|
||||||
viewport={trigger === 'inView' ? { once: true, margin: viewportMargin } : undefined}
|
trigger === "inView"
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
viewport={
|
||||||
|
trigger === "inView"
|
||||||
|
? { once: true, margin: viewportMargin }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 50,
|
stiffness: 50,
|
||||||
damping: 20,
|
damping: 20,
|
||||||
mass: 1,
|
mass: 1,
|
||||||
delay: delay
|
delay: delay,
|
||||||
}}
|
}}
|
||||||
className={`${fullWidth ? 'w-full' : ''} ${className} motion-fix`}
|
className={`${fullWidth ? "w-full" : ""} ${className} motion-fix will-change-[transform,opacity]`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,13 +78,12 @@ interface StaggerProps {
|
|||||||
staggerDelay?: number;
|
staggerDelay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Stagger = ({
|
export const Stagger = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = "",
|
||||||
staggerDelay = 0.1
|
staggerDelay = 0.1,
|
||||||
}: StaggerProps) => {
|
}: StaggerProps) => {
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
initial="initial"
|
initial="initial"
|
||||||
whileInView="animate"
|
whileInView="animate"
|
||||||
@@ -90,6 +99,5 @@ export const Stagger = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
|
||||||
|
|
||||||
export const TileGrid = () => {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
const rows = 15;
|
|
||||||
const cols = 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<div className="flex flex-col gap-3 min-w-[120%] min-h-[120%] -left-[10%] -top-[10%] absolute">
|
|
||||||
{[...Array(rows)].map((_, rowIndex) => (
|
|
||||||
<div
|
|
||||||
key={rowIndex}
|
|
||||||
className="flex gap-3 justify-center"
|
|
||||||
style={{
|
|
||||||
transform: rowIndex % 2 === 0 ? 'translateX(0)' : 'translateX(80px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[...Array(cols)].map((_, colIndex) => (
|
|
||||||
<m.div
|
|
||||||
key={`${rowIndex}-${colIndex}`}
|
|
||||||
initial={{ opacity: 0.05 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0.05, Math.random() > 0.9 ? 0.25 : 0.05, 0.05],
|
|
||||||
scale: [1, Math.random() > 0.9 ? 1.05 : 1, 1]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 5 + Math.random() * 5,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: Math.random() * 20,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="w-24 h-24 md:w-40 md:h-40 bg-white/10 backdrop-blur-[2px] rounded-2xl md:rounded-3xl border border-white/20 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] shrink-0"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</LazyMotion>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${PROJECT_NAME}-auth"
|
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
||||||
@@ -27,6 +27,12 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function createConfig() {
|
|||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.UMAMI_WEBSITE_ID,
|
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
enabled: Boolean(
|
||||||
|
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,21 @@ export async function ensureAuthenticated() {
|
|||||||
if (adminEmail && password) {
|
if (adminEmail && password) {
|
||||||
try {
|
try {
|
||||||
await client.login({ email: adminEmail, password: password });
|
await client.login({ email: adminEmail, password: password });
|
||||||
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
getServerAppServices().errors.captureException(e, {
|
getServerAppServices().errors.captureException(e, {
|
||||||
phase: "directus_auth",
|
phase: "directus_auth_fallback",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.error("Failed to authenticate with Directus login fallback:", e);
|
console.error("Failed to authenticate with Directus login fallback:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Missing Directus authentication credentials (token or admin email/password)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default client;
|
export default client;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const envSchema = z
|
|||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().optional(),
|
z.string().optional(),
|
||||||
),
|
),
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
|
||||||
|
preprocessEmptyString,
|
||||||
|
z.string().optional(),
|
||||||
|
),
|
||||||
UMAMI_API_ENDPOINT: z.preprocess(
|
UMAMI_API_ENDPOINT: z.preprocess(
|
||||||
preprocessEmptyString,
|
preprocessEmptyString,
|
||||||
z.string().url().default("https://analytics.infra.mintel.me"),
|
z.string().url().default("https://analytics.infra.mintel.me"),
|
||||||
@@ -115,6 +119,7 @@ export function getRawEnv() {
|
|||||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||||
UMAMI_WEBSITE_ID:
|
UMAMI_WEBSITE_ID:
|
||||||
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||||
UMAMI_API_ENDPOINT:
|
UMAMI_API_ENDPOINT:
|
||||||
process.env.UMAMI_API_ENDPOINT ||
|
process.env.UMAMI_API_ENDPOINT ||
|
||||||
process.env.UMAMI_SCRIPT_URL ||
|
process.env.UMAMI_SCRIPT_URL ||
|
||||||
|
|||||||
@@ -73,4 +73,15 @@ export interface AnalyticsService {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
trackPageview(url?: string): void;
|
trackPageview(url?: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the server-side context for the current request.
|
||||||
|
* This is used for server-side tracking (e.g. from Next.js proxy).
|
||||||
|
*/
|
||||||
|
setServerContext?(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,16 @@ export class NoopAnalyticsService implements AnalyticsService {
|
|||||||
trackPageview(_url?: string) {
|
trackPageview(_url?: string) {
|
||||||
// intentionally noop - analytics are disabled
|
// intentionally noop - analytics are disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op implementation of setServerContext.
|
||||||
|
*/
|
||||||
|
setServerContext(_context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
// intentionally noop - analytics are disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
private websiteId?: string;
|
private websiteId?: string;
|
||||||
private endpoint: string;
|
private endpoint: string;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||||
this.websiteId = config.analytics.umami.websiteId;
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
@@ -36,6 +42,19 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
: "/stats";
|
: "/stats";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the server-side context for the current request.
|
||||||
|
* This allows the service to use real request headers for tracking.
|
||||||
|
*/
|
||||||
|
setServerContext(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
this.serverContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal method to send the payload to Umami API.
|
* Internal method to send the payload to Umami API.
|
||||||
*/
|
*/
|
||||||
@@ -53,18 +72,37 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
? `${window.screen.width}x${window.screen.height}`
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
: undefined,
|
: undefined,
|
||||||
language:
|
language:
|
||||||
typeof window !== "undefined" ? navigator.language : undefined,
|
typeof window !== "undefined"
|
||||||
referrer: typeof window !== "undefined" ? document.referrer : undefined,
|
? navigator.language
|
||||||
|
: this.serverContext?.language,
|
||||||
|
referrer:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? document.referrer
|
||||||
|
: this.serverContext?.referrer,
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set User-Agent
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
headers["User-Agent"] = navigator.userAgent;
|
||||||
|
} else if (this.serverContext?.userAgent) {
|
||||||
|
headers["User-Agent"] = this.serverContext.userAgent;
|
||||||
|
} else {
|
||||||
|
headers["User-Agent"] = "Mintel-Server-Proxy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward client IP if available (Umami must be configured to trust this)
|
||||||
|
if (this.serverContext?.ip) {
|
||||||
|
headers["X-Forwarded-For"] = this.serverContext.ip;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent":
|
|
||||||
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload }),
|
body: JSON.stringify({ type, payload }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Index": {
|
"Index": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"tag": "Engineering Excellence",
|
"tag": "Technische Beratung",
|
||||||
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
||||||
"titleHighlight": "Energiekabelprojekte",
|
"titleHighlight": "Energiekabelprojekte",
|
||||||
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"expertise": {
|
"expertise": {
|
||||||
"tag": "Expertise",
|
"tag": "Expertise",
|
||||||
"title": "Anwendungen & Zielgruppen",
|
"title": "Anwendungen & Zielgruppen",
|
||||||
"description": "Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.",
|
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
|
||||||
"groups": [
|
"groups": [
|
||||||
"Energieversorger",
|
"Energieversorger",
|
||||||
"Ingenieurbüros",
|
"Ingenieurbüros",
|
||||||
@@ -83,16 +83,16 @@
|
|||||||
"datenschutz": "Datenschutz",
|
"datenschutz": "Datenschutz",
|
||||||
"agb": "AGB",
|
"agb": "AGB",
|
||||||
"rights": "Alle Rechte vorbehalten.",
|
"rights": "Alle Rechte vorbehalten.",
|
||||||
"madeWith": "Made with",
|
"madeWith": "Entwickelt mit",
|
||||||
"precision": "precision",
|
"precision": "Präzision",
|
||||||
"inGermany": "in Germany"
|
"inGermany": "in Deutschland"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"tagline": "Über uns",
|
"tagline": "Über uns",
|
||||||
"title": "Wir gestalten die Infrastructure der Zukunft",
|
"title": "Zuverlässige Begleitung für Ihre Netzinfrastruktur",
|
||||||
"subtitle": "MB Grid Solution steht für technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
"subtitle": "Herstellerneutrale Beratung in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"p1": "Unsere Wurzeln liegen in der tiefen praktischen Erfahrung unserer technischen Berater und unserer Netzwerke im globalem Kabelmarkt. Wir vereinen Tradition mit modernster Innovation, um zuverlässige Energielösungen für Projekte bis 110 kV zu realisieren.",
|
"p1": "Unsere Wurzeln liegen in der tiefen praktischen Erfahrung unserer technischen Berater und unserer Netzwerke im globalem Kabelmarkt. Wir vereinen Tradition mit modernster Innovation, um zuverlässige Energielösungen für Projekte bis 110 kV zu realisieren.",
|
||||||
|
|||||||
@@ -14,5 +14,9 @@ export default createMiddleware({
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Matcher for all pages and internationalized pathnames
|
// Matcher for all pages and internationalized pathnames
|
||||||
// excluding api, _next, static files, etc.
|
// excluding api, _next, static files, etc.
|
||||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)", "/", "/(de)/:path*"],
|
matcher: [
|
||||||
|
"/((?!api|stats|errors|_next|_vercel|.*\\..*).*)",
|
||||||
|
"/",
|
||||||
|
"/(de)/:path*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ const nextConfig = {
|
|||||||
source: "/stats/:path*",
|
source: "/stats/:path*",
|
||||||
destination: `${umamiUrl}/:path*`,
|
destination: `${umamiUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/stats/:path*",
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/errors/:path*",
|
source: "/errors/:path*",
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/errors/:path*",
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
|
|||||||
case $ENV in
|
case $ENV in
|
||||||
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
|
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
|
||||||
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
|
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
|
||||||
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
|
production) PROJECT_NAME="${PRJ_ID}-production"; ENV_FILE=".env.prod" ;;
|
||||||
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -35,8 +35,21 @@ DB_NAME="directus"
|
|||||||
|
|
||||||
echo "🔍 Detecting local database..."
|
echo "🔍 Detecting local database..."
|
||||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||||
|
|
||||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
# Check if it exists but is stopped
|
||||||
|
LOCAL_DB_EXISTS=$(docker compose ps -a -q directus-db)
|
||||||
|
if [ -n "$LOCAL_DB_EXISTS" ]; then
|
||||||
|
echo "⏳ Local directus-db is stopped. Starting it..."
|
||||||
|
docker compose up -d directus-db
|
||||||
|
# Wait a few seconds for PG to be ready
|
||||||
|
sleep 2
|
||||||
|
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||||
|
echo "❌ Local directus-db container not found. Is it defined in docker-compose.yaml?"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
0
tests/.gitkeep
Normal file
0
tests/.gitkeep
Normal file
@@ -1,97 +0,0 @@
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import Contact from '../app/kontakt/page'
|
|
||||||
|
|
||||||
// Mock fetch
|
|
||||||
const fetchMock = vi.fn()
|
|
||||||
global.fetch = fetchMock
|
|
||||||
|
|
||||||
// Mock alert
|
|
||||||
const alertMock = vi.fn()
|
|
||||||
global.alert = alertMock
|
|
||||||
|
|
||||||
describe('Contact Page', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchMock.mockClear()
|
|
||||||
alertMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the contact form correctly', () => {
|
|
||||||
render(<Contact />)
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: /Nachricht senden/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('submits the form successfully', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true }),
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Contact />)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/Firma/i), { target: { value: 'Acme Corp' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/contact', expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: 'John Doe',
|
|
||||||
company: 'Acme Corp',
|
|
||||||
email: 'john@example.com',
|
|
||||||
message: 'This is a test message that is long enough.',
|
|
||||||
website: ''
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Vielen Dank für Ihre Anfrage/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles submission errors', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
json: async () => ({ error: 'Server error' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<Contact />)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Fehler: Server error')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles network errors', async () => {
|
|
||||||
fetchMock.mockRejectedValueOnce(new Error('Network error'))
|
|
||||||
|
|
||||||
render(<Contact />)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), { target: { value: 'John Doe' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), { target: { value: 'john@example.com' } })
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), { target: { value: 'This is a test message that is long enough.' } })
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(alertMock).toHaveBeenCalledWith('Es gab einen Fehler beim Senden Ihrer Nachricht.')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import Home from '../app/page'
|
|
||||||
|
|
||||||
describe('Home Page', () => {
|
|
||||||
it('renders the hero section with correct title', () => {
|
|
||||||
render(<Home />)
|
|
||||||
expect(screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('contains the CTA button', () => {
|
|
||||||
render(<Home />)
|
|
||||||
const ctaButton = screen.getByRole('link', { name: /Projekt anfragen/i })
|
|
||||||
expect(ctaButton).toBeInTheDocument()
|
|
||||||
expect(ctaButton).toHaveAttribute('href', '/kontakt')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the portfolio section', () => {
|
|
||||||
render(<Home />)
|
|
||||||
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument()
|
|
||||||
// Use getAllByText because it appears in both hero description and card title
|
|
||||||
const elements = screen.getAllByText(/Technische Beratung/i)
|
|
||||||
expect(elements.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import '@testing-library/jest-dom'
|
|
||||||
import { vi } from 'vitest'
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: () => '/',
|
|
||||||
useRouter: () => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
replace: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
126
tests_bak/contact.test.tsx
Normal file
126
tests_bak/contact.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import Contact from "../app/kontakt/page";
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
// Mock alert
|
||||||
|
const alertMock = vi.fn();
|
||||||
|
global.alert = alertMock;
|
||||||
|
|
||||||
|
describe("Contact Page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockClear();
|
||||||
|
alertMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the contact form correctly", () => {
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Nachricht senden/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the form successfully", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||||
|
target: { value: "John Doe" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Firma/i), {
|
||||||
|
target: { value: "Acme Corp" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||||
|
target: { value: "john@example.com" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||||
|
target: { value: "This is a test message that is long enough." },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/contact",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "John Doe",
|
||||||
|
company: "Acme Corp",
|
||||||
|
email: "john@example.com",
|
||||||
|
message: "This is a test message that is long enough.",
|
||||||
|
website: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Vielen Dank für Ihre Anfrage/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles submission errors", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ error: "Server error" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||||
|
target: { value: "John Doe" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||||
|
target: { value: "john@example.com" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||||
|
target: { value: "This is a test message that is long enough." },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertMock).toHaveBeenCalledWith("Fehler: Server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles network errors", async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<Contact />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||||
|
target: { value: "John Doe" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||||
|
target: { value: "john@example.com" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||||
|
target: { value: "This is a test message that is long enough." },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(alertMock).toHaveBeenCalledWith(
|
||||||
|
"Es gab einen Fehler beim Senden Ihrer Nachricht.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests_bak/home.test.tsx
Normal file
27
tests_bak/home.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import Home from "../app/page";
|
||||||
|
|
||||||
|
describe("Home Page", () => {
|
||||||
|
it("renders the hero section with correct title", () => {
|
||||||
|
render(<Home />);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains the CTA button", () => {
|
||||||
|
render(<Home />);
|
||||||
|
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
||||||
|
expect(ctaButton).toBeInTheDocument();
|
||||||
|
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the portfolio section", () => {
|
||||||
|
render(<Home />);
|
||||||
|
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
||||||
|
// Use getAllByText because it appears in both hero description and card title
|
||||||
|
const elements = screen.getAllByText(/Technische Beratung/i);
|
||||||
|
expect(elements.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
tests_bak/setup.ts
Normal file
12
tests_bak/setup.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/",
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -13,5 +13,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "tests", "tests_bak"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user