forked from RheinFrame/RheinFrame-Web
Initial commit: RheinFrame Web codebase
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# SMTP-Zugangsdaten für das Kontaktformular (/kontakt).
|
||||||
|
# Funktioniert mit jedem SMTP-Anbieter (kein Vendor-Lock-in) — z. B. eigener
|
||||||
|
# Mailserver, Mailbox.org, Fastmail, o. ä.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
CONTACT_TO_EMAIL=kontakt@rheinframe.de
|
||||||
|
|
||||||
|
# Basis-URL der Website, u. a. für Sitemap, OpenGraph-Bilder und JSON-LD.
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://www.rheinframe.de
|
||||||
|
|
||||||
|
# Optional: privatsphärefreundliches, self-hostable Analytics (z. B. Plausible
|
||||||
|
# oder Umami). Bleibt das Feld leer, wird kein Analytics-Skript geladen und es
|
||||||
|
# ist kein Cookie-Banner nötig.
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=
|
||||||
|
# Nur nötig bei selbst gehostetem Plausible/Umami — sonst plausible.io-Default.
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SRC=
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6892
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "rheinmedia",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"next": "16.2.10",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.10",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
interface ContactPayload {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
company?: string;
|
||||||
|
message?: string;
|
||||||
|
honeypot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let body: ContactPayload;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, company, message, honeypot } = body;
|
||||||
|
|
||||||
|
// Honeypot field — real users never fill this in, bots often do.
|
||||||
|
if (honeypot) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !email || !message || !EMAIL_RE.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Bitte Name, eine gültige E-Mail-Adresse und eine Nachricht angeben." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO_EMAIL } = process.env;
|
||||||
|
|
||||||
|
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS || !CONTACT_TO_EMAIL) {
|
||||||
|
console.error("Kontaktformular: SMTP-Umgebungsvariablen sind nicht konfiguriert.");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Der Versand ist derzeit nicht konfiguriert. Bitte kontaktieren Sie uns per E-Mail." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: SMTP_HOST,
|
||||||
|
port: Number(SMTP_PORT ?? 587),
|
||||||
|
secure: Number(SMTP_PORT ?? 587) === 465,
|
||||||
|
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Rheinframe Kontaktformular" <${SMTP_USER}>`,
|
||||||
|
to: CONTACT_TO_EMAIL,
|
||||||
|
replyTo: email,
|
||||||
|
subject: `Neue Anfrage von ${name}${company ? ` (${company})` : ""}`,
|
||||||
|
text: [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`E-Mail: ${email}`,
|
||||||
|
company ? `Unternehmen: ${company}` : null,
|
||||||
|
"",
|
||||||
|
message,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Kontaktformular: Versand fehlgeschlagen.", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Nachricht konnte nicht versendet werden. Bitte versuchen Sie es später erneut." },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Datenschutz",
|
||||||
|
description: "Datenschutzerklärung der Rheinframe IT GmbH.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DatenschutzPage() {
|
||||||
|
return (
|
||||||
|
<section className="py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 lg:px-8">
|
||||||
|
<SectionLabel>Rechtliches</SectionLabel>
|
||||||
|
<h1 className="mt-5 text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-12 space-y-10 text-sm leading-relaxed text-ink-300">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">1. Verantwortlicher</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Verantwortlich für die Datenverarbeitung auf dieser Website ist:
|
||||||
|
<br />
|
||||||
|
Rheinframe IT GmbH, Rheinstraße 42, 55116 Mainz
|
||||||
|
<br />
|
||||||
|
E-Mail: kontakt@rheinframe.de
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">2. Ihre Rechte</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Sie haben jederzeit das Recht auf Auskunft über Ihre bei uns
|
||||||
|
gespeicherten personenbezogenen Daten, deren Herkunft und
|
||||||
|
Empfänger sowie den Zweck der Datenverarbeitung. Ihnen steht
|
||||||
|
zudem ein Recht auf Berichtigung, Sperrung, Löschung sowie
|
||||||
|
Widerspruch gegen die Verarbeitung dieser Daten zu. Hierzu sowie
|
||||||
|
zu weiteren Fragen können Sie sich jederzeit unter der im
|
||||||
|
Impressum genannten Adresse an uns wenden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">
|
||||||
|
3. Erhebung und Speicherung beim Besuch der Website
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Beim Aufrufen unserer Website übermittelt Ihr Browser
|
||||||
|
automatisch Informationen an unseren Server (Server-Logfiles):
|
||||||
|
IP-Adresse, Datum und Uhrzeit der Anfrage, Zeitzonendifferenz,
|
||||||
|
Inhalt der Anforderung, Zugriffsstatus, übertragene Datenmenge,
|
||||||
|
Website, von der die Anforderung kommt, Browser, Betriebssystem
|
||||||
|
und dessen Oberfläche. Die Verarbeitung erfolgt auf Grundlage
|
||||||
|
von Art. 6 Abs. 1 lit. f DSGVO aus unserem berechtigten
|
||||||
|
Interesse an der Gewährleistung eines störungsfreien
|
||||||
|
Betriebs sowie der Sicherheit unserer IT-Systeme. Die Daten
|
||||||
|
werden gelöscht, sobald sie für den Zweck ihrer Erhebung nicht
|
||||||
|
mehr erforderlich sind, in der Regel nach wenigen Tagen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">4. Kontaktformular</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Wenn Sie uns über das Kontaktformular unter{" "}
|
||||||
|
<span className="text-ink-100">/kontakt</span> Anfragen
|
||||||
|
zukommen lassen, werden Ihre Angaben aus dem Formular
|
||||||
|
einschließlich der von Ihnen dort angegebenen Kontaktdaten zum
|
||||||
|
Zwecke der Bearbeitung der Anfrage und für den Fall von
|
||||||
|
Anschlussfragen bei uns gespeichert. Die Verarbeitung dieser
|
||||||
|
Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO,
|
||||||
|
sofern Ihre Anfrage mit der Erfüllung eines Vertrags
|
||||||
|
zusammenhängt oder zur Durchführung vorvertraglicher Maßnahmen
|
||||||
|
erforderlich ist. Der Versand erfolgt über einen von uns
|
||||||
|
beauftragten E-Mail-Dienstleister (SMTP), der die Daten
|
||||||
|
ausschließlich zur Zustellung verarbeitet. Die Daten werden
|
||||||
|
gelöscht, sobald sie für die Erreichung des Zwecks ihrer
|
||||||
|
Erhebung nicht mehr erforderlich sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">5. Cookies & Analyse-Tools</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Diese Website verwendet standardmäßig keine Tracking- oder
|
||||||
|
Marketing-Cookies. Sofern wir zur anonymisierten,
|
||||||
|
reichweitenbezogenen Auswertung ein selbst gehostetes,
|
||||||
|
cookieloses Analyse-Tool (z. B. Plausible oder Umami)
|
||||||
|
einsetzen, werden keine personenbezogenen Profile gebildet und
|
||||||
|
keine IP-Adressen dauerhaft gespeichert. Eine Einwilligung ist
|
||||||
|
in diesem Fall gemäß den einschlägigen Aufsichtsbehörden nicht
|
||||||
|
erforderlich; ein entsprechender Hinweis würde an dieser Stelle
|
||||||
|
ergänzt, sobald ein solches Tool aktiv im Einsatz ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">6. SSL-/TLS-Verschlüsselung</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Diese Seite nutzt aus Sicherheitsgründen eine SSL- bzw.
|
||||||
|
TLS-Verschlüsselung für die Übertragung vertraulicher Inhalte,
|
||||||
|
wie zum Beispiel Anfragen, die Sie an uns als Seitenbetreiber
|
||||||
|
senden. Eine verschlüsselte Verbindung erkennen Sie an dem
|
||||||
|
Schloss-Symbol Ihres Browsers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">
|
||||||
|
7. Änderung dieser Datenschutzerklärung
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Wir behalten uns vor, diese Datenschutzerklärung anzupassen,
|
||||||
|
damit sie stets den aktuellen rechtlichen Anforderungen
|
||||||
|
entspricht oder um Änderungen unserer Leistungen in der
|
||||||
|
Datenschutzerklärung umzusetzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Neutral "ink" scale — slightly blue-tinted slate, not flat gray */
|
||||||
|
--color-ink-950: #05070a;
|
||||||
|
--color-ink-900: #090c11;
|
||||||
|
--color-ink-850: #0d1119;
|
||||||
|
--color-ink-800: #121722;
|
||||||
|
--color-ink-700: #1a2029;
|
||||||
|
--color-ink-600: #262d38;
|
||||||
|
--color-ink-500: #3a4350;
|
||||||
|
--color-ink-400: #5c6672;
|
||||||
|
--color-ink-300: #838d99;
|
||||||
|
--color-ink-200: #aeb6c0;
|
||||||
|
--color-ink-100: #d6dbe1;
|
||||||
|
--color-ink-50: #f2f4f6;
|
||||||
|
|
||||||
|
/* Accent — "river" teal-blue, referencing the Rhein */
|
||||||
|
--color-river-700: #0a5c56;
|
||||||
|
--color-river-600: #0c7d72;
|
||||||
|
--color-river-500: #16a394;
|
||||||
|
--color-river-400: #2ec4b3;
|
||||||
|
--color-river-300: #63dbca;
|
||||||
|
--color-river-200: #a3ece0;
|
||||||
|
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
|
--animate-fade-up: fade-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
|
||||||
|
@keyframes fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: var(--color-ink-950);
|
||||||
|
--foreground: var(--color-ink-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-river-500);
|
||||||
|
color: var(--color-ink-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin, unobtrusive scrollbar to match the premium-tech feel */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-ink-600) transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-ink-600);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--color-ink-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fine background grid, used behind hero/section panels */
|
||||||
|
.bg-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, color-mix(in oklab, var(--color-ink-100) 6%, transparent) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, color-mix(in oklab, var(--color-ink-100) 6%, transparent) 1px, transparent 1px);
|
||||||
|
background-size: 56px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="30" height="30" rx="7.5" fill="#05070a"/>
|
||||||
|
<path d="M7 20.5L13.5 9.5L17 15.5L20 10.5L23 20.5" stroke="#f2f4f6" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="13.5" cy="9.5" r="1.6" fill="#2ec4b3"/>
|
||||||
|
<circle cx="20" cy="10.5" r="1.6" fill="#2ec4b3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Impressum",
|
||||||
|
description: "Impressum der Rheinframe IT GmbH gemäß § 5 TMG.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ImpressumPage() {
|
||||||
|
return (
|
||||||
|
<section className="py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 lg:px-8">
|
||||||
|
<SectionLabel>Rechtliches</SectionLabel>
|
||||||
|
<h1 className="mt-5 text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-12 space-y-10 text-sm leading-relaxed text-ink-300">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Angaben gemäß § 5 TMG</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Rheinframe IT GmbH
|
||||||
|
<br />
|
||||||
|
Rheinstraße 42
|
||||||
|
<br />
|
||||||
|
55116 Mainz
|
||||||
|
<br />
|
||||||
|
Deutschland
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Vertreten durch</h2>
|
||||||
|
<p className="mt-3">Geschäftsführung: [Name der Geschäftsführung]</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Kontakt</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Telefon: 06131 / 000 000
|
||||||
|
<br />
|
||||||
|
E-Mail: kontakt@rheinframe.de
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Registereintrag</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Eintragung im Handelsregister.
|
||||||
|
<br />
|
||||||
|
Registergericht: Amtsgericht Mainz
|
||||||
|
<br />
|
||||||
|
Registernummer: HRB [Nummer]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Umsatzsteuer-ID</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:
|
||||||
|
<br />
|
||||||
|
DE [Nummer]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">
|
||||||
|
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
[Name der verantwortlichen Person]
|
||||||
|
<br />
|
||||||
|
Rheinstraße 42, 55116 Mainz
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">EU-Streitschlichtung</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Die Europäische Kommission stellt eine Plattform zur
|
||||||
|
Online-Streitbeilegung (OS) bereit:{" "}
|
||||||
|
<a
|
||||||
|
href="https://ec.europa.eu/consumers/odr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-river-300 hover:text-river-200"
|
||||||
|
>
|
||||||
|
ec.europa.eu/consumers/odr
|
||||||
|
</a>
|
||||||
|
. Unsere E-Mail-Adresse finden Sie oben im Impressum. Wir sind
|
||||||
|
nicht verpflichtet und nicht bereit, an
|
||||||
|
Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle
|
||||||
|
teilzunehmen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Haftung für Inhalte</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene
|
||||||
|
Inhalte auf diesen Seiten nach den allgemeinen Gesetzen
|
||||||
|
verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte
|
||||||
|
oder gespeicherte fremde Informationen zu überwachen oder nach
|
||||||
|
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit
|
||||||
|
hinweisen. Verpflichtungen zur Entfernung oder Sperrung der
|
||||||
|
Nutzung von Informationen nach den allgemeinen Gesetzen bleiben
|
||||||
|
hiervon unberührt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Haftung für Links</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Unser Angebot enthält Links zu externen Websites Dritter, auf
|
||||||
|
deren Inhalte wir keinen Einfluss haben. Deshalb können wir für
|
||||||
|
diese fremden Inhalte auch keine Gewähr übernehmen. Für die
|
||||||
|
Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter
|
||||||
|
oder Betreiber der Seiten verantwortlich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium text-ink-50">Urheberrecht</h2>
|
||||||
|
<p className="mt-3">
|
||||||
|
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf
|
||||||
|
diesen Seiten unterliegen dem deutschen Urheberrecht. Die
|
||||||
|
Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||||
|
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der
|
||||||
|
schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { GitBranch, Home, Rocket, Users } from "lucide-react";
|
||||||
|
import PageHero from "@/components/ui/PageHero";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
import BentoCard from "@/components/ui/BentoCard";
|
||||||
|
import PhotoPanel from "@/components/ui/PhotoPanel";
|
||||||
|
import JobCard from "@/components/karriere/JobCard";
|
||||||
|
import InlineCta from "@/components/ui/InlineCta";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Karriere",
|
||||||
|
description:
|
||||||
|
"Offene Stellen bei Rheinframe IT in Mainz: Systemadministrator Linux, DevOps Engineer und Initiativbewerbungen im Open-Source-Infrastruktur-Umfeld.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconProps = { className: "size-5", strokeWidth: 1.5 } as const;
|
||||||
|
|
||||||
|
const benefits = [
|
||||||
|
{
|
||||||
|
icon: <GitBranch {...iconProps} />,
|
||||||
|
title: "Open-Source-first",
|
||||||
|
description: "Du arbeitest mit Systemen, die man versteht — und aktiv mitgestalten kann.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Rocket {...iconProps} />,
|
||||||
|
title: "Verantwortung ab Tag 1",
|
||||||
|
description: "Eigene Kundensysteme, eigene Entscheidungen. Keine Mikroverwaltung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Users {...iconProps} />,
|
||||||
|
title: "Echtes Team in Mainz",
|
||||||
|
description: "Kurze Wege, direkter Austausch — statt endloser Meeting-Ketten.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Home {...iconProps} />,
|
||||||
|
title: "Flexibel & remote-freundlich",
|
||||||
|
description: "Homeoffice möglich, feste Ansprechpartner vor Ort bei Bedarf.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
{
|
||||||
|
title: "Systemadministrator Linux (m/w/d)",
|
||||||
|
meta: "Vollzeit · Mainz",
|
||||||
|
description:
|
||||||
|
"Du betreust und härtest Kunden-Infrastrukturen auf Basis von Proxmox, Ceph und OPNsense — vom Monitoring bis zur Störungsbehebung im Ernstfall.",
|
||||||
|
tags: ["Linux", "Proxmox VE", "Ceph", "OPNsense"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "DevOps Engineer (m/w/d)",
|
||||||
|
meta: "Vollzeit · Mainz / Remote",
|
||||||
|
description:
|
||||||
|
"Du automatisierst Rollouts, baust CI/CD-Pipelines und Infrastructure-as-Code für unsere Kunden- und Eigenprojekte.",
|
||||||
|
tags: ["Terraform", "Ansible", "CI/CD", "Docker"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Initiativbewerbung",
|
||||||
|
meta: "Jederzeit",
|
||||||
|
description:
|
||||||
|
"Nichts Passendes dabei, aber Lust auf Open-Source-Infrastruktur im Rhein-Main-Gebiet? Wir lesen jede Nachricht.",
|
||||||
|
tags: ["Open-Source", "Infrastruktur"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function KarrierePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero label="Karriere" title="Wachse mit uns am Rhein.">
|
||||||
|
Wir suchen Menschen, die Infrastruktur nicht nur verwalten, sondern
|
||||||
|
verstehen wollen. Kein Konzern-Apparat, sondern ein Team, das für
|
||||||
|
seine Systeme geradesteht.
|
||||||
|
</PageHero>
|
||||||
|
|
||||||
|
<section className="pt-16 lg:pt-20">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<PhotoPanel
|
||||||
|
src="/images/team-working.jpg"
|
||||||
|
alt="Team von Rheinframe IT bei der gemeinsamen Arbeit"
|
||||||
|
tag="Team @ Rheinframe"
|
||||||
|
className="aspect-[21/9]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SectionLabel>Warum Rheinframe</SectionLabel>
|
||||||
|
<h2 className="mt-5 text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Ein kleines Team mit echter Verantwortung.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{benefits.map((benefit, i) => (
|
||||||
|
<BentoCard
|
||||||
|
key={benefit.title}
|
||||||
|
icon={benefit.icon}
|
||||||
|
title={benefit.title}
|
||||||
|
description={benefit.description}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-5xl px-6 lg:px-8">
|
||||||
|
<SectionLabel>Offene Stellen</SectionLabel>
|
||||||
|
<h2 className="mt-5 text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Aktuelle Positionen
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-xl text-base leading-relaxed text-ink-300">
|
||||||
|
Nichts dabei? Eine Initiativbewerbung lesen wir genauso aufmerksam
|
||||||
|
wie eine Antwort auf eine ausgeschriebene Stelle.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-4">
|
||||||
|
{jobs.map((job, i) => (
|
||||||
|
<JobCard key={job.title} {...job} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<InlineCta
|
||||||
|
title="Noch Fragen zum Bewerbungsprozess?"
|
||||||
|
subtitle="Schreiben Sie uns formlos — wir antworten persönlich, nicht mit einer Bewerbungssoftware."
|
||||||
|
buttonLabel="Kontakt aufnehmen"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Clock, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import PageHero from "@/components/ui/PageHero";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
import ContactForm from "@/components/contact/ContactForm";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Kontakt",
|
||||||
|
description:
|
||||||
|
"Erstgespräch mit Rheinframe IT vereinbaren — IT-Infrastruktur, E-Mail & Collaboration, Virtualisierung und Security aus Mainz.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KontaktPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero label="Kontakt" title="Reden wir über Ihre Infrastruktur.">
|
||||||
|
Schildern Sie uns Ihre Ausgangslage — wir melden uns innerhalb eines
|
||||||
|
Werktags mit einer ehrlichen Einschätzung, kein Verkaufsgespräch.
|
||||||
|
</PageHero>
|
||||||
|
|
||||||
|
<section className="py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-14 lg:grid-cols-[1.2fr_1fr] lg:gap-20">
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Nachricht senden</SectionLabel>
|
||||||
|
<h2 className="mt-5 text-balance text-2xl font-medium tracking-tight text-ink-50 sm:text-3xl">
|
||||||
|
Erstgespräch anfragen
|
||||||
|
</h2>
|
||||||
|
<div className="mt-8">
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="rounded-2xl border border-ink-700 bg-ink-900/60 p-8">
|
||||||
|
<h3 className="font-mono text-xs uppercase tracking-[0.2em] text-ink-500">
|
||||||
|
Direkter Kontakt
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-6 space-y-5">
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
<Mail className="size-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink-200">
|
||||||
|
<a href="mailto:kontakt@rheinframe.de" className="hover:text-river-300">
|
||||||
|
kontakt@rheinframe.de
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">Antwort innerhalb eines Werktags</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
<Phone className="size-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink-200">
|
||||||
|
<a href="tel:+4961310000000" className="hover:text-river-300">
|
||||||
|
06131 / 000 000
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">Mo–Fr, 9–17 Uhr</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
<MapPin className="size-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink-200">Rheinstraße 42, 55116 Mainz</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">Termine nach Vereinbarung</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
<Clock className="size-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink-200">Reaktionszeit im Ernstfall</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">Ø unter 15 Minuten für Bestandskunden</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import Navbar from "@/components/layout/Navbar";
|
||||||
|
import Footer from "@/components/layout/Footer";
|
||||||
|
import Analytics from "@/components/layout/Analytics";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(siteConfig.url),
|
||||||
|
title: {
|
||||||
|
default: `${siteConfig.name} — IT-Infrastruktur aus Mainz`,
|
||||||
|
template: `%s — ${siteConfig.name}`,
|
||||||
|
},
|
||||||
|
description: siteConfig.description,
|
||||||
|
alternates: { canonical: "/" },
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "de_DE",
|
||||||
|
siteName: siteConfig.name,
|
||||||
|
url: siteConfig.url,
|
||||||
|
title: `${siteConfig.name} — IT-Infrastruktur aus Mainz`,
|
||||||
|
description: siteConfig.description,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: `${siteConfig.name} — IT-Infrastruktur aus Mainz`,
|
||||||
|
description: siteConfig.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfessionalService",
|
||||||
|
name: siteConfig.legalName,
|
||||||
|
url: siteConfig.url,
|
||||||
|
description: siteConfig.description,
|
||||||
|
email: siteConfig.email,
|
||||||
|
telephone: siteConfig.phone,
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: siteConfig.address.street,
|
||||||
|
postalCode: siteConfig.address.postalCode,
|
||||||
|
addressLocality: siteConfig.address.city,
|
||||||
|
addressCountry: siteConfig.address.country,
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
latitude: siteConfig.geo.latitude,
|
||||||
|
longitude: siteConfig.geo.longitude,
|
||||||
|
},
|
||||||
|
areaServed: "Rhein-Main-Gebiet",
|
||||||
|
priceRange: "$$",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="de"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="flex min-h-full flex-col bg-ink-950 text-ink-50">
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
export const alt = `${siteConfig.name} — IT-Infrastruktur aus Mainz`;
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "88px",
|
||||||
|
backgroundColor: "#05070a",
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 78% 20%, rgba(46,196,179,0.28), transparent 55%)",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 18 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: "2px solid #262d38",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="34" height="34" viewBox="0 0 30 30" fill="none">
|
||||||
|
<path
|
||||||
|
d="M7 20.5L13.5 9.5L17 15.5L20 10.5L23 20.5"
|
||||||
|
stroke="#f2f4f6"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="13.5" cy="9.5" r="1.8" fill="#2ec4b3" />
|
||||||
|
<circle cx="20" cy="10.5" r="1.8" fill="#2ec4b3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 10, fontSize: 32, color: "#f2f4f6" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Rheinframe</span>
|
||||||
|
<span style={{ fontFamily: "monospace", color: "#2ec4b3", fontWeight: 400 }}>IT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
marginTop: 56,
|
||||||
|
fontSize: 60,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
maxWidth: 920,
|
||||||
|
color: "#f2f4f6",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Digitale Souveränität für den Mittelstand.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
marginTop: 32,
|
||||||
|
fontSize: 24,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#838d99",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mainz am Rhein · Open Source gedacht
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Hero from "@/components/home/Hero";
|
||||||
|
import About from "@/components/home/About";
|
||||||
|
import CompetenceGrid from "@/components/home/CompetenceGrid";
|
||||||
|
import ContactCta from "@/components/home/ContactCta";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<About />
|
||||||
|
<CompetenceGrid />
|
||||||
|
<ContactCta />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import PageHero from "@/components/ui/PageHero";
|
||||||
|
import InlineCta from "@/components/ui/InlineCta";
|
||||||
|
import PhotoPanel from "@/components/ui/PhotoPanel";
|
||||||
|
import CaseStudyCard from "@/components/referenzen/CaseStudyCard";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Referenzen",
|
||||||
|
description:
|
||||||
|
"Anonymisierte Praxisbeispiele: Wie Rheinframe IT Mittelstandsunternehmen im Rhein-Main-Gebiet auf unabhängige, hochverfügbare Infrastruktur umgestellt hat.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
category: "Fertigung · 80 Mitarbeitende",
|
||||||
|
title: "Von Einzelserver zu ausfallsicherem Proxmox-Cluster",
|
||||||
|
stat: { value: "−40 %", label: "Lizenzkosten p. a." },
|
||||||
|
situation:
|
||||||
|
"Die gesamte Produktions- und Bürokommunikation lief auf einem einzelnen, sieben Jahre alten Server. Ein Ausfall hätte den Betrieb tagelang lahmgelegt, gleichzeitig stiegen die Kosten für Microsoft-365-Lizenzen jedes Jahr weiter.",
|
||||||
|
solution:
|
||||||
|
"Aufbau eines dreiknotigen Proxmox-VE-Clusters mit Live-Migration, Umstieg der Mailinfrastruktur auf eine Grommunio-Hybrid-Lösung mit reduziertem M365-Footprint für mobile Mitarbeitende.",
|
||||||
|
result:
|
||||||
|
"Wartung und Hardware-Ausfälle laufen seither ohne spürbare Unterbrechung im Tagesgeschäft. Die Lizenzkosten sanken um rund 40 Prozent im ersten Jahr.",
|
||||||
|
badges: ["Proxmox VE", "Grommunio", "Microsoft 365 Hybrid", "Live-Migration"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Kanzlei · 25 Arbeitsplätze",
|
||||||
|
title: "Netzwerksegmentierung für mandantensichere Daten",
|
||||||
|
stat: { value: "0", label: "ungeplante Ausfälle seit Rollout" },
|
||||||
|
situation:
|
||||||
|
"Eine veraltete Consumer-Firewall ohne Segmentierung verband Gäste-WLAN, Arbeitsplätze und den Dokumentenserver im selben Netz — ein erhebliches Risiko für mandantenbezogene Daten.",
|
||||||
|
solution:
|
||||||
|
"Rollout einer OPNsense-Firewall mit VLAN-Segmentierung nach Zero-Trust-Prinzipien, WireGuard-VPN für Homeoffice-Zugriffe sowie ein replizierter Ceph-Speicher für den Dokumentenserver.",
|
||||||
|
result:
|
||||||
|
"Klare Trennung von Gast-, Arbeitsplatz- und Servernetz, verschlüsselter Fernzugriff für alle Mitarbeitenden und eine lückenlos dokumentierte, DSGVO-konforme Netzwerkstruktur.",
|
||||||
|
badges: ["OPNsense", "WireGuard", "VLAN", "Ceph"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Softwarehaus · 40 Mitarbeitende",
|
||||||
|
title: "Ablösung eines VMware-Hosts nach Lizenzänderung",
|
||||||
|
stat: { value: "100 %", label: "VMware-Lizenzkosten eliminiert" },
|
||||||
|
situation:
|
||||||
|
"Nach der Übernahme durch Broadcom stiegen die Lizenzkosten für den bestehenden VMware-ESXi-Host drastisch, während die einzelne Maschine zunehmend zum Kapazitäts-Engpass wurde.",
|
||||||
|
solution:
|
||||||
|
"Vollständige Migration aller virtuellen Maschinen auf einen neu aufgebauten Proxmox-VE-Cluster mit ZFS-Replikation zwischen zwei Standorten, begleitet durch einen getakteten Migrationsplan ohne Downtime für Kundensysteme.",
|
||||||
|
result:
|
||||||
|
"Keine VMware-Lizenzkosten mehr, mehr nutzbare Rechenkapazität auf gleicher Hardware und ein zweiter Standort als Notfall-Replikat.",
|
||||||
|
badges: ["Proxmox VE", "ZFS Replication", "VMware-Migration"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReferenzenPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero label="Referenzen" title="Anonymisierte Einblicke statt Logo-Wand.">
|
||||||
|
Aus Vertraulichkeitsgründen nennen wir keine Kundennamen — die
|
||||||
|
Ausgangslagen, Lösungen und Ergebnisse sind real und stellvertretend
|
||||||
|
für unsere Arbeit im Rhein-Main-Gebiet.
|
||||||
|
</PageHero>
|
||||||
|
|
||||||
|
<section className="pt-16 lg:pt-20">
|
||||||
|
<div className="mx-auto max-w-5xl px-6 lg:px-8">
|
||||||
|
<PhotoPanel
|
||||||
|
src="/images/server-rack.jpg"
|
||||||
|
alt="Servergestell mit strukturierter Netzwerkverkabelung"
|
||||||
|
tag="rheinframe-dc-01"
|
||||||
|
className="aspect-[21/9]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6 px-6 lg:px-8">
|
||||||
|
{cases.map((c, i) => (
|
||||||
|
<CaseStudyCard key={c.title} {...c} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<InlineCta
|
||||||
|
title="Ihre Ausgangslage klingt ähnlich?"
|
||||||
|
subtitle="Lassen Sie uns in einem kurzen Gespräch herausfinden, wo der größte Hebel liegt."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: { userAgent: "*", allow: "/" },
|
||||||
|
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Mail, Server, Database, ShieldCheck } from "lucide-react";
|
||||||
|
import PageHero from "@/components/ui/PageHero";
|
||||||
|
import InlineCta from "@/components/ui/InlineCta";
|
||||||
|
import ServiceOverview from "@/components/services/ServiceOverview";
|
||||||
|
import ServiceSection from "@/components/services/ServiceSection";
|
||||||
|
import SegmentPanel from "@/components/graphics/SegmentPanel";
|
||||||
|
import MailFlowGraphic from "@/components/graphics/MailFlowGraphic";
|
||||||
|
import VirtualizationGraphic from "@/components/graphics/VirtualizationGraphic";
|
||||||
|
import HaClusterGraphic from "@/components/graphics/HaClusterGraphic";
|
||||||
|
import SecurityGraphic from "@/components/graphics/SecurityGraphic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Dienstleistungen & Produkte",
|
||||||
|
description:
|
||||||
|
"E-Mail & Collaboration, Infrastruktur & Virtualisierung, High Availability & Storage sowie Netzwerk & IT-Security — Infrastruktur-Leistungen von Rheinframe IT.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero label="Dienstleistungen & Produkte" title="Infrastruktur, die zu Ihrem Betrieb passt.">
|
||||||
|
Vier Bausteine, ein Team: von der Mailbox über die Virtualisierung
|
||||||
|
bis zur Firewall. Jede Lösung wird auf Ihre Anforderungen zugeschnitten
|
||||||
|
— nicht aus dem Katalog verkauft.
|
||||||
|
</PageHero>
|
||||||
|
|
||||||
|
<ServiceOverview />
|
||||||
|
|
||||||
|
<ServiceSection
|
||||||
|
id="email-collaboration"
|
||||||
|
index="01"
|
||||||
|
icon={<Mail className="size-5" strokeWidth={1.5} />}
|
||||||
|
eyebrow="E-Mail & Collaboration"
|
||||||
|
title="Ihr Postfach, Ihre Daten, Ihre Regeln."
|
||||||
|
lead="Microsoft 365 ist bequem — aber nicht immer die richtige Antwort auf Datenschutz, Kosten oder Abhängigkeit. Wir bauen und betreiben Mailplattformen auf Grommunio-, Zimbra- oder Kopano-Basis mit voller Outlook- und Mobile-Kompatibilität, ohne dass Ihre Daten das eigene Rechenzentrum verlassen müssen. Wo ein kompletter Umstieg (noch) nicht sinnvoll ist, verbinden wir beide Welten in einer sauberen Hybrid-Umgebung."
|
||||||
|
features={[
|
||||||
|
"Aufbau und Betrieb von Grommunio-, Zimbra- oder Kopano-Umgebungen mit vollem Datenzugriff",
|
||||||
|
"Hybrid-Anbindung an Microsoft 365 für schrittweise oder dauerhafte Koexistenz",
|
||||||
|
"Verschlüsselte Postfach-Migration ohne Datenverlust und mit minimaler Ausfallzeit",
|
||||||
|
"Kalender-, Kontakt- und Datei-Synchronisation über ActiveSync, CalDAV und CardDAV",
|
||||||
|
"Anti-Spam- und Anti-Malware-Filterung direkt in der Zustellkette",
|
||||||
|
]}
|
||||||
|
badges={["Grommunio", "Zimbra", "Kopano", "Microsoft 365", "ActiveSync"]}
|
||||||
|
graphic={
|
||||||
|
<SegmentPanel tag="mail-flow.yml">
|
||||||
|
<MailFlowGraphic />
|
||||||
|
</SegmentPanel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServiceSection
|
||||||
|
id="infrastruktur"
|
||||||
|
index="02"
|
||||||
|
icon={<Server className="size-5" strokeWidth={1.5} />}
|
||||||
|
eyebrow="Infrastruktur & Virtualisierung"
|
||||||
|
title="Virtualisierung, die mit Ihnen mitwächst."
|
||||||
|
lead="Nicht jedes Unternehmen braucht ein Rechenzentrum — aber jedes Unternehmen braucht eine Plattform, die zur eigenen Größe passt. Auf Basis von Proxmox VE bauen wir alles zwischen einem gehärteten Einzelserver für den Mittelstand und einem mehrknotigen Enterprise-Cluster mit Live-Migration und automatisierter Ressourcensteuerung."
|
||||||
|
features={[
|
||||||
|
"Konzeption und Rollout von Single-Node- bis Multi-Cluster-Umgebungen",
|
||||||
|
"Migration von Bestandssystemen aus VMware oder Hyper-V nach Proxmox VE",
|
||||||
|
"Ressourcenplanung nach tatsächlicher Last statt Bauchgefühl",
|
||||||
|
"Automatisiertes Patch-, Update- und Snapshot-Management",
|
||||||
|
"Klare Trennung von Produktiv-, Test- und Backup-Umgebungen",
|
||||||
|
]}
|
||||||
|
badges={["Proxmox VE", "KVM", "LXC", "ZFS", "Terraform"]}
|
||||||
|
graphic={
|
||||||
|
<SegmentPanel tag="pve-cluster.conf">
|
||||||
|
<VirtualizationGraphic />
|
||||||
|
</SegmentPanel>
|
||||||
|
}
|
||||||
|
reverse
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServiceSection
|
||||||
|
id="ha-storage"
|
||||||
|
index="03"
|
||||||
|
icon={<Database className="size-5" strokeWidth={1.5} />}
|
||||||
|
eyebrow="High Availability & Storage"
|
||||||
|
title="Weiterlaufen, wenn Hardware ausfällt."
|
||||||
|
lead="Ein einzelner Server-Ausfall sollte keine Betriebsunterbrechung mehr auslösen. Mit Ceph-basierten Storage-Clustern und replizierten Speichersystemen verteilen wir Daten und Dienste über mehrere Knoten, sodass Wartung, Defekte oder ganze Node-Ausfälle im laufenden Betrieb abgefangen werden."
|
||||||
|
features={[
|
||||||
|
"Aufbau redundanter Ceph-Cluster über mehrere Knoten und Standorte",
|
||||||
|
"Automatisiertes Failover ohne manuellen Eingriff im Störfall",
|
||||||
|
"Replizierter Blockspeicher für unterbrechungsfreien VM-Betrieb",
|
||||||
|
"Backup- und Restore-Konzepte mit klar definierten RPO-/RTO-Zielen",
|
||||||
|
"Kapazitätsplanung, die mit Ihrem Wachstum mitskaliert",
|
||||||
|
]}
|
||||||
|
badges={["Ceph", "Proxmox HA", "ZFS Replication", "Proxmox Backup Server"]}
|
||||||
|
graphic={
|
||||||
|
<SegmentPanel tag="ceph-status.log">
|
||||||
|
<HaClusterGraphic />
|
||||||
|
</SegmentPanel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServiceSection
|
||||||
|
id="security"
|
||||||
|
index="04"
|
||||||
|
icon={<ShieldCheck className="size-5" strokeWidth={1.5} />}
|
||||||
|
eyebrow="Netzwerk & IT-Security"
|
||||||
|
title="Ein Perimeter, der wirklich hält."
|
||||||
|
lead="Die Firewall ist keine Checkbox, sondern die erste Verteidigungslinie. Wir konzipieren und betreiben Next-Generation-Firewalls auf Basis von OPNsense — mit sauberer Netzwerksegmentierung, verschlüsseltem Remote-Zugriff und Regeln, die zu Ihrem Netzwerk passen statt aus einer Vorlage zu stammen."
|
||||||
|
features={[
|
||||||
|
"Konzeption und Härtung von OPNsense-Firewalls als Netzwerk-Perimeter",
|
||||||
|
"Netzwerksegmentierung nach Zero-Trust-Prinzipien mit VLANs und DMZ",
|
||||||
|
"Site-to-Site- und Remote-Access-VPN für Standorte und Homeoffice",
|
||||||
|
"Intrusion Detection & Prevention sowie zentrales Log-Monitoring",
|
||||||
|
"Regelmäßige Sicherheitsaudits und strukturierte Patch-Zyklen",
|
||||||
|
]}
|
||||||
|
badges={["OPNsense", "WireGuard", "IPsec", "Suricata", "VLAN"]}
|
||||||
|
graphic={
|
||||||
|
<SegmentPanel tag="fw-edge.rules">
|
||||||
|
<SecurityGraphic />
|
||||||
|
</SegmentPanel>
|
||||||
|
}
|
||||||
|
reverse
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InlineCta
|
||||||
|
title="Unsicher, welcher Baustein der richtige Einstieg ist?"
|
||||||
|
subtitle="Wir schauen uns Ihre Ausgangslage gemeinsam an und priorisieren, was wirklich Wirkung zeigt."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const routes = [
|
||||||
|
{ path: "/", priority: 1, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/services", priority: 0.9, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/referenzen", priority: 0.7, changeFrequency: "monthly" as const },
|
||||||
|
{ path: "/karriere", priority: 0.6, changeFrequency: "weekly" as const },
|
||||||
|
{ path: "/kontakt", priority: 0.8, changeFrequency: "yearly" as const },
|
||||||
|
{ path: "/impressum", priority: 0.2, changeFrequency: "yearly" as const },
|
||||||
|
{ path: "/datenschutz", priority: 0.2, changeFrequency: "yearly" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
return routes.map((route) => ({
|
||||||
|
url: `${siteConfig.url}${route.path}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: route.changeFrequency,
|
||||||
|
priority: route.priority,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function Template({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { alt, size, contentType, default } from "./opengraph-image";
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { CheckCircle2, CircleAlert, Loader2 } from "lucide-react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
type Status = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
const fieldClasses =
|
||||||
|
"w-full rounded-lg border border-ink-600 bg-ink-850 px-4 py-3 text-sm text-ink-50 placeholder:text-ink-500 outline-none transition-colors duration-200 focus:border-river-400/60";
|
||||||
|
|
||||||
|
const labelClasses = "font-mono text-xs uppercase tracking-[0.15em] text-ink-400";
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
const payload = {
|
||||||
|
name: data.get("name")?.toString().trim(),
|
||||||
|
email: data.get("email")?.toString().trim(),
|
||||||
|
company: data.get("company")?.toString().trim(),
|
||||||
|
message: data.get("message")?.toString().trim(),
|
||||||
|
honeypot: data.get("website")?.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(result.error ?? "Etwas ist schiefgelaufen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("success");
|
||||||
|
form.reset();
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-3 rounded-2xl border border-river-400/30 bg-river-400/5 p-8">
|
||||||
|
<CheckCircle2 className="size-8 text-river-300" strokeWidth={1.5} />
|
||||||
|
<h3 className="text-lg font-medium text-ink-50">Nachricht gesendet.</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-ink-300">
|
||||||
|
Danke für Ihre Anfrage. Wir melden uns in der Regel innerhalb eines
|
||||||
|
Werktags bei Ihnen zurück.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||||
|
{/* Honeypot — hidden from real users, catches simple bots */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
className="absolute left-[-9999px] h-0 w-0 opacity-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className={labelClasses}>
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input id="name" name="name" type="text" required className={fieldClasses} placeholder="Ihr Name" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className={labelClasses}>
|
||||||
|
E-Mail *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className={fieldClasses}
|
||||||
|
placeholder="name@unternehmen.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="company" className={labelClasses}>
|
||||||
|
Unternehmen
|
||||||
|
</label>
|
||||||
|
<input id="company" name="company" type="text" className={fieldClasses} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="message" className={labelClasses}>
|
||||||
|
Nachricht *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
className={`${fieldClasses} resize-none`}
|
||||||
|
placeholder="Kurz Ihre Ausgangslage — wir melden uns mit einer ehrlichen Einschätzung."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-amber-400/25 bg-amber-400/5 px-4 py-3 text-sm text-amber-200">
|
||||||
|
<CircleAlert className="mt-0.5 size-4 shrink-0" strokeWidth={1.5} />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" disabled={status === "loading"} icon={status !== "loading"}>
|
||||||
|
{status === "loading" ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" strokeWidth={2} />
|
||||||
|
Wird gesendet …
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Nachricht senden"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default function DotGrid({ className = "" }: { className?: string }) {
|
||||||
|
const id = "dot-grid-pattern";
|
||||||
|
return (
|
||||||
|
<svg className={`absolute inset-0 h-full w-full ${className}`} aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id={id} width="22" height="22" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="1.5" cy="1.5" r="1.5" className="fill-ink-700" />
|
||||||
|
</pattern>
|
||||||
|
<radialGradient id="dot-grid-fade" cx="50%" cy="30%" r="75%">
|
||||||
|
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<mask id="dot-grid-mask">
|
||||||
|
<rect width="100%" height="100%" fill="url(#dot-grid-fade)" />
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill={`url(#${id})`} mask="url(#dot-grid-mask)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{ x: 150, y: 30, label: "node-a" },
|
||||||
|
{ x: 260, y: 150, label: "node-b" },
|
||||||
|
{ x: 40, y: 150, label: "node-c" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HaClusterGraphic() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 300 200" fill="none" className="h-full w-full max-w-sm" aria-hidden="true">
|
||||||
|
{/* mesh connections */}
|
||||||
|
<path d="M150,30 L260,150 L40,150 Z" className="stroke-ink-600" strokeWidth="1.25" fill="none" />
|
||||||
|
|
||||||
|
{/* orbiting replication ring */}
|
||||||
|
<motion.circle
|
||||||
|
cx="150"
|
||||||
|
cy="110"
|
||||||
|
r="46"
|
||||||
|
className="stroke-river-400/50"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
fill="none"
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 14, repeat: Infinity, ease: "linear" }}
|
||||||
|
style={{ transformOrigin: "150px 110px" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* central replicated storage icon */}
|
||||||
|
<g transform="translate(134,94)">
|
||||||
|
<ellipse cx="16" cy="4" rx="16" ry="5" className="fill-ink-900 stroke-river-300" strokeWidth="1.1" />
|
||||||
|
<path d="M0,4 L0,20 A16,5 0 0 0 32,20 L32,4" className="stroke-river-300" strokeWidth="1.1" fill="none" />
|
||||||
|
<path d="M0,12 A16,5 0 0 0 32,12" className="stroke-river-300" strokeWidth="1.1" fill="none" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<g key={n.label}>
|
||||||
|
<rect x={n.x - 16} y={n.y - 12} width="32" height="24" rx="4" className="fill-ink-850 stroke-ink-500" strokeWidth="1.25" />
|
||||||
|
<circle cx={n.x - 8} cy={n.y} r="1.4" className="fill-river-400" />
|
||||||
|
<line x1={n.x - 2} y1={n.y - 4} x2={n.x + 10} y2={n.y - 4} className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<line x1={n.x - 2} y1={n.y + 4} x2={n.x + 10} y2={n.y + 4} className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<text x={n.x} y={n.y + 26} textAnchor="middle" className="fill-ink-400 font-mono" fontSize="8">
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<text x="150" y="196" textAnchor="middle" className="fill-ink-500 font-mono" fontSize="8">
|
||||||
|
Ceph-Replikation über 3 Nodes — kein Single Point of Failure
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import DotGrid from "./DotGrid";
|
||||||
|
import NetworkTopology from "./NetworkTopology";
|
||||||
|
|
||||||
|
export default function HeroVisual() {
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-[6/5] w-full overflow-hidden rounded-3xl border border-ink-700 bg-ink-900/50">
|
||||||
|
<DotGrid />
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-1/2 h-64 w-64 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-40 blur-3xl"
|
||||||
|
style={{ background: "radial-gradient(circle, var(--color-river-500), transparent 70%)" }}
|
||||||
|
/>
|
||||||
|
<NetworkTopology className="relative h-full w-full p-6" />
|
||||||
|
|
||||||
|
<div className="absolute left-6 top-6 rounded-md border border-ink-700 bg-ink-950/80 px-2.5 py-1 font-mono text-[11px] text-ink-300 backdrop-blur">
|
||||||
|
status: <span className="text-river-300">operational</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-6 right-6 rounded-md border border-ink-700 bg-ink-950/80 px-2.5 py-1 font-mono text-[11px] text-ink-300 backdrop-blur">
|
||||||
|
uptime 99.98 %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function MailFlowGraphic() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 300 200" fill="none" className="h-full w-full max-w-sm" aria-hidden="true">
|
||||||
|
{/* legacy / M365 node */}
|
||||||
|
<rect x="14" y="76" width="60" height="48" rx="4" className="fill-ink-850 stroke-ink-500" strokeWidth="1.25" />
|
||||||
|
<line x1="22" y1="90" x2="66" y2="90" className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<line x1="22" y1="98" x2="66" y2="98" className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<line x1="22" y1="106" x2="66" y2="106" className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<text x="44" y="140" textAnchor="middle" className="fill-ink-400 font-mono" fontSize="9">
|
||||||
|
Microsoft 365
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* self-hosted node */}
|
||||||
|
<rect x="226" y="76" width="60" height="48" rx="4" className="fill-ink-850 stroke-river-400/70" strokeWidth="1.25" />
|
||||||
|
<line x1="234" y1="90" x2="278" y2="90" className="stroke-river-300" strokeWidth="0.75" />
|
||||||
|
<line x1="234" y1="98" x2="278" y2="98" className="stroke-river-300" strokeWidth="0.75" />
|
||||||
|
<line x1="234" y1="106" x2="278" y2="106" className="stroke-river-300" strokeWidth="0.75" />
|
||||||
|
<text x="256" y="140" textAnchor="middle" className="fill-river-300 font-mono" fontSize="9">
|
||||||
|
Grommunio
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* connecting path */}
|
||||||
|
<path d="M74,100 L226,100" stroke="currentColor" className="text-ink-600" strokeWidth="1.25" strokeDasharray="2 5" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{/* envelope */}
|
||||||
|
<g transform="translate(130,78)">
|
||||||
|
<rect x="0" y="0" width="40" height="28" rx="3" className="fill-ink-900 stroke-ink-100" strokeWidth="1.25" />
|
||||||
|
<path d="M2,3 L20,17 L38,3" className="stroke-ink-100" strokeWidth="1.25" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<motion.circle
|
||||||
|
cy="100"
|
||||||
|
r="3"
|
||||||
|
className="fill-river-400"
|
||||||
|
animate={{ cx: [78, 222] }}
|
||||||
|
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text x="150" y="170" textAnchor="middle" className="fill-ink-500 font-mono" fontSize="8">
|
||||||
|
verschlüsselte Migration & Hybrid-Sync
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export default function MainzSkyline({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 520 220" fill="none" className={className} aria-hidden="true">
|
||||||
|
{/* network overlay across the city */}
|
||||||
|
<g className="text-ink-600">
|
||||||
|
<path d="M60,60 L180,40 L300,52 L420,34" stroke="currentColor" strokeWidth="1" strokeDasharray="2 5" fill="none" />
|
||||||
|
<circle cx="60" cy="60" r="2" className="fill-river-400" />
|
||||||
|
<circle cx="180" cy="40" r="2" className="fill-river-400" />
|
||||||
|
<circle cx="300" cy="52" r="2" className="fill-river-400" />
|
||||||
|
<circle cx="420" cy="34" r="2" className="fill-river-400" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* skyline */}
|
||||||
|
<g className="text-ink-700" fill="none" stroke="currentColor" strokeWidth="1.25">
|
||||||
|
<rect x="20" y="120" width="34" height="60" />
|
||||||
|
<rect x="64" y="100" width="26" height="80" />
|
||||||
|
<rect x="100" y="130" width="40" height="50" />
|
||||||
|
<rect x="360" y="110" width="30" height="70" />
|
||||||
|
<rect x="398" y="135" width="46" height="45" />
|
||||||
|
<rect x="452" y="115" width="28" height="65" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Mainzer Dom — stylised twin-tower silhouette */}
|
||||||
|
<g className="text-ink-100" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<rect x="222" y="86" width="18" height="94" />
|
||||||
|
<path d="M222,86 L231,66 L240,86" />
|
||||||
|
<rect x="252" y="70" width="22" height="110" />
|
||||||
|
<path d="M252,70 L263,44 L274,70" />
|
||||||
|
<rect x="284" y="94" width="16" height="86" />
|
||||||
|
<path d="M284,94 L292,76 L300,94" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Rhein */}
|
||||||
|
<path
|
||||||
|
d="M0,190 C90,178 140,202 220,192 C310,182 360,204 520,188 L520,220 L0,220 Z"
|
||||||
|
className="fill-ink-900"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0,190 C90,178 140,202 220,192 C310,182 360,204 520,188"
|
||||||
|
className="stroke-river-400/60"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
"M240,200 L240,120 L140,120 L140,70",
|
||||||
|
"M240,200 L240,120 L340,120 L340,70",
|
||||||
|
"M240,200 L150,200 L150,260 L70,260",
|
||||||
|
"M240,200 L330,200 L330,260 L410,260",
|
||||||
|
"M240,200 L240,290 L240,330",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{ x: 140, y: 70, label: "mail-01" },
|
||||||
|
{ x: 340, y: 70, label: "vm-host" },
|
||||||
|
{ x: 70, y: 260, label: "ceph-a" },
|
||||||
|
{ x: 410, y: 260, label: "ceph-b" },
|
||||||
|
{ x: 240, y: 330, label: "fw-edge" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NetworkTopology({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 480 400"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{paths.map((d) => (
|
||||||
|
<path
|
||||||
|
key={d}
|
||||||
|
d={d}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
className="text-ink-600"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{paths.map((d, i) => (
|
||||||
|
<motion.path
|
||||||
|
key={`flow-${d}`}
|
||||||
|
d={d}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="text-river-400"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="6 220"
|
||||||
|
initial={{ strokeDashoffset: 0, opacity: 0 }}
|
||||||
|
animate={{ strokeDashoffset: -240, opacity: [0, 1, 1, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: 3.2,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.7,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* core node */}
|
||||||
|
<g>
|
||||||
|
<circle cx="240" cy="200" r="26" className="fill-ink-900 stroke-river-400/60" strokeWidth="1.25" />
|
||||||
|
<rect x="228" y="190" width="24" height="20" rx="2" className="fill-none stroke-river-300" strokeWidth="1.25" />
|
||||||
|
<line x1="231" y1="195" x2="245" y2="195" className="stroke-river-300" strokeWidth="1" />
|
||||||
|
<line x1="231" y1="200" x2="245" y2="200" className="stroke-river-300" strokeWidth="1" />
|
||||||
|
<line x1="231" y1="205" x2="245" y2="205" className="stroke-river-300" strokeWidth="1" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<g key={n.label}>
|
||||||
|
<rect
|
||||||
|
x={n.x - 14}
|
||||||
|
y={n.y - 10}
|
||||||
|
width="28"
|
||||||
|
height="20"
|
||||||
|
rx="3"
|
||||||
|
className="fill-ink-850 stroke-ink-500"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
/>
|
||||||
|
<line x1={n.x - 9} y1={n.y - 4} x2={n.x + 9} y2={n.y - 4} className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<line x1={n.x - 9} y1={n.y} x2={n.x + 9} y2={n.y} className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<line x1={n.x - 9} y1={n.y + 4} x2={n.x + 9} y2={n.y + 4} className="stroke-ink-500" strokeWidth="0.75" />
|
||||||
|
<circle cx={n.x + 7} cy={n.y - 4} r="1.1" className="fill-river-400" />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function SecurityGraphic() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 300 200" fill="none" className="h-full w-full max-w-sm" aria-hidden="true">
|
||||||
|
{/* incoming traffic lines */}
|
||||||
|
{[70, 90, 110].map((y, i) => (
|
||||||
|
<path
|
||||||
|
key={y}
|
||||||
|
d={`M10,${y} L118,100`}
|
||||||
|
className={i === 1 ? "stroke-ink-500" : "stroke-ink-700"}
|
||||||
|
strokeWidth="1.1"
|
||||||
|
strokeDasharray={i === 1 ? "0" : "2 4"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* outgoing clean traffic */}
|
||||||
|
{[85, 100, 115].map((y) => (
|
||||||
|
<path key={y} d={`M182,100 L290,${y}`} className="stroke-river-400/70" strokeWidth="1.1" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* shield */}
|
||||||
|
<path
|
||||||
|
d="M150,26 L196,42 V96 C196,132 176,154 150,166 C124,154 104,132 104,96 V42 Z"
|
||||||
|
className="fill-ink-900 stroke-ink-100"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M150,26 L196,42 V96 C196,132 176,154 150,166 C124,154 104,132 104,96 V42 Z"
|
||||||
|
className="stroke-river-400"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray="6 360"
|
||||||
|
animate={{ strokeDashoffset: [0, -366] }}
|
||||||
|
transition={{ duration: 3.5, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d="M132,96 L145,110 L172,80"
|
||||||
|
className="stroke-river-300"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text x="150" y="188" textAnchor="middle" className="fill-ink-500 font-mono" fontSize="8">
|
||||||
|
OPNsense Edge-Firewall — geprüfter Traffic only
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import DotGrid from "./DotGrid";
|
||||||
|
|
||||||
|
export default function SegmentPanel({
|
||||||
|
children,
|
||||||
|
tag,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tag?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl border border-ink-700 bg-ink-900/50">
|
||||||
|
<DotGrid />
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-1/2 h-56 w-56 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-30 blur-3xl"
|
||||||
|
style={{ background: "radial-gradient(circle, var(--color-river-500), transparent 70%)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center p-10">{children}</div>
|
||||||
|
{tag && (
|
||||||
|
<div className="absolute left-5 top-5 rounded-md border border-ink-700 bg-ink-950/80 px-2.5 py-1 font-mono text-[11px] text-ink-300 backdrop-blur">
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const vms = [
|
||||||
|
{ label: "vm-erp", x: 60 },
|
||||||
|
{ label: "vm-crm", x: 150 },
|
||||||
|
{ label: "vm-dms", x: 240 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function VirtualizationGraphic() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 300 200" fill="none" className="h-full w-full max-w-sm" aria-hidden="true">
|
||||||
|
{vms.map((vm, i) => (
|
||||||
|
<motion.g
|
||||||
|
key={vm.label}
|
||||||
|
initial={{ y: 6, opacity: 0.6 }}
|
||||||
|
animate={{ y: [6, 0, 6] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={vm.x - 36}
|
||||||
|
y={40}
|
||||||
|
width="72"
|
||||||
|
height="40"
|
||||||
|
rx="4"
|
||||||
|
className={i === 1 ? "fill-ink-850 stroke-river-400/70" : "fill-ink-850 stroke-ink-500"}
|
||||||
|
strokeWidth="1.25"
|
||||||
|
/>
|
||||||
|
<line x1={vm.x - 28} y1="52" x2={vm.x + 28} y2="52" className={i === 1 ? "stroke-river-300" : "stroke-ink-500"} strokeWidth="0.75" />
|
||||||
|
<line x1={vm.x - 28} y1="60" x2={vm.x + 28} y2="60" className={i === 1 ? "stroke-river-300" : "stroke-ink-500"} strokeWidth="0.75" />
|
||||||
|
<line x1={vm.x - 28} y1="68" x2={vm.x + 28} y2="68" className={i === 1 ? "stroke-river-300" : "stroke-ink-500"} strokeWidth="0.75" />
|
||||||
|
<text x={vm.x} y="98" textAnchor="middle" className="fill-ink-400 font-mono" fontSize="8">
|
||||||
|
{vm.label}
|
||||||
|
</text>
|
||||||
|
<line x1={vm.x} y1="104" x2={vm.x} y2="120" className="stroke-ink-600" strokeWidth="1" />
|
||||||
|
</motion.g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* hypervisor host base */}
|
||||||
|
<rect x="20" y="120" width="260" height="34" rx="4" className="fill-ink-900 stroke-ink-100/80" strokeWidth="1.25" />
|
||||||
|
<text x="150" y="141" textAnchor="middle" className="fill-ink-100 font-mono" fontSize="9">
|
||||||
|
Proxmox VE Host
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<line x1="20" y1="164" x2="280" y2="164" className="stroke-ink-700" strokeWidth="1" />
|
||||||
|
<text x="150" y="180" textAnchor="middle" className="fill-ink-500 font-mono" fontSize="8">
|
||||||
|
ein Server, saubere Trennung, volle Kontrolle
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
import MainzSkyline from "@/components/graphics/MainzSkyline";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: "10+", label: "Jahre Systemadministration" },
|
||||||
|
{ value: "150+", label: "betreute Server & VMs" },
|
||||||
|
{ value: "100 %", label: "Open-Source-Kern" },
|
||||||
|
{ value: "< 15 Min", label: "Ø Reaktionszeit im Ernstfall" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function About() {
|
||||||
|
return (
|
||||||
|
<section className="border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-14 lg:grid-cols-2 lg:gap-20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<SectionLabel>Über uns</SectionLabel>
|
||||||
|
<h2 className="mt-5 text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Verlässlich, unabhängig und mit kurzen Wegen im Rhein-Main-Gebiet.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-5 text-base leading-relaxed text-ink-300">
|
||||||
|
Rheinframe IT ist ein Infrastruktur-Team mit Sitz in Mainz. Wir
|
||||||
|
kommen aus dem Linux- und Open-Source-Umfeld und bauen seit über
|
||||||
|
zehn Jahren Systeme, die auch dann laufen, wenn Hardware
|
||||||
|
ausfällt, ein Anbieter Preise erhöht oder eine
|
||||||
|
Lizenzbedingung sich über Nacht ändert.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-ink-300">
|
||||||
|
Statt Standardpaketen liefern wir Architektur, die zu Ihrem
|
||||||
|
Betrieb passt — dokumentiert, wartbar und ohne versteckte
|
||||||
|
Abhängigkeiten. Bei Bedarf sind wir vor Ort in Mainz, Wiesbaden
|
||||||
|
oder Frankfurt, nicht nur im Ticket-System.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-10 grid grid-cols-2 gap-8 border-t border-ink-800 pt-8">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div key={stat.label}>
|
||||||
|
<dt className="font-mono text-2xl font-medium text-river-300">{stat.value}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-ink-400">{stat.label}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.97 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-ink-700 bg-ink-900/50"
|
||||||
|
>
|
||||||
|
<MainzSkyline className="h-full w-full" />
|
||||||
|
<div className="absolute left-5 top-5 rounded-md border border-ink-700 bg-ink-950/80 px-2.5 py-1 font-mono text-[11px] text-ink-300 backdrop-blur">
|
||||||
|
49.9929° N, 8.2473° O — Mainz
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Activity, Compass, Database, Mail, Server, ShieldCheck } from "lucide-react";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
import BentoCard from "@/components/ui/BentoCard";
|
||||||
|
|
||||||
|
const iconProps = { className: "size-5", strokeWidth: 1.5 } as const;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: <Mail {...iconProps} />,
|
||||||
|
title: "E-Mail & Collaboration",
|
||||||
|
description:
|
||||||
|
"Grommunio, Zimbra und Kopano oder saubere M365-Hybrid-Anbindung — Ihre Kommunikation, Ihre Regeln.",
|
||||||
|
href: "/services#email-collaboration",
|
||||||
|
className: "lg:col-span-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server {...iconProps} />,
|
||||||
|
title: "Infrastruktur & Virtualisierung",
|
||||||
|
description: "Vom Single-Server bis zum ausfallsicheren Proxmox-Cluster.",
|
||||||
|
href: "/services#infrastruktur",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Database {...iconProps} />,
|
||||||
|
title: "High Availability & Storage",
|
||||||
|
description: "Ceph-Cluster und replizierter Speicher gegen Downtime durch Hardware-Ausfälle.",
|
||||||
|
href: "/services#ha-storage",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ShieldCheck {...iconProps} />,
|
||||||
|
title: "Netzwerk & IT-Security",
|
||||||
|
description: "Next-Generation-Firewalls auf OPNsense-Basis, sauber segmentiert.",
|
||||||
|
href: "/services#security",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Activity {...iconProps} />,
|
||||||
|
title: "Monitoring & Support",
|
||||||
|
description: "Proaktive Überwachung Ihrer Systeme mit klaren SLAs statt Rätselraten.",
|
||||||
|
href: "/services",
|
||||||
|
className: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Compass {...iconProps} />,
|
||||||
|
title: "Beratung & Migration",
|
||||||
|
description: "Von der Bestandsaufnahme bis zum Rollout — planbar und dokumentiert.",
|
||||||
|
href: "/services",
|
||||||
|
className: "lg:col-span-2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CompetenceGrid() {
|
||||||
|
return (
|
||||||
|
<section className="border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SectionLabel>Kernkompetenzen</SectionLabel>
|
||||||
|
<h2 className="mt-5 text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Ein Team für die gesamte Infrastruktur-Kette.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-ink-300">
|
||||||
|
Kein Flickenteppich aus Dienstleistern. Wir planen und betreiben
|
||||||
|
jede Ebene — von der Mailbox bis zur Firewall.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<BentoCard
|
||||||
|
key={item.title}
|
||||||
|
icon={item.icon}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
href={item.href}
|
||||||
|
className={item.className}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
|
||||||
|
export default function ContactCta() {
|
||||||
|
return (
|
||||||
|
<section id="kontakt" className="scroll-mt-24 border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-ink-700 bg-ink-900/60 px-8 py-14 text-center sm:px-14 lg:py-20"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full opacity-20 blur-3xl"
|
||||||
|
style={{ background: "radial-gradient(circle, var(--color-river-500), transparent 70%)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full opacity-20 blur-3xl"
|
||||||
|
style={{ background: "radial-gradient(circle, var(--color-river-500), transparent 70%)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
<SectionLabel>Kontakt</SectionLabel>
|
||||||
|
<h2 className="mt-5 max-w-2xl text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
Bereit für Infrastruktur, die Ihnen gehört?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-lg text-balance text-base leading-relaxed text-ink-300">
|
||||||
|
Schildern Sie uns Ihre Ausgangslage — wir melden uns innerhalb
|
||||||
|
eines Werktags mit einer ehrlichen Einschätzung, kein
|
||||||
|
Verkaufsgespräch.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button href="/kontakt" size="lg">
|
||||||
|
Erstgespräch vereinbaren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import SectionLabel from "@/components/ui/SectionLabel";
|
||||||
|
import HeroVisual from "@/components/graphics/HeroVisual";
|
||||||
|
|
||||||
|
const trustPoints = ["Kein Vendor-Lock-in", "DSGVO-konform gehostet", "Ansässig in Mainz"];
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-grid">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-px"
|
||||||
|
style={{ background: "linear-gradient(to right, transparent, var(--color-river-500), transparent)" }}
|
||||||
|
/>
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-1 items-center gap-16 px-6 py-20 lg:grid-cols-2 lg:gap-12 lg:px-8 lg:py-28">
|
||||||
|
<div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<SectionLabel>Rheinframe IT · Mainz am Rhein</SectionLabel>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.7, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="mt-6 text-balance text-4xl font-medium tracking-tight text-ink-50 sm:text-5xl lg:text-[3.4rem] lg:leading-[1.08]"
|
||||||
|
>
|
||||||
|
Digitale Souveränität für den Mittelstand.
|
||||||
|
<span className="text-river-300"> Open Source gedacht.</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.7, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="mt-6 max-w-xl text-balance text-lg leading-relaxed text-ink-300"
|
||||||
|
>
|
||||||
|
Wir planen, betreiben und härten IT-Infrastrukturen für Unternehmen
|
||||||
|
im Rhein-Main-Gebiet — von Mainz aus, mit kurzen Wegen und langer
|
||||||
|
Verantwortung. Kein Lizenz-Dschungel, keine Blackbox: Systeme, die
|
||||||
|
Ihnen gehören.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.7, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="mt-9 flex flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<Button href="/kontakt" size="lg">
|
||||||
|
Erstgespräch vereinbaren
|
||||||
|
</Button>
|
||||||
|
<Button href="/services" variant="secondary" size="lg">
|
||||||
|
Dienstleistungen entdecken
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.7, delay: 0.45 }}
|
||||||
|
className="mt-10 flex flex-wrap gap-x-6 gap-y-2 border-t border-ink-800 pt-6"
|
||||||
|
>
|
||||||
|
{trustPoints.map((point) => (
|
||||||
|
<span key={point} className="font-mono text-xs text-ink-400">
|
||||||
|
<span className="text-river-400">›</span> {point}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<HeroVisual />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import TechBadge from "@/components/ui/TechBadge";
|
||||||
|
|
||||||
|
interface JobCardProps {
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobCard({ title, meta, description, tags, index = 0 }: JobCardProps) {
|
||||||
|
const subject = encodeURIComponent(`Bewerbung: ${title}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.06, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="group rounded-2xl border border-ink-700 bg-ink-900/60 p-7 transition-colors duration-300 hover:border-river-400/40 sm:p-8"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.15em] text-river-300">{meta}</span>
|
||||||
|
<h3 className="mt-2 text-xl font-medium tracking-tight text-ink-50">{title}</h3>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-ink-300">{description}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TechBadge key={tag}>{tag}</TechBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`mailto:karriere@rheinframe.de?subject=${subject}`}
|
||||||
|
className="inline-flex shrink-0 items-center gap-2 self-start rounded-full border border-ink-600 px-5 py-2.5 text-sm font-medium text-ink-50 transition-colors duration-200 hover:border-river-400/60 hover:text-river-200 sm:self-center"
|
||||||
|
>
|
||||||
|
Jetzt bewerben
|
||||||
|
<ArrowUpRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a privacy-friendly, cookieless analytics script (Plausible) only if
|
||||||
|
* a domain is configured. Unconfigured by default — no script, no request,
|
||||||
|
* no cookie banner required. Self-hosted Plausible/Umami instances work too,
|
||||||
|
* just point NEXT_PUBLIC_PLAUSIBLE_SRC at your own deployment.
|
||||||
|
*/
|
||||||
|
export default function Analytics() {
|
||||||
|
const domain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
|
||||||
|
if (!domain) return null;
|
||||||
|
|
||||||
|
const src = process.env.NEXT_PUBLIC_PLAUSIBLE_SRC ?? "https://plausible.io/js/script.js";
|
||||||
|
|
||||||
|
return <Script defer data-domain={domain} src={src} strategy="afterInteractive" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import Logo from "@/components/ui/Logo";
|
||||||
|
|
||||||
|
const serviceLinks = [
|
||||||
|
{ href: "/services#email-collaboration", label: "E-Mail & Collaboration" },
|
||||||
|
{ href: "/services#infrastruktur", label: "Infrastruktur & Virtualisierung" },
|
||||||
|
{ href: "/services#ha-storage", label: "High Availability & Storage" },
|
||||||
|
{ href: "/services#security", label: "Netzwerk & IT-Security" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const companyLinks = [
|
||||||
|
{ href: "/", label: "Start" },
|
||||||
|
{ href: "/services", label: "Dienstleistungen" },
|
||||||
|
{ href: "/referenzen", label: "Referenzen" },
|
||||||
|
{ href: "/karriere", label: "Karriere" },
|
||||||
|
{ href: "/kontakt", label: "Kontakt" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-ink-800 bg-ink-950">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-12 md:grid-cols-[1.3fr_1fr_1fr_1.1fr]">
|
||||||
|
<div>
|
||||||
|
<Logo />
|
||||||
|
<p className="mt-4 max-w-xs text-sm leading-relaxed text-ink-300">
|
||||||
|
Unabhängige IT-Infrastruktur aus Mainz. Wir planen, betreiben und
|
||||||
|
härten Systeme, die Ihrem Unternehmen gehören — nicht einem
|
||||||
|
Lizenzmodell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-mono text-xs uppercase tracking-[0.2em] text-ink-500">
|
||||||
|
Dienstleistungen
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{serviceLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-ink-300 transition-colors hover:text-river-300"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-mono text-xs uppercase tracking-[0.2em] text-ink-500">
|
||||||
|
Unternehmen
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
{companyLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-ink-300 transition-colors hover:text-river-300"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-mono text-xs uppercase tracking-[0.2em] text-ink-500">
|
||||||
|
Kontakt
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm text-ink-300">
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<MapPin className="mt-0.5 size-4 shrink-0 text-river-400" strokeWidth={1.5} />
|
||||||
|
<span>Rheinstraße 42, 55116 Mainz</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<Mail className="size-4 shrink-0 text-river-400" strokeWidth={1.5} />
|
||||||
|
<a href="mailto:kontakt@rheinframe.de" className="hover:text-river-300">
|
||||||
|
kontakt@rheinframe.de
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<Phone className="size-4 shrink-0 text-river-400" strokeWidth={1.5} />
|
||||||
|
<a href="tel:+4961310000000" className="hover:text-river-300">
|
||||||
|
06131 / 000 000
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-14 flex flex-col gap-4 border-t border-ink-800 pt-8 text-xs text-ink-500 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p>© {new Date().getFullYear()} Rheinframe IT GmbH. Alle Rechte vorbehalten.</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<Link href="/impressum" className="hover:text-river-300">
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
<Link href="/datenschutz" className="hover:text-river-300">
|
||||||
|
Datenschutz
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import Logo from "@/components/ui/Logo";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/", label: "Start" },
|
||||||
|
{ href: "/services", label: "Dienstleistungen" },
|
||||||
|
{ href: "/referenzen", label: "Referenzen" },
|
||||||
|
{ href: "/karriere", label: "Karriere" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-ink-800 bg-ink-950/80 backdrop-blur-md">
|
||||||
|
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:px-8">
|
||||||
|
<Logo />
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-8 md:flex">
|
||||||
|
{links.map((link) => {
|
||||||
|
const active = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`relative text-sm font-medium transition-colors duration-200 ${
|
||||||
|
active ? "text-ink-50" : "text-ink-300 hover:text-ink-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="nav-active"
|
||||||
|
className="absolute -bottom-[21px] left-0 right-0 h-px bg-river-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Button href="/kontakt" size="md">
|
||||||
|
Erstgespräch vereinbaren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border border-ink-700 p-2 text-ink-100 md:hidden"
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
>
|
||||||
|
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="overflow-hidden border-b border-ink-800 md:hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 px-6 py-4">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg px-3 py-2.5 text-sm font-medium text-ink-100 hover:bg-ink-900"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="mt-2 px-3">
|
||||||
|
<Button href="/kontakt" size="md" className="w-full justify-center">
|
||||||
|
Erstgespräch vereinbaren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import TechBadge from "@/components/ui/TechBadge";
|
||||||
|
import { onSpotlightMove, spotlightBackground } from "@/lib/spotlight";
|
||||||
|
|
||||||
|
interface CaseStudyCardProps {
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
stat: { value: string; label: string };
|
||||||
|
situation: string;
|
||||||
|
solution: string;
|
||||||
|
result: string;
|
||||||
|
badges: string[];
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CaseStudyCard({
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
stat,
|
||||||
|
situation,
|
||||||
|
solution,
|
||||||
|
result,
|
||||||
|
badges,
|
||||||
|
index = 0,
|
||||||
|
}: CaseStudyCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.08, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onMouseMove={onSpotlightMove}
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-ink-700 bg-ink-900/60 p-8 transition-colors duration-300 hover:border-river-400/40 sm:p-10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
style={{ background: spotlightBackground }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-river-300">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-2 text-xl font-medium tracking-tight text-ink-50 sm:text-2xl">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-xl border border-river-400/30 bg-river-400/5 px-5 py-3 text-center">
|
||||||
|
<div className="font-mono text-2xl font-medium text-river-300">{stat.value}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-ink-400">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-1 gap-6 border-t border-ink-800 pt-8 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-xs uppercase tracking-[0.15em] text-ink-500">
|
||||||
|
Ausgangslage
|
||||||
|
</h4>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink-300">{situation}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-xs uppercase tracking-[0.15em] text-ink-500">Lösung</h4>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink-300">{solution}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-mono text-xs uppercase tracking-[0.15em] text-ink-500">Ergebnis</h4>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink-300">{result}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<TechBadge key={b}>{b}</TechBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Mail, Server, Database, ShieldCheck } from "lucide-react";
|
||||||
|
import ServiceCard from "@/components/ui/ServiceCard";
|
||||||
|
|
||||||
|
const iconProps = { className: "size-5", strokeWidth: 1.5 } as const;
|
||||||
|
|
||||||
|
const overview = [
|
||||||
|
{
|
||||||
|
icon: <Mail {...iconProps} />,
|
||||||
|
index: "01",
|
||||||
|
title: "E-Mail & Collaboration",
|
||||||
|
description: "Open-Source-Mailplattformen und Microsoft-365-Hybrid-Anbindung.",
|
||||||
|
href: "#email-collaboration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server {...iconProps} />,
|
||||||
|
index: "02",
|
||||||
|
title: "Infrastruktur & Virtualisierung",
|
||||||
|
description: "Proxmox VE — vom Einzelserver bis zum Enterprise-Hypervisor.",
|
||||||
|
href: "#infrastruktur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Database {...iconProps} />,
|
||||||
|
index: "03",
|
||||||
|
title: "High Availability & Storage",
|
||||||
|
description: "Ceph-Cluster und replizierter Speicher ohne Single Point of Failure.",
|
||||||
|
href: "#ha-storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ShieldCheck {...iconProps} />,
|
||||||
|
index: "04",
|
||||||
|
title: "Netzwerk & IT-Security",
|
||||||
|
description: "Next-Generation-Firewalls auf Basis von OPNsense.",
|
||||||
|
href: "#security",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ServiceOverview() {
|
||||||
|
return (
|
||||||
|
<section className="py-16 lg:py-20">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{overview.map((item) => (
|
||||||
|
<ServiceCard
|
||||||
|
key={item.title}
|
||||||
|
icon={item.icon}
|
||||||
|
index={item.index}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
href={item.href}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import FeatureListItem from "@/components/ui/FeatureListItem";
|
||||||
|
import TechBadge from "@/components/ui/TechBadge";
|
||||||
|
|
||||||
|
interface ServiceSectionProps {
|
||||||
|
id: string;
|
||||||
|
index: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
features: string[];
|
||||||
|
badges: string[];
|
||||||
|
graphic: ReactNode;
|
||||||
|
reverse?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceSection({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
icon,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
lead,
|
||||||
|
features,
|
||||||
|
badges,
|
||||||
|
graphic,
|
||||||
|
reverse = false,
|
||||||
|
}: ServiceSectionProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-24 border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 items-center gap-14 lg:grid-cols-2 lg:gap-20 ${
|
||||||
|
reverse ? "lg:[&>*:first-child]:order-2" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-xs text-ink-500">{index}</span>
|
||||||
|
<span className="h-px w-8 bg-river-400/70" />
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.2em] text-river-300">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 inline-flex size-11 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-5 text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-xl text-base leading-relaxed text-ink-300">{lead}</p>
|
||||||
|
|
||||||
|
<ul className="mt-8 space-y-3.5">
|
||||||
|
{features.map((f) => (
|
||||||
|
<FeatureListItem key={f}>{f}</FeatureListItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-2">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<TechBadge key={b}>{b}</TechBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.97 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true, margin: "-80px" }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
{graphic}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { onSpotlightMove, spotlightBackground } from "@/lib/spotlight";
|
||||||
|
|
||||||
|
interface BentoCardProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href?: string;
|
||||||
|
className?: string;
|
||||||
|
index?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BentoCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
href,
|
||||||
|
className = "",
|
||||||
|
index = 0,
|
||||||
|
children,
|
||||||
|
}: BentoCardProps) {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
style={{ background: spotlightBackground }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div className="inline-flex size-10 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{href && (
|
||||||
|
<ArrowUpRight className="size-4 text-ink-500 transition-all duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-river-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium tracking-tight text-ink-50">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
{children && <div className="relative mt-6">{children}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.06, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onMouseMove={onSpotlightMove}
|
||||||
|
className={`group relative overflow-hidden rounded-2xl border border-ink-700 bg-ink-900/60 transition-colors duration-300 hover:border-river-400/40 ${className}`}
|
||||||
|
>
|
||||||
|
{href ? (
|
||||||
|
<Link href={href} className="flex h-full flex-col justify-between p-7">
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col justify-between p-7">{body}</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "ghost";
|
||||||
|
type ButtonSize = "md" | "lg";
|
||||||
|
|
||||||
|
interface ButtonBaseProps {
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
icon?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonAsLink extends ButtonBaseProps {
|
||||||
|
href: string;
|
||||||
|
onClick?: never;
|
||||||
|
type?: never;
|
||||||
|
disabled?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonAsButton extends ButtonBaseProps {
|
||||||
|
href?: undefined;
|
||||||
|
onClick?: () => void;
|
||||||
|
type?: "button" | "submit";
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = ButtonAsLink | ButtonAsButton;
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
md: "px-5 py-2.5 text-sm",
|
||||||
|
lg: "px-7 py-3.5 text-base",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-river-400 text-ink-950 hover:bg-river-300 shadow-[0_0_0_1px_rgba(46,196,179,0.35)]",
|
||||||
|
secondary:
|
||||||
|
"bg-transparent text-ink-50 border border-ink-600 hover:border-river-400/60 hover:text-river-200",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent text-ink-200 hover:text-river-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
icon = true,
|
||||||
|
className = "",
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
}: ButtonProps) {
|
||||||
|
const classes = [
|
||||||
|
"group inline-flex items-center gap-2 rounded-full font-medium tracking-tight transition-colors duration-200 whitespace-nowrap",
|
||||||
|
sizeClasses[size],
|
||||||
|
variantClasses[variant],
|
||||||
|
disabled ? "pointer-events-none opacity-50" : "",
|
||||||
|
className,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span>{children}</span>
|
||||||
|
{icon && (
|
||||||
|
<ArrowRight
|
||||||
|
className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const motionProps = {
|
||||||
|
whileTap: { scale: 0.97 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<motion.div {...motionProps} className="inline-block">
|
||||||
|
<Link href={href} className={classes}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
{...motionProps}
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
export default function FeatureListItem({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-md border border-river-400/30 bg-river-400/10 text-river-300">
|
||||||
|
<Check className="size-3" strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm leading-relaxed text-ink-200">{children}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
export default function InlineCta({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
buttonLabel = "Erstgespräch vereinbaren",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
buttonLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="border-t border-ink-800 py-20 lg:py-28">
|
||||||
|
<div className="mx-auto max-w-3xl px-6 text-center lg:px-8">
|
||||||
|
<h2 className="text-balance text-3xl font-medium tracking-tight text-ink-50 sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-xl text-balance text-base leading-relaxed text-ink-300">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<Button href="/kontakt" size="lg">
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Logo({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Link href="/" className={`group inline-flex items-center gap-2.5 ${className}`}>
|
||||||
|
<svg
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 30 30"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="0.5" y="0.5" width="29" height="29" rx="7.5" stroke="currentColor" className="text-ink-600" />
|
||||||
|
<path
|
||||||
|
d="M7 20.5L13.5 9.5L17 15.5L20 10.5L23 20.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-ink-100"
|
||||||
|
/>
|
||||||
|
<circle cx="13.5" cy="9.5" r="1.6" fill="currentColor" className="text-river-400" />
|
||||||
|
<circle cx="20" cy="10.5" r="1.6" fill="currentColor" className="text-river-400" />
|
||||||
|
</svg>
|
||||||
|
<span className="flex items-baseline gap-1.5 font-medium tracking-tight text-ink-50">
|
||||||
|
<span>Rheinframe</span>
|
||||||
|
<span className="hidden font-mono text-xs font-normal text-river-300 sm:inline">IT</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import SectionLabel from "./SectionLabel";
|
||||||
|
|
||||||
|
export default function PageHero({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden border-b border-ink-800 bg-grid">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-px"
|
||||||
|
style={{ background: "linear-gradient(to right, transparent, var(--color-river-500), transparent)" }}
|
||||||
|
/>
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-24 text-center lg:px-8 lg:py-32">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<SectionLabel>{label}</SectionLabel>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-balance text-4xl font-medium tracking-tight text-ink-50 sm:text-5xl lg:text-6xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{children && (
|
||||||
|
<div className="mt-6 text-balance text-lg leading-relaxed text-ink-300">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function PhotoPanel({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
tag,
|
||||||
|
className = "",
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
tag?: string;
|
||||||
|
className?: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden rounded-3xl border border-ink-700 ${className}`}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
priority={priority}
|
||||||
|
sizes="(min-width: 1024px) 1152px, 100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-ink-950/35" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ background: "linear-gradient(to top, rgba(5,7,10,0.85), rgba(5,7,10,0.05) 45%)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 mix-blend-color"
|
||||||
|
style={{ backgroundColor: "var(--color-river-600)", opacity: 0.18 }}
|
||||||
|
/>
|
||||||
|
{tag && (
|
||||||
|
<div className="absolute left-5 top-5 rounded-md border border-ink-700 bg-ink-950/80 px-2.5 py-1 font-mono text-[11px] text-ink-300 backdrop-blur">
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface SectionLabelProps {
|
||||||
|
children: string;
|
||||||
|
light?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionLabel({ children, light = false }: SectionLabelProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="h-px w-8 bg-river-400/70" />
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs uppercase tracking-[0.2em] ${
|
||||||
|
light ? "text-ink-600" : "text-river-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { onSpotlightMove, spotlightBackground } from "@/lib/spotlight";
|
||||||
|
|
||||||
|
interface ServiceCardProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
index: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceCard({ icon, index, title, description, href }: ServiceCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onMouseMove={onSpotlightMove}
|
||||||
|
className="group relative flex h-full flex-col justify-between overflow-hidden rounded-2xl border border-ink-700 bg-ink-900/60 p-7 transition-all duration-300 hover:border-river-400/40 hover:bg-ink-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
style={{ background: spotlightBackground }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div className="inline-flex size-10 items-center justify-center rounded-lg border border-ink-600 bg-ink-850 text-river-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-ink-500">{index}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium tracking-tight text-ink-50">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-ink-300">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-8 flex items-center gap-1.5 text-sm font-medium text-river-300">
|
||||||
|
<span>Mehr erfahren</span>
|
||||||
|
<ArrowUpRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function TechBadge({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-ink-600 bg-ink-850 px-2.5 py-1 font-mono text-xs text-ink-200">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const siteConfig = {
|
||||||
|
name: "Rheinframe IT",
|
||||||
|
legalName: "Rheinframe IT GmbH",
|
||||||
|
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.rheinframe.de",
|
||||||
|
description:
|
||||||
|
"Rheinframe IT plant und betreibt unabhängige IT-Infrastrukturen für den Mittelstand im Rhein-Main-Gebiet: E-Mail & Collaboration, Virtualisierung, High Availability und IT-Security auf Open-Source-Basis.",
|
||||||
|
email: "kontakt@rheinframe.de",
|
||||||
|
phone: "+4961310000000",
|
||||||
|
phoneDisplay: "06131 / 000 000",
|
||||||
|
address: {
|
||||||
|
street: "Rheinstraße 42",
|
||||||
|
postalCode: "55116",
|
||||||
|
city: "Mainz",
|
||||||
|
country: "DE",
|
||||||
|
},
|
||||||
|
geo: { latitude: 49.9929, longitude: 8.2473 },
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { MouseEvent } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks pointer position on a card and writes it to CSS custom properties
|
||||||
|
* (`--x` / `--y`) via direct DOM mutation — avoids a React re-render on
|
||||||
|
* every mousemove, which a state-driven approach would otherwise trigger.
|
||||||
|
*/
|
||||||
|
export function onSpotlightMove(event: MouseEvent<HTMLElement>) {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
target.style.setProperty("--x", `${event.clientX - rect.left}px`);
|
||||||
|
target.style.setProperty("--y", `${event.clientY - rect.top}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spotlightBackground =
|
||||||
|
"radial-gradient(320px circle at var(--x, 50%) var(--y, 0%), rgba(46,196,179,0.1), transparent 70%)";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user