Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"
|
||||||
@@ -228,6 +232,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 +251,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 }}
|
||||||
@@ -284,7 +288,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
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
|||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
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 +38,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,7 @@ 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";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -70,9 +71,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") {
|
||||||
@@ -122,7 +123,9 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<Layout>{children}</Layout>
|
<LazyMotion features={domAnimation}>
|
||||||
|
<Layout>{children}</Layout>
|
||||||
|
</LazyMotion>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -47,7 +47,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 +63,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: `
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,11 +80,10 @@ interface StaggerProps {
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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