feat: initial Funktechnik Schubert website
This commit is contained in:
commit
9a648e2ec8
35 changed files with 7521 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
.git
|
||||||
|
README.md
|
||||||
5
.env.example
Normal file
5
.env.example
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
108
README.md
Normal 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
25
app/api/contact/route.ts
Normal 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
34
app/api/repair/route.ts
Normal 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
26
app/datenschutz/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/funkgeraete-service/page.tsx
Normal file
35
app/funkgeraete-service/page.tsx
Normal 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
404
app/globals.css
Normal 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
24
app/impressum/page.tsx
Normal 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
27
app/kontakt/page.tsx
Normal 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
22
app/layout.tsx
Normal 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
33
app/leistungen/page.tsx
Normal 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
57
app/page.tsx
Normal 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
33
app/reparatur/page.tsx
Normal 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
12
app/robots.ts
Normal 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
37
app/seo.ts
Normal 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
22
app/sitemap.ts
Normal 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
27
app/ueber-uns/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/ContactForm.tsx
Normal file
61
components/ContactForm.tsx
Normal 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
26
components/Footer.tsx
Normal 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
27
components/Header.tsx
Normal 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
19
components/PageHero.tsx
Normal 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
92
components/RepairForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/ServiceCard.tsx
Normal file
15
components/ServiceCard.tsx
Normal 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
14
docker-compose.yml
Normal 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
9
eslint.config.mjs
Normal 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
6
next-env.d.ts
vendored
Normal 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
8
next.config.ts
Normal 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
18
nginx.example.conf
Normal 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
6156
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
public/workbench-signal.svg
Normal file
31
public/workbench-signal.svg
Normal 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
41
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue