feat: add basic webui
This commit is contained in:
15
apps/web/.env.development
Normal file
15
apps/web/.env.development
Normal file
@@ -0,0 +1,15 @@
|
||||
# Server
|
||||
BETTER_AUTH_SECRET="konobangu"
|
||||
DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"
|
||||
BETTERSTACK_API_KEY=""
|
||||
BETTERSTACK_URL=""
|
||||
FLAGS_SECRET=""
|
||||
ARCJET_KEY=""
|
||||
SVIX_TOKEN=""
|
||||
LIVEBLOCKS_SECRET=""
|
||||
|
||||
# Client
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
|
||||
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="https://webui.konobangu.com"
|
||||
15
apps/web/.env.example
Normal file
15
apps/web/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Server
|
||||
BETTER_AUTH_SECRET=""
|
||||
DATABASE_URL=""
|
||||
BETTERSTACK_API_KEY=""
|
||||
BETTERSTACK_URL=""
|
||||
FLAGS_SECRET=""
|
||||
ARCJET_KEY=""
|
||||
SVIX_TOKEN=""
|
||||
LIVEBLOCKS_SECRET=""
|
||||
|
||||
# Client
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
|
||||
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000"
|
||||
48
apps/web/.gitignore
vendored
Normal file
48
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
.env
|
||||
|
||||
# react.email
|
||||
.react-email
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
|
||||
# content-collections
|
||||
.content-collections
|
||||
3
apps/web/.well-known/vercel/flags/route.ts
Normal file
3
apps/web/.well-known/vercel/flags/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getFlags } from '@konobangu/feature-flags/access';
|
||||
|
||||
export const GET = getFlags;
|
||||
53
apps/web/app/(home)/components/cases.tsx
Normal file
53
apps/web/app/(home)/components/cases.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
apps/web/app/(home)/components/cta.tsx
Normal file
35
apps/web/app/(home)/components/cta.tsx
Normal 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>
|
||||
);
|
||||
55
apps/web/app/(home)/components/faq.tsx
Normal file
55
apps/web/app/(home)/components/faq.tsx
Normal 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>
|
||||
);
|
||||
63
apps/web/app/(home)/components/features.tsx
Normal file
63
apps/web/app/(home)/components/features.tsx
Normal 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>
|
||||
);
|
||||
64
apps/web/app/(home)/components/hero.tsx
Normal file
64
apps/web/app/(home)/components/hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
apps/web/app/(home)/components/stats.tsx
Normal file
75
apps/web/app/(home)/components/stats.tsx
Normal 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>
|
||||
);
|
||||
78
apps/web/app/(home)/components/testimonials.tsx
Normal file
78
apps/web/app/(home)/components/testimonials.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
apps/web/app/(home)/page.tsx
Normal file
41
apps/web/app/(home)/page.tsx
Normal 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
BIN
apps/web/app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
132
apps/web/app/blog/[slug]/page.tsx
Normal file
132
apps/web/app/blog/[slug]/page.tsx
Normal 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;
|
||||
15
apps/web/app/blog/layout.tsx
Normal file
15
apps/web/app/blog/layout.tsx
Normal 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;
|
||||
87
apps/web/app/blog/page.tsx
Normal file
87
apps/web/app/blog/page.tsx
Normal 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;
|
||||
118
apps/web/app/components/footer.tsx
Normal file
118
apps/web/app/components/footer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
195
apps/web/app/components/header/index.tsx
Normal file
195
apps/web/app/components/header/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
apps/web/app/components/header/logo.svg
Normal file
1
apps/web/app/components/header/logo.svg
Normal 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 |
48
apps/web/app/contact/actions/contact.tsx
Normal file
48
apps/web/app/contact/actions/contact.tsx
Normal 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 };
|
||||
}
|
||||
};
|
||||
116
apps/web/app/contact/components/contact-form.tsx
Normal file
116
apps/web/app/contact/components/contact-form.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
};
|
||||
16
apps/web/app/contact/page.tsx
Normal file
16
apps/web/app/contact/page.tsx
Normal 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;
|
||||
29
apps/web/app/global-error.tsx
Normal file
29
apps/web/app/global-error.tsx
Normal 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
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
30
apps/web/app/layout.tsx
Normal 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;
|
||||
98
apps/web/app/legal/[slug]/page.tsx
Normal file
98
apps/web/app/legal/[slug]/page.tsx
Normal 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;
|
||||
15
apps/web/app/legal/layout.tsx
Normal file
15
apps/web/app/legal/layout.tsx
Normal 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;
|
||||
BIN
apps/web/app/opengraph-image.png
Normal file
BIN
apps/web/app/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
156
apps/web/app/pricing/page.tsx
Normal file
156
apps/web/app/pricing/page.tsx
Normal 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
15
apps/web/app/robots.ts
Normal 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
49
apps/web/app/sitemap.ts
Normal 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;
|
||||
36
apps/web/app/styles/web.css
Normal file
36
apps/web/app/styles/web.css
Normal 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;
|
||||
}
|
||||
50
apps/web/components/sidebar.tsx
Normal file
50
apps/web/components/sidebar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { capitalize } from '@konobangu/design-system/lib/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type SidebarProperties = {
|
||||
readonly date: Date;
|
||||
readonly readingTime: string;
|
||||
readonly tags?: string[];
|
||||
readonly toc?: ReactNode;
|
||||
};
|
||||
|
||||
export const Sidebar = async ({
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
toc: Toc,
|
||||
}: SidebarProperties) => (
|
||||
<div className="col-span-4 flex w-72 flex-col items-start gap-8 border-foreground/10 border-l px-6 lg:col-span-2">
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground text-sm">Published</p>
|
||||
<p className="rounded-sm text-foreground text-sm">
|
||||
{new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/New_York',
|
||||
}).format(date)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground text-sm">Reading Time</p>
|
||||
<p className="rounded-sm text-foreground text-sm">{readingTime}</p>
|
||||
</div>
|
||||
{tags && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground text-sm">Tags</p>
|
||||
<p className="rounded-sm text-foreground text-sm">
|
||||
{tags.map(capitalize).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{Toc ? (
|
||||
<div className="-mx-2">
|
||||
<div className="grid gap-2 p-2">
|
||||
<p className="text-muted-foreground text-sm">Sections</p>
|
||||
{Toc}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
1
apps/web/content-collections.ts
Normal file
1
apps/web/content-collections.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@konobangu/cms/collections';
|
||||
6
apps/web/content/blog/my-first-blog.mdx
Normal file
6
apps/web/content/blog/my-first-blog.mdx
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 'My First Post'
|
||||
description: 'This is my first blog post'
|
||||
date: 2024-10-23
|
||||
image: /blog/my-first-post.png
|
||||
---
|
||||
3
apps/web/instrumentation.ts
Normal file
3
apps/web/instrumentation.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { initializeSentry } from '@konobangu/next-config/instrumentation';
|
||||
|
||||
export const register = initializeSentry();
|
||||
41
apps/web/middleware.ts
Normal file
41
apps/web/middleware.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { authMiddleware } from '@konobangu/auth/middleware';
|
||||
import { env } from '@konobangu/env';
|
||||
import { parseError } from '@konobangu/observability/error';
|
||||
import { secure } from '@konobangu/security';
|
||||
import {
|
||||
noseconeConfig,
|
||||
noseconeMiddleware,
|
||||
} from '@konobangu/security/middleware';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const config = {
|
||||
// matcher tells Next.js which routes to run the middleware on. This runs the
|
||||
// middleware on all routes except for static assets and Posthog ingest
|
||||
matcher: ['/((?!_next/static|_next/image|ingest|favicon.ico).*)'],
|
||||
};
|
||||
|
||||
const securityHeaders = noseconeMiddleware(noseconeConfig);
|
||||
|
||||
export default authMiddleware(async (_auth, request) => {
|
||||
if (!env.ARCJET_KEY) {
|
||||
return securityHeaders();
|
||||
}
|
||||
|
||||
try {
|
||||
await secure(
|
||||
[
|
||||
// See https://docs.arcjet.com/bot-protection/identifying-bots
|
||||
'CATEGORY:SEARCH_ENGINE', // Allow search engines
|
||||
'CATEGORY:PREVIEW', // Allow preview links to show OG images
|
||||
'CATEGORY:MONITOR', // Allow uptime monitoring services
|
||||
],
|
||||
request
|
||||
);
|
||||
|
||||
return securityHeaders();
|
||||
} catch (error) {
|
||||
const message = parseError(error);
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 403 });
|
||||
}
|
||||
});
|
||||
33
apps/web/next.config.ts
Normal file
33
apps/web/next.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { withCMS } from '@konobangu/cms/next-config';
|
||||
import { env } from '@konobangu/env';
|
||||
import { config, withAnalyzer, withSentry } from '@konobangu/next-config';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
let nextConfig: NextConfig = { ...config };
|
||||
|
||||
nextConfig.images?.remotePatterns?.push({
|
||||
protocol: 'https',
|
||||
hostname: 'assets.basehub.com',
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const redirects: NextConfig['redirects'] = async () => [
|
||||
{
|
||||
source: '/legal',
|
||||
destination: '/legal/privacy',
|
||||
statusCode: 301,
|
||||
},
|
||||
];
|
||||
|
||||
nextConfig.redirects = redirects;
|
||||
}
|
||||
|
||||
if (env.VERCEL) {
|
||||
nextConfig = withSentry(nextConfig);
|
||||
}
|
||||
|
||||
if (env.ANALYZE === 'true') {
|
||||
nextConfig = withAnalyzer(nextConfig);
|
||||
}
|
||||
|
||||
export default withCMS(nextConfig);
|
||||
55
apps/web/package.json
Normal file
55
apps/web/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001 --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arcjet/next": "1.0.0-alpha.34",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@konobangu/cms": "workspace:*",
|
||||
"@konobangu/design-system": "workspace:*",
|
||||
"@konobangu/email": "workspace:*",
|
||||
"@konobangu/env": "workspace:*",
|
||||
"@konobangu/feature-flags": "workspace:*",
|
||||
"@konobangu/next-config": "workspace:*",
|
||||
"@konobangu/observability": "workspace:*",
|
||||
"@konobangu/rate-limit": "workspace:*",
|
||||
"@konobangu/seo": "workspace:*",
|
||||
"@konobangu/tailwind-config": "workspace:*",
|
||||
"@sentry/nextjs": "^8.43.0",
|
||||
"@content-collections/mdx": "^0.2.0",
|
||||
"@content-collections/core": "^0.8.0",
|
||||
"@content-collections/next": "^0.2.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"fumadocs-core": "^14.6.0",
|
||||
"import-in-the-middle": "^1.11.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mdx-bundler": "^10.0.3",
|
||||
"next": "^15.1.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"reading-time": "^1.5.0",
|
||||
"require-in-the-middle": "^7.4.0",
|
||||
"sharp": "^0.33.5",
|
||||
"shiki": "^1.24.2",
|
||||
"sqip": "1.0.0-alpha.51",
|
||||
"sqip-plugin-data-uri": "1.0.0-alpha.52",
|
||||
"sqip-plugin-primitive": "1.0.0-alpha.53",
|
||||
"sqip-plugin-svgo": "1.0.0-alpha.54"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@konobangu/typescript-config": "workspace:*",
|
||||
"@types/node": "22.10.1",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
1
apps/web/postcss.config.mjs
Normal file
1
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@konobangu/design-system/postcss.config.mjs';
|
||||
1
apps/web/tailwind.config.ts
Normal file
1
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { config as default } from '@konobangu/tailwind-config/config';
|
||||
20
apps/web/tsconfig.json
Normal file
20
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": [
|
||||
"@konobangu/typescript-config/nextjs.json",
|
||||
"@konobangu/cms/typescript-config.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@konobangu/*": ["../../packages/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
]
|
||||
}
|
||||
3
apps/web/vercel.json
Normal file
3
apps/web/vercel.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"crons": []
|
||||
}
|
||||
Reference in New Issue
Block a user