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