TASKS-1823 WebManager module
This commit is contained in:
@@ -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} />
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user