Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+44
View File
@@ -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];
}
+46
View File
@@ -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
};
}
+153
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+11
View File
@@ -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}` : '/'
});
}
+66
View File
@@ -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 }}
/>
);
}
+508
View File
@@ -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);
}
+24
View File
@@ -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} />;
}
+108
View File
@@ -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 } : {})
}
};
}
+18
View File
@@ -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);
}
+53
View File
@@ -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) }} />
);
}
+20
View File
@@ -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>
);
}
+209
View File
@@ -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>;