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