Unify workspace dropdowns; Update layouts (#458)
Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
committed by
GitHub
parent
ca585e09be
commit
4bc8448a1d
@@ -0,0 +1,30 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('cookiePolicy'),
|
||||
};
|
||||
}
|
||||
|
||||
async function CookiePolicyPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader
|
||||
title={t(`marketing.cookiePolicy`)}
|
||||
subtitle={t(`marketing.cookiePolicyDescription`)}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto py-8'}>
|
||||
<div>Your terms of service content here</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CookiePolicyPage;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('privacyPolicy'),
|
||||
};
|
||||
}
|
||||
|
||||
async function PrivacyPolicyPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader
|
||||
title={t('privacyPolicy')}
|
||||
subtitle={t('privacyPolicyDescription')}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto py-8'}>
|
||||
<div>Your terms of service content here</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivacyPolicyPage;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('termsOfService'),
|
||||
};
|
||||
}
|
||||
|
||||
async function TermsOfServicePage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader
|
||||
title={t(`marketing.termsOfService`)}
|
||||
subtitle={t(`marketing.termsOfServiceDescription`)}
|
||||
/>
|
||||
|
||||
<div className={'container mx-auto py-8'}>
|
||||
<div>Your terms of service content here</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TermsOfServicePage;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Footer } from '@kit/ui/marketing';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<Footer
|
||||
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
||||
description={<Trans i18nKey="marketing.footerDescription" />}
|
||||
copyright={
|
||||
<Trans
|
||||
i18nKey="marketing.copyright"
|
||||
values={{
|
||||
product: appConfig.name,
|
||||
year: new Date().getFullYear(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
heading: <Trans i18nKey="marketing.about" />,
|
||||
links: [
|
||||
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
|
||||
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: <Trans i18nKey="marketing.product" />,
|
||||
links: [
|
||||
{
|
||||
href: '/docs',
|
||||
label: <Trans i18nKey="marketing.documentation" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: <Trans i18nKey="marketing.legal" />,
|
||||
links: [
|
||||
{
|
||||
href: '/terms-of-service',
|
||||
label: <Trans i18nKey="marketing.termsOfService" />,
|
||||
},
|
||||
{
|
||||
href: '/privacy-policy',
|
||||
label: <Trans i18nKey="marketing.privacyPolicy" />,
|
||||
},
|
||||
{
|
||||
href: '/cookie-policy',
|
||||
label: <Trans i18nKey="marketing.cookiePolicy" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const ModeToggle = dynamic(
|
||||
() =>
|
||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||
default: mod.ModeToggle,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const MobileModeToggle = dynamic(
|
||||
() =>
|
||||
import('@kit/ui/mobile-mode-toggle').then((mod) => ({
|
||||
default: mod.MobileModeToggle,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const paths = {
|
||||
home: pathsConfig.app.home,
|
||||
profileSettings: pathsConfig.app.personalAccountSettings,
|
||||
};
|
||||
|
||||
const features = {
|
||||
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
|
||||
};
|
||||
|
||||
export function SiteHeaderAccountSection({
|
||||
user,
|
||||
}: {
|
||||
user: JWTUserData | null;
|
||||
}) {
|
||||
const signOut = useSignOut();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
showProfileName={false}
|
||||
paths={paths}
|
||||
features={features}
|
||||
user={user}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthButtons />;
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
return (
|
||||
<div
|
||||
className={'animate-in fade-in flex items-center gap-x-2 duration-500'}
|
||||
>
|
||||
<div className={'hidden md:flex'}>
|
||||
<If condition={features.enableThemeToggle}>
|
||||
<ModeToggle />
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'md:hidden'}>
|
||||
<If condition={features.enableThemeToggle}>
|
||||
<MobileModeToggle />
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center gap-x-2'}>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
className={'hidden md:flex md:text-sm'}
|
||||
render={
|
||||
<Link href={pathsConfig.auth.signIn}>
|
||||
<Trans i18nKey={'auth.signIn'} />
|
||||
</Link>
|
||||
}
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
/>
|
||||
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Trans i18nKey={'auth.signUp'} />
|
||||
</Link>
|
||||
}
|
||||
className="text-xs md:text-sm"
|
||||
variant={'default'}
|
||||
size={'sm'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
import { Header } from '@kit/ui/marketing';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
import { SiteHeaderAccountSection } from './site-header-account-section';
|
||||
import { SiteNavigation } from './site-navigation';
|
||||
|
||||
export function SiteHeader(props: { user?: JWTUserData | null }) {
|
||||
return (
|
||||
<Header
|
||||
logo={<AppLogo className="mx-auto sm:mx-0" href="/" />}
|
||||
navigation={<SiteNavigation />}
|
||||
actions={<SiteHeaderAccountSection user={props.user ?? null} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { NavigationMenuItem } from '@kit/ui/navigation-menu';
|
||||
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||
|
||||
const getClassName = (path: string, currentPathName: string) => {
|
||||
const isActive = isRouteActive(path, currentPathName);
|
||||
|
||||
return cn(
|
||||
`inline-flex w-max text-sm font-medium transition-colors duration-300`,
|
||||
{
|
||||
'dark:text-gray-300 dark:hover:text-white': !isActive,
|
||||
'text-current dark:text-white': isActive,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export function SiteNavigationItem({
|
||||
path,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
path: string;
|
||||
}>) {
|
||||
const currentPathName = usePathname();
|
||||
const className = getClassName(path, currentPathName);
|
||||
|
||||
return (
|
||||
<NavigationMenuItem key={path}>
|
||||
<Link className={className} href={path} as={path} prefetch={true}>
|
||||
{children}
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { NavigationMenu, NavigationMenuList } from '@kit/ui/navigation-menu';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SiteNavigationItem } from './site-navigation-item';
|
||||
|
||||
const links = {
|
||||
Blog: {
|
||||
label: 'marketing.blog',
|
||||
path: '/blog',
|
||||
},
|
||||
Changelog: {
|
||||
label: 'marketing.changelog',
|
||||
path: '/changelog',
|
||||
},
|
||||
Docs: {
|
||||
label: 'marketing.documentation',
|
||||
path: '/docs',
|
||||
},
|
||||
Pricing: {
|
||||
label: 'marketing.pricing',
|
||||
path: '/pricing',
|
||||
},
|
||||
FAQ: {
|
||||
label: 'marketing.faq',
|
||||
path: '/faq',
|
||||
},
|
||||
};
|
||||
|
||||
export function SiteNavigation() {
|
||||
const NavItems = Object.values(links).map((item) => {
|
||||
return (
|
||||
<SiteNavigationItem key={item.path} path={item.path}>
|
||||
<Trans i18nKey={item.label} />
|
||||
</SiteNavigationItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'hidden items-center justify-center md:flex'}>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className={'gap-x-2.5'}>
|
||||
{NavItems}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-start sm:items-center md:hidden'}>
|
||||
<MobileDropdown />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={'Open Menu'}>
|
||||
<Menu className={'h-8 w-8'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className={'w-full'}>
|
||||
{Object.values(links).map((item) => {
|
||||
const className = 'flex w-full h-full items-center';
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
render={
|
||||
<Link className={className} href={item.path}>
|
||||
<Trans i18nKey={item.label} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function SitePageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
container = true,
|
||||
className = '',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
container?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const containerClass = container ? 'container' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/40 border-b py-6 xl:py-8 2xl:py-10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-y-2 lg:gap-y-3',
|
||||
containerClass,
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={
|
||||
'font-heading text-3xl tracking-tighter xl:text-5xl dark:text-white'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<h2
|
||||
className={
|
||||
'text-muted-foreground text-lg tracking-tight 2xl:text-2xl'
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx
Normal file
76
apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
|
||||
import { Post } from '../../blog/_components/post';
|
||||
|
||||
interface BlogPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const getPostBySlug = cache(postLoader);
|
||||
|
||||
async function postLoader(slug: string) {
|
||||
const client = await createCmsClient();
|
||||
|
||||
return client.getContentItemBySlug({ slug, collection: 'posts' });
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: BlogPageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
const post = await getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, publishedAt, description, image } = post;
|
||||
|
||||
return Promise.resolve({
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: publishedAt,
|
||||
url: post.url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function BlogPost({ params }: BlogPageProps) {
|
||||
const slug = (await params).slug;
|
||||
const post = await getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container sm:max-w-none sm:p-0'}>
|
||||
<Post post={post} content={post.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogPost;
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function BlogPagination(props: {
|
||||
currentPage: number;
|
||||
canGoToNextPage: boolean;
|
||||
canGoToPreviousPage: boolean;
|
||||
}) {
|
||||
const navigate = useGoToPage();
|
||||
|
||||
return (
|
||||
<div className={'flex items-center space-x-2'}>
|
||||
<If condition={props.canGoToPreviousPage}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
navigate(props.currentPage - 1);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className={'mr-2 h-4'} />
|
||||
<Trans i18nKey={'marketing.blogPaginationPrevious'} />
|
||||
</Button>
|
||||
</If>
|
||||
|
||||
<If condition={props.canGoToNextPage}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
navigate(props.currentPage + 1);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey={'marketing.blogPaginationNext'} />
|
||||
<ArrowRight className={'ml-2 h-4'} />
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useGoToPage() {
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
return (page: number) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
});
|
||||
|
||||
router.push(path + '?' + searchParams.toString());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
src: string;
|
||||
preloadImage?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CoverImage({ title, src, preloadImage, className }: Props) {
|
||||
return (
|
||||
<Image
|
||||
className={cn('block rounded-md object-cover', {
|
||||
className,
|
||||
})}
|
||||
src={src}
|
||||
priority={preloadImage}
|
||||
alt={`Cover Image for ${title}`}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
export const DateFormatter = ({ dateString }: Props) => {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DraftPostBadge({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CoverImage } from './cover-image';
|
||||
import { DateFormatter } from './date-formatter';
|
||||
|
||||
export function PostHeader({ post }: { post: Cms.ContentItem }) {
|
||||
const { title, publishedAt, description, image } = post;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<div className={cn('border-border/50 border-b py-8')}>
|
||||
<div className={'mx-auto flex max-w-3xl flex-col gap-y-2.5'}>
|
||||
<div>
|
||||
<span className={'text-muted-foreground text-xs'}>
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className={
|
||||
'font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<h2
|
||||
className={'text-muted-foreground text-base'}
|
||||
dangerouslySetInnerHTML={{ __html: description ?? '' }}
|
||||
></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
|
||||
<CoverImage
|
||||
preloadImage
|
||||
className="rounded-md"
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
|
||||
|
||||
type Props = {
|
||||
post: Cms.ContentItem;
|
||||
preloadImage?: boolean;
|
||||
imageHeight?: string | number;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_HEIGHT = 220;
|
||||
|
||||
export function PostPreview({
|
||||
post,
|
||||
preloadImage,
|
||||
imageHeight,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const { title, image, publishedAt, description } = post;
|
||||
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
|
||||
|
||||
const slug = `/blog/${post.slug}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={slug}
|
||||
className="hover:bg-muted/50 active:bg-muted flex flex-col gap-y-2.5 rounded-md p-4 transition-all"
|
||||
>
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mb-2 w-full" style={{ height }}>
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div className="flex flex-row items-center gap-x-3 text-xs">
|
||||
<div className="text-muted-foreground">
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-medium tracking-tight">
|
||||
<span className="hover:underline">{title}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mb-4 text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: trimText(description ?? '', 200) }}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function trimText(text: string, maxLength: number) {
|
||||
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
|
||||
}
|
||||
24
apps/web/app/[locale]/(marketing)/blog/_components/post.tsx
Normal file
24
apps/web/app/[locale]/(marketing)/blog/_components/post.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { ContentRenderer } from '@kit/cms';
|
||||
|
||||
import { PostHeader } from './post-header';
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
content,
|
||||
}: {
|
||||
post: Cms.ContentItem;
|
||||
content: unknown;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<PostHeader post={post} />
|
||||
|
||||
<div className={'mx-auto flex max-w-3xl flex-col space-y-6 py-8'}>
|
||||
<article className="markdoc">
|
||||
<ContentRenderer content={content} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/app/[locale]/(marketing)/blog/page.tsx
Normal file
120
apps/web/app/[locale]/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// local imports
|
||||
import { SitePageHeader } from '../_components/site-page-header';
|
||||
import { BlogPagination } from './_components/blog-pagination';
|
||||
import { PostPreview } from './_components/post-preview';
|
||||
|
||||
interface BlogPageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
const BLOG_POSTS_PER_PAGE = 10;
|
||||
|
||||
export const generateMetadata = async (
|
||||
props: BlogPageProps,
|
||||
): Promise<Metadata> => {
|
||||
const t = await getTranslations('marketing');
|
||||
const resolvedLanguage = await getLocale();
|
||||
const searchParams = await props.searchParams;
|
||||
const limit = BLOG_POSTS_PER_PAGE;
|
||||
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total } = await getContentItems(resolvedLanguage, limit, offset);
|
||||
|
||||
return {
|
||||
title: t('blog'),
|
||||
description: t('blogSubtitle'),
|
||||
pagination: {
|
||||
previous: page > 0 ? `/blog?page=${page - 1}` : undefined,
|
||||
next: offset + limit < total ? `/blog?page=${page + 1}` : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getContentItems = cache(
|
||||
async (language: string | undefined, limit: number, offset: number) => {
|
||||
const client = await createCmsClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
return await client.getContentItems({
|
||||
collection: 'posts',
|
||||
limit,
|
||||
offset,
|
||||
language,
|
||||
content: false,
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load blog posts');
|
||||
|
||||
return { total: 0, items: [] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function BlogPage(props: BlogPageProps) {
|
||||
const t = await getTranslations('marketing');
|
||||
const language = await getLocale();
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const limit = BLOG_POSTS_PER_PAGE;
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total, items: posts } = await getContentItems(
|
||||
language,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SitePageHeader title={t('blog')} subtitle={t('blogSubtitle')} />
|
||||
|
||||
<div className={'container flex flex-col space-y-6 py-8'}>
|
||||
<If
|
||||
condition={posts.length > 0}
|
||||
fallback={<Trans i18nKey="marketing.noPosts" />}
|
||||
>
|
||||
<PostsGridList>
|
||||
{posts.map((post, idx) => {
|
||||
return <PostPreview key={idx} post={post} />;
|
||||
})}
|
||||
</PostsGridList>
|
||||
|
||||
<div>
|
||||
<BlogPagination
|
||||
currentPage={page}
|
||||
canGoToNextPage={offset + limit < total}
|
||||
canGoToPreviousPage={page > 0}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlogPage;
|
||||
|
||||
function PostsGridList({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-2 md:gap-y-12 lg:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx
Normal file
108
apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
|
||||
import { ChangelogDetail } from '../_components/changelog-detail';
|
||||
|
||||
interface ChangelogEntryPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const getChangelogData = cache(changelogEntryLoader);
|
||||
|
||||
async function changelogEntryLoader(slug: string) {
|
||||
const client = await createCmsClient();
|
||||
|
||||
const [entry, allEntries] = await Promise.all([
|
||||
client.getContentItemBySlug({ slug, collection: 'changelog' }),
|
||||
client.getContentItems({
|
||||
collection: 'changelog',
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
content: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find previous and next entries in the timeline
|
||||
const currentIndex = allEntries.items.findIndex((item) => item.slug === slug);
|
||||
const newerEntry =
|
||||
currentIndex > 0 ? allEntries.items[currentIndex - 1] : null;
|
||||
const olderEntry =
|
||||
currentIndex < allEntries.items.length - 1
|
||||
? allEntries.items[currentIndex + 1]
|
||||
: null;
|
||||
|
||||
return {
|
||||
entry,
|
||||
previousEntry: olderEntry,
|
||||
nextEntry: newerEntry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ChangelogEntryPageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
const data = await getChangelogData(slug);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, publishedAt, description, image } = data.entry;
|
||||
|
||||
return Promise.resolve({
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: publishedAt,
|
||||
url: data.entry.url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getChangelogData(slug);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container sm:max-w-none sm:p-0">
|
||||
<ChangelogDetail
|
||||
entry={data.entry}
|
||||
content={data.entry.content}
|
||||
previousEntry={data.previousEntry ?? null}
|
||||
nextEntry={data.nextEntry ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangelogEntryPage;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { ContentRenderer } from '@kit/cms';
|
||||
|
||||
import { ChangelogHeader } from './changelog-header';
|
||||
import { ChangelogNavigation } from './changelog-navigation';
|
||||
|
||||
interface ChangelogDetailProps {
|
||||
entry: Cms.ContentItem;
|
||||
content: unknown;
|
||||
previousEntry: Cms.ContentItem | null;
|
||||
nextEntry: Cms.ContentItem | null;
|
||||
}
|
||||
|
||||
export function ChangelogDetail({
|
||||
entry,
|
||||
content,
|
||||
previousEntry,
|
||||
nextEntry,
|
||||
}: ChangelogDetailProps) {
|
||||
return (
|
||||
<div>
|
||||
<ChangelogHeader entry={entry} />
|
||||
|
||||
<div className="mx-auto flex max-w-3xl flex-col space-y-6 py-8">
|
||||
<article className="markdoc">
|
||||
<ContentRenderer content={content} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ChangelogNavigation
|
||||
previousEntry={previousEntry}
|
||||
nextEntry={nextEntry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { type Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { DateBadge } from './date-badge';
|
||||
|
||||
interface ChangelogEntryProps {
|
||||
entry: Cms.ContentItem;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function ChangelogEntry({
|
||||
entry,
|
||||
highlight = false,
|
||||
}: ChangelogEntryProps) {
|
||||
const { title, slug, publishedAt, description } = entry;
|
||||
const entryUrl = `/changelog/${slug}`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 md:gap-8">
|
||||
<div className="md:border-border relative flex flex-1 flex-col space-y-0 gap-y-2.5 border-l border-dashed border-transparent pb-4 md:pl-8 lg:pl-12">
|
||||
{highlight ? (
|
||||
<span className="absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 md:flex">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-red-400"></span>
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 rounded-full md:block',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="hover:bg-muted/50 active:bg-muted rounded-md transition-colors">
|
||||
<Link href={entryUrl} className="block space-y-2 p-4">
|
||||
<div>
|
||||
<DateBadge date={publishedAt} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl leading-tight font-semibold tracking-tight group-hover/link:underline">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<If condition={description}>
|
||||
{(desc) => (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{desc}
|
||||
</p>
|
||||
)}
|
||||
</If>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CoverImage } from '../../blog/_components/cover-image';
|
||||
import { DateFormatter } from '../../blog/_components/date-formatter';
|
||||
|
||||
export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) {
|
||||
const { title, publishedAt, description, image } = entry;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="border-border/50 border-b py-4">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<Trans i18nKey="marketing.changelog" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('border-border/50 border-b py-8')}>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-y-2.5">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{description && (
|
||||
<h2
|
||||
className="text-muted-foreground text-base"
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
|
||||
<CoverImage
|
||||
preloadImage
|
||||
className="rounded-md"
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { DateFormatter } from '../../blog/_components/date-formatter';
|
||||
|
||||
interface ChangelogNavigationProps {
|
||||
previousEntry: Cms.ContentItem | null;
|
||||
nextEntry: Cms.ContentItem | null;
|
||||
}
|
||||
|
||||
interface NavLinkProps {
|
||||
entry: Cms.ContentItem;
|
||||
direction: 'previous' | 'next';
|
||||
}
|
||||
|
||||
function NavLink({ entry, direction }: NavLinkProps) {
|
||||
const isPrevious = direction === 'previous';
|
||||
|
||||
const Icon = isPrevious ? ChevronLeft : ChevronRight;
|
||||
const i18nKey = isPrevious
|
||||
? 'marketing.changelogNavigationPrevious'
|
||||
: 'marketing.changelogNavigationNext';
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/changelog/${entry.slug}`}
|
||||
className={cn(
|
||||
'border-border/50 hover:bg-muted/50 group flex flex-col gap-2 rounded-lg border p-4 transition-all',
|
||||
!isPrevious && 'text-right md:items-end',
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
{isPrevious && <Icon className="h-3 w-3" />}
|
||||
|
||||
<span className="font-medium tracking-wider uppercase">
|
||||
<Trans i18nKey={i18nKey} />
|
||||
</span>
|
||||
{!isPrevious && <Icon className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="group-hover:text-primary text-sm leading-tight font-semibold transition-colors">
|
||||
{entry.title}
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<DateFormatter dateString={entry.publishedAt} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogNavigation({
|
||||
previousEntry,
|
||||
nextEntry,
|
||||
}: ChangelogNavigationProps) {
|
||||
return (
|
||||
<div className="border-border/50 border-t py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<If condition={previousEntry} fallback={<div />}>
|
||||
{(prev) => <NavLink entry={prev} direction="previous" />}
|
||||
</If>
|
||||
|
||||
<If condition={nextEntry} fallback={<div />}>
|
||||
{(next) => <NavLink entry={next} direction="next" />}
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
interface ChangelogPaginationProps {
|
||||
currentPage: number;
|
||||
canGoToNextPage: boolean;
|
||||
canGoToPreviousPage: boolean;
|
||||
}
|
||||
|
||||
export function ChangelogPagination({
|
||||
currentPage,
|
||||
canGoToNextPage,
|
||||
canGoToPreviousPage,
|
||||
}: ChangelogPaginationProps) {
|
||||
const nextPage = currentPage + 1;
|
||||
const previousPage = currentPage - 1;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canGoToPreviousPage && (
|
||||
<Button
|
||||
render={<Link href={`/changelog?page=${previousPage}`} />}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-3 w-3" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey="marketing.changelogPaginationPrevious" />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canGoToNextPage && (
|
||||
<Button
|
||||
render={<Link href={`/changelog?page=${nextPage}`} />}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<span>
|
||||
<Trans i18nKey="marketing.changelogPaginationNext" />
|
||||
</span>
|
||||
<ArrowRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
interface DateBadgeProps {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function DateBadge({ date }: DateBadgeProps) {
|
||||
const formattedDate = format(new Date(date), 'MMMM d, yyyy');
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-shrink-0 items-center gap-2 text-sm">
|
||||
<CalendarIcon className="size-3" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
apps/web/app/[locale]/(marketing)/changelog/page.tsx
Normal file
118
apps/web/app/[locale]/(marketing)/changelog/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SitePageHeader } from '../_components/site-page-header';
|
||||
import { ChangelogEntry } from './_components/changelog-entry';
|
||||
import { ChangelogPagination } from './_components/changelog-pagination';
|
||||
|
||||
interface ChangelogPageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
const CHANGELOG_ENTRIES_PER_PAGE = 50;
|
||||
|
||||
export const generateMetadata = async (
|
||||
props: ChangelogPageProps,
|
||||
): Promise<Metadata> => {
|
||||
const t = await getTranslations('marketing');
|
||||
const resolvedLanguage = await getLocale();
|
||||
const searchParams = await props.searchParams;
|
||||
const limit = CHANGELOG_ENTRIES_PER_PAGE;
|
||||
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total } = await getContentItems(resolvedLanguage, limit, offset);
|
||||
|
||||
return {
|
||||
title: t('changelog'),
|
||||
description: t('changelogSubtitle'),
|
||||
pagination: {
|
||||
previous: page > 0 ? `/changelog?page=${page - 1}` : undefined,
|
||||
next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getContentItems = cache(
|
||||
async (language: string | undefined, limit: number, offset: number) => {
|
||||
const client = await createCmsClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
return await client.getContentItems({
|
||||
collection: 'changelog',
|
||||
limit,
|
||||
offset,
|
||||
content: false,
|
||||
language,
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load changelog entries');
|
||||
|
||||
return { total: 0, items: [] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function ChangelogPage(props: ChangelogPageProps) {
|
||||
const t = await getTranslations('marketing');
|
||||
const language = await getLocale();
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const limit = CHANGELOG_ENTRIES_PER_PAGE;
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total, items: entries } = await getContentItems(
|
||||
language,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SitePageHeader
|
||||
title={t('changelog')}
|
||||
subtitle={t('changelogSubtitle')}
|
||||
/>
|
||||
|
||||
<div className="container flex max-w-4xl flex-col space-y-12 py-12">
|
||||
<If
|
||||
condition={entries.length > 0}
|
||||
fallback={<Trans i18nKey="marketing.noChangelogEntries" />}
|
||||
>
|
||||
<div className="space-y-0">
|
||||
{entries.map((entry, index) => {
|
||||
return (
|
||||
<ChangelogEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
highlight={index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ChangelogPagination
|
||||
currentPage={page}
|
||||
canGoToNextPage={offset + limit < total}
|
||||
canGoToPreviousPage={page > 0}
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangelogPage;
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.schema';
|
||||
import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions';
|
||||
|
||||
export function ContactForm() {
|
||||
const [state, setState] = useState({
|
||||
success: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const { execute, isPending } = useAction(sendContactEmail, {
|
||||
onSuccess: () => {
|
||||
setState({ success: true, error: false });
|
||||
},
|
||||
onError: () => {
|
||||
setState({ error: true, success: false });
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ContactEmailSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (state.success) {
|
||||
return <SuccessAlert />;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <ErrorAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
execute(data);
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing.contactName'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input maxLength={200} {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing.contactEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type={'email'} {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'message'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'marketing.contactMessage'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className={'min-h-36'}
|
||||
maxLength={5000}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button disabled={isPending} type={'submit'}>
|
||||
<Trans i18nKey={'marketing.sendMessage'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'marketing.contactSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'marketing.contactSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'marketing.contactError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'marketing.contactErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ContactEmailSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1).max(5000),
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use server';
|
||||
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { publicActionClient } from '@kit/next/safe-action';
|
||||
|
||||
import { ContactEmailSchema } from '../contact-email.schema';
|
||||
|
||||
const contactEmail = z
|
||||
.string({
|
||||
error:
|
||||
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||
})
|
||||
.parse(process.env.CONTACT_EMAIL);
|
||||
|
||||
const emailFrom = z
|
||||
.string({
|
||||
error:
|
||||
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||
})
|
||||
.parse(process.env.EMAIL_SENDER);
|
||||
|
||||
export const sendContactEmail = publicActionClient
|
||||
.schema(ContactEmailSchema)
|
||||
.action(async ({ parsedInput: data }) => {
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: contactEmail,
|
||||
from: emailFrom,
|
||||
subject: 'Contact Form Submission',
|
||||
html: `
|
||||
<p>
|
||||
You have received a new contact form submission.
|
||||
</p>
|
||||
|
||||
<p>Name: ${data.name}</p>
|
||||
<p>Email: ${data.email}</p>
|
||||
<p>Message: ${data.message}</p>
|
||||
`,
|
||||
});
|
||||
|
||||
return {};
|
||||
});
|
||||
51
apps/web/app/[locale]/(marketing)/contact/page.tsx
Normal file
51
apps/web/app/[locale]/(marketing)/contact/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('contact'),
|
||||
};
|
||||
}
|
||||
|
||||
async function ContactPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SitePageHeader title={t(`contact`)} subtitle={t(`contactDescription`)} />
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={'flex flex-1 flex-col items-center justify-center py-8'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex w-full max-w-lg flex-col space-y-4 rounded-lg border p-8'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Heading level={3}>
|
||||
<Trans i18nKey={'marketing.contactHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={'marketing.contactSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactPage;
|
||||
92
apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx
Normal file
92
apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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';
|
||||
|
||||
// local imports
|
||||
import { DocsCards } from '../_components/docs-cards';
|
||||
|
||||
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 ?? '';
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden'}>
|
||||
<div className={'flex size-full overflow-y-hidden'}>
|
||||
<div className="relative size-full">
|
||||
<article
|
||||
className={cn(
|
||||
'absolute size-full w-full gap-y-12 overflow-y-auto pt-4 pb-36',
|
||||
)}
|
||||
>
|
||||
<section
|
||||
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
|
||||
>
|
||||
<h1
|
||||
className={
|
||||
'text-foreground text-3xl font-semibold tracking-tighter'
|
||||
}
|
||||
>
|
||||
{page.title}
|
||||
</h1>
|
||||
|
||||
<h2 className={'text-secondary-foreground/80 text-lg'}>
|
||||
{description}
|
||||
</h2>
|
||||
</section>
|
||||
|
||||
<div className={'markdoc'}>
|
||||
<ContentRenderer content={page.content} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={page.children.length > 0}>
|
||||
<Separator />
|
||||
|
||||
<DocsCards cards={page.children ?? []} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentationPage;
|
||||
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function DocsCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
link,
|
||||
}: React.PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
link: { url: string; label?: string };
|
||||
}>) {
|
||||
return (
|
||||
<Link href={link.url} className="flex flex-col">
|
||||
<div
|
||||
className={`hover:bg-muted/70 flex grow flex-col gap-y-0.5 rounded border p-4`}
|
||||
>
|
||||
<h3 className="mt-0 text-lg font-medium hover:underline dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{subtitle && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className="text-sm">{children}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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={'absolute flex w-full flex-col gap-4 pb-48 lg:max-w-2xl'}>
|
||||
{cardsSortedByOrder.map((item) => {
|
||||
return (
|
||||
<DocsCard
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
subtitle={item.description}
|
||||
link={{
|
||||
url: `/docs/${item.slug}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar';
|
||||
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||
|
||||
export function DocsNavLink({
|
||||
label,
|
||||
url,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ label: string; url: string }>) {
|
||||
const currentPath = usePathname();
|
||||
const isCurrent = isRouteActive(url, currentPath);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
render={<Link href={url} />}
|
||||
isActive={isCurrent}
|
||||
className={cn('text-secondary-foreground transition-all')}
|
||||
>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
|
||||
{children}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { Collapsible } from '@kit/ui/collapsible';
|
||||
import { cn, 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),
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('group/collapsible', {
|
||||
'group/active': isChildActive,
|
||||
})}
|
||||
defaultOpen={isChildActive ? true : !props.node.collapsed}
|
||||
>
|
||||
{props.children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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/sidebar';
|
||||
|
||||
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
|
||||
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
|
||||
|
||||
import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button';
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<NodeContainer node={node} prefix={prefix}>
|
||||
<NodeTrigger node={node} label={label} url={url} />
|
||||
|
||||
<NodeContentContainer node={node}>
|
||||
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
|
||||
</NodeContentContainer>
|
||||
</NodeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeContentContainer({
|
||||
node,
|
||||
children,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return <CollapsibleContent>{children}</CollapsibleContent>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function NodeContainer({
|
||||
node,
|
||||
prefix,
|
||||
children,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
prefix: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return (
|
||||
<DocsNavigationCollapsible node={node} prefix={prefix}>
|
||||
{children}
|
||||
</DocsNavigationCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function NodeTrigger({
|
||||
node,
|
||||
label,
|
||||
url,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
label: string;
|
||||
url: string;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return (
|
||||
<CollapsibleTrigger render={<SidebarMenuItem />}>
|
||||
<SidebarMenuButton>
|
||||
{label}
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocsNavLink label={label} url={url} />;
|
||||
}
|
||||
|
||||
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={'sidebar'}
|
||||
className={'sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'}
|
||||
>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className={'pb-48'}>
|
||||
<Tree pages={pages} level={0} prefix={prefix} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
|
||||
<FloatingDocumentationNavigationButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { useSidebar } from '@kit/ui/sidebar';
|
||||
|
||||
export function FloatingDocumentationNavigationButton() {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className={
|
||||
'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden'
|
||||
}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Menu className={'text-primary-foreground size-6'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
33
apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts
Normal file
33
apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
45
apps/web/app/[locale]/(marketing)/docs/layout.tsx
Normal file
45
apps/web/app/[locale]/(marketing)/docs/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getLocale } from 'next-intl/server';
|
||||
|
||||
import { SidebarProvider } from '@kit/ui/sidebar';
|
||||
|
||||
// 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 locale = await getLocale();
|
||||
const docs = await getDocs(locale);
|
||||
const tree = buildDocumentationTree(docs);
|
||||
|
||||
return (
|
||||
<div className={'container h-[calc(100vh-56px)] overflow-y-hidden'}>
|
||||
<SidebarProvider
|
||||
className="lg:gap-x-6"
|
||||
style={{ '--sidebar-width': '17em' } as React.CSSProperties}
|
||||
>
|
||||
<HideFooterStyles />
|
||||
|
||||
<DocsNavigation pages={tree} />
|
||||
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HideFooterStyles() {
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.site-footer {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocsLayout;
|
||||
37
apps/web/app/[locale]/(marketing)/docs/page.tsx
Normal file
37
apps/web/app/[locale]/(marketing)/docs/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
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 getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('documentation'),
|
||||
};
|
||||
};
|
||||
|
||||
async function DocsPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
const locale = await getLocale();
|
||||
const items = await getDocs(locale);
|
||||
|
||||
// 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 w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
|
||||
<SitePageHeader
|
||||
title={t('documentation')}
|
||||
subtitle={t('documentationSubtitle')}
|
||||
/>
|
||||
|
||||
<div className={'relative flex size-full justify-center overflow-y-auto'}>
|
||||
<DocsCards cards={cards} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocsPage;
|
||||
139
apps/web/app/[locale]/(marketing)/faq/page.tsx
Normal file
139
apps/web/app/[locale]/(marketing)/faq/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRight, ChevronDown } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('faq'),
|
||||
};
|
||||
};
|
||||
|
||||
async function FAQPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
// replace this content with translations
|
||||
const faqItems = [
|
||||
{
|
||||
// or: t('faq.question1')
|
||||
question: `Do you offer a free trial?`,
|
||||
// or: t('faq.answer1')
|
||||
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
|
||||
},
|
||||
{
|
||||
question: `Can I cancel my subscription?`,
|
||||
answer: `You can cancel your subscription at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Where can I find my invoices?`,
|
||||
answer: `You can find your invoices in your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `What payment methods do you accept?`,
|
||||
answer: `We accept all major credit cards and PayPal.`,
|
||||
},
|
||||
{
|
||||
question: `Can I upgrade or downgrade my plan?`,
|
||||
answer: `Yes, you can upgrade or downgrade your plan at any time. You can do this from your account settings.`,
|
||||
},
|
||||
{
|
||||
question: `Do you offer discounts for non-profits?`,
|
||||
answer: `Yes, we offer a 50% discount for non-profits. Please contact us to learn more.`,
|
||||
},
|
||||
];
|
||||
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => {
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
key={'ld:json'}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
|
||||
<div className={'flex flex-col space-y-4 xl:space-y-8'}>
|
||||
<SitePageHeader title={t('faq')} subtitle={t('faqSubtitle')} />
|
||||
|
||||
<div className={'container flex flex-col items-center space-y-8 pb-16'}>
|
||||
<div className="divide-border flex w-full max-w-xl flex-col divide-y divide-dashed rounded-md border">
|
||||
{faqItems.map((item, index) => {
|
||||
return <FaqItem key={index} item={item} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={<Link href={'/contact'} />}
|
||||
variant={'link'}
|
||||
>
|
||||
<span>
|
||||
<Trans i18nKey={'marketing.contactFaq'} />
|
||||
</span>
|
||||
|
||||
<ArrowRight className={'ml-2 w-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FAQPage;
|
||||
|
||||
function FaqItem({
|
||||
item,
|
||||
}: React.PropsWithChildren<{
|
||||
item: {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
}>) {
|
||||
return (
|
||||
<details
|
||||
className={
|
||||
'hover:bg-muted/70 [&:open]:bg-muted/70 [&:open]:hover:bg-muted transition-all'
|
||||
}
|
||||
>
|
||||
<summary
|
||||
className={'flex items-center justify-between p-4 hover:cursor-pointer'}
|
||||
>
|
||||
<h2 className={'cursor-pointer font-sans text-base'}>
|
||||
<Trans i18nKey={item.question} defaults={item.question} />
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<ChevronDown
|
||||
className={'h-5 transition duration-300 group-open:-rotate-180'}
|
||||
/>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className={'text-muted-foreground flex flex-col gap-y-2 px-4 pb-2'}>
|
||||
<Trans i18nKey={item.answer} defaults={item.answer} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
22
apps/web/app/[locale]/(marketing)/layout.tsx
Normal file
22
apps/web/app/[locale]/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||
|
||||
async function SiteLayout(props: React.PropsWithChildren) {
|
||||
const client = getSupabaseServerClient();
|
||||
const user = await requireUser(client, { verifyMfa: false });
|
||||
|
||||
return (
|
||||
<div className={'flex min-h-[100vh] flex-col'}>
|
||||
<SiteHeader user={user.data} />
|
||||
|
||||
{props.children}
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SiteLayout;
|
||||
203
apps/web/app/[locale]/(marketing)/page.tsx
Normal file
203
apps/web/app/[locale]/(marketing)/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRightIcon, LayoutDashboard } from 'lucide-react';
|
||||
|
||||
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||
import {
|
||||
CtaButton,
|
||||
EcosystemShowcase,
|
||||
FeatureCard,
|
||||
FeatureGrid,
|
||||
FeatureShowcase,
|
||||
FeatureShowcaseIconContainer,
|
||||
Hero,
|
||||
Pill,
|
||||
PillActionButton,
|
||||
SecondaryHero,
|
||||
} from '@kit/ui/marketing';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className={'mt-4 flex flex-col space-y-24 py-14'}>
|
||||
<div className={'mx-auto'}>
|
||||
<Hero
|
||||
pill={
|
||||
<Pill label={'New'}>
|
||||
<span>The SaaS Starter Kit for ambitious developers</span>
|
||||
<PillActionButton
|
||||
render={
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<ArrowRightIcon className={'h-4 w-4'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Pill>
|
||||
}
|
||||
title={
|
||||
<span className="text-secondary-foreground">
|
||||
<span>Ship a SaaS faster than ever.</span>
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
<span>
|
||||
Makerkit gives you a production-ready boilerplate to build your
|
||||
SaaS faster than ever before with the next-gen SaaS Starter Kit.
|
||||
Get started in minutes.
|
||||
</span>
|
||||
}
|
||||
cta={<MainCallToActionButton />}
|
||||
image={
|
||||
<Image
|
||||
priority
|
||||
className={
|
||||
'dark:border-primary/10 w-full rounded-lg border border-gray-200'
|
||||
}
|
||||
width={3558}
|
||||
height={2222}
|
||||
src={`/images/dashboard.webp`}
|
||||
alt={`App Image`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div className={'py-4 xl:py-8'}>
|
||||
<FeatureShowcase
|
||||
heading={
|
||||
<>
|
||||
<b className="font-medium tracking-tight dark:text-white">
|
||||
The ultimate SaaS Starter Kit
|
||||
</b>
|
||||
.{' '}
|
||||
<span className="text-secondary-foreground/70 block font-normal tracking-tight">
|
||||
Unleash your creativity and build your SaaS faster than ever
|
||||
with Makerkit.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<FeatureShowcaseIconContainer>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
<span>All-in-one solution</span>
|
||||
</FeatureShowcaseIconContainer>
|
||||
}
|
||||
>
|
||||
<FeatureGrid>
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Beautiful Dashboard'}
|
||||
description={`Makerkit provides a beautiful dashboard to manage your SaaS business.`}
|
||||
></FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 w-full overflow-hidden'}
|
||||
label={'Authentication'}
|
||||
description={`Makerkit provides a variety of providers to allow your users to sign in.`}
|
||||
></FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Multi Tenancy'}
|
||||
description={`Multi tenant memberships for your SaaS business.`}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Billing'}
|
||||
description={`Makerkit supports multiple payment gateways to charge your customers.`}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Plugins'}
|
||||
description={`Extend your SaaS with plugins that you can install using the CLI.`}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
className={'relative col-span-1 overflow-hidden'}
|
||||
label={'Documentation'}
|
||||
description={`Makerkit provides a comprehensive documentation to help you get started.`}
|
||||
/>
|
||||
</FeatureGrid>
|
||||
</FeatureShowcase>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<EcosystemShowcase
|
||||
heading="The ultimate SaaS Starter Kit for founders."
|
||||
description="Unleash your creativity and build your SaaS faster than ever with Makerkit. Get started in minutes and ship your SaaS in no time."
|
||||
>
|
||||
<Image
|
||||
className="rounded-md"
|
||||
src={'/images/sign-in.webp'}
|
||||
alt="Sign in"
|
||||
width={1000}
|
||||
height={1000}
|
||||
/>
|
||||
</EcosystemShowcase>
|
||||
</div>
|
||||
|
||||
<div className={'container mx-auto'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-12 py-4 xl:py-8'
|
||||
}
|
||||
>
|
||||
<SecondaryHero
|
||||
pill={<Pill label="Start for free">No credit card required.</Pill>}
|
||||
heading="Fair pricing for all types of businesses"
|
||||
subheading="Get started on our free plan and upgrade when you are ready."
|
||||
/>
|
||||
|
||||
<div className={'w-full'}>
|
||||
<PricingTable
|
||||
config={billingConfig}
|
||||
paths={{
|
||||
signUp: pathsConfig.auth.signUp,
|
||||
return: pathsConfig.app.home,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
||||
function MainCallToActionButton() {
|
||||
return (
|
||||
<div className={'flex space-x-2.5'}>
|
||||
<CtaButton className="h-10 text-sm">
|
||||
<Link href={'/auth/sign-up'}>
|
||||
<span className={'flex items-center space-x-0.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'common.getStarted'} />
|
||||
</span>
|
||||
|
||||
<ArrowRightIcon
|
||||
className={
|
||||
'animate-in fade-in slide-in-from-left-8 h-4' +
|
||||
' zoom-in fill-mode-both delay-1000 duration-1000'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</CtaButton>
|
||||
|
||||
<CtaButton variant={'link'} className="h-10 text-sm">
|
||||
<Link href={'/pricing'}>
|
||||
<Trans i18nKey={'common.pricing'} />
|
||||
</Link>
|
||||
</CtaButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/web/app/[locale]/(marketing)/pricing/page.tsx
Normal file
36
apps/web/app/[locale]/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PricingTable } from '@kit/billing-gateway/marketing';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('pricing'),
|
||||
};
|
||||
};
|
||||
|
||||
const paths = {
|
||||
signUp: pathsConfig.auth.signUp,
|
||||
return: pathsConfig.app.home,
|
||||
};
|
||||
|
||||
async function PricingPage() {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
|
||||
|
||||
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
||||
<PricingTable paths={paths} config={billingConfig} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingPage;
|
||||
56
apps/web/app/[locale]/admin/AGENTS.md
Normal file
56
apps/web/app/[locale]/admin/AGENTS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Super Admin
|
||||
|
||||
## Critical Security Rules
|
||||
|
||||
- **ALWAYS** use `AdminGuard` to protect pages
|
||||
- **ALWAYS** validate admin status before operations
|
||||
- **NEVER** bypass authentication or authorization
|
||||
- **ALWAYS** audit admin operations with logging
|
||||
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
|
||||
|
||||
## Page Structure
|
||||
|
||||
```typescript
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
async function AdminPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Admin" />
|
||||
<PageBody>{/* Content */}</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminPage);
|
||||
```
|
||||
|
||||
## Admin Client Usage
|
||||
|
||||
```typescript
|
||||
import { isSuperAdmin } from '@kit/admin';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
async function adminOperation() {
|
||||
// CRITICAL: Validate first - admin client bypasses RLS
|
||||
if (!(await isSuperAdmin(currentUser))) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
// Safe to proceed
|
||||
}
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
```typescript
|
||||
const logger = await getLogger();
|
||||
logger.info({
|
||||
name: 'admin-audit',
|
||||
action: 'delete-user',
|
||||
adminId: currentUser.id,
|
||||
targetId: userId,
|
||||
}, 'Admin action performed');
|
||||
```
|
||||
1
apps/web/app/[locale]/admin/CLAUDE.md
Normal file
1
apps/web/app/[locale]/admin/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
68
apps/web/app/[locale]/admin/_components/admin-sidebar.tsx
Normal file
68
apps/web/app/[locale]/admin/_components/admin-sidebar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { LayoutDashboard, Users } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
} from '@kit/ui/sidebar';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
|
||||
export function AdminSidebar() {
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className={'m-2'}>
|
||||
<AppLogo href={'/admin'} className="max-w-full" />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuButton
|
||||
isActive={path === '/admin'}
|
||||
render={<Link className={'flex gap-2.5'} href={'/admin'} />}
|
||||
>
|
||||
<LayoutDashboard className={'h-4'} />
|
||||
<span>Dashboard</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<SidebarMenuButton
|
||||
isActive={path.includes('/admin/accounts')}
|
||||
render={
|
||||
<Link
|
||||
className={'flex size-full gap-2.5'}
|
||||
href={'/admin/accounts'}
|
||||
>
|
||||
<Users className={'h-4'} />
|
||||
<span>Accounts</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<ProfileAccountDropdownContainer />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
export function AdminMobileNavigation() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Menu className={'h-8 w-8'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={'/admin'}>Home</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Link href={'/admin/accounts'}>Accounts</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
47
apps/web/app/[locale]/admin/accounts/[id]/page.tsx
Normal file
47
apps/web/app/[locale]/admin/accounts/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
interface Params {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async (props: Params) => {
|
||||
const params = await props.params;
|
||||
const account = await loadAccount(params.id);
|
||||
|
||||
return {
|
||||
title: `Admin | ${account.name}`,
|
||||
};
|
||||
};
|
||||
|
||||
async function AccountPage(props: Params) {
|
||||
const params = await props.params;
|
||||
const account = await loadAccount(params.id);
|
||||
|
||||
return <AdminAccountPage account={account} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(AccountPage);
|
||||
|
||||
const loadAccount = cache(accountLoader);
|
||||
|
||||
async function accountLoader(id: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('accounts')
|
||||
.select('*, memberships: accounts_memberships (*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
3
apps/web/app/[locale]/admin/accounts/loading.tsx
Normal file
3
apps/web/app/[locale]/admin/accounts/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
79
apps/web/app/[locale]/admin/accounts/page.tsx
Normal file
79
apps/web/app/[locale]/admin/accounts/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
||||
|
||||
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
||||
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
interface SearchParams {
|
||||
page?: string;
|
||||
account_type?: 'all' | 'team' | 'personal';
|
||||
query?: string;
|
||||
}
|
||||
|
||||
interface AdminAccountsPageProps {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Accounts`,
|
||||
};
|
||||
|
||||
async function AccountsPage(props: AdminAccountsPageProps) {
|
||||
const client = getSupabaseServerClient();
|
||||
const searchParams = await props.searchParams;
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<AppBreadcrumbs />}>
|
||||
<div className="flex justify-end">
|
||||
<AdminCreateUserDialog>
|
||||
<Button data-test="admin-create-user-button">Create User</Button>
|
||||
</AdminCreateUserDialog>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<ServerDataLoader
|
||||
table={'accounts'}
|
||||
client={client}
|
||||
page={page}
|
||||
where={(queryBuilder) => {
|
||||
const { account_type: type, query } = searchParams;
|
||||
|
||||
if (type && type !== 'all') {
|
||||
queryBuilder.eq('is_personal_account', type === 'personal');
|
||||
}
|
||||
|
||||
if (query) {
|
||||
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%`);
|
||||
}
|
||||
|
||||
return queryBuilder;
|
||||
}}
|
||||
>
|
||||
{({ data, page, pageSize, pageCount }) => {
|
||||
return (
|
||||
<AdminAccountsTable
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageCount={pageCount}
|
||||
data={data}
|
||||
filters={{
|
||||
type: searchParams.account_type ?? 'all',
|
||||
query: searchParams.query ?? '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ServerDataLoader>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AccountsPage);
|
||||
44
apps/web/app/[locale]/admin/layout.tsx
Normal file
44
apps/web/app/[locale]/admin/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
import { SidebarProvider } from '@kit/ui/sidebar';
|
||||
|
||||
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
||||
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
||||
|
||||
export const metadata = {
|
||||
title: `Super Admin`,
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function AdminLayout(props: React.PropsWithChildren) {
|
||||
const state = use(getLayoutState());
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={state.open}>
|
||||
<Page style={'sidebar'}>
|
||||
<PageNavigation>
|
||||
<AdminSidebar />
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation>
|
||||
<AdminMobileNavigation />
|
||||
</PageMobileNavigation>
|
||||
|
||||
{props.children}
|
||||
</Page>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function getLayoutState() {
|
||||
const cookieStore = await cookies();
|
||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||
|
||||
return {
|
||||
open: sidebarOpenCookie?.value !== 'true',
|
||||
};
|
||||
}
|
||||
17
apps/web/app/[locale]/admin/page.tsx
Normal file
17
apps/web/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
|
||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={`Super Admin`} />
|
||||
|
||||
<PageBody>
|
||||
<AdminDashboard />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminPage);
|
||||
74
apps/web/app/[locale]/auth/callback/error/page.tsx
Normal file
74
apps/web/app/[locale]/auth/callback/error/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { AuthError } from '@supabase/supabase-js';
|
||||
|
||||
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
interface AuthCallbackErrorPageProps {
|
||||
searchParams: Promise<{
|
||||
error: string;
|
||||
callback?: string;
|
||||
email?: string;
|
||||
code?: AuthError['code'];
|
||||
}>;
|
||||
}
|
||||
|
||||
async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
|
||||
const { error, callback, code } = await props.searchParams;
|
||||
const signInPath = pathsConfig.auth.signIn;
|
||||
const redirectPath = callback ?? pathsConfig.auth.callback;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4 py-4'}>
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.authenticationErrorAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={error ?? 'auth.authenticationErrorAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<AuthCallbackForm
|
||||
code={code}
|
||||
signInPath={signInPath}
|
||||
redirectPath={redirectPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCallbackForm(props: {
|
||||
signInPath: string;
|
||||
redirectPath?: string;
|
||||
code?: AuthError['code'];
|
||||
}) {
|
||||
switch (props.code) {
|
||||
case 'otp_expired':
|
||||
return <ResendAuthLinkForm redirectPath={props.redirectPath} />;
|
||||
|
||||
default:
|
||||
return <SignInButton signInPath={props.signInPath} />;
|
||||
}
|
||||
}
|
||||
|
||||
function SignInButton(props: { signInPath: string }) {
|
||||
return (
|
||||
<Button
|
||||
className={'w-full'}
|
||||
render={
|
||||
<Link href={props.signInPath}>
|
||||
<Trans i18nKey={'auth.signIn'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthCallbackErrorPage;
|
||||
18
apps/web/app/[locale]/auth/callback/route.ts
Normal file
18
apps/web/app/[locale]/auth/callback/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
|
||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||
joinTeamPath: pathsConfig.app.joinTeam,
|
||||
redirectPath: pathsConfig.app.home,
|
||||
});
|
||||
|
||||
return redirect(nextPath);
|
||||
}
|
||||
17
apps/web/app/[locale]/auth/confirm/route.ts
Normal file
17
apps/web/app/[locale]/auth/confirm/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||
|
||||
const url = await service.verifyTokenHash(request, {
|
||||
joinTeamPath: pathsConfig.app.joinTeam,
|
||||
redirectPath: pathsConfig.app.home,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
9
apps/web/app/[locale]/auth/layout.tsx
Normal file
9
apps/web/app/[locale]/auth/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||
}
|
||||
|
||||
export default AuthLayout;
|
||||
3
apps/web/app/[locale]/auth/loading.tsx
Normal file
3
apps/web/app/[locale]/auth/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
56
apps/web/app/[locale]/auth/password-reset/page.tsx
Normal file
56
apps/web/app/[locale]/auth/password-reset/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('auth');
|
||||
|
||||
return {
|
||||
title: t('passwordResetLabel'),
|
||||
};
|
||||
};
|
||||
|
||||
const { callback, passwordUpdate, signIn } = pathsConfig.auth;
|
||||
const redirectPath = `${callback}?next=${passwordUpdate}`;
|
||||
|
||||
function PasswordResetPage() {
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth.passwordResetLabel'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth.passwordResetSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<PasswordResetRequestContainer redirectPath={redirectPath} />
|
||||
|
||||
<div className={'flex justify-center text-xs'}>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant={'link'}
|
||||
size={'sm'}
|
||||
render={
|
||||
<Link href={signIn}>
|
||||
<Trans i18nKey={'auth.passwordRecoveredQuestion'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordResetPage;
|
||||
71
apps/web/app/[locale]/auth/sign-in/page.tsx
Normal file
71
apps/web/app/[locale]/auth/sign-in/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||
import { getSafeRedirectPath } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
interface SignInPageProps {
|
||||
searchParams: Promise<{
|
||||
next?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('auth');
|
||||
|
||||
return {
|
||||
title: t('signIn'),
|
||||
};
|
||||
};
|
||||
|
||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const { next } = await searchParams;
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
returnPath: getSafeRedirectPath(next, pathsConfig.app.home),
|
||||
joinTeam: pathsConfig.app.joinTeam,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth.signInHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth.signInSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignInMethodsContainer
|
||||
paths={paths}
|
||||
providers={authConfig.providers}
|
||||
captchaSiteKey={authConfig.captchaTokenSiteKey}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant={'link'}
|
||||
size={'sm'}
|
||||
render={
|
||||
<Link href={pathsConfig.auth.signUp} prefetch={true}>
|
||||
<Trans i18nKey={'auth.doNotHaveAccountYet'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInPage;
|
||||
62
apps/web/app/[locale]/auth/sign-up/page.tsx
Normal file
62
apps/web/app/[locale]/auth/sign-up/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('auth');
|
||||
|
||||
return {
|
||||
title: t('signUp'),
|
||||
};
|
||||
};
|
||||
|
||||
const paths = {
|
||||
callback: pathsConfig.auth.callback,
|
||||
appHome: pathsConfig.app.home,
|
||||
};
|
||||
|
||||
async function SignUpPage() {
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col items-center gap-1'}>
|
||||
<Heading level={4} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth.signUpHeading'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth.signUpSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignUpMethodsContainer
|
||||
providers={authConfig.providers}
|
||||
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||
paths={paths}
|
||||
captchaSiteKey={authConfig.captchaTokenSiteKey}
|
||||
/>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button
|
||||
render={
|
||||
<Link href={pathsConfig.auth.signIn} prefetch={true}>
|
||||
<Trans i18nKey={'auth.alreadyHaveAnAccount'} />
|
||||
</Link>
|
||||
}
|
||||
variant={'link'}
|
||||
size={'sm'}
|
||||
nativeButton={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpPage;
|
||||
54
apps/web/app/[locale]/auth/verify/page.tsx
Normal file
54
apps/web/app/[locale]/auth/verify/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
|
||||
import { getSafeRedirectPath } from '@kit/shared/utils';
|
||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{
|
||||
next?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('auth');
|
||||
|
||||
return {
|
||||
title: t('signIn'),
|
||||
};
|
||||
};
|
||||
|
||||
async function VerifyPage(props: Props) {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data } = await client.auth.getClaims();
|
||||
|
||||
if (!data?.claims) {
|
||||
redirect(pathsConfig.auth.signIn);
|
||||
}
|
||||
|
||||
const needsMfa = await checkRequiresMultiFactorAuthentication(client);
|
||||
|
||||
if (!needsMfa) {
|
||||
redirect(pathsConfig.auth.signIn);
|
||||
}
|
||||
|
||||
const nextPath = (await props.searchParams).next;
|
||||
const redirectPath = getSafeRedirectPath(nextPath, pathsConfig.app.home);
|
||||
|
||||
return (
|
||||
<MultiFactorChallengeContainer
|
||||
userId={data.claims.sub}
|
||||
paths={{
|
||||
redirectPath,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyPage;
|
||||
35
apps/web/app/[locale]/error.tsx
Normal file
35
apps/web/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||
import { ErrorPageContent } from '~/components/error-page-content';
|
||||
|
||||
const ErrorPage = ({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) => {
|
||||
useCaptureException(error);
|
||||
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className={'flex h-screen flex-1 flex-col'}>
|
||||
<SiteHeader user={user.data} />
|
||||
|
||||
<ErrorPageContent
|
||||
statusCode={'common.errorPageHeading'}
|
||||
heading={'common.genericError'}
|
||||
subtitle={'common.genericErrorSubHeading'}
|
||||
backLabel={'common.goBack'}
|
||||
reset={reset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { SidebarContext } from '@kit/ui/sidebar';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const features = {
|
||||
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||
};
|
||||
|
||||
export function HomeAccountSelector(props: {
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
}>;
|
||||
|
||||
userId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
collapsed={!context?.open}
|
||||
accounts={props.accounts}
|
||||
features={features}
|
||||
userId={props.userId}
|
||||
onAccountChange={(value) => {
|
||||
if (value) {
|
||||
const path = pathsConfig.app.accountHome.replace('[account]', value);
|
||||
router.replace(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CardButton,
|
||||
CardButtonHeader,
|
||||
CardButtonTitle,
|
||||
} from '@kit/ui/card-button';
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateButton,
|
||||
EmptyStateHeading,
|
||||
EmptyStateText,
|
||||
} from '@kit/ui/empty-state';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
import { HomeAddAccountButton } from './home-add-account-button';
|
||||
|
||||
export function HomeAccountsList() {
|
||||
const { accounts, canCreateTeamAccount } = use(loadUserWorkspace());
|
||||
|
||||
if (!accounts.length) {
|
||||
return (
|
||||
<HomeAccountsListEmptyState canCreateTeamAccount={canCreateTeamAccount} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{accounts.map((account) => (
|
||||
<CardButton
|
||||
key={account.value}
|
||||
render={
|
||||
<Link href={`/home/${account.value}`}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>{account.label}</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeAccountsListEmptyState(props: {
|
||||
canCreateTeamAccount: { allowed: boolean; reason?: string };
|
||||
}) {
|
||||
return (
|
||||
<div className={'flex flex-1'}>
|
||||
<EmptyState>
|
||||
<EmptyStateButton
|
||||
render={
|
||||
<HomeAddAccountButton
|
||||
className={'mt-4'}
|
||||
canCreateTeamAccount={props.canCreateTeamAccount}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<EmptyStateHeading>
|
||||
<Trans i18nKey={'account.noTeamsYet'} />
|
||||
</EmptyStateHeading>
|
||||
|
||||
<EmptyStateText>
|
||||
<Trans i18nKey={'account.createTeam'} />
|
||||
</EmptyStateText>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
interface HomeAddAccountButtonProps {
|
||||
className?: string;
|
||||
canCreateTeamAccount?: {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
|
||||
const [isAddingAccount, setIsAddingAccount] = useState(false);
|
||||
|
||||
const canCreate = props.canCreateTeamAccount?.allowed ?? true;
|
||||
const reason = props.canCreateTeamAccount?.reason;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
className={props.className}
|
||||
onClick={() => setIsAddingAccount(true)}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
<Trans i18nKey={'account.createTeamButtonLabel'} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!canCreate && reason ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="cursor-not-allowed">{button}</span>}
|
||||
/>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans i18nKey={reason} defaults={reason} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
|
||||
<CreateTeamAccountDialog
|
||||
isOpen={isAddingAccount}
|
||||
setIsOpen={setIsAddingAccount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BorderedNavigationMenu,
|
||||
BorderedNavigationMenuItem,
|
||||
} from '@kit/ui/bordered-navigation-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||
|
||||
// home imports
|
||||
import { HomeAccountSelector } from '../_components/home-account-selector';
|
||||
import { UserNotifications } from '../_components/user-notifications';
|
||||
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
|
||||
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
|
||||
const { workspace, user, accounts } = props.workspace;
|
||||
|
||||
const routes = personalAccountNavigationConfig.routes.reduce<
|
||||
Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon?: React.ReactNode;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>
|
||||
>((acc, item) => {
|
||||
if ('children' in item) {
|
||||
return [...acc, ...item.children];
|
||||
}
|
||||
|
||||
if ('divider' in item) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-1 justify-between'}>
|
||||
<div className={'flex items-center space-x-8'}>
|
||||
<AppLogo />
|
||||
|
||||
<BorderedNavigationMenu>
|
||||
{routes.map((route) => (
|
||||
<BorderedNavigationMenuItem {...route} key={route.path} />
|
||||
))}
|
||||
</BorderedNavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<UserNotifications userId={user.id} />
|
||||
|
||||
<If condition={featuresFlagConfig.enableTeamAccounts}>
|
||||
<HomeAccountSelector userId={user.id} accounts={accounts} />
|
||||
</If>
|
||||
|
||||
<div>
|
||||
<ProfileAccountDropdownContainer
|
||||
user={user}
|
||||
account={workspace}
|
||||
showProfileName={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { LogOut, Menu } from 'lucide-react';
|
||||
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||
|
||||
// home imports
|
||||
import { HomeAccountSelector } from '../_components/home-account-selector';
|
||||
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
|
||||
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
||||
if ('children' in item) {
|
||||
return item.children.map((child) => {
|
||||
return (
|
||||
<DropdownLink
|
||||
key={child.path}
|
||||
Icon={child.Icon}
|
||||
path={child.path}
|
||||
label={child.label}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ('divider' in item) {
|
||||
return <DropdownMenuSeparator key={index} />;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Menu className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||
<If condition={featuresFlagConfig.enableTeamAccounts}>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
<Trans i18nKey={'common.yourAccounts'} />
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<HomeAccountSelector
|
||||
userId={props.workspace.user.id}
|
||||
accounts={props.workspace.accounts}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</If>
|
||||
|
||||
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownLink(
|
||||
props: React.PropsWithChildren<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
href={props.path}
|
||||
className={'flex h-12 w-full items-center space-x-4'}
|
||||
>
|
||||
{props.Icon}
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={props.label} defaults={props.label} />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
key={props.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutDropdownItem(
|
||||
props: React.PropsWithChildren<{
|
||||
onSignOut: () => unknown;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={'flex h-12 w-full items-center space-x-4'}
|
||||
onClick={props.onSignOut}
|
||||
>
|
||||
<LogOut className={'h-6'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
export function HomeLayoutPageHeader(
|
||||
props: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<PageHeader description={props.description}>{props.children}</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
|
||||
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
|
||||
|
||||
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
|
||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
|
||||
|
||||
// home imports
|
||||
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
|
||||
interface HomeSidebarProps {
|
||||
workspace: UserWorkspace;
|
||||
}
|
||||
|
||||
export function HomeSidebar(props: HomeSidebarProps) {
|
||||
const { workspace, user, accounts } = props.workspace;
|
||||
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" collapsible={collapsible}>
|
||||
<SidebarHeader className={'h-16 justify-center'}>
|
||||
<div className={'flex items-center justify-between gap-x-3'}>
|
||||
<WorkspaceDropdown
|
||||
user={user}
|
||||
accounts={accounts}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
<div className={'group-data-[collapsible=icon]:hidden'}>
|
||||
<UserNotifications userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarNavigation config={personalAccountNavigationConfig} />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NotificationsPopover } from '@kit/notifications/components';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
|
||||
export function UserNotifications(props: { userId: string }) {
|
||||
if (!featuresFlagConfig.enableNotifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationsPopover
|
||||
accountIds={[props.userId]}
|
||||
realtime={featuresFlagConfig.realtimeNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createAccountCreationPolicyEvaluator } from '@kit/team-accounts/policies';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
|
||||
|
||||
export type UserWorkspace = Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||
|
||||
/**
|
||||
* @name loadUserWorkspace
|
||||
* @description
|
||||
* Load the user workspace data. It's a cached per-request function that fetches the user workspace data.
|
||||
* It can be used across the server components to load the user workspace data.
|
||||
*/
|
||||
export const loadUserWorkspace = cache(workspaceLoader);
|
||||
|
||||
async function workspaceLoader() {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const accountsPromise = shouldLoadAccounts
|
||||
? () => api.loadUserAccounts()
|
||||
: () => Promise.resolve([]);
|
||||
|
||||
const workspacePromise = api.getAccountWorkspace();
|
||||
|
||||
const [accounts, workspace, user] = await Promise.all([
|
||||
accountsPromise(),
|
||||
workspacePromise,
|
||||
requireUserInServerComponent(),
|
||||
]);
|
||||
|
||||
// If the user is not found or the workspace is not found, redirect to the home page - this may happen if the JWT is invalid or expired (ex. user deleted?)
|
||||
if (!workspace || !user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
// Check if user can create team accounts (policy check)
|
||||
const canCreateTeamAccount = shouldLoadAccounts
|
||||
? await checkCanCreateTeamAccount(user.id)
|
||||
: { allowed: false, reason: undefined };
|
||||
|
||||
return {
|
||||
accounts,
|
||||
workspace,
|
||||
user,
|
||||
canCreateTeamAccount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can create a team account based on policies.
|
||||
* Preliminary checks run without account name - name validation happens during submission.
|
||||
*/
|
||||
async function checkCanCreateTeamAccount(userId: string) {
|
||||
const evaluator = createAccountCreationPolicyEvaluator();
|
||||
const hasPolicies = await evaluator.hasPoliciesForStage('preliminary');
|
||||
|
||||
if (!hasPolicies) {
|
||||
return { allowed: true, reason: undefined };
|
||||
}
|
||||
|
||||
const context = {
|
||||
timestamp: new Date().toISOString(),
|
||||
userId,
|
||||
accountName: '',
|
||||
};
|
||||
|
||||
const result = await evaluator.canCreateAccount(context, 'preliminary');
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
reason: result.reasons[0],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { PlanPicker } from '@kit/billing-gateway/components';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
import { createPersonalAccountCheckoutSession } from '../_lib/server/server-actions';
|
||||
|
||||
const EmbeddedCheckout = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return {
|
||||
default: EmbeddedCheckout,
|
||||
};
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function PersonalAccountCheckoutForm(props: {
|
||||
customerId: string | null | undefined;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
const appEvents = useAppEvents();
|
||||
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { execute, isPending } = useAction(
|
||||
createPersonalAccountCheckoutSession,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.checkoutToken) {
|
||||
setCheckoutToken(data.checkoutToken);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setError(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// only allow trial if the user is not already a customer
|
||||
const canStartTrial = !props.customerId;
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
onClose={() => setCheckoutToken(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'billing.planCardLabel'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'billing.planCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<If condition={error}>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<PlanPicker
|
||||
pending={isPending}
|
||||
config={billingConfig}
|
||||
canStartTrial={canStartTrial}
|
||||
onSubmit={({ planId, productId }) => {
|
||||
appEvents.emit({
|
||||
type: 'checkout.started',
|
||||
payload: { planId },
|
||||
});
|
||||
|
||||
execute({
|
||||
planId,
|
||||
productId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'common.planPickerAlertErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common.planPickerAlertErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { BillingPortalCard } from '@kit/billing-gateway/components';
|
||||
|
||||
import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions';
|
||||
|
||||
export function PersonalBillingPortalForm() {
|
||||
const { execute } = useAction(createPersonalAccountBillingPortalSession);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
execute();
|
||||
}}
|
||||
>
|
||||
<BillingPortalCard />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const PersonalAccountCheckoutSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
/**
|
||||
* Load the personal account billing page data for the given user.
|
||||
* @param userId
|
||||
* @returns The subscription data or the orders data and the billing customer ID.
|
||||
* This function is cached per-request.
|
||||
*/
|
||||
export const loadPersonalAccountBillingPageData = cache(
|
||||
personalAccountBillingPageDataLoader,
|
||||
);
|
||||
|
||||
function personalAccountBillingPageDataLoader(userId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
|
||||
const subscription = api.getSubscription(userId);
|
||||
const order = api.getOrder(userId);
|
||||
const customerId = api.getCustomerId(userId);
|
||||
|
||||
return Promise.all([subscription, order, customerId]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||
import { createUserBillingService } from './user-billing.service';
|
||||
|
||||
/**
|
||||
* @name enabled
|
||||
* @description This feature flag is used to enable or disable personal account billing.
|
||||
*/
|
||||
const enabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
/**
|
||||
* @name createPersonalAccountCheckoutSession
|
||||
* @description Creates a checkout session for a personal account.
|
||||
*/
|
||||
export const createPersonalAccountCheckoutSession = authActionClient
|
||||
.schema(PersonalAccountCheckoutSchema)
|
||||
.action(async ({ parsedInput: data }) => {
|
||||
if (!enabled) {
|
||||
throw new Error('Personal account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createUserBillingService(client);
|
||||
|
||||
return await service.createCheckoutSession(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* @name createPersonalAccountBillingPortalSession
|
||||
* @description Creates a billing Portal session for a personal account
|
||||
*/
|
||||
export const createPersonalAccountBillingPortalSession =
|
||||
authActionClient.action(async () => {
|
||||
if (!enabled) {
|
||||
throw new Error('Personal account billing is not enabled');
|
||||
}
|
||||
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createUserBillingService(client);
|
||||
|
||||
// get url to billing portal
|
||||
const url = await service.createBillingPortalSession();
|
||||
|
||||
redirect(url);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import * as z from 'zod';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getProductPlanPair } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { requireUser } from '@kit/supabase/require-user';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { Database } from '~/lib/database.types';
|
||||
|
||||
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
|
||||
|
||||
export function createUserBillingService(client: SupabaseClient<Database>) {
|
||||
return new UserBillingService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name UserBillingService
|
||||
* @description Service for managing billing for personal accounts.
|
||||
*/
|
||||
class UserBillingService {
|
||||
private readonly namespace = 'billing.personal-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name createCheckoutSession
|
||||
* @description Create a checkout session for the user
|
||||
* @param planId
|
||||
* @param productId
|
||||
*/
|
||||
async createCheckoutSession({
|
||||
planId,
|
||||
productId,
|
||||
}: z.output<typeof PersonalAccountCheckoutSchema>) {
|
||||
// get the authenticated user
|
||||
const { data: user, error } = await requireUser(this.client);
|
||||
|
||||
if (error ?? !user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
|
||||
// in the case of personal accounts
|
||||
// the account ID is the same as the user ID
|
||||
const accountId = user.id;
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl();
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const api = createAccountsApi(this.client);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
const product = billingConfig.products.find(
|
||||
(item) => item.id === productId,
|
||||
);
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const { plan } = getProductPlanPair(billingConfig, planId);
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: `billing.personal-account`,
|
||||
planId,
|
||||
customerId,
|
||||
accountId,
|
||||
},
|
||||
`User requested a personal account checkout session. Contacting provider...`,
|
||||
);
|
||||
|
||||
try {
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
returnUrl,
|
||||
accountId,
|
||||
customerEmail: user.email,
|
||||
customerId,
|
||||
plan,
|
||||
variantQuantities: [],
|
||||
enableDiscountField: product.enableDiscountField,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
`Checkout session created. Returning checkout token to client...`,
|
||||
);
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
name: `billing.personal-account`,
|
||||
planId,
|
||||
customerId,
|
||||
accountId,
|
||||
error,
|
||||
},
|
||||
`Checkout session not created due to an error`,
|
||||
);
|
||||
|
||||
throw new Error(`Failed to create a checkout session`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingPortalSession
|
||||
* @description Create a billing portal session for the user
|
||||
* @returns The URL to redirect the user to the billing portal
|
||||
*/
|
||||
async createBillingPortalSession() {
|
||||
const { data, error } = await requireUser(this.client);
|
||||
|
||||
if (error ?? !data) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getBillingGatewayProvider(this.client);
|
||||
const logger = await getLogger();
|
||||
|
||||
const accountId = data.id;
|
||||
const api = createAccountsApi(this.client);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
const returnUrl = getBillingPortalReturnUrl();
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
customerId,
|
||||
accountId,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
`User requested a Billing Portal session. Contacting provider...`,
|
||||
);
|
||||
|
||||
let url: string;
|
||||
|
||||
try {
|
||||
const session = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
url = session.url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
...ctx,
|
||||
},
|
||||
`Failed to create a Billing Portal session`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Encountered an error creating the Billing Portal session`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(ctx, `Session successfully created.`);
|
||||
|
||||
// redirect user to billing portal
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl() {
|
||||
return new URL(
|
||||
pathsConfig.app.personalAccountBillingReturn,
|
||||
appConfig.url,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl() {
|
||||
return new URL(
|
||||
pathsConfig.app.personalAccountBilling,
|
||||
appConfig.url,
|
||||
).toString();
|
||||
}
|
||||
7
apps/web/app/[locale]/home/(user)/billing/error.tsx
Normal file
7
apps/web/app/[locale]/home/(user)/billing/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
// We reuse the page from the billing module
|
||||
// as there is no need to create a new one.
|
||||
import BillingErrorPage from '~/home/[account]/billing/error';
|
||||
|
||||
export default BillingErrorPage;
|
||||
15
apps/web/app/[locale]/home/(user)/billing/layout.tsx
Normal file
15
apps/web/app/[locale]/home/(user)/billing/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
|
||||
function UserBillingLayout(props: React.PropsWithChildren) {
|
||||
const isEnabled = featureFlagsConfig.enablePersonalAccountBilling;
|
||||
|
||||
if (!isEnabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
export default UserBillingLayout;
|
||||
105
apps/web/app/[locale]/home/(user)/billing/page.tsx
Normal file
105
apps/web/app/[locale]/home/(user)/billing/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { resolveProductPlan } from '@kit/billing-gateway';
|
||||
import {
|
||||
CurrentLifetimeOrderCard,
|
||||
CurrentSubscriptionCard,
|
||||
} from '@kit/billing-gateway/components';
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
// local imports
|
||||
import { HomeLayoutPageHeader } from '../_components/home-page-header';
|
||||
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
|
||||
import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form';
|
||||
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('account');
|
||||
const title = t('billingTab');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
async function PersonalAccountBillingPage() {
|
||||
const user = await requireUserInServerComponent();
|
||||
|
||||
const [subscription, order, customerId] =
|
||||
await loadPersonalAccountBillingPageData(user.id);
|
||||
|
||||
const subscriptionVariantId = subscription?.items[0]?.variant_id;
|
||||
const orderVariantId = order?.items[0]?.variant_id;
|
||||
|
||||
const subscriptionProductPlan =
|
||||
subscription && subscriptionVariantId
|
||||
? await resolveProductPlan(
|
||||
billingConfig,
|
||||
subscriptionVariantId,
|
||||
subscription.currency,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const orderProductPlan =
|
||||
order && orderVariantId
|
||||
? await resolveProductPlan(billingConfig, orderVariantId, order.currency)
|
||||
: undefined;
|
||||
|
||||
const hasBillingData = subscription || order;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'common.routes.billing'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
|
||||
<div className={'flex max-w-2xl flex-col space-y-4'}>
|
||||
<If
|
||||
condition={hasBillingData}
|
||||
fallback={
|
||||
<>
|
||||
<PersonalAccountCheckoutForm customerId={customerId} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col space-y-6'}>
|
||||
<If condition={subscription}>
|
||||
{(subscription) => {
|
||||
return (
|
||||
<CurrentSubscriptionCard
|
||||
subscription={subscription}
|
||||
product={subscriptionProductPlan!.product}
|
||||
plan={subscriptionProductPlan!.plan}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<If condition={order}>
|
||||
{(order) => {
|
||||
return (
|
||||
<CurrentLifetimeOrderCard
|
||||
order={order}
|
||||
product={orderProductPlan!.product}
|
||||
plan={orderProductPlan!.plan}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={customerId}>{() => <PersonalBillingPortalForm />}</If>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonalAccountBillingPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
// We reuse the page from the billing module
|
||||
// as there is no need to create a new one.
|
||||
import ReturnCheckoutSessionPage from '~/home/[account]/billing/return/page';
|
||||
|
||||
export default ReturnCheckoutSessionPage;
|
||||
140
apps/web/app/[locale]/home/(user)/layout.tsx
Normal file
140
apps/web/app/[locale]/home/(user)/layout.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import * as z from 'zod';
|
||||
|
||||
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
import { SidebarProvider } from '@kit/ui/sidebar';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
||||
|
||||
// home imports
|
||||
import { HomeMenuNavigation } from './_components/home-menu-navigation';
|
||||
import { HomeMobileNavigation } from './_components/home-mobile-navigation';
|
||||
import { HomeSidebar } from './_components/home-sidebar';
|
||||
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
|
||||
|
||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||
const state = use(getLayoutState());
|
||||
|
||||
if (state.style === 'sidebar') {
|
||||
return <SidebarLayout>{children}</SidebarLayout>;
|
||||
}
|
||||
|
||||
return <HeaderLayout>{children}</HeaderLayout>;
|
||||
}
|
||||
|
||||
export default UserHomeLayout;
|
||||
|
||||
async function SidebarLayout({ children }: React.PropsWithChildren) {
|
||||
const [workspace, state] = await Promise.all([
|
||||
loadUserWorkspace().catch(() => null),
|
||||
getLayoutState(),
|
||||
]);
|
||||
|
||||
if (!workspace) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
redirectIfTeamsOnly(workspace);
|
||||
|
||||
return (
|
||||
<UserWorkspaceContextProvider value={workspace}>
|
||||
<SidebarProvider defaultOpen={state.open}>
|
||||
<Page style={'sidebar'}>
|
||||
<PageNavigation>
|
||||
<HomeSidebar workspace={workspace} />
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||
<MobileNavigation workspace={workspace} />
|
||||
</PageMobileNavigation>
|
||||
|
||||
{children}
|
||||
</Page>
|
||||
</SidebarProvider>
|
||||
</UserWorkspaceContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLayout({ children }: React.PropsWithChildren) {
|
||||
const workspace = use(loadUserWorkspace());
|
||||
|
||||
redirectIfTeamsOnly(workspace);
|
||||
|
||||
return (
|
||||
<UserWorkspaceContextProvider value={workspace}>
|
||||
<Page style={'header'}>
|
||||
<PageNavigation>
|
||||
<HomeMenuNavigation workspace={workspace} />
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||
<MobileNavigation workspace={workspace} />
|
||||
</PageMobileNavigation>
|
||||
|
||||
{children}
|
||||
</Page>
|
||||
</UserWorkspaceContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavigation({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AppLogo />
|
||||
|
||||
<HomeMobileNavigation workspace={workspace} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function redirectIfTeamsOnly(
|
||||
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>,
|
||||
) {
|
||||
if (featuresFlagConfig.enableTeamsOnly) {
|
||||
const firstTeam = workspace.accounts[0];
|
||||
|
||||
if (firstTeam?.value) {
|
||||
redirect(
|
||||
pathsConfig.app.accountHome.replace('[account]', firstTeam.value),
|
||||
);
|
||||
} else {
|
||||
redirect(pathsConfig.app.createTeam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getLayoutState() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
|
||||
|
||||
const layoutStyleCookie = cookieStore.get('layout-style');
|
||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||
|
||||
const sidebarOpen = sidebarOpenCookie
|
||||
? sidebarOpenCookie.value === 'false'
|
||||
: !personalAccountNavigationConfig.sidebarCollapsed;
|
||||
|
||||
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
|
||||
|
||||
const style = parsedStyle.success
|
||||
? parsedStyle.data
|
||||
: personalAccountNavigationConfig.style;
|
||||
|
||||
return {
|
||||
open: sidebarOpen,
|
||||
style,
|
||||
};
|
||||
}
|
||||
3
apps/web/app/[locale]/home/(user)/loading.tsx
Normal file
3
apps/web/app/[locale]/home/(user)/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
29
apps/web/app/[locale]/home/(user)/page.tsx
Normal file
29
apps/web/app/[locale]/home/(user)/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// local imports
|
||||
import { HomeLayoutPageHeader } from './_components/home-page-header';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('account');
|
||||
const title = t('homePage');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function UserHomePage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'common.routes.home'} />}
|
||||
description={<Trans i18nKey={'common.homeTabDescription'} />}
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserHomePage;
|
||||
21
apps/web/app/[locale]/home/(user)/settings/layout.tsx
Normal file
21
apps/web/app/[locale]/home/(user)/settings/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// local imports
|
||||
import { HomeLayoutPageHeader } from '../_components/home-page-header';
|
||||
|
||||
function UserSettingsLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<PageBody>
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'account.routes.settings'} />}
|
||||
description={<AppBreadcrumbs />}
|
||||
/>
|
||||
|
||||
{props.children}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSettingsLayout;
|
||||
58
apps/web/app/[locale]/home/(user)/settings/page.tsx
Normal file
58
apps/web/app/[locale]/home/(user)/settings/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
// Show email option if password, magic link, or OTP is enabled
|
||||
const showEmailOption =
|
||||
authConfig.providers.password ||
|
||||
authConfig.providers.magicLink ||
|
||||
authConfig.providers.otp;
|
||||
|
||||
const features = {
|
||||
showLinkEmailOption: showEmailOption,
|
||||
enablePasswordUpdate: authConfig.providers.password,
|
||||
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
|
||||
enableAccountLinking: authConfig.enableIdentityLinking,
|
||||
};
|
||||
|
||||
const providers = authConfig.providers.oAuth;
|
||||
|
||||
const callbackPath = pathsConfig.auth.callback;
|
||||
const accountSettingsPath = pathsConfig.app.accountSettings;
|
||||
|
||||
const paths = {
|
||||
callback: callbackPath + `?next=${accountSettingsPath}`,
|
||||
};
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('account');
|
||||
const title = t('settingsTab');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
function PersonalAccountSettingsPage() {
|
||||
const user = use(requireUserInServerComponent());
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
|
||||
<PersonalAccountSettingsContainer
|
||||
userId={user.id}
|
||||
features={features}
|
||||
paths={paths}
|
||||
providers={providers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonalAccountSettingsPage;
|
||||
@@ -0,0 +1,921 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ArrowDown, ArrowUp, Menu, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@kit/ui/chart';
|
||||
import { useIsMobile } from '@kit/ui/hooks/use-mobile';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
export default function DashboardDemo() {
|
||||
const mrr = useMemo(() => generateDemoData(), []);
|
||||
const netRevenue = useMemo(() => generateDemoData(), []);
|
||||
const fees = useMemo(() => generateDemoData(), []);
|
||||
const newCustomers = useMemo(() => generateDemoData(), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'animate-in fade-in flex flex-col space-y-4 pb-36 duration-500'
|
||||
}
|
||||
>
|
||||
<div className={'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center gap-2.5'}>
|
||||
<span>MRR</span>
|
||||
<Trend trend={'up'}>20%</Trend>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<span>Monthly recurring revenue</span>
|
||||
</CardDescription>
|
||||
|
||||
<div>
|
||||
<Figure>{`$${mrr[1]}`}</Figure>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-4'}>
|
||||
<Chart data={mrr[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center gap-2.5'}>
|
||||
<span>Revenue</span>
|
||||
<Trend trend={'up'}>12%</Trend>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<span>Total revenue including fees</span>
|
||||
</CardDescription>
|
||||
|
||||
<div>
|
||||
<Figure>{`$${netRevenue[1]}`}</Figure>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Chart data={netRevenue[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center gap-2.5'}>
|
||||
<span>Fees</span>
|
||||
<Trend trend={'up'}>9%</Trend>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<span>Total fees collected</span>
|
||||
</CardDescription>
|
||||
|
||||
<div>
|
||||
<Figure>{`$${fees[1]}`}</Figure>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Chart data={fees[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className={'flex items-center gap-2.5'}>
|
||||
<span>New Customers</span>
|
||||
<Trend trend={'down'}>-25%</Trend>
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<span>Customers who signed up this month</span>
|
||||
</CardDescription>
|
||||
|
||||
<div>
|
||||
<Figure>{`${Number(newCustomers[1]).toFixed(0)}`}</Figure>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Chart data={newCustomers[0]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<VisitorsChart />
|
||||
|
||||
<PageViewsChart />
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Best Customers</CardTitle>
|
||||
<CardDescription>Showing the top customers by MRR</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CustomersTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generateDemoData() {
|
||||
const today = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-us', {
|
||||
month: 'long',
|
||||
year: '2-digit',
|
||||
});
|
||||
|
||||
const data: { value: string; name: string }[] = [];
|
||||
|
||||
for (let n = 8; n > 0; n -= 1) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth() - n, 1);
|
||||
|
||||
data.push({
|
||||
name: formatter.format(date),
|
||||
value: (Math.random() * 10).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
const lastValue = data[data.length - 1]?.value;
|
||||
|
||||
return [data, lastValue] as [typeof data, string];
|
||||
}
|
||||
|
||||
function Chart(
|
||||
props: React.PropsWithChildren<{ data: { value: string; name: string }[] }>,
|
||||
) {
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={(props) => <ChartTooltipContent hideLabel {...props} />}
|
||||
/>
|
||||
<Line
|
||||
dataKey="value"
|
||||
type="natural"
|
||||
stroke="var(--color-desktop)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersTable() {
|
||||
const customers = [
|
||||
{
|
||||
name: 'John Doe',
|
||||
email: 'john@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$120.5',
|
||||
logins: 1020,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Emma Smith',
|
||||
email: 'emma@makerit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$65.4',
|
||||
logins: 570,
|
||||
status: 'Possible Churn',
|
||||
trend: 'stale',
|
||||
},
|
||||
{
|
||||
name: 'Robert Johnson',
|
||||
email: 'robert@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$500.1',
|
||||
logins: 2050,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Olivia Brown',
|
||||
email: 'olivia@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$10',
|
||||
logins: 50,
|
||||
status: 'Churn',
|
||||
trend: 'down',
|
||||
},
|
||||
{
|
||||
name: 'Michael Davis',
|
||||
email: 'michael@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$300.2',
|
||||
logins: 1520,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Emily Jones',
|
||||
email: 'emily@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$75.7',
|
||||
logins: 780,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Daniel Garcia',
|
||||
email: 'daniel@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$50',
|
||||
logins: 320,
|
||||
status: 'Possible Churn',
|
||||
trend: 'stale',
|
||||
},
|
||||
{
|
||||
name: 'Liam Miller',
|
||||
email: 'liam@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$90.8',
|
||||
logins: 1260,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Emma Clark',
|
||||
email: 'emma@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$0',
|
||||
logins: 20,
|
||||
status: 'Churn',
|
||||
trend: 'down',
|
||||
},
|
||||
{
|
||||
name: 'Elizabeth Rodriguez',
|
||||
email: 'liz@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$145.3',
|
||||
logins: 1380,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'James Martinez',
|
||||
email: 'james@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$120.5',
|
||||
logins: 940,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Charlotte Ryan',
|
||||
email: 'carlotte@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$80.6',
|
||||
logins: 460,
|
||||
status: 'Possible Churn',
|
||||
trend: 'stale',
|
||||
},
|
||||
{
|
||||
name: 'Lucas Evans',
|
||||
email: 'lucas@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$210.3',
|
||||
logins: 1850,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Sophia Wilson',
|
||||
email: 'sophia@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$10',
|
||||
logins: 35,
|
||||
status: 'Churn',
|
||||
trend: 'down',
|
||||
},
|
||||
{
|
||||
name: 'William Kelly',
|
||||
email: 'will@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$350.2',
|
||||
logins: 1760,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Oliver Thomas',
|
||||
email: 'olly@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$145.6',
|
||||
logins: 1350,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Samantha White',
|
||||
email: 'sam@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$60.3',
|
||||
logins: 425,
|
||||
status: 'Possible Churn',
|
||||
trend: 'stale',
|
||||
},
|
||||
{
|
||||
name: 'Benjamin Lewis',
|
||||
email: 'ben@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$175.8',
|
||||
logins: 1600,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
{
|
||||
name: 'Zoe Harris',
|
||||
email: 'zoe@makerkit.dev',
|
||||
plan: 'Basic',
|
||||
mrr: '$0',
|
||||
logins: 18,
|
||||
status: 'Churn',
|
||||
trend: 'down',
|
||||
},
|
||||
{
|
||||
name: 'Zachary Nelson',
|
||||
email: 'zac@makerkit.dev',
|
||||
plan: 'Pro',
|
||||
mrr: '$255.9',
|
||||
logins: 1785,
|
||||
status: 'Healthy',
|
||||
trend: 'up',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>MRR</TableHead>
|
||||
<TableHead>Logins</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customers.map((customer) => (
|
||||
<TableRow key={customer.name}>
|
||||
<TableCell className={'flex flex-col'}>
|
||||
<span>{customer.name}</span>
|
||||
<span className={'text-muted-foreground text-sm'}>
|
||||
{customer.email}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{customer.plan}</TableCell>
|
||||
<TableCell>{customer.mrr}</TableCell>
|
||||
<TableCell>{customer.logins}</TableCell>
|
||||
<TableCell>
|
||||
<BadgeWithTrend trend={customer.trend}>
|
||||
{customer.status}
|
||||
</BadgeWithTrend>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeWithTrend(props: React.PropsWithChildren<{ trend: string }>) {
|
||||
const className = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return 'text-green-500';
|
||||
case 'down':
|
||||
return 'text-destructive';
|
||||
case 'stale':
|
||||
return 'text-orange-500';
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={'outline'}
|
||||
className={'border-transparent px-1.5 font-normal'}
|
||||
>
|
||||
<span className={className}>{props.children}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className={'font-heading text-2xl font-semibold'}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trend(
|
||||
props: React.PropsWithChildren<{
|
||||
trend: 'up' | 'down' | 'stale';
|
||||
}>,
|
||||
) {
|
||||
const Icon = useMemo(() => {
|
||||
switch (props.trend) {
|
||||
case 'up':
|
||||
return <ArrowUp className={'h-3 w-3 text-green-500'} />;
|
||||
case 'down':
|
||||
return <ArrowDown className={'text-destructive h-3 w-3'} />;
|
||||
case 'stale':
|
||||
return <Menu className={'h-3 w-3 text-orange-500'} />;
|
||||
}
|
||||
}, [props.trend]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BadgeWithTrend trend={props.trend}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
{Icon}
|
||||
<span>{props.children}</span>
|
||||
</span>
|
||||
</BadgeWithTrend>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisitorsChart() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{ date: '2024-04-01', desktop: 222, mobile: 150 },
|
||||
{ date: '2024-04-02', desktop: 97, mobile: 180 },
|
||||
{ date: '2024-04-03', desktop: 167, mobile: 120 },
|
||||
{ date: '2024-04-04', desktop: 242, mobile: 260 },
|
||||
{ date: '2024-04-05', desktop: 373, mobile: 290 },
|
||||
{ date: '2024-04-06', desktop: 301, mobile: 340 },
|
||||
{ date: '2024-04-07', desktop: 245, mobile: 180 },
|
||||
{ date: '2024-04-08', desktop: 409, mobile: 320 },
|
||||
{ date: '2024-04-09', desktop: 59, mobile: 110 },
|
||||
{ date: '2024-04-10', desktop: 261, mobile: 190 },
|
||||
{ date: '2024-04-11', desktop: 327, mobile: 350 },
|
||||
{ date: '2024-04-12', desktop: 292, mobile: 210 },
|
||||
{ date: '2024-04-13', desktop: 342, mobile: 380 },
|
||||
{ date: '2024-04-14', desktop: 137, mobile: 220 },
|
||||
{ date: '2024-04-15', desktop: 120, mobile: 170 },
|
||||
{ date: '2024-04-16', desktop: 138, mobile: 190 },
|
||||
{ date: '2024-04-17', desktop: 446, mobile: 360 },
|
||||
{ date: '2024-04-18', desktop: 364, mobile: 410 },
|
||||
{ date: '2024-04-19', desktop: 243, mobile: 180 },
|
||||
{ date: '2024-04-20', desktop: 89, mobile: 150 },
|
||||
{ date: '2024-04-21', desktop: 137, mobile: 200 },
|
||||
{ date: '2024-04-22', desktop: 224, mobile: 170 },
|
||||
{ date: '2024-04-23', desktop: 138, mobile: 230 },
|
||||
{ date: '2024-04-24', desktop: 387, mobile: 290 },
|
||||
{ date: '2024-04-25', desktop: 215, mobile: 250 },
|
||||
{ date: '2024-04-26', desktop: 75, mobile: 130 },
|
||||
{ date: '2024-04-27', desktop: 383, mobile: 420 },
|
||||
{ date: '2024-04-28', desktop: 122, mobile: 180 },
|
||||
{ date: '2024-04-29', desktop: 315, mobile: 240 },
|
||||
{ date: '2024-04-30', desktop: 454, mobile: 380 },
|
||||
{ date: '2024-05-01', desktop: 165, mobile: 220 },
|
||||
{ date: '2024-05-02', desktop: 293, mobile: 310 },
|
||||
{ date: '2024-05-03', desktop: 247, mobile: 190 },
|
||||
{ date: '2024-05-04', desktop: 385, mobile: 420 },
|
||||
{ date: '2024-05-05', desktop: 481, mobile: 390 },
|
||||
{ date: '2024-05-06', desktop: 498, mobile: 520 },
|
||||
{ date: '2024-05-07', desktop: 388, mobile: 300 },
|
||||
{ date: '2024-05-08', desktop: 149, mobile: 210 },
|
||||
{ date: '2024-05-09', desktop: 227, mobile: 180 },
|
||||
{ date: '2024-05-10', desktop: 293, mobile: 330 },
|
||||
{ date: '2024-05-11', desktop: 335, mobile: 270 },
|
||||
{ date: '2024-05-12', desktop: 197, mobile: 240 },
|
||||
{ date: '2024-05-13', desktop: 197, mobile: 160 },
|
||||
{ date: '2024-05-14', desktop: 448, mobile: 490 },
|
||||
{ date: '2024-05-15', desktop: 473, mobile: 380 },
|
||||
{ date: '2024-05-16', desktop: 338, mobile: 400 },
|
||||
{ date: '2024-05-17', desktop: 499, mobile: 420 },
|
||||
{ date: '2024-05-18', desktop: 315, mobile: 350 },
|
||||
{ date: '2024-05-19', desktop: 235, mobile: 180 },
|
||||
{ date: '2024-05-20', desktop: 177, mobile: 230 },
|
||||
{ date: '2024-05-21', desktop: 82, mobile: 140 },
|
||||
{ date: '2024-05-22', desktop: 81, mobile: 120 },
|
||||
{ date: '2024-05-23', desktop: 252, mobile: 290 },
|
||||
{ date: '2024-05-24', desktop: 294, mobile: 220 },
|
||||
{ date: '2024-05-25', desktop: 201, mobile: 250 },
|
||||
{ date: '2024-05-26', desktop: 213, mobile: 170 },
|
||||
{ date: '2024-05-27', desktop: 420, mobile: 460 },
|
||||
{ date: '2024-05-28', desktop: 233, mobile: 190 },
|
||||
{ date: '2024-05-29', desktop: 78, mobile: 130 },
|
||||
{ date: '2024-05-30', desktop: 340, mobile: 280 },
|
||||
{ date: '2024-05-31', desktop: 178, mobile: 230 },
|
||||
{ date: '2024-06-01', desktop: 178, mobile: 200 },
|
||||
{ date: '2024-06-02', desktop: 470, mobile: 410 },
|
||||
{ date: '2024-06-03', desktop: 103, mobile: 160 },
|
||||
{ date: '2024-06-04', desktop: 439, mobile: 380 },
|
||||
{ date: '2024-06-05', desktop: 88, mobile: 140 },
|
||||
{ date: '2024-06-06', desktop: 294, mobile: 250 },
|
||||
{ date: '2024-06-07', desktop: 323, mobile: 370 },
|
||||
{ date: '2024-06-08', desktop: 385, mobile: 320 },
|
||||
{ date: '2024-06-09', desktop: 438, mobile: 480 },
|
||||
{ date: '2024-06-10', desktop: 155, mobile: 200 },
|
||||
{ date: '2024-06-11', desktop: 92, mobile: 150 },
|
||||
{ date: '2024-06-12', desktop: 492, mobile: 420 },
|
||||
{ date: '2024-06-13', desktop: 81, mobile: 130 },
|
||||
{ date: '2024-06-14', desktop: 426, mobile: 380 },
|
||||
{ date: '2024-06-15', desktop: 307, mobile: 350 },
|
||||
{ date: '2024-06-16', desktop: 371, mobile: 310 },
|
||||
{ date: '2024-06-17', desktop: 475, mobile: 520 },
|
||||
{ date: '2024-06-18', desktop: 107, mobile: 170 },
|
||||
{ date: '2024-06-19', desktop: 341, mobile: 290 },
|
||||
{ date: '2024-06-20', desktop: 408, mobile: 450 },
|
||||
{ date: '2024-06-21', desktop: 169, mobile: 210 },
|
||||
{ date: '2024-06-22', desktop: 317, mobile: 270 },
|
||||
{ date: '2024-06-23', desktop: 480, mobile: 530 },
|
||||
{ date: '2024-06-24', desktop: 132, mobile: 180 },
|
||||
{ date: '2024-06-25', desktop: 141, mobile: 190 },
|
||||
{ date: '2024-06-26', desktop: 434, mobile: 380 },
|
||||
{ date: '2024-06-27', desktop: 448, mobile: 490 },
|
||||
{ date: '2024-06-28', desktop: 149, mobile: 200 },
|
||||
{ date: '2024-06-29', desktop: 103, mobile: 160 },
|
||||
{ date: '2024-06-30', desktop: 446, mobile: 400 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: 'Visitors',
|
||||
},
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 6 months
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
defaultIndex={isMobile ? -1 : 10}
|
||||
content={(props) => (
|
||||
<ChartTooltipContent
|
||||
{...props}
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<div className="flex w-full items-start gap-2 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2 leading-none">
|
||||
January - June 2024
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageViewsChart() {
|
||||
const [activeChart, setActiveChart] =
|
||||
useState<keyof typeof chartConfig>('desktop');
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{ date: '2024-04-01', desktop: 222, mobile: 150 },
|
||||
{ date: '2024-04-02', desktop: 97, mobile: 180 },
|
||||
{ date: '2024-04-03', desktop: 167, mobile: 120 },
|
||||
{ date: '2024-04-04', desktop: 242, mobile: 260 },
|
||||
{ date: '2024-04-05', desktop: 373, mobile: 290 },
|
||||
{ date: '2024-04-06', desktop: 301, mobile: 340 },
|
||||
{ date: '2024-04-07', desktop: 245, mobile: 180 },
|
||||
{ date: '2024-04-08', desktop: 409, mobile: 320 },
|
||||
{ date: '2024-04-09', desktop: 59, mobile: 110 },
|
||||
{ date: '2024-04-10', desktop: 261, mobile: 190 },
|
||||
{ date: '2024-04-11', desktop: 327, mobile: 350 },
|
||||
{ date: '2024-04-12', desktop: 292, mobile: 210 },
|
||||
{ date: '2024-04-13', desktop: 342, mobile: 380 },
|
||||
{ date: '2024-04-14', desktop: 137, mobile: 220 },
|
||||
{ date: '2024-04-15', desktop: 120, mobile: 170 },
|
||||
{ date: '2024-04-16', desktop: 138, mobile: 190 },
|
||||
{ date: '2024-04-17', desktop: 446, mobile: 360 },
|
||||
{ date: '2024-04-18', desktop: 364, mobile: 410 },
|
||||
{ date: '2024-04-19', desktop: 243, mobile: 180 },
|
||||
{ date: '2024-04-20', desktop: 89, mobile: 150 },
|
||||
{ date: '2024-04-21', desktop: 137, mobile: 200 },
|
||||
{ date: '2024-04-22', desktop: 224, mobile: 170 },
|
||||
{ date: '2024-04-23', desktop: 138, mobile: 230 },
|
||||
{ date: '2024-04-24', desktop: 387, mobile: 290 },
|
||||
{ date: '2024-04-25', desktop: 215, mobile: 250 },
|
||||
{ date: '2024-04-26', desktop: 75, mobile: 130 },
|
||||
{ date: '2024-04-27', desktop: 383, mobile: 420 },
|
||||
{ date: '2024-04-28', desktop: 122, mobile: 180 },
|
||||
{ date: '2024-04-29', desktop: 315, mobile: 240 },
|
||||
{ date: '2024-04-30', desktop: 454, mobile: 380 },
|
||||
{ date: '2024-05-01', desktop: 165, mobile: 220 },
|
||||
{ date: '2024-05-02', desktop: 293, mobile: 310 },
|
||||
{ date: '2024-05-03', desktop: 247, mobile: 190 },
|
||||
{ date: '2024-05-04', desktop: 385, mobile: 420 },
|
||||
{ date: '2024-05-05', desktop: 481, mobile: 390 },
|
||||
{ date: '2024-05-06', desktop: 498, mobile: 520 },
|
||||
{ date: '2024-05-07', desktop: 388, mobile: 300 },
|
||||
{ date: '2024-05-08', desktop: 149, mobile: 210 },
|
||||
{ date: '2024-05-09', desktop: 227, mobile: 180 },
|
||||
{ date: '2024-05-10', desktop: 293, mobile: 330 },
|
||||
{ date: '2024-05-11', desktop: 335, mobile: 270 },
|
||||
{ date: '2024-05-12', desktop: 197, mobile: 240 },
|
||||
{ date: '2024-05-13', desktop: 197, mobile: 160 },
|
||||
{ date: '2024-05-14', desktop: 448, mobile: 490 },
|
||||
{ date: '2024-05-15', desktop: 473, mobile: 380 },
|
||||
{ date: '2024-05-16', desktop: 338, mobile: 400 },
|
||||
{ date: '2024-05-17', desktop: 499, mobile: 420 },
|
||||
{ date: '2024-05-18', desktop: 315, mobile: 350 },
|
||||
{ date: '2024-05-19', desktop: 235, mobile: 180 },
|
||||
{ date: '2024-05-20', desktop: 177, mobile: 230 },
|
||||
{ date: '2024-05-21', desktop: 82, mobile: 140 },
|
||||
{ date: '2024-05-22', desktop: 81, mobile: 120 },
|
||||
{ date: '2024-05-23', desktop: 252, mobile: 290 },
|
||||
{ date: '2024-05-24', desktop: 294, mobile: 220 },
|
||||
{ date: '2024-05-25', desktop: 201, mobile: 250 },
|
||||
{ date: '2024-05-26', desktop: 213, mobile: 170 },
|
||||
{ date: '2024-05-27', desktop: 420, mobile: 460 },
|
||||
{ date: '2024-05-28', desktop: 233, mobile: 190 },
|
||||
{ date: '2024-05-29', desktop: 78, mobile: 130 },
|
||||
{ date: '2024-05-30', desktop: 340, mobile: 280 },
|
||||
{ date: '2024-05-31', desktop: 178, mobile: 230 },
|
||||
{ date: '2024-06-01', desktop: 178, mobile: 200 },
|
||||
{ date: '2024-06-02', desktop: 470, mobile: 410 },
|
||||
{ date: '2024-06-03', desktop: 103, mobile: 160 },
|
||||
{ date: '2024-06-04', desktop: 439, mobile: 380 },
|
||||
{ date: '2024-06-05', desktop: 88, mobile: 140 },
|
||||
{ date: '2024-06-06', desktop: 294, mobile: 250 },
|
||||
{ date: '2024-06-07', desktop: 323, mobile: 370 },
|
||||
{ date: '2024-06-08', desktop: 385, mobile: 320 },
|
||||
{ date: '2024-06-09', desktop: 438, mobile: 480 },
|
||||
{ date: '2024-06-10', desktop: 155, mobile: 200 },
|
||||
{ date: '2024-06-11', desktop: 92, mobile: 150 },
|
||||
{ date: '2024-06-12', desktop: 492, mobile: 420 },
|
||||
{ date: '2024-06-13', desktop: 81, mobile: 130 },
|
||||
{ date: '2024-06-14', desktop: 426, mobile: 380 },
|
||||
{ date: '2024-06-15', desktop: 307, mobile: 350 },
|
||||
{ date: '2024-06-16', desktop: 371, mobile: 310 },
|
||||
{ date: '2024-06-17', desktop: 475, mobile: 520 },
|
||||
{ date: '2024-06-18', desktop: 107, mobile: 170 },
|
||||
{ date: '2024-06-19', desktop: 341, mobile: 290 },
|
||||
{ date: '2024-06-20', desktop: 408, mobile: 450 },
|
||||
{ date: '2024-06-21', desktop: 169, mobile: 210 },
|
||||
{ date: '2024-06-22', desktop: 317, mobile: 270 },
|
||||
{ date: '2024-06-23', desktop: 480, mobile: 530 },
|
||||
{ date: '2024-06-24', desktop: 132, mobile: 180 },
|
||||
{ date: '2024-06-25', desktop: 141, mobile: 190 },
|
||||
{ date: '2024-06-26', desktop: 434, mobile: 380 },
|
||||
{ date: '2024-06-27', desktop: 448, mobile: 490 },
|
||||
{ date: '2024-06-28', desktop: 149, mobile: 200 },
|
||||
{ date: '2024-06-29', desktop: 103, mobile: 160 },
|
||||
{ date: '2024-06-30', desktop: 446, mobile: 400 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: 'Page Views',
|
||||
},
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const total = useMemo(
|
||||
() => ({
|
||||
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
|
||||
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
|
||||
}),
|
||||
[chartData],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Page Views</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
{['desktop', 'mobile'].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
<button
|
||||
key={chart}
|
||||
data-active={activeChart === chart}
|
||||
className="data-[active=true]:bg-muted/50 relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{chartConfig[chart].label}
|
||||
</span>
|
||||
<span className="text-lg leading-none font-bold sm:text-3xl">
|
||||
{total[key as keyof typeof total].toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-64 w-full"
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={(props) => (
|
||||
<ChartTooltipContent
|
||||
{...props}
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
export const DashboardDemo = dynamic(() => import('./dashboard-demo-charts'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<LoadingOverlay
|
||||
fullPage={false}
|
||||
className={'flex flex-1 flex-col items-center justify-center'}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { useSidebar } from '@kit/ui/sidebar';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const features = {
|
||||
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||
};
|
||||
|
||||
export function TeamAccountAccountsSelector(params: {
|
||||
selectedAccount: string;
|
||||
userId: string;
|
||||
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
}>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ctx = useSidebar();
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
selectedAccount={params.selectedAccount}
|
||||
accounts={params.accounts}
|
||||
userId={params.userId}
|
||||
collapsed={!ctx?.open}
|
||||
features={features}
|
||||
showPersonalAccount={!featureFlagsConfig.enableTeamsOnly}
|
||||
onAccountChange={(value) => {
|
||||
if (!value && featureFlagsConfig.enableTeamsOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Home, LogOut, Menu } from 'lucide-react';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
|
||||
type Accounts = Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
}>;
|
||||
|
||||
const features = {
|
||||
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
|
||||
enableTeamCreation: featureFlagsConfig.enableTeamCreation,
|
||||
};
|
||||
|
||||
export const TeamAccountLayoutMobileNavigation = (
|
||||
props: React.PropsWithChildren<{
|
||||
account: string;
|
||||
userId: string;
|
||||
accounts: Accounts;
|
||||
}>,
|
||||
) => {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const Links = getTeamAccountSidebarConfig(props.account).routes.map(
|
||||
(item, index) => {
|
||||
if ('children' in item) {
|
||||
return item.children.map((child) => {
|
||||
return (
|
||||
<DropdownLink
|
||||
key={child.path}
|
||||
Icon={child.Icon}
|
||||
path={child.path}
|
||||
label={child.label}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ('divider' in item) {
|
||||
return <DropdownMenuSeparator key={index} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Menu className={'h-9'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||
<TeamAccountsModal
|
||||
userId={props.userId}
|
||||
accounts={props.accounts}
|
||||
account={props.account}
|
||||
/>
|
||||
|
||||
{Links}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
function DropdownLink(
|
||||
props: React.PropsWithChildren<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
href={props.path}
|
||||
className={'flex h-12 w-full items-center gap-x-3 px-3'}
|
||||
>
|
||||
{props.Icon}
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={props.label} defaults={props.label} />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutDropdownItem(
|
||||
props: React.PropsWithChildren<{
|
||||
onSignOut: () => unknown;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={'flex h-12 w-full items-center space-x-2'}
|
||||
onClick={props.onSignOut}
|
||||
>
|
||||
<LogOut className={'h-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.signOut'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamAccountsModal(props: {
|
||||
accounts: Accounts;
|
||||
userId: string;
|
||||
account: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<DropdownMenuItem
|
||||
className={'flex h-12 w-full items-center space-x-2'}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Home className={'h-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.yourAccounts'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'common.yourAccounts'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={'py-6'}>
|
||||
<AccountSelector
|
||||
className={'w-full max-w-full'}
|
||||
userId={props.userId}
|
||||
accounts={props.accounts}
|
||||
features={features}
|
||||
selectedAccount={props.account}
|
||||
onAccountChange={(value) => {
|
||||
const path = value
|
||||
? pathsConfig.app.accountHome.replace('[account]', value)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
router.replace(path);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
export function TeamAccountLayoutPageHeader(
|
||||
props: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
account: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<PageHeader description={props.description}>{props.children}</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
|
||||
|
||||
export function TeamAccountLayoutSidebarNavigation({
|
||||
config,
|
||||
}: React.PropsWithChildren<{
|
||||
config: z.output<typeof NavigationConfigSchema>;
|
||||
}>) {
|
||||
return <SidebarNavigation config={config} />;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
|
||||
|
||||
import type { AccountModel } from '~/components/workspace-dropdown';
|
||||
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
|
||||
|
||||
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
|
||||
|
||||
export function TeamAccountLayoutSidebar(props: {
|
||||
account: string;
|
||||
accountId: string;
|
||||
accounts: AccountModel[];
|
||||
user: JWTUserData;
|
||||
}) {
|
||||
const { account, accounts, user } = props;
|
||||
|
||||
const config = getTeamAccountSidebarConfig(account);
|
||||
const collapsible = config.sidebarCollapsedStyle;
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" collapsible={collapsible}>
|
||||
<SidebarHeader className={'h-16 justify-center'}>
|
||||
<div className={'flex items-center justify-between gap-x-1'}>
|
||||
<WorkspaceDropdown
|
||||
user={user}
|
||||
accounts={accounts}
|
||||
selectedAccount={account}
|
||||
/>
|
||||
|
||||
<div className={'group-data-[collapsible=icon]:hidden'}>
|
||||
<TeamAccountNotifications
|
||||
userId={user.id}
|
||||
accountId={props.accountId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="h-[calc(100%-160px)] overflow-y-auto">
|
||||
<TeamAccountLayoutSidebarNavigation config={config} />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
BorderedNavigationMenu,
|
||||
BorderedNavigationMenuItem,
|
||||
} from '@kit/ui/bordered-navigation-menu';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
||||
import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
|
||||
|
||||
// local imports
|
||||
import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader';
|
||||
import { TeamAccountNotifications } from './team-account-notifications';
|
||||
|
||||
export function TeamAccountNavigationMenu(props: {
|
||||
workspace: TeamAccountWorkspace;
|
||||
}) {
|
||||
const { account, user, accounts } = props.workspace;
|
||||
|
||||
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
|
||||
Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
Icon?: React.ReactNode;
|
||||
end?: boolean | ((path: string) => boolean);
|
||||
}>
|
||||
>((acc, item) => {
|
||||
if ('children' in item) {
|
||||
return [...acc, ...item.children];
|
||||
}
|
||||
|
||||
if ('divider' in item) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-1 justify-between'}>
|
||||
<div className={'flex items-center space-x-8'}>
|
||||
<AppLogo />
|
||||
|
||||
<BorderedNavigationMenu>
|
||||
{routes.map((route) => (
|
||||
<BorderedNavigationMenuItem {...route} key={route.path} />
|
||||
))}
|
||||
</BorderedNavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-end space-x-2.5'}>
|
||||
<TeamAccountNotifications accountId={account.id} userId={user.id} />
|
||||
|
||||
<TeamAccountAccountsSelector
|
||||
userId={user.id}
|
||||
selectedAccount={account.slug}
|
||||
accounts={accounts.map((account) => ({
|
||||
label: account.name,
|
||||
value: account.slug,
|
||||
image: account.picture_url,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<ProfileAccountDropdownContainer
|
||||
user={user}
|
||||
showProfileName={false}
|
||||
accountSlug={account.slug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NotificationsPopover } from '@kit/notifications/components';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
|
||||
export function TeamAccountNotifications(params: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
}) {
|
||||
if (!featuresFlagConfig.enableNotifications) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationsPopover
|
||||
accountIds={[params.userId, params.accountId]}
|
||||
realtime={featuresFlagConfig.realtimeNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
/**
|
||||
* @name loadTeamAccountBillingPage
|
||||
* @description Load the team account billing page data for the given account.
|
||||
*/
|
||||
export const loadTeamAccountBillingPage = cache(teamAccountBillingPageLoader);
|
||||
|
||||
function teamAccountBillingPageLoader(accountId: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const subscription = api.getSubscription(accountId);
|
||||
const order = api.getOrder(accountId);
|
||||
const customerId = api.getCustomerId(accountId);
|
||||
|
||||
return Promise.all([subscription, order, customerId]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createTeamAccountsApi } from '@kit/team-accounts/api';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
|
||||
|
||||
export type TeamAccountWorkspace = Awaited<
|
||||
ReturnType<typeof loadTeamWorkspace>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Load the account workspace data.
|
||||
* We place this function into a separate file so it can be reused in multiple places across the server components.
|
||||
*
|
||||
* This function is used in the layout component for the account workspace.
|
||||
* It is cached so that the data is only fetched once per request.
|
||||
*
|
||||
* @param accountSlug
|
||||
*/
|
||||
export const loadTeamWorkspace = cache(workspaceLoader);
|
||||
|
||||
async function workspaceLoader(accountSlug: string) {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createTeamAccountsApi(client);
|
||||
|
||||
const [workspace, user] = await Promise.all([
|
||||
api.getAccountWorkspace(accountSlug),
|
||||
requireUserInServerComponent(),
|
||||
]);
|
||||
|
||||
// we cannot find any record for the selected account
|
||||
// so we redirect the user to the home page
|
||||
if (!workspace.data?.account) {
|
||||
return redirect(pathsConfig.app.home);
|
||||
}
|
||||
|
||||
return {
|
||||
...workspace.data,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export const EmbeddedCheckoutForm = dynamic(
|
||||
async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
|
||||
|
||||
return EmbeddedCheckout;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user