B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,90 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { ContentRenderer, createCmsClient } from '@kit/cms';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { cn } from '@kit/ui/utils';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { DocsCards } from '../_components/docs-cards';
import { DocsTableOfContents } from '../_components/docs-table-of-contents';
import { extractHeadingsFromJSX } from '../_lib/utils';
const getPageBySlug = cache(pageLoader);
interface DocumentationPageProps {
params: Promise<{ slug: string[] }>;
}
async function pageLoader(slug: string) {
const client = await createCmsClient();
return client.getContentItemBySlug({ slug, collection: 'documentation' });
}
export const generateMetadata = async ({ params }: DocumentationPageProps) => {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const { title, description } = page;
return {
title,
description,
};
};
async function DocumentationPage({ params }: DocumentationPageProps) {
const slug = (await params).slug.join('/');
const page = await getPageBySlug(slug);
if (!page) {
notFound();
}
const description = page?.description ?? '';
const headings = extractHeadingsFromJSX(
page.content as {
props: { children: React.ReactElement[] };
},
);
return (
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden py-5'}>
<div className={'flex overflow-y-hidden'}>
<article className={cn('gap-y-12 overflow-y-auto px-6')}>
<section className={'flex flex-col gap-y-2.5'}>
<h1 className={'text-foreground text-3xl font-semibold'}>
{page.title}
</h1>
<h2 className={'text-muted-foreground text-lg'}>{description}</h2>
</section>
<div className={'markdoc'}>
<ContentRenderer content={page.content} />
</div>
</article>
<DocsTableOfContents data={headings} />
</div>
<If condition={page.children.length > 0}>
<Separator />
<DocsCards cards={page.children ?? []} />
</If>
</div>
);
}
export default withI18n(DocumentationPage);

View File

@@ -0,0 +1,53 @@
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
export function DocsCard({
title,
subtitle,
children,
link,
}: React.PropsWithChildren<{
title: string;
subtitle?: string | null;
link: { url: string; label?: string };
}>) {
return (
<div className="flex flex-col">
<div
className={`bg-background flex grow flex-col gap-y-2 border p-6 ${link ? 'rounded-t-lg border-b-0' : 'rounded-lg'}`}
>
<h3 className="mt-0 text-lg font-semibold hover:underline dark:text-white">
<Link href={link.url}>{title}</Link>
</h3>
{subtitle && (
<div className="text-muted-foreground text-sm">
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
</div>
)}
{children && <div className="text-sm">{children}</div>}
</div>
{link && (
<div className="bg-muted/50 rounded-b-lg border p-6 py-4">
<Link
className={
'flex items-center space-x-2 text-sm font-medium hover:underline'
}
href={link.url}
>
<span>
{link.label ?? <Trans i18nKey={'marketing:readMore'} />}
</span>
<ChevronRight className={'h-4'} />
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Cms } from '@kit/cms';
import { DocsCard } from './docs-card';
export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
return (
<div className={'grid grid-cols-1 gap-6 lg:grid-cols-2'}>
{cardsSortedByOrder.map((item) => {
return (
<DocsCard
key={item.title}
title={item.title}
subtitle={item.description}
link={{
url: `/docs/${item.slug}`,
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useRef } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavLink({
label,
url,
children,
}: React.PropsWithChildren<{ label: string; url: string }>) {
const currentPath = usePathname();
const ref = useRef<HTMLElement>(null);
const isCurrent = isRouteActive(url, currentPath, true);
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={isCurrent}
className={cn('transition-background font-normal!', {
'text-secondary-foreground font-bold': isCurrent,
})}
>
<Link href={url}>
<span ref={ref} className="block max-w-full truncate">
{label}
</span>
{children}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { usePathname } from 'next/navigation';
import { Cms } from '@kit/cms';
import { Collapsible } from '@kit/ui/collapsible';
import { isRouteActive } from '@kit/ui/utils';
export function DocsNavigationCollapsible(
props: React.PropsWithChildren<{
node: Cms.ContentItem;
prefix: string;
}>,
) {
const currentPath = usePathname();
const prefix = props.prefix;
const isChildActive = props.node.children.some((child) =>
isRouteActive(prefix + '/' + child.url, currentPath, false),
);
return (
<Collapsible
className={'group/collapsible'}
defaultOpen={isChildActive ? true : !props.node.collapsed}
>
{props.children}
</Collapsible>
);
}

View File

@@ -0,0 +1,143 @@
import { ChevronDown } from 'lucide-react';
import { Cms } from '@kit/cms';
import { CollapsibleContent, CollapsibleTrigger } from '@kit/ui/collapsible';
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@kit/ui/shadcn-sidebar';
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
import { FloatingDocumentationNavigation } from './floating-docs-navigation';
function Node({
node,
level,
prefix,
}: {
node: Cms.ContentItem;
level: number;
prefix: string;
}) {
const url = `${prefix}/${node.slug}`;
const label = node.label ? node.label : node.title;
const Container = (props: React.PropsWithChildren) => {
if (node.collapsible) {
return (
<DocsNavigationCollapsible node={node} prefix={prefix}>
{props.children}
</DocsNavigationCollapsible>
);
}
return props.children;
};
const ContentContainer = (props: React.PropsWithChildren) => {
if (node.collapsible) {
return <CollapsibleContent>{props.children}</CollapsibleContent>;
}
return props.children;
};
const Trigger = () => {
if (node.collapsible) {
return (
<CollapsibleTrigger asChild>
<SidebarMenuItem>
<SidebarMenuButton>
{label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</SidebarMenuItem>
</CollapsibleTrigger>
);
}
return <DocsNavLink label={label} url={url} />;
};
return (
<Container>
<Trigger />
<ContentContainer>
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
</ContentContainer>
</Container>
);
}
function Tree({
pages,
level,
prefix,
}: {
pages: Cms.ContentItem[];
level: number;
prefix: string;
}) {
if (level === 0) {
return pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
));
}
if (pages.length === 0) {
return null;
}
return (
<SidebarMenuSub>
{pages.map((treeNode, index) => (
<Node key={index} node={treeNode} level={level} prefix={prefix} />
))}
</SidebarMenuSub>
);
}
export function DocsNavigation({
pages,
prefix = '/docs',
}: {
pages: Cms.ContentItem[];
prefix?: string;
}) {
return (
<>
<Sidebar
variant={'ghost'}
className={'sticky z-1 mt-4 max-h-full overflow-y-auto'}
>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<Tree pages={pages} level={0} prefix={prefix} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Sidebar>
<div className={'lg:hidden'}>
<FloatingDocumentationNavigation>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<Tree pages={pages} level={0} prefix={prefix} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</FloatingDocumentationNavigation>
</div>
</>
);
}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/utils';
export function DocsPageLink({
page,
before,
after,
}: React.PropsWithChildren<{
page: {
url: string;
title: string;
};
before?: React.ReactNode;
after?: React.ReactNode;
}>) {
return (
<Link
className={cn(
`ring-muted hover:ring-primary flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 transition-all`,
{
'justify-start': before,
'justify-end self-end': after,
},
)}
href={page.url}
>
<If condition={before}>{(node) => <>{node}</>}</If>
<span className={'flex flex-col space-y-1.5'}>
<span
className={'text-muted-foreground text-xs font-semibold uppercase'}
>
{before ? `Previous` : ``}
{after ? `Next` : ``}
</span>
<span>{page.title}</span>
</span>
<If condition={after}>{(node) => <>{node}</>}</If>
</Link>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
interface NavItem {
text: string;
level: number;
href: string;
children: NavItem[];
}
export function DocsTableOfContents(props: { data: NavItem[] }) {
const navData = props.data;
return (
<div className="bg-background sticky inset-y-0 hidden h-svh max-h-full min-w-[14em] border-l p-4 lg:block">
<ol
role="list"
className="relative text-sm text-gray-600 dark:text-gray-400"
>
{navData.map((item) => (
<li key={item.href} className="group/item relative mt-3 first:mt-0">
<a
href={item.href}
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
>
{item.text}
</a>
{item.children && (
<ol role="list" className="relative mt-3 pl-4">
{item.children.map((child) => (
<li
key={child.href}
className="group/subitem relative mt-3 first:mt-0"
>
<Link
href={child.href}
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
>
{child.text}
</Link>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { Menu } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
export function FloatingDocumentationNavigation(
props: React.PropsWithChildren,
) {
const activePath = usePathname();
const body = useMemo(() => {
return isBrowser() ? document.body : null;
}, []);
const [isVisible, setIsVisible] = useState(false);
const enableScrolling = (element: HTMLElement) =>
(element.style.overflowY = '');
const disableScrolling = (element: HTMLElement) =>
(element.style.overflowY = 'hidden');
// enable/disable body scrolling when the docs are toggled
useEffect(() => {
if (!body) {
return;
}
if (isVisible) {
disableScrolling(body);
} else {
enableScrolling(body);
}
}, [isVisible, body]);
// hide docs when navigating to another page
useEffect(() => {
setIsVisible(false);
}, [activePath]);
const onClick = () => {
setIsVisible(!isVisible);
};
return (
<>
<If condition={isVisible}>
<div
className={
'fixed top-0 left-0 z-10 h-screen w-full p-4' +
' dark:bg-background flex flex-col space-y-4 overflow-auto bg-white'
}
>
{props.children}
</div>
</If>
<Button
className={'fixed right-5 bottom-5 z-10 h-16 w-16 rounded-full'}
onClick={onClick}
>
<Menu className={'h-8'} />
</Button>
</>
);
}

View File

@@ -0,0 +1,31 @@
import { cache } from 'react';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
/**
* @name getDocs
* @description Load the documentation pages.
* @param language
*/
export const getDocs = cache(docsLoader);
async function docsLoader(language: string | undefined) {
const cms = await createCmsClient();
const logger = await getLogger();
try {
const data = await cms.getContentItems({
collection: 'documentation',
language,
limit: Infinity,
content: false,
});
return data.items;
} catch (error) {
logger.error({ error }, 'Failed to load documentation pages');
return [];
}
}

View File

@@ -0,0 +1,146 @@
import { Cms } from '@kit/cms';
interface HeadingNode {
text: string;
level: number;
href: string;
children: HeadingNode[];
}
/**
* @name buildDocumentationTree
* @description Build a tree structure for the documentation pages.
* @param pages
*/
export function buildDocumentationTree(pages: Cms.ContentItem[]) {
const tree: Cms.ContentItem[] = [];
pages.forEach((page) => {
if (page.parentId) {
const parent = pages.find((item) => item.slug === page.parentId);
if (!parent) {
return;
}
if (!parent.children) {
parent.children = [];
}
parent.children.push(page);
// sort children by order
parent.children.sort((a, b) => a.order - b.order);
} else {
tree.push(page);
}
});
return tree.sort((a, b) => a.order - b.order);
}
/**
* @name extractHeadingsFromJSX
* @description Extract headings from JSX. This is used to generate the table of contents for the documentation pages.
* @param jsx
*/
export function extractHeadingsFromJSX(jsx: {
props: { children: React.ReactElement[] };
}) {
const headings: HeadingNode[] = [];
let currentH2: HeadingNode | null = null;
function getTextContent(
children: React.ReactElement[] | string | React.ReactElement,
): string {
try {
if (typeof children === 'string') {
return children;
}
if (Array.isArray(children)) {
return children.map((child) => getTextContent(child)).join('');
}
if (
(
children.props as {
children: React.ReactElement;
}
).children
) {
return getTextContent(
(children.props as { children: React.ReactElement }).children,
);
}
return '';
} catch {
return '';
}
}
try {
jsx.props.children.forEach((node) => {
if (!node || typeof node !== 'object' || !('type' in node)) {
return;
}
const nodeType = node.type as string;
const text = getTextContent(
(
node.props as {
children: React.ReactElement[];
}
).children,
);
if (nodeType === 'h1') {
const slug = generateSlug(text);
headings.push({
text,
level: 1,
href: `#${slug}`,
children: [],
});
} else if (nodeType === 'h2') {
const slug = generateSlug(text);
currentH2 = {
text,
level: 2,
href: `#${slug}`,
children: [],
};
if (headings.length > 0) {
headings[headings.length - 1]!.children.push(currentH2);
} else {
headings.push(currentH2);
}
} else if (nodeType === 'h3' && currentH2) {
const slug = generateSlug(text);
currentH2.children.push({
text,
level: 3,
href: `#${slug}`,
children: [],
});
}
});
return headings;
} catch {
return [];
}
}
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}

View File

@@ -0,0 +1,27 @@
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
// local imports
import { DocsNavigation } from './_components/docs-navigation';
import { getDocs } from './_lib/server/docs.loader';
import { buildDocumentationTree } from './_lib/utils';
async function DocsLayout({ children }: React.PropsWithChildren) {
const { resolvedLanguage } = await createI18nServerInstance();
const docs = await getDocs(resolvedLanguage);
const tree = buildDocumentationTree(docs);
return (
<SidebarProvider
style={{ '--sidebar-width': '18em' } as React.CSSProperties}
className={'h-[calc(100vh-72px)] overflow-y-hidden lg:container'}
>
<DocsNavigation pages={tree} />
{children}
</SidebarProvider>
);
}
export default DocsLayout;

View File

@@ -0,0 +1,3 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -0,0 +1,39 @@
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../_components/site-page-header';
import { DocsCards } from './_components/docs-cards';
import { getDocs } from './_lib/server/docs.loader';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
return {
title: t('marketing:documentation'),
};
};
async function DocsPage() {
const { t, resolvedLanguage } = await createI18nServerInstance();
const items = await getDocs(resolvedLanguage);
// Filter out any docs that have a parentId, as these are children of other docs
const cards = items.filter((item) => !item.parentId);
return (
<div className={'flex flex-col gap-y-6 xl:gap-y-10'}>
<SitePageHeader
title={t('marketing:documentation')}
subtitle={t('marketing:documentationSubtitle')}
/>
<div className={'flex flex-col items-center'}>
<div className={'container mx-auto max-w-5xl'}>
<DocsCards cards={cards} />
</div>
</div>
</div>
);
}
export default withI18n(DocsPage);