Compare commits

..

3 commits
v0.4.0 ... main

Author SHA1 Message Date
Schubert Ferenc
6f6891134e feat(status): add estimate customer actions 2026-07-05 02:17:19 +02:00
Schubert Ferenc
efdc4d5be1 feat(status): add public repair status page 2026-07-04 21:06:43 +02:00
Schubert Ferenc
a284bbc188 feat(cms): improve admin content editing 2026-07-04 11:01:29 +02:00
34 changed files with 1481 additions and 149 deletions

View file

@ -25,3 +25,13 @@ OLYMPUS_INTAKE_API_URL=
# Optional future server-side Olympus intake token. # Optional future server-side Olympus intake token.
# Leave empty until the Olympus integration is explicitly implemented. # Leave empty until the Olympus integration is explicitly implemented.
OLYMPUS_INTAKE_API_TOKEN= OLYMPUS_INTAKE_API_TOKEN=
# Server-side Olympus base URL for public repair status links.
# Docker example when Hermes is reachable in the same network: http://hermes:8000
# Production example: https://olympus.example.internal
# This URL must be reachable by the Next.js server, not by the browser.
OLYMPUS_PUBLIC_STATUS_API_URL=
# Optional server-side token for future protected status API access.
# Leave empty for Olympus v0.8.1 unless the endpoint is protected later.
OLYMPUS_PUBLIC_STATUS_API_TOKEN=

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ data/smtp-settings.json
storage/uploads/images/* storage/uploads/images/*
storage/config/* storage/config/*
storage/content/* storage/content/*
storage/content/backups/*
!storage/.gitkeep !storage/.gitkeep
!storage/config/.gitkeep !storage/config/.gitkeep
!storage/content/.gitkeep !storage/content/.gitkeep

View file

@ -14,7 +14,7 @@ FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_APP_VERSION=0.4.0 ENV NEXT_PUBLIC_APP_VERSION=0.4.1
ENV DOCKER_ENV=true ENV DOCKER_ENV=true
ENV PORT=3010 ENV PORT=3010

View file

@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- Nginx/Reverse-Proxy-fähig - Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt - SEO Metadata, Sitemap und robots.txt
Version: `0.4.0` Version: `0.4.1`
## Seiten ## Seiten
@ -23,6 +23,7 @@ Version: `0.4.0`
- `/reparatur` - `/reparatur`
- `/ueber-uns` - `/ueber-uns`
- `/kontakt` - `/kontakt`
- `/status/[token]`
- `/impressum` - `/impressum`
- `/datenschutz` - `/datenschutz`
- `/admin/login` - `/admin/login`
@ -109,8 +110,12 @@ Variablen:
- `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false` - `AUTH_COOKIE_SECURE`: `true` in Produktion mit HTTPS, lokal `false`
- `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_URL`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung - `OLYMPUS_INTAKE_API_TOKEN`: vorbereitet für spätere serverseitige Olympus-Anbindung
- `OLYMPUS_PUBLIC_STATUS_API_URL`: serverseitig erreichbare Olympus-Basis-URL für öffentliche Reparaturstatuslinks
- `OLYMPUS_PUBLIC_STATUS_API_TOKEN`: optionaler serverseitiger Token für spätere geschützte Status-API-Zugriffe
Die Olympus-Variablen sind nur für eine spätere serverseitige Integration vorbereitet. Sie werden nicht im Browser verwendet. Die Intake-Variablen sind für eine spätere serverseitige Integration vorbereitet. Die Status-Variablen werden für `/status/[token]` serverseitig verwendet. Sie werden nicht im Browser verwendet.
Für Reparaturstatuslinks muss `OLYMPUS_PUBLIC_STATUS_API_URL` vom Next.js-Server erreichbar sein. In Docker kann das je nach Netzwerk z. B. `http://hermes:8000` sein. Produktiv sollte eine interne Server-zu-Server-Adresse oder eine abgesicherte Olympus-URL verwendet werden.
Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein. Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss HTTPS verwendet und `AUTH_COOKIE_SECURE=true` gesetzt werden. `ADMIN_SESSION_SECRET` muss produktiv ein langer zufälliger Wert sein.
@ -130,6 +135,57 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss
- `GET /api/media/[filename]` - `GET /api/media/[filename]`
- `GET /api/health` - `GET /api/health`
## Öffentlicher Reparaturstatus
Olympus CRM erzeugt sichere Statuslinks für Reparaturen. Die Website stellt dafür die öffentliche Route bereit:
```text
/status/<token>
```
Die Seite ruft Olympus ausschließlich serverseitig auf:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>
```
Der Browser sieht weder die Olympus-URL noch optionale Server-Tokens. Die öffentliche Statusseite zeigt nur:
- Reparaturnummer
- Gerät
- aktuellen Status
- letzte Aktualisierung
- kundenfreundliche Status-Timeline
- optionalen Kostenvoranschlag aus Olympus CRM v0.8.6
Wenn Olympus einen Kostenvoranschlag im Status `sent` liefert, zeigt die Website:
- Nummer, Titel, Nachricht an den Kunden und Gültigkeit
- Positionen mit Menge, Einheit und Betrag
- Zwischensumme, Steuer und Gesamtbetrag
- Aktionen zum Freigeben, Ablehnen oder für eine Rückfrage
Diese Aktionen laufen nicht direkt vom Browser zu Olympus. Der Browser ruft ausschließlich relative Website-Endpunkte auf:
```text
POST /api/status/<token>/estimate/approve
POST /api/status/<token>/estimate/decline
POST /api/status/<token>/estimate/question
```
Die Website leitet diese Anfragen serverseitig an Olympus weiter:
```text
${OLYMPUS_PUBLIC_STATUS_API_URL}/public/repairs/status/<token>/estimate/<action>
```
Nicht angezeigt werden Kundendaten, interne Notizen, Diagnosedetails oder technische Fehlermeldungen.
Fehlerverhalten:
- ungültiger oder abgelaufener Token: freundliche Meldung mit Links zu Reparatur und Kontakt
- Olympus nicht erreichbar: neutrale Meldung ohne technische Details
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. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert. 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. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert.
## Admin-Bereich ## Admin-Bereich
@ -180,6 +236,7 @@ Website-Inhalte liegen unter `storage/content/` und werden nicht committed:
storage/content/home.json storage/content/home.json
storage/content/services.json storage/content/services.json
storage/content/radio-service.json storage/content/radio-service.json
storage/content/repair.json
storage/content/about.json storage/content/about.json
storage/content/contact.json storage/content/contact.json
storage/content/settings.json storage/content/settings.json
@ -193,6 +250,7 @@ Bearbeitbar sind aktuell:
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen - Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen - Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten - Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
- Reparaturannahme mit SEO-Daten und Seitentexten
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie - Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten - Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen - Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
@ -200,6 +258,16 @@ Bearbeitbar sind aktuell:
Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert. Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert.
## Roadmap Kundenportal
Ein vollständiges Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Die öffentliche Statuslink-Seite ist bereits vorhanden. Für spätere Ausbaustufen sind folgende Routen vorgesehen:
- `/status/[token]`: öffentliche Statuslink-Seite für Olympus
- `/reparatur/status`: Statusabfrage für Reparaturanfragen
- `/portal/login`: geschützter Kundenlogin
Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen.
## Healthcheck ## Healthcheck
```bash ```bash
@ -217,7 +285,7 @@ Antwort:
```json ```json
{ {
"status": "ok", "status": "ok",
"version": "0.4.0", "version": "0.4.1",
"storage": "ok", "storage": "ok",
"admin": "configured", "admin": "configured",
"smtp": "configured", "smtp": "configured",

View file

@ -1,8 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store"; import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
import { getContentSummary } from "@/lib/content/service";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { appVersion, checkStorage } from "@/lib/runtime/config";
function countNew<T extends { status: string }>(items: T[]) { function countNew<T extends { status: string }>(items: T[]) {
return items.filter((item) => item.status === "new").length; return items.filter((item) => item.status === "new").length;
@ -10,8 +13,23 @@ function countNew<T extends { status: string }>(items: T[]) {
export default async function AdminDashboardPage() { export default async function AdminDashboardPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]); let healthStatus = "OK";
try {
await checkStorage();
} catch {
healthStatus = "Fehler";
}
const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([
getContactInquiries(),
getRepairInquiries(),
getSmtpSettings(),
listMediaFiles(),
getContentSummary(),
]);
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6); const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
return ( return (
<AdminShell> <AdminShell>
@ -20,10 +38,13 @@ export default async function AdminDashboardPage() {
<h1>Dashboard</h1> <h1>Dashboard</h1>
</div> </div>
<div className="admin-kpis"> <div className="admin-kpis">
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div> <div className="admin-kpi"><span>Kontaktanfragen</span><strong>{contacts.length}</strong><small>{countNew(contacts)} neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div> <div className="admin-kpi"><span>Reparaturanfragen</span><strong>{repairs.length}</strong><small>{countNew(repairs)} neu</small></div>
<div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</small></div> <div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div>
<div className="admin-kpi"><span>Content</span><strong>{contentSummary.pageCount}</strong><small>{contentModified}</small></div>
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div> <div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
<div className="admin-kpi"><span>Version</span><strong>v{appVersion}</strong><small>Website</small></div>
<div className="admin-kpi"><span>Health</span><strong>{healthStatus}</strong><small>Storage und Runtime</small></div>
</div> </div>
<section className="admin-card"> <section className="admin-card">
<h2>Letzte Anfragen</h2> <h2>Letzte Anfragen</h2>

View file

@ -52,6 +52,22 @@ export default async function AdminSystemPage() {
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li> <li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
</ul> </ul>
</section> </section>
<section className="admin-card">
<h2>Letzte Content-Backups</h2>
<table className="admin-table">
<thead><tr><th>Seite</th><th>Datum</th><th>Dateiname</th></tr></thead>
<tbody>
{contentStatus.backups.map((backup) => (
<tr key={backup.fileName}>
<td>{backup.page}</td>
<td>{new Date(backup.createdAt).toLocaleString("de-DE")}</td>
<td>{backup.fileName}</td>
</tr>
))}
{contentStatus.backups.length === 0 && <tr><td colSpan={3}>Noch keine Content-Backups vorhanden.</td></tr>}
</tbody>
</table>
</section>
</AdminShell> </AdminShell>
); );
} }

View file

@ -3,7 +3,7 @@ import { requireAdminSession } from "@/lib/admin/auth";
import { getAllContent, saveContent } from "@/lib/content/service"; import { getAllContent, saveContent } from "@/lib/content/service";
import type { ContentDocuments, ContentKey } from "@/lib/content/types"; import type { ContentDocuments, ContentKey } from "@/lib/content/types";
const keys: ContentKey[] = ["home", "services", "radio-service", "about", "contact", "settings"]; const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"];
function isContentKey(value: unknown): value is ContentKey { function isContentKey(value: unknown): value is ContentKey {
return typeof value === "string" && keys.includes(value as ContentKey); return typeof value === "string" && keys.includes(value as ContentKey);

View file

@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import type { EstimateActionResult } from "@/lib/olympus/status";
export function estimateActionResponse(result: EstimateActionResult, successMessage: string) {
if (result.ok) {
return NextResponse.json({ message: successMessage });
}
if (result.reason === "invalid") {
return NextResponse.json(
{ message: "Dieser Statuslink ist ungültig oder nicht mehr aktiv." },
{ status: 404 },
);
}
if (result.reason === "unavailable") {
return NextResponse.json(
{ message: "Der Reparaturstatus ist momentan nicht abrufbar." },
{ status: 502 },
);
}
return NextResponse.json(
{ message: "Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut." },
{ status: 400 },
);
}
export async function readOptionalMessage(request: Request) {
try {
const body = await request.json() as { message?: unknown };
return typeof body.message === "string" ? body.message : "";
} catch {
return "";
}
}

View file

@ -0,0 +1,15 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(_request: Request, { params }: RouteContext) {
const { token } = await params;
const result = await submitOlympusEstimateAction(token, "approve");
return estimateActionResponse(result, "Kostenvoranschlag wurde freigegeben.");
}

View file

@ -0,0 +1,16 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse, readOptionalMessage } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(request: Request, { params }: RouteContext) {
const { token } = await params;
const message = await readOptionalMessage(request);
const result = await submitOlympusEstimateAction(token, "decline", message);
return estimateActionResponse(result, "Kostenvoranschlag wurde abgelehnt.");
}

View file

@ -0,0 +1,16 @@
import { submitOlympusEstimateAction } from "@/lib/olympus/status";
import { estimateActionResponse, readOptionalMessage } from "../action-response";
type RouteContext = {
params: Promise<{
token: string;
}>;
};
export async function POST(request: Request, { params }: RouteContext) {
const { token } = await params;
const message = await readOptionalMessage(request);
const result = await submitOlympusEstimateAction(token, "question", message);
return estimateActionResponse(result, "Ihre Rückfrage wurde gesendet.");
}

View file

@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo"; import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() { export async function generateMetadata() {
const content = await getContent("radio-service"); const content = await getContent("radio-service");
return createContentMetadata(content.seo); return createContentMetadata(content.seo, defaultContent["radio-service"].seo);
} }
export default async function RadioServicePage() { export default async function RadioServicePage() {

View file

@ -488,6 +488,372 @@ h3 {
line-height: 1; line-height: 1;
} }
.status-page {
min-height: calc(100vh - 78px);
padding: 72px 0;
background:
linear-gradient(135deg, rgba(244, 246, 248, 0.96), rgba(232, 238, 246, 0.94)),
url("/workbench-signal.svg");
background-position: center;
background-size: cover;
}
.status-page-inner {
display: grid;
align-items: start;
}
.status-card {
border: 1px solid rgba(8, 42, 96, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.97);
padding: 34px;
box-shadow: var(--shadow);
}
.status-card-narrow {
width: min(760px, 100%);
margin: 0 auto;
}
.status-card h1 {
margin-bottom: 10px;
color: var(--ink);
font-size: clamp(36px, 5vw, 58px);
line-height: 1;
}
.status-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 26px;
}
.status-badge,
.status-pill {
display: inline-flex;
min-height: 32px;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(30, 159, 184, 0.24);
background: rgba(30, 159, 184, 0.1);
color: var(--navy);
font-size: 13px;
font-weight: 800;
padding: 0 12px;
white-space: nowrap;
}
.status-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin: 0 0 34px;
}
.status-summary div {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #f8fafc;
padding: 16px;
}
.status-summary dt {
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.status-summary dd {
margin: 0;
color: var(--ink);
font-size: 17px;
font-weight: 800;
}
.status-timeline-section h2 {
margin-bottom: 20px;
font-size: clamp(26px, 3vw, 34px);
}
.public-status-timeline {
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.public-status-timeline li {
position: relative;
border-left: 2px solid #d7e0ea;
padding: 0 0 18px 26px;
}
.public-status-timeline li:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -8px;
top: 15px;
width: 14px;
height: 14px;
border: 3px solid #fff;
border-radius: 999px;
background: var(--steel);
box-shadow: 0 0 0 1px rgba(8, 42, 96, 0.12);
}
.public-status-timeline li.is-current .timeline-dot {
background: var(--orange);
}
.timeline-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
padding: 16px;
}
.timeline-title {
margin-bottom: 8px;
color: var(--ink);
font-size: 17px;
font-weight: 800;
}
.timeline-content time,
.status-muted {
color: var(--muted);
font-size: 14px;
}
.estimate-card {
display: grid;
gap: 22px;
border: 1px solid rgba(8, 42, 96, 0.12);
border-radius: var(--radius);
background: #f8fafc;
margin: 0 0 34px;
padding: 24px;
}
.estimate-header,
.estimate-item,
.estimate-totals div,
.estimate-form-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.estimate-header h2 {
margin: 0 0 8px;
color: var(--ink);
font-size: clamp(26px, 3vw, 34px);
}
.estimate-status {
display: inline-flex;
min-height: 32px;
align-items: center;
border: 1px solid rgba(232, 124, 46, 0.28);
border-radius: 999px;
background: rgba(232, 124, 46, 0.12);
color: #8b420d;
font-size: 13px;
font-weight: 800;
padding: 0 12px;
white-space: nowrap;
}
.estimate-message,
.estimate-status-text {
border-left: 4px solid var(--steel);
background: #fff;
color: var(--ink);
margin: 0;
padding: 14px 16px;
}
.estimate-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.estimate-meta div {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 14px;
}
.estimate-meta dt,
.estimate-totals dt {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.estimate-meta dd,
.estimate-totals dd {
margin: 6px 0 0;
color: var(--ink);
font-weight: 800;
}
.estimate-items {
display: grid;
gap: 12px;
}
.estimate-item {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
}
.estimate-item h3 {
margin: 6px 0;
color: var(--ink);
font-size: 18px;
}
.estimate-item p {
margin: 0;
color: var(--muted);
}
.estimate-item-type {
color: var(--steel);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.estimate-item-price {
display: grid;
gap: 6px;
min-width: 150px;
text-align: right;
}
.estimate-item-price span {
color: var(--muted);
font-size: 14px;
}
.estimate-item-price strong {
color: var(--ink);
font-size: 18px;
}
.estimate-totals {
display: grid;
gap: 8px;
width: min(380px, 100%);
justify-self: end;
margin: 0;
}
.estimate-totals div {
border-bottom: 1px solid var(--line);
padding: 8px 0;
}
.estimate-totals .estimate-total-highlight {
border-bottom: 0;
color: var(--ink);
font-size: 20px;
}
.estimate-actions-panel {
display: grid;
gap: 16px;
border-top: 1px solid var(--line);
padding-top: 18px;
}
.estimate-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.button.danger {
border-color: #a33a2d;
background: #a33a2d;
color: #fff;
}
.button.danger:hover {
background: #842f25;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.64;
}
.estimate-action-form {
display: grid;
gap: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
}
.estimate-action-form label {
color: var(--ink);
font-weight: 800;
}
.estimate-action-form textarea {
width: 100%;
border: 1px solid #cfd8e5;
border-radius: 8px;
color: var(--ink);
padding: 12px;
resize: vertical;
}
.estimate-form-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.estimate-feedback {
border: 1px solid rgba(46, 125, 50, 0.24);
border-radius: 8px;
background: rgba(46, 125, 50, 0.08);
color: #1b5e20;
font-weight: 800;
margin: 0;
padding: 12px 14px;
}
.estimate-feedback.error {
border-color: rgba(163, 58, 45, 0.26);
background: rgba(163, 58, 45, 0.08);
color: #842f25;
}
.admin-login-page { .admin-login-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@ -630,7 +996,7 @@ h3 {
.admin-kpis { .admin-kpis {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px; gap: 14px;
margin-bottom: 18px; margin-bottom: 18px;
} }
@ -1039,6 +1405,11 @@ h3 {
margin-bottom: 0; margin-bottom: 0;
} }
.content-save-actions {
display: flex;
justify-content: flex-end;
}
.content-editor-heading { .content-editor-heading {
margin: 14px 0 0; margin: 14px 0 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@ -1094,6 +1465,27 @@ h3 {
padding: 14px; padding: 14px;
} }
.content-image-picker {
display: grid;
gap: 10px;
}
.content-image-preview {
display: grid;
grid-template-columns: minmax(140px, 240px) auto;
gap: 12px;
align-items: end;
}
.content-image-preview img {
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
object-fit: cover;
}
.content-preview-card { .content-preview-card {
overflow: hidden; overflow: hidden;
} }
@ -1110,6 +1502,10 @@ h3 {
margin-bottom: 0; margin-bottom: 0;
} }
.content-preview-toolbar .eyebrow {
margin-bottom: 4px;
}
.content-preview-toolbar div { .content-preview-toolbar div {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1233,10 +1629,44 @@ h3 {
.admin-shell, .admin-shell,
.admin-kpis, .admin-kpis,
.admin-detail-grid, .admin-detail-grid,
.media-grid { .media-grid,
.status-summary {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.status-page {
padding: 38px 0;
}
.status-card {
padding: 22px;
}
.status-title-row,
.timeline-content,
.estimate-header,
.estimate-item {
display: grid;
}
.status-badge,
.estimate-status {
width: fit-content;
}
.estimate-meta {
grid-template-columns: 1fr;
}
.estimate-item-price {
min-width: 0;
text-align: left;
}
.estimate-totals {
justify-self: stretch;
}
.admin-sidebar { .admin-sidebar {
position: static; position: static;
height: auto; height: auto;
@ -1273,4 +1703,16 @@ h3 {
.content-field-grid { .content-field-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.content-editor-top,
.content-preview-toolbar,
.content-image-preview {
grid-template-columns: 1fr;
align-items: stretch;
}
.content-editor-top,
.content-preview-toolbar {
display: grid;
}
} }

View file

@ -1,5 +1,6 @@
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm"; import ContactForm from "@/components/ContactForm";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo"; import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() { export async function generateMetadata() {
const content = await getContent("contact"); const content = await getContent("contact");
return createContentMetadata(content.seo); return createContentMetadata(content.seo, defaultContent.contact.seo);
} }
export default async function ContactPage() { export default async function ContactPage() {

View file

@ -1,12 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import SiteFrame from "@/components/SiteFrame"; import SiteFrame from "@/components/SiteFrame";
import "./globals.css"; import "./globals.css";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo"; import { createContentMetadata } from "./seo";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const settings = await getContent("settings"); const settings = await getContent("settings");
return createContentMetadata(settings.seo); return createContentMetadata(settings.seo, defaultContent.settings.seo);
} }
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {

View file

@ -1,5 +1,6 @@
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard"; import ServiceCard from "@/components/ServiceCard";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo"; import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() { export async function generateMetadata() {
const content = await getContent("services"); const content = await getContent("services");
return createContentMetadata(content.seo); return createContentMetadata(content.seo, defaultContent.services.seo);
} }
export default async function ServicesPage() { export default async function ServicesPage() {

View file

@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import ServiceCard from "@/components/ServiceCard"; import ServiceCard from "@/components/ServiceCard";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo"; import { createContentMetadata } from "./seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() { export async function generateMetadata() {
const content = await getContent("home"); const content = await getContent("home");
return createContentMetadata(content.seo); return createContentMetadata(content.seo, defaultContent.home.seo);
} }
export default async function HomePage() { export default async function HomePage() {
@ -15,7 +16,10 @@ export default async function HomePage() {
return ( return (
<> <>
<section className="hero"> <section
className="hero"
style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.96), rgba(8, 42, 96, 0.78), rgba(8, 42, 96, 0.58)), url("${content.heroImage || "/workbench-signal.svg"}"), linear-gradient(135deg, #07172d, #0b3778)` }}
>
<div className="container hero-content"> <div className="container hero-content">
<p className="eyebrow">{content.eyebrow}</p> <p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1> <h1>{content.title}</h1>

View file

@ -1,27 +1,32 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import RepairForm from "@/components/RepairForm"; import RepairForm from "@/components/RepairForm";
import { createMetadata } from "../seo"; import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"); export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("repair");
return createContentMetadata(content.seo, defaultContent.repair.seo);
}
export default async function RepairPage() {
const content = await getContent("repair");
const paragraph = content.paragraphs[0];
export default function RepairPage() {
return ( return (
<> <>
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen"> <PageHero eyebrow={content.eyebrow} title={content.title}>
Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet. {content.subtitle}
</PageHero> </PageHero>
<section className="section"> <section className="section">
<div className="container split"> <div className="container split">
<div> <div>
<h2>Vor der Einsendung</h2> <h2>{paragraph?.title || content.cta.text}</h2>
<p className="lead">Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.</p> <p className="lead">{paragraph?.text || content.intro}</p>
<ul className="list"> <ul className="list">
<li>Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden</li> {content.list.map((item) => <li key={item}>{item}</li>)}
<li>Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung</li>
<li>Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben</li>
<li>Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen</li>
<li>Fotos können später nach Rückmeldung ergänzt werden</li>
</ul> </ul>
</div> </div>
<div className="panel"> <div className="panel">

View file

@ -60,28 +60,32 @@ export function createMetadata(title: string, description: string, path = "/"):
}; };
} }
export function createContentMetadata(seo: SeoContent): Metadata { export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata {
const path = seo.canonicalUrl || "/"; const resolvedSeo = {
...fallbackSeo,
...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")),
} as SeoContent;
const path = resolvedSeo.canonicalUrl || "/";
const url = new URL(path, siteUrl).toString(); const url = new URL(path, siteUrl).toString();
const title = seo.metaTitle || siteName; const title = resolvedSeo.metaTitle || siteName;
const description = seo.metaDescription || defaultTitle; const description = resolvedSeo.metaDescription || defaultTitle;
return { return {
...createMetadata(title, description, path), ...createMetadata(title, description, path),
keywords: seo.keywords ? seo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords,
openGraph: { openGraph: {
type: "website", type: "website",
locale: "de_DE", locale: "de_DE",
url, url,
siteName, siteName,
title: seo.openGraphTitle || title, title: resolvedSeo.openGraphTitle || title,
description: seo.openGraphDescription || description, description: resolvedSeo.openGraphDescription || description,
images: [ images: [
{ {
url: seo.socialImage || "/funktechnik_schubert_logo.jpg", url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg",
width: 1200, width: 1200,
height: 630, height: 630,
alt: seo.openGraphTitle || title, alt: resolvedSeo.openGraphTitle || title,
}, },
], ],
}, },

268
app/status/[token]/page.tsx Normal file
View file

@ -0,0 +1,268 @@
import type { Metadata } from "next";
import Link from "next/link";
import { EstimateActions } from "@/components/status/EstimateActions";
import {
fetchOlympusRepairStatus,
type OlympusEstimate,
type OlympusRepairStatus,
} from "@/lib/olympus/status";
type PageProps = {
params: Promise<{
token: string;
}>;
};
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Reparaturstatus | Funktechnik Schubert",
description: "Öffentlicher Reparaturstatus für Kunden von Funktechnik Schubert.",
robots: {
index: false,
follow: false,
},
};
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDateOnly(value: string | null) {
if (!value) {
return "Nicht angegeben";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).format(new Date(value));
}
function formatMoney(cents: number, currency: string) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: currency || "EUR",
}).format(cents / 100);
}
function estimateStatusLabel(status: string) {
const labels: Record<string, string> = {
draft: "In Vorbereitung",
sent: "Zur Freigabe",
approved: "Freigegeben",
declined: "Abgelehnt",
expired: "Abgelaufen",
cancelled: "Storniert",
};
return labels[status] ?? status;
}
function estimateStatusText(status: string) {
const texts: Record<string, string> = {
sent: "Bitte prüfen Sie den Kostenvoranschlag. Sie können ihn freigeben, ablehnen oder eine Rückfrage senden.",
approved: "Dieser Kostenvoranschlag wurde freigegeben.",
declined: "Dieser Kostenvoranschlag wurde abgelehnt.",
expired: "Dieser Kostenvoranschlag ist nicht mehr gültig.",
cancelled: "Dieser Kostenvoranschlag wurde storniert.",
};
return texts[status] ?? "Der Kostenvoranschlag ist aktuell nicht zur Bearbeitung freigegeben.";
}
function estimateItemTypeLabel(type: string) {
const labels: Record<string, string> = {
labor: "Arbeitszeit",
part: "Ersatzteil",
material: "Material",
service: "Service",
shipping: "Versand",
other: "Sonstiges",
};
return labels[type] ?? "Position";
}
function EstimateCard({ estimate, token }: { estimate: OlympusEstimate | null | undefined; token: string }) {
if (!estimate) {
return null;
}
return (
<section className="estimate-card" aria-labelledby="estimate-title">
<div className="estimate-header">
<div>
<p className="eyebrow">Kostenvoranschlag</p>
<h2 id="estimate-title">{estimate.title || "Kostenvoranschlag"}</h2>
<p className="status-muted">Nummer {estimate.estimate_number}</p>
</div>
<span className="estimate-status">{estimateStatusLabel(estimate.status)}</span>
</div>
{estimate.customer_message && <p className="estimate-message">{estimate.customer_message}</p>}
<dl className="estimate-meta">
<div>
<dt>Gültig bis</dt>
<dd>{formatDateOnly(estimate.valid_until)}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{estimateStatusLabel(estimate.status)}</dd>
</div>
</dl>
<div className="estimate-items" aria-label="Positionen des Kostenvoranschlags">
{estimate.items.map((item, index) => (
<div className="estimate-item" key={`${item.title}-${index}`}>
<div>
<span className="estimate-item-type">{estimateItemTypeLabel(item.item_type)}</span>
<h3>{item.title}</h3>
{item.description && <p>{item.description}</p>}
</div>
<div className="estimate-item-price">
<span>
{item.quantity} {item.unit}
</span>
<strong>{formatMoney(item.total_cents, estimate.currency)}</strong>
</div>
</div>
))}
</div>
<dl className="estimate-totals">
<div>
<dt>Zwischensumme</dt>
<dd>{formatMoney(estimate.subtotal_cents, estimate.currency)}</dd>
</div>
<div>
<dt>Mehrwertsteuer</dt>
<dd>{formatMoney(estimate.tax_cents, estimate.currency)}</dd>
</div>
<div className="estimate-total-highlight">
<dt>Gesamt</dt>
<dd>{formatMoney(estimate.total_cents, estimate.currency)}</dd>
</div>
</dl>
<p className="estimate-status-text">{estimateStatusText(estimate.status)}</p>
{estimate.status === "sent" && <EstimateActions token={token} />}
</section>
);
}
function StatusError({ title, text }: { title: string; text: string }) {
return (
<section className="status-page">
<div className="container status-page-inner">
<div className="status-card status-card-narrow">
<p className="eyebrow">Reparaturstatus</p>
<h1>{title}</h1>
<p className="lead">{text}</p>
<div className="actions">
<Link className="button" href="/reparatur">Reparatur anfragen</Link>
<Link className="button light" href="/kontakt">Kontakt aufnehmen</Link>
</div>
</div>
</div>
</section>
);
}
function StatusTimeline({ status }: { status: OlympusRepairStatus }) {
if (status.status_history_public.length === 0) {
return <p className="status-muted">Noch keine Statushistorie vorhanden.</p>;
}
const latestIndex = status.status_history_public.length - 1;
return (
<ol className="public-status-timeline">
{status.status_history_public.map((item, index) => (
<li key={`${item.status}-${item.created_at}-${index}`} className={index === latestIndex ? "is-current" : ""}>
<span className="timeline-dot" aria-hidden="true" />
<div className="timeline-content">
<div>
<p className="timeline-title">{item.status_label}</p>
{index === latestIndex && <span className="status-pill">Aktueller Status</span>}
</div>
<time dateTime={item.created_at}>{formatDate(item.created_at)}</time>
</div>
</li>
))}
</ol>
);
}
export default async function RepairStatusPage({ params }: PageProps) {
const { token } = await params;
const result = await fetchOlympusRepairStatus(token);
if (!result.ok && result.reason === "invalid") {
return (
<StatusError
title="Statuslink nicht aktiv"
text="Dieser Statuslink ist ungültig oder nicht mehr aktiv."
/>
);
}
if (!result.ok) {
return (
<StatusError
title="Status momentan nicht abrufbar"
text="Der Reparaturstatus ist momentan nicht abrufbar. Bitte versuchen Sie es später erneut oder kontaktieren Sie uns direkt."
/>
);
}
const status = result.status;
const device = `${status.device_manufacturer} ${status.device_model}`.trim();
return (
<section className="status-page">
<div className="container status-page-inner">
<div className="status-card">
<p className="eyebrow">Reparaturstatus</p>
<div className="status-title-row">
<div>
<h1>Reparaturstatus</h1>
<p className="lead">Hier sehen Sie den aktuellen Stand Ihrer Reparatur.</p>
</div>
<span className="status-badge">{status.public_status_label}</span>
</div>
<dl className="status-summary">
<div>
<dt>Reparaturnummer</dt>
<dd>{status.repair_number}</dd>
</div>
<div>
<dt>Gerät</dt>
<dd>{device || "Nicht angegeben"}</dd>
</div>
<div>
<dt>Aktueller Status</dt>
<dd>{status.public_status_label}</dd>
</div>
<div>
<dt>Letzte Aktualisierung</dt>
<dd>{formatDate(status.updated_at)}</dd>
</div>
</dl>
<EstimateCard estimate={status.estimate} token={token} />
<div className="status-timeline-section">
<h2>Statusverlauf</h2>
<StatusTimeline status={status} />
</div>
</div>
</div>
</section>
);
}

View file

@ -1,4 +1,5 @@
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo"; import { createContentMetadata } from "../seo";
@ -6,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() { export async function generateMetadata() {
const content = await getContent("about"); const content = await getContent("about");
return createContentMetadata(content.seo); return createContentMetadata(content.seo, defaultContent.about.seo);
} }
export default async function AboutPage() { export default async function AboutPage() {

View file

@ -61,7 +61,7 @@ export default function Footer() {
<div className="container footer-grid"> <div className="container footer-grid">
<div> <div>
<Image <Image
src={settings.logoUrl} src={settings.logoUrl || fallbackSettings.logoUrl}
alt={settings.siteName} alt={settings.siteName}
width={1672} width={1672}
height={941} height={941}

View file

@ -36,7 +36,7 @@ export default function Header() {
if (active && response.ok && result.settings) { if (active && response.ok && result.settings) {
setSettings({ setSettings({
siteName: result.settings.siteName, siteName: result.settings.siteName,
logoUrl: result.settings.logoUrl, logoUrl: result.settings.logoUrl || fallbackSettings.logoUrl,
headerCta: result.settings.headerCta, headerCta: result.settings.headerCta,
}); });
} }

View file

@ -1,21 +1,32 @@
"use client"; "use client";
/* eslint-disable @next/next/no-img-element */
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { MediaFile } from "@/lib/admin/media"; import type { MediaFile } from "@/lib/admin/media";
import type { ContentDocuments, ContentKey, LinkContent, PageContent, ServiceContent, TextBlockContent } from "@/lib/content/types"; import type { ContentDocuments, ContentKey, LinkContent, PageContent, SeoContent, ServiceContent, TextBlockContent } from "@/lib/content/types";
type Viewport = "desktop" | "tablet" | "mobile"; type Viewport = "desktop" | "tablet" | "mobile";
type ContentTabId = ContentKey | "footer" | "global-seo" | "general-settings"; type ContentTabId = "home" | "services" | "radio-service" | "about" | "contact" | "footer" | "seo";
type SeoPageKey = Exclude<ContentKey, "settings">;
const tabs: Array<{ id: ContentTabId; key: ContentKey; label: string }> = [ const tabs: Array<{ id: ContentTabId; label: string }> = [
{ id: "home", key: "home", label: "Startseite" }, { id: "home", label: "Startseite" },
{ id: "services", key: "services", label: "Leistungen" }, { id: "services", label: "Leistungen" },
{ id: "radio-service", key: "radio-service", label: "Funkgeräte-Service" }, { id: "radio-service", label: "Funkgeräte-Service" },
{ id: "about", key: "about", label: "Über Uns" }, { id: "about", label: "Über uns" },
{ id: "contact", key: "contact", label: "Kontakt" }, { id: "contact", label: "Kontakt" },
{ id: "footer", key: "settings", label: "Footer" }, { id: "footer", label: "Footer" },
{ id: "global-seo", key: "settings", label: "SEO" }, { id: "seo", label: "SEO" },
{ id: "general-settings", key: "settings", label: "Allgemeine Einstellungen" }, ];
const seoPages: Array<{ key: SeoPageKey; label: string }> = [
{ key: "home", label: "Startseite" },
{ key: "services", label: "Leistungen" },
{ key: "radio-service", label: "Funkgeräte-Service" },
{ key: "repair", label: "Reparatur" },
{ key: "about", label: "Über uns" },
{ key: "contact", label: "Kontakt" },
]; ];
type Props = { type Props = {
@ -28,6 +39,12 @@ type ApiResponse<K extends ContentKey> = {
content?: ContentDocuments[K]; content?: ContentDocuments[K];
}; };
function contentKeyForTab(tab: ContentTabId, seoPage: SeoPageKey): ContentKey {
if (tab === "footer") return "settings";
if (tab === "seo") return seoPage;
return tab;
}
function linesToText(lines: string[]) { function linesToText(lines: string[]) {
return lines.join("\n"); return lines.join("\n");
} }
@ -58,23 +75,41 @@ function TextArea({ label, value, onChange, rows = 4 }: { label: string; value:
); );
} }
function ImageSelect({ label, value, mediaFiles, onChange }: { label: string; value: string; mediaFiles: MediaFile[]; onChange: (value: string) => void }) { function SectionTitle({ children }: { children: string }) {
return <h3 className="content-editor-heading">{children}</h3>;
}
function ImagePicker({ label, value, mediaFiles, onChange, fallback = "" }: { label: string; value: string; mediaFiles: MediaFile[]; fallback?: string; onChange: (value: string) => void }) {
const images = mediaFiles.filter((file) => file.isImage); const images = mediaFiles.filter((file) => file.isImage);
const preview = value || fallback;
return ( return (
<label> <div className="content-image-picker">
{label} <label>
<select value={value} onChange={(event) => onChange(event.target.value)}> {label}
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option> <select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="/workbench-signal.svg">Standard-Hintergrund</option> <option value="">Standard verwenden</option>
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)} <option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
</select> <option value="/workbench-signal.svg">Standard-Hintergrund</option>
</label> {images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
</select>
</label>
{preview && (
<div className="content-image-preview">
<img src={preview} alt="" />
<button className="button light" type="button" onClick={() => onChange("")}>Bild entfernen</button>
</div>
)}
</div>
); );
} }
function SectionTitle({ children }: { children: string }) { function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) {
return <h3 className="content-editor-heading">{children}</h3>; return (
<div className="content-save-actions">
<button className="button" type="button" onClick={onSave} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
</div>
);
} }
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) { function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
@ -96,7 +131,7 @@ function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChan
); );
} }
function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFiles: MediaFile[]; onChange: (page: PageContent) => void }) { function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) {
return ( return (
<> <>
<SectionTitle>Hero</SectionTitle> <SectionTitle>Hero</SectionTitle>
@ -118,20 +153,24 @@ function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFi
<TextInput label="Button 2 Text" value={page.cta.secondary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} /> <TextInput label="Button 2 Text" value={page.cta.secondary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} />
<TextInput label="Button 2 Link" value={page.cta.secondary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} /> <TextInput label="Button 2 Link" value={page.cta.secondary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} />
</div> </div>
<SectionTitle>SEO</SectionTitle>
<div className="content-field-grid">
<TextInput label="Meta Title" value={page.seo.metaTitle} onChange={(value) => onChange({ ...page, seo: { ...page.seo, metaTitle: value } })} />
<TextInput label="Meta Description" value={page.seo.metaDescription} onChange={(value) => onChange({ ...page, seo: { ...page.seo, metaDescription: value } })} />
<TextInput label="Keywords" value={page.seo.keywords} onChange={(value) => onChange({ ...page, seo: { ...page.seo, keywords: value } })} />
<TextInput label="OpenGraph Titel" value={page.seo.openGraphTitle} onChange={(value) => onChange({ ...page, seo: { ...page.seo, openGraphTitle: value } })} />
<TextInput label="OpenGraph Beschreibung" value={page.seo.openGraphDescription} onChange={(value) => onChange({ ...page, seo: { ...page.seo, openGraphDescription: value } })} />
<ImageSelect label="Social Image" value={page.seo.socialImage} mediaFiles={mediaFiles} onChange={(value) => onChange({ ...page, seo: { ...page.seo, socialImage: value } })} />
<TextInput label="Canonical URL" value={page.seo.canonicalUrl} onChange={(value) => onChange({ ...page, seo: { ...page.seo, canonicalUrl: value } })} />
</div>
</> </>
); );
} }
function SeoFields({ seo, mediaFiles, onChange }: { seo: SeoContent; mediaFiles: MediaFile[]; onChange: (seo: SeoContent) => void }) {
return (
<div className="content-field-grid">
<TextInput label="Meta Title" value={seo.metaTitle} onChange={(value) => onChange({ ...seo, metaTitle: value })} />
<TextInput label="Meta Description" value={seo.metaDescription} onChange={(value) => onChange({ ...seo, metaDescription: value })} />
<TextInput label="Keywords" value={seo.keywords} onChange={(value) => onChange({ ...seo, keywords: value })} />
<TextInput label="OpenGraph Title" value={seo.openGraphTitle} onChange={(value) => onChange({ ...seo, openGraphTitle: value })} />
<TextInput label="OpenGraph Description" value={seo.openGraphDescription} onChange={(value) => onChange({ ...seo, openGraphDescription: value })} />
<ImagePicker label="OpenGraph Image" value={seo.socialImage} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => onChange({ ...seo, socialImage: value })} />
<TextInput label="Canonical URL" value={seo.canonicalUrl} onChange={(value) => onChange({ ...seo, canonicalUrl: value })} />
</div>
);
}
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) { function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) { function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service)); onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
@ -165,14 +204,16 @@ function ServicesEditor({ services, onChange }: { services: ServiceContent[]; on
export default function ContentManager({ initialContent, mediaFiles }: Props) { export default function ContentManager({ initialContent, mediaFiles }: Props) {
const [content, setContent] = useState(initialContent); const [content, setContent] = useState(initialContent);
const [activeTab, setActiveTab] = useState<ContentTabId>("home"); const [activeTab, setActiveTab] = useState<ContentTabId>("home");
const [seoPage, setSeoPage] = useState<SeoPageKey>("home");
const [viewport, setViewport] = useState<Viewport>("desktop"); const [viewport, setViewport] = useState<Viewport>("desktop");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const active = tabs.find((tab) => tab.id === activeTab)?.key ?? "home"; const active = contentKeyForTab(activeTab, seoPage);
const current = content[active]; const current = content[active];
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? active, [active, activeTab]); const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]);
const pagePreview = "title" in current ? current : content.home;
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) { function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
setContent((previous) => ({ ...previous, [key]: value })); setContent((previous) => ({ ...previous, [key]: value }));
@ -190,17 +231,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
body: JSON.stringify({ key: active, content: current }), body: JSON.stringify({ key: active, content: current }),
}); });
const result = await response.json() as ApiResponse<typeof active>; const result = await response.json() as ApiResponse<typeof active>;
if (!response.ok || !result.content) throw new Error(result.message ?? "Inhalt konnte nicht gespeichert werden."); if (!response.ok || !result.content) throw new Error("Inhalt konnte nicht gespeichert werden.");
update(active, result.content); update(active, result.content);
setMessage(result.message ?? "Inhalt gespeichert."); setMessage("Inhalt wurde gespeichert.");
} catch (err) { } catch {
setError(err instanceof Error ? err.message : "Inhalt konnte nicht gespeichert werden."); setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.");
} finally { } finally {
setPending(false); setPending(false);
} }
} }
const pagePreview = "title" in current ? current : content.home; const heroBackground = content.home.heroImage || "/workbench-signal.svg";
return ( return (
<div className="content-manager"> <div className="content-manager">
@ -208,8 +249,8 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
{error && <p className="error admin-message">{error}</p>} {error && <p className="error admin-message">{error}</p>}
<section className="admin-card content-editor-layout"> <section className="admin-card content-editor-layout">
<aside className="content-tabs" aria-label="Website-Inhalte"> <aside className="content-tabs" aria-label="Website-Inhalte">
{tabs.map((tab, index) => ( {tabs.map((tab) => (
<button key={`${tab.label}-${index}`} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}> <button key={tab.id} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
{tab.label} {tab.label}
</button> </button>
))} ))}
@ -220,15 +261,14 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
<p className="eyebrow">Editor</p> <p className="eyebrow">Editor</p>
<h2>{activeLabel}</h2> <h2>{activeLabel}</h2>
</div> </div>
<button className="button" type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button> <SaveActions pending={pending} onSave={save} />
</div> </div>
{"title" in current && ( {activeTab === "home" && (
<PageFields page={current} mediaFiles={mediaFiles} onChange={(value) => update(active, value as ContentDocuments[typeof active])} />
)}
{active === "home" && (
<> <>
<PageFields page={content.home} onChange={(value) => update("home", { ...content.home, ...value })} />
<SectionTitle>Medien</SectionTitle>
<ImagePicker label="Hero-Bild" value={content.home.heroImage} mediaFiles={mediaFiles} fallback="/workbench-signal.svg" onChange={(value) => update("home", { ...content.home, heroImage: value })} />
<SectionTitle>Homepage Builder</SectionTitle> <SectionTitle>Homepage Builder</SectionTitle>
<TextArea label="Badges, eine Zeile pro Eintrag" value={linesToText(content.home.badges)} onChange={(value) => update("home", { ...content.home, badges: textToLines(value) })} /> <TextArea label="Badges, eine Zeile pro Eintrag" value={linesToText(content.home.badges)} onChange={(value) => update("home", { ...content.home, badges: textToLines(value) })} />
<h4>3 Featureboxen</h4> <h4>3 Featureboxen</h4>
@ -243,15 +283,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
</> </>
)} )}
{active === "services" && ( {activeTab === "services" && (
<> <>
<PageFields page={content.services} onChange={(value) => update("services", { ...content.services, ...value })} />
<SectionTitle>Leistungen</SectionTitle> <SectionTitle>Leistungen</SectionTitle>
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} /> <ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
</> </>
)} )}
{active === "radio-service" && ( {activeTab === "radio-service" && (
<> <>
<PageFields page={content["radio-service"]} onChange={(value) => update("radio-service", { ...content["radio-service"], ...value })} />
<SectionTitle>Funkgeräte-Service</SectionTitle> <SectionTitle>Funkgeräte-Service</SectionTitle>
<TextArea label="Marken" value={linesToText(content["radio-service"].brands)} onChange={(value) => update("radio-service", { ...content["radio-service"], brands: textToLines(value) })} /> <TextArea label="Marken" value={linesToText(content["radio-service"].brands)} onChange={(value) => update("radio-service", { ...content["radio-service"], brands: textToLines(value) })} />
<TextArea label="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} /> <TextArea label="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} />
@ -262,17 +304,19 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
</> </>
)} )}
{active === "about" && ( {activeTab === "about" && (
<> <>
<SectionTitle>Über Uns</SectionTitle> <PageFields page={content.about} onChange={(value) => update("about", { ...content.about, ...value })} />
<SectionTitle>Über uns</SectionTitle>
<TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} /> <TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} />
<TextArea label="Werkstattbeschreibung" value={content.about.workshopDescription} onChange={(value) => update("about", { ...content.about, workshopDescription: value })} /> <TextArea label="Werkstattbeschreibung" value={content.about.workshopDescription} onChange={(value) => update("about", { ...content.about, workshopDescription: value })} />
<TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} /> <TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} />
</> </>
)} )}
{active === "contact" && ( {activeTab === "contact" && (
<> <>
<PageFields page={content.contact} onChange={(value) => update("contact", { ...content.contact, ...value })} />
<SectionTitle>Kontaktinformationen</SectionTitle> <SectionTitle>Kontaktinformationen</SectionTitle>
<div className="content-field-grid"> <div className="content-field-grid">
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} /> <TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
@ -286,50 +330,49 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
{activeTab === "footer" && ( {activeTab === "footer" && (
<> <>
<SectionTitle>Footer und Einstellungen</SectionTitle> <SectionTitle>Footer und Header</SectionTitle>
<div className="content-field-grid">
<ImageSelect label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: value })} />
</div>
<TextArea label="Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
</>
)}
{activeTab === "global-seo" && (
<>
<SectionTitle>Globale SEO</SectionTitle>
<div className="content-field-grid">
<TextInput label="Meta Title" value={content.settings.seo.metaTitle} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, metaTitle: value } })} />
<TextInput label="Meta Description" value={content.settings.seo.metaDescription} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, metaDescription: value } })} />
<ImageSelect label="Social Image" value={content.settings.seo.socialImage} mediaFiles={mediaFiles} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, socialImage: value } })} />
</div>
</>
)}
{activeTab === "general-settings" && (
<>
<SectionTitle>Allgemeine Einstellungen</SectionTitle>
<div className="content-field-grid"> <div className="content-field-grid">
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} /> <TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
<ImagePicker label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: value })} />
<TextInput label="Header CTA Text" value={content.settings.headerCta.label} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "label", value) })} /> <TextInput label="Header CTA Text" value={content.settings.headerCta.label} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "label", value) })} />
<TextInput label="Header CTA Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} /> <TextInput label="Header CTA Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} />
</div> </div>
<TextArea label="Footer-Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
</> </>
)} )}
{activeTab === "seo" && (
<>
<SectionTitle>SEO pro Seite</SectionTitle>
<label>
Seite
<select value={seoPage} onChange={(event) => setSeoPage(event.target.value as SeoPageKey)}>
{seoPages.map((page) => <option key={page.key} value={page.key}>{page.label}</option>)}
</select>
</label>
<SeoFields seo={content[seoPage].seo} mediaFiles={mediaFiles} onChange={(seo) => update(seoPage, { ...content[seoPage], seo })} />
</>
)}
<SaveActions pending={pending} onSave={save} />
</div> </div>
</section> </section>
<section className="admin-card content-preview-card"> <section className="admin-card content-preview-card">
<div className="content-preview-toolbar"> <div className="content-preview-toolbar">
<h2>Live Preview</h2> <div>
<p className="eyebrow">Vorschau</p>
<h2>Live Preview</h2>
</div>
<div> <div>
{(["desktop", "tablet", "mobile"] as const).map((item) => ( {(["desktop", "tablet", "mobile"] as const).map((item) => (
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button> <button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
))} ))}
</div> </div>
</div> </div>
<div className={`content-preview ${viewport}`}> <div className={`content-preview ${viewport}`} style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), url("${activeTab === "home" ? heroBackground : pagePreview.heroImage || "/workbench-signal.svg"}")` }}>
<p className="eyebrow">{pagePreview.eyebrow}</p> <p className="eyebrow">{activeTab === "seo" ? content[seoPage].seo.metaTitle || pagePreview.eyebrow : pagePreview.eyebrow}</p>
<h1>{pagePreview.title}</h1> <h1>{pagePreview.title}</h1>
<p className="lead">{pagePreview.subtitle}</p> <p className="lead">{pagePreview.subtitle}</p>
<p>{pagePreview.heroText}</p> <p>{pagePreview.heroText}</p>

View file

@ -0,0 +1,153 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type EstimateAction = "approve" | "decline" | "question";
type EstimateActionsProps = {
token: string;
};
type ActionResponse = {
message?: string;
};
const actionLabels: Record<EstimateAction, string> = {
approve: "Freigeben",
decline: "Ablehnen",
question: "Rückfrage senden",
};
async function readActionMessage(response: Response) {
try {
const body = await response.json() as ActionResponse;
return body.message || "Die Aktion konnte nicht abgeschlossen werden.";
} catch {
return "Die Aktion konnte nicht abgeschlossen werden.";
}
}
export function EstimateActions({ token }: EstimateActionsProps) {
const router = useRouter();
const [activeForm, setActiveForm] = useState<Exclude<EstimateAction, "approve"> | null>(null);
const [message, setMessage] = useState("");
const [pendingAction, setPendingAction] = useState<EstimateAction | null>(null);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
async function submitAction(action: EstimateAction, actionMessage = "") {
setPendingAction(action);
setFeedback(null);
try {
const response = await fetch(`/api/status/${encodeURIComponent(token)}/estimate/${action}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: actionMessage.trim() || null }),
});
const responseMessage = await readActionMessage(response);
if (!response.ok) {
setFeedback({ type: "error", text: responseMessage });
return;
}
setMessage("");
setActiveForm(null);
setFeedback({ type: "success", text: responseMessage });
router.refresh();
} catch {
setFeedback({
type: "error",
text: "Die Aktion konnte momentan nicht gesendet werden. Bitte versuchen Sie es erneut.",
});
} finally {
setPendingAction(null);
}
}
const isPending = pendingAction !== null;
return (
<div className="estimate-actions-panel">
<div className="estimate-action-buttons">
<button
className="button"
disabled={isPending}
type="button"
onClick={() => void submitAction("approve")}
>
{pendingAction === "approve" ? "Wird freigegeben..." : actionLabels.approve}
</button>
<button
className="button light"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm("question");
setFeedback(null);
}}
>
Rückfrage
</button>
<button
className="button danger"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm("decline");
setFeedback(null);
}}
>
Ablehnen
</button>
</div>
{activeForm && (
<form
className="estimate-action-form"
onSubmit={(event) => {
event.preventDefault();
void submitAction(activeForm, message);
}}
>
<label htmlFor={`estimate-${activeForm}-message`}>
{activeForm === "decline" ? "Nachricht zur Ablehnung" : "Ihre Rückfrage"}
</label>
<textarea
id={`estimate-${activeForm}-message`}
name="message"
rows={4}
value={message}
placeholder={activeForm === "decline" ? "Optionaler Hinweis für uns" : "Ihre Frage zum Kostenvoranschlag"}
onChange={(event) => setMessage(event.currentTarget.value)}
/>
<div className="estimate-form-actions">
<button className="button" disabled={isPending} type="submit">
{pendingAction === activeForm ? "Wird gesendet..." : actionLabels[activeForm]}
</button>
<button
className="button light"
disabled={isPending}
type="button"
onClick={() => {
setActiveForm(null);
setMessage("");
}}
>
Abbrechen
</button>
</div>
</form>
)}
{feedback && (
<p className={`estimate-feedback ${feedback.type === "error" ? "error" : "success"}`} role="status">
{feedback.text}
</p>
)}
</div>
);
}

View file

@ -8,13 +8,15 @@ services:
- "3010:3010" - "3010:3010"
environment: environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010}
NEXT_PUBLIC_APP_VERSION: "0.4.0" NEXT_PUBLIC_APP_VERSION: "0.4.1"
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-} OLYMPUS_INTAKE_API_URL: ${OLYMPUS_INTAKE_API_URL:-}
OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-} OLYMPUS_INTAKE_API_TOKEN: ${OLYMPUS_INTAKE_API_TOKEN:-}
OLYMPUS_PUBLIC_STATUS_API_URL: ${OLYMPUS_PUBLIC_STATUS_API_URL:-}
OLYMPUS_PUBLIC_STATUS_API_TOKEN: ${OLYMPUS_PUBLIC_STATUS_API_TOKEN:-}
DOCKER_ENV: "true" DOCKER_ENV: "true"
volumes: volumes:
- funktechnik-data:/app/data - funktechnik-data:/app/data

View file

@ -20,6 +20,7 @@ export const defaultContent: ContentDocuments = {
title: "Funktechnik Schubert", title: "Funktechnik Schubert",
subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.", subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.",
heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.", heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.",
heroImage: "/workbench-signal.svg",
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.", intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
paragraphs: [], paragraphs: [],
cta: { cta: {
@ -62,6 +63,7 @@ export const defaultContent: ContentDocuments = {
title: "Service für Funkgeräte und Kommunikationstechnik", title: "Service für Funkgeräte und Kommunikationstechnik",
subtitle: "Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.", subtitle: "Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.",
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.", heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
heroImage: "/workbench-signal.svg",
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.", intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
paragraphs: [], paragraphs: [],
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } }, cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
@ -83,6 +85,7 @@ export const defaultContent: ContentDocuments = {
title: "Prüfung, Diagnose und Abgleich", title: "Prüfung, Diagnose und Abgleich",
subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich.", subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich.",
heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.", heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.",
heroImage: "/workbench-signal.svg",
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.", intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
paragraphs: [], paragraphs: [],
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } }, cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
@ -105,11 +108,35 @@ export const defaultContent: ContentDocuments = {
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"), seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
updatedAt: now, updatedAt: now,
}, },
repair: {
eyebrow: "Reparaturannahme",
title: "Reparatur anfragen",
subtitle: "Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten.",
heroText: "Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.",
heroImage: "/workbench-signal.svg",
intro: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.",
paragraphs: [
{ title: "Vor der Einsendung", text: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen." },
],
cta: { text: "Reparatur vorbereiten", primary: { label: "Formular ausfüllen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
listTitle: "Wichtige Angaben",
list: [
"Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden",
"Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung",
"Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben",
"Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen",
"Fotos können später nach Rückmeldung ergänzt werden",
],
faq: [],
seo: seo("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"),
updatedAt: now,
},
about: { about: {
eyebrow: "Über uns", eyebrow: "Über uns",
title: "Technik verstehen, Fehler nachvollziehbar lösen", title: "Technik verstehen, Fehler nachvollziehbar lösen",
subtitle: "Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.", subtitle: "Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.",
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.", heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
heroImage: "/workbench-signal.svg",
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.", intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
paragraphs: [ paragraphs: [
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." }, { title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
@ -130,6 +157,7 @@ export const defaultContent: ContentDocuments = {
title: "Kontakt aufnehmen", title: "Kontakt aufnehmen",
subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.", subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.",
heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.", heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.",
heroImage: "/workbench-signal.svg",
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.", intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
paragraphs: [], paragraphs: [],
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } }, cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
@ -170,6 +198,7 @@ export const contentFileNames = {
home: "home.json", home: "home.json",
services: "services.json", services: "services.json",
"radio-service": "radio-service.json", "radio-service": "radio-service.json",
repair: "repair.json",
about: "about.json", about: "about.json",
contact: "contact.json", contact: "contact.json",
settings: "settings.json", settings: "settings.json",

View file

@ -4,11 +4,11 @@ import path from "path";
import { storageDirectory } from "@/lib/runtime/config"; import { storageDirectory } from "@/lib/runtime/config";
import { listMediaFiles } from "@/lib/admin/media"; import { listMediaFiles } from "@/lib/admin/media";
import { contentFileNames, defaultContent } from "./defaults"; import { contentFileNames, defaultContent } from "./defaults";
import type { ContentDocuments, ContentKey, ContentSummary } from "./types"; import type { ContentBackup, ContentDocuments, ContentKey, ContentSummary } from "./types";
export const contentDirectory = path.join(storageDirectory, "content"); export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups"); export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const contentVersion = "0.4.0"; export const contentVersion = "0.4.1";
const contentKeys = Object.keys(contentFileNames) as ContentKey[]; const contentKeys = Object.keys(contentFileNames) as ContentKey[];
@ -51,7 +51,7 @@ function filePathFor(key: ContentKey) {
function backupStamp() { function backupStamp() {
const date = new Date(); const date = new Date();
const pad = (value: number) => String(value).padStart(2, "0"); const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`; return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
} }
async function pruneBackups(key: ContentKey) { async function pruneBackups(key: ContentKey) {
@ -144,10 +144,33 @@ export async function getContentSummary(): Promise<ContentSummary> {
}; };
} }
export async function listContentBackups(limit = 20): Promise<ContentBackup[]> {
await ensureContentStorage();
const entries = await readdir(contentBackupDirectory).catch(() => []);
const backups = await Promise.all(entries
.filter((entry) => entry.endsWith(".json"))
.map(async (entry) => {
const page = entry.split(".")[0] as ContentKey;
const fullPath = path.join(contentBackupDirectory, entry);
const fileStat = await stat(fullPath);
return {
page,
fileName: entry,
createdAt: fileStat.mtime.toISOString(),
};
}));
return backups
.filter((backup) => contentKeys.includes(backup.page))
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, limit);
}
export async function getContentSystemStatus() { export async function getContentSystemStatus() {
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]); const [summary, media, backups] = await Promise.all([getContentSummary(), listMediaFiles(), listContentBackups()]);
return { return {
...summary, ...summary,
mediaCount: media.length, mediaCount: media.length,
backups,
}; };
} }

View file

@ -36,6 +36,7 @@ export type PageContent = {
title: string; title: string;
subtitle: string; subtitle: string;
heroText: string; heroText: string;
heroImage: string;
intro: string; intro: string;
paragraphs: TextBlockContent[]; paragraphs: TextBlockContent[];
cta: { cta: {
@ -92,6 +93,8 @@ export type ContactContent = PageContent & {
socialMedia: LinkContent[]; socialMedia: LinkContent[];
}; };
export type RepairContent = PageContent;
export type SettingsContent = { export type SettingsContent = {
siteName: string; siteName: string;
logoUrl: string; logoUrl: string;
@ -108,6 +111,7 @@ export type ContentDocuments = {
home: HomeContent; home: HomeContent;
services: ServicesContent; services: ServicesContent;
"radio-service": RadioServiceContent; "radio-service": RadioServiceContent;
repair: RepairContent;
about: AboutContent; about: AboutContent;
contact: ContactContent; contact: ContactContent;
settings: SettingsContent; settings: SettingsContent;
@ -126,3 +130,9 @@ export type ContentSummary = {
updatedAt: string; updatedAt: string;
}>; }>;
}; };
export type ContentBackup = {
page: ContentKey;
fileName: string;
createdAt: string;
};

145
lib/olympus/status.ts Normal file
View file

@ -0,0 +1,145 @@
import "server-only";
export type OlympusRepairStatusHistoryItem = {
status: string;
status_label: string;
created_at: string;
};
export type OlympusEstimateItem = {
item_type: string;
title: string;
description: string | null;
quantity: string | number;
unit: string;
unit_price_cents: number;
total_cents: number;
};
export type OlympusEstimate = {
estimate_number: string;
status: "draft" | "sent" | "approved" | "declined" | "expired" | "cancelled" | string;
title: string;
customer_message: string | null;
subtotal_cents: number;
tax_cents: number;
total_cents: number;
currency: string;
valid_until: string | null;
items: OlympusEstimateItem[];
};
export type OlympusRepairStatus = {
repair_number: string;
public_status_label: string;
device_manufacturer: string;
device_model: string;
status_history_public: OlympusRepairStatusHistoryItem[];
updated_at: string;
estimate?: OlympusEstimate | null;
};
export type RepairStatusResult =
| { ok: true; status: OlympusRepairStatus }
| { ok: false; reason: "invalid" | "unavailable" };
export type EstimateAction = "approve" | "decline" | "question";
export type EstimateActionResult =
| { ok: true }
| { ok: false; reason: "invalid" | "unavailable" | "failed" };
function getStatusApiBaseUrl() {
return process.env.OLYMPUS_PUBLIC_STATUS_API_URL?.replace(/\/$/, "") ?? "";
}
function getStatusApiToken() {
return process.env.OLYMPUS_PUBLIC_STATUS_API_TOKEN ?? "";
}
export async function fetchOlympusRepairStatus(token: string): Promise<RepairStatusResult> {
const baseUrl = getStatusApiBaseUrl();
if (!baseUrl || !token.trim()) {
return { ok: false, reason: "unavailable" };
}
const headers: HeadersInit = {
Accept: "application/json",
};
const apiToken = getStatusApiToken();
if (apiToken) {
headers.Authorization = `Bearer ${apiToken}`;
}
let response: Response;
try {
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}`, {
headers,
cache: "no-store",
});
} catch {
return { ok: false, reason: "unavailable" };
}
if (response.status === 401 || response.status === 403 || response.status === 404) {
return { ok: false, reason: "invalid" };
}
if (!response.ok) {
return { ok: false, reason: "unavailable" };
}
try {
return { ok: true, status: await response.json() as OlympusRepairStatus };
} catch {
return { ok: false, reason: "unavailable" };
}
}
export async function submitOlympusEstimateAction(
token: string,
action: EstimateAction,
message?: string,
): Promise<EstimateActionResult> {
const baseUrl = getStatusApiBaseUrl();
if (!baseUrl || !token.trim()) {
return { ok: false, reason: "unavailable" };
}
const headers: HeadersInit = {
Accept: "application/json",
"Content-Type": "application/json",
};
const apiToken = getStatusApiToken();
if (apiToken) {
headers.Authorization = `Bearer ${apiToken}`;
}
let response: Response;
try {
response = await fetch(`${baseUrl}/public/repairs/status/${encodeURIComponent(token)}/estimate/${action}`, {
method: "POST",
headers,
body: JSON.stringify({ message: message?.trim() || null }),
cache: "no-store",
});
} catch {
return { ok: false, reason: "unavailable" };
}
if (response.status === 401 || response.status === 403 || response.status === 404) {
return { ok: false, reason: "invalid" };
}
if (!response.ok) {
return { ok: false, reason: "failed" };
}
return { ok: true };
}

View file

@ -1 +1 @@
export const appVersion = "0.4.0"; export const appVersion = "0.4.1";

40
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"next": "16.2.10", "next": "16.2.10",
@ -529,7 +529,7 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -551,7 +551,7 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -760,7 +760,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -785,7 +785,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -810,7 +810,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -835,7 +835,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -860,7 +860,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -885,7 +885,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -910,7 +910,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -935,7 +935,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -957,7 +957,7 @@
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.7.0"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -976,7 +976,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -995,7 +995,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -1014,7 +1014,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -2711,9 +2711,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.385", "version": "1.5.387",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
"integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -5313,7 +5313,7 @@
"semver": "^7.7.3" "semver": "^7.7.3"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.4.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"

View file

@ -1,6 +1,6 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.4.0", "version": "0.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3010", "dev": "next dev -p 3010",

View file

@ -56,5 +56,5 @@ try {
fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
} }
process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.4.0"}.\n`); process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.4.1"}.\n`);
require("./server.js"); require("./server.js");