commit 9a648e2ec8ec83b621c7b7025d8255c70aadfe9e Author: Schubert Ferenc Date: Fri Jul 3 19:14:34 2026 +0200 feat: initial Funktechnik Schubert website diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b02fd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.env +.env.local +npm-debug.log* +.git +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a68e82 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef9c7d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.next +node_modules +out +dist +.env +.env.local +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c607bb --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6cb911 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..0d0d0a0 --- /dev/null +++ b/app/api/contact/route.ts @@ -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.", + }); +} diff --git a/app/api/repair/route.ts b/app/api/repair/route.ts new file mode 100644 index 0000000..9fbdc6d --- /dev/null +++ b/app/api/repair/route.ts @@ -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.", + }); +} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx new file mode 100644 index 0000000..265ff71 --- /dev/null +++ b/app/datenschutz/page.tsx @@ -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 ( + <> + Informationen zur Verarbeitung personenbezogener Daten. +
+
+

TODO: Rechtliche Angaben ergänzen

+

Verantwortlicher

+

TODO: Rechtliche Angaben ergänzen

+

Kontakt- und Reparaturanfragen

+

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.

+

Hosting und Server-Logs

+

TODO: Rechtliche Angaben ergänzen

+

Betroffenenrechte

+

TODO: Rechtliche Angaben ergänzen

+
+
+ + ); +} diff --git a/app/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx new file mode 100644 index 0000000..95d1583 --- /dev/null +++ b/app/funkgeraete-service/page.tsx @@ -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 ( + <> + + Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung und Bedienung. Der Service betrachtet diese Zusammenhänge strukturiert. + +
+
+
+

Typische Fehlerbilder

+
    +
  • Kein oder schwacher Empfang
  • +
  • Keine, leise oder verzerrte Modulation
  • +
  • Frequenzversatz, PLL-Rasten oder instabile Kanäle
  • +
  • Aussetzer durch Stromversorgung, Buchsen oder Bedienelemente
  • +
  • Unklare Vorarbeiten oder verbastelte Geräte
  • +
+
+
+

Serviceablauf

+

Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte. Danach folgt eine technische Einschätzung und die weitere Abstimmung.

+ Reparatur anfragen +
+
+
+ + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..74b660b --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx new file mode 100644 index 0000000..b413f8f --- /dev/null +++ b/app/impressum/page.tsx @@ -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 ( + <> + Rechtliche Angaben für die öffentliche Firmenwebsite. +
+
+

TODO: Rechtliche Angaben ergänzen

+

Anbieterkennzeichnung

+

TODO: Rechtliche Angaben ergänzen

+

Kontakt

+

TODO: Rechtliche Angaben ergänzen

+

Umsatzsteuer / Registerangaben

+

TODO: Rechtliche Angaben ergänzen

+
+
+ + ); +} diff --git a/app/kontakt/page.tsx b/app/kontakt/page.tsx new file mode 100644 index 0000000..67ac7d2 --- /dev/null +++ b/app/kontakt/page.tsx @@ -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 ( + <> + + Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme. + +
+
+
+

Direkt und technisch

+

Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.

+
+
+ +
+
+
+ + ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..119dd20 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + +
+
{children}
+