feat(nx-dev): add metrics and related blogs section
This commit is contained in:
parent
13b9c23e3b
commit
541acf536b
@ -75,6 +75,7 @@ export class BlogApi {
|
|||||||
podcastAppleUrl: frontmatter.podcastAppleUrl,
|
podcastAppleUrl: frontmatter.podcastAppleUrl,
|
||||||
podcastAmazonUrl: frontmatter.podcastAmazonUrl,
|
podcastAmazonUrl: frontmatter.podcastAmazonUrl,
|
||||||
published: frontmatter.published ?? true,
|
published: frontmatter.published ?? true,
|
||||||
|
metrics: frontmatter.metrics,
|
||||||
};
|
};
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
const shouldIncludePost = !frontmatter.draft || isDevelopment;
|
const shouldIncludePost = !frontmatter.draft || isDevelopment;
|
||||||
|
|||||||
@ -18,6 +18,9 @@ export type BlogPostDataEntry = {
|
|||||||
podcastAppleUrl?: string;
|
podcastAppleUrl?: string;
|
||||||
podcastIHeartUrl?: string;
|
podcastIHeartUrl?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
ogImage?: string;
|
||||||
|
ogImageType?: string;
|
||||||
|
metrics?: Array<{ value: string; label: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlogAuthor = {
|
export type BlogAuthor = {
|
||||||
|
|||||||
@ -47,12 +47,14 @@ export default async function BlogPostDetail({
|
|||||||
}: BlogPostDetailProps) {
|
}: BlogPostDetailProps) {
|
||||||
const ctaHeaderConfig = [tryNxCloudForFree];
|
const ctaHeaderConfig = [tryNxCloudForFree];
|
||||||
const blog = await blogApi.getBlogPostBySlug(slug);
|
const blog = await blogApi.getBlogPostBySlug(slug);
|
||||||
|
const allPosts = await blogApi.getBlogs((p) => !!p.published);
|
||||||
|
|
||||||
return blog ? (
|
return blog ? (
|
||||||
<>
|
<>
|
||||||
{/* This empty div is necessary as app router does not automatically scroll on route changes */}
|
{/* This empty div is necessary as app router does not automatically scroll on route changes */}
|
||||||
<div></div>
|
<div></div>
|
||||||
<DefaultLayout headerCTAConfig={ctaHeaderConfig}>
|
<DefaultLayout headerCTAConfig={ctaHeaderConfig}>
|
||||||
<BlogDetails post={blog} />
|
<BlogDetails post={blog} allPosts={allPosts} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FeaturedBlogs } from './featured-blogs';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Filters } from './filters';
|
import { Filters } from './filters';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { ALL_TOPICS } from './topics';
|
||||||
import {
|
import {
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
@ -21,57 +22,6 @@ export interface BlogContainerProps {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let ALL_TOPICS = [
|
|
||||||
{
|
|
||||||
label: 'All',
|
|
||||||
icon: ListBulletIcon,
|
|
||||||
value: 'All',
|
|
||||||
heading: 'All Blogs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Stories',
|
|
||||||
icon: BookOpenIcon,
|
|
||||||
value: 'customer story',
|
|
||||||
heading: 'Customer Stories',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Webinars',
|
|
||||||
icon: ComputerDesktopIcon,
|
|
||||||
value: 'webinar',
|
|
||||||
heading: 'Webinars',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Podcasts',
|
|
||||||
icon: MicrophoneIcon,
|
|
||||||
value: 'podcast',
|
|
||||||
heading: 'Podcasts',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Releases',
|
|
||||||
icon: CubeIcon,
|
|
||||||
value: 'release',
|
|
||||||
heading: 'Release Blogs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Talks',
|
|
||||||
icon: ChatBubbleOvalLeftEllipsisIcon,
|
|
||||||
value: 'talk',
|
|
||||||
heading: 'Talks',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tutorials',
|
|
||||||
icon: AcademicCapIcon,
|
|
||||||
value: 'tutorial',
|
|
||||||
heading: 'Tutorials',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Livestreams',
|
|
||||||
icon: VideoCameraIcon,
|
|
||||||
value: 'livestream',
|
|
||||||
heading: 'Livestreams',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// first five blog posts contain potentially pinned plus the last published ones. They
|
// first five blog posts contain potentially pinned plus the last published ones. They
|
||||||
// should be sorted by date (not just all pinned first)
|
// should be sorted by date (not just all pinned first)
|
||||||
export function sortFirstFivePosts(
|
export function sortFirstFivePosts(
|
||||||
@ -142,7 +92,14 @@ export function BlogContainer({ blogPosts, tags }: BlogContainerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FeaturedBlogs blogs={firstFiveBlogs} />
|
<FeaturedBlogs blogs={firstFiveBlogs} />
|
||||||
{!!remainingBlogs.length && <MoreBlogs blogs={remainingBlogs} />}
|
{!!remainingBlogs.length && (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto mb-8 mt-20 border-b-2 border-slate-300 pb-3 text-sm dark:border-slate-700">
|
||||||
|
<h2 className="font-semibold">More blogs</h2>
|
||||||
|
</div>
|
||||||
|
<MoreBlogs blogs={remainingBlogs} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,16 +2,21 @@ import Link from 'next/link';
|
|||||||
import { BlogPostDataEntry } from '@nx/nx-dev/data-access-documents/node-only';
|
import { BlogPostDataEntry } from '@nx/nx-dev/data-access-documents/node-only';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { BlogAuthors } from './authors';
|
import { BlogAuthors } from './authors';
|
||||||
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
import { ChevronLeftIcon, ListBulletIcon } from '@heroicons/react/24/outline';
|
||||||
import { renderMarkdown } from '@nx/nx-dev/ui-markdoc';
|
import { renderMarkdown } from '@nx/nx-dev/ui-markdoc';
|
||||||
import { EpisodePlayer } from './episode-player';
|
import { EpisodePlayer } from './episode-player';
|
||||||
import { YouTube } from '@nx/nx-dev/ui-common';
|
import { YouTube } from '@nx/nx-dev/ui-common';
|
||||||
|
import { FeaturedBlogs } from './featured-blogs';
|
||||||
|
import { MoreBlogs } from './more-blogs';
|
||||||
|
import { ALL_TOPICS, type Topic } from './topics';
|
||||||
|
import { Metrics } from '@nx/nx-dev/ui-markdoc';
|
||||||
|
|
||||||
export interface BlogDetailsProps {
|
export interface BlogDetailsProps {
|
||||||
post: BlogPostDataEntry;
|
post: BlogPostDataEntry;
|
||||||
|
allPosts: BlogPostDataEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BlogDetails({ post }: BlogDetailsProps) {
|
export function BlogDetails({ post, allPosts }: BlogDetailsProps) {
|
||||||
const { node } = renderMarkdown(post.content, {
|
const { node } = renderMarkdown(post.content, {
|
||||||
filePath: post.filePath ?? '',
|
filePath: post.filePath ?? '',
|
||||||
headingClass: 'scroll-mt-20',
|
headingClass: 'scroll-mt-20',
|
||||||
@ -23,30 +28,48 @@ export function BlogDetails({ post }: BlogDetailsProps) {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Find the primary topic of the current post
|
||||||
|
const primaryTopic = ALL_TOPICS.find((topic: Topic) =>
|
||||||
|
post.tags.includes(topic.value.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const relatedPosts = allPosts
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.slug !== post.slug && // Exclude current post
|
||||||
|
p.tags.some((tag) => post.tags.includes(tag)) // Include posts with matching tags
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main id="main" role="main" className="w-full py-8">
|
<main id="main" role="main" className="w-full py-8">
|
||||||
<div className="mx-auto flex max-w-3xl justify-between px-4 lg:px-0">
|
<div className="mx-auto max-w-screen-md">
|
||||||
<Link
|
{/* Top navigation and author info */}
|
||||||
href="/blog"
|
<div className="mx-auto flex justify-between px-4">
|
||||||
className="flex w-20 shrink-0 items-center gap-2 text-slate-400 hover:text-slate-800 dark:text-slate-600 dark:hover:text-slate-200"
|
<Link
|
||||||
prefetch={false}
|
href="/blog"
|
||||||
>
|
className="flex w-20 shrink-0 items-center gap-2 text-slate-400 hover:text-slate-800 dark:text-slate-600 dark:hover:text-slate-200"
|
||||||
<ChevronLeftIcon className="h-3 w-3" />
|
prefetch={false}
|
||||||
Blog
|
>
|
||||||
</Link>
|
<ChevronLeftIcon className="h-3 w-3" />
|
||||||
<div className="flex max-w-sm flex-1 grow items-center justify-end gap-2">
|
Blog
|
||||||
<BlogAuthors authors={post.authors} />
|
</Link>
|
||||||
<span className="text-sm text-slate-400 dark:text-slate-600">
|
<div className="flex max-w-sm flex-1 grow items-center justify-end gap-2">
|
||||||
{formattedDate}
|
<BlogAuthors authors={post.authors} />
|
||||||
</span>
|
<span className="text-sm text-slate-400 dark:text-slate-600">
|
||||||
|
{formattedDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div id="content-wrapper">
|
{/* Title */}
|
||||||
<header className="mx-auto mb-16 mt-8 max-w-3xl px-4 lg:px-0">
|
<header className="mx-auto mb-16 mt-8 px-4">
|
||||||
<h1 className="text-center text-4xl font-semibold text-slate-900 dark:text-white">
|
<h1 className="text-center text-4xl font-semibold text-slate-900 dark:text-white">
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Media content (podcast, youtube, or image) */}
|
||||||
{post.podcastYoutubeId && post.podcastSpotifyId ? (
|
{post.podcastYoutubeId && post.podcastSpotifyId ? (
|
||||||
<div className="mx-auto mb-16 w-full max-w-screen-md">
|
<div className="mx-auto mb-16 w-full max-w-screen-md">
|
||||||
<EpisodePlayer
|
<EpisodePlayer
|
||||||
@ -74,17 +97,73 @@ export function BlogDetails({ post }: BlogDetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<div className="mx-auto min-w-0 max-w-3xl flex-auto px-4 pb-24 lg:px-0 lg:pb-16">
|
</div>
|
||||||
<div className="relative">
|
|
||||||
|
{/* Main grid layout */}
|
||||||
|
<div className="mx-auto max-w-7xl px-4 lg:px-8">
|
||||||
|
<div className="relative isolate grid grid-cols-1 gap-8 xl:grid-cols-[200px_minmax(0,1fr)_200px]">
|
||||||
|
<div className="hidden min-h-full xl:block">
|
||||||
|
{post.metrics && (
|
||||||
|
<div className="sticky top-28 pr-4 pt-8">
|
||||||
|
<Metrics metrics={post.metrics} variant="vertical" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle column - main content */}
|
||||||
|
<div className="w-full min-w-0 md:mx-auto md:max-w-screen-md">
|
||||||
|
{post.metrics && (
|
||||||
|
<div className="mb-8 xl:hidden">
|
||||||
|
<Metrics metrics={post.metrics} variant="horizontal" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
data-document="main"
|
data-document="main"
|
||||||
className="prose prose-lg prose-slate dark:prose-invert w-full max-w-none 2xl:max-w-4xl"
|
className="prose prose-lg prose-slate dark:prose-invert w-full max-w-none"
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right column - for future sticky content */}
|
||||||
|
<div className="hidden xl:block">
|
||||||
|
<div className="sticky top-24">
|
||||||
|
{/* Right sidebar content can go here */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Related Posts Section */}
|
||||||
|
{post.tags.length > 0 && relatedPosts.length > 0 && (
|
||||||
|
<section className="mt-24 border-b border-t border-slate-200 bg-slate-50 py-24 sm:py-32 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-2xl lg:mx-0 lg:max-w-none">
|
||||||
|
<h2 className="mb-8 flex items-center gap-3 text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
{primaryTopic ? (
|
||||||
|
<>
|
||||||
|
<primaryTopic.icon className="h-7 w-7" />
|
||||||
|
More {primaryTopic.label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ListBulletIcon className="h-7 w-7" />
|
||||||
|
More Articles
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{/* Show list view on small screens */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<MoreBlogs blogs={relatedPosts} />
|
||||||
|
</div>
|
||||||
|
{/* Show grid view on larger screens */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<FeaturedBlogs blogs={relatedPosts} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,9 +9,6 @@ export interface MoreBlogsProps {
|
|||||||
export function MoreBlogs({ blogs }: MoreBlogsProps) {
|
export function MoreBlogs({ blogs }: MoreBlogsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto mb-8 mt-20 border-b-2 border-slate-300 pb-3 text-sm dark:border-slate-700">
|
|
||||||
<h2 className="font-semibold">More blogs</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto">
|
<div className="mx-auto">
|
||||||
{blogs?.map((post) => {
|
{blogs?.map((post) => {
|
||||||
const formattedDate = new Date(post.date).toLocaleDateString(
|
const formattedDate = new Date(post.date).toLocaleDateString(
|
||||||
|
|||||||
69
nx-dev/ui-blog/src/lib/topics.ts
Normal file
69
nx-dev/ui-blog/src/lib/topics.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import type { FC, SVGProps } from 'react';
|
||||||
|
import {
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
BookOpenIcon,
|
||||||
|
MicrophoneIcon,
|
||||||
|
CubeIcon,
|
||||||
|
AcademicCapIcon,
|
||||||
|
ChatBubbleOvalLeftEllipsisIcon,
|
||||||
|
ListBulletIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export interface Topic {
|
||||||
|
label: string;
|
||||||
|
icon: FC<SVGProps<SVGSVGElement>>;
|
||||||
|
value: string;
|
||||||
|
heading: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_TOPICS: Topic[] = [
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
icon: ListBulletIcon,
|
||||||
|
value: 'All',
|
||||||
|
heading: 'All Blogs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stories',
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
value: 'customer story',
|
||||||
|
heading: 'Customer Stories',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Webinars',
|
||||||
|
icon: ComputerDesktopIcon,
|
||||||
|
value: 'webinar',
|
||||||
|
heading: 'Webinars',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Podcasts',
|
||||||
|
icon: MicrophoneIcon,
|
||||||
|
value: 'podcast',
|
||||||
|
heading: 'Podcasts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Releases',
|
||||||
|
icon: CubeIcon,
|
||||||
|
value: 'release',
|
||||||
|
heading: 'Release Blogs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Talks',
|
||||||
|
icon: ChatBubbleOvalLeftEllipsisIcon,
|
||||||
|
value: 'talk',
|
||||||
|
heading: 'Talks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tutorials',
|
||||||
|
icon: AcademicCapIcon,
|
||||||
|
value: 'tutorial',
|
||||||
|
heading: 'Tutorials',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Livestreams',
|
||||||
|
icon: VideoCameraIcon,
|
||||||
|
value: 'livestream',
|
||||||
|
heading: 'Livestreams',
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -16,7 +16,7 @@ export function DefaultLayout({
|
|||||||
headerCTAConfig?: ButtonLinkProps[];
|
headerCTAConfig?: ButtonLinkProps[];
|
||||||
} & PropsWithChildren): JSX.Element {
|
} & PropsWithChildren): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-hidden dark:bg-slate-950">
|
<div className="w-full dark:bg-slate-950">
|
||||||
{!hideHeader && <Header ctaButtons={headerCTAConfig} />}
|
{!hideHeader && <Header ctaButtons={headerCTAConfig} />}
|
||||||
<div className="relative isolate">
|
<div className="relative isolate">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -56,8 +56,8 @@ import { TableOfContents } from './lib/tags/table-of-contents.component';
|
|||||||
import { tableOfContents } from './lib/tags/table-of-contents.schema';
|
import { tableOfContents } from './lib/tags/table-of-contents.schema';
|
||||||
import { Quote } from './lib/tags/quote.component';
|
import { Quote } from './lib/tags/quote.component';
|
||||||
import { quote } from './lib/tags/quote.schema';
|
import { quote } from './lib/tags/quote.schema';
|
||||||
// TODO fix this export
|
import { metrics } from './lib/tags/metrics.schema';
|
||||||
export { GithubRepository } from './lib/tags/github-repository.component';
|
import { Metrics } from './lib/tags/metrics.component';
|
||||||
|
|
||||||
export const getMarkdocCustomConfig = (
|
export const getMarkdocCustomConfig = (
|
||||||
documentFilePath: string,
|
documentFilePath: string,
|
||||||
@ -96,6 +96,7 @@ export const getMarkdocCustomConfig = (
|
|||||||
tweet,
|
tweet,
|
||||||
youtube,
|
youtube,
|
||||||
'video-link': videoLink,
|
'video-link': videoLink,
|
||||||
|
metrics,
|
||||||
// 'svg-animation': svgAnimation,
|
// 'svg-animation': svgAnimation,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -128,6 +129,7 @@ export const getMarkdocCustomConfig = (
|
|||||||
YouTube,
|
YouTube,
|
||||||
VideoLink,
|
VideoLink,
|
||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
|
Metrics,
|
||||||
// SvgAnimation,
|
// SvgAnimation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -177,3 +179,6 @@ export const renderMarkdown: (
|
|||||||
treeNode,
|
treeNode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { GithubRepository } from './lib/tags/github-repository.component';
|
||||||
|
export { Metrics };
|
||||||
|
|||||||
27
nx-dev/ui-markdoc/src/lib/tags/metrics-cta.tsx
Normal file
27
nx-dev/ui-markdoc/src/lib/tags/metrics-cta.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/* this is a separate component s.t. it can be client-side only to avoid hydration errors*/
|
||||||
|
|
||||||
|
import { ButtonLink } from '@nx/nx-dev/ui-common';
|
||||||
|
import { sendCustomEvent } from '@nx/nx-dev/feature-analytics';
|
||||||
|
|
||||||
|
export function MetricsCTA() {
|
||||||
|
return (
|
||||||
|
<div className="not-prose flex flex-col space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
Ready to get started?
|
||||||
|
</h3>
|
||||||
|
<ButtonLink
|
||||||
|
href="/enterprise/trial"
|
||||||
|
title="Reach out"
|
||||||
|
variant="primary"
|
||||||
|
size="default"
|
||||||
|
onClick={() =>
|
||||||
|
sendCustomEvent('request-trial-click', 'metrics-cta', 'blog')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reach out
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
nx-dev/ui-markdoc/src/lib/tags/metrics.component.tsx
Normal file
52
nx-dev/ui-markdoc/src/lib/tags/metrics.component.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MetricsCTA } from './metrics-cta';
|
||||||
|
import { Metric } from './metrics.schema';
|
||||||
|
|
||||||
|
interface MetricsProps {
|
||||||
|
metrics: Metric[];
|
||||||
|
variant?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Metrics({
|
||||||
|
metrics,
|
||||||
|
variant = 'vertical',
|
||||||
|
}: MetricsProps): JSX.Element {
|
||||||
|
if (variant === 'horizontal') {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-none">
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{metrics.map((metric, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col items-center space-y-1 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-snug text-slate-500 dark:text-slate-400">
|
||||||
|
{metric.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{metrics.map((metric, index) => (
|
||||||
|
<div key={index} className="flex flex-col space-y-2">
|
||||||
|
<div className="text-4xl font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm leading-snug text-slate-500 dark:text-slate-400">
|
||||||
|
{metric.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<MetricsCTA />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
nx-dev/ui-markdoc/src/lib/tags/metrics.schema.ts
Normal file
16
nx-dev/ui-markdoc/src/lib/tags/metrics.schema.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Schema } from '@markdoc/markdoc';
|
||||||
|
|
||||||
|
export interface Metric {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metrics: Schema = {
|
||||||
|
render: 'Metrics',
|
||||||
|
attributes: {
|
||||||
|
metrics: {
|
||||||
|
type: 'Array',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user