Implement public audit pages
This commit is contained in:
1
lib/audits/public-audit-cache.ts
Normal file
1
lib/audits/public-audit-cache.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const publicAuditCacheTag = (slug: string) => `public-audit:${slug}`;
|
||||
51
lib/audits/public-audit-presenter.ts
Normal file
51
lib/audits/public-audit-presenter.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
8
lib/audits/public-audit-revalidation.ts
Normal file
8
lib/audits/public-audit-revalidation.ts
Normal 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}`);
|
||||
};
|
||||
57
lib/audits/public-audit-types.ts
Normal file
57
lib/audits/public-audit-types.ts
Normal 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
40
lib/audits/slugs.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user