TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Cache tags attached to public Web Manager fetches. The backend references
|
||||
* the same tag names when it asks `/api/revalidate` to purge cached content,
|
||||
* so any change here must stay in sync with the backend revalidation service
|
||||
* (`apps/backend/app/base_modules/WebManager/services/web_revalidation_service.ts`).
|
||||
*
|
||||
* `WEB_MANAGER_TAG` is on every fetch and exists as the deliberate sledgehammer:
|
||||
* the backend purges it for structural changes (a page created, deleted or moved)
|
||||
*/
|
||||
|
||||
export const WEB_MANAGER_TAG = 'web-manager';
|
||||
|
||||
export function pageCacheTag(locale: string, path: string): string {
|
||||
return `${WEB_MANAGER_TAG}:page:${locale}:${path || '/'}`;
|
||||
}
|
||||
|
||||
export function settingsCacheTag(locale: string): string {
|
||||
return `${WEB_MANAGER_TAG}:settings:${locale}`;
|
||||
}
|
||||
|
||||
export function blogCacheTag(locale: string): string {
|
||||
return `${WEB_MANAGER_TAG}:blog:${locale}`;
|
||||
}
|
||||
|
||||
export function pageCacheTags(locale: string, path: string): string[] {
|
||||
return [WEB_MANAGER_TAG, pageCacheTag(locale, path), settingsCacheTag(locale)];
|
||||
}
|
||||
|
||||
export function blogListCacheTags(locale: string): string[] {
|
||||
return [WEB_MANAGER_TAG, blogCacheTag(locale)];
|
||||
}
|
||||
|
||||
export function settingsCacheTags(locale: string): string[] {
|
||||
return [WEB_MANAGER_TAG, settingsCacheTag(locale)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The sitemap spans every locale and every page, so the only tag that can invalidate it is the
|
||||
* site-wide one — which the backend purges for exactly the changes that alter the URL set
|
||||
* (a page created, deleted, moved, published or unpublished).
|
||||
*/
|
||||
export function sitemapCacheTags(): string[] {
|
||||
return [WEB_MANAGER_TAG];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'server-only';
|
||||
import { cache } from 'react';
|
||||
import { createEmptySiteSettings, normalizeRoutePath } from '../data/normalize';
|
||||
import type { PageRenderData } from '../types';
|
||||
import { getWebRuntimeAPI } from './web-runtime-api';
|
||||
|
||||
export const resolvePageRenderData = cache(
|
||||
async (locale: string, requestedPath: string): Promise<PageRenderData | null> => {
|
||||
const api = getWebRuntimeAPI();
|
||||
const request = {
|
||||
locale: locale.trim(),
|
||||
path: normalizeRoutePath(requestedPath)
|
||||
};
|
||||
const resolution = await api.resolvePage(request);
|
||||
|
||||
if (!resolution || resolution.page.status !== 'published') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = resolution.settings ?? (await api.getSettings(request.locale));
|
||||
|
||||
return {
|
||||
page: resolution.page,
|
||||
settings: settings ?? createEmptySiteSettings(request.locale),
|
||||
languageAlternatives: resolution.languageAlternatives
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export async function resolvePreviewRenderData(token: string): Promise<PageRenderData | null> {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution = await getWebRuntimeAPI().getPreview(token);
|
||||
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
page: resolution.page,
|
||||
settings: resolution.settings ?? createEmptySiteSettings(resolution.page.locale),
|
||||
languageAlternatives: resolution.languageAlternatives
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'server-only';
|
||||
import { isRecord, normalizePageResolution, normalizePageTeaserList, normalizeSitemapEntries, normalizeSiteSettings, sitemapLastPage } from '../data/normalize';
|
||||
import type { PageRequest, PageResolution, PageTeaserList, SitemapEntry, SiteSettings } from '../types';
|
||||
import { blogListCacheTags, pageCacheTags, settingsCacheTags, sitemapCacheTags } from './cache-tags';
|
||||
|
||||
/**
|
||||
* Ceiling on sitemap round trips, so a runaway `lastPage` cannot spin forever. At the backend's
|
||||
* 1000 rows per request this covers 50,000 URLs — the sitemap protocol's own limit per file.
|
||||
*/
|
||||
const SITEMAP_MAX_REQUESTS = 50;
|
||||
|
||||
function extractPreviewRequest(payload: unknown): PageRequest {
|
||||
if (isRecord(payload)) {
|
||||
const root = isRecord(payload['data']) ? payload['data'] : payload;
|
||||
const page = isRecord(root['page']) ? root['page'] : root;
|
||||
|
||||
return {
|
||||
locale: typeof page['locale'] === 'string' ? page['locale'] : 'en',
|
||||
path: typeof page['path'] === 'string' ? page['path'] : ''
|
||||
};
|
||||
}
|
||||
|
||||
return { locale: 'en', path: '' };
|
||||
}
|
||||
|
||||
export class WebRuntimeAPI {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
async resolvePage(request: PageRequest): Promise<PageResolution | null> {
|
||||
const payload = await this.request('/web-manager/public/pages/resolve', {
|
||||
params: { locale: request.locale, path: request.path },
|
||||
next: {
|
||||
revalidate: 60,
|
||||
tags: pageCacheTags(request.locale, request.path)
|
||||
}
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePageResolution(payload, request);
|
||||
}
|
||||
|
||||
async listBlogPosts(request: {
|
||||
locale: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}): Promise<PageTeaserList> {
|
||||
const payload = await this.request('/web-manager/public/blog/pages', {
|
||||
params: {
|
||||
locale: request.locale,
|
||||
page: String(request.page ?? 1),
|
||||
perPage: String(request.perPage ?? 12)
|
||||
},
|
||||
next: { revalidate: 60, tags: blogListCacheTags(request.locale) }
|
||||
});
|
||||
|
||||
return normalizePageTeaserList(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every published page, walked page by page. Tagged like any other public read, so
|
||||
* publishing refreshes the sitemap rather than leaving it a day out of date.
|
||||
*/
|
||||
async listSitemapPages(): Promise<SitemapEntry[]> {
|
||||
const entries: SitemapEntry[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const payload = await this.request('/web-manager/public/pages/sitemap', {
|
||||
params: { page: String(page) },
|
||||
next: { revalidate: 3600, tags: sitemapCacheTags() }
|
||||
});
|
||||
|
||||
entries.push(...normalizeSitemapEntries(payload));
|
||||
lastPage = Math.min(sitemapLastPage(payload), SITEMAP_MAX_REQUESTS);
|
||||
page += 1;
|
||||
} while (page <= lastPage);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async getSettings(locale: string): Promise<SiteSettings | null> {
|
||||
const payload = await this.request('/web-manager/public/settings', {
|
||||
params: { locale },
|
||||
next: { revalidate: 60, tags: settingsCacheTags(locale) }
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSettings =
|
||||
isRecord(payload) && isRecord(payload['settings']) ? payload['settings'] : payload;
|
||||
|
||||
return normalizeSiteSettings(rawSettings, locale);
|
||||
}
|
||||
|
||||
async getPreview(token: string): Promise<PageResolution | null> {
|
||||
const payload = await this.request('/web-manager/public/pages/preview', {
|
||||
params: { token },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePageResolution(payload, extractPreviewRequest(payload));
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
{ params, ...init }: RequestInit & { params: Record<string, string> }
|
||||
): Promise<unknown> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Web Manager request to ${path} failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
let api: WebRuntimeAPI | null = null;
|
||||
|
||||
export function getWebRuntimeAPI(): WebRuntimeAPI {
|
||||
if (!api) {
|
||||
const baseUrl = process.env['BACKEND_API_URL']?.trim().replace(/\/$/, '');
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('BACKEND_API_URL is required');
|
||||
}
|
||||
|
||||
api = new WebRuntimeAPI(baseUrl);
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PageLink } from '#web-runtime/components/links';
|
||||
import { RichTextContent } from '#web-runtime/components/rich-text';
|
||||
import type { ArticlesBlock as ArticlesBlockData } from '#web-runtime/types';
|
||||
|
||||
export default function ArticlesBlock({ block }: { block: ArticlesBlockData }) {
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[45rem]:grid-cols-1',
|
||||
3: 'grid-cols-3 max-lg:grid-cols-2 max-[45rem]:grid-cols-1'
|
||||
}[Math.min(block.items.length, 3) as 1 | 2 | 3];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{block.title || block.subtitle.content || block.cta ? (
|
||||
<header className="mb-[clamp(2rem,5vw,4rem)] flex flex-wrap items-end justify-between gap-x-8 gap-y-6">
|
||||
<div className="max-w-3xl">
|
||||
{block.title ? (
|
||||
<h2 className="text-[clamp(2rem,4vw,3.8rem)] leading-[1.04] font-semibold tracking-[-0.055em]">
|
||||
{block.title}
|
||||
</h2>
|
||||
) : null}
|
||||
<RichTextContent value={block.subtitle} className="mt-4 max-w-160 text-lg" />
|
||||
</div>
|
||||
{block.cta ? <PageLink link={block.cta} /> : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className={`grid gap-4 ${columnClasses}`}>
|
||||
{block.items.map((item, index) => (
|
||||
<article
|
||||
className="flex min-w-0 flex-col items-start gap-4 rounded-[1.25rem] border border-[#d6d7cf] bg-white/50 p-[clamp(1.5rem,3vw,2.25rem)]"
|
||||
key={`${block.id}-item-${index}`}
|
||||
>
|
||||
{item.title ? (
|
||||
<h3 className="text-[1.35rem] leading-[1.18] font-semibold tracking-[-0.035em]">
|
||||
{item.title}
|
||||
</h3>
|
||||
) : null}
|
||||
<RichTextContent value={item.content} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { LinkList } from '#web-runtime/components/links';
|
||||
import { RichTextContent } from '#web-runtime/components/rich-text';
|
||||
import type { BannerBlock as BannerBlockData } from '#web-runtime/types';
|
||||
|
||||
const styleClasses: Record<BannerBlockData['style'], string> = {
|
||||
info: 'border-[#a9bed9] bg-[#eef3fb]',
|
||||
success: 'border-[#99d900] bg-[#f0fbd4]',
|
||||
warning: 'border-[#e3b341] bg-[#fdf3d8]',
|
||||
error: 'border-[#e0604d] bg-[#fdeae5]'
|
||||
};
|
||||
|
||||
export default function BannerBlock({ block }: { block: BannerBlockData }) {
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(2.5rem,5vw,4rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-between gap-x-8 gap-y-5 rounded-[1.25rem] border p-[clamp(1.5rem,3vw,2.25rem)] ${styleClasses[block.style]}`}
|
||||
data-banner-style={block.style}
|
||||
>
|
||||
<RichTextContent
|
||||
value={block.content}
|
||||
className="min-w-64 flex-1 basis-96 text-[#172019]"
|
||||
/>
|
||||
<LinkList links={block.ctaItems} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LinkList } from '#web-runtime/components/links';
|
||||
import { RichTextContent } from '#web-runtime/components/rich-text';
|
||||
import type { ContentBlock as ContentBlockData } from '#web-runtime/types';
|
||||
|
||||
export default function ContentBlock({ block }: { block: ContentBlockData }) {
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[45rem]:grid-cols-1',
|
||||
3: 'grid-cols-3 max-lg:grid-cols-1'
|
||||
}[Math.min(block.columns.length, 3) as 1 | 2 | 3];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{block.title || block.subtitle ? (
|
||||
<header className="mb-[clamp(2rem,5vw,4rem)] max-w-3xl">
|
||||
{block.title ? (
|
||||
<h2 className="text-[clamp(2rem,4vw,3.8rem)] leading-[1.04] font-semibold tracking-[-0.055em]">
|
||||
{block.title}
|
||||
</h2>
|
||||
) : null}
|
||||
{block.subtitle ? (
|
||||
<p className="mt-4 max-w-160 text-lg leading-[1.6] text-[#5d675f]">
|
||||
{block.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className={`grid gap-4 ${columnClasses}`}>
|
||||
{block.columns.map((column, index) => (
|
||||
<article
|
||||
className="flex min-w-0 flex-col items-start gap-6 rounded-[1.25rem] border border-[#d6d7cf] bg-white/50 p-[clamp(1.5rem,3vw,2.25rem)] [&>div:last-child]:mt-auto"
|
||||
key={`${block.id}-column-${index}`}
|
||||
>
|
||||
<RichTextContent value={column.content} />
|
||||
<LinkList links={column.ctaItems} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FaqBlock as FaqBlockData } from '#web-runtime/types';
|
||||
|
||||
export default function FaqBlock({ block }: { block: FaqBlockData }) {
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto grid w-[min(calc(100%-2rem),76rem)] grid-cols-[minmax(15rem,0.65fr)_minmax(0,1fr)] items-start gap-[clamp(2rem,7vw,7rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)] max-[45rem]:grid-cols-1">
|
||||
{block.title || block.subtitle ? (
|
||||
<header className="sticky top-8 max-w-3xl max-[45rem]:static">
|
||||
{block.title ? (
|
||||
<h2 className="text-[clamp(2rem,4vw,3.8rem)] leading-[1.04] font-semibold tracking-[-0.055em]">
|
||||
{block.title}
|
||||
</h2>
|
||||
) : null}
|
||||
{block.subtitle ? (
|
||||
<p className="mt-4 max-w-160 text-lg leading-[1.6] text-[#5d675f]">
|
||||
{block.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-[#172019]">
|
||||
{block.items.map((item, index) => (
|
||||
<details
|
||||
className="group border-b border-[#d6d7cf] py-5"
|
||||
key={`${block.id}-item-${index}`}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-[1.05rem] leading-[1.4] font-bold marker:hidden after:shrink-0 after:font-mono after:text-[1.35rem] after:text-[#517000] after:content-['+'] group-open:after:content-['−'] [&::-webkit-details-marker]:hidden">
|
||||
{item.question}
|
||||
</summary>
|
||||
<p className="mt-4 max-w-180 leading-[1.7] text-[#5d675f]">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MediaImage } from '#web-runtime/components/media-image';
|
||||
import type { MediaBlock as MediaBlockData } from '#web-runtime/types';
|
||||
|
||||
export default function MediaBlock({ block }: { block: MediaBlockData }) {
|
||||
if (!block.media?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<figure className="m-0">
|
||||
<MediaImage
|
||||
media={block.media}
|
||||
className="rounded-4xl shadow-[0_1.5rem_4rem_rgb(23_32_25/12%)]"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LinkList } from '#web-runtime/components/links';
|
||||
import { MediaImage } from '#web-runtime/components/media-image';
|
||||
import { RichTextContent } from '#web-runtime/components/rich-text';
|
||||
import type { MediaContentBlock as MediaContentBlockData } from '#web-runtime/types';
|
||||
|
||||
export default function MediaContentBlock({ block }: { block: MediaContentBlockData }) {
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)] nth-of-type-[even]:bg-[#fffdf8]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto grid w-[min(calc(100%-2rem),76rem)] grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)] items-center gap-[clamp(2.5rem,7vw,6rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)] max-[45rem]:grid-cols-1">
|
||||
<figure
|
||||
className={`m-0 empty:hidden ${block.mediaPosition === 'right' ? 'order-2 max-[45rem]:order-0' : ''}`}
|
||||
>
|
||||
<MediaImage
|
||||
media={block.media}
|
||||
className="aspect-4/3 rounded-4xl shadow-[0_1.5rem_4rem_rgb(23_32_25/12%)]"
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<div>
|
||||
{block.title ? (
|
||||
<header className="mb-6 max-w-3xl">
|
||||
<h2 className="text-[clamp(2rem,4vw,3.8rem)] leading-[1.04] font-semibold tracking-[-0.055em]">
|
||||
{block.title}
|
||||
</h2>
|
||||
</header>
|
||||
) : null}
|
||||
<RichTextContent value={block.content} />
|
||||
<LinkList links={block.ctaItems} className="mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageBlock } from '#web-runtime/types';
|
||||
|
||||
export default function UnsupportedBlock({ block }: { block: PageBlock }) {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] bg-[#fff0d7] py-4 font-mono text-sm text-[#754200]"
|
||||
data-page-block={block.type}
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
Renderer missing for block type <code>{block.type}</code>.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ArticlesBlock as ArticlesBlockData, BannerBlock as BannerBlockData, ContentBlock as ContentBlockData, FaqBlock as FaqBlockData, MediaBlock as MediaBlockData, MediaContentBlock as MediaContentBlockData, PageBlock } from '#web-runtime/types';
|
||||
import ArticlesBlock from './components/ArticlesBlock';
|
||||
import BannerBlock from './components/BannerBlock';
|
||||
import ContentBlock from './components/ContentBlock';
|
||||
import FaqBlock from './components/FaqBlock';
|
||||
import MediaBlock from './components/MediaBlock';
|
||||
import MediaContentBlock from './components/MediaContentBlock';
|
||||
import UnsupportedBlock from './components/UnsupportedBlock';
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: PageBlock;
|
||||
}
|
||||
|
||||
type BlockRenderer = ComponentType<BlockRendererProps>;
|
||||
|
||||
function isContentBlock(block: PageBlock): block is ContentBlockData {
|
||||
return block.type === 'content' && 'columns' in block;
|
||||
}
|
||||
|
||||
function isFaqBlock(block: PageBlock): block is FaqBlockData {
|
||||
return block.type === 'faq' && 'items' in block;
|
||||
}
|
||||
|
||||
function isMediaContentBlock(block: PageBlock): block is MediaContentBlockData {
|
||||
return block.type === 'media-content' && 'content' in block;
|
||||
}
|
||||
|
||||
function isArticlesBlock(block: PageBlock): block is ArticlesBlockData {
|
||||
return block.type === 'articles' && 'items' in block;
|
||||
}
|
||||
|
||||
function isBannerBlock(block: PageBlock): block is BannerBlockData {
|
||||
return block.type === 'banner' && 'style' in block;
|
||||
}
|
||||
|
||||
function isMediaBlock(block: PageBlock): block is MediaBlockData {
|
||||
return block.type === 'media' && !('data' in block);
|
||||
}
|
||||
|
||||
const ContentBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isContentBlock(block) ? <ContentBlock block={block} /> : null;
|
||||
|
||||
const FaqBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isFaqBlock(block) ? <FaqBlock block={block} /> : null;
|
||||
|
||||
const MediaContentBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isMediaContentBlock(block) ? <MediaContentBlock block={block} /> : null;
|
||||
|
||||
const ArticlesBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isArticlesBlock(block) ? <ArticlesBlock block={block} /> : null;
|
||||
|
||||
const BannerBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isBannerBlock(block) ? <BannerBlock block={block} /> : null;
|
||||
|
||||
const MediaBlockRenderer: BlockRenderer = ({ block }) =>
|
||||
isMediaBlock(block) ? <MediaBlock block={block} /> : null;
|
||||
|
||||
export const PAGE_BLOCK_REGISTRY: Readonly<Record<string, BlockRenderer>> = {
|
||||
'content': ContentBlockRenderer,
|
||||
'faq': FaqBlockRenderer,
|
||||
'media-content': MediaContentBlockRenderer,
|
||||
'articles': ArticlesBlockRenderer,
|
||||
'banner': BannerBlockRenderer,
|
||||
'media': MediaBlockRenderer
|
||||
};
|
||||
|
||||
export function PageBlocks({ blocks }: { blocks: PageBlock[] }) {
|
||||
return blocks.map((block) => {
|
||||
const Renderer = PAGE_BLOCK_REGISTRY[block.type];
|
||||
|
||||
return Renderer ? (
|
||||
<Renderer key={block.id} block={block} />
|
||||
) : (
|
||||
<UnsupportedBlock key={block.id} block={block} />
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import React from 'react';
|
||||
import { PageLink } from '#web-runtime/components/links';
|
||||
import type { LanguageAlternative, NavigationLink, SiteMenu } from '#web-runtime/types';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import LanguageAlternatives from './LanguageAlternatives';
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
className={`size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2 4l4 4 4-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLink({ link, className }: { link: NavigationLink; className: string }) {
|
||||
return <PageLink link={{ ...link, variant: 'ghost' }} className={className} />;
|
||||
}
|
||||
|
||||
export default function HeaderNav({
|
||||
menus,
|
||||
ctaItems,
|
||||
languageAlternatives
|
||||
}: {
|
||||
menus: SiteMenu[];
|
||||
ctaItems: NavigationLink[];
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
const [openMenu, setOpenMenu] = React.useState<number | null>(null);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const visibleMenus = menus.filter((menu) => menu.items.length > 0);
|
||||
const hasMenuContent = visibleMenus.length > 0 || ctaItems.length > 0;
|
||||
const anythingOpen = openMenu !== null || mobileOpen;
|
||||
|
||||
const closeAll = React.useCallback(() => {
|
||||
setOpenMenu(null);
|
||||
setMobileOpen(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!anythingOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [anythingOpen, closeAll]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex min-w-0 flex-1 items-center gap-8 max-lg:gap-4">
|
||||
{visibleMenus.length > 0 ? (
|
||||
<nav
|
||||
className="flex min-w-0 flex-1 items-center gap-1 max-lg:hidden"
|
||||
aria-label={t('main')}
|
||||
>
|
||||
{visibleMenus.map((menu, menuIndex) =>
|
||||
menu.title ? (
|
||||
<div className="relative" key={menuIndex}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-1.5 px-2.5 py-2 text-sm font-semibold text-[#5d675f] hover:translate-y-0 hover:text-[#172019]"
|
||||
aria-expanded={openMenu === menuIndex}
|
||||
aria-haspopup="true"
|
||||
onClick={() => {
|
||||
setOpenMenu(openMenu === menuIndex ? null : menuIndex);
|
||||
}}
|
||||
>
|
||||
{menu.title}
|
||||
<Chevron open={openMenu === menuIndex} />
|
||||
</Button>
|
||||
|
||||
{openMenu === menuIndex ? (
|
||||
<div className="absolute top-full left-0 z-30 mt-2 flex min-w-52 flex-col gap-0.5 rounded-2xl border border-[#d6d7cf] bg-[#fffdf8] p-2 shadow-[0_1rem_3rem_rgb(23_32_25/12%)]">
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<MenuLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="w-full rounded-lg! border-0! px-3! py-2! text-sm font-semibold text-[#172019] hover:bg-[#f4f1e9] hover:text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
aria-label={undefined}
|
||||
key={`menu-${menuIndex}`}
|
||||
>
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="px-2.5 py-2 text-sm font-semibold whitespace-nowrap text-[#5d675f] hover:text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-4">
|
||||
<LanguageAlternatives alternatives={languageAlternatives} />
|
||||
|
||||
{ctaItems.map((item, index) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${index}`}
|
||||
link={item}
|
||||
className="whitespace-nowrap max-lg:hidden"
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMenuContent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-11 border-[#172019]/15 p-0 hover:translate-y-0 hover:border-[#172019]/15 hover:bg-white/60 max-lg:flex"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-label={mobileOpen ? t('close') : t('open')}
|
||||
onClick={() => {
|
||||
setMobileOpen((open) => !open);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="size-5" aria-hidden="true">
|
||||
{mobileOpen ? (
|
||||
<path
|
||||
d="M5 5l10 10M15 5L5 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
d="M3 6h14M3 10h14M3 14h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mobileOpen ? (
|
||||
<nav
|
||||
className="absolute inset-x-0 top-full z-30 border-b border-[#172019]/10 bg-[#f4f1e9] shadow-[0_1.5rem_3rem_rgb(23_32_25/12%)] lg:hidden"
|
||||
aria-label={t('main')}
|
||||
>
|
||||
<div className="mx-auto flex w-[min(calc(100%-2rem),76rem)] flex-col gap-5 py-6 max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{visibleMenus.map((menu, menuIndex) => (
|
||||
<div key={`${menu.title}-${menuIndex}`}>
|
||||
{menu.title ? (
|
||||
<p className="mb-1 font-mono text-[0.7rem] font-bold tracking-[0.08em] text-[#5d675f] uppercase">
|
||||
{menu.title}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<MenuLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="w-full py-2! text-[0.95rem] font-semibold text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctaItems.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3 border-t border-[#d6d7cf] pt-5">
|
||||
{ctaItems.map((item, index) => (
|
||||
<PageLink key={`${item.label}-${index}`} link={item} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { LanguageAlternative } from '#web-runtime/types';
|
||||
import { localeHref } from '../helpers';
|
||||
|
||||
export default function LanguageAlternatives({
|
||||
alternatives
|
||||
}: {
|
||||
alternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
|
||||
if (alternatives.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex gap-2.5 font-mono text-[0.7rem] font-bold text-[#5d675f]"
|
||||
aria-label={t('languages')}
|
||||
>
|
||||
{alternatives.map((alternative) => (
|
||||
<a
|
||||
key={`${alternative.locale}-${alternative.path}`}
|
||||
href={localeHref(alternative)}
|
||||
hrefLang={alternative.locale}
|
||||
className="hover:text-[#172019] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
>
|
||||
{alternative.locale.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageLink } from '#web-runtime/components/links';
|
||||
import { SiteSettings } from '#web-runtime/types';
|
||||
import { Link } from '#i18n/navigation';
|
||||
|
||||
export default function SiteFooter({ settings }: { settings: SiteSettings }) {
|
||||
const t = useTranslations('Components.Footer');
|
||||
|
||||
const hasLegalLinks = Boolean(settings.legal.privacyPath || settings.legal.termsPath);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#172019] pt-[clamp(4rem,8vw,7rem)] pb-8 text-[#f6f5ef]">
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<div className="grid grid-cols-[minmax(15rem,0.75fr)_minmax(0,1.25fr)] gap-[clamp(3rem,8vw,8rem)] max-lg:grid-cols-1">
|
||||
<div>
|
||||
<Link
|
||||
className="inline-block text-[1.45rem] font-extrabold tracking-[-0.06em] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
href="/"
|
||||
>
|
||||
CRUX
|
||||
<span className="text-[#99d900]" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{settings.footer.menus.length > 0 ? (
|
||||
<nav
|
||||
className="grid grid-cols-3 gap-8 max-[45rem]:grid-cols-2"
|
||||
aria-label={t('navigation')}
|
||||
>
|
||||
{settings.footer.menus.map((menu, menuIndex) => (
|
||||
<div
|
||||
className="flex flex-col items-start gap-1.5"
|
||||
key={`${menu.title}-${menuIndex}`}
|
||||
>
|
||||
{menu.title ? (
|
||||
<h2 className="mb-3 font-mono text-[0.7rem] font-semibold tracking-widest text-[#aab4aa] uppercase">
|
||||
{menu.title}
|
||||
</h2>
|
||||
) : null}
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="text-sm font-medium text-[#f6f5ef] hover:text-[#c7ff3f]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-[clamp(4rem,8vw,7rem)] flex items-center justify-between gap-8 border-t border-white/15 pt-6 text-[0.78rem] text-[#aab4aa] max-[45rem]:flex-col max-[45rem]:items-start">
|
||||
{settings.footer.copyright ? <p>{settings.footer.copyright}</p> : <span />}
|
||||
{hasLegalLinks ? (
|
||||
<nav className="flex gap-5" aria-label={t('legal')}>
|
||||
{settings.legal.privacyPath ? (
|
||||
<a className="hover:text-white" href={settings.legal.privacyPath}>
|
||||
{t('privacy')}
|
||||
</a>
|
||||
) : null}
|
||||
{settings.legal.termsPath ? (
|
||||
<a className="hover:text-white" href={settings.legal.termsPath}>
|
||||
{t('terms')}
|
||||
</a>
|
||||
) : null}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageAlternative, SiteSettings } from '#web-runtime/types';
|
||||
import { Link } from '#i18n/navigation';
|
||||
import HeaderNav from './HeaderNav';
|
||||
|
||||
export default function SiteHeader({
|
||||
settings,
|
||||
languageAlternatives
|
||||
}: {
|
||||
settings: SiteSettings;
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
|
||||
return (
|
||||
<header className="relative z-20 border-b border-[#172019]/10 bg-[#f4f1e9]/92 backdrop-blur-2xl">
|
||||
<div className="mx-auto flex min-h-20 w-[min(calc(100%-2rem),76rem)] items-center gap-8 max-lg:gap-4 max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<Link
|
||||
className="shrink-0 text-lg font-extrabold tracking-[-0.06em] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
href="/"
|
||||
aria-label={t('home')}
|
||||
>
|
||||
CRUX
|
||||
<span className="text-[#99d900]" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<HeaderNav
|
||||
menus={settings.header.menus}
|
||||
ctaItems={settings.header.ctaItems}
|
||||
languageAlternatives={languageAlternatives}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getPathname } from '#i18n/navigation';
|
||||
import type { LanguageAlternative } from '../types';
|
||||
|
||||
export function localeHref(alternative: LanguageAlternative): string {
|
||||
const path = alternative.path.split('/').filter(Boolean).join('/');
|
||||
|
||||
return getPathname({
|
||||
locale: alternative.locale,
|
||||
href: path ? `/${path}` : '/'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from '@crux-kit/utils';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
import { buttonVariants } from '#shared/ui/Button';
|
||||
import type { NavigationLink } from '../types';
|
||||
|
||||
export function safeHref(link: NavigationLink): string | null {
|
||||
const href = link.href || link.externalUrl;
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (href.startsWith('/')) {
|
||||
return toPublicHref(href);
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(href);
|
||||
return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol) ? href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function PageLink({ link, className = '' }: { link: NavigationLink; className?: string }) {
|
||||
const href = safeHref(link);
|
||||
const classes = cn(buttonVariants({ variant: link.variant }), className);
|
||||
|
||||
if (!href) {
|
||||
return <span className={cn(classes, 'cursor-not-allowed opacity-45')}>{link.label}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={classes}
|
||||
target={link.openInNewTab ? '_blank' : undefined}
|
||||
rel={link.openInNewTab ? 'noreferrer noopener' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkList({
|
||||
links,
|
||||
className = ''
|
||||
}: {
|
||||
links: NavigationLink[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-3', className)}>
|
||||
{links.map((link, index) => (
|
||||
<PageLink key={`${link.label}-${index}`} link={link} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Media } from '../types';
|
||||
|
||||
export function MediaImage({
|
||||
media,
|
||||
eager = false,
|
||||
className = ''
|
||||
}: {
|
||||
media?: Media;
|
||||
eager?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!media?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// Media origins are CMS-managed and intentionally unrestricted, so a native image is used here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={`block h-auto w-full object-cover ${className}`}
|
||||
src={media.url}
|
||||
alt={media.alt}
|
||||
width={media.width}
|
||||
height={media.height}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
fetchPriority={eager ? 'high' : 'auto'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RichText } from '../types';
|
||||
|
||||
export function RichTextContent({
|
||||
value,
|
||||
className = ''
|
||||
}: {
|
||||
value?: RichText;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!value?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-base leading-7 text-[#5d675f] *:first:mt-0 *:last:mb-0 [&_a]:font-medium [&_a]:underline [&_a]:decoration-[#99d900] [&_a]:decoration-2 [&_a]:underline-offset-4 [&_h2]:mt-[1.25em] [&_h2]:mb-[0.5em] [&_h2]:font-sans [&_h2]:leading-[1.18] [&_h2]:font-semibold [&_h2]:tracking-[-0.035em] [&_h2]:text-[#172019] [&_h3]:mt-[1.25em] [&_h3]:mb-[0.5em] [&_h3]:font-sans [&_h3]:text-[1.35rem] [&_h3]:leading-[1.18] [&_h3]:font-semibold [&_h3]:tracking-[-0.035em] [&_h3]:text-[#172019] [&_h4]:mt-[1.25em] [&_h4]:mb-[0.5em] [&_h4]:font-sans [&_h4]:leading-[1.18] [&_h4]:font-semibold [&_h4]:tracking-[-0.035em] [&_h4]:text-[#172019] [&_li+li]:mt-1.5 [&_ol]:mb-[1em] [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-[1em] [&_ul]:mb-[1em] [&_ul]:list-disc [&_ul]:pl-5 ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: value.content }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import type { ArticlesBlock, BannerBlock, BannerStyle, Breadcrumb, ContentBlock, FaqBlock, LanguageAlternative, Media, MediaBlock, MediaContentBlock, NavigationLink, NavigationLinkVariant, PageBlock, PageDocument, PageHero, PageRequest, PageResolution, PageTeaser, PageTeaserList, RichText, SitemapEntry, SiteMenu, SiteSettings, UnknownBlock } from '../types';
|
||||
|
||||
const LINK_VARIANTS = new Set<NavigationLinkVariant>([
|
||||
'primary',
|
||||
'secondary',
|
||||
'outline',
|
||||
'ghost',
|
||||
'inline'
|
||||
]);
|
||||
|
||||
const HERO_IMPACTS = new Set(['none', 'low', 'middle', 'high']);
|
||||
|
||||
const BANNER_STYLES = new Set<BannerStyle>(['info', 'success', 'warning', 'error']);
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function asOptionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.split('/').filter(Boolean).join('/');
|
||||
}
|
||||
|
||||
function normalizeRichText(value: unknown): RichText {
|
||||
if (isRecord(value)) {
|
||||
return { content: asString(value['content']) };
|
||||
}
|
||||
|
||||
return { content: asString(value) };
|
||||
}
|
||||
|
||||
function normalizeMedia(value: unknown): Media | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = asString(value['url']);
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const media: Media = {
|
||||
url,
|
||||
alt: asString(value['alt'])
|
||||
};
|
||||
const width = asOptionalNumber(value['width']);
|
||||
const height = asOptionalNumber(value['height']);
|
||||
const mimeType = asOptionalString(value['mimeType']);
|
||||
|
||||
if (width !== undefined) media.width = width;
|
||||
if (height !== undefined) media.height = height;
|
||||
if (mimeType !== undefined) media.mimeType = mimeType;
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
function normalizeNavigationLink(value: unknown): NavigationLink | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = asString(value['label']);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawVariant = asString(value['variant']);
|
||||
const variant = LINK_VARIANTS.has(rawVariant as NavigationLinkVariant)
|
||||
? (rawVariant as NavigationLinkVariant)
|
||||
: 'ghost';
|
||||
const rawLinkType = asString(value['linkType']);
|
||||
const linkType = rawLinkType === 'internal' ? 'internal' : 'external';
|
||||
const rawPageId = value['pageId'];
|
||||
const link: NavigationLink = {
|
||||
linkType,
|
||||
pageId: typeof rawPageId === 'number' || typeof rawPageId === 'string' ? String(rawPageId) : '',
|
||||
externalUrl: asString(value['externalUrl']),
|
||||
openInNewTab: value['openInNewTab'] === true,
|
||||
label,
|
||||
variant
|
||||
};
|
||||
const href = asOptionalString(value['href'] ?? value['url'] ?? value['path']);
|
||||
|
||||
if (href !== undefined) link.href = href;
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
function normalizeNavigationLinks(value: unknown): NavigationLink[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => normalizeNavigationLink(item))
|
||||
.filter((item): item is NavigationLink => item !== null);
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(value: unknown): Breadcrumb[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(isRecord).flatMap((item) => {
|
||||
const label = asString(item['label']);
|
||||
const href = asString(item['href']);
|
||||
|
||||
return label ? [{ label, href }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHero(value: unknown): PageHero | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawImpact = asString(value['type']);
|
||||
const type = HERO_IMPACTS.has(rawImpact) ? (rawImpact as PageHero['type']) : 'middle';
|
||||
const hero: PageHero = {
|
||||
type,
|
||||
ctaItems: normalizeNavigationLinks(value['ctaItems'])
|
||||
};
|
||||
const title = asOptionalString(value['title']);
|
||||
const subtitle = asOptionalString(value['subtitle']);
|
||||
const content = normalizeRichText(value['content']);
|
||||
const media = normalizeMedia(value['media']);
|
||||
|
||||
if (title !== undefined) hero.title = title;
|
||||
if (subtitle !== undefined) hero.subtitle = subtitle;
|
||||
if (content.content) hero.content = content;
|
||||
if (media !== undefined) hero.media = media;
|
||||
|
||||
return hero;
|
||||
}
|
||||
|
||||
function unwrapBlock(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const data = value['data'];
|
||||
if (!isRecord(data)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
id: value['id'] ?? data['id'],
|
||||
type: value['type'] ?? data['type']
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlock(value: unknown, index: number): PageBlock | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const block = unwrapBlock(value);
|
||||
const type = asString(block['type']);
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = String(block['id'] ?? `${type}-${index + 1}`);
|
||||
|
||||
if (type === 'content') {
|
||||
const columns = Array.isArray(block['columns'])
|
||||
? block['columns'].filter(isRecord).map((column) => ({
|
||||
content: normalizeRichText(column['content']),
|
||||
ctaItems: normalizeNavigationLinks(column['ctaItems'])
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: asString(block['title']),
|
||||
subtitle: asString(block['subtitle']),
|
||||
columns
|
||||
} satisfies ContentBlock;
|
||||
}
|
||||
|
||||
if (type === 'faq') {
|
||||
const items = Array.isArray(block['items'])
|
||||
? block['items'].filter(isRecord).map((item) => ({
|
||||
question: asString(item['question']),
|
||||
answer: asString(item['answer'])
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: asString(block['title']),
|
||||
subtitle: asString(block['subtitle']),
|
||||
items
|
||||
} satisfies FaqBlock;
|
||||
}
|
||||
|
||||
if (type === 'media-content') {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: asString(block['title']),
|
||||
mediaPosition: block['mediaPosition'] === 'right' ? 'right' : 'left',
|
||||
media: normalizeMedia(block['media']),
|
||||
content: normalizeRichText(block['content']),
|
||||
ctaItems: normalizeNavigationLinks(block['ctaItems'])
|
||||
} satisfies MediaContentBlock;
|
||||
}
|
||||
|
||||
if (type === 'articles') {
|
||||
const items = Array.isArray(block['items'])
|
||||
? block['items'].filter(isRecord).map((item) => ({
|
||||
title: asString(item['title']),
|
||||
content: normalizeRichText(item['content'])
|
||||
}))
|
||||
: [];
|
||||
const cta = normalizeNavigationLink(block['cta']);
|
||||
const articlesBlock: ArticlesBlock = {
|
||||
id,
|
||||
type,
|
||||
title: asString(block['title']),
|
||||
subtitle: normalizeRichText(block['subtitle']),
|
||||
items
|
||||
};
|
||||
|
||||
if (cta !== null) articlesBlock.cta = cta;
|
||||
|
||||
return articlesBlock;
|
||||
}
|
||||
|
||||
if (type === 'banner') {
|
||||
const rawStyle = asString(block['style']);
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
style: BANNER_STYLES.has(rawStyle as BannerStyle) ? (rawStyle as BannerStyle) : 'info',
|
||||
content: normalizeRichText(block['content']),
|
||||
ctaItems: normalizeNavigationLinks(block['ctaItems'])
|
||||
} satisfies BannerBlock;
|
||||
}
|
||||
|
||||
if (type === 'media') {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
media: normalizeMedia(block['media'])
|
||||
} satisfies MediaBlock;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
data: block
|
||||
} satisfies UnknownBlock;
|
||||
}
|
||||
|
||||
function normalizeBlocks(value: unknown): PageBlock[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((block, index) => normalizeBlock(block, index))
|
||||
.filter((block): block is PageBlock => block !== null);
|
||||
}
|
||||
|
||||
function normalizeLayout(value: unknown, fallbackValue: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
return asString(value['type'], 'landing');
|
||||
}
|
||||
|
||||
return asString(fallbackValue, 'landing');
|
||||
}
|
||||
|
||||
export function normalizePage(value: unknown, request: PageRequest): PageDocument | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPageIdentity =
|
||||
value['id'] !== undefined ||
|
||||
typeof value['path'] === 'string' ||
|
||||
typeof value['title'] === 'string';
|
||||
if (!hasPageIdentity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = normalizePath(asString(value['path'], request.path));
|
||||
const finalPathSegment = path.split('/').at(-1) ?? '';
|
||||
const locale = asString(value['locale'], request.locale);
|
||||
const hero = normalizeHero(value['hero']);
|
||||
const seoValue = isRecord(value['seo']) ? value['seo'] : {};
|
||||
const title = asString(value['title'], hero?.title ?? finalPathSegment);
|
||||
const page: PageDocument = {
|
||||
id: String(value['id'] ?? `${locale}:${path || 'index'}`),
|
||||
status: asString(value['status'], 'published'),
|
||||
locale,
|
||||
path,
|
||||
groupKey: asString(value['groupKey'], finalPathSegment),
|
||||
layout: normalizeLayout(value['layout'], value['pageType']),
|
||||
title,
|
||||
seo: {
|
||||
title: asString(seoValue['title'] ?? value['seoTitle'], title),
|
||||
description: asString(seoValue['description'] ?? value['seoDescription'])
|
||||
},
|
||||
breadcrumbs: normalizeBreadcrumbs(value['breadcrumbs']),
|
||||
blocks: normalizeBlocks(value['blocks'] ?? value['components']),
|
||||
publishedAt: asOptionalString(value['publishedAt']) ?? null,
|
||||
updatedAt: asOptionalString(value['updatedAt']) ?? null
|
||||
};
|
||||
|
||||
if (hero !== undefined) page.hero = hero;
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
function normalizeMenus(value: unknown): SiteMenu[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(isRecord).map((menu) => ({
|
||||
title: asString(menu['title']).trim(),
|
||||
items: normalizeNavigationLinks(menu['items'])
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEmptySiteSettings(locale: string): SiteSettings {
|
||||
return {
|
||||
locale,
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '', menus: [] },
|
||||
legal: { privacyPath: '', termsPath: '' }
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSiteSettings(value: unknown, locale: string): SiteSettings | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = isRecord(value['header']) ? value['header'] : {};
|
||||
const footer = isRecord(value['footer']) ? value['footer'] : {};
|
||||
const legal = isRecord(value['legal']) ? value['legal'] : {};
|
||||
|
||||
return {
|
||||
locale: asString(value['locale'], locale),
|
||||
header: {
|
||||
menus: normalizeMenus(header['menus']),
|
||||
ctaItems: normalizeNavigationLinks(header['ctaItems'])
|
||||
},
|
||||
footer: {
|
||||
copyright: asString(footer['copyright']),
|
||||
menus: normalizeMenus(footer['menus'])
|
||||
},
|
||||
legal: {
|
||||
privacyPath: asString(legal['privacyPath']),
|
||||
termsPath: asString(legal['termsPath'])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLanguageAlternatives(value: unknown): LanguageAlternative[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(isRecord).flatMap((item) => {
|
||||
const locale = asString(item['locale']);
|
||||
const path = normalizePath(asString(item['path']));
|
||||
|
||||
return locale ? [{ locale, path }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePageResolution(
|
||||
value: unknown,
|
||||
request: PageRequest
|
||||
): PageResolution | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = isRecord(value['data']) ? value['data'] : value;
|
||||
const wrappedPage = isRecord(root['page']) ? root['page'] : null;
|
||||
const rawPage = wrappedPage
|
||||
? {
|
||||
...wrappedPage,
|
||||
blocks: root['components'] ?? wrappedPage['blocks'],
|
||||
hero: wrappedPage['hero'] ?? root['hero'],
|
||||
breadcrumbs: root['breadcrumbs'] ?? wrappedPage['breadcrumbs']
|
||||
}
|
||||
: root;
|
||||
const page = normalizePage(rawPage, request);
|
||||
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution: PageResolution = {
|
||||
page,
|
||||
languageAlternatives: normalizeLanguageAlternatives(root['languageAlternatives'])
|
||||
};
|
||||
const settings = normalizeSiteSettings(root['settings'], request.locale);
|
||||
|
||||
if (settings) resolution.settings = settings;
|
||||
|
||||
return resolution;
|
||||
}
|
||||
|
||||
export function normalizeSitemapEntries(payload: unknown): SitemapEntry[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload['items'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload['items'].filter(isRecord).flatMap((item) => {
|
||||
const locale = asString(item['locale']);
|
||||
|
||||
if (!locale) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
locale,
|
||||
path: normalizePath(asString(item['path'])),
|
||||
groupKey: asString(item['groupKey']),
|
||||
pageType: asString(item['pageType']),
|
||||
publishedAt: asOptionalString(item['publishedAt']) ?? null,
|
||||
updatedAt: asOptionalString(item['updatedAt']) ?? null
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function sitemapLastPage(payload: unknown): number {
|
||||
const pagination =
|
||||
isRecord(payload) && isRecord(payload['pagination']) ? payload['pagination'] : {};
|
||||
const lastPage = pagination['lastPage'];
|
||||
|
||||
return typeof lastPage === 'number' && Number.isInteger(lastPage) && lastPage > 0 ? lastPage : 1;
|
||||
}
|
||||
|
||||
export function normalizeRoutePath(path: string): string {
|
||||
return normalizePath(path);
|
||||
}
|
||||
|
||||
export function normalizePageTeaserList(payload: unknown): PageTeaserList {
|
||||
const items = normalizePageTeasers(payload);
|
||||
const rawPagination =
|
||||
isRecord(payload) && isRecord(payload['pagination']) ? payload['pagination'] : {};
|
||||
|
||||
const asPositiveInt = (value: unknown, fallback: number): number => {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: asPositiveInt(rawPagination['page'], 1),
|
||||
perPage: asPositiveInt(rawPagination['perPage'], Math.max(items.length, 1)),
|
||||
total: asPositiveInt(rawPagination['total'], items.length),
|
||||
lastPage: asPositiveInt(rawPagination['lastPage'], 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePageTeasers(payload: unknown): PageTeaser[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload['items'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload['items'].filter(isRecord).flatMap((item) => {
|
||||
const title = asString(item['title']);
|
||||
const path = normalizePath(asString(item['path']));
|
||||
|
||||
if (!title || !path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const teaser: PageTeaser = {
|
||||
id: String(item['id'] ?? path),
|
||||
path,
|
||||
title,
|
||||
description: asString(item['description']),
|
||||
publishedAt: asOptionalString(item['publishedAt']) ?? null
|
||||
};
|
||||
const media = normalizeMedia(item['media']);
|
||||
|
||||
if (media !== undefined) teaser.media = media;
|
||||
|
||||
return [teaser];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { cn } from '@crux-kit/utils';
|
||||
import { getWebRuntimeAPI } from '#web-runtime/api/web-runtime-api';
|
||||
import { MediaImage } from '#web-runtime/components/media-image';
|
||||
import type { PageDocument, PageSearchParams, PageTeaser, PageTeaserPagination } from '#web-runtime/types';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { pagerPages, requestedPage } from './blog-pager';
|
||||
import Breadcrumbs from './Breadcrumbs';
|
||||
import PageHero from './PageHero';
|
||||
|
||||
const POSTS_PER_PAGE = 12;
|
||||
|
||||
function PostCard({
|
||||
post,
|
||||
locale,
|
||||
readMoreLabel
|
||||
}: {
|
||||
post: PageTeaser;
|
||||
locale: string;
|
||||
readMoreLabel: string;
|
||||
}) {
|
||||
const href = toPublicHref(`/${locale}/${post.path}`);
|
||||
const publishedAt = post.publishedAt ? new Date(post.publishedAt) : null;
|
||||
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col rounded-[1.25rem] border border-[#d6d7cf] bg-white/50 transition duration-150 hover:-translate-y-0.5 hover:border-[#172019]/35 motion-reduce:transform-none motion-reduce:transition-none">
|
||||
<a
|
||||
href={href}
|
||||
className="flex flex-1 flex-col gap-4 p-[clamp(1.25rem,2.5vw,1.75rem)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
>
|
||||
{post.media ? (
|
||||
<MediaImage media={post.media} className="aspect-8/5 rounded-xl" />
|
||||
) : (
|
||||
<div className="aspect-8/5 rounded-xl bg-[#e8e6dc]" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col items-start gap-2">
|
||||
{publishedAt && !Number.isNaN(publishedAt.getTime()) ? (
|
||||
<time
|
||||
dateTime={post.publishedAt ?? undefined}
|
||||
className="font-mono text-[0.7rem] font-bold tracking-[0.08em] text-[#5d675f] uppercase"
|
||||
>
|
||||
{new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(publishedAt)}
|
||||
</time>
|
||||
) : null}
|
||||
|
||||
<h2 className="text-[1.35rem] leading-[1.18] font-semibold tracking-[-0.035em]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.description ? (
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-[#5d675f]">
|
||||
{post.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* The whole card is the link; this is its visual call to action. */}
|
||||
<Button asChild variant="inline" size="sm" className="mt-auto pt-2">
|
||||
<span aria-hidden="true">{readMoreLabel} →</span>
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
pagination,
|
||||
basePath,
|
||||
labels
|
||||
}: {
|
||||
pagination: PageTeaserPagination;
|
||||
basePath: string;
|
||||
labels: { pagination: string; previous: string; next: string };
|
||||
}) {
|
||||
if (pagination.lastPage <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageHref = (page: number) => (page <= 1 ? basePath : `${basePath}?page=${page}`);
|
||||
const controlClassName = 'size-11 p-0';
|
||||
const pages = pagerPages(pagination.page, pagination.lastPage);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="mt-[clamp(2.5rem,5vw,4rem)] flex flex-wrap items-center justify-center gap-2"
|
||||
aria-label={labels.pagination}
|
||||
>
|
||||
{pagination.page > 1 ? (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(pagination.page - 1)} rel="prev" aria-label={labels.previous}>
|
||||
←
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className={controlClassName} disabled aria-hidden="true">
|
||||
←
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pages.map((page, index) => {
|
||||
const previousPage = pages[index - 1];
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2" key={page}>
|
||||
{previousPage !== undefined && page - previousPage > 1 ? (
|
||||
<span className="text-[#5d675f]" aria-hidden="true">
|
||||
…
|
||||
</span>
|
||||
) : null}
|
||||
{page === pagination.page ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="primary"
|
||||
className={cn(controlClassName, 'pointer-events-none')}
|
||||
>
|
||||
<span aria-current="page">{page}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(page)}>{page}</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{pagination.page < pagination.lastPage ? (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(pagination.page + 1)} rel="next" aria-label={labels.next}>
|
||||
→
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className={controlClassName} disabled aria-hidden="true">
|
||||
→
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BlogPageLayout({
|
||||
page,
|
||||
searchParams
|
||||
}: {
|
||||
page: PageDocument;
|
||||
searchParams?: PageSearchParams;
|
||||
}) {
|
||||
const t = await getTranslations('Pages.BlogPage');
|
||||
const { items: posts, pagination } = await getWebRuntimeAPI().listBlogPosts({
|
||||
locale: page.locale,
|
||||
page: requestedPage(searchParams),
|
||||
perPage: POSTS_PER_PAGE
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1" data-page-layout={page.layout}>
|
||||
<Breadcrumbs items={page.breadcrumbs} />
|
||||
<PageHero page={page} />
|
||||
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)]"
|
||||
data-page-block="blog-posts"
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-lg text-[#5d675f]">{t('empty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-[45rem]:grid-cols-1">
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
locale={page.locale}
|
||||
readMoreLabel={t('readMore')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pager
|
||||
pagination={pagination}
|
||||
basePath={toPublicHref(`/${page.locale}/${page.path}`)}
|
||||
labels={{
|
||||
pagination: t('pagination'),
|
||||
previous: t('previous'),
|
||||
next: t('next')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { breadcrumbListJsonLd, JsonLdScript } from '#web-runtime/lib/structured-data';
|
||||
import type { Breadcrumb } from '#web-runtime/types';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
|
||||
export default function Breadcrumbs({ items }: { items: Breadcrumb[] }) {
|
||||
if (items.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="border-b border-[#d6d7cf]">
|
||||
<ol className="mx-auto flex w-[min(calc(100%-2rem),76rem)] flex-wrap items-center gap-x-2 gap-y-1 py-3.5 font-mono text-xs font-medium tracking-[0.02em] text-[#5d675f] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li key={`${item.href}-${index}`} className="flex items-center gap-2">
|
||||
{index > 0 ? (
|
||||
<span aria-hidden="true" className="text-[#aab4aa]">
|
||||
/
|
||||
</span>
|
||||
) : null}
|
||||
{isLast ? (
|
||||
<span aria-current="page" className="text-[#172019]">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={toPublicHref(item.href)}
|
||||
className="rounded-sm underline-offset-4 transition-colors hover:text-[#172019] hover:underline focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<JsonLdScript data={breadcrumbListJsonLd(items)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PageBlocks } from '#web-runtime/blocks/registry';
|
||||
import { articleJsonLd, JsonLdScript } from '#web-runtime/lib/structured-data';
|
||||
import type { PageDocument } from '#web-runtime/types';
|
||||
import Breadcrumbs from './Breadcrumbs';
|
||||
import PageHero from './PageHero';
|
||||
|
||||
export default function EditorialPageLayout({ page }: { page: PageDocument }) {
|
||||
const article = articleJsonLd(page);
|
||||
|
||||
return (
|
||||
<main className="flex-1" data-page-layout={page.layout}>
|
||||
{article && <JsonLdScript data={article} />}
|
||||
<Breadcrumbs items={page.breadcrumbs} />
|
||||
<PageHero page={page} />
|
||||
<div className="[&_header]:max-w-184 [&_section_div.text-base]:max-w-184">
|
||||
<PageBlocks blocks={page.blocks} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { LinkList } from '#web-runtime/components/links';
|
||||
import { MediaImage } from '#web-runtime/components/media-image';
|
||||
import { RichTextContent } from '#web-runtime/components/rich-text';
|
||||
import type { PageDocument } from '#web-runtime/types';
|
||||
|
||||
export default function PageHero({ page }: { page: PageDocument }) {
|
||||
const hero = page.hero;
|
||||
|
||||
if (hero?.type === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEditorial = ['blog', 'blog-item'].includes(page.layout);
|
||||
const containerClasses = `mx-auto w-[min(calc(100%_-_2rem),76rem)] max-[45rem]:w-[min(calc(100%_-_1.25rem),76rem)]`;
|
||||
const eyebrowClasses =
|
||||
'mb-5 font-mono text-xs font-bold tracking-[0.12em] text-[#517000] uppercase';
|
||||
const titleClasses = `max-w-[15ch] font-sans font-semibold tracking-[-0.065em] leading-[0.98] ${
|
||||
hero?.type === 'low'
|
||||
? 'text-[clamp(2.4rem,4.7vw,4.5rem)]'
|
||||
: hero?.type === 'middle'
|
||||
? 'text-[clamp(2.7rem,5.4vw,5.2rem)]'
|
||||
: 'text-[clamp(2.75rem,6.2vw,6rem)]'
|
||||
}`;
|
||||
|
||||
if (!hero) {
|
||||
return (
|
||||
<section className="overflow-hidden border-b border-[#d6d7cf]">
|
||||
<div
|
||||
className={`${containerClasses} relative z-10 max-w-200 py-[clamp(2.75rem,6vw,5rem)] ${isEditorial ? 'max-w-184' : ''}`}
|
||||
>
|
||||
<p className={eyebrowClasses}>{page.layout.replace('-', ' ')}</p>
|
||||
<h1 className="max-w-[15ch] text-[clamp(2.4rem,4.7vw,4.5rem)] leading-[0.98] font-semibold tracking-[-0.065em]">
|
||||
{page.title}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const hasMedia = Boolean(hero.media?.url);
|
||||
const impactClasses = {
|
||||
low: 'py-[clamp(2.75rem,6vw,5rem)]',
|
||||
middle: 'py-[clamp(4rem,8vw,7rem)]',
|
||||
high: 'min-h-[min(49rem,calc(100dvh-5rem))] py-[clamp(4rem,9vw,8rem)] max-lg:min-h-0'
|
||||
}[hero.type];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`overflow-hidden border-b border-[#d6d7cf] ${
|
||||
hero.type === 'high'
|
||||
? 'bg-[radial-gradient(circle_at_85%_15%,rgb(199_255_63/23%),transparent_26rem)]'
|
||||
: ''
|
||||
}`}
|
||||
data-hero-impact={hero.type}
|
||||
>
|
||||
<div
|
||||
className={`${containerClasses} grid items-center gap-[clamp(2rem,5vw,5rem)] ${impactClasses} max-[45rem]:py-14 ${
|
||||
hasMedia
|
||||
? 'grid-cols-[minmax(0,1fr)_minmax(20rem,0.85fr)] max-lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.75fr)] max-[45rem]:grid-cols-1'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className={`relative z-10 max-w-200 ${isEditorial ? 'max-w-184' : ''}`}>
|
||||
<p className={eyebrowClasses}>{page.layout.replace('-', ' ')}</p>
|
||||
<h1 className={titleClasses}>{hero.title || page.title}</h1>
|
||||
{hero.subtitle ? (
|
||||
<p className="mt-6 max-w-152 text-[clamp(1.1rem,2vw,1.35rem)] leading-[1.55] text-[#5d675f]">
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
<RichTextContent
|
||||
value={hero.content}
|
||||
className={`mt-5 max-w-156 ${isEditorial ? 'max-w-184' : ''}`}
|
||||
/>
|
||||
<LinkList links={hero.ctaItems} className="mt-8" />
|
||||
</div>
|
||||
{hasMedia ? (
|
||||
<figure className="relative m-0 before:absolute before:-inset-3 before:rotate-2 before:rounded-[2.75rem] before:border before:border-[#172019]/15 before:content-['']">
|
||||
<MediaImage
|
||||
media={hero.media}
|
||||
eager
|
||||
className="relative aspect-4/5 rounded-4xl shadow-[0_1.5rem_4rem_rgb(23_32_25/12%)] max-[45rem]:aspect-4/3"
|
||||
/>
|
||||
</figure>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PageBlocks } from '#web-runtime/blocks/registry';
|
||||
import type { PageDocument } from '#web-runtime/types';
|
||||
import PageHero from './PageHero';
|
||||
|
||||
export default function StandardPageLayout({ page }: { page: PageDocument }) {
|
||||
return (
|
||||
<main className="flex-1" data-page-layout={page.layout}>
|
||||
<PageHero page={page} />
|
||||
<PageBlocks blocks={page.blocks} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PageSearchParams } from '#web-runtime/types';
|
||||
|
||||
export function requestedPage(searchParams?: PageSearchParams): number {
|
||||
const raw = searchParams?.['page'];
|
||||
const value = Number.parseInt(Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? ''), 10);
|
||||
return Number.isNaN(value) || value < 1 ? 1 : value;
|
||||
}
|
||||
|
||||
export function pagerPages(current: number, lastPage: number): number[] {
|
||||
const pages = new Set<number>([1, lastPage]);
|
||||
for (let page = current - 1; page <= current + 1; page++) {
|
||||
if (page >= 1 && page <= lastPage) pages.add(page);
|
||||
}
|
||||
return [...pages].sort((left, right) => left - right);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PageDocument, PageSearchParams } from '#web-runtime/types';
|
||||
import BlogPageLayout from './components/BlogPageLayout';
|
||||
import EditorialPageLayout from './components/EditorialPageLayout';
|
||||
import StandardPageLayout from './components/StandardPageLayout';
|
||||
|
||||
export interface PageLayoutProps {
|
||||
page: PageDocument;
|
||||
searchParams?: PageSearchParams;
|
||||
}
|
||||
|
||||
type PageLayout = ComponentType<PageLayoutProps>;
|
||||
|
||||
export const PAGE_LAYOUT_REGISTRY: Readonly<Record<string, PageLayout>> = {
|
||||
'landing': StandardPageLayout,
|
||||
'blog': BlogPageLayout,
|
||||
'blog-item': EditorialPageLayout
|
||||
};
|
||||
|
||||
export function RegisteredPageLayout({ page, searchParams }: PageLayoutProps) {
|
||||
const Layout = PAGE_LAYOUT_REGISTRY[page.layout] ?? StandardPageLayout;
|
||||
|
||||
return <Layout page={page} searchParams={searchParams} />;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { PageRenderData } from '#web-runtime/types';
|
||||
|
||||
/** Resolves a page's public URL path; injected so this module stays free of routing setup. */
|
||||
export type PublicPathResolver = (locale: string, path: string) => string;
|
||||
|
||||
/**
|
||||
* `x-default` tells search engines which version to serve when no language matches. The
|
||||
* site's default locale is the honest answer; if this page has no version in that locale,
|
||||
* the page itself is the next best thing.
|
||||
*/
|
||||
function xDefaultLocale(data: PageRenderData, defaultLocale: string): string {
|
||||
const hasDefault = data.languageAlternatives.some(
|
||||
(alternative) => alternative.locale === defaultLocale
|
||||
);
|
||||
|
||||
return hasDefault ? defaultLocale : data.page.locale;
|
||||
}
|
||||
|
||||
function languageAlternates(
|
||||
data: PageRenderData,
|
||||
defaultLocale: string,
|
||||
publicPath: PublicPathResolver
|
||||
): Record<string, string> | undefined {
|
||||
const hasTranslations = data.languageAlternatives.some(
|
||||
(alternative) => alternative.locale !== data.page.locale
|
||||
);
|
||||
|
||||
if (!hasTranslations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const languages = Object.fromEntries(
|
||||
data.languageAlternatives.map((alternative) => [
|
||||
alternative.locale,
|
||||
publicPath(alternative.locale, alternative.path)
|
||||
])
|
||||
);
|
||||
const fallbackLocale = xDefaultLocale(data, defaultLocale);
|
||||
const fallbackPath = languages[fallbackLocale];
|
||||
|
||||
if (fallbackPath !== undefined) {
|
||||
languages['x-default'] = fallbackPath;
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero image doubles as the share card. Width and height are included when the backend
|
||||
* knows them, because crawlers use them to pick a layout before fetching the file.
|
||||
*/
|
||||
function shareImages(data: PageRenderData): NonNullable<Metadata['openGraph']>['images'] {
|
||||
const media = data.page.hero?.media;
|
||||
|
||||
if (!media) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
url: media.url,
|
||||
alt: media.alt || data.page.title,
|
||||
...(media.width !== undefined ? { width: media.width } : {}),
|
||||
...(media.height !== undefined ? { height: media.height } : {})
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a resolved public page. Kept separate from the route so it can be tested
|
||||
* without a Next request: everything it needs arrives as an argument.
|
||||
*/
|
||||
export function pageMetadata(
|
||||
data: PageRenderData,
|
||||
defaultLocale: string,
|
||||
publicPath: PublicPathResolver
|
||||
): Metadata {
|
||||
const title = data.page.seo.title || data.page.title;
|
||||
const description = data.page.seo.description || undefined;
|
||||
const canonical = publicPath(data.page.locale, data.page.path);
|
||||
const languages = languageAlternates(data, defaultLocale, publicPath);
|
||||
const images = shareImages(data);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
...(languages ? { languages } : {})
|
||||
},
|
||||
openGraph: {
|
||||
// An editorial page is an article to a crawler; everything else is a plain page.
|
||||
type: data.page.layout === 'blog-item' ? 'article' : 'website',
|
||||
title,
|
||||
description,
|
||||
locale: data.page.locale,
|
||||
url: canonical,
|
||||
...(images ? { images } : {})
|
||||
},
|
||||
twitter: {
|
||||
card: images ? 'summary_large_image' : 'summary',
|
||||
title,
|
||||
description,
|
||||
...(images ? { images } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The public origin of this site, used as Next's `metadataBase`.
|
||||
*/
|
||||
const DEVELOPMENT_FALLBACK = 'http://localhost:3000';
|
||||
|
||||
export function siteUrl(): URL {
|
||||
const configured = process.env['SITE_URL']?.trim();
|
||||
|
||||
if (configured) {
|
||||
return new URL(configured);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('SITE_URL is not set — canonical and Open Graph URLs will point at localhost.');
|
||||
}
|
||||
|
||||
return new URL(DEVELOPMENT_FALLBACK);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import type { SitemapEntry } from '#web-runtime/types';
|
||||
import type { PublicPathResolver } from './page-metadata';
|
||||
|
||||
/**
|
||||
* Turns the backend's page listing into sitemap entries.
|
||||
*
|
||||
* Pages that share a `groupKey` are the same page in different languages, so each entry links
|
||||
* to its siblings through `alternates.languages` — the sitemap equivalent of the `hreflang`
|
||||
* tags on the page itself, and the form Google prefers for multilingual sites.
|
||||
*
|
||||
* Kept free of `next/headers` and routing setup so it can be tested directly: the caller
|
||||
* supplies the path resolver and the absolute origin.
|
||||
*/
|
||||
export function buildSitemap(
|
||||
entries: SitemapEntry[],
|
||||
origin: URL,
|
||||
publicPath: PublicPathResolver
|
||||
): MetadataRoute.Sitemap {
|
||||
const absolute = (locale: string, path: string) => new URL(publicPath(locale, path), origin).href;
|
||||
|
||||
const byGroupKey = new Map<string, SitemapEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.groupKey) continue;
|
||||
|
||||
const group = byGroupKey.get(entry.groupKey);
|
||||
if (group) group.push(entry);
|
||||
else byGroupKey.set(entry.groupKey, [entry]);
|
||||
}
|
||||
|
||||
return entries.map((entry) => {
|
||||
const siblings = byGroupKey.get(entry.groupKey) ?? [];
|
||||
const lastModified = entry.updatedAt ?? entry.publishedAt;
|
||||
const item: MetadataRoute.Sitemap[number] = {
|
||||
url: absolute(entry.locale, entry.path)
|
||||
};
|
||||
|
||||
if (lastModified) {
|
||||
item.lastModified = new Date(lastModified);
|
||||
}
|
||||
|
||||
// A single-locale group has nothing to point at but itself; omit the noise.
|
||||
if (siblings.length > 1) {
|
||||
item.alternates = {
|
||||
languages: Object.fromEntries(
|
||||
siblings.map((sibling) => [sibling.locale, absolute(sibling.locale, sibling.path)])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Breadcrumb, PageDocument } from '#web-runtime/types';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
|
||||
export type JsonLd = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* schema.org `BreadcrumbList` for a resolved breadcrumb trail. Positions are
|
||||
* 1-based. The current page (the last crumb) intentionally omits `item` — per
|
||||
* Google's guidance it is where the user already is, so it carries no URL.
|
||||
*/
|
||||
export function breadcrumbListJsonLd(items: Breadcrumb[]): JsonLd {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
'itemListElement': items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
'position': index + 1,
|
||||
'name': item.label,
|
||||
...(index < items.length - 1 ? { item: toPublicHref(item.href) } : {})
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org `Article` for an editorial page. Only the fields the backend actually knows are
|
||||
* emitted — a date or image invented here would be markup that contradicts the page.
|
||||
*
|
||||
* `dateModified` is omitted when it equals `datePublished`, since repeating it tells a crawler
|
||||
* nothing, and the whole object is skipped by the caller when there is no publication date:
|
||||
* an `Article` without one is markup Google will ignore anyway.
|
||||
*/
|
||||
export function articleJsonLd(page: PageDocument): JsonLd | null {
|
||||
if (!page.publishedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = page.hero?.media?.url;
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': page.seo.title || page.title,
|
||||
...(page.seo.description ? { description: page.seo.description } : {}),
|
||||
'datePublished': page.publishedAt,
|
||||
...(page.updatedAt && page.updatedAt !== page.publishedAt
|
||||
? { dateModified: page.updatedAt }
|
||||
: {}),
|
||||
...(image ? { image: [image] } : {}),
|
||||
'inLanguage': page.locale
|
||||
};
|
||||
}
|
||||
|
||||
export function JsonLdScript({ data }: { data: JsonLd }) {
|
||||
return (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SiteFooter from './chrome/components/SiteFooter';
|
||||
import SiteHeader from './chrome/components/SiteHeader';
|
||||
import { RegisteredPageLayout } from './layouts/registry';
|
||||
import type { PageRenderData, PageSearchParams } from './types';
|
||||
|
||||
export default function PageRenderer({
|
||||
data,
|
||||
searchParams
|
||||
}: {
|
||||
data: PageRenderData;
|
||||
searchParams?: PageSearchParams;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<SiteHeader settings={data.settings} languageAlternatives={data.languageAlternatives} />
|
||||
<RegisteredPageLayout page={data.page} searchParams={searchParams} />
|
||||
<SiteFooter settings={data.settings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
export type PageStatus = 'draft' | 'published';
|
||||
|
||||
export type HeroImpact = 'none' | 'low' | 'middle' | 'high';
|
||||
|
||||
export type NavigationLinkVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'inline';
|
||||
|
||||
export interface NavigationLink {
|
||||
linkType: 'internal' | 'external';
|
||||
pageId: string;
|
||||
externalUrl: string;
|
||||
href?: string;
|
||||
openInNewTab: boolean;
|
||||
label: string;
|
||||
variant: NavigationLinkVariant;
|
||||
}
|
||||
|
||||
export interface RichText {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
url: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface PageHero {
|
||||
type: HeroImpact;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
content?: RichText;
|
||||
ctaItems: NavigationLink[];
|
||||
media?: Media;
|
||||
}
|
||||
|
||||
export interface PageBlockBase {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ContentBlock extends PageBlockBase {
|
||||
type: 'content';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
columns: Array<{
|
||||
content: RichText;
|
||||
ctaItems: NavigationLink[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FaqBlock extends PageBlockBase {
|
||||
type: 'faq';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: Array<{
|
||||
question: string;
|
||||
answer: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MediaContentBlock extends PageBlockBase {
|
||||
type: 'media-content';
|
||||
title: string;
|
||||
mediaPosition: 'left' | 'right';
|
||||
media?: Media;
|
||||
content: RichText;
|
||||
ctaItems: NavigationLink[];
|
||||
}
|
||||
|
||||
export interface ArticlesBlock extends PageBlockBase {
|
||||
type: 'articles';
|
||||
title: string;
|
||||
subtitle: RichText;
|
||||
cta?: NavigationLink;
|
||||
items: Array<{
|
||||
title: string;
|
||||
content: RichText;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type BannerStyle = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface BannerBlock extends PageBlockBase {
|
||||
type: 'banner';
|
||||
style: BannerStyle;
|
||||
content: RichText;
|
||||
ctaItems: NavigationLink[];
|
||||
}
|
||||
|
||||
export interface MediaBlock extends PageBlockBase {
|
||||
type: 'media';
|
||||
media?: Media;
|
||||
}
|
||||
|
||||
export interface UnknownBlock extends PageBlockBase {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PageBlock =
|
||||
| ContentBlock
|
||||
| FaqBlock
|
||||
| MediaContentBlock
|
||||
| ArticlesBlock
|
||||
| BannerBlock
|
||||
| MediaBlock
|
||||
| UnknownBlock;
|
||||
|
||||
export interface PageSeo {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface PageDocument {
|
||||
id: string;
|
||||
status: PageStatus | string;
|
||||
locale: string;
|
||||
path: string;
|
||||
groupKey: string;
|
||||
layout: string;
|
||||
title: string;
|
||||
hero?: PageHero;
|
||||
seo: PageSeo;
|
||||
breadcrumbs: Breadcrumb[];
|
||||
blocks: PageBlock[];
|
||||
publishedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SitemapEntry {
|
||||
locale: string;
|
||||
path: string;
|
||||
groupKey: string;
|
||||
pageType: string;
|
||||
publishedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SiteMenu {
|
||||
title: string;
|
||||
items: NavigationLink[];
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
locale: string;
|
||||
header: {
|
||||
menus: SiteMenu[];
|
||||
ctaItems: NavigationLink[];
|
||||
};
|
||||
footer: {
|
||||
copyright: string;
|
||||
menus: SiteMenu[];
|
||||
};
|
||||
legal: {
|
||||
privacyPath: string;
|
||||
termsPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LanguageAlternative {
|
||||
locale: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PageResolution {
|
||||
page: PageDocument;
|
||||
settings?: SiteSettings;
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}
|
||||
|
||||
export interface PageRenderData {
|
||||
page: PageDocument;
|
||||
settings: SiteSettings;
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}
|
||||
|
||||
export interface PageRequest {
|
||||
locale: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PageTeaser {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
publishedAt: string | null;
|
||||
media?: Media;
|
||||
}
|
||||
|
||||
export interface PageTeaserPagination {
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export interface PageTeaserList {
|
||||
items: PageTeaser[];
|
||||
pagination: PageTeaserPagination;
|
||||
}
|
||||
|
||||
export type PageSearchParams = Record<string, string | string[] | undefined>;
|
||||
Reference in New Issue
Block a user