TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
BACKEND_API_URL=
|
||||
REVALIDATE_SECRET=
|
||||
SITE_URL=
|
||||
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
build
|
||||
/.next/
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"quoteProps": "consistent",
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"importOrder": [
|
||||
"<BUILTIN_MODULES>",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@crux-kit/(.*)$",
|
||||
"^#web-runtime/(.*)$",
|
||||
"^#(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderCaseSensitive": false,
|
||||
"plugins": [
|
||||
"@ianvs/prettier-plugin-sort-imports",
|
||||
"../../plugins/prettier/one-line-sorted-imports.cjs",
|
||||
"prettier-plugin-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:24-alpine3.22
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all workspaces before installation so npm resolves local packages.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm ci --force
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Web App
|
||||
|
||||
The public website: a Next.js App Router app that server-renders the pages editors publish in [Web Manager](../../docs/core/web-manager/Readme.md). Everything visitors see comes from the backend's public API. Package: `@crux-kit/web-app`.
|
||||
|
||||
Architecture — request flow, layers, backend boundary, rendering registries, i18n, SEO — is documented in the [Next.js web architecture](../../docs/architecture/web/Readme.md).
|
||||
|
||||
| Tech | Version |
|
||||
| ---------- | ---------------------------------------- |
|
||||
| Next.js | 16 (App Router, React Server Components) |
|
||||
| React | 19 |
|
||||
| next-intl | 4 — locale routing and translations |
|
||||
| Tailwind | 4 — CSS-first, no config file |
|
||||
| TypeScript | 5.9, `strict`, `noEmit` |
|
||||
| Vitest | 4 — node environment, no DOM tests |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
| ------------------- | ----------------------- | --------------------------------------------------------------------- |
|
||||
| `BACKEND_API_URL` | yes — throws without it | Backend origin. No `/web-manager/public` suffix. |
|
||||
| `SITE_URL` | in production | Canonical origin. Missing → `http://localhost:3000` + a logged error. |
|
||||
| `REVALIDATE_SECRET` | for purges to work | Must equal the backend's `WEB_REVALIDATE_SECRET`. |
|
||||
|
||||
Copy `.env.example` to `.env`. In Docker the backend URL must use the compose service name (`http://backend:3333`), not `localhost`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run test:web
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
`build` runs `tsc --noEmit` before `next build`, so a type error fails the build.
|
||||
|
||||
Under Docker the `web` service runs `npm run dev` on port 3000 with `.next` on an anonymous volume. That volume survives rebuilds and is the usual cause of a stale bundle — recreate with `docker compose up -d --force-recreate --renew-anon-volumes web`.
|
||||
|
||||
Path alias: `#*` maps to the app root. `vitest.config.ts` mirrors it by hand (Vitest ignores `tsconfig` paths) and stubs `server-only`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/` covers the pure logic (normalization, cache tags, registries, metadata, sitemap, site URL, structured data) in a node environment — component rendering is not covered and would need `@testing-library/react`. `registry.test.ts` keeps a hand-mirrored list of the backend's block and page types and asserts the registries match exactly — the tripwire for a block the CMS can publish but the site cannot draw.
|
||||
|
||||
## Adding Things
|
||||
|
||||
| Task | Where |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| A new CMS block or page type | Spans three apps — follow [Web Manager → Extending](../../docs/core/web-manager/integration.md#extending) |
|
||||
| A non-CMS route | A folder under `app/[locale]/`; keep the logic in `web-runtime/` |
|
||||
| A shared UI primitive | `shared/ui/` — content-agnostic only |
|
||||
| A new locale | Add it to `site.config.ts` and add the five `i18n/translations/*/<locale>.json` files |
|
||||
| A new backend read | A method on `WebRuntimeAPI` with its tags, plus a normalizer for the response |
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { resolvePageRenderData } from '#web-runtime/api/render-data';
|
||||
import { pageMetadata } from '#web-runtime/lib/page-metadata';
|
||||
import PageRenderer from '#web-runtime/renderer';
|
||||
import { getPathname } from '#i18n/navigation';
|
||||
import { routing } from '#i18n/routing';
|
||||
|
||||
interface PageRouteProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
path?: string[];
|
||||
}>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
function routePath(path?: string[]): string {
|
||||
return path?.join('/') ?? '';
|
||||
}
|
||||
|
||||
function publicPath(locale: string, path: string): string {
|
||||
return getPathname({ locale, href: path ? `/${path}` : '/' });
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageRouteProps): Promise<Metadata> {
|
||||
const { locale, path } = await params;
|
||||
const data = await resolvePageRenderData(locale, routePath(path));
|
||||
|
||||
if (!data) {
|
||||
const t = await getTranslations({ locale, namespace: 'Pages.NotFoundPage' });
|
||||
return { title: t('metaTitle') };
|
||||
}
|
||||
|
||||
return pageMetadata(data, routing.defaultLocale, publicPath);
|
||||
}
|
||||
|
||||
export default async function PublicPage({ params, searchParams }: PageRouteProps) {
|
||||
const { locale, path } = await params;
|
||||
const data = await resolvePageRenderData(locale, routePath(path));
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <PageRenderer data={data} searchParams={await searchParams} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { siteUrl } from '#web-runtime/lib/site-url';
|
||||
import { routing } from '#i18n/routing';
|
||||
import '../globals.css';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
||||
|
||||
return {
|
||||
metadataBase: siteUrl(),
|
||||
title: {
|
||||
default: t('title'),
|
||||
template: t('template')
|
||||
},
|
||||
description: t('description')
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}>) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="scroll-smooth bg-[#f4f1e9] motion-reduce:scroll-auto">
|
||||
<body className="min-h-dvh bg-[#f4f1e9] font-sans text-[#172019] antialiased">
|
||||
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '#i18n/navigation';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations('Pages.NotFoundPage');
|
||||
|
||||
return (
|
||||
<main className="grid min-h-dvh place-items-center p-8">
|
||||
<div className="max-w-176">
|
||||
<p className="mb-5 font-mono text-xs font-bold tracking-[0.12em] text-[#517000] uppercase">
|
||||
404
|
||||
</p>
|
||||
<h1 className="max-w-[15ch] text-[clamp(3rem,8vw,6rem)] leading-[0.98] font-semibold tracking-[-0.065em]">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-6 mb-8 text-lg text-[#5d675f]">{t('description')}</p>
|
||||
<Button asChild>
|
||||
<Link href="/">{t('cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { resolvePreviewRenderData } from '#web-runtime/api/render-data';
|
||||
import PageRenderer from '#web-runtime/renderer';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Preview',
|
||||
robots: { index: false, follow: false }
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PreviewRouteProps {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}
|
||||
|
||||
async function PreviewUnavailable() {
|
||||
const t = await getTranslations('Pages.PreviewPage');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<h1 className="text-2xl font-semibold">{t('title')}</h1>
|
||||
<p className="max-w-md text-sm opacity-70">{t('description')}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function PreviewPage({ searchParams }: PreviewRouteProps) {
|
||||
const { token } = await searchParams;
|
||||
|
||||
const data = token ? await resolvePreviewRenderData(token) : null;
|
||||
|
||||
if (!data) {
|
||||
return <PreviewUnavailable />;
|
||||
}
|
||||
|
||||
return <PageRenderer data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
const MAX_TAGS = 50;
|
||||
const MAX_TAG_LENGTH = 256;
|
||||
|
||||
function parseTags(payload: unknown): string[] | null {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = (payload as { tags?: unknown }).tags;
|
||||
|
||||
if (!Array.isArray(tags) || tags.length === 0 || tags.length > MAX_TAGS) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!tags.every((tag) => typeof tag === 'string' && tag.length > 0 && tag.length <= MAX_TAG_LENGTH)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env['REVALIDATE_SECRET'];
|
||||
|
||||
if (!secret || request.headers.get('x-revalidate-secret') !== secret) {
|
||||
return Response.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return Response.json({ message: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tags = parseTags(payload);
|
||||
if (!tags) {
|
||||
return Response.json(
|
||||
{ message: 'Body must contain a non-empty "tags" array of strings' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
revalidateTag(tag, 'max');
|
||||
}
|
||||
|
||||
return Response.json({ revalidated: tags });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { siteUrl } from '#web-runtime/lib/site-url';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const origin = siteUrl();
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
// Preview URLs render unpublished drafts behind a signed token; they must never be indexed.
|
||||
// The page also sends `noindex`, this just saves crawlers the fetch.
|
||||
disallow: '/*/preview'
|
||||
},
|
||||
sitemap: new URL('/sitemap.xml', origin).href
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getWebRuntimeAPI } from '#web-runtime/api/web-runtime-api';
|
||||
import { siteUrl } from '#web-runtime/lib/site-url';
|
||||
import { buildSitemap } from '#web-runtime/lib/sitemap';
|
||||
import { getPathname } from '#i18n/navigation';
|
||||
|
||||
function publicPath(locale: string, path: string): string {
|
||||
return getPathname({ locale, href: path ? `/${path}` : '/' });
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const entries = await getWebRuntimeAPI().listSitemapPages();
|
||||
|
||||
return buildSitemap(entries, siteUrl(), publicPath);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts'])
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { routing } from './routing';
|
||||
|
||||
/**
|
||||
* Internal hrefs in page content are locale-prefixed (`/{locale}/{path}`);
|
||||
* when the site serves unprefixed URLs, the prefix is stripped at render
|
||||
* time so navigation does not bounce through the proxy redirect.
|
||||
*/
|
||||
export function toPublicHref(href: string): string {
|
||||
if (routing.localePrefix !== 'never') {
|
||||
return href;
|
||||
}
|
||||
|
||||
const [, first = '', ...rest] = href.split('/');
|
||||
|
||||
if (!hasLocale(routing.locales, first)) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return `/${rest.join('/')}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './routing';
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { routing } from './routing';
|
||||
|
||||
async function loadTranslationType(type: string, locale: string) {
|
||||
try {
|
||||
return (await import(`./translations/${type}/${locale}.json`)).default;
|
||||
} catch {
|
||||
return (await import(`./translations/${type}/${routing.defaultLocale}.json`)).default;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(locale: string) {
|
||||
const resources = ['messages', 'pages', 'components', 'errors', 'form'].map((type) => {
|
||||
return loadTranslationType(type, locale);
|
||||
});
|
||||
const loaded = await Promise.all(resources);
|
||||
|
||||
return Object.assign({}, ...loaded);
|
||||
}
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
|
||||
const messages = await loadMessages(locale);
|
||||
|
||||
return { locale, messages };
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
import { siteConfig } from '#site.config';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: siteConfig.locales,
|
||||
defaultLocale: siteConfig.defaultLocale,
|
||||
localePrefix: siteConfig.locales.length > 1 ? 'always' : 'never'
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Components": {
|
||||
"Navigation": {
|
||||
"home": "Crux home",
|
||||
"main": "Main navigation",
|
||||
"open": "Open navigation",
|
||||
"close": "Close navigation",
|
||||
"languages": "Languages"
|
||||
},
|
||||
"Footer": {
|
||||
"navigation": "Footer navigation",
|
||||
"legal": "Legal",
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"Errors": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Form": {
|
||||
"fields": {},
|
||||
"actions": {},
|
||||
"validation": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Metadata": {
|
||||
"title": "Crux",
|
||||
"template": "%s | Crux",
|
||||
"description": "Crux web app"
|
||||
},
|
||||
"Common": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"Pages": {
|
||||
"NotFoundPage": {
|
||||
"metaTitle": "Page not found",
|
||||
"title": "That page is not available.",
|
||||
"description": "Check the address or return to the site.",
|
||||
"cta": "Return home"
|
||||
},
|
||||
"BlogPage": {
|
||||
"empty": "No posts have been published yet.",
|
||||
"readMore": "Read more",
|
||||
"pagination": "Blog pages",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page"
|
||||
},
|
||||
"PreviewPage": {
|
||||
"title": "Preview unavailable",
|
||||
"description": "The preview link is invalid or has expired. Open the page in the admin editor and start a new preview."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type components from './translations/components/en.json';
|
||||
import type errors from './translations/errors/en.json';
|
||||
import type form from './translations/form/en.json';
|
||||
import type messages from './translations/messages/en.json';
|
||||
import type pages from './translations/pages/en.json';
|
||||
|
||||
type Messages = typeof messages & typeof pages & typeof components & typeof errors & typeof form;
|
||||
|
||||
declare module 'next-intl' {
|
||||
interface AppConfig {
|
||||
Messages: Messages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@crux-kit/web-app",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "tsc --noEmit && next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run tests",
|
||||
"lint": "node ../../scripts/eslint-with-typescript6.cjs .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@crux-kit/hooks": "*",
|
||||
"@crux-kit/intl-date": "*",
|
||||
"@crux-kit/intl-name": "*",
|
||||
"@crux-kit/keenicon": "*",
|
||||
"@crux-kit/local-storage": "*",
|
||||
"@crux-kit/types": "*",
|
||||
"@crux-kit/utils": "*",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"next": "16.2.10",
|
||||
"next-intl": "^4.13.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"vitest": "^4.1.9",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,8 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from '#i18n/routing';
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: '/((?!api|_next|_vercel|.*\\..*).*)'
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@crux-kit/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex cursor-pointer items-center justify-center rounded-full border font-bold whitespace-nowrap transition duration-150 hover:-translate-y-px focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900] disabled:pointer-events-none disabled:opacity-45 motion-reduce:transform-none motion-reduce:transition-none',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'min-h-9 gap-1.5 px-3.5 py-2 text-xs leading-none',
|
||||
md: 'min-h-11 gap-2 px-[1.05rem] py-[0.7rem] text-sm leading-none',
|
||||
lg: 'min-h-13 gap-2 px-6 py-3.5 text-base leading-none'
|
||||
},
|
||||
variant: {
|
||||
primary:
|
||||
'border-[#172019] bg-[#172019] text-white hover:border-[#29382c] hover:bg-[#29382c]',
|
||||
secondary: 'border-[#c7ff3f] bg-[#c7ff3f] text-[#172019] hover:bg-[#b8f51f]',
|
||||
outline:
|
||||
'border-[#172019]/35 bg-transparent text-[#172019] hover:border-[#172019] hover:bg-white/40',
|
||||
ghost:
|
||||
'min-h-0 justify-start rounded-none border-transparent px-0 py-1.5 text-inherit hover:text-[#517000]',
|
||||
inline:
|
||||
'min-h-0 justify-start rounded-none border-transparent px-0 py-1.5 text-[#517000] underline-offset-4 hover:text-[#3f5700] hover:underline'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
type ButtonProps = React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
type={asChild ? undefined : type}
|
||||
className={cn(buttonVariants({ size, variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
@@ -0,0 +1,9 @@
|
||||
interface SiteConfig {
|
||||
locales: readonly [string, ...string[]];
|
||||
defaultLocale: string;
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
locales: ['en', 'uk', 'de'],
|
||||
defaultLocale: 'en'
|
||||
} as const satisfies SiteConfig;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pagerPages, requestedPage } from '#web-runtime/layouts/components/blog-pager';
|
||||
|
||||
describe('requestedPage', () => {
|
||||
it('defaults to page 1 with no search params at all', () => {
|
||||
expect(requestedPage()).toBe(1);
|
||||
expect(requestedPage({})).toBe(1);
|
||||
});
|
||||
|
||||
it('parses a plain string value', () => {
|
||||
expect(requestedPage({ page: '3' })).toBe(3);
|
||||
});
|
||||
|
||||
it('takes the first entry when the param is repeated', () => {
|
||||
expect(requestedPage({ page: ['4', '9'] })).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to 1 for an empty repeated param', () => {
|
||||
expect(requestedPage({ page: [] })).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 1 for values that are not numbers', () => {
|
||||
expect(requestedPage({ page: 'abc' })).toBe(1);
|
||||
expect(requestedPage({ page: '' })).toBe(1);
|
||||
expect(requestedPage({ page: undefined })).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 1 for zero and negative pages', () => {
|
||||
expect(requestedPage({ page: '0' })).toBe(1);
|
||||
expect(requestedPage({ page: '-2' })).toBe(1);
|
||||
});
|
||||
|
||||
it('truncates decimals via parseInt (pinned behavior)', () => {
|
||||
expect(requestedPage({ page: '2.9' })).toBe(2);
|
||||
});
|
||||
|
||||
it('accepts numbers with trailing junk, as parseInt does (pinned behavior)', () => {
|
||||
expect(requestedPage({ page: '3abc' })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagerPages', () => {
|
||||
it('shows a single page as itself', () => {
|
||||
expect(pagerPages(1, 1)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('shows every page when the range is small', () => {
|
||||
expect(pagerPages(2, 3)).toEqual([1, 2, 3]);
|
||||
expect(pagerPages(3, 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('windows a large range around the current page, keeping the first and last', () => {
|
||||
expect(pagerPages(5, 10)).toEqual([1, 4, 5, 6, 10]);
|
||||
});
|
||||
|
||||
it('anchors the window at the start when on page 1', () => {
|
||||
expect(pagerPages(1, 10)).toEqual([1, 2, 10]);
|
||||
});
|
||||
|
||||
it('anchors the window at the end when on the last page', () => {
|
||||
expect(pagerPages(10, 10)).toEqual([1, 9, 10]);
|
||||
});
|
||||
|
||||
it('deduplicates when the window overlaps the anchors', () => {
|
||||
expect(pagerPages(2, 2)).toEqual([1, 2]);
|
||||
expect(pagerPages(2, 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('always returns a sorted ascending list', () => {
|
||||
const pages = pagerPages(37, 80);
|
||||
expect(pages).toEqual([...pages].sort((a, b) => a - b));
|
||||
expect(pages).toEqual([1, 36, 37, 38, 80]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { blogCacheTag, blogListCacheTags, pageCacheTag, pageCacheTags, settingsCacheTag, settingsCacheTags, WEB_MANAGER_TAG } from '#web-runtime/api/cache-tags';
|
||||
|
||||
/**
|
||||
* These strings are a contract with the backend: it purges by the exact names the web app
|
||||
* labels its fetches with. The literals are duplicated in
|
||||
* `apps/backend/app/base_modules/WebManager/services/web_revalidation_service.ts`, and the
|
||||
* backend has a matching spec — change one side without the other and published content
|
||||
* silently keeps serving from cache until the 60s window expires.
|
||||
*/
|
||||
describe('cache tag names', () => {
|
||||
it('matches the names the backend purges', () => {
|
||||
expect(pageCacheTag('en', 'pricing')).toBe('web-manager:page:en:pricing');
|
||||
expect(settingsCacheTag('uk')).toBe('web-manager:settings:uk');
|
||||
expect(blogCacheTag('de')).toBe('web-manager:blog:de');
|
||||
});
|
||||
|
||||
it('gives the home page a tag that cannot collide with a real path', () => {
|
||||
expect(pageCacheTag('en', '')).toBe('web-manager:page:en:/');
|
||||
expect(pageCacheTag('en', '')).not.toBe(pageCacheTag('en', 'index'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache tag sets', () => {
|
||||
it('puts the site-wide tag on every fetch so a structural purge reaches all of them', () => {
|
||||
expect(pageCacheTags('en', 'about')).toContain(WEB_MANAGER_TAG);
|
||||
expect(blogListCacheTags('en')).toContain(WEB_MANAGER_TAG);
|
||||
expect(settingsCacheTags('en')).toContain(WEB_MANAGER_TAG);
|
||||
});
|
||||
|
||||
it('tags a page with its own settings, since the page payload embeds them', () => {
|
||||
expect(pageCacheTags('en', 'about')).toContain(settingsCacheTag('en'));
|
||||
});
|
||||
|
||||
it('keeps locales apart', () => {
|
||||
expect(pageCacheTags('en', 'about')).not.toContain(pageCacheTag('uk', 'about'));
|
||||
expect(settingsCacheTags('en')).not.toContain(settingsCacheTag('uk'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createEmptySiteSettings, normalizePage, normalizePageResolution, normalizePageTeaserList, normalizeRoutePath, normalizeSiteSettings } from '#web-runtime/data/normalize';
|
||||
|
||||
const request = { locale: 'en', path: 'about' };
|
||||
|
||||
/**
|
||||
* Everything the public site renders passes through here, and it is the only guard between
|
||||
* an unexpected backend payload and a crashed page. So the rule these tests hold it to is:
|
||||
* degrade, never throw.
|
||||
*/
|
||||
describe('normalizePageResolution', () => {
|
||||
/** The exact envelope `GET /web-manager/public/pages/resolve` returns. */
|
||||
const backendPayload = {
|
||||
page: {
|
||||
id: 61,
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'blog/api-webhooks',
|
||||
groupKey: 'blog-api-webhooks',
|
||||
layout: 'blog-item',
|
||||
pageType: 'blog-item',
|
||||
title: 'Working with webhooks',
|
||||
hero: { type: 'low', title: 'Working with webhooks', ctaItems: [] },
|
||||
seo: { title: 'Working with webhooks — Crux', description: 'A seeded page.' }
|
||||
},
|
||||
components: [
|
||||
{ id: 238, type: 'content', data: { title: 'Introduction', subtitle: '', columns: [] } }
|
||||
],
|
||||
settings: { locale: 'en', header: { menus: [] }, footer: {}, legal: {} },
|
||||
breadcrumbs: [
|
||||
{ label: 'Home', href: '/en' },
|
||||
{ label: 'Blog', href: '/en/blog' }
|
||||
],
|
||||
languageAlternatives: [{ locale: 'de', path: 'blog/api-webhooks' }]
|
||||
};
|
||||
|
||||
it('reads the wrapped shape the backend actually sends', () => {
|
||||
const resolution = normalizePageResolution(backendPayload, request);
|
||||
|
||||
expect(resolution?.page.id).toBe('61');
|
||||
expect(resolution?.page.layout).toBe('blog-item');
|
||||
expect(resolution?.page.title).toBe('Working with webhooks');
|
||||
expect(resolution?.page.blocks).toHaveLength(1);
|
||||
expect(resolution?.page.breadcrumbs.map((crumb) => crumb.label)).toEqual(['Home', 'Blog']);
|
||||
expect(resolution?.languageAlternatives).toEqual([{ locale: 'de', path: 'blog/api-webhooks' }]);
|
||||
expect(resolution?.settings?.locale).toBe('en');
|
||||
});
|
||||
|
||||
it('unwraps a `data` envelope, so a wrapped response still renders', () => {
|
||||
const wrapped = normalizePageResolution({ data: backendPayload }, request);
|
||||
|
||||
expect(wrapped?.page.path).toBe('blog/api-webhooks');
|
||||
});
|
||||
|
||||
it('returns null for anything that is not a page', () => {
|
||||
expect(normalizePageResolution(null, request)).toBeNull();
|
||||
expect(normalizePageResolution('nope', request)).toBeNull();
|
||||
expect(normalizePageResolution({}, request)).toBeNull();
|
||||
expect(normalizePageResolution({ page: {} }, request)).toBeNull();
|
||||
});
|
||||
|
||||
it('omits settings rather than inventing them when the payload has none', () => {
|
||||
const resolution = normalizePageResolution({ ...backendPayload, settings: null }, request);
|
||||
|
||||
expect(resolution?.settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePage', () => {
|
||||
it('falls back to the request and the hero when fields are missing', () => {
|
||||
const page = normalizePage({ title: 'About us' }, request);
|
||||
|
||||
expect(page?.locale).toBe('en');
|
||||
expect(page?.path).toBe('about');
|
||||
expect(page?.status).toBe('published');
|
||||
expect(page?.layout).toBe('landing');
|
||||
expect(page?.seo.title).toBe('About us');
|
||||
expect(page?.seo.description).toBe('');
|
||||
});
|
||||
|
||||
it('takes the seo title from the flat backend fields too', () => {
|
||||
const page = normalizePage(
|
||||
{ title: 'About us', seoTitle: 'About us — Crux', seoDescription: 'Who we are' },
|
||||
request
|
||||
);
|
||||
|
||||
expect(page?.seo).toEqual({ title: 'About us — Crux', description: 'Who we are' });
|
||||
});
|
||||
|
||||
it('strips stray slashes from the path', () => {
|
||||
const page = normalizePage({ title: 'x', path: '/blog/post/' }, request);
|
||||
|
||||
expect(page?.path).toBe('blog/post');
|
||||
});
|
||||
|
||||
it('reads blocks from either `blocks` or the backend `components` key', () => {
|
||||
const fromComponents = normalizePage(
|
||||
{ title: 'x', components: [{ type: 'banner', data: { style: 'info' } }] },
|
||||
request
|
||||
);
|
||||
const fromBlocks = normalizePage(
|
||||
{ title: 'x', blocks: [{ type: 'banner', style: 'info' }] },
|
||||
request
|
||||
);
|
||||
|
||||
expect(fromComponents?.blocks[0]?.type).toBe('banner');
|
||||
expect(fromBlocks?.blocks[0]?.type).toBe('banner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('block normalization', () => {
|
||||
const blocksOf = (blocks: unknown[]) =>
|
||||
normalizePage({ title: 'x', blocks }, request)?.blocks ?? [];
|
||||
|
||||
it('keeps an unknown block instead of dropping it, so the page shows it is unsupported', () => {
|
||||
const [block] = blocksOf([{ type: 'carousel', slides: 3 }]);
|
||||
|
||||
expect(block?.type).toBe('carousel');
|
||||
expect(block).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('drops entries that cannot be a block at all', () => {
|
||||
expect(blocksOf([null, 'text', 42, {}, { data: {} }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives every block a stable id, falling back to its position', () => {
|
||||
const blocks = blocksOf([{ type: 'banner' }, { id: 9, type: 'banner' }]);
|
||||
|
||||
expect(blocks[0]?.id).toBe('banner-1');
|
||||
expect(blocks[1]?.id).toBe('9');
|
||||
});
|
||||
|
||||
it('falls back to safe defaults for unknown enum values', () => {
|
||||
const [banner] = blocksOf([{ type: 'banner', style: 'chartreuse' }]);
|
||||
const [mediaContent] = blocksOf([{ type: 'media-content', mediaPosition: 'sideways' }]);
|
||||
|
||||
expect(banner).toMatchObject({ style: 'info' });
|
||||
expect(mediaContent).toMatchObject({ mediaPosition: 'left' });
|
||||
});
|
||||
|
||||
it('accepts partial block data without throwing', () => {
|
||||
expect(() =>
|
||||
blocksOf([{ type: 'content' }, { type: 'faq' }, { type: 'articles' }])
|
||||
).not.toThrow();
|
||||
|
||||
const [content, faq, articles] = blocksOf([
|
||||
{ type: 'content' },
|
||||
{ type: 'faq' },
|
||||
{ type: 'articles' }
|
||||
]);
|
||||
|
||||
expect(content).toMatchObject({ columns: [] });
|
||||
expect(faq).toMatchObject({ items: [] });
|
||||
expect(articles).toMatchObject({ items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('media normalization', () => {
|
||||
const mediaOf = (media: unknown) =>
|
||||
normalizePage({ title: 'x', blocks: [{ type: 'media', media }] }, request)?.blocks[0];
|
||||
|
||||
it('drops media that has no url, since an empty <img> is worse than nothing', () => {
|
||||
expect(mediaOf({ alt: 'No file' })).toMatchObject({ media: undefined });
|
||||
expect(mediaOf(null)).toMatchObject({ media: undefined });
|
||||
});
|
||||
|
||||
it('keeps optional dimensions only when they are real numbers', () => {
|
||||
expect(mediaOf({ url: '/a.jpg', width: 800, height: 525 })).toMatchObject({
|
||||
media: { url: '/a.jpg', alt: '', width: 800, height: 525 }
|
||||
});
|
||||
expect(mediaOf({ url: '/a.jpg', width: 'wide', height: Number.NaN })).toMatchObject({
|
||||
media: { url: '/a.jpg', alt: '' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation link normalization', () => {
|
||||
const linksOf = (ctaItems: unknown) =>
|
||||
normalizePage({ title: 'x', blocks: [{ type: 'banner', ctaItems }] }, request)?.blocks[0];
|
||||
|
||||
it('drops links with no label, which would render as an invisible target', () => {
|
||||
expect(linksOf([{ href: '/en/about' }, { label: '', href: '/x' }])).toMatchObject({
|
||||
ctaItems: []
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an unknown variant and link type rather than rendering nothing', () => {
|
||||
expect(linksOf([{ label: 'Go', variant: 'sparkly', linkType: 'telepathic' }])).toMatchObject({
|
||||
ctaItems: [{ variant: 'ghost', linkType: 'external' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the href under any of the keys the backend has used', () => {
|
||||
expect(linksOf([{ label: 'a', href: '/1' }])).toMatchObject({ ctaItems: [{ href: '/1' }] });
|
||||
expect(linksOf([{ label: 'b', url: '/2' }])).toMatchObject({ ctaItems: [{ href: '/2' }] });
|
||||
expect(linksOf([{ label: 'c', path: '/3' }])).toMatchObject({ ctaItems: [{ href: '/3' }] });
|
||||
});
|
||||
|
||||
it('normalizes a numeric pageId to a string so lookups match', () => {
|
||||
expect(linksOf([{ label: 'a', linkType: 'internal', pageId: 13 }])).toMatchObject({
|
||||
ctaItems: [{ pageId: '13' }]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('site settings normalization', () => {
|
||||
it('fills in every section a payload leaves out', () => {
|
||||
const settings = normalizeSiteSettings({ locale: 'uk' }, 'en');
|
||||
|
||||
expect(settings).toEqual({
|
||||
locale: 'uk',
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '', menus: [] },
|
||||
legal: { privacyPath: '', termsPath: '' }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a non-object, letting the caller substitute empty settings', () => {
|
||||
expect(normalizeSiteSettings(null, 'en')).toBeNull();
|
||||
expect(createEmptySiteSettings('en').locale).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog teaser list normalization', () => {
|
||||
it('reads items and pagination from the backend payload', () => {
|
||||
const list = normalizePageTeaserList({
|
||||
items: [
|
||||
{
|
||||
id: 40,
|
||||
title: 'Getting started',
|
||||
path: '/blog/getting-started',
|
||||
publishedAt: '2026-01-01'
|
||||
}
|
||||
],
|
||||
pagination: { page: 2, perPage: 12, total: 30, lastPage: 3 }
|
||||
});
|
||||
|
||||
expect(list.items).toEqual([
|
||||
{
|
||||
id: '40',
|
||||
path: 'blog/getting-started',
|
||||
title: 'Getting started',
|
||||
description: '',
|
||||
publishedAt: '2026-01-01'
|
||||
}
|
||||
]);
|
||||
expect(list.pagination).toEqual({ page: 2, perPage: 12, total: 30, lastPage: 3 });
|
||||
});
|
||||
|
||||
it('drops teasers that could not be linked to', () => {
|
||||
const list = normalizePageTeaserList({
|
||||
items: [{ title: 'No path' }, { path: 'no-title' }, { title: 'Good', path: 'good' }]
|
||||
});
|
||||
|
||||
expect(list.items.map((item) => item.title)).toEqual(['Good']);
|
||||
});
|
||||
|
||||
it('survives a missing or nonsense pagination block', () => {
|
||||
expect(normalizePageTeaserList({ items: [] }).pagination).toEqual({
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
total: 0,
|
||||
lastPage: 1
|
||||
});
|
||||
expect(
|
||||
normalizePageTeaserList({ items: [], pagination: { page: -3, total: 'lots' } })
|
||||
).toMatchObject({
|
||||
pagination: { page: 1, total: 0 }
|
||||
});
|
||||
expect(normalizePageTeaserList(null).items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRoutePath', () => {
|
||||
it('produces the same path shape the backend stores', () => {
|
||||
expect(normalizeRoutePath('/blog/post/')).toBe('blog/post');
|
||||
expect(normalizeRoutePath('')).toBe('');
|
||||
expect(normalizeRoutePath('/')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pageMetadata } from '#web-runtime/lib/page-metadata';
|
||||
import type { PageDocument, PageRenderData, SiteSettings } from '#web-runtime/types';
|
||||
|
||||
const settings: SiteSettings = {
|
||||
locale: 'en',
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '', menus: [] },
|
||||
legal: { privacyPath: '', termsPath: '' }
|
||||
};
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '7',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
layout: 'landing',
|
||||
title: 'About us',
|
||||
seo: { title: 'About us — Crux', description: 'Who we are' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function renderData(overrides: Partial<PageRenderData> = {}): PageRenderData {
|
||||
return {
|
||||
page: page(),
|
||||
settings,
|
||||
languageAlternatives: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** The site serves locale-prefixed paths; metadataBase turns these into absolute URLs. */
|
||||
const publicPath = (locale: string, path: string) => `/${locale}${path ? `/${path}` : ''}`;
|
||||
|
||||
describe('title and description', () => {
|
||||
it('prefers the editor SEO fields', () => {
|
||||
const metadata = pageMetadata(renderData(), 'en', publicPath);
|
||||
|
||||
expect(metadata.title).toBe('About us — Crux');
|
||||
expect(metadata.description).toBe('Who we are');
|
||||
});
|
||||
|
||||
it('falls back to the page title, and omits an empty description entirely', () => {
|
||||
const data = renderData({ page: page({ seo: { title: '', description: '' } }) });
|
||||
const metadata = pageMetadata(data, 'en', publicPath);
|
||||
|
||||
expect(metadata.title).toBe('About us');
|
||||
expect(metadata.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical and hreflang', () => {
|
||||
it('always sets a canonical for the page itself', () => {
|
||||
expect(pageMetadata(renderData(), 'en', publicPath).alternates?.canonical).toBe('/en/about');
|
||||
});
|
||||
|
||||
it('emits no language map for a page with no translations', () => {
|
||||
const data = renderData({ languageAlternatives: [{ locale: 'en', path: 'about' }] });
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists every locale including this one, plus x-default', () => {
|
||||
const data = renderData({
|
||||
languageAlternatives: [
|
||||
{ locale: 'de', path: 'ueber-uns' },
|
||||
{ locale: 'en', path: 'about' },
|
||||
{ locale: 'uk', path: 'pro-nas' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toEqual({
|
||||
'de': '/de/ueber-uns',
|
||||
'en': '/en/about',
|
||||
'uk': '/uk/pro-nas',
|
||||
'x-default': '/en/about'
|
||||
});
|
||||
});
|
||||
|
||||
it('points x-default at this page when the default locale has no translation', () => {
|
||||
const data = renderData({
|
||||
page: page({ locale: 'uk', path: 'pro-nas' }),
|
||||
languageAlternatives: [
|
||||
{ locale: 'uk', path: 'pro-nas' },
|
||||
{ locale: 'de', path: 'ueber-uns' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toMatchObject({
|
||||
'x-default': '/uk/pro-nas'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('share cards', () => {
|
||||
const media = {
|
||||
url: 'https://cdn.example.com/hero.jpg',
|
||||
alt: 'Team photo',
|
||||
width: 800,
|
||||
height: 525
|
||||
};
|
||||
|
||||
it('uses the hero image, with the dimensions crawlers lay out against', () => {
|
||||
const data = renderData({ page: page({ hero: { type: 'middle', ctaItems: [], media } }) });
|
||||
const metadata = pageMetadata(data, 'en', publicPath);
|
||||
|
||||
expect(metadata.openGraph?.images).toEqual([
|
||||
{ url: media.url, alt: 'Team photo', width: 800, height: 525 }
|
||||
]);
|
||||
expect(metadata.twitter).toMatchObject({ card: 'summary_large_image' });
|
||||
});
|
||||
|
||||
it('falls back to the page title when the image has no alt text', () => {
|
||||
const data = renderData({
|
||||
page: page({ hero: { type: 'middle', ctaItems: [], media: { ...media, alt: '' } } })
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).openGraph?.images).toMatchObject([
|
||||
{ alt: 'About us' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits dimensions the backend did not provide rather than guessing', () => {
|
||||
const data = renderData({
|
||||
page: page({ hero: { type: 'middle', ctaItems: [], media: { url: media.url, alt: 'x' } } })
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).openGraph?.images).toEqual([
|
||||
{ url: media.url, alt: 'x' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops to a small card when the page has no hero image', () => {
|
||||
const metadata = pageMetadata(renderData(), 'en', publicPath);
|
||||
|
||||
expect(metadata.openGraph?.images).toBeUndefined();
|
||||
expect(metadata.twitter).toMatchObject({ card: 'summary' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('open graph type', () => {
|
||||
it('marks an editorial page as an article and everything else as a page', () => {
|
||||
const article = renderData({ page: page({ layout: 'blog-item' }) });
|
||||
const landing = renderData({ page: page({ layout: 'landing' }) });
|
||||
|
||||
expect(pageMetadata(article, 'en', publicPath).openGraph).toMatchObject({ type: 'article' });
|
||||
expect(pageMetadata(landing, 'en', publicPath).openGraph).toMatchObject({ type: 'website' });
|
||||
});
|
||||
|
||||
it('carries the page locale and its own url', () => {
|
||||
expect(pageMetadata(renderData(), 'en', publicPath).openGraph).toMatchObject({
|
||||
locale: 'en',
|
||||
url: '/en/about'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PAGE_BLOCK_REGISTRY } from '#web-runtime/blocks/registry';
|
||||
import { normalizePage } from '#web-runtime/data/normalize';
|
||||
import { PAGE_LAYOUT_REGISTRY } from '#web-runtime/layouts/registry';
|
||||
|
||||
/**
|
||||
* The registries are how the renderer decides what to draw. A block type the backend can
|
||||
* publish but the registry does not know renders as "unsupported", and a page type it does
|
||||
* not know silently renders with the wrong layout — neither fails a build, so these are the
|
||||
* checks that catch it.
|
||||
*/
|
||||
|
||||
/** Every block type the backend can store — mirrors `BLOCK_TYPES` in the backend content types. */
|
||||
const BACKEND_BLOCK_TYPES = ['content', 'faq', 'media-content', 'articles', 'banner', 'media'];
|
||||
|
||||
/** Every page type the backend can store — mirrors `PAGE_TYPES`. */
|
||||
const BACKEND_PAGE_TYPES = ['landing', 'blog', 'blog-item'];
|
||||
|
||||
describe('block registry', () => {
|
||||
it.each(BACKEND_BLOCK_TYPES)('renders the %s block the backend can publish', (type) => {
|
||||
expect(PAGE_BLOCK_REGISTRY[type]).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('registers nothing the normalizer cannot produce', () => {
|
||||
expect(Object.keys(PAGE_BLOCK_REGISTRY).sort()).toEqual([...BACKEND_BLOCK_TYPES].sort());
|
||||
});
|
||||
|
||||
it('has no renderer for an unknown type, so it falls through to the unsupported block', () => {
|
||||
expect(PAGE_BLOCK_REGISTRY['carousel']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers every block a normalized page can contain', () => {
|
||||
const page = normalizePage(
|
||||
{ title: 'x', blocks: BACKEND_BLOCK_TYPES.map((type) => ({ type })) },
|
||||
{ locale: 'en', path: 'x' }
|
||||
);
|
||||
|
||||
expect(page?.blocks).toHaveLength(BACKEND_BLOCK_TYPES.length);
|
||||
for (const block of page?.blocks ?? []) {
|
||||
expect(PAGE_BLOCK_REGISTRY[block.type]).toBeTypeOf('function');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout registry', () => {
|
||||
it.each(BACKEND_PAGE_TYPES)('has a layout for the %s page type', (pageType) => {
|
||||
expect(PAGE_LAYOUT_REGISTRY[pageType]).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('leaves an unknown layout to the standard fallback', () => {
|
||||
expect(PAGE_LAYOUT_REGISTRY['microsite']).toBeUndefined();
|
||||
expect(PAGE_LAYOUT_REGISTRY['landing']).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('resolves the layout a normalized page reports', () => {
|
||||
const page = normalizePage({ title: 'x', pageType: 'blog-item' }, { locale: 'en', path: 'x' });
|
||||
|
||||
expect(page?.layout).toBe('blog-item');
|
||||
expect(PAGE_LAYOUT_REGISTRY[page?.layout ?? '']).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolvePageRenderData, resolvePreviewRenderData } from '#web-runtime/api/render-data';
|
||||
import { createEmptySiteSettings } from '#web-runtime/data/normalize';
|
||||
import type { PageDocument, SiteSettings } from '#web-runtime/types';
|
||||
|
||||
const { resolvePage, getSettings, getPreview } = vi.hoisted(() => ({
|
||||
resolvePage: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getPreview: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#web-runtime/api/web-runtime-api', () => ({
|
||||
getWebRuntimeAPI: () => ({ resolvePage, getSettings, getPreview })
|
||||
}));
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '7',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
layout: 'landing',
|
||||
title: 'About us',
|
||||
seo: { title: 'About us', description: '' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function settings(locale = 'en'): SiteSettings {
|
||||
return {
|
||||
locale,
|
||||
header: {
|
||||
menus: [],
|
||||
ctaItems: [
|
||||
{
|
||||
linkType: 'external',
|
||||
pageId: '',
|
||||
externalUrl: 'https://example.com',
|
||||
openInNewTab: false,
|
||||
label: 'CTA',
|
||||
variant: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: { copyright: '© Crux', menus: [] },
|
||||
legal: { privacyPath: 'privacy', termsPath: 'terms' }
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolvePage.mockReset();
|
||||
getSettings.mockReset();
|
||||
getPreview.mockReset();
|
||||
});
|
||||
|
||||
// resolvePageRenderData is wrapped in React cache(); outside an RSC render
|
||||
// react's cache has no store and calls straight through, but each test still
|
||||
// uses a distinct locale/path pair so results can never leak between tests.
|
||||
|
||||
describe('resolvePageRenderData', () => {
|
||||
it('returns null when the backend cannot resolve the page', async () => {
|
||||
resolvePage.mockResolvedValue(null);
|
||||
|
||||
expect(await resolvePageRenderData('en', 'missing')).toBeNull();
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to render a page that is not published (the unpublish gate)', async () => {
|
||||
resolvePage.mockResolvedValue({
|
||||
page: page({ status: 'draft', path: 'draft-page' }),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
expect(await resolvePageRenderData('en', 'draft-page')).toBeNull();
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims the locale and normalizes the path before asking the backend', async () => {
|
||||
resolvePage.mockResolvedValue(null);
|
||||
|
||||
await resolvePageRenderData(' en ', '/about/team/');
|
||||
|
||||
expect(resolvePage).toHaveBeenCalledWith({ locale: 'en', path: 'about/team' });
|
||||
});
|
||||
|
||||
it('uses the settings bundled into the resolution without a second request', async () => {
|
||||
const bundled = settings();
|
||||
resolvePage.mockResolvedValue({
|
||||
page: page({ path: 'bundled' }),
|
||||
settings: bundled,
|
||||
languageAlternatives: [{ locale: 'de', path: 'gebuendelt' }]
|
||||
});
|
||||
|
||||
const data = await resolvePageRenderData('en', 'bundled');
|
||||
|
||||
expect(data?.settings).toBe(bundled);
|
||||
expect(data?.languageAlternatives).toEqual([{ locale: 'de', path: 'gebuendelt' }]);
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches settings separately when the resolution has none', async () => {
|
||||
const fetched = settings();
|
||||
resolvePage.mockResolvedValue({ page: page({ path: 'fetched' }), languageAlternatives: [] });
|
||||
getSettings.mockResolvedValue(fetched);
|
||||
|
||||
const data = await resolvePageRenderData('en', 'fetched');
|
||||
|
||||
expect(getSettings).toHaveBeenCalledWith('en');
|
||||
expect(data?.settings).toBe(fetched);
|
||||
});
|
||||
|
||||
it('falls back to empty settings when the backend has none at all', async () => {
|
||||
resolvePage.mockResolvedValue({ page: page({ path: 'bare' }), languageAlternatives: [] });
|
||||
getSettings.mockResolvedValue(null);
|
||||
|
||||
const data = await resolvePageRenderData('en', 'bare');
|
||||
|
||||
expect(data?.settings).toEqual(createEmptySiteSettings('en'));
|
||||
});
|
||||
|
||||
it('returns the resolved page unchanged', async () => {
|
||||
const resolved = page({ path: 'unchanged' });
|
||||
resolvePage.mockResolvedValue({
|
||||
page: resolved,
|
||||
settings: settings(),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePageRenderData('en', 'unchanged');
|
||||
|
||||
expect(data?.page).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePreviewRenderData', () => {
|
||||
it('returns null for an empty token without calling the backend', async () => {
|
||||
expect(await resolvePreviewRenderData('')).toBeNull();
|
||||
expect(getPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the token resolves to nothing', async () => {
|
||||
getPreview.mockResolvedValue(null);
|
||||
|
||||
expect(await resolvePreviewRenderData('token-1')).toBeNull();
|
||||
expect(getPreview).toHaveBeenCalledWith('token-1');
|
||||
});
|
||||
|
||||
it('renders a draft: previews deliberately skip the published gate', async () => {
|
||||
getPreview.mockResolvedValue({
|
||||
page: page({ status: 'draft' }),
|
||||
settings: settings(),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePreviewRenderData('token-2');
|
||||
|
||||
expect(data?.page.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('falls back to empty settings in the locale of the previewed page', async () => {
|
||||
getPreview.mockResolvedValue({
|
||||
page: page({ locale: 'de', status: 'draft' }),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePreviewRenderData('token-3');
|
||||
|
||||
expect(data?.settings).toEqual(createEmptySiteSettings('de'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { POST } from '#app/api/revalidate/route';
|
||||
|
||||
const { revalidateTag } = vi.hoisted(() => ({ revalidateTag: vi.fn() }));
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidateTag }));
|
||||
|
||||
const SECRET = 'test-revalidate-secret';
|
||||
|
||||
function request({
|
||||
secret = SECRET,
|
||||
body = JSON.stringify({ tags: ['web-manager'] })
|
||||
}: { secret?: string | null; body?: string } = {}) {
|
||||
const headers = new Headers({ 'content-type': 'application/json' });
|
||||
if (secret !== null) {
|
||||
headers.set('x-revalidate-secret', secret);
|
||||
}
|
||||
|
||||
return new Request('http://web.test/api/revalidate', { method: 'POST', headers, body });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('REVALIDATE_SECRET', SECRET);
|
||||
revalidateTag.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('rejects a request with no secret header', async () => {
|
||||
const response = await POST(request({ secret: null }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a wrong secret', async () => {
|
||||
const response = await POST(request({ secret: 'wrong' }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects everything when the server has no secret configured — even a matching empty header', async () => {
|
||||
vi.stubEnv('REVALIDATE_SECRET', '');
|
||||
|
||||
expect((await POST(request({ secret: '' }))).status).toBe(401);
|
||||
expect((await POST(request())).status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload validation', () => {
|
||||
it('rejects a body that is not JSON', async () => {
|
||||
const response = await POST(request({ body: 'not json {' }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ message: 'Invalid JSON body' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-object payload', JSON.stringify('tags')],
|
||||
['a null payload', JSON.stringify(null)],
|
||||
['a missing tags field', JSON.stringify({})],
|
||||
['tags that are not an array', JSON.stringify({ tags: 'web-manager' })],
|
||||
['an empty tags array', JSON.stringify({ tags: [] })],
|
||||
['a non-string tag', JSON.stringify({ tags: ['ok', 7] })],
|
||||
['an empty-string tag', JSON.stringify({ tags: [''] })]
|
||||
])('rejects %s with 400', async (_label, body) => {
|
||||
const response = await POST(request({ body }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts exactly 50 tags but rejects 51', async () => {
|
||||
const fifty = Array.from({ length: 50 }, (_, i) => `tag-${i}`);
|
||||
|
||||
expect((await POST(request({ body: JSON.stringify({ tags: fifty }) }))).status).toBe(200);
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: [...fifty, 'one-more'] }) }))).status
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a 256-character tag but rejects 257', async () => {
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: ['a'.repeat(256)] }) }))).status
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: ['a'.repeat(257)] }) }))).status
|
||||
).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidation', () => {
|
||||
it('revalidates every tag with the max profile and echoes them back', async () => {
|
||||
const tags = ['web-manager', 'web-manager:page:en:about'];
|
||||
const response = await POST(request({ body: JSON.stringify({ tags }) }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ revalidated: tags });
|
||||
expect(revalidateTag).toHaveBeenCalledTimes(2);
|
||||
expect(revalidateTag).toHaveBeenNthCalledWith(1, 'web-manager', 'max');
|
||||
expect(revalidateTag).toHaveBeenNthCalledWith(2, 'web-manager:page:en:about', 'max');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import robots from '#app/robots';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('robots.txt', () => {
|
||||
it('lets crawlers in everywhere except preview URLs', () => {
|
||||
vi.stubEnv('SITE_URL', 'https://crux.example.com');
|
||||
|
||||
const result = robots();
|
||||
|
||||
expect(result.rules).toEqual({
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/*/preview'
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the sitemap on the configured origin', () => {
|
||||
vi.stubEnv('SITE_URL', 'https://crux.example.com');
|
||||
|
||||
expect(robots().sitemap).toBe('https://crux.example.com/sitemap.xml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHref } from '#web-runtime/components/links';
|
||||
import type { NavigationLink } from '#web-runtime/types';
|
||||
|
||||
/**
|
||||
* `safeHref` is the gate between CMS-authored link data and the `href`
|
||||
* attribute the browser receives, so these tests pin the allowlist hard:
|
||||
* anything not explicitly safe must come back as `null`.
|
||||
*/
|
||||
function link(overrides: Partial<NavigationLink> = {}): NavigationLink {
|
||||
return {
|
||||
linkType: 'external',
|
||||
pageId: '',
|
||||
externalUrl: '',
|
||||
openInNewTab: false,
|
||||
label: 'A link',
|
||||
variant: 'primary',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('allowed protocols', () => {
|
||||
it.each([
|
||||
'http://example.com/page',
|
||||
'https://example.com/page?q=1#frag',
|
||||
'mailto:hello@example.com',
|
||||
'tel:+380441234567'
|
||||
])('passes %s through untouched', (href) => {
|
||||
expect(safeHref(link({ href }))).toBe(href);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangerous protocols', () => {
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/uuid',
|
||||
'ftp://example.com/file'
|
||||
])('rejects %s', (href) => {
|
||||
expect(safeHref(link({ href }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects javascript: regardless of letter case', () => {
|
||||
expect(safeHref(link({ href: 'JavaScript:alert(1)' }))).toBeNull();
|
||||
expect(safeHref(link({ href: 'JAVASCRIPT:alert(1)' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects javascript: hidden behind leading whitespace, which URL parsing strips', () => {
|
||||
expect(safeHref(link({ href: ' javascript:alert(1)' }))).toBeNull();
|
||||
expect(safeHref(link({ href: '\njavascript:alert(1)' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a dangerous externalUrl the same way as a dangerous href', () => {
|
||||
expect(safeHref(link({ externalUrl: 'javascript:alert(1)' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed and missing values', () => {
|
||||
it('rejects plain text that is not a URL at all', () => {
|
||||
expect(safeHref(link({ href: 'not a url' }))).toBeNull();
|
||||
expect(safeHref(link({ href: 'example.com/no-protocol' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither href nor externalUrl is set', () => {
|
||||
expect(safeHref(link())).toBeNull();
|
||||
expect(safeHref(link({ href: '' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('href source precedence', () => {
|
||||
it('prefers href over externalUrl when both exist', () => {
|
||||
expect(
|
||||
safeHref(
|
||||
link({ href: 'https://primary.example.com', externalUrl: 'https://other.example.com' })
|
||||
)
|
||||
).toBe('https://primary.example.com');
|
||||
});
|
||||
|
||||
it('falls back to externalUrl when href is empty', () => {
|
||||
expect(safeHref(link({ href: '', externalUrl: 'https://fallback.example.com' }))).toBe(
|
||||
'https://fallback.example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchors and relative paths', () => {
|
||||
it('passes in-page anchors through untouched', () => {
|
||||
expect(safeHref(link({ href: '#pricing' }))).toBe('#pricing');
|
||||
});
|
||||
|
||||
it('routes site-relative paths through toPublicHref, which keeps the locale prefix in this multi-locale config', () => {
|
||||
// site.config.ts declares en/uk/de, so localePrefix is 'always' and
|
||||
// toPublicHref is the identity function.
|
||||
expect(safeHref(link({ href: '/en/about' }))).toBe('/en/about');
|
||||
expect(safeHref(link({ href: '/pricing' }))).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('currently lets protocol-relative URLs through via the "/" branch (pinned behavior)', () => {
|
||||
// '//evil.example.com' starts with '/', so it never reaches the protocol
|
||||
// allowlist. Browsers resolve it against the current scheme (http/https),
|
||||
// so it cannot smuggle javascript:, but it is an external URL in disguise.
|
||||
expect(safeHref(link({ href: '//evil.example.com' }))).toBe('//evil.example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { siteUrl } from '#web-runtime/lib/site-url';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('siteUrl', () => {
|
||||
it('uses SITE_URL, trimming stray whitespace', () => {
|
||||
vi.stubEnv('SITE_URL', ' https://crux.example.com ');
|
||||
|
||||
expect(siteUrl().origin).toBe('https://crux.example.com');
|
||||
});
|
||||
|
||||
it('falls back to localhost for local development', () => {
|
||||
vi.stubEnv('SITE_URL', '');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
|
||||
expect(siteUrl().origin).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('complains loudly when a production build has none, since the symptom is silent', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubEnv('SITE_URL', '');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
expect(siteUrl().origin).toBe('http://localhost:3000');
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('throws on a malformed SITE_URL rather than building wrong canonicals', () => {
|
||||
vi.stubEnv('SITE_URL', 'crux.example.com');
|
||||
|
||||
expect(() => siteUrl()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeSitemapEntries, sitemapLastPage } from '#web-runtime/data/normalize';
|
||||
import { buildSitemap } from '#web-runtime/lib/sitemap';
|
||||
import type { SitemapEntry } from '#web-runtime/types';
|
||||
|
||||
const origin = new URL('https://crux.example.com');
|
||||
const publicPath = (locale: string, path: string) => `/${locale}${path ? `/${path}` : ''}`;
|
||||
|
||||
function entry(overrides: Partial<SitemapEntry> = {}): SitemapEntry {
|
||||
return {
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: '2026-02-01T00:00:00.000+00:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('sitemap entry normalization', () => {
|
||||
it('reads the backend listing', () => {
|
||||
const entries = normalizeSitemapEntries({
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
locale: 'en',
|
||||
path: '/about/',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops entries with no locale, which cannot become a URL', () => {
|
||||
expect(normalizeSitemapEntries({ items: [{ path: 'about' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a payload that is not a listing at all', () => {
|
||||
expect(normalizeSitemapEntries(null)).toEqual([]);
|
||||
expect(normalizeSitemapEntries({})).toEqual([]);
|
||||
expect(normalizeSitemapEntries({ items: 'nope' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the page count, defaulting to a single page', () => {
|
||||
expect(sitemapLastPage({ pagination: { lastPage: 57 } })).toBe(57);
|
||||
expect(sitemapLastPage({ pagination: { lastPage: 0 } })).toBe(1);
|
||||
expect(sitemapLastPage({})).toBe(1);
|
||||
expect(sitemapLastPage(null)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSitemap', () => {
|
||||
it('emits absolute URLs against the configured origin', () => {
|
||||
const [item] = buildSitemap([entry()], origin, publicPath);
|
||||
|
||||
expect(item?.url).toBe('https://crux.example.com/en/about');
|
||||
});
|
||||
|
||||
it('uses the home path for a page with no path segment', () => {
|
||||
const [item] = buildSitemap([entry({ path: '', groupKey: 'home' })], origin, publicPath);
|
||||
|
||||
expect(item?.url).toBe('https://crux.example.com/en');
|
||||
});
|
||||
|
||||
it('reports lastModified from the update date, falling back to publication', () => {
|
||||
const [updated] = buildSitemap([entry()], origin, publicPath);
|
||||
const [neverUpdated] = buildSitemap([entry({ updatedAt: null })], origin, publicPath);
|
||||
const [undated] = buildSitemap(
|
||||
[entry({ updatedAt: null, publishedAt: null })],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(updated?.lastModified).toEqual(new Date('2026-02-01T00:00:00.000+00:00'));
|
||||
expect(neverUpdated?.lastModified).toEqual(new Date('2026-01-01T00:00:00.000+00:00'));
|
||||
expect(undated?.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('links translations of the same page to each other', () => {
|
||||
const sitemap = buildSitemap(
|
||||
[
|
||||
entry({ locale: 'en', path: 'about' }),
|
||||
entry({ locale: 'de', path: 'ueber-uns' }),
|
||||
entry({ locale: 'uk', path: 'pro-nas' })
|
||||
],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(sitemap).toHaveLength(3);
|
||||
for (const item of sitemap) {
|
||||
expect(item.alternates?.languages).toEqual({
|
||||
en: 'https://crux.example.com/en/about',
|
||||
de: 'https://crux.example.com/de/ueber-uns',
|
||||
uk: 'https://crux.example.com/uk/pro-nas'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('omits the alternates block for a page that exists in one locale only', () => {
|
||||
const [item] = buildSitemap([entry()], origin, publicPath);
|
||||
|
||||
expect(item?.alternates).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never groups pages that merely lack a group key', () => {
|
||||
const sitemap = buildSitemap(
|
||||
[entry({ groupKey: '', path: 'a' }), entry({ groupKey: '', path: 'b', locale: 'de' })],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(sitemap.every((item) => item.alternates === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { articleJsonLd, breadcrumbListJsonLd } from '#web-runtime/lib/structured-data';
|
||||
import type { PageDocument } from '#web-runtime/types';
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '61',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'blog/api-webhooks',
|
||||
groupKey: 'blog-api-webhooks',
|
||||
layout: 'blog-item',
|
||||
title: 'Working with webhooks',
|
||||
seo: { title: 'Working with webhooks — Crux', description: 'How webhooks work.' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: '2026-06-23T13:28:30.697+00:00',
|
||||
updatedAt: '2026-08-13T13:28:30.698+00:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('articleJsonLd', () => {
|
||||
it('marks up the dates and the hero image', () => {
|
||||
const media = { url: 'https://cdn.example.com/hero.jpg', alt: 'Hero' };
|
||||
const data = articleJsonLd(page({ hero: { type: 'low', ctaItems: [], media } }));
|
||||
|
||||
expect(data).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': 'Working with webhooks — Crux',
|
||||
'description': 'How webhooks work.',
|
||||
'datePublished': '2026-06-23T13:28:30.697+00:00',
|
||||
'dateModified': '2026-08-13T13:28:30.698+00:00',
|
||||
'image': ['https://cdn.example.com/hero.jpg'],
|
||||
'inLanguage': 'en'
|
||||
});
|
||||
});
|
||||
|
||||
it('emits nothing without a publication date, rather than an Article a crawler will drop', () => {
|
||||
expect(articleJsonLd(page({ publishedAt: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it('omits dateModified when the page has never been changed since publishing', () => {
|
||||
const published = '2026-06-23T13:28:30.697+00:00';
|
||||
const data = articleJsonLd(page({ publishedAt: published, updatedAt: published }));
|
||||
|
||||
expect(data).not.toHaveProperty('dateModified');
|
||||
expect(data).toMatchObject({ datePublished: published });
|
||||
});
|
||||
|
||||
it('falls back to the page title and omits fields the page does not have', () => {
|
||||
const data = articleJsonLd(page({ seo: { title: '', description: '' } }));
|
||||
|
||||
expect(data).toMatchObject({ headline: 'Working with webhooks' });
|
||||
expect(data).not.toHaveProperty('description');
|
||||
expect(data).not.toHaveProperty('image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('breadcrumbListJsonLd', () => {
|
||||
it('numbers the trail from one and leaves the current page without a url', () => {
|
||||
const data = breadcrumbListJsonLd([
|
||||
{ label: 'Home', href: '/en' },
|
||||
{ label: 'Blog', href: '/en/blog' },
|
||||
{ label: 'Working with webhooks', href: '/en/blog/api-webhooks' }
|
||||
]) as { itemListElement: Array<Record<string, unknown>> };
|
||||
|
||||
expect(data.itemListElement.map((item) => item['position'])).toEqual([1, 2, 3]);
|
||||
expect(data.itemListElement[0]).toHaveProperty('item');
|
||||
expect(data.itemListElement[2]).not.toHaveProperty('item');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* `server-only` is resolved by Next's bundler, not by node_modules, so importing any module
|
||||
* that guards itself with it fails outside a Next build. Vitest aliases the specifier here;
|
||||
* the guard has nothing to enforce in a test process that is server-side by definition.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WebRuntimeAPI } from '#web-runtime/api/web-runtime-api';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
/** The URL the nth fetch call received, as a URL object. */
|
||||
function fetchedUrl(call = 0): URL {
|
||||
return new URL(String(fetchMock.mock.calls[call]?.[0]));
|
||||
}
|
||||
|
||||
// Next augments the global RequestInit with `next.revalidate?: number | false`,
|
||||
// so the recorded init has to carry the same shape.
|
||||
type RecordedInit = RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
|
||||
|
||||
function fetchedInit(call = 0): RecordedInit {
|
||||
return fetchMock.mock.calls[call]?.[1] as RecordedInit;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const api = () => new WebRuntimeAPI('https://backend.test');
|
||||
|
||||
describe('request plumbing', () => {
|
||||
it('resolves pages against the resolve endpoint with locale and path params', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1, title: 'About', path: 'about' }));
|
||||
|
||||
await api().resolvePage({ locale: 'en', path: 'about' });
|
||||
|
||||
const url = fetchedUrl();
|
||||
expect(url.origin).toBe('https://backend.test');
|
||||
expect(url.pathname).toBe('/web-manager/public/pages/resolve');
|
||||
expect(url.searchParams.get('locale')).toBe('en');
|
||||
expect(url.searchParams.get('path')).toBe('about');
|
||||
expect(fetchedInit().next).toEqual({
|
||||
revalidate: 60,
|
||||
tags: ['web-manager', 'web-manager:page:en:about', 'web-manager:settings:en']
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a 404 as "no such page", not an error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: 'not found' }, 404));
|
||||
|
||||
expect(await api().resolvePage({ locale: 'en', path: 'missing' })).toBeNull();
|
||||
});
|
||||
|
||||
it('throws loudly on any other failure status', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: 'boom' }, 500));
|
||||
|
||||
await expect(api().resolvePage({ locale: 'en', path: 'about' })).rejects.toThrow(
|
||||
'Web Manager request to /web-manager/public/pages/resolve failed with status 500.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('unwraps a { settings } envelope and normalizes it', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ settings: { footer: { copyright: '© Crux' } } }));
|
||||
|
||||
const settings = await api().getSettings('de');
|
||||
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/settings');
|
||||
expect(fetchedUrl().searchParams.get('locale')).toBe('de');
|
||||
expect(settings?.locale).toBe('de');
|
||||
expect(settings?.footer.copyright).toBe('© Crux');
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
expect(await api().getSettings('en')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreview', () => {
|
||||
it('passes the token and opts out of caching', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
page: { id: 3, title: 'Draft', path: 'draft', locale: 'de', status: 'draft' }
|
||||
})
|
||||
);
|
||||
|
||||
const resolution = await api().getPreview('tok-123');
|
||||
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/pages/preview');
|
||||
expect(fetchedUrl().searchParams.get('token')).toBe('tok-123');
|
||||
expect(fetchedInit().cache).toBe('no-store');
|
||||
expect(resolution?.page.locale).toBe('de');
|
||||
expect(resolution?.page.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('returns null for an expired or unknown token (404)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
expect(await api().getPreview('tok-dead')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSitemapPages pagination', () => {
|
||||
it('walks every page the backend reports and concatenates the entries', async () => {
|
||||
fetchMock.mockImplementation((url: URL) => {
|
||||
const page = Number(new URL(String(url)).searchParams.get('page'));
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
items: [{ locale: 'en', path: `page-${page}` }],
|
||||
pagination: { lastPage: 3 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
fetchMock.mock.calls.map((call) => new URL(String(call[0])).searchParams.get('page'))
|
||||
).toEqual(['1', '2', '3']);
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/pages/sitemap');
|
||||
expect(entries.map((entry) => entry.path)).toEqual(['page-1', 'page-2', 'page-3']);
|
||||
});
|
||||
|
||||
it('makes exactly one request when the listing fits on one page', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ items: [{ locale: 'en', path: 'only' }], pagination: { lastPage: 1 } })
|
||||
);
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps a runaway lastPage at 50 requests instead of spinning forever', async () => {
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
items: [{ locale: 'en', path: 'p' }],
|
||||
pagination: { lastPage: 100000 }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(50);
|
||||
expect(entries).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebRuntimeAPI singleton', () => {
|
||||
async function freshModule() {
|
||||
vi.resetModules();
|
||||
return await import('#web-runtime/api/web-runtime-api');
|
||||
}
|
||||
|
||||
it('throws when BACKEND_API_URL is missing or blank', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', '');
|
||||
|
||||
expect(() => mod.getWebRuntimeAPI()).toThrow('BACKEND_API_URL is required');
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', ' ');
|
||||
expect(() => mod.getWebRuntimeAPI()).toThrow('BACKEND_API_URL is required');
|
||||
});
|
||||
|
||||
it('trims whitespace and a trailing slash off the base URL', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', ' https://backend.test/ ');
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
await mod.getWebRuntimeAPI().getSettings('en');
|
||||
|
||||
expect(fetchedUrl().href).toBe('https://backend.test/web-manager/public/settings?locale=en');
|
||||
});
|
||||
|
||||
it('constructs the client once and ignores later env changes (pinned singleton behavior)', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', 'https://first.test');
|
||||
|
||||
const first = mod.getWebRuntimeAPI();
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', 'https://second.test');
|
||||
expect(mod.getWebRuntimeAPI()).toBe(first);
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', '');
|
||||
expect(() => mod.getWebRuntimeAPI()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"#*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts",
|
||||
"../../packages/**/*"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^#(.*)$/,
|
||||
replacement: fileURLToPath(new URL('./$1', import.meta.url))
|
||||
},
|
||||
{
|
||||
find: 'server-only',
|
||||
replacement: fileURLToPath(new URL('./tests/stubs/server-only.ts', import.meta.url))
|
||||
}
|
||||
]
|
||||
},
|
||||
esbuild: {
|
||||
jsx: 'automatic'
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
@@ -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