Implement public audit pages

This commit is contained in:
Matthias
2026-06-05 14:14:07 +02:00
parent 03cb65fde4
commit 47ee2c2d51
25 changed files with 1039 additions and 45 deletions

View File

@@ -0,0 +1 @@
export const publicAuditCacheTag = (slug: string) => `public-audit:${slug}`;

View File

@@ -0,0 +1,51 @@
import type {
PublicAuditLookupResult,
PublicAuditOffer,
PublicAuditRenderState,
} from "./public-audit-types";
const isSafeCtaHref = (href: string) => {
try {
const parsed = new URL(href);
return parsed.protocol === "https:" || parsed.protocol === "mailto:" || parsed.protocol === "tel:";
} catch {
return href.startsWith("/");
}
};
const sanitizeOffer = (offer: PublicAuditOffer): PublicAuditOffer => {
if (!offer.ctaHref || isSafeCtaHref(offer.ctaHref)) {
return offer;
}
return {
body: offer.body,
ctaLabel: offer.ctaLabel,
};
};
export const toPublicAuditRenderState = (
result: PublicAuditLookupResult,
): PublicAuditRenderState => {
if (!result || result.publicationStatus === "deactivated") {
return { kind: "unavailable" };
}
if (result.publicationStatus !== "published") {
return { kind: "pending" };
}
return {
kind: "published",
audit: {
companyName: result.companyName,
domain: result.domain,
publishedAt: result.publishedAt,
headline: result.publicContent.headline,
intro: result.publicContent.intro,
observations: result.publicContent.observations,
finalOffer: sanitizeOffer(result.publicContent.finalOffer),
screenshots: result.screenshots,
},
};
};

View File

@@ -0,0 +1,8 @@
import { revalidatePath, revalidateTag } from "next/cache";
import { publicAuditCacheTag } from "./public-audit-cache";
export const revalidatePublicAudit = (slug: string) => {
revalidateTag(publicAuditCacheTag(slug), "max");
revalidatePath(`/audit/${slug}`);
};

View File

@@ -0,0 +1,57 @@
export type PublicAuditLookupResult =
| null
| { publicationStatus: "draft" | "approved" | "deactivated" }
| {
publicationStatus: "published";
companyName: string;
domain: string;
publishedAt: string;
publicContent: {
headline: string;
intro: string;
observations: PublicAuditObservation[];
finalOffer: PublicAuditOffer;
};
screenshots: PublicAuditScreenshot[];
};
export type PublicAuditObservation = {
title: string;
observation: string;
impact: string;
suggestion: string;
screenshotIds?: string[];
};
export type PublicAuditOffer = {
body: string;
ctaLabel?: string;
ctaHref?: string;
};
export type PublicAuditScreenshot = {
id: string;
url: string;
alt: string;
viewport: "desktop" | "mobile";
sourceUrl: string;
width: number;
height: number;
};
export type PublicAuditRenderState =
| { kind: "pending" }
| { kind: "unavailable" }
| {
kind: "published";
audit: {
companyName: string;
domain: string;
publishedAt: string;
headline: string;
intro: string;
observations: PublicAuditObservation[];
finalOffer: PublicAuditOffer;
screenshots: PublicAuditScreenshot[];
};
};

40
lib/audits/slugs.ts Normal file
View File

@@ -0,0 +1,40 @@
const MAX_PUBLIC_AUDIT_SLUG_LENGTH = 120;
const transliterations: Record<string, string> = {
ä: "ae",
ö: "oe",
ü: "ue",
ß: "ss",
æ: "ae",
ø: "oe",
å: "a",
};
export const toPublicAuditSlug = (companyName: string, domain: string) => {
const input = `${companyName} ${domain}`.trim().toLowerCase();
const normalized = input
.replace(/[äöüßæøå]/g, (character) => transliterations[character] ?? character)
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-")
.slice(0, MAX_PUBLIC_AUDIT_SLUG_LENGTH)
.replace(/-+$/g, "");
return normalized.length > 0 ? normalized : "audit";
};
export const parsePublicAuditSlug = (slug: string) => {
const normalized = slug.trim().toLowerCase();
if (
normalized.length === 0 ||
normalized.length > MAX_PUBLIC_AUDIT_SLUG_LENGTH ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)
) {
return null;
}
return normalized;
};