Initial commit: RheinFrame Web codebase

This commit is contained in:
2026-07-07 11:59:15 +02:00
commit 8790c52484
63 changed files with 9961 additions and 0 deletions
+18
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -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.
+18
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6892
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+7
View File
@@ -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

+78
View File
@@ -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 }
);
}
}
+123
View File
@@ -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 &amp; 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

+87
View File
@@ -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;
}
+6
View File
@@ -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

+136
View File
@@ -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>
);
}
+135
View File
@@ -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"
/>
</>
);
}
+92
View File
@@ -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">MoFr, 917 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>
</>
);
}
+88
View File
@@ -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>
);
}
+86
View File
@@ -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 }
);
}
+15
View File
@@ -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 />
</>
);
}
+87
View File
@@ -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."
/>
</>
);
}
+9
View File
@@ -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`,
};
}
+126
View File
@@ -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."
/>
</>
);
}
+21
View File
@@ -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,
}));
}
+16
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export { alt, size, contentType, default } from "./opengraph-image";
+142
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+22
View File
@@ -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 &amp; Hybrid-Sync
</text>
</svg>
);
}
+46
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+16
View File
@@ -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" />;
}
+109
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+7
View File
@@ -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>
);
}
+17
View File
@@ -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;
+16
View File
@@ -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%)";
+34
View File
@@ -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"]
}