feat: initial Funktechnik Schubert website

This commit is contained in:
Schubert Ferenc 2026-07-03 19:14:34 +02:00
commit 9a648e2ec8
35 changed files with 7521 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
.next
.env
.env.local
npm-debug.log*
.git
README.md

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
NEXT_PUBLIC_SITE_URL=http://localhost:3010
# Optional spaetere Integration. Nicht im Browser verwenden.
OLYMPUS_INTAKE_API_URL=
OLYMPUS_INTAKE_API_TOKEN=

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.next
node_modules
out
dist
.env
.env.local
npm-debug.log*

29
Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3010
ENV PORT=3010
CMD ["node", "server.js"]

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# Funktechnik Schubert Website
Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Projekt ist nicht Olympus CRM und koppelt nicht direkt an Olympus.
## Technologie
- Next.js 16 App Router
- TypeScript strict
- Serverseitige API-Routen für Kontakt und Reparaturannahme
- Docker
- Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt
## Seiten
- `/` Startseite
- `/leistungen`
- `/funkgeraete-service`
- `/reparatur`
- `/ueber-uns`
- `/kontakt`
- `/impressum`
- `/datenschutz`
## Lokale Entwicklung
```bash
npm install
npm run dev
```
Die Website läuft lokal unter:
```text
http://localhost:3010
```
## Checks
```bash
npm run lint
npx next build
```
## Docker
```bash
docker compose build
docker compose up -d
```
Service:
```text
funktechnik-website
```
Port:
```text
3010:3010
```
## Umgebung
`.env.example` kopieren:
```bash
cp .env.example .env
```
Variablen:
- `NEXT_PUBLIC_SITE_URL`
- `OLYMPUS_INTAKE_API_URL`
- `OLYMPUS_INTAKE_API_TOKEN`
Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet.
## API-Routen
- `POST /api/repair`
- `POST /api/contact`
Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt.
## Nginx
Siehe `nginx.example.conf`.
Beispiel-Domain:
```text
funktechnik-schubert.de
```
Produktiv HTTPS aktivieren, z. B. mit Certbot:
```bash
certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de
```
## Offene manuelle Punkte
- TODO: Rechtliche Angaben ergänzen
- Produktives Kontakt-/Mail-System anbinden
- Spätere Olympus-Reparaturannahme serverseitig anbinden
- Finale Domain und HTTPS-Konfiguration setzen

25
app/api/contact/route.ts Normal file
View file

@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
function text(value: FormDataEntryValue | null) {
return typeof value === "string" ? value.trim() : "";
}
export async function POST(request: Request) {
const form = await request.formData();
const name = text(form.get("name"));
const email = text(form.get("email"));
const message = text(form.get("message"));
if (!name || !email.includes("@") || message.length < 10 || form.get("privacy") !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
console.info("contact_request.received", {
messageLength: message.length,
hasEmail: true,
});
return NextResponse.json({
message: "Ihre Nachricht wurde erfasst. Sie erhalten eine Rückmeldung.",
});
}

34
app/api/repair/route.ts Normal file
View file

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
function required(value: FormDataEntryValue | null) {
return typeof value === "string" && value.trim().length > 0;
}
export async function POST(request: Request) {
const form = await request.formData();
const name = form.get("name");
const email = form.get("email");
const manufacturer = form.get("manufacturer");
const model = form.get("model");
const description = form.get("description");
const privacy = form.get("privacy");
if (!required(name) || !required(email) || !required(manufacturer) || !required(model) || !required(description) || privacy !== "on") {
return NextResponse.json({ message: "Bitte füllen Sie alle Pflichtfelder aus." }, { status: 400 });
}
const intakeUrl = process.env.OLYMPUS_INTAKE_API_URL;
const hasIntegrationToken = Boolean(process.env.OLYMPUS_INTAKE_API_TOKEN);
console.info("repair_intake.received", {
hasIntakeIntegration: Boolean(intakeUrl && hasIntegrationToken),
manufacturer: String(manufacturer),
model: String(model),
hasPhone: required(form.get("phone")),
hasSerialNumber: required(form.get("serialNumber")),
});
return NextResponse.json({
message: "Ihre Reparaturanfrage wurde erfasst. Sie erhalten nach Prüfung eine Rückmeldung.",
});
}

26
app/datenschutz/page.tsx Normal file
View file

@ -0,0 +1,26 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Datenschutz", "Datenschutzhinweise von Funktechnik Schubert.", "/datenschutz");
export default function DatenschutzPage() {
return (
<>
<PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero>
<section className="section">
<div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<h2>Verantwortlicher</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Kontakt- und Reparaturanfragen</h2>
<p>Die Formulare erfassen Angaben, die zur Bearbeitung der jeweiligen Anfrage erforderlich sind. Eine produktive Datenschutzerklärung muss vor Veröffentlichung rechtlich geprüft und ergänzt werden.</p>
<h2>Hosting und Server-Logs</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Betroffenenrechte</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,35 @@
import type { Metadata } from "next";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service");
export default function RadioServicePage() {
return (
<>
<PageHero eyebrow="Funkgeräte-Service" title="Prüfung, Diagnose und Abgleich">
Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung und Bedienung. Der Service betrachtet diese Zusammenhänge strukturiert.
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Typische Fehlerbilder</h2>
<ul className="list">
<li>Kein oder schwacher Empfang</li>
<li>Keine, leise oder verzerrte Modulation</li>
<li>Frequenzversatz, PLL-Rasten oder instabile Kanäle</li>
<li>Aussetzer durch Stromversorgung, Buchsen oder Bedienelemente</li>
<li>Unklare Vorarbeiten oder verbastelte Geräte</li>
</ul>
</div>
<div className="panel">
<h2>Serviceablauf</h2>
<p className="lead">Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte. Danach folgt eine technische Einschätzung und die weitere Abstimmung.</p>
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
</div>
</div>
</section>
</>
);
}

404
app/globals.css Normal file
View file

@ -0,0 +1,404 @@
:root {
--bg: #f6f8fb;
--surface: #ffffff;
--ink: #101827;
--muted: #5b677a;
--line: #d9e1ec;
--navy: #0b1728;
--navy-2: #13243a;
--cyan: #16b6d9;
--orange: #f28c28;
--radius: 8px;
--shadow: 0 20px 50px rgba(10, 23, 40, 0.12);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Arial, Helvetica, sans-serif;
letter-spacing: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
.container {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
}
.site-header {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(11, 23, 40, 0.96);
color: #fff;
backdrop-filter: blur(14px);
}
.header-inner {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
font-weight: 800;
}
.brand-mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid rgba(22, 182, 217, 0.45);
border-radius: 8px;
background: linear-gradient(135deg, rgba(22, 182, 217, 0.22), rgba(242, 140, 40, 0.18));
}
.brand-mark::before {
width: 16px;
height: 16px;
border: 3px solid var(--cyan);
border-left-color: transparent;
border-radius: 999px;
content: "";
}
.nav {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.nav a {
border-radius: 8px;
padding: 9px 11px;
color: rgba(255, 255, 255, 0.82);
font-size: 14px;
}
.nav a:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.hero {
position: relative;
overflow: hidden;
min-height: calc(100vh - 72px);
display: grid;
align-items: center;
color: #fff;
background:
linear-gradient(90deg, rgba(8, 18, 32, 0.94), rgba(8, 18, 32, 0.72), rgba(8, 18, 32, 0.58)),
url("/workbench-signal.svg"),
linear-gradient(135deg, #0b1728, #17324e);
background-position: center;
background-size: cover;
}
.hero-content {
max-width: 760px;
padding: 76px 0 118px;
}
.eyebrow {
color: var(--cyan);
font-size: 13px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 18px;
font-size: clamp(44px, 7vw, 76px);
line-height: 0.96;
}
h2 {
margin-bottom: 14px;
color: var(--ink);
font-size: clamp(30px, 4vw, 44px);
line-height: 1.05;
}
h3 {
margin-bottom: 8px;
font-size: 20px;
}
.lead {
max-width: 720px;
color: var(--muted);
font-size: 19px;
line-height: 1.65;
}
.hero .lead {
color: rgba(255, 255, 255, 0.82);
font-size: 21px;
}
.hero-actions,
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.button {
display: inline-flex;
min-height: 44px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
padding: 0 18px;
background: var(--cyan);
color: #06111d;
font-weight: 800;
}
.button.secondary {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.button.light {
border-color: var(--line);
background: #fff;
color: var(--ink);
}
.section {
padding: 82px 0;
}
.section.dark {
background: var(--navy);
color: #fff;
}
.section.dark h2,
.section.dark h3 {
color: #fff;
}
.section.dark .lead,
.section.dark p {
color: rgba(255, 255, 255, 0.72);
}
.section-head {
max-width: 760px;
margin-bottom: 34px;
}
.grid {
display: grid;
gap: 18px;
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: 24px;
box-shadow: 0 8px 26px rgba(12, 28, 48, 0.05);
}
.dark .card {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.card p,
.list li {
color: var(--muted);
line-height: 1.6;
}
.dark .card p,
.dark .list li {
color: rgba(255, 255, 255, 0.72);
}
.list {
display: grid;
gap: 10px;
padding-left: 19px;
}
.split {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 34px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
padding: 28px;
box-shadow: var(--shadow);
}
.form {
display: grid;
gap: 16px;
}
.field {
display: grid;
gap: 7px;
}
.field label {
color: var(--ink);
font-size: 14px;
font-weight: 800;
}
.field input,
.field select,
.field textarea {
width: 100%;
border: 1px solid #cfd8e5;
border-radius: 8px;
background: #fff;
padding: 11px 12px;
color: var(--ink);
}
.field textarea {
min-height: 130px;
resize: vertical;
}
.checkbox {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.error {
color: #b42318;
font-size: 13px;
}
.success {
border: 1px solid #a6d8bd;
border-radius: 8px;
background: #edf9f1;
padding: 12px;
color: #15562d;
}
.notice {
border-left: 4px solid var(--orange);
background: #fff8ef;
padding: 14px 16px;
color: #5a3713;
}
.footer {
background: #08111f;
color: rgba(255, 255, 255, 0.76);
padding: 44px 0;
}
.footer-grid {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr;
gap: 28px;
}
.footer a {
color: #fff;
}
.legal {
max-width: 860px;
}
.legal h2 {
margin-top: 28px;
font-size: 26px;
}
@media (max-width: 860px) {
.header-inner,
.nav {
justify-content: flex-start;
}
.header-inner {
flex-direction: column;
align-items: flex-start;
padding: 14px 0;
}
.hero {
min-height: auto;
}
.hero-content {
padding: 64px 0 92px;
}
.grid.three,
.grid.two,
.split,
.footer-grid {
grid-template-columns: 1fr;
}
}

24
app/impressum/page.tsx Normal file
View file

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Impressum", "Impressum von Funktechnik Schubert.", "/impressum");
export default function ImpressumPage() {
return (
<>
<PageHero eyebrow="Rechtliches" title="Impressum">Rechtliche Angaben für die öffentliche Firmenwebsite.</PageHero>
<section className="section">
<div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p>
<h2>Anbieterkennzeichnung</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Kontakt</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
<h2>Umsatzsteuer / Registerangaben</h2>
<p>TODO: Rechtliche Angaben ergänzen</p>
</div>
</section>
</>
);
}

27
app/kontakt/page.tsx Normal file
View file

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt");
export default function ContactPage() {
return (
<>
<PageHero eyebrow="Kontakt" title="Kontakt aufnehmen">
Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Direkt und technisch</h2>
<p className="lead">Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.</p>
</div>
<div className="panel">
<ContactForm />
</div>
</div>
</section>
</>
);
}

22
app/layout.tsx Normal file
View file

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import "./globals.css";
import { createMetadata } from "./seo";
export const metadata: Metadata = createMetadata(
"Funktechnik Schubert",
"Service, Reparatur und technische Unterstützung für Funkgeräte und Kommunikationstechnik.",
);
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}

33
app/leistungen/page.tsx Normal file
View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen");
const services = [
["Funkgeräte-Service", "Prüfung, Wartung und technische Beurteilung von Funkgeräten mit Fokus auf nachvollziehbare Fehlerbilder."],
["CB-Funk", "Service für klassische und moderne CB-Funkgeräte, inklusive Empfangs-, Sende- und Modulationsproblemen."],
["Amateurfunk", "Unterstützung bei ausgewählter Amateurfunktechnik, Abgleichfragen und gerätespezifischen Fehlern."],
["Betriebsfunk", "Technische Einschätzung und Prüfung von betriebsnaher Kommunikationstechnik im Rahmen der Werkstattmöglichkeiten."],
["Fehlersuche", "Strukturierte Diagnose bei Aussetzern, Störungen, instabiler Versorgung oder auffälligem Verhalten."],
["Modulationsprobleme", "Analyse von leiser, verzerrter oder fehlender Modulation sowie Prüfung relevanter Baugruppen."],
["PLL-/Frequenzprobleme", "Prüfung von Frequenzstabilität, Rastung, Synthesizer- und Referenzproblemen."],
["Abgleich und Prüfung", "Sorgfältiger Abgleich nach technischen Unterlagen und dokumentierte Funktionsprüfung."],
["Dokumentation", "Einbindung von Serviceunterlagen, Schaltplänen und Reparaturhinweisen in den Prüfprozess."],
] as const;
export default function ServicesPage() {
return (
<>
<PageHero eyebrow="Leistungen" title="Service für Funkgeräte und Kommunikationstechnik">
Technische Unterstützung für Geräte, bei denen Diagnose, Erfahrung und saubere Dokumentation zählen.
</PageHero>
<section className="section">
<div className="container grid three">
{services.map(([title, text]) => <ServiceCard key={title} title={title}>{text}</ServiceCard>)}
</div>
</section>
</>
);
}

57
app/page.tsx Normal file
View file

@ -0,0 +1,57 @@
import Link from "next/link";
import ServiceCard from "@/components/ServiceCard";
export default function HomePage() {
return (
<>
<section className="hero">
<div className="container hero-content">
<p className="eyebrow">Funktechnik, Elektronikservice, Werkstatt</p>
<h1>Funktechnik Schubert</h1>
<p className="lead">Service, Reparatur und technische Unterstützung für Funkgeräte und Kommunikationstechnik.</p>
<div className="hero-actions">
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<Link className="button secondary" href="/leistungen">Leistungen ansehen</Link>
</div>
</div>
</section>
<section className="section">
<div className="container">
<div className="section-head">
<p className="eyebrow">Leistungsbereiche</p>
<h2>Technischer Service mit sauberer Diagnose</h2>
<p className="lead">Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet Werkstatterfahrung mit strukturierter Kommunikation.</p>
</div>
<div className="grid three">
<ServiceCard title="Funkgeräte-Service">Prüfung, Abgleich und Service für CB-Funk, Amateurfunk und ausgewählte Kommunikationstechnik.</ServiceCard>
<ServiceCard title="Reparatur & Diagnose">Systematische Fehlersuche bei Empfang, Modulation, PLL, Frequenzstabilität und Stromversorgung.</ServiceCard>
<ServiceCard title="Serviceunterlagen">Sorgfältiger Umgang mit Schaltplänen, Service Manuals und gerätespezifischen Dokumentationen.</ServiceCard>
</div>
</div>
</section>
<section className="section dark">
<div className="container split">
<div>
<p className="eyebrow">Persönlicher Support</p>
<h2>Direkte technische Einschätzung statt anonymer Abwicklung</h2>
<p className="lead">Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung eingrenzen. Die Reparaturannahme fragt deshalb die wichtigsten technischen Details strukturiert ab.</p>
<div className="actions">
<Link className="button" href="/reparatur">Reparaturformular öffnen</Link>
</div>
</div>
<div className="card">
<h3>Warum Funktechnik Schubert?</h3>
<ul className="list">
<li>Technischer Fokus auf Funkgeräte und Kommunikationstechnik</li>
<li>Nachvollziehbare Diagnose statt pauschaler Austauschlogik</li>
<li>Klare Rückmeldung zu Aufwand, Zustand und nächsten Schritten</li>
<li>Vorbereitung für strukturierte Service- und Dokumentationsprozesse</li>
</ul>
</div>
</div>
</section>
</>
);
}

33
app/reparatur/page.tsx Normal file
View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import RepairForm from "@/components/RepairForm";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur");
export default function RepairPage() {
return (
<>
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen">
Beschreiben Sie Gerät, Fehlerbild und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Übergabe vorbereitet.
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Vor der Einsendung</h2>
<p className="lead">Bitte senden Sie Geräte erst nach Rückmeldung ein. Eine klare Fehlerbeschreibung hilft, den Aufwand besser einzuschätzen.</p>
<ul className="list">
<li>Hersteller, Modell und Seriennummer bereithalten</li>
<li>Fehler möglichst reproduzierbar beschreiben</li>
<li>Vorarbeiten und geöffnete Geräte ehrlich angeben</li>
<li>Zubehör nur nach Absprache mitsenden</li>
</ul>
</div>
<div className="panel">
<RepairForm />
</div>
</div>
</section>
</>
);
}

12
app/robots.ts Normal file
View file

@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "./seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: new URL("/sitemap.xml", siteUrl).toString(),
};
}

37
app/seo.ts Normal file
View file

@ -0,0 +1,37 @@
import type { Metadata } from "next";
const siteName = "Funktechnik Schubert";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3010";
const keywords = [
"Funktechnik Schubert",
"Funkgeräte Reparatur",
"CB Funk Service",
"Amateurfunk Reparatur",
"Funkgeräte Abgleich",
"Funktechnik Service",
"Kommunikationstechnik",
];
export function createMetadata(title: string, description: string, path = "/"): Metadata {
const url = new URL(path, siteUrl).toString();
const fullTitle = title === siteName ? title : `${title} | ${siteName}`;
return {
title: fullTitle,
description,
keywords,
alternates: {
canonical: url,
},
openGraph: {
type: "website",
locale: "de_DE",
url,
siteName,
title: fullTitle,
description,
},
};
}
export { siteName, siteUrl };

22
app/sitemap.ts Normal file
View file

@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "./seo";
const routes = [
"",
"/leistungen",
"/funkgeraete-service",
"/reparatur",
"/ueber-uns",
"/kontakt",
"/impressum",
"/datenschutz",
];
export default function sitemap(): MetadataRoute.Sitemap {
return routes.map((route) => ({
url: new URL(route, siteUrl).toString(),
lastModified: new Date(),
changeFrequency: route === "" ? "weekly" : "monthly",
priority: route === "" ? 1 : 0.7,
}));
}

27
app/ueber-uns/page.tsx Normal file
View file

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
import { createMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns");
export default function AboutPage() {
return (
<>
<PageHero eyebrow="Über uns" title="Technik verstehen, Fehler nachvollziehbar lösen">
Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.
</PageHero>
<section className="section">
<div className="container grid two">
<article className="card">
<h2>Arbeitsweise</h2>
<p>Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.</p>
</article>
<article className="card">
<h2>Fokus</h2>
<p>Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.</p>
</article>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { useState, type FormEvent } from "react";
export default function ContactForm() {
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setMessage("");
const form = new FormData(event.currentTarget);
const name = String(form.get("name") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const text = String(form.get("message") ?? "").trim();
if (!name || !email.includes("@") || text.length < 10 || form.get("privacy") !== "on") {
setError("Bitte füllen Sie alle Pflichtfelder aus und bestätigen Sie die Datenschutz-Hinweise.");
return;
}
setPending(true);
try {
const response = await fetch("/api/contact", { method: "POST", body: form });
const result = await response.json() as { message?: string };
if (!response.ok) throw new Error(result.message ?? "Nachricht konnte nicht gesendet werden.");
event.currentTarget.reset();
setMessage(result.message ?? "Ihre Nachricht wurde vorbereitet.");
} catch (err) {
setError(err instanceof Error ? err.message : "Nachricht konnte nicht gesendet werden.");
} finally {
setPending(false);
}
}
return (
<form className="form" onSubmit={submit} noValidate>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" name="name" />
</div>
<div className="field">
<label htmlFor="email">E-Mail</label>
<input id="email" name="email" type="email" />
</div>
<div className="field">
<label htmlFor="message">Nachricht</label>
<textarea id="message" name="message" />
</div>
<label className="checkbox">
<input type="checkbox" name="privacy" />
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Kontaktanfrage verarbeitet werden.</span>
</label>
{error && <p className="error">{error}</p>}
{message && <p className="success">{message}</p>}
<button className="button" type="submit" disabled={pending}>{pending ? "Wird gesendet..." : "Nachricht senden"}</button>
</form>
);
}

26
components/Footer.tsx Normal file
View file

@ -0,0 +1,26 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="footer">
<div className="container footer-grid">
<div>
<h3>Funktechnik Schubert</h3>
<p>Service, Reparatur und technische Unterstützung für Funkgeräte und Kommunikationstechnik.</p>
</div>
<div>
<h3>Service</h3>
<p><Link href="/leistungen">Leistungen</Link></p>
<p><Link href="/funkgeraete-service">Funkgeräte-Service</Link></p>
<p><Link href="/reparatur">Reparaturannahme</Link></p>
</div>
<div>
<h3>Rechtliches</h3>
<p><Link href="/impressum">Impressum</Link></p>
<p><Link href="/datenschutz">Datenschutz</Link></p>
<p><Link href="/kontakt">Kontakt</Link></p>
</div>
</div>
</footer>
);
}

27
components/Header.tsx Normal file
View file

@ -0,0 +1,27 @@
import Link from "next/link";
const navigation = [
["Leistungen", "/leistungen"],
["Funkgeräte-Service", "/funkgeraete-service"],
["Reparatur", "/reparatur"],
["Über uns", "/ueber-uns"],
["Kontakt", "/kontakt"],
] as const;
export default function Header() {
return (
<header className="site-header">
<div className="container header-inner">
<Link href="/" className="brand" aria-label="Funktechnik Schubert Startseite">
<span className="brand-mark" aria-hidden="true" />
<span>Funktechnik Schubert</span>
</Link>
<nav className="nav" aria-label="Hauptnavigation">
{navigation.map(([label, href]) => (
<Link key={href} href={href}>{label}</Link>
))}
</nav>
</div>
</header>
);
}

19
components/PageHero.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
type Props = {
eyebrow?: string;
title: string;
children: ReactNode;
};
export default function PageHero({ eyebrow, title, children }: Props) {
return (
<section className="section dark">
<div className="container">
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
<p className="lead">{children}</p>
</div>
</section>
);
}

92
components/RepairForm.tsx Normal file
View file

@ -0,0 +1,92 @@
"use client";
import { useState, type FormEvent } from "react";
type Errors = Partial<Record<"name" | "email" | "manufacturer" | "model" | "description" | "privacy", string>>;
export default function RepairForm() {
const [errors, setErrors] = useState<Errors>({});
const [status, setStatus] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("");
const form = new FormData(event.currentTarget);
const nextErrors: Errors = {};
if (!String(form.get("name") ?? "").trim()) nextErrors.name = "Bitte geben Sie Ihren Namen ein.";
if (!String(form.get("email") ?? "").includes("@")) nextErrors.email = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
if (!String(form.get("manufacturer") ?? "").trim()) nextErrors.manufacturer = "Bitte nennen Sie den Hersteller.";
if (!String(form.get("model") ?? "").trim()) nextErrors.model = "Bitte nennen Sie das Modell.";
if (String(form.get("description") ?? "").trim().length < 20) nextErrors.description = "Bitte beschreiben Sie den Fehler mit mindestens 20 Zeichen.";
if (form.get("privacy") !== "on") nextErrors.privacy = "Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.";
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
setPending(true);
try {
const response = await fetch("/api/repair", {
method: "POST",
body: form,
});
const result = await response.json() as { message?: string };
if (!response.ok) throw new Error(result.message ?? "Anfrage konnte nicht gesendet werden.");
event.currentTarget.reset();
setStatus(result.message ?? "Ihre Reparaturanfrage wurde vorbereitet.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Anfrage konnte nicht gesendet werden.");
} finally {
setPending(false);
}
}
return (
<form className="form" onSubmit={submit} noValidate>
<div className="grid two">
<Field label="Name" name="name" error={errors.name} />
<Field label="E-Mail" name="email" type="email" error={errors.email} />
<Field label="Telefon" name="phone" />
<Field label="Hersteller" name="manufacturer" error={errors.manufacturer} />
<Field label="Modell" name="model" error={errors.model} />
<Field label="Seriennummer optional" name="serialNumber" />
</div>
<div className="field">
<label htmlFor="opened">Wurde das Gerät bereits geöffnet?</label>
<select id="opened" name="opened" defaultValue="unknown">
<option value="unknown">Bitte auswählen</option>
<option value="no">Nein</option>
<option value="yes">Ja</option>
</select>
</div>
<div className="field">
<label htmlFor="previousWork">Gibt es Vorarbeiten?</label>
<textarea id="previousWork" name="previousWork" placeholder="z. B. Bauteile getauscht, Abgleich versucht, Fremdwerkstatt" />
</div>
<div className="field">
<label htmlFor="description">Fehlerbeschreibung</label>
<textarea id="description" name="description" placeholder="Bitte Gerät, Fehlerbild und bisherige Beobachtungen beschreiben." />
{errors.description && <span className="error">{errors.description}</span>}
</div>
<p className="notice">Datei- und Bild-Upload ist für eine spätere Ausbaustufe vorbereitet. Bitte senden Sie Bilder aktuell erst nach Rückmeldung.</p>
<label className="checkbox">
<input type="checkbox" name="privacy" />
<span>Ich stimme zu, dass meine Angaben zur Bearbeitung der Reparaturanfrage verarbeitet werden.</span>
</label>
{errors.privacy && <span className="error">{errors.privacy}</span>}
{status && <p className="success">{status}</p>}
<button className="button" type="submit" disabled={pending}>{pending ? "Wird gesendet..." : "Reparatur anfragen"}</button>
</form>
);
}
function Field({ label, name, type = "text", error }: { label: string; name: string; type?: string; error?: string }) {
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input id={name} name={name} type={type} />
{error && <span className="error">{error}</span>}
</div>
);
}

View file

@ -0,0 +1,15 @@
import type { ReactNode } from "react";
type Props = {
title: string;
children: ReactNode;
};
export default function ServiceCard({ title, children }: Props) {
return (
<article className="card">
<h3>{title}</h3>
<p>{children}</p>
</article>
);
}

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
funktechnik-website:
build:
context: .
dockerfile: Dockerfile
container_name: funktechnik-website
restart: unless-stopped
environment:
NODE_ENV: production
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-}
OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-}
ports:
- "3010:3010"

9
eslint.config.mjs Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);

6
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
next.config.ts Normal file
View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
};
export default nextConfig;

18
nginx.example.conf Normal file
View file

@ -0,0 +1,18 @@
server {
listen 80;
server_name funktechnik-schubert.de www.funktechnik-schubert.de;
location / {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# HTTPS sollte produktiv z. B. mit Certbot aktiviert werden:
# certbot --nginx -d funktechnik-schubert.de -d www.funktechnik-schubert.de

6156
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "funktechnik-schubert-website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3010",
"build": "next build",
"start": "next start -p 3010",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.10",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-config-next": "16.2.10",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000" role="img" aria-label="Technischer Arbeitsplatz mit Signalraster">
<defs>
<linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1d3956"/>
<stop offset="1" stop-color="#0a1525"/>
</linearGradient>
<pattern id="grid" width="54" height="54" patternUnits="userSpaceOnUse">
<path d="M54 0H0v54" fill="none" stroke="#2f5578" stroke-width="1" opacity=".32"/>
</pattern>
</defs>
<rect width="1600" height="1000" fill="url(#panel)"/>
<rect width="1600" height="1000" fill="url(#grid)"/>
<g opacity=".56" fill="none" stroke="#16b6d9" stroke-width="6">
<path d="M1080 250c110 96 110 254 0 350"/>
<path d="M1160 170c160 142 160 368 0 510"/>
<path d="M1245 90c212 188 212 482 0 670"/>
</g>
<g transform="translate(720 350)">
<rect x="0" y="0" width="420" height="220" rx="18" fill="#0d1a2c" stroke="#5a7796" stroke-width="4"/>
<rect x="34" y="36" width="190" height="42" rx="5" fill="#152b43" stroke="#16b6d9" stroke-width="3"/>
<circle cx="310" cy="74" r="34" fill="#152b43" stroke="#f28c28" stroke-width="5"/>
<circle cx="310" cy="74" r="10" fill="#f28c28"/>
<path d="M52 140h82l24-42 37 86 38-64h122" stroke="#16b6d9" stroke-width="5"/>
<rect x="54" y="178" width="286" height="14" rx="7" fill="#5a7796"/>
</g>
<g opacity=".5" stroke="#f28c28" stroke-width="5">
<path d="M250 705h380"/>
<path d="M250 745h300"/>
<path d="M250 785h340"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

41
tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"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": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}