Compare commits
3
Commits
ae2726b0ee
...
a4581d3467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4581d3467 | ||
|
|
52ce37e216 | ||
|
|
4f442ec06d |
No files matched your search
@@ -72,6 +72,14 @@ const nextConfig: NextConfig = {
|
|||||||
turbopackFileSystemCacheForBuild: false,
|
turbopackFileSystemCacheForBuild: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Das Favicon ist im Admin austauschbar und wird von app/icon.tsx erzeugt;
|
||||||
|
// eine statische app/favicon.ico gibt es deshalb nicht mehr. Browser und
|
||||||
|
// Crawler, die ohne <link rel="icon"> direkt /favicon.ico abfragen,
|
||||||
|
// bekommen dasselbe Bild.
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/favicon.ico", destination: "/icon" }];
|
||||||
|
},
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
||||||
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export default async function AdminInhaltePage() {
|
|||||||
const current = await loadCurrentValues();
|
const current = await loadCurrentValues();
|
||||||
|
|
||||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||||
|
{ key: "brand", heading: t.adminContentPage.groupBrand },
|
||||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||||
{ key: "shipping", heading: t.adminContentPage.groupShipping },
|
{ key: "shipping", heading: t.adminContentPage.groupShipping },
|
||||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { logoutAction } from "@/app/konto/actions";
|
|||||||
import { AdminShell } from "@/components/admin-shell";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getBrandAssets } from "@/lib/brand";
|
||||||
|
|
||||||
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
||||||
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
||||||
@@ -47,6 +48,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
|
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
|
||||||
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
|
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
|
||||||
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
|
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
|
||||||
|
const brand = await getBrandAssets();
|
||||||
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
|
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||||
@@ -59,6 +61,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||||
|
logoSrc={brand.logoInverted}
|
||||||
badges={{
|
badges={{
|
||||||
"/admin/bestellungen": openOrders,
|
"/admin/bestellungen": openOrders,
|
||||||
"/admin/retouren": openReturns,
|
"/admin/retouren": openReturns,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
type EntraIdTokenClaims,
|
type EntraIdTokenClaims,
|
||||||
} from "@/lib/entra";
|
} from "@/lib/entra";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
const CALLBACK_ATTEMPTS_PER_WINDOW = 20;
|
const CALLBACK_ATTEMPTS_PER_WINDOW = 20;
|
||||||
const CALLBACK_WINDOW_MS = 15 * 60 * 1000;
|
const CALLBACK_WINDOW_MS = 15 * 60 * 1000;
|
||||||
@@ -48,10 +48,9 @@ function lastNameFromClaims(claims: EntraIdTokenClaims): string {
|
|||||||
* ausgeben und ein fremdes Konto uebernehmen/anlegen.
|
* ausgeben und ein fremdes Konto uebernehmen/anlegen.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
function redirectWithError(message: string): NextResponse {
|
function redirectWithError(message: string): NextResponse {
|
||||||
return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
|
return withFlash(redirectWithinSite("/konto"), "error", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
@@ -160,9 +159,9 @@ export async function GET(request: Request) {
|
|||||||
if (customer.role === "ADMIN" && customer.twoFactorEnabled) {
|
if (customer.role === "ADMIN" && customer.twoFactorEnabled) {
|
||||||
const challengeToken = await signTwoFactorChallenge(customer.id);
|
const challengeToken = await signTwoFactorChallenge(customer.id);
|
||||||
await setTwoFactorChallengeCookie(challengeToken);
|
await setTwoFactorChallengeCookie(challengeToken);
|
||||||
return NextResponse.redirect(new URL("/konto/zwei-faktor-code", baseUrl));
|
return redirectWithinSite("/konto/zwei-faktor-code");
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||||
return NextResponse.redirect(new URL("/konto", baseUrl));
|
return redirectWithinSite("/konto");
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { setEntraOAuthCookie } from "@/lib/auth";
|
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||||
import { signEntraOAuthState } from "@/lib/session";
|
import { signEntraOAuthState } from "@/lib/session";
|
||||||
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
|
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
|
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
|
||||||
@@ -14,7 +14,7 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!isEntraConfigured()) {
|
if (!isEntraConfigured()) {
|
||||||
return withFlash(
|
return withFlash(
|
||||||
NextResponse.redirect(new URL("/konto", getBaseUrl())),
|
redirectWithinSite("/konto"),
|
||||||
"error",
|
"error",
|
||||||
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { withFlash } from "@/lib/flash-core";
|
import { withFlash } from "@/lib/flash-core";
|
||||||
import { NOTICES, isNoticeCode } from "@/lib/notice";
|
import { NOTICES, isNoticeCode } from "@/lib/notice";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
|
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
|
||||||
@@ -10,8 +9,7 @@ import { getBaseUrl } from "@/lib/url";
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const code = new URL(request.url).searchParams.get("code");
|
const code = new URL(request.url).searchParams.get("code");
|
||||||
const baseUrl = getBaseUrl();
|
if (!isNoticeCode(code)) return redirectWithinSite("/");
|
||||||
if (!isNoticeCode(code)) return NextResponse.redirect(new URL("/", baseUrl));
|
|
||||||
const notice = NOTICES[code];
|
const notice = NOTICES[code];
|
||||||
return withFlash(NextResponse.redirect(new URL(notice.to, baseUrl)), "error", await notice.message());
|
return withFlash(redirectWithinSite(notice.to), "error", await notice.message());
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { hashVerificationToken } from "@/lib/email-verification";
|
import { hashVerificationToken } from "@/lib/email-verification";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { withFlash } from "@/lib/flash";
|
import { withFlash } from "@/lib/flash";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
const fail = (message: string) => withFlash(redirectWithinSite("/konto"), "error", message);
|
||||||
const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||||
@@ -35,7 +33,7 @@ export async function GET(request: Request) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return withFlash(
|
return withFlash(
|
||||||
NextResponse.redirect(`${baseUrl}/konto`),
|
redirectWithinSite("/konto"),
|
||||||
"success",
|
"success",
|
||||||
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
|
||||||
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
||||||
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.newsletterSubscriber.update({
|
await prisma.newsletterSubscriber.update({
|
||||||
@@ -22,5 +20,5 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
await ensureNewsletterDiscountCode();
|
await ensureNewsletterDiscountCode();
|
||||||
|
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=ok`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=ok");
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getBaseUrl } from "@/lib/url";
|
import { redirectWithinSite } from "@/lib/redirect";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const token = new URL(request.url).searchParams.get("token");
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.newsletterSubscriber.update({
|
await prisma.newsletterSubscriber.update({
|
||||||
@@ -20,5 +18,5 @@ export async function GET(request: Request) {
|
|||||||
data: { unsubscribedAt: new Date() },
|
data: { unsubscribedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=unsubscribed`);
|
return redirectWithinSite("/newsletter-bestaetigt?status=unsubscribed");
|
||||||
}
|
}
|
||||||
+15
-33
@@ -1,41 +1,23 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { getBrandAssets } from "@/lib/brand";
|
||||||
|
import { renderBrandIcon } from "@/lib/brand-icon";
|
||||||
|
|
||||||
export const size = { width: 180, height: 180 };
|
export const size = { width: 180, height: 180 };
|
||||||
export const contentType = "image/png";
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
// Wie app/icon.tsx: im Admin austauschbar, deshalb nicht zur Build-Zeit festschreiben.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apple-Touch-Icon fuer "Zum Home-Bildschirm hinzufuegen" auf iOS/iPadOS —
|
* Apple-Touch-Icon fuer "Zum Home-Bildschirm hinzufuegen" auf iOS/iPadOS —
|
||||||
* Apple akzeptiert dafuer ausschliesslich PNG/JPG, kein SVG (anders als
|
* Apple akzeptiert dafuer ausschliesslich PNG/JPG, kein SVG. Ohne diese
|
||||||
* app/icon.svg, das moderne Chrome/Android direkt lesen, siehe
|
* Datei nimmt iOS ersatzweise einen Screenshot der Seite als Icon. Gleiche
|
||||||
* app/manifest.ts). Ohne diese Datei nimmt iOS ersatzweise einen
|
* Quelle wie das Favicon, aber auf Creme-Grund: iOS stellt transparente
|
||||||
* Screenshot der Seite als Icon — unbrandet und meist unbrauchbar. Gleiche
|
* Flaechen schwarz dar.
|
||||||
* Grafik wie app/icon.svg, per ImageResponse als Raster gerendert.
|
|
||||||
*/
|
*/
|
||||||
export default function AppleIcon() {
|
export default async function AppleIcon(): Promise<Response> {
|
||||||
return new ImageResponse(
|
const { favicon } = await getBrandAssets();
|
||||||
(
|
const png = await renderBrandIcon(favicon, size.width, "#FAF8F3");
|
||||||
<svg width={180} height={180} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
return new Response(new Uint8Array(png), {
|
||||||
<rect width={100} height={100} rx={20} fill="#24352B" />
|
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
|
||||||
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" strokeWidth={4} />
|
});
|
||||||
<g fill="#F4EFE5">
|
|
||||||
<g transform="translate(26,42) scale(0.62)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x={8} y={22} width={4} height={4} />
|
|
||||||
</g>
|
|
||||||
<g transform="translate(40,30) scale(0.82)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x={8} y={22} width={4} height={4} />
|
|
||||||
</g>
|
|
||||||
<g transform="translate(56,42) scale(0.62)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x={8} y={22} width={4} height={4} />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
{ ...size }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<rect width="100" height="100" rx="20" fill="#24352B" />
|
|
||||||
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" stroke-width="4" />
|
|
||||||
<g fill="#F4EFE5">
|
|
||||||
<g transform="translate(26,42) scale(0.62)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x="8" y="22" width="4" height="4" />
|
|
||||||
</g>
|
|
||||||
<g transform="translate(40,30) scale(0.82)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x="8" y="22" width="4" height="4" />
|
|
||||||
</g>
|
|
||||||
<g transform="translate(56,42) scale(0.62)">
|
|
||||||
<polygon points="10,2 5,13 15,13" />
|
|
||||||
<polygon points="10,10 2,22 18,22" />
|
|
||||||
<rect x="8" y="22" width="4" height="4" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 844 B |
@@ -0,0 +1,24 @@
|
|||||||
|
import { getBrandAssets } from "@/lib/brand";
|
||||||
|
import { renderBrandIcon } from "@/lib/brand-icon";
|
||||||
|
|
||||||
|
export const size = { width: 512, height: 512 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
// Das Favicon ist im Admin austauschbar (Inhalte > Logo und Favicon) — nicht
|
||||||
|
// zur Build-Zeit einfrieren. "no-cache" statt langem Caching: Browser halten
|
||||||
|
// Favicons sonst tagelang fest, ein neues Bild waere nicht zu sehen.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favicon und Manifest-Icon. 512 px, weil dieselbe Datei auch als Android-
|
||||||
|
* Startbildschirm-Icon dient (app/manifest.ts); Browser skalieren fuer den
|
||||||
|
* Tab selbst herunter. /favicon.ico wird in next.config.ts hierher
|
||||||
|
* umgeleitet.
|
||||||
|
*/
|
||||||
|
export default async function Icon(): Promise<Response> {
|
||||||
|
const { favicon } = await getBrandAssets();
|
||||||
|
const png = await renderBrandIcon(favicon, size.width);
|
||||||
|
return new Response(new Uint8Array(png), {
|
||||||
|
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
+9
-3
@@ -14,6 +14,7 @@ import { getSocialLinks } from "@/lib/social-links";
|
|||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||||
|
import { getBrandAssets } from "@/lib/brand";
|
||||||
|
|
||||||
const cormorant = Cormorant_Garamond({
|
const cormorant = Cormorant_Garamond({
|
||||||
variable: "--font-cormorant",
|
variable: "--font-cormorant",
|
||||||
@@ -47,7 +48,12 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const dict = getDictionary(locale);
|
const dict = getDictionary(locale);
|
||||||
const [company, socialLinks, flash] = await Promise.all([getCompanyInfo(), getSocialLinks(), readFlash()]);
|
const [company, socialLinks, flash, brand] = await Promise.all([
|
||||||
|
getCompanyInfo(),
|
||||||
|
getSocialLinks(),
|
||||||
|
readFlash(),
|
||||||
|
getBrandAssets(),
|
||||||
|
]);
|
||||||
const base = getSiteUrl();
|
const base = getSiteUrl();
|
||||||
|
|
||||||
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
||||||
@@ -131,13 +137,13 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
{dict.nav.skipToContent}
|
{dict.nav.skipToContent}
|
||||||
</a>
|
</a>
|
||||||
<HideOnAdmin>
|
<HideOnAdmin>
|
||||||
<Header companyName={company.name} />
|
<Header companyName={company.name} logoSrc={brand.logo} logoInvertedSrc={brand.logoInverted} />
|
||||||
</HideOnAdmin>
|
</HideOnAdmin>
|
||||||
<main id="main-content" className="flex-1">
|
<main id="main-content" className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<HideOnAdmin>
|
<HideOnAdmin>
|
||||||
<Footer companyInfo={company} socialLinks={socialLinks} />
|
<Footer companyInfo={company} socialLinks={socialLinks} logoSrc={brand.logoInverted} />
|
||||||
</HideOnAdmin>
|
</HideOnAdmin>
|
||||||
<CartDrawer />
|
<CartDrawer />
|
||||||
<CookieConsentBanner />
|
<CookieConsentBanner />
|
||||||
|
|||||||
+6
-7
@@ -4,10 +4,9 @@ import { getLocale } from "@/i18n/locale";
|
|||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web-App-Manifest fuer "Zum Startbildschirm hinzufuegen" auf Mobilgeraeten —
|
* Web-App-Manifest fuer "Zum Startbildschirm hinzufuegen" auf Mobilgeraeten.
|
||||||
* nutzt das bestehende icon.svg direkt statt zusaetzlich generierter
|
* Icon ist das im Admin austauschbare Favicon (app/icon.tsx, 512 px PNG).
|
||||||
* Raster-Varianten (Chrome/Android unterstuetzen SVG-Manifest-Icons mit
|
* Sprachabhaengig wie der Rest der Seite.
|
||||||
* sizes:"any" nativ, siehe MDN). Sprachabhaengig wie der Rest der Seite.
|
|
||||||
*/
|
*/
|
||||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
@@ -24,9 +23,9 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
|||||||
theme_color: "#24352B",
|
theme_color: "#24352B",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/icon.svg",
|
src: "/icon",
|
||||||
sizes: "any",
|
sizes: "512x512",
|
||||||
type: "image/svg+xml",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ function NavLinks({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Wordmark() {
|
function Wordmark({ logoSrc }: { logoSrc: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Link href="/admin" className="flex items-center gap-2">
|
<Link href="/admin" className="flex items-center gap-2">
|
||||||
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
|
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" src={logoSrc} />
|
||||||
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -175,6 +175,8 @@ function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; lab
|
|||||||
|
|
||||||
interface AdminShellProps {
|
interface AdminShellProps {
|
||||||
adminName: string;
|
adminName: string;
|
||||||
|
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
|
||||||
|
logoSrc?: string | null;
|
||||||
badges?: AdminBadges;
|
badges?: AdminBadges;
|
||||||
logoutAction: () => void | Promise<void>;
|
logoutAction: () => void | Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -188,7 +190,7 @@ interface AdminShellProps {
|
|||||||
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||||
* (components/hide-on-admin.tsx).
|
* (components/hide-on-admin.tsx).
|
||||||
*/
|
*/
|
||||||
export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) {
|
export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const groups = getAdminNavGroups(t);
|
const groups = getAdminNavGroups(t);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -205,6 +207,26 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
|
|||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<div className="px-5 py-4 border-t border-cream/10">
|
<div className="px-5 py-4 border-t border-cream/10">
|
||||||
|
{/* Der Shop-Kopf ist im Admin ausgeblendet (HideOnAdmin) — ohne diesen
|
||||||
|
Link fuehrte kein Weg zurueck auf die Website. */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="group mb-4 inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="transition-transform duration-300 ease-[var(--ease-arrive)] group-hover:-translate-x-0.5 motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
<path d="M15 6l-6 6 6 6" />
|
||||||
|
</svg>
|
||||||
|
<span className="link-draw">{t.adminShell.backToWebsite}</span>
|
||||||
|
</Link>
|
||||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||||
<form action={logoutAction} className="mt-2">
|
<form action={logoutAction} className="mt-2">
|
||||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||||
@@ -219,7 +241,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
|
|||||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||||
<Wordmark />
|
<Wordmark logoSrc={logoSrc} />
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<PaletteTrigger
|
<PaletteTrigger
|
||||||
@@ -236,7 +258,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
||||||
<Wordmark />
|
<Wordmark logoSrc={logoSrc} />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
aria-label={t.adminShell.paletteOpen}
|
aria-label={t.adminShell.paletteOpen}
|
||||||
@@ -274,7 +296,7 @@ export function AdminShell({ adminName, badges = {}, logoutAction, children }: A
|
|||||||
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
|
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
|
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
|
||||||
<Wordmark />
|
<Wordmark logoSrc={logoSrc} />
|
||||||
<button
|
<button
|
||||||
aria-label={t.adminShell.closeMenu}
|
aria-label={t.adminShell.closeMenu}
|
||||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||||
|
|||||||
@@ -189,9 +189,17 @@ export async function Reviews() {
|
|||||||
<div className="mt-14 grid md:grid-cols-3 gap-10">
|
<div className="mt-14 grid md:grid-cols-3 gap-10">
|
||||||
{items.slice(0, 6).map((review, i) => (
|
{items.slice(0, 6).map((review, i) => (
|
||||||
<ScrollReveal key={`${review.author}-${i}`} delay={i * 0.08}>
|
<ScrollReveal key={`${review.author}-${i}`} delay={i * 0.08}>
|
||||||
<span aria-hidden="true" className="block font-serif text-7xl leading-[0.6] text-honey-light/70">
|
{/* Gezeichnet statt als Schriftzeichen: ein grosses "„" sitzt auf
|
||||||
„
|
der Grundlinie und ragte mit enger Zeilenhoehe in die Sterne. */}
|
||||||
</span>
|
<svg
|
||||||
|
viewBox="0 0 40 28"
|
||||||
|
className="mb-4 h-7 w-10 text-honey-light/70"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8.5 2a7.5 7.5 0 0 1 7.5 7.5c0 7.4-4.6 13.9-11.6 16.5l-1-2.2c4.2-2.1 6.7-5.5 7.1-9.3A7.5 7.5 0 1 1 8.5 2z" />
|
||||||
|
<path d="M30 2a7.5 7.5 0 0 1 7.5 7.5c0 7.4-4.6 13.9-11.6 16.5l-1-2.2c4.2-2.1 6.7-5.5 7.1-9.3A7.5 7.5 0 1 1 30 2z" />
|
||||||
|
</svg>
|
||||||
<StarRating value={review.rating} />
|
<StarRating value={review.rating} />
|
||||||
<span className="sr-only">{t.home.reviews.starsOutOf5(review.rating)}</span>
|
<span className="sr-only">{t.home.reviews.starsOutOf5(review.rating)}</span>
|
||||||
<p className="mt-4 font-serif text-xl text-cream/90 leading-relaxed italic">
|
<p className="mt-4 font-serif text-xl text-cream/90 leading-relaxed italic">
|
||||||
|
|||||||
@@ -7,10 +7,30 @@
|
|||||||
export function Logomark({
|
export function Logomark({
|
||||||
className = "h-8 w-8",
|
className = "h-8 w-8",
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
src,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: "default" | "inverted";
|
variant?: "default" | "inverted";
|
||||||
|
/** Im Admin hochgeladenes Logo (lib/brand.ts); ersetzt das gezeichnete Zeichen. */
|
||||||
|
src?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
if (src) {
|
||||||
|
// Rohes <img> statt next/image: das Seitenverhaeltnis eines hochgeladenen
|
||||||
|
// Logos ist unbekannt (quadratisch bis breite Wortmarke). Die Hoehe kommt
|
||||||
|
// aus className, die Breite folgt dem Bild — begrenzt, damit eine sehr
|
||||||
|
// breite Datei den Kopf nicht sprengt. alt="" weil der Firmenname direkt
|
||||||
|
// daneben als Text steht.
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className={`${className} ${className.includes("max-w-") ? "" : "max-w-40"} object-contain`}
|
||||||
|
style={{ width: "auto" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
|
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
|
||||||
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
|
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,11 @@ function useEscapeKey(active: boolean, onEscape: () => void) {
|
|||||||
}, [active, onEscape]);
|
}, [active, onEscape]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Logo({ t, companyName }: { t: Dictionary; companyName: string }) {
|
function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string; logoSrc: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Link href="/" className="flex items-center gap-3 group">
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
<Logomark className="h-9 w-9 flex-shrink-0" />
|
{/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */}
|
||||||
|
<Logomark className="h-9 w-9 max-w-14 flex-shrink-0 sm:max-w-28" src={logoSrc} />
|
||||||
<span className="flex flex-col leading-none">
|
<span className="flex flex-col leading-none">
|
||||||
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
|
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
|
||||||
{companyName}
|
{companyName}
|
||||||
@@ -375,7 +376,15 @@ function isActivePath(pathname: string, href: string): boolean {
|
|||||||
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
|
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ companyName }: { companyName: string }) {
|
export function Header({
|
||||||
|
companyName,
|
||||||
|
logoSrc = null,
|
||||||
|
logoInvertedSrc = null,
|
||||||
|
}: {
|
||||||
|
companyName: string;
|
||||||
|
logoSrc?: string | null;
|
||||||
|
logoInvertedSrc?: string | null;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
@@ -410,7 +419,7 @@ export function Header({ companyName }: { companyName: string }) {
|
|||||||
Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander.
|
Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander.
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
|
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
|
||||||
<Logo t={t} companyName={companyName} />
|
<Logo t={t} companyName={companyName} logoSrc={logoSrc} />
|
||||||
|
|
||||||
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
|
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
|
||||||
{mainNav.map((item) => {
|
{mainNav.map((item) => {
|
||||||
@@ -478,7 +487,7 @@ export function Header({ companyName }: { companyName: string }) {
|
|||||||
>
|
>
|
||||||
<Container className="flex items-center justify-between h-20">
|
<Container className="flex items-center justify-between h-20">
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<Logomark className="h-8 w-8" variant="inverted" />
|
<Logomark className="h-8 w-8" variant="inverted" src={logoInvertedSrc} />
|
||||||
<span className="text-xl tracking-[0.2em] font-serif">
|
<span className="text-xl tracking-[0.2em] font-serif">
|
||||||
{companyName}
|
{companyName}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -89,9 +89,12 @@ function SocialLink({
|
|||||||
export async function Footer({
|
export async function Footer({
|
||||||
companyInfo,
|
companyInfo,
|
||||||
socialLinks,
|
socialLinks,
|
||||||
|
logoSrc = null,
|
||||||
}: {
|
}: {
|
||||||
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
|
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
|
||||||
socialLinks: SocialLinks;
|
socialLinks: SocialLinks;
|
||||||
|
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
|
||||||
|
logoSrc?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
|
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
|
||||||
@@ -123,7 +126,7 @@ export async function Footer({
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Logomark className="h-9 w-9" variant="inverted" />
|
<Logomark className="h-9 w-9" variant="inverted" src={logoSrc} />
|
||||||
<span className="text-2xl tracking-[0.2em] font-serif">
|
<span className="text-2xl tracking-[0.2em] font-serif">
|
||||||
{companyInfo.name}
|
{companyInfo.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1281,6 +1281,7 @@ export const de: Dictionary = {
|
|||||||
sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.",
|
sectionImagesIntro: "Alle Bilder und das Hero-Video, die du hochladen kannst, sortiert nach Seite.",
|
||||||
sectionText: "Texte",
|
sectionText: "Texte",
|
||||||
sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.",
|
sectionTextIntro: "Alle Textinhalte, sortiert nach Bereich.",
|
||||||
|
groupBrand: "Logo und Favicon",
|
||||||
groupCompany: "Firmendaten",
|
groupCompany: "Firmendaten",
|
||||||
groupShipping: "Versand",
|
groupShipping: "Versand",
|
||||||
groupHome: "Homepage",
|
groupHome: "Homepage",
|
||||||
@@ -1326,6 +1327,7 @@ export const de: Dictionary = {
|
|||||||
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
|
uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.",
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
|
backToWebsite: "Zur Website",
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Bestellungen",
|
navOrders: "Bestellungen",
|
||||||
navReturns: "Retouren",
|
navReturns: "Retouren",
|
||||||
|
|||||||
@@ -1267,6 +1267,7 @@ export const en: Dictionary = {
|
|||||||
sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.",
|
sectionImagesIntro: "Every image and the hero video you can upload, grouped by page.",
|
||||||
sectionText: "Text",
|
sectionText: "Text",
|
||||||
sectionTextIntro: "Every text field, grouped by section.",
|
sectionTextIntro: "Every text field, grouped by section.",
|
||||||
|
groupBrand: "Logo and favicon",
|
||||||
groupCompany: "Company details",
|
groupCompany: "Company details",
|
||||||
groupShipping: "Shipping",
|
groupShipping: "Shipping",
|
||||||
groupHome: "Homepage",
|
groupHome: "Homepage",
|
||||||
@@ -1312,6 +1313,7 @@ export const en: Dictionary = {
|
|||||||
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
|
uploadErrorNetwork: "The connection to the server was interrupted during the upload.",
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
|
backToWebsite: "Back to website",
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Orders",
|
navOrders: "Orders",
|
||||||
navReturns: "Returns",
|
navReturns: "Returns",
|
||||||
|
|||||||
@@ -940,6 +940,7 @@ export interface Dictionary {
|
|||||||
sectionImagesIntro: string;
|
sectionImagesIntro: string;
|
||||||
sectionText: string;
|
sectionText: string;
|
||||||
sectionTextIntro: string;
|
sectionTextIntro: string;
|
||||||
|
groupBrand: string;
|
||||||
groupCompany: string;
|
groupCompany: string;
|
||||||
groupShipping: string;
|
groupShipping: string;
|
||||||
groupHome: string;
|
groupHome: string;
|
||||||
@@ -981,6 +982,7 @@ export interface Dictionary {
|
|||||||
uploadErrorNetwork: string;
|
uploadErrorNetwork: string;
|
||||||
};
|
};
|
||||||
adminShell: {
|
adminShell: {
|
||||||
|
backToWebsite: string;
|
||||||
navDashboard: string;
|
navDashboard: string;
|
||||||
navOrders: string;
|
navOrders: string;
|
||||||
navReturns: string;
|
navReturns: string;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { isValidUploadPath, resolveUploadPath } from "@/lib/uploads";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard-Icon (Waben-Rahmen mit drei Tannen auf Waldgruen) — frueher als
|
||||||
|
* statische app/icon.svg ausgeliefert. Jetzt Quelle fuer app/icon.tsx und
|
||||||
|
* app/apple-icon.tsx, solange im Admin kein eigenes Favicon hochgeladen ist.
|
||||||
|
*/
|
||||||
|
const DEFAULT_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" rx="20" fill="#24352B" />
|
||||||
|
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" stroke-width="4" />
|
||||||
|
<g fill="#F4EFE5">
|
||||||
|
<g transform="translate(26,42) scale(0.62)">
|
||||||
|
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(40,30) scale(0.82)">
|
||||||
|
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||||
|
</g>
|
||||||
|
<g transform="translate(56,42) scale(0.62)">
|
||||||
|
<polygon points="10,2 5,13 15,13" /><polygon points="10,10 2,22 18,22" /><rect x="8" y="22" width="4" height="4" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
async function readUploadedIcon(faviconPath: string | null): Promise<Buffer | null> {
|
||||||
|
if (!faviconPath || !isValidUploadPath(faviconPath)) return null;
|
||||||
|
// resolveUploadPath haelt den Zugriff innerhalb von UPLOAD_DIR.
|
||||||
|
const file = resolveUploadPath(faviconPath.replace(/^\/uploads\//, "").split("/"));
|
||||||
|
if (!file) return null;
|
||||||
|
try {
|
||||||
|
return await readFile(file);
|
||||||
|
} catch {
|
||||||
|
// Datei fehlt (z.B. Upload-Volume nicht persistent): bewusst kein Fehler,
|
||||||
|
// der Browser bekaeme sonst gar kein Icon — das Standard-Icon springt ein.
|
||||||
|
// Das Dashboard meldet fehlende Upload-Dateien ohnehin gesondert.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quadratisches PNG in `size` Pixeln. Nicht quadratische Uploads werden
|
||||||
|
* eingepasst (nicht beschnitten), der Rand bleibt transparent — ausser
|
||||||
|
* `background` ist gesetzt (Apple-Touch-Icon: iOS zeigt Transparenz schwarz).
|
||||||
|
*/
|
||||||
|
export async function renderBrandIcon(
|
||||||
|
faviconPath: string | null,
|
||||||
|
size: number,
|
||||||
|
background?: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const uploaded = await readUploadedIcon(faviconPath);
|
||||||
|
const input = uploaded ?? Buffer.from(DEFAULT_ICON_SVG);
|
||||||
|
const image = sharp(input, { density: 384 }).resize(size, size, {
|
||||||
|
fit: "contain",
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
});
|
||||||
|
return (background ? image.flatten({ background }) : image).png().toBuffer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getContentValues } from "@/lib/content";
|
||||||
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
|
|
||||||
|
export interface BrandAssets {
|
||||||
|
/** Logo fuer helle Flaechen (Kopfzeile); null = gezeichnetes Waben-Zeichen. */
|
||||||
|
logo: string | null;
|
||||||
|
/** Logo fuer dunkle Flaechen (Mobilmenue, Fusszeile, Admin-Leiste); faellt auf `logo` zurueck. */
|
||||||
|
logoInverted: string | null;
|
||||||
|
/** Quadratisches Favicon-Bild; null = Standard-Icon (siehe lib/brand-icon.ts). */
|
||||||
|
favicon: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BRAND_CONTENT_KEYS = ["brand.logo", "brand.logoInverted", "brand.favicon"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hochgeladene Marken-Bilder aus den Inhalten (Admin > Inhalte > Logo und
|
||||||
|
* Favicon). Jeder Wert wird hier noch einmal gegen das Upload-Pfadmuster
|
||||||
|
* geprueft, obwohl updateSiteContentAction das beim Speichern schon tut:
|
||||||
|
* der Pfad landet ungefiltert in einem src-Attribut auf jeder Seite, ein
|
||||||
|
* irgendwie in die Datenbank geratener fremder Wert (z.B. eine externe URL)
|
||||||
|
* soll dort nie ankommen.
|
||||||
|
*/
|
||||||
|
export async function getBrandAssets(): Promise<BrandAssets> {
|
||||||
|
// Nicht lokalisierte Felder — die Locale spielt fuer den Wert keine Rolle.
|
||||||
|
const values = await getContentValues([...BRAND_CONTENT_KEYS], "de");
|
||||||
|
const pick = (value: string | undefined): string | null =>
|
||||||
|
value && isValidUploadPath(value) ? value : null;
|
||||||
|
|
||||||
|
const logo = pick(values["brand.logo"]);
|
||||||
|
return {
|
||||||
|
logo,
|
||||||
|
logoInverted: pick(values["brand.logoInverted"]) ?? logo,
|
||||||
|
favicon: pick(values["brand.favicon"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export type ContentFieldType =
|
|||||||
|
|
||||||
export interface ContentFieldDef {
|
export interface ContentFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
group: "company" | "shipping" | "home" | "pages" | "legal" | "social";
|
group: "brand" | "company" | "shipping" | "home" | "pages" | "legal" | "social";
|
||||||
labelDe: string;
|
labelDe: string;
|
||||||
labelEn: string;
|
labelEn: string;
|
||||||
type: ContentFieldType;
|
type: ContentFieldType;
|
||||||
@@ -54,6 +54,14 @@ function joinSections(sections: LegalSectionContent[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||||
|
// --- Logo und Favicon (nicht lokalisiert). Leer = das gezeichnete
|
||||||
|
// Waben-Zeichen (components/logomark.tsx) bzw. das Standard-Icon
|
||||||
|
// (lib/brand-icon.ts). Nur Rasterbilder: der Upload nimmt bewusst kein SVG
|
||||||
|
// an (ein SVG kann Skripte enthalten). Gelesen ueber lib/brand.ts.
|
||||||
|
{ key: "brand.logo", group: "brand", labelDe: "Logo (PNG mit transparentem Hintergrund, erscheint neben dem Firmennamen)", labelEn: "Logo (PNG with transparent background, shown next to the company name)", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
{ key: "brand.logoInverted", group: "brand", labelDe: "Logo für dunkle Flächen (optional: Mobilmenü, Fußzeile, Admin-Leiste; leer = normales Logo)", labelEn: "Logo for dark backgrounds (optional: mobile menu, footer, admin sidebar; empty = regular logo)", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
{ key: "brand.favicon", group: "brand", labelDe: "Favicon (quadratisch, mindestens 512 × 512 px; leer = Standard-Wabe)", labelEn: "Favicon (square, at least 512 × 512 px; empty = default honeycomb)", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
|
||||||
// --- Firmendaten (nicht lokalisiert) ---
|
// --- Firmendaten (nicht lokalisiert) ---
|
||||||
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
|
{ key: "company.name", group: "company", labelDe: "Firmenname", labelEn: "Company name", type: "text", localized: false, getDefault: () => siteConfig.name },
|
||||||
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },
|
{ key: "company.claim", group: "company", labelDe: "Slogan", labelEn: "Tagline", type: "text", localized: false, getDefault: () => siteConfig.claim },
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { redirectWithinSite } from "./redirect";
|
||||||
|
|
||||||
|
describe("redirectWithinSite", () => {
|
||||||
|
test("setzt einen relativen Location-Header statt einer absoluten Domain", () => {
|
||||||
|
const response = redirectWithinSite("/konto");
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toBe("/konto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("behaelt Query-Parameter bei", () => {
|
||||||
|
const response = redirectWithinSite("/newsletter-bestaetigt?status=ok");
|
||||||
|
expect(response.headers.get("location")).toBe("/newsletter-bestaetigt?status=ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("laesst sich mit Cookies (Flash-Meldung) versehen", () => {
|
||||||
|
const response = redirectWithinSite("/konto");
|
||||||
|
response.cookies.set("probe", "1");
|
||||||
|
expect(response.headers.get("set-cookie")).toContain("probe=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["https://evil.example/konto", "//evil.example/konto", "konto", "/\\evil.example"])(
|
||||||
|
"lehnt %s ab (keine offene Weiterleitung)",
|
||||||
|
(target) => {
|
||||||
|
expect(() => redirectWithinSite(target)).toThrow();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weiterleitung innerhalb der Seite aus einem Route-Handler — mit relativem
|
||||||
|
* Location-Header ("/konto"), den der Browser gegen die Adresse aufloest, unter
|
||||||
|
* der er die Seite gerade aufgerufen hat.
|
||||||
|
*
|
||||||
|
* Gegenstueck zu redirect("/konto") in Server Actions und Seiten, das ebenfalls
|
||||||
|
* relativ weiterleitet. Frueher bauten einige Route-Handler das Ziel mit
|
||||||
|
* getBaseUrl() absolut zusammen: ohne gesetztes NEXT_PUBLIC_SITE_URL (lokal,
|
||||||
|
* Staging, Vorschau-Host) sprang man damit auf die Produktionsseite, und die
|
||||||
|
* gerade gesetzte Flash-Meldung ging auf dem fremden Host verloren.
|
||||||
|
*
|
||||||
|
* Absolut bleiben nur Adressen, die die Seite verlassen: E-Mail-Links,
|
||||||
|
* Stripe-Ruecksprung-URLs, die Entra-redirect_uri, Sitemap und Canonicals —
|
||||||
|
* dafuer ist getSiteUrl() da (lib/url.ts).
|
||||||
|
*/
|
||||||
|
export function redirectWithinSite(path: string, status: 303 | 307 = 307): NextResponse {
|
||||||
|
// Nur echte Pfade dieser Seite: "//host" und "/\host" werten Browser als
|
||||||
|
// Adresse auf einem fremden Host — das waere eine offene Weiterleitung.
|
||||||
|
if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) {
|
||||||
|
throw new Error(`redirectWithinSite erwartet einen Pfad dieser Seite, bekam: ${path}`);
|
||||||
|
}
|
||||||
|
return new NextResponse(null, { status, headers: { Location: path } });
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ export async function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
return withCsp(
|
return withCsp(
|
||||||
withFlash(
|
withFlash(
|
||||||
|
// Absolut gegen den eigenen Host (request.url), nicht gegen getSiteUrl():
|
||||||
|
// der Proxy verlangt einen absoluten Location-Header — ein relativer wie
|
||||||
|
// bei redirectWithinSite() (lib/redirect.ts) endet hier in "Invalid URL".
|
||||||
NextResponse.redirect(new URL("/konto", request.url)),
|
NextResponse.redirect(new URL("/konto", request.url)),
|
||||||
"error",
|
"error",
|
||||||
session
|
session
|
||||||
|
|||||||
Reference in new issue
Block a user