feat: add basic webui

This commit is contained in:
2024-12-30 06:39:09 +08:00
parent 608a7fb9c6
commit a4c549e7c3
462 changed files with 35900 additions and 2491 deletions

View File

@@ -0,0 +1,53 @@
'use client';
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from '@konobangu/design-system/components/ui/carousel';
import { useEffect, useState } from 'react';
export const Cases = () => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setTimeout(() => {
if (api.selectedScrollSnap() + 1 === api.scrollSnapList().length) {
setCurrent(0);
api.scrollTo(0);
} else {
api.scrollNext();
setCurrent(current + 1);
}
}, 1000);
}, [api, current]);
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="flex flex-col gap-10">
<h2 className="text-left font-regular text-xl tracking-tighter md:text-5xl lg:max-w-xl">
Trusted by thousands of businesses worldwide
</h2>
<Carousel setApi={setApi} className="w-full">
<CarouselContent>
{Array.from({ length: 15 }).map((_, index) => (
<CarouselItem className="basis-1/4 lg:basis-1/6" key={index}>
<div className="flex aspect-square items-center justify-center rounded-md bg-muted p-6">
<span className="text-sm">Logo {index + 1}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,35 @@
import { Button } from '@konobangu/design-system/components/ui/button';
import { env } from '@konobangu/env';
import { MoveRight, PhoneCall } from 'lucide-react';
import Link from 'next/link';
export const CTA = () => (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="flex flex-col items-center gap-8 rounded-md bg-muted p-4 text-center lg:p-14">
<div className="flex flex-col gap-2">
<h3 className="max-w-xl font-regular text-3xl tracking-tighter md:text-5xl">
Try our platform today!
</h3>
<p className="max-w-xl text-lg text-muted-foreground leading-relaxed tracking-tight">
Managing a small business today is already tough. Avoid further
complications by ditching outdated, tedious trade methods. Our goal
is to streamline SMB trade, making it easier and faster than ever.
</p>
</div>
<div className="flex flex-row gap-4">
<Button className="gap-4" variant="outline" asChild>
<Link href="/contact">
Jump on a call <PhoneCall className="h-4 w-4" />
</Link>
</Button>
<Button className="gap-4" asChild>
<Link href={env.NEXT_PUBLIC_APP_URL}>
Sign up here <MoveRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,55 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@konobangu/design-system/components/ui/accordion';
import { Button } from '@konobangu/design-system/components/ui/button';
import { PhoneCall } from 'lucide-react';
import Link from 'next/link';
export const FAQ = () => (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="grid gap-10 lg:grid-cols-2">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h4 className="max-w-xl text-left font-regular text-3xl tracking-tighter md:text-5xl">
This is the start of something new
</h4>
<p className="max-w-xl text-left text-lg text-muted-foreground leading-relaxed tracking-tight lg:max-w-lg">
Managing a small business today is already tough. Avoid further
complications by ditching outdated, tedious trade methods. Our
goal is to streamline SMB trade, making it easier and faster
than ever.
</p>
</div>
<div className="">
<Button className="gap-4" variant="outline" asChild>
<Link href="/contact">
Any questions? Reach out <PhoneCall className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
<Accordion type="single" collapsible className="w-full">
{Array.from({ length: 8 }).map((_, index) => (
<AccordionItem key={index} value={`index-${index}`}>
<AccordionTrigger>
This is the start of something new
</AccordionTrigger>
<AccordionContent>
Managing a small business today is already tough. Avoid further
complications by ditching outdated, tedious trade methods. Our
goal is to streamline SMB trade, making it easier and faster
than ever.
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,63 @@
import { User } from 'lucide-react';
export const Features = () => (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="flex flex-col gap-10">
<div className="flex flex-col items-start gap-4">
<div className="flex flex-col gap-2">
<h2 className="max-w-xl text-left font-regular text-3xl tracking-tighter md:text-5xl">
Something new!
</h2>
<p className="max-w-xl text-left text-lg text-muted-foreground leading-relaxed tracking-tight lg:max-w-lg">
Managing a small business today is already tough.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex aspect-square h-full flex-col justify-between rounded-md bg-muted p-6 lg:col-span-2 lg:aspect-auto">
<User className="h-8 w-8 stroke-1" />
<div className="flex flex-col">
<h3 className="text-xl tracking-tight">Pay supplier invoices</h3>
<p className="max-w-xs text-base text-muted-foreground">
Our goal is to streamline SMB trade, making it easier and faster
than ever.
</p>
</div>
</div>
<div className="flex aspect-square flex-col justify-between rounded-md bg-muted p-6">
<User className="h-8 w-8 stroke-1" />
<div className="flex flex-col">
<h3 className="text-xl tracking-tight">Pay supplier invoices</h3>
<p className="max-w-xs text-base text-muted-foreground">
Our goal is to streamline SMB trade, making it easier and faster
than ever.
</p>
</div>
</div>
<div className="flex aspect-square flex-col justify-between rounded-md bg-muted p-6">
<User className="h-8 w-8 stroke-1" />
<div className="flex flex-col">
<h3 className="text-xl tracking-tight">Pay supplier invoices</h3>
<p className="max-w-xs text-base text-muted-foreground">
Our goal is to streamline SMB trade, making it easier and faster
than ever.
</p>
</div>
</div>
<div className="flex aspect-square h-full flex-col justify-between rounded-md bg-muted p-6 lg:col-span-2 lg:aspect-auto">
<User className="h-8 w-8 stroke-1" />
<div className="flex flex-col">
<h3 className="text-xl tracking-tight">Pay supplier invoices</h3>
<p className="max-w-xs text-base text-muted-foreground">
Our goal is to streamline SMB trade, making it easier and faster
than ever.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,64 @@
import { blog } from '@konobangu/cms';
import { Feed } from '@konobangu/cms/components/feed';
import { Button } from '@konobangu/design-system/components/ui/button';
import { env } from '@konobangu/env';
import { MoveRight, PhoneCall } from 'lucide-react';
import { draftMode } from 'next/headers';
import Link from 'next/link';
export const Hero = async () => {
const draft = await draftMode();
return (
<div className="w-full">
<div className="container mx-auto">
<div className="flex flex-col items-center justify-center gap-8 py-20 lg:py-40">
<div>
<Feed queries={[blog.latestPostQuery]} draft={draft.isEnabled}>
{/* biome-ignore lint/suspicious/useAwait: "Server Actions must be async" */}
{async ([data]) => {
'use server';
return (
<Button
variant="secondary"
size="sm"
className="gap-4"
asChild
>
<Link href={`/blog/${data.blog.posts.items.at(0)?._slug}`}>
Read our latest article <MoveRight className="h-4 w-4" />
</Link>
</Button>
);
}}
</Feed>
</div>
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center font-regular text-5xl tracking-tighter md:text-7xl">
This is the start of something new
</h1>
<p className="max-w-2xl text-center text-lg text-muted-foreground leading-relaxed tracking-tight md:text-xl">
Managing a small business today is already tough. Avoid further
complications by ditching outdated, tedious trade methods. Our
goal is to streamline SMB trade, making it easier and faster than
ever.
</p>
</div>
<div className="flex flex-row gap-3">
<Button size="lg" className="gap-4" variant="outline" asChild>
<Link href="/contact">
Get in touch <PhoneCall className="h-4 w-4" />
</Link>
</Button>
<Button size="lg" className="gap-4" asChild>
<Link href={env.NEXT_PUBLIC_APP_URL}>
Sign up <MoveRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,75 @@
import { MoveDownLeft, MoveUpRight } from 'lucide-react';
export const Stats = () => (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2">
<div className="flex flex-col items-start gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-left font-regular text-xl tracking-tighter md:text-5xl lg:max-w-xl">
This is the start of something new
</h2>
<p className="text-left text-lg text-muted-foreground leading-relaxed tracking-tight lg:max-w-sm">
Managing a small business today is already tough. Avoid further
complications by ditching outdated, tedious trade methods. Our
goal is to streamline SMB trade, making it easier and faster than
ever.
</p>
</div>
</div>
<div className="flex items-center justify-center">
<div className="grid w-full grid-cols-1 gap-2 text-left sm:grid-cols-2 lg:grid-cols-2">
<div className="flex flex-col justify-between gap-0 rounded-md border p-6">
<MoveUpRight className="mb-10 h-4 w-4 text-primary" />
<h2 className="flex max-w-xl flex-row items-end gap-4 text-left font-regular text-4xl tracking-tighter">
500.000
<span className="text-muted-foreground text-sm tracking-normal">
+20.1%
</span>
</h2>
<p className="max-w-xl text-left text-base text-muted-foreground leading-relaxed tracking-tight">
Monthly active users
</p>
</div>
<div className="flex flex-col justify-between gap-0 rounded-md border p-6">
<MoveDownLeft className="mb-10 h-4 w-4 text-destructive" />
<h2 className="flex max-w-xl flex-row items-end gap-4 text-left font-regular text-4xl tracking-tighter">
20.105
<span className="text-muted-foreground text-sm tracking-normal">
-2%
</span>
</h2>
<p className="max-w-xl text-left text-base text-muted-foreground leading-relaxed tracking-tight">
Daily active users
</p>
</div>
<div className="flex flex-col justify-between gap-0 rounded-md border p-6">
<MoveUpRight className="mb-10 h-4 w-4 text-primary" />
<h2 className="flex max-w-xl flex-row items-end gap-4 text-left font-regular text-4xl tracking-tighter">
$523.520
<span className="text-muted-foreground text-sm tracking-normal">
+8%
</span>
</h2>
<p className="max-w-xl text-left text-base text-muted-foreground leading-relaxed tracking-tight">
Monthly recurring revenue
</p>
</div>
<div className="flex flex-col justify-between gap-0 rounded-md border p-6">
<MoveUpRight className="mb-10 h-4 w-4 text-primary" />
<h2 className="flex max-w-xl flex-row items-end gap-4 text-left font-regular text-4xl tracking-tighter">
$1052
<span className="text-muted-foreground text-sm tracking-normal">
+2%
</span>
</h2>
<p className="max-w-xl text-left text-base text-muted-foreground leading-relaxed tracking-tight">
Cost per acquisition
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,78 @@
'use client';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@konobangu/design-system/components/ui/avatar';
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from '@konobangu/design-system/components/ui/carousel';
import { User } from 'lucide-react';
import { useEffect, useState } from 'react';
export const Testimonials = () => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setTimeout(() => {
if (api.selectedScrollSnap() + 1 === api.scrollSnapList().length) {
setCurrent(0);
api.scrollTo(0);
} else {
api.scrollNext();
setCurrent(current + 1);
}
}, 4000);
}, [api, current]);
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="flex flex-col gap-10">
<h2 className="text-left font-regular text-3xl tracking-tighter md:text-5xl lg:max-w-xl">
Trusted by thousands of businesses worldwide
</h2>
<Carousel setApi={setApi} className="w-full">
<CarouselContent>
{Array.from({ length: 15 }).map((_, index) => (
<CarouselItem className="lg:basis-1/2" key={index}>
<div className="flex aspect-video h-full flex-col justify-between rounded-md bg-muted p-6 lg:col-span-2">
<User className="h-8 w-8 stroke-1" />
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-xl tracking-tight">
Best decision
</h3>
<p className="max-w-xs text-base text-muted-foreground">
Our goal was to streamline SMB trade, making it easier
and faster than ever and we did it together.
</p>
</div>
<p className="flex flex-row items-center gap-2 text-sm">
<span className="text-muted-foreground">By</span>
<Avatar className="h-6 w-6">
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<span>John Johnsen</span>
</p>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { showBetaFeature } from '@konobangu/feature-flags';
import { createMetadata } from '@konobangu/seo/metadata';
import type { Metadata } from 'next';
import { Cases } from './components/cases';
import { CTA } from './components/cta';
import { FAQ } from './components/faq';
import { Features } from './components/features';
import { Hero } from './components/hero';
import { Stats } from './components/stats';
import { Testimonials } from './components/testimonials';
const meta = {
title: 'From zero to production in minutes.',
description:
"next-forge is a production-grade boilerplate for modern Next.js apps. It's designed to have everything you need to build your new SaaS app as quick as possible. Authentication, billing, analytics, SEO, and more. It's all here.",
};
export const metadata: Metadata = createMetadata(meta);
const Home = async () => {
const betaFeature = await showBetaFeature();
return (
<>
{betaFeature && (
<div className="w-full bg-black py-2 text-center text-white">
Beta feature now available
</div>
)}
<Hero />
<Cases />
<Features />
<Stats />
<Testimonials />
<FAQ />
<CTA />
</>
);
};
export default Home;

BIN
apps/web/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

View File

@@ -0,0 +1,132 @@
import { Sidebar } from '@/components/sidebar';
import { blog } from '@konobangu/cms';
import { Body } from '@konobangu/cms/components/body';
import { Feed } from '@konobangu/cms/components/feed';
import { Image } from '@konobangu/cms/components/image';
import { TableOfContents } from '@konobangu/cms/components/toc';
import { env } from '@konobangu/env';
import { JsonLd } from '@konobangu/seo/json-ld';
import { createMetadata } from '@konobangu/seo/metadata';
import { ArrowLeftIcon } from '@radix-ui/react-icons';
import type { Metadata } from 'next';
import { draftMode } from 'next/headers';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Balancer from 'react-wrap-balancer';
type BlogPostProperties = {
readonly params: Promise<{
slug: string;
}>;
};
export const generateMetadata = async ({
params,
}: BlogPostProperties): Promise<Metadata> => {
const { slug } = await params;
const post = await blog.getPost(slug);
if (!post) {
return {};
}
return createMetadata({
title: post._title,
description: post.description,
image: post.image.url,
});
};
export const generateStaticParams = async (): Promise<{ slug: string }[]> => {
const posts = await blog.getPosts();
return posts.map(({ _slug }) => ({ slug: _slug }));
};
const BlogPost = async ({ params }: BlogPostProperties) => {
const { slug } = await params;
const draft = await draftMode();
return (
<Feed queries={[blog.postQuery(slug)]} draft={draft.isEnabled}>
{/* biome-ignore lint/suspicious/useAwait: "Server Actions must be async" */}
{async ([data]) => {
'use server';
const [page] = data.blog.posts.items;
if (!page) {
notFound();
}
return (
<>
<JsonLd
code={{
'@type': 'BlogPosting',
'@context': 'https://schema.org',
datePublished: page.date,
description: page.description,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': new URL(
`/blog/${slug}`,
env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
).toString(),
},
headline: page._title,
image: page.image.url,
dateModified: page.date,
author: page.authors.at(0)?._title,
isAccessibleForFree: true,
}}
/>
<div className="container py-16">
<Link
className="mb-4 inline-flex items-center gap-1 text-muted-foreground text-sm focus:underline focus:outline-none"
href="/blog"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to Blog
</Link>
<div className="mt-16 flex flex-col items-start gap-8 sm:flex-row">
<div className="sm:flex-1">
<div className="prose prose-neutral dark:prose-invert max-w-none">
<h1 className="scroll-m-20 font-extrabold text-4xl tracking-tight lg:text-5xl">
<Balancer>{page._title}</Balancer>
</h1>
<p className="leading-7 [&:not(:first-child)]:mt-6">
<Balancer>{page.description}</Balancer>
</p>
{page.image ? (
<Image
src={page.image.url}
width={page.image.width}
height={page.image.height}
alt={page.image.alt ?? ''}
className="my-16 h-full w-full rounded-xl"
priority
/>
) : undefined}
<div className="mx-auto max-w-prose">
<Body content={page.body.json.content} />
</div>
</div>
</div>
<div className="sticky top-24 hidden shrink-0 md:block">
<Sidebar
toc={<TableOfContents data={page.body.json.toc} />}
readingTime={`${page.body.readingTime} min read`}
date={new Date(page.date)}
/>
</div>
</div>
</div>
</>
);
}}
</Feed>
);
};
export default BlogPost;

View File

@@ -0,0 +1,15 @@
import { Toolbar } from '@konobangu/cms/components/toolbar';
import type { ReactNode } from 'react';
type BlogLayoutProps = {
children: ReactNode;
};
const BlogLayout = ({ children }: BlogLayoutProps) => (
<>
{children}
<Toolbar />
</>
);
export default BlogLayout;

View File

@@ -0,0 +1,87 @@
// import { blog } from '@konobangu/cms';
// import { Feed } from '@konobangu/cms/components/feed';
import { Image } from '@konobangu/cms/components/image';
import { cn } from '@konobangu/design-system/lib/utils';
import type { Blog, WithContext } from '@konobangu/seo/json-ld';
import { JsonLd } from '@konobangu/seo/json-ld';
import { createMetadata } from '@konobangu/seo/metadata';
import type { Metadata } from 'next';
import { draftMode } from 'next/headers';
import Link from 'next/link';
const title = 'Blog';
const description = 'Thoughts, ideas, and opinions.';
export const metadata: Metadata = createMetadata({ title, description });
const BlogIndex = async () => {
const draft = await draftMode();
const jsonLd: WithContext<Blog> = {
'@type': 'Blog',
'@context': 'https://schema.org',
};
return (
<>
<JsonLd code={jsonLd} />
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto flex flex-col gap-14">
<div className="flex w-full flex-col gap-8 sm:flex-row sm:items-center sm:justify-between">
<h4 className="max-w-xl font-regular text-3xl tracking-tighter md:text-5xl">
Latest articles
</h4>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* <Feed queries={[blog.postsQuery]} draft={draft.isEnabled}>
{async ([data]) => {
'use server';
if (!data.blog.posts.items.length) {
return null;
}
return data.blog.posts.items.map((post, index) => (
<Link
href={`/blog/${post._slug}`}
className={cn(
'flex cursor-pointer flex-col gap-4 hover:opacity-75',
!index && 'md:col-span-2'
)}
key={post._slug}
>
<Image
src={post.image.url}
alt={post.image.alt ?? ''}
width={post.image.width}
height={post.image.height}
/>
<div className="flex flex-row items-center gap-4">
<p className="text-muted-foreground text-sm">
{new Date(post.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
<div className="flex flex-col gap-2">
<h3 className="max-w-3xl text-4xl tracking-tight">
{post._title}
</h3>
<p className="max-w-3xl text-base text-muted-foreground">
{post.description}
</p>
</div>
</Link>
));
}}
</Feed> */}
</div>
</div>
</div>
</>
);
};
export default BlogIndex;

View File

@@ -0,0 +1,118 @@
import { env } from '@konobangu/env';
import { Status } from '@konobangu/observability/status';
import Link from 'next/link';
export const Footer = () => {
const navigationItems = [
{
title: 'Home',
href: '/',
description: '',
},
{
title: 'Pages',
description: 'Managing a small business today is already tough.',
items: [
{
title: 'Blog',
href: '/blog',
},
],
},
{
title: 'Legal',
description: 'We stay on top of the latest legal requirements.',
items: [
{
title: 'Terms of Service',
href: '/legal/terms',
},
{
title: 'Privacy Policy',
href: '/legal/privacy',
},
{
title: 'Acceptable Use',
href: '/legal/acceptable-use',
},
],
},
];
if (env.NEXT_PUBLIC_DOCS_URL) {
navigationItems.at(1)?.items?.push({
title: 'Docs',
href: env.NEXT_PUBLIC_DOCS_URL,
});
}
return (
<section className="dark border-foreground/10 border-t">
<div className="w-full bg-background py-20 text-foreground lg:py-40">
<div className="container mx-auto">
<div className="grid items-center gap-10 lg:grid-cols-2">
<div className="flex flex-col items-start gap-8">
<div className="flex flex-col gap-2">
<h2 className="max-w-xl text-left font-regular text-3xl tracking-tighter md:text-5xl">
next-forge
</h2>
<p className="max-w-lg text-left text-foreground/75 text-lg leading-relaxed tracking-tight">
This is the start of something new.
</p>
</div>
<Status />
</div>
<div className="grid items-start gap-10 lg:grid-cols-3">
{navigationItems.map((item) => (
<div
key={item.title}
className="flex flex-col items-start gap-1 text-base"
>
<div className="flex flex-col gap-2">
{item.href ? (
<Link
href={item.href}
className="flex items-center justify-between"
target={
item.href.includes('http') ? '_blank' : undefined
}
rel={
item.href.includes('http')
? 'noopener noreferrer'
: undefined
}
>
<span className="text-xl">{item.title}</span>
</Link>
) : (
<p className="text-xl">{item.title}</p>
)}
{item.items?.map((subItem) => (
<Link
key={subItem.title}
href={subItem.href}
className="flex items-center justify-between"
target={
subItem.href.includes('http') ? '_blank' : undefined
}
rel={
subItem.href.includes('http')
? 'noopener noreferrer'
: undefined
}
>
<span className="text-foreground/75">
{subItem.title}
</span>
</Link>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,195 @@
'use client';
import { ModeToggle } from '@konobangu/design-system/components/mode-toggle';
import { Button } from '@konobangu/design-system/components/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@konobangu/design-system/components/ui/navigation-menu';
import { env } from '@konobangu/env';
import { Menu, MoveRight, X } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import Image from 'next/image';
import Logo from './logo.svg';
export const Header = () => {
const navigationItems = [
{
title: 'Home',
href: '/',
description: '',
},
{
title: 'Product',
description: 'Managing a small business today is already tough.',
items: [
{
title: 'Pricing',
href: '/pricing',
},
{
title: 'Pricing',
href: '/pricing',
},
{
title: 'Pricing',
href: '/pricing',
},
{
title: 'Pricing',
href: '/pricing',
},
],
},
{
title: 'Blog',
href: '/blog',
description: '',
},
];
if (env.NEXT_PUBLIC_DOCS_URL) {
navigationItems.push({
title: 'Docs',
href: env.NEXT_PUBLIC_DOCS_URL,
description: '',
});
}
const [isOpen, setOpen] = useState(false);
return (
<header className="sticky top-0 left-0 z-40 w-full border-b bg-background">
<div className="container relative mx-auto flex min-h-20 flex-row items-center gap-4 lg:grid lg:grid-cols-3">
<div className="hidden flex-row items-center justify-start gap-4 lg:flex">
<NavigationMenu className="flex items-start justify-start">
<NavigationMenuList className="flex flex-row justify-start gap-4">
{navigationItems.map((item) => (
<NavigationMenuItem key={item.title}>
{item.href ? (
<>
<NavigationMenuLink asChild>
<Button variant="ghost" asChild>
<Link href={item.href}>{item.title}</Link>
</Button>
</NavigationMenuLink>
</>
) : (
<>
<NavigationMenuTrigger className="font-medium text-sm">
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="!w-[450px] p-4">
<div className="flex grid-cols-2 flex-col gap-4 lg:grid">
<div className="flex h-full flex-col justify-between">
<div className="flex flex-col">
<p className="text-base">{item.title}</p>
<p className="text-muted-foreground text-sm">
{item.description}
</p>
</div>
<Button size="sm" className="mt-10" asChild>
<Link href="/contact">Book a call today</Link>
</Button>
</div>
<div className="flex h-full flex-col justify-end text-sm">
{item.items?.map((subItem, idx) => (
<NavigationMenuLink
href={subItem.href}
key={idx}
className="flex flex-row items-center justify-between rounded px-4 py-2 hover:bg-muted"
>
<span>{subItem.title}</span>
<MoveRight className="h-4 w-4 text-muted-foreground" />
</NavigationMenuLink>
))}
</div>
</div>
</NavigationMenuContent>
</>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
<div className="flex items-center gap-2 lg:justify-center">
<Image
src={Logo}
alt="Logo"
width={24}
height={24}
className="dark:invert"
/>
<p className="whitespace-nowrap font-semibold">next-forge</p>
</div>
<div className="flex w-full justify-end gap-4">
<Button variant="ghost" className="hidden md:inline" asChild>
<Link href="/contact">Contact us</Link>
</Button>
<div className="hidden border-r md:inline" />
<div className="hidden md:inline">
<ModeToggle />
</div>
<Button variant="outline" asChild className="hidden md:inline">
<Link href={`${env.NEXT_PUBLIC_APP_URL}/sign-in`}>Sign in</Link>
</Button>
<Button asChild>
<Link href={`${env.NEXT_PUBLIC_APP_URL}/sign-up`}>Get started</Link>
</Button>
</div>
<div className="flex w-12 shrink items-end justify-end lg:hidden">
<Button variant="ghost" onClick={() => setOpen(!isOpen)}>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{isOpen && (
<div className="container absolute top-20 right-0 flex w-full flex-col gap-8 border-t bg-background py-4 shadow-lg">
{navigationItems.map((item) => (
<div key={item.title}>
<div className="flex flex-col gap-2">
{item.href ? (
<Link
href={item.href}
className="flex items-center justify-between"
target={
item.href.startsWith('http') ? '_blank' : undefined
}
rel={
item.href.startsWith('http')
? 'noopener noreferrer'
: undefined
}
>
<span className="text-lg">{item.title}</span>
<MoveRight className="h-4 w-4 stroke-1 text-muted-foreground" />
</Link>
) : (
<p className="text-lg">{item.title}</p>
)}
{item.items?.map((subItem) => (
<Link
key={subItem.title}
href={subItem.href}
className="flex items-center justify-between"
>
<span className="text-muted-foreground">
{subItem.title}
</span>
<MoveRight className="h-4 w-4 stroke-1" />
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1 @@
<svg fill="none" height="96" viewBox="0 0 96 96" width="96" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m0 0h96v96h-96z"/></clipPath><g clip-path="url(#a)"><path clip-rule="evenodd" d="m48 0h-48l48 48h-48l48 48h48l-48-48h48z" fill="#000" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,48 @@
'use server';
import { konosend } from '@konobangu/email';
import { ContactTemplate } from '@konobangu/email/templates/contact';
import { env } from '@konobangu/env';
import { parseError } from '@konobangu/observability/error';
import { createRateLimiter, slidingWindow } from '@konobangu/rate-limit';
import { headers } from 'next/headers';
export const contact = async (
name: string,
email: string,
message: string
): Promise<{
error?: string;
}> => {
try {
if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) {
const rateLimiter = createRateLimiter({
limiter: slidingWindow(1, '1d'),
});
const head = await headers();
const ip = head.get('x-forwarded-for');
const { success } = await rateLimiter.limit(`contact_form_${ip}`);
if (!success) {
throw new Error(
'You have reached your request limit. Please try again later.'
);
}
}
await konosend.emails.send({
from: env.RESEND_FROM,
to: env.RESEND_FROM,
subject: 'Contact form submission',
replyTo: email,
react: <ContactTemplate name={name} email={email} message={message} />,
});
return {};
} catch (error) {
const errorMessage = parseError(error);
return { error: errorMessage };
}
};

View File

@@ -0,0 +1,116 @@
'use client';
import { Button } from '@konobangu/design-system/components/ui/button';
import { Calendar } from '@konobangu/design-system/components/ui/calendar';
import { Input } from '@konobangu/design-system/components/ui/input';
import { Label } from '@konobangu/design-system/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@konobangu/design-system/components/ui/popover';
import { cn } from '@konobangu/design-system/lib/utils';
import { format } from 'date-fns';
import { CalendarIcon, Check, MoveRight } from 'lucide-react';
import { useState } from 'react';
export const ContactForm = () => {
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto max-w-6xl">
<div className="grid gap-10 lg:grid-cols-2">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h4 className="max-w-xl text-left font-regular text-3xl tracking-tighter md:text-5xl">
Something new
</h4>
<p className="max-w-sm text-left text-lg text-muted-foreground leading-relaxed tracking-tight">
Managing a small business today is already tough. Avoid
further complications by ditching outdated, tedious trade
methods.
</p>
</div>
</div>
<div className="flex flex-row items-start gap-6 text-left">
<Check className="mt-2 h-4 w-4 text-primary" />
<div className="flex flex-col gap-1">
<p>Easy to use</p>
<p className="text-muted-foreground text-sm">
We&apos;ve made it easy to use and understand.
</p>
</div>
</div>
<div className="flex flex-row items-start gap-6 text-left">
<Check className="mt-2 h-4 w-4 text-primary" />
<div className="flex flex-col gap-1">
<p>Fast and reliable</p>
<p className="text-muted-foreground text-sm">
We&apos;ve made it easy to use and understand.
</p>
</div>
</div>
<div className="flex flex-row items-start gap-6 text-left">
<Check className="mt-2 h-4 w-4 text-primary" />
<div className="flex flex-col gap-1">
<p>Beautiful and modern</p>
<p className="text-muted-foreground text-sm">
We&apos;ve made it easy to use and understand.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="flex max-w-sm flex-col gap-4 rounded-md border p-8">
<p>Book a meeting</p>
<div className="grid w-full max-w-sm items-center gap-1">
<Label htmlFor="picture">Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full max-w-sm justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="grid w-full max-w-sm items-center gap-1">
<Label htmlFor="firstname">First name</Label>
<Input id="firstname" type="text" />
</div>
<div className="grid w-full max-w-sm items-center gap-1">
<Label htmlFor="lastname">Last name</Label>
<Input id="lastname" type="text" />
</div>
<div className="grid w-full max-w-sm items-center gap-1">
<Label htmlFor="picture">Upload resume</Label>
<Input id="picture" type="file" />
</div>
<Button className="w-full gap-4">
Book the meeting <MoveRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { createMetadata } from '@konobangu/seo/metadata';
import type { Metadata } from 'next';
import { ContactForm } from './components/contact-form';
const title = 'Contact';
const description =
"Let us know what's on your mind. We'll get back to you as soon as possible.";
export const metadata: Metadata = createMetadata({
title,
description,
});
const Contact = () => <ContactForm />;
export default Contact;

View File

@@ -0,0 +1,29 @@
'use client';
import { Button } from '@konobangu/design-system/components/ui/button';
import { fonts } from '@konobangu/design-system/lib/fonts';
import { captureException } from '@sentry/nextjs';
import type NextError from 'next/error';
import { useEffect } from 'react';
type GlobalErrorProperties = {
readonly error: NextError & { digest?: string };
readonly reset: () => void;
};
const GlobalError = ({ error, reset }: GlobalErrorProperties) => {
useEffect(() => {
captureException(error);
}, [error]);
return (
<html lang="en" className={fonts}>
<body>
<h1>Oops, something went wrong</h1>
<Button onClick={() => reset()}>Try again</Button>
</body>
</html>
);
};
export default GlobalError;

BIN
apps/web/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

30
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import '@konobangu/design-system/styles/globals.css';
import './styles/web.css';
import { DesignSystemProvider } from '@konobangu/design-system';
import { fonts } from '@konobangu/design-system/lib/fonts';
import { cn } from '@konobangu/design-system/lib/utils';
import type { ReactNode } from 'react';
import { Footer } from './components/footer';
import { Header } from './components/header';
type RootLayoutProperties = {
readonly children: ReactNode;
};
const RootLayout = ({ children }: RootLayoutProperties) => (
<html
lang="en"
className={cn(fonts, 'scroll-smooth')}
suppressHydrationWarning
>
<body>
<DesignSystemProvider>
<Header />
{children}
<Footer />
</DesignSystemProvider>
</body>
</html>
);
export default RootLayout;

View File

@@ -0,0 +1,98 @@
import { Sidebar } from '@/components/sidebar';
import { legal } from '@konobangu/cms';
import { Body } from '@konobangu/cms/components/body';
// import { Feed } from '@konobangu/cms/components/feed';
import { TableOfContents } from '@konobangu/cms/components/toc';
import { createMetadata } from '@konobangu/seo/metadata';
import { ArrowLeftIcon } from '@radix-ui/react-icons';
import type { Metadata } from 'next';
import { draftMode } from 'next/headers';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Balancer from 'react-wrap-balancer';
type LegalPageProperties = {
readonly params: Promise<{
slug: string;
}>;
};
export const generateMetadata = async ({
params,
}: LegalPageProperties): Promise<Metadata> => {
const { slug } = await params;
const post = await legal.getPost(slug);
if (!post) {
return {};
}
return createMetadata({
title: post._title,
description: post.description,
});
};
export const generateStaticParams = async (): Promise<{ slug: string }[]> => {
const posts = await legal.getPosts();
return posts.map(({ _slug }) => ({ slug: _slug }));
};
const LegalPage = async ({ params }: LegalPageProperties) => {
const { slug } = await params;
const draft = await draftMode();
return (
<></>
)
// return (
// <Feed queries={[legal.postQuery(slug)]} draft={draft.isEnabled}>
// {/* biome-ignore lint/suspicious/useAwait: "Server Actions must be async" */}
// {async ([data]) => {
// 'use server';
// const [page] = data.legalPages.items;
// if (!page) {
// notFound();
// }
// return (
// <div className="container max-w-5xl py-16">
// <Link
// className="mb-4 inline-flex items-center gap-1 text-sm text-white/50 decoration-white/30 transition-colors hover:text-white/70 focus:text-white focus:underline focus:outline-none"
// href="/blog"
// >
// <ArrowLeftIcon className="h-4 w-4" />
// Back to Blog
// </Link>
// <h1 className="scroll-m-20 font-extrabold text-4xl tracking-tight lg:text-5xl">
// <Balancer>{page._title}</Balancer>
// </h1>
// <p className="leading-7 [&:not(:first-child)]:mt-6">
// <Balancer>{page.description}</Balancer>
// </p>
// <div className="mt-16 flex flex-col items-start gap-8 sm:flex-row">
// <div className="sm:flex-1">
// <div className="prose prose-neutral dark:prose-invert">
// <Body content={page.body.json.content} />
// </div>
// </div>
// <div className="sticky top-24 hidden shrink-0 md:block">
// <Sidebar
// toc={<TableOfContents data={page.body.json.toc} />}
// readingTime={`${page.body.readingTime} min read`}
// date={new Date()}
// />
// </div>
// </div>
// </div>
// );
// }}
// </Feed>
// );
};
export default LegalPage;

View File

@@ -0,0 +1,15 @@
import { Toolbar } from '@konobangu/cms/components/toolbar';
import type { ReactNode } from 'react';
type LegalLayoutProps = {
children: ReactNode;
};
const LegalLayout = ({ children }: LegalLayoutProps) => (
<>
{children}
<Toolbar />
</>
);
export default LegalLayout;

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -0,0 +1,156 @@
import { Button } from '@konobangu/design-system/components/ui/button';
import { env } from '@konobangu/env';
import { Check, Minus, MoveRight, PhoneCall } from 'lucide-react';
import Link from 'next/link';
const Pricing = () => (
<div className="w-full py-20 lg:py-40">
<div className="container mx-auto">
<div className="flex flex-col items-center justify-center gap-4 text-center">
<div className="flex flex-col gap-2">
<h2 className="max-w-xl text-center font-regular text-3xl tracking-tighter md:text-5xl">
Prices that make sense!
</h2>
<p className="max-w-xl text-center text-lg text-muted-foreground leading-relaxed tracking-tight">
Managing a small business today is already tough.
</p>
</div>
<div className="grid w-full grid-cols-3 divide-x pt-20 text-left lg:grid-cols-4">
<div className="col-span-3 lg:col-span-1" />
<div className="flex flex-col gap-2 px-3 py-1 md:px-6 md:py-4">
<p className="text-2xl">Startup</p>
<p className="text-muted-foreground text-sm">
Our goal is to streamline SMB trade, making it easier and faster
than ever for everyone and everywhere.
</p>
<p className="mt-8 flex flex-col gap-2 text-xl lg:flex-row lg:items-center">
<span className="text-4xl">$40</span>
<span className="text-muted-foreground text-sm"> / month</span>
</p>
<Button variant="outline" className="mt-8 gap-4" asChild>
<Link href={env.NEXT_PUBLIC_APP_URL}>
Try it <MoveRight className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-2 px-3 py-1 md:px-6 md:py-4">
<p className="text-2xl">Growth</p>
<p className="text-muted-foreground text-sm">
Our goal is to streamline SMB trade, making it easier and faster
than ever for everyone and everywhere.
</p>
<p className="mt-8 flex flex-col gap-2 text-xl lg:flex-row lg:items-center">
<span className="text-4xl">$40</span>
<span className="text-muted-foreground text-sm"> / month</span>
</p>
<Button className="mt-8 gap-4" asChild>
<Link href={env.NEXT_PUBLIC_APP_URL}>
Try it <MoveRight className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-2 px-3 py-1 md:px-6 md:py-4">
<p className="text-2xl">Enterprise</p>
<p className="text-muted-foreground text-sm">
Our goal is to streamline SMB trade, making it easier and faster
than ever for everyone and everywhere.
</p>
<p className="mt-8 flex flex-col gap-2 text-xl lg:flex-row lg:items-center">
<span className="text-4xl">$40</span>
<span className="text-muted-foreground text-sm"> / month</span>
</p>
<Button variant="outline" className="mt-8 gap-4" asChild>
<Link href="/contact">
Contact us <PhoneCall className="h-4 w-4" />
</Link>
</Button>
</div>
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
<b>Features</b>
</div>
<div />
<div />
<div />
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">SSO</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
AI Assistant
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Minus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
Version Control
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Minus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
Members
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<p className="text-muted-foreground text-sm">5 members</p>
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<p className="text-muted-foreground text-sm">25 members</p>
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<p className="text-muted-foreground text-sm">100+ members</p>
</div>
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
Multiplayer Mode
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Minus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
{/* New Line */}
<div className="col-span-3 px-3 py-4 lg:col-span-1 lg:px-6">
Orchestration
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Minus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
<div className="flex justify-center px-3 py-1 md:px-6 md:py-4">
<Check className="h-4 w-4 text-primary" />
</div>
</div>
</div>
</div>
</div>
);
export default Pricing;

15
apps/web/app/robots.ts Normal file
View File

@@ -0,0 +1,15 @@
import { env } from '@konobangu/env';
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: new URL(
'/sitemap.xml',
env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
).href,
};
}

49
apps/web/app/sitemap.ts Normal file
View File

@@ -0,0 +1,49 @@
import fs from 'node:fs';
import { env } from '@konobangu/env';
import type { MetadataRoute } from 'next';
const appFolders = fs.readdirSync('app', { withFileTypes: true });
const pages = appFolders
.filter((file) => file.isDirectory())
.filter((folder) => !folder.name.startsWith('_'))
.filter((folder) => !folder.name.startsWith('('))
.map((folder) => folder.name);
const blogs = fs
.readdirSync('content/blog', { withFileTypes: true })
.filter((file) => !file.isDirectory())
.filter((file) => !file.name.startsWith('_'))
.filter((file) => !file.name.startsWith('('))
.map((file) => file.name.replace('.mdx', ''));
const legals = fs
.readdirSync('content/legal', { withFileTypes: true })
.filter((file) => !file.isDirectory())
.filter((file) => !file.name.startsWith('_'))
.filter((file) => !file.name.startsWith('('))
.map((file) => file.name.replace('.mdx', ''));
const sitemap = async (): Promise<MetadataRoute.Sitemap> => [
{
url: env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL,
lastModified: new Date(),
},
...pages.map((page) => ({
url: new URL(page, env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL).href,
lastModified: new Date(),
})),
...blogs.map((blog) => ({
url: new URL(`blog/${blog}`, env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL)
.href,
lastModified: new Date(),
})),
...legals.map((legal) => ({
url: new URL(
`legal/${legal}`,
env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
).href,
lastModified: new Date(),
})),
];
export default sitemap;

View File

@@ -0,0 +1,36 @@
.shiki {
background-color: var(--shiki-light-bg);
color: var(--shiki-light);
@apply border-border;
}
.shiki span {
color: var(--shiki-light);
}
.dark .shiki {
background-color: var(--shiki-dark-bg);
color: var(--shiki-dark);
}
.dark .shiki span {
color: var(--shiki-dark);
}
.shiki code {
display: grid;
font-size: 13px;
counter-reset: line;
}
.shiki .line:before {
content: counter(line);
counter-increment: line;
@apply inline-block w-4 mr-8 text-muted-foreground text-right;
}
.shiki[title]:before {
content: attr(title);
@apply inline-block text-muted-foreground text-right mb-6 text-sm;
}