feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { getMetadata } from "~/lib/metadata";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
export const generateMetadata = getMetadata({
title: "ai:agent.title",
description: "ai:agent.description",
});
export default function AgentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header>
<div className="flex items-center gap-1">
<ThemeSwitcher />
</div>
</Header>
<div className="@container relative flex h-full flex-col items-center contain-layout">
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { Agent } from "~/modules/agent";
export default function AgentPage() {
return <Agent />;
}

View File

@@ -0,0 +1,63 @@
import { notFound, redirect } from "next/navigation";
import { z } from "zod";
import { messageSchema, partSchema } from "@turbostarter/ai/chat/schema";
import { toChatMessage } from "@turbostarter/ai/chat/utils";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { ViewChat } from "~/modules/chat/layout/view";
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) => {
const id = (await params).id;
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
param: { id },
});
return getMetadata({
...(data?.name && { title: data.name }),
})({ params });
};
export default async function Chat({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
const id = (await params).id;
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
param: { id },
});
if (!data) {
return notFound();
}
const messages = await handle(api.ai.chat.chats[":id"].messages.$get, {
throwOnError: false,
schema: z.array(
messageSchema.extend({
parts: z.array(partSchema),
}),
),
})({
param: { id },
});
const initialMessages = (messages ?? []).map(toChatMessage);
return <ViewChat id={id} initialMessages={initialMessages} />;
}

View File

@@ -0,0 +1,30 @@
import { getMetadata } from "~/lib/metadata";
import { ChatHistory } from "~/modules/chat/history";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
export const generateMetadata = getMetadata({
title: "ai:chat.title",
description: "ai:chat.description",
});
export default function ChatLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
</div>
</Header>
<div className="@container relative flex h-full flex-col items-center contain-layout">
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useMemo } from "react";
import { generateId } from "@turbostarter/shared/utils";
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
import { NewChat } from "~/modules/chat/layout/new";
import { ViewChat } from "~/modules/chat/layout/view";
export default function Chat() {
const id = useMemo(() => generateId(), []);
const { messages } = useComposer({
id,
});
if (messages.length) {
return <ViewChat id={id} />;
}
return <NewChat id={id} />;
}

View File

@@ -0,0 +1,125 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import Link from "next/link";
import { cn } from "@turbostarter/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
interface DemoItem {
title: string;
description: string;
href: string;
icon: keyof typeof Icons;
status: "stable" | "experimental" | "new";
}
const demos: DemoItem[] = [
{
title: "Scroll Fade",
description: "CSS mask-image based scroll indicators that fade content at edges when scrollable.",
href: "/demo/scroll-test",
icon: "ScrollText",
status: "new",
},
];
const statusStyles = {
stable: "bg-green-500/10 text-green-500 border-green-500/20",
experimental: "bg-amber-500/10 text-amber-500 border-amber-500/20",
new: "bg-blue-500/10 text-blue-500 border-blue-500/20",
};
const statusLabels = {
stable: "Stable",
experimental: "Experimental",
new: "New",
};
export default function DemoHubPage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b bg-card/50 backdrop-blur-sm">
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icons.LayoutDashboard className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Component Demos</h1>
<p className="text-muted-foreground text-sm">
Interactive demonstrations of UI components and patterns
</p>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-6xl px-6 py-8">
{demos.length === 0 ? (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12">
<Icons.Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-muted-foreground">No demos available yet</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{demos.map((demo) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const Icon = Icons[demo.icon] || Icons.Package;
return (
<Link key={demo.href} href={demo.href}>
<Card className="group h-full transition-all hover:border-primary/50 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-primary/10">
<Icon className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary" />
</div>
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs font-medium",
statusStyles[demo.status]
)}
>
{statusLabels[demo.status]}
</span>
</div>
<CardTitle className="mt-3 text-lg">{demo.title}</CardTitle>
<CardDescription className="line-clamp-2">
{demo.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center text-sm text-muted-foreground group-hover:text-primary">
<span>View demo</span>
<Icons.ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
{/* Footer info */}
<div className="mt-12 rounded-lg border border-dashed bg-muted/30 p-6">
<div className="flex items-start gap-3">
<Icons.Info className="mt-0.5 h-5 w-5 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">Adding new demos</p>
<p className="text-sm text-muted-foreground">
Create a new folder in <code className="rounded bg-muted px-1.5 py-0.5 text-xs">app/[locale]/(apps)/demo/</code> and
add it to the demos array in this page.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollAreaWithShadows } from "@turbostarter/ui-web/scroll-area";
export default function ScrollTestPage() {
return (
<div className="min-h-screen bg-background p-8">
<div className="mb-8">
<Link
href="/demo"
className="mb-4 inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Icons.ArrowLeft className="mr-1 h-4 w-4" />
Back to demos
</Link>
<h1 className="text-2xl font-bold">Scroll Fade Demo</h1>
<p className="mt-1 text-muted-foreground text-sm">
CSS mask-image based scroll indicators that fade content at edges
</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
{/* Card with scrollable content */}
<Card>
<CardHeader>
<CardTitle>Scrollable Card</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollAreaWithShadows className="px-6 pb-6" maxHeight="300px">
<div className="space-y-4">
{Array.from({ length: 20 }, (_, i) => (
<div key={i} className="rounded-lg border p-4">
<h3 className="font-medium">Item {i + 1}</h3>
<p className="text-muted-foreground text-sm">
This is some sample content for item {i + 1}.
Scroll up and down to see the fade effect at the edges.
</p>
</div>
))}
</div>
</ScrollAreaWithShadows>
</CardContent>
</Card>
{/* Another card for comparison */}
<Card>
<CardHeader>
<CardTitle>Another Scrollable Card</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollAreaWithShadows className="px-6 pb-6" maxHeight="300px">
<div className="space-y-3">
{Array.from({ length: 15 }, (_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-md bg-muted/50 p-3"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary font-medium">
{i + 1}
</div>
<div>
<div className="font-medium">List item {i + 1}</div>
<div className="text-muted-foreground text-sm">
Description text here
</div>
</div>
</div>
))}
</div>
</ScrollAreaWithShadows>
</CardContent>
</Card>
{/* Short content - no scroll needed */}
<Card>
<CardHeader>
<CardTitle>No Scroll Needed</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollAreaWithShadows className="px-6 pb-6" maxHeight="300px">
<div className="space-y-4">
<div className="rounded-lg border p-4">
<h3 className="font-medium">Single Item</h3>
<p className="text-muted-foreground text-sm">
This card has little content, so no fade effect should appear.
</p>
</div>
</div>
</ScrollAreaWithShadows>
</CardContent>
</Card>
{/* Taller card */}
<Card>
<CardHeader>
<CardTitle>Taller Scroll Area</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollAreaWithShadows className="px-6 pb-6" maxHeight="400px">
<div className="space-y-4">
{Array.from({ length: 25 }, (_, i) => (
<div
key={i}
className="rounded-lg border border-dashed p-4"
>
<div className="flex items-center justify-between">
<span className="font-medium">Row {i + 1}</span>
<span className="text-muted-foreground text-xs">
{new Date().toLocaleDateString()}
</span>
</div>
<p className="text-muted-foreground mt-2 text-sm">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore.
</p>
</div>
))}
</div>
</ScrollAreaWithShadows>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { notFound } from "next/navigation";
import { generationSchema } from "@turbostarter/ai/image/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { ViewGeneration } from "~/modules/image/generation/view";
import { HistoryCta } from "~/modules/image/history/cta";
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) => {
const id = (await params).id;
const generation = await handle(api.ai.image.generations[":id"].$get)({
param: { id },
});
return getMetadata({
...(generation?.prompt && {
title:
generation.prompt.length > 50
? `${generation.prompt.slice(0, 50)}...`
: generation.prompt,
}),
})({ params });
};
export default async function ImageGeneration({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const generation = await handle(api.ai.image.generations[":id"].$get, {
schema: generationSchema.nullable(),
})({
param: { id },
});
if (!generation) {
return notFound();
}
const images = await handle(api.ai.image.generations[":id"].images.$get)({
param: { id },
});
return (
<>
<Header>
<div className="flex items-center gap-1">
<HistoryCta />
<ThemeSwitcher />
</div>
</Header>
<ViewGeneration
id={id}
initialGeneration={{
...generation,
input: {
prompt: generation.prompt,
options: generation,
},
images: images.map((image) => ({
url: image.url,
})),
}}
/>
</>
);
}

View File

@@ -0,0 +1,23 @@
import { getMetadata } from "~/lib/metadata";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { History } from "~/modules/image/history";
export const generateMetadata = getMetadata({
title: "ai:image.history.title",
description: "ai:image.history.description",
});
export default function HistoryPage() {
return (
<>
<Header>
<div className="flex items-center gap-1">
<ThemeSwitcher />
</div>
</Header>
<History />
</>
);
}

View File

@@ -0,0 +1,18 @@
import { getMetadata } from "~/lib/metadata";
export const generateMetadata = getMetadata({
title: "ai:image.title",
description: "ai:image.description",
});
export default function ImageLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="@container relative flex h-full flex-col items-center contain-layout">
{children}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useMemo } from "react";
import { generateId } from "@turbostarter/shared/utils";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { NewGeneration } from "~/modules/image/generation/new";
import { ViewGeneration } from "~/modules/image/generation/view";
import { HistoryCta } from "~/modules/image/history/cta";
import { useImageGeneration } from "~/modules/image/use-image-generation";
const Image = () => {
const id = useMemo(() => generateId(), []);
const { generation } = useImageGeneration({
id,
});
if (generation) {
return <ViewGeneration id={id} />;
}
return <NewGeneration id={id} />;
};
export default function Page() {
return (
<>
<Header className="bg-transparent">
<div className="flex items-center gap-1">
<HistoryCta />
<ThemeSwitcher />
</div>
</Header>
<Image />
</>
);
}

View File

@@ -0,0 +1,14 @@
import { SidebarInset, SidebarProvider } from "@turbostarter/ui-web/sidebar";
import { AppsSidebar } from "~/modules/common/layout/sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<AppsSidebar />
<SidebarInset className="relative h-dvh shrink grow sm:h-[calc(100dvh-1rem)]">
{children}
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,12 @@
import { PdfLayout } from "~/modules/pdf/layout/layout";
export default async function Layout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
return <PdfLayout id={id}>{children}</PdfLayout>;
}

View File

@@ -0,0 +1,57 @@
import * as z from "zod";
import { messageSchema } from "@turbostarter/ai/pdf/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { ChatComposer } from "~/modules/pdf/composer";
import { Chat } from "~/modules/pdf/thread";
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) => {
const id = (await params).id;
const chat = await handle(api.ai.pdf.chats[":id"].$get)({
param: { id },
});
return getMetadata({
...(chat?.name && { title: chat.name }),
})({ params });
};
const PdfChat = async ({ params }: { params: Promise<{ id: string }> }) => {
const id = (await params).id;
const messages = await handle(api.ai.pdf.chats[":id"].messages.$get, {
schema: z.array(messageSchema),
})({
param: { id },
});
const initialMessages = messages.map((message) => ({
...message,
parts: [
{
type: "text" as const,
text: message.content,
},
],
}));
return (
<>
<Chat id={id} initialMessages={initialMessages} />
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-200">
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
<ChatComposer id={id} initialMessages={initialMessages} />
</div>
</div>
</>
);
};
export default PdfChat;

View File

@@ -0,0 +1,14 @@
import { getMetadata } from "~/lib/metadata";
export const generateMetadata = getMetadata({
title: "ai:pdf.title",
description: "ai:pdf.description",
});
export default function PdfLayout({ children }: { children: React.ReactNode }) {
return (
<div className="@container relative flex h-full flex-col items-center contain-layout">
{children}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { RecentChats } from "~/modules/pdf/components/recent-chats";
import { ChatHistory } from "~/modules/pdf/history";
import { PdfUpload } from "~/modules/pdf/upload";
export default function PdfPage() {
return (
<>
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
</div>
</Header>
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-3 pt-12 md:pt-14">
<PdfUpload />
<RecentChats />
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
import { getMetadata } from "~/lib/metadata";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
export const generateMetadata = getMetadata({
title: "ai:tts.title",
description: "ai:tts.description",
});
export default function AgentLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header className="bg-transparent">
<div className="flex items-center gap-1">
<ThemeSwitcher />
</div>
</Header>
<div className="@container relative flex h-full flex-col items-center contain-layout">
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,37 @@
import { unstable_cache } from "next/cache";
import { getVoices } from "@turbostarter/ai/tts/api";
// Skip static generation - requires ELEVENLABS_API_KEY at runtime
export const dynamic = "force-dynamic";
import { random } from "@turbostarter/shared/utils";
import { Tts } from "~/modules/tts";
const getCachedVoices = unstable_cache(
async () => {
const voices = await getVoices();
return voices.map((voice) => ({
...voice,
avatar: {
src: `/images/avatars/${random(1, 3)}.webp`,
style: {
filter: `hue-rotate(${random(0, 360)}deg) saturate(1.2)`,
transform: `rotate(${random(0, 360)}deg)`,
},
},
}));
},
["voices"],
{
revalidate: 3600 * 24, // Cache for 1 day
tags: ["voices"],
},
);
export default async function TtsPage() {
const voices = await getCachedVoices();
return <Tts voices={voices} />;
}

View File

@@ -0,0 +1,121 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import Image from "next/image";
import { notFound } from "next/navigation";
import { getContentItemBySlug, getContentItems } from "@turbostarter/cms";
import { CollectionType } from "@turbostarter/cms";
import { getTranslation } from "@turbostarter/i18n/server";
import { badgeVariants } from "@turbostarter/ui-web/badge";
import { BLOG_PREFIX } from "~/config/paths";
import { getMetadata } from "~/lib/metadata";
import { Mdx } from "~/modules/common/mdx";
import { TurboLink } from "~/modules/common/turbo-link";
import {
Section,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
dayjs.extend(duration);
export default async function Page({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}) {
const { t } = await getTranslation({ ns: "marketing" });
const item = getContentItemBySlug({
collection: CollectionType.BLOG,
slug: (await params).slug,
locale: (await params).locale,
});
if (!item) {
return notFound();
}
return (
<Section>
<SectionHeader className="max-w-3xl">
<div className="mr-auto flex flex-wrap gap-1 md:gap-1.5">
{item.tags.map((tag) => (
<TurboLink
key={tag}
href={`${BLOG_PREFIX}?tag=${tag}`}
className={badgeVariants({ variant: "outline" })}
>
{t(`blog.tag.${tag}`)}
</TurboLink>
))}
</div>
<SectionTitle as="h1" className="mt-2 text-left">
{item.title}
</SectionTitle>
<div className="text-muted-foreground mr-auto flex flex-wrap items-center gap-1.5">
<time
className="text-muted-foreground"
dateTime={item.publishedAt.toISOString()}
>
{dayjs(item.publishedAt).format("MMMM D, YYYY")}
</time>
{item.timeToRead && <span>·</span>}
{typeof item.timeToRead !== "undefined" && (
<span>
{t("blog.timeToRead", {
time: Math.ceil(dayjs.duration(item.timeToRead).asMinutes()),
})}
</span>
)}
</div>
<SectionDescription className="text-left">
{item.description}
</SectionDescription>
<div className="relative -mx-2 mt-4 aspect-[12/8] w-[calc(100%+1rem)]">
<Image
alt=""
fill
src={item.thumbnail}
className="rounded-lg object-cover"
/>
</div>
</SectionHeader>
<Mdx mdx={item.mdx} />
</Section>
);
}
export function generateStaticParams() {
return getContentItems({ collection: CollectionType.BLOG }).items.map(
(post) => ({
slug: post.slug,
}),
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: string }>;
}) {
const item = getContentItemBySlug({
collection: CollectionType.BLOG,
slug: (await params).slug,
locale: (await params).locale,
});
if (!item) {
return notFound();
}
return getMetadata({
title: item.title,
description: item.description,
})({ params });
}

View File

@@ -0,0 +1,128 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import Image from "next/image";
import {
CollectionType,
ContentStatus,
getContentItems,
} from "@turbostarter/cms";
import { getTranslation } from "@turbostarter/i18n/server";
import { SortOrder } from "@turbostarter/shared/constants";
import { Badge } from "@turbostarter/ui-web/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import { pathsConfig } from "~/config/paths";
import { getMetadata } from "~/lib/metadata";
import { TurboLink } from "~/modules/common/turbo-link";
import { TagsPicker } from "~/modules/marketing/blog/tags-picker";
import {
Section,
SectionBadge,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
import type { ContentTag } from "@turbostarter/cms";
dayjs.extend(duration);
export const generateMetadata = getMetadata({
title: "marketing:blog.label",
description: "marketing:blog.description",
canonical: pathsConfig.marketing.blog.index,
});
export default async function BlogPage({
searchParams,
params,
}: {
searchParams: Promise<{ tag?: ContentTag }>;
params: Promise<{ locale: string }>;
}) {
const tag = (await searchParams).tag;
const locale = (await params).locale;
const { t } = await getTranslation({ ns: "marketing" });
const { items } = getContentItems({
collection: CollectionType.BLOG,
tags: tag ? [tag] : [],
sortBy: "publishedAt",
sortOrder: SortOrder.DESCENDING,
status: ContentStatus.PUBLISHED,
locale,
});
return (
<Section>
<SectionHeader className="flex flex-col items-center justify-center gap-3">
<SectionBadge>{t("blog.label")}</SectionBadge>
<SectionTitle as="h1">{t("blog.title")}</SectionTitle>
<SectionDescription>{t("blog.description")}</SectionDescription>
</SectionHeader>
<div className="-mt-2 sm:-mt-4 md:-mt-6 lg:-mt-10">
<TagsPicker />
</div>
<div className="grid grid-cols-1 items-start justify-center gap-x-6 gap-y-8 md:grid-cols-2 md:gap-y-12 lg:grid-cols-3 lg:gap-y-16">
{items.map((post) => (
<TurboLink
key={post.slug}
href={pathsConfig.marketing.blog.post(post.slug)}
className="group h-full basis-[34rem]"
>
<Card className="group-hover:bg-muted/50 h-full border-none shadow-none">
<CardHeader className="space-y-2 p-3 pb-2">
<div className="bg-muted -mx-3 -mt-3 mb-3 aspect-[12/8] overflow-hidden rounded-lg">
<div className="relative h-full w-full transition-transform duration-300 group-hover:scale-105">
<Image
alt=""
fill
src={post.thumbnail}
className="object-cover"
/>
</div>
</div>
<div className="flex flex-wrap gap-1 pb-1">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">
{t(`blog.tag.${tag}`)}
</Badge>
))}
</div>
<CardTitle className="leading-tight">{post.title}</CardTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm">
<time dateTime={post.publishedAt.toISOString()}>
{dayjs(post.publishedAt).format("MMMM D, YYYY")}
</time>
<span>·</span>
<span>
{t("blog.timeToRead", {
time: Math.ceil(
dayjs.duration(post.timeToRead).asMinutes(),
),
})}
</span>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
<p className="text-muted-foreground text-sm leading-relaxed">
{post.description}
</p>
</CardContent>
</Card>
</TurboLink>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,34 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { withI18n } from "@turbostarter/i18n/with-i18n";
import { getMetadata } from "~/lib/metadata";
import { ContactForm } from "~/modules/marketing/contact/contact-form";
import {
Section,
SectionBadge,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
export const generateMetadata = getMetadata({
title: "marketing:contact.label",
description: "marketing:contact.description",
});
const ContactPage = async () => {
const { t } = await getTranslation({ ns: "marketing" });
return (
<Section>
<SectionHeader>
<SectionBadge>{t("contact.label")}</SectionBadge>
<SectionTitle>{t("contact.title")}</SectionTitle>
<SectionDescription>{t("contact.description")}</SectionDescription>
</SectionHeader>
<ContactForm />
</Section>
);
};
export default withI18n(ContactPage);

View File

@@ -0,0 +1,12 @@
import { Footer } from "~/modules/marketing/layout/footer";
import { Header } from "~/modules/marketing/layout/header/header";
export default function MainLayout(props: { children: React.ReactNode }) {
return (
<>
<Header />
<main className="w-full">{props.children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,76 @@
import { notFound } from "next/navigation";
import {
CollectionType,
getContentItemBySlug,
getContentItems,
} from "@turbostarter/cms";
import { getTranslation } from "@turbostarter/i18n/server";
import { getMetadata } from "~/lib/metadata";
import { Mdx } from "~/modules/common/mdx";
import {
Section,
SectionBadge,
SectionDescription,
SectionHeader,
SectionTitle,
} from "~/modules/marketing/layout/section";
interface PageParams {
params: Promise<{
slug: string;
locale: string;
}>;
}
export default async function Page({ params }: PageParams) {
const item = getContentItemBySlug({
collection: CollectionType.LEGAL,
slug: (await params).slug,
locale: (await params).locale,
});
if (!item) {
return notFound();
}
const { t } = await getTranslation({ ns: "common" });
return (
<Section>
<SectionHeader>
<SectionBadge>{t("legal.label")}</SectionBadge>
<SectionTitle as="h1">{item.title}</SectionTitle>
<SectionDescription>{item.description}</SectionDescription>
</SectionHeader>
<Mdx mdx={item.mdx} />
</Section>
);
}
export function generateStaticParams() {
return getContentItems({ collection: CollectionType.LEGAL }).items.map(
({ slug, locale }) => ({
slug,
locale,
}),
);
}
export async function generateMetadata({ params }: PageParams) {
const item = getContentItemBySlug({
collection: CollectionType.LEGAL,
slug: (await params).slug,
locale: (await params).locale,
});
if (!item) {
return notFound();
}
return getMetadata({
title: item.title,
description: item.description,
})({ params });
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
const HomePage = () => {
const { t } = useTranslation("common");
return (
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center px-4">
<div className="mx-auto max-w-3xl text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
{t("home.title", { defaultValue: "Welcome to TurboStarter" })}
</h1>
<p className="mt-6 text-lg leading-8 text-muted-foreground">
{t("home.description", { defaultValue: "The fastest way to build your next SaaS. Authentication, billing, database, and UI components — all pre-configured and ready to go." })}
</p>
<div className="mt-10 flex items-center justify-center gap-x-6">
<TurboLink
href={pathsConfig.auth.login}
className={buttonVariants({ size: "lg" })}
>
{t("home.getStarted", { defaultValue: "Get Started" })}
<Icons.ArrowRight className="ml-2 size-4" />
</TurboLink>
<TurboLink
href="https://turbostarter.dev/docs"
className={buttonVariants({ variant: "outline", size: "lg" })}
target="_blank"
>
{t("home.documentation", { defaultValue: "Documentation" })}
</TurboLink>
</div>
</div>
</main>
);
};
export default HomePage;

View File

@@ -0,0 +1,7 @@
import { PricingSectionSkeleton } from "~/modules/billing/pricing/section";
const PricingLoadingPage = () => {
return <PricingSectionSkeleton />;
};
export default PricingLoadingPage;

View File

@@ -0,0 +1,13 @@
import { getMetadata } from "~/lib/metadata";
import { Pricing } from "~/modules/billing/pricing/pricing";
export const generateMetadata = getMetadata({
title: "billing:pricing.label",
description: "billing:pricing.description",
});
const PricingPage = () => {
return <Pricing />;
};
export default PricingPage;

View File

@@ -0,0 +1,94 @@
import {
parseAsArrayOf,
parseAsInteger,
parseAsString,
parseAsStringEnum,
} from "nuqs/server";
import { createSearchParamsCache } from "nuqs/server";
import { Suspense } from "react";
import { getCustomersResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { BillingStatus, PricingPlanType } from "@turbostarter/billing";
import { getTranslation } from "@turbostarter/i18n/server";
import { pickBy } from "@turbostarter/shared/utils";
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { CustomersDataTable } from "~/modules/admin/customers/data-table/customers-data-table";
import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "admin:customers.header.title",
description: "admin:customers.header.description",
});
const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
sort: getSortingStateParser().withDefault([{ id: "user.name", desc: false }]),
q: parseAsString,
status: parseAsArrayOf(
parseAsStringEnum<BillingStatus>(Object.values(BillingStatus)),
),
plan: parseAsArrayOf(
parseAsStringEnum<PricingPlanType>(Object.values(PricingPlanType)),
),
createdAt: parseAsArrayOf(parseAsInteger),
});
export default async function CustomersPage(props: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { t } = await getTranslation({ ns: "admin" });
const searchParams = await props.searchParams;
const { page, perPage, sort, ...rest } =
searchParamsCache.parse(searchParams);
const filters = pickBy(rest, Boolean);
const promise = handle(api.admin.customers.$get, {
schema: getCustomersResponseSchema,
})({
query: {
...filters,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sort),
},
});
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("customers.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("customers.header.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<Suspense
fallback={
<DataTableSkeleton
columnCount={3}
filterCount={4}
cellWidths={["15rem", "10rem", "10rem"]}
shrinkZero
/>
}
>
<CustomersDataTable promise={promise} perPage={perPage} />
</Suspense>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { redirect } from "next/navigation";
import { hasAdminPermission } from "@turbostarter/auth";
import { Icons } from "@turbostarter/ui-web/icons";
import { SidebarProvider } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { getSession } from "~/lib/auth/server";
import { AdminSidebar } from "~/modules/admin/layout/sidebar";
import { DashboardInset } from "~/modules/common/layout/dashboard/inset";
const menu = [
{
label: "admin",
items: [
{
title: "home",
href: pathsConfig.admin.index,
icon: Icons.Home,
},
{
title: "users",
href: pathsConfig.admin.users.index,
icon: Icons.UsersRound,
},
{
title: "organizations",
href: pathsConfig.admin.organizations.index,
icon: Icons.Building,
},
{
title: "customers",
href: pathsConfig.admin.customers.index,
icon: Icons.HandCoins,
},
],
},
];
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
if (!hasAdminPermission(user)) {
return redirect(pathsConfig.dashboard.user.index);
}
return (
<SidebarProvider>
<AdminSidebar user={user} menu={menu} />
<DashboardInset>{children}</DashboardInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,89 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { notFound } from "next/navigation";
import { getOrganizationResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { getTranslation } from "@turbostarter/i18n/server";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { getQueryClient } from "~/lib/query/server";
import { admin } from "~/modules/admin/lib/api";
import { OrganizationDetails } from "~/modules/admin/organizations/organization/details";
import { OrganizationHeader } from "~/modules/admin/organizations/organization/header";
import { InvitationsDataTable } from "~/modules/admin/organizations/organization/invitations/data-table/invitations-data-table";
import { MembersDataTable } from "~/modules/admin/organizations/organization/members/data-table/members-data-table";
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) => {
const id = (await params).id;
const organization = await handle(api.admin.organizations[":id"].$get, {
schema: getOrganizationResponseSchema,
})({
param: { id },
});
if (!organization) {
return notFound();
}
return getMetadata({
title: organization.name,
})({ params });
};
export default async function OrganizationPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { t } = await getTranslation({ ns: "common" });
const { id } = await params;
const organization = await handle(api.admin.organizations[":id"].$get, {
schema: getOrganizationResponseSchema,
})({
param: { id },
});
if (!organization) {
return notFound();
}
const queryClient = getQueryClient();
queryClient.setQueryData(
admin.queries.organizations.get({ id }).queryKey,
organization,
);
const sections = [
{
label: t("members"),
component: <MembersDataTable organizationId={id} />,
},
{
label: t("invitations"),
component: <InvitationsDataTable organizationId={id} />,
},
];
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrganizationHeader id={id} />
<OrganizationDetails id={id} />
<div className="mt-4 flex w-full flex-col gap-10">
{sections.map(({ label, component }) => (
<section key={label} className="flex w-full flex-col gap-4">
<header>
<h3 className="text-xl font-semibold tracking-tight">{label}</h3>
</header>
{component}
</section>
))}
</div>
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,86 @@
import {
createSearchParamsCache,
parseAsArrayOf,
parseAsInteger,
parseAsString,
} from "nuqs/server";
import { Suspense } from "react";
import { getOrganizationsResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { getTranslation } from "@turbostarter/i18n/server";
import { pickBy } from "@turbostarter/shared/utils";
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { OrganizationsDataTable } from "~/modules/admin/organizations/data-table/organizations-data-table";
import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "admin:organizations.header.title",
description: "admin:organizations.header.description",
});
const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
sort: getSortingStateParser().withDefault([{ id: "name", desc: false }]),
q: parseAsString,
createdAt: parseAsArrayOf(parseAsInteger),
members: parseAsArrayOf(parseAsInteger),
});
export default async function OrganizationsPage(props: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const searchParams = await props.searchParams;
const { page, perPage, sort, ...rest } =
searchParamsCache.parse(searchParams);
const { t } = await getTranslation({ ns: "admin" });
const filters = pickBy(rest, Boolean);
const promise = handle(api.admin.organizations.$get, {
schema: getOrganizationsResponseSchema,
})({
query: {
...filters,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sort),
},
});
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("organizations.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("organizations.header.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<Suspense
fallback={
<DataTableSkeleton
columnCount={3}
filterCount={3}
cellWidths={["15rem", "10rem", "10rem"]}
shrinkZero
/>
}
>
<OrganizationsDataTable promise={promise} perPage={perPage} />
</Suspense>
</>
);
}

View File

@@ -0,0 +1,93 @@
import * as z from "zod";
import { handle } from "@turbostarter/api/utils";
import { getTranslation } from "@turbostarter/i18n/server";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import {
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { TurboLink } from "~/modules/common/turbo-link";
export const generateMetadata = getMetadata({
title: "admin:home.header.title",
description: "admin:home.header.description",
});
export default async function AdminPage() {
const { t, i18n } = await getTranslation({ ns: ["common", "admin"] });
const adminSummarySchema = z.object({
users: z.number(),
organizations: z.number(),
customers: z.number(),
});
const data = await handle(api.admin.summary.$get, {
schema: adminSummarySchema,
})();
const cards = ["users", "organizations", "customers"] as const;
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("admin:home.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("admin:home.header.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<nav className="@container/stats w-full">
<ul className="grid grid-cols-1 gap-4 @lg/stats:grid-cols-2 @2xl/stats:grid-cols-3">
{cards.map((key) => (
<li key={key}>
<TurboLink
href={pathsConfig.admin[key].index}
className={cn(
buttonVariants({ variant: "outline" }),
"text-muted-foreground h-full w-full flex-col items-start justify-between gap-3 p-0",
)}
>
<CardHeader className="w-full">
<div className="flex w-full items-center justify-between gap-3">
<CardTitle className="text-foreground truncate">
{t(`common:${key}`)}
</CardTitle>
<Icons.ChevronRight className="mt-0.5 size-4" />
</div>
<CardDescription className="whitespace-normal">
{t(`home.summary.${key}`)}
</CardDescription>
</CardHeader>
<CardFooter>
<span className="text-foreground font-mono text-4xl font-bold tracking-tight">
{new Intl.NumberFormat(i18n.language).format(data[key])}
</span>
</CardFooter>
</TurboLink>
</li>
))}
</ul>
</nav>
</>
);
}

View File

@@ -0,0 +1,90 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { notFound } from "next/navigation";
import { getTranslation } from "@turbostarter/i18n/server";
import { getUser } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { getQueryClient } from "~/lib/query/server";
import { admin } from "~/modules/admin/lib/api";
import { AccountsDataTable } from "~/modules/admin/users/user/accounts/data-table/accounts-data-table";
import { UserDetails } from "~/modules/admin/users/user/details";
import { UserHeader } from "~/modules/admin/users/user/header";
import { InvitationsDataTable } from "~/modules/admin/users/user/invitations/data-table/invitations-data-table";
import { MembershipsDataTable } from "~/modules/admin/users/user/memberships/data-table/memberships-data-table";
import { PlansDataTable } from "~/modules/admin/users/user/plans/data-table/plans-data-table";
import { SessionsList } from "~/modules/admin/users/user/sessions/sessions-list";
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) => {
const id = (await params).id;
const user = await getUser({ id });
if (!user) {
return notFound();
}
return getMetadata({
title: user.name,
})({ params });
};
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { t } = await getTranslation({ ns: "common" });
const { id } = await params;
const user = await getUser({ id });
if (!user) {
return notFound();
}
const queryClient = getQueryClient();
await queryClient.setQueryData(
admin.queries.users.get({ id }).queryKey,
user,
);
const sections = [
{
label: t("accounts"),
component: <AccountsDataTable id={id} />,
},
{
label: t("plans"),
component: <PlansDataTable userId={id} />,
},
{
label: t("memberships"),
component: <MembershipsDataTable userId={id} />,
},
{
label: t("invitations"),
component: <InvitationsDataTable userId={id} />,
},
];
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserHeader id={id} />
<UserDetails id={id} />
<div className="mt-4 flex w-full flex-col gap-10">
{sections.map(({ label, component }) => (
<section key={label} className="flex w-full flex-col gap-4">
<header>
<h3 className="text-xl font-semibold tracking-tight">{label}</h3>
</header>
{component}
</section>
))}
<SessionsList id={id} />
</div>
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,89 @@
import {
createSearchParamsCache,
parseAsInteger,
parseAsString,
parseAsArrayOf,
parseAsBoolean,
parseAsStringEnum,
} from "nuqs/server";
import { Suspense } from "react";
import { getUsersResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { UserRole } from "@turbostarter/auth";
import { getTranslation } from "@turbostarter/i18n/server";
import { pickBy } from "@turbostarter/shared/utils";
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import { UsersDataTable } from "~/modules/admin/users/data-table/users-data-table";
import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
sort: getSortingStateParser().withDefault([{ id: "name", desc: false }]),
q: parseAsString,
role: parseAsArrayOf(parseAsStringEnum<UserRole>(Object.values(UserRole))),
twoFactorEnabled: parseAsBoolean,
banned: parseAsBoolean,
createdAt: parseAsArrayOf(parseAsInteger),
});
export const generateMetadata = getMetadata({
title: "admin:users.header.title",
description: "admin:users.header.description",
});
export default async function UsersPage(props: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const searchParams = await props.searchParams;
const { page, perPage, sort, ...rest } =
searchParamsCache.parse(searchParams);
const { t } = await getTranslation({ ns: "admin" });
const filters = pickBy(rest, Boolean);
const promise = handle(api.admin.users.$get, {
schema: getUsersResponseSchema,
})({
query: {
...filters,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sort),
},
});
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>{t("users.header.title")}</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("users.header.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<Suspense
fallback={
<DataTableSkeleton
columnCount={6}
filterCount={3}
cellWidths={["15rem", "10rem", "5rem", "3rem", "3rem", "5rem"]}
shrinkZero
/>
}
>
<UsersDataTable promise={promise} perPage={perPage} />
</Suspense>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
const AuthError = async ({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) => {
const { error } = await searchParams;
const { t } = await getTranslation({ ns: ["common", "auth"] });
return (
<div className="flex flex-col items-center justify-center gap-4">
<Icons.CircleX className="text-destructive size-24" strokeWidth={1.2} />
<h1 className="text-center text-2xl font-semibold">
{t("error.general")}
</h1>
{error && (
<code className="bg-muted rounded-md px-2 py-0.5 font-mono">
{error}
</code>
)}
<TurboLink
href={pathsConfig.auth.login}
className="text-muted-foreground hover:text-primary mt-3 text-sm font-medium underline underline-offset-4"
>
{t("goToLogin")}
</TurboLink>
</div>
);
};
export default AuthError;

View File

@@ -0,0 +1,65 @@
import { notFound, redirect } from "next/navigation";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getInvitation, getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { Invitation } from "~/modules/organization/invitations/invitation";
import { InvitationEmailMismatch } from "~/modules/organization/invitations/invitation-email-mismatch";
import { InvitationExpired } from "~/modules/organization/invitations/invitation-expired";
export const generateMetadata = getMetadata({
title: "organization:join.title",
description: "organization:join.description",
});
export default async function JoinPage({
searchParams,
}: {
searchParams: Promise<{ invitationId?: string; email?: string }>;
}) {
const { invitationId, email } = await searchParams;
if (!invitationId) {
return notFound();
}
const { user } = await getSession();
if (!user) {
const searchParams = new URLSearchParams();
searchParams.set("invitationId", invitationId);
if (email) searchParams.set("email", email);
searchParams.set(
"redirectTo",
`${pathsConfig.auth.join}?${searchParams.toString()}`,
);
return redirect(`${pathsConfig.auth.login}?${searchParams.toString()}`);
}
const invitation = await getInvitation({ id: invitationId });
if (invitation) {
const { organization } = await handle(api.organizations[":id"].$get)({
param: {
id: invitation.organizationId,
},
});
if (!organization) {
return notFound();
}
return <Invitation invitation={invitation} organization={organization} />;
}
if (email && user.email !== email) {
return (
<InvitationEmailMismatch invitationId={invitationId} email={email} />
);
}
return <InvitationExpired />;
}

View File

@@ -0,0 +1,35 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const { t } = await getTranslation({ ns: "common" });
return (
<main className="grid h-full w-full flex-1 lg:grid-cols-2">
<section className="flex h-full flex-col items-center justify-center p-6 lg:p-10">
<header className="text-navy -mt-1 mb-auto flex self-start justify-self-start">
<TurboLink
href={pathsConfig.index}
className="flex shrink-0 items-center gap-3"
aria-label={t("home")}
>
<Icons.Logo className="text-primary h-8" />
<Icons.LogoText className="text-foreground h-4" />
</TurboLink>
</header>
<div className="mt-16 mb-auto flex w-full max-w-md flex-col gap-6 pb-16">
{children}
</div>
</section>
<aside className="bg-muted hidden flex-1 lg:block"></aside>
</main>
);
}

View File

@@ -0,0 +1,28 @@
import { getMetadata } from "~/lib/metadata";
import { LoginFlow } from "~/modules/auth/login";
export const generateMetadata = getMetadata({
title: "auth:login.title",
});
const Login = async ({
searchParams,
}: {
searchParams: Promise<{
redirectTo?: string;
invitationId?: string;
email?: string;
}>;
}) => {
const { redirectTo, invitationId, email } = await searchParams;
return (
<LoginFlow
redirectTo={redirectTo}
invitationId={invitationId}
email={email}
/>
);
};
export default Login;

View File

@@ -0,0 +1,24 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { getMetadata } from "~/lib/metadata";
import { ForgotPasswordForm } from "~/modules/auth/form/password/forgot";
import { AuthHeader } from "~/modules/auth/layout/header";
export const generateMetadata = getMetadata({
title: "auth:account.password.forgot.title",
});
const ForgotPassword = async () => {
const { t } = await getTranslation({ ns: "auth" });
return (
<>
<AuthHeader
title={t("account.password.forgot.header.title")}
description={t("account.password.forgot.header.description")}
/>
<ForgotPasswordForm />
</>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,32 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { getMetadata } from "~/lib/metadata";
import { UpdatePasswordForm } from "~/modules/auth/form/password/update";
import { AuthHeader } from "~/modules/auth/layout/header";
export const generateMetadata = getMetadata({
title: "auth:account.password.update.title",
});
interface UpdatePasswordPageProps {
readonly searchParams: Promise<{
readonly token?: string;
}>;
}
const UpdatePassword = async ({ searchParams }: UpdatePasswordPageProps) => {
const token = (await searchParams).token;
const { t } = await getTranslation({ ns: "auth" });
return (
<>
<AuthHeader
title={t("account.password.update.header.title")}
description={t("account.password.update.header.description")}
/>
<UpdatePasswordForm token={token} />
</>
);
};
export default UpdatePassword;

View File

@@ -0,0 +1,50 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { authConfig } from "~/config/auth";
import { getMetadata } from "~/lib/metadata";
import { AnonymousLogin } from "~/modules/auth/form/anonymous";
import { LoginCta } from "~/modules/auth/form/login/form";
import { RegisterForm } from "~/modules/auth/form/register-form";
import { SocialProviders } from "~/modules/auth/form/social-providers";
import { AuthDivider } from "~/modules/auth/layout/divider";
import { AuthHeader } from "~/modules/auth/layout/header";
import { InvitationDisclaimer } from "~/modules/auth/layout/invitation-disclaimer";
export const generateMetadata = getMetadata({
title: "auth:register.title",
});
const Register = async ({
searchParams,
}: {
searchParams: Promise<{
redirectTo?: string;
invitationId?: string;
email?: string;
}>;
}) => {
const { redirectTo, invitationId, email } = await searchParams;
const { t } = await getTranslation({ ns: "auth" });
return (
<>
<AuthHeader
title={t("register.header.title")}
description={t("register.header.description")}
/>
{invitationId && <InvitationDisclaimer />}
<SocialProviders
providers={authConfig.providers.oAuth}
redirectTo={redirectTo}
/>
{authConfig.providers.oAuth.length > 0 && <AuthDivider />}
<div className="flex flex-col gap-2">
<RegisterForm redirectTo={redirectTo} email={email} />
{authConfig.providers.anonymous && <AnonymousLogin />}
</div>
<LoginCta />
</>
);
};
export default Register;

View File

@@ -0,0 +1,69 @@
import { redirect } from "next/navigation";
import { Icons } from "@turbostarter/ui-web/icons";
import { SidebarProvider } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { getSession } from "~/lib/auth/server";
import { DashboardInset } from "~/modules/common/layout/dashboard/inset";
import { DashboardSidebar } from "~/modules/common/layout/dashboard/sidebar";
/**
* Dashboard sidebar menu configuration.
*/
const menu = [
{
label: "platform",
items: [
{
title: "dashboard",
href: pathsConfig.dashboard.user.index,
icon: Icons.Home,
},
{
title: "aiTools",
href: pathsConfig.apps.chat.index,
icon: Icons.Sparkles,
},
],
},
{
label: "manage",
items: [
{
title: "settings",
href: pathsConfig.dashboard.user.settings.index,
icon: Icons.Settings,
},
],
},
{
label: "dev",
items: [
{
title: "demos",
href: pathsConfig.demo.index,
icon: Icons.LayoutDashboard,
},
],
},
];
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
return (
<SidebarProvider>
<DashboardSidebar user={user} menu={menu} />
<DashboardInset>{children}</DashboardInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Icons } from "@turbostarter/ui-web/icons";
/**
* Dashboard Home Page
*
* Welcome page for authenticated users.
*/
export default function DashboardPage() {
const { t } = useTranslation("dashboard");
return (
<div className="@container h-full p-6">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("welcome.title", { defaultValue: "Welcome to your Dashboard" })}
</h1>
<p className="text-muted-foreground">
{t("welcome.description", { defaultValue: "Get started by exploring the features below." })}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.aiChat.title", { defaultValue: "AI Chat" })}</CardTitle>
<Icons.MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.aiChat.description", { defaultValue: "Have a conversation with AI assistants" })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.imageGeneration.title", { defaultValue: "Image Generation" })}</CardTitle>
<Icons.Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.imageGeneration.description", { defaultValue: "Create images with AI" })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t("features.pdfAnalysis.title", { defaultValue: "PDF Analysis" })}</CardTitle>
<Icons.FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t("features.pdfAnalysis.description", { defaultValue: "Upload and analyze PDF documents" })}
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { pathsConfig } from "~/config/paths";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { ManagePlan } from "~/modules/user/settings/billing/manage-plan";
import { PlanSummary } from "~/modules/user/settings/billing/plan-summary";
export const generateMetadata = getMetadata({
title: "billing",
description: "billing:manage.description",
});
export default async function BillingPage() {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<PlanSummary />
<ManagePlan />
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { pathsConfig } from "~/config/paths";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { SettingsNav } from "~/modules/user/settings/layout/nav";
const LINKS = [
{
label: "general",
href: pathsConfig.dashboard.user.settings.index,
},
{
label: "security",
href: pathsConfig.dashboard.user.settings.security,
},
{
label: "billing",
href: pathsConfig.dashboard.user.settings.billing,
},
] as const;
export default async function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const { t } = await getTranslation({ ns: ["common", "auth"] });
return (
<div className="@container h-full overflow-auto p-6">
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("account.settings.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("account.settings.header.description")}
</DashboardHeaderDescription>
</div>
<div className="lg:hidden">
<SettingsNav
links={LINKS.map((link) => ({
...link,
label: t(link.label),
}))}
/>
</div>
</DashboardHeader>
<div className="flex w-full gap-3">
<div className="hidden w-96 lg:block">
<div className="sticky top-[calc(var(--banner-height)+theme(spacing.6))]">
<SettingsNav
links={LINKS.map((link) => ({
...link,
label: t(link.label),
}))}
/>
</div>
</div>
<div className="flex w-full flex-col gap-6">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { pathsConfig } from "~/config/paths";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { DeleteAccount } from "~/modules/user/settings/general/delete-account";
import { EditAvatar } from "~/modules/user/settings/general/edit-avatar";
import { EditEmail } from "~/modules/user/settings/general/edit-email";
import { EditName } from "~/modules/user/settings/general/edit-name";
import { LanguageSwitcher } from "~/modules/user/settings/general/language-switcher";
export const generateMetadata = getMetadata({
title: "auth:account.settings.title",
description: "auth:account.settings.header.description",
});
export default async function SettingsPage() {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<EditAvatar user={user} />
<LanguageSwitcher />
<EditName user={user} />
<EditEmail user={user} />
<DeleteAccount />
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { authConfig } from "~/config/auth";
import { getMetadata } from "~/lib/metadata";
import { Accounts } from "~/modules/user/settings/security/accounts";
import { EditPassword } from "~/modules/user/settings/security/edit-password";
import { Passkeys } from "~/modules/user/settings/security/passkeys";
import { Sessions } from "~/modules/user/settings/security/sessions";
import { TwoFactorAuthentication } from "~/modules/user/settings/security/two-factor/two-factor";
export const generateMetadata = getMetadata({
title: "auth:account.settings.security.title",
description: "auth:account.settings.security.description",
});
export default function SettingsPage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<EditPassword />
<Accounts />
{authConfig.providers.passkey && <Passkeys />}
<TwoFactorAuthentication />
<Sessions />
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { Icons } from "@turbostarter/ui-web/icons";
import { SidebarProvider } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { getOrganization, getSession } from "~/lib/auth/server";
import { getQueryClient } from "~/lib/query/server";
import { DashboardInset } from "~/modules/common/layout/dashboard/inset";
import { DashboardSidebar } from "~/modules/common/layout/dashboard/sidebar";
import { organization } from "~/modules/organization/lib/api";
const menu = (organization: string) => [
{
label: "platform",
items: [
{
title: "home",
href: pathsConfig.dashboard.organization(organization).index,
icon: Icons.Home,
},
],
},
{
label: "organization",
items: [
{
title: "settings",
href: pathsConfig.dashboard.organization(organization).settings.index,
icon: Icons.Settings,
},
{
title: "members",
href: pathsConfig.dashboard.organization(organization).members,
icon: Icons.UsersRound,
},
],
},
];
export default async function DashboardLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{
organization: string;
}>;
}) {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
const organizationSlug = (await params).organization;
const activeOrganization = await getOrganization({ slug: organizationSlug });
if (!activeOrganization) {
return redirect(pathsConfig.dashboard.user.index);
}
const queryClient = getQueryClient();
queryClient.setQueryData(
organization.queries.get({ slug: organizationSlug }).queryKey,
activeOrganization,
);
queryClient.setQueryData(
organization.queries.get({ id: activeOrganization.id }).queryKey,
activeOrganization,
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<SidebarProvider>
<DashboardSidebar user={user} menu={menu(organizationSlug)} />
<DashboardInset>{children}</DashboardInset>
</SidebarProvider>
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,74 @@
import { notFound } from "next/navigation";
import { getTranslation } from "@turbostarter/i18n/server";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@turbostarter/ui-web/tabs";
import { getOrganization } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { InvitationsDataTable } from "~/modules/organization/invitations/data-table/invitations-data-table";
import { MembersDataTable } from "~/modules/organization/members/data-table/members-data-table";
import { InviteMember } from "~/modules/organization/members/invite-member";
export const generateMetadata = getMetadata({
title: "organization:members.title",
description: "organization:members.header.description",
});
export default async function MembersPage({
params,
}: {
params: Promise<{ organization: string }>;
}) {
const { organization } = await params;
const activeOrganization = await getOrganization({ slug: organization });
if (!activeOrganization) {
return notFound();
}
const { t } = await getTranslation({ ns: "organization" });
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("members.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("members.header.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<InviteMember organizationId={activeOrganization.id} />
<Tabs defaultValue="members" className="mt-4 w-full">
<TabsList>
<TabsTrigger value="members">{t("members.title")}</TabsTrigger>
<TabsTrigger value="invitations">
{t("invitations.title")}
</TabsTrigger>
</TabsList>
<TabsContent value="members">
<MembersDataTable organizationId={activeOrganization.id} />
</TabsContent>
<TabsContent value="invitations">
<InvitationsDataTable organizationId={activeOrganization.id} />
</TabsContent>
</Tabs>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderTitle,
DashboardHeaderDescription,
} from "~/modules/common/layout/dashboard/header";
import { AreaChart } from "~/modules/organization/home/charts/area";
import { BarChart } from "~/modules/organization/home/charts/bar";
import { LineChart } from "~/modules/organization/home/charts/line";
import { PieChart } from "~/modules/organization/home/charts/pie";
import { RadarChart } from "~/modules/organization/home/charts/radar";
import { RadialChart } from "~/modules/organization/home/charts/radial";
import { ShapeChart } from "~/modules/organization/home/charts/shape";
export const generateMetadata = getMetadata({
title: "common:home",
description: "dashboard:organization.home.description",
});
export default async function OrganizationPage() {
const { t } = await getTranslation({ ns: "dashboard" });
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("organization.home.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("organization.home.description")}
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<BarChart />
<PieChart />
<ShapeChart />
</div>
<AreaChart />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<RadialChart />
<RadarChart />
</div>
<LineChart />
</div>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { pathsConfig } from "~/config/paths";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { SettingsNav } from "~/modules/user/settings/layout/nav";
const LINKS = (organization: string) =>
[
{
label: "general",
href: pathsConfig.dashboard.organization(organization).settings.index,
},
] as const;
export default async function SettingsLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{
organization: string;
}>;
}) {
const { t } = await getTranslation({ ns: ["common", "organization"] });
const organization = (await params).organization;
return (
<div className="@container h-full overflow-auto p-6">
<DashboardHeader>
<div>
<DashboardHeaderTitle>
{t("settings.header.title")}
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{t("settings.header.description")}
</DashboardHeaderDescription>
</div>
<div className="lg:hidden">
<SettingsNav
links={LINKS(organization).map((link) => ({
...link,
label: t(link.label),
}))}
/>
</div>
</DashboardHeader>
<div className="flex w-full gap-3">
<div className="hidden w-96 lg:block">
<div className="sticky top-[calc(var(--banner-height)+theme(spacing.6))]">
<SettingsNav
links={LINKS(organization).map((link) => ({
...link,
label: t(link.label),
}))}
/>
</div>
</div>
<div className="flex w-full flex-col gap-6">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { notFound } from "next/navigation";
import { getOrganization } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { DeleteOrganization } from "~/modules/organization/settings/delete-organization";
import { EditLogo } from "~/modules/organization/settings/edit-logo";
import { EditName } from "~/modules/organization/settings/edit-name";
import { LeaveOrganization } from "~/modules/organization/settings/leave-organization";
export const generateMetadata = getMetadata({
title: "organization:settings.title",
description: "organization:settings.header.description",
});
export default async function SettingsPage({
params,
}: {
params: Promise<{ organization: string }>;
}) {
const { organization } = await params;
const activeOrganization = await getOrganization({ slug: organization });
if (!activeOrganization) {
return notFound();
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<EditLogo organizationId={activeOrganization.id} />
<EditName organizationId={activeOrganization.id} />
<LeaveOrganization organizationId={activeOrganization.id} />
<DeleteOrganization organizationId={activeOrganization.id} />
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { handle } from "@turbostarter/api/utils";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getSession } from "~/lib/auth/server";
import { getQueryClient } from "~/lib/query/server";
import { billing } from "~/modules/billing/lib/api";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
...billing.queries.customer.get,
queryFn: handle(api.billing.customer.$get),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { captureException } from "@turbostarter/monitoring-web";
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const { t } = useTranslation("common");
useEffect(() => {
captureException(error);
}, [error]);
return (
<main className="mx-auto flex max-w-xl flex-1 flex-col items-center justify-center gap-8 px-6">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-center text-4xl font-bold">{t("error.general")}</h1>
<p className="text-muted-foreground max-w-md text-center">
{error.message || t("error.apologies")}
</p>
{error.message && (
<code className="bg-muted text-muted-foreground inline-block rounded-sm px-1.5 py-0.5 text-center text-sm leading-tight text-pretty">
{error.digest}
</code>
)}
</div>
<div className="flex items-center justify-center gap-4">
<Button onClick={reset}>{t("tryAgain")}</Button>
<TurboLink
href={pathsConfig.index}
className={buttonVariants({ variant: "outline" })}
>
{t("goBackHome")}
</TurboLink>
</div>
</main>
);
}

View File

@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { config, isLocaleSupported } from "@turbostarter/i18n";
import { getMetadata } from "~/lib/metadata";
import { Providers } from "~/lib/providers/providers";
import { ImpersonatingBanner } from "~/modules/admin/users/user/impersonating-banner";
import { BaseLayout } from "~/modules/common/layout/base";
import { Toaster } from "~/modules/common/toast";
import { BuyCtaDialog } from "~/modules/marketing/layout/buy-cta-dialog";
export function generateStaticParams() {
return config.locales.map((locale) => ({ locale }));
}
export const generateMetadata = getMetadata();
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const locale = (await params).locale;
if (!isLocaleSupported(locale)) {
return notFound();
}
return (
<BaseLayout locale={locale}>
<Providers locale={locale}>
<ImpersonatingBanner />
{children}
<BuyCtaDialog />
<Toaster />
</Providers>
</BaseLayout>
);
}

View File

@@ -0,0 +1,14 @@
import { handle } from "hono/vercel";
import { appRouter } from "@turbostarter/api";
const handler = handle(appRouter);
export {
handler as GET,
handler as POST,
handler as OPTIONS,
handler as PUT,
handler as PATCH,
handler as DELETE,
handler as HEAD,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,35 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import { useEffect } from "react";
import { captureException } from "@turbostarter/monitoring-web";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
captureException(error);
}, [error]);
return (
<html>
<body>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
{(error.digest ?? error.stack) && (
<details>
<summary>More details</summary>
{error.digest && <p>{error.digest}</p>}
{error.stack && <pre>{error.stack}</pre>}
</details>
)}
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,15 @@
import "~/assets/styles/globals.css";
import { DEFAULT_VIEWPORT, DEFAULT_METADATA } from "~/lib/metadata";
export const viewport = DEFAULT_VIEWPORT;
export const metadata = DEFAULT_METADATA;
// Since we have a `not-found.tsx` page on the root, a layout file
// is required, even if it's just passing children through.
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -0,0 +1,33 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { BaseLayout } from "~/modules/common/layout/base";
import { TurboLink } from "~/modules/common/turbo-link";
export default async function NotFound() {
const { t } = await getTranslation({ ns: "common" });
return (
<BaseLayout locale={appConfig.locale}>
<main className="mx-auto flex max-w-xl flex-1 flex-col items-center justify-center gap-8">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-center text-4xl font-bold">
{t("error.notFound")}
</h1>
<p className="text-muted-foreground max-w-md text-center">
{t("error.resourceDoesNotExist")}
</p>
</div>
<TurboLink
href={pathsConfig.index}
className={buttonVariants({ variant: "outline" })}
>
{t("goBackHome")}
</TurboLink>
</main>
</BaseLayout>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,14 @@
import { appConfig } from "~/config/app";
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api", "/dashboard", "/auth"],
},
sitemap: `${appConfig.url}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,80 @@
import { CollectionType, getContentItems } from "@turbostarter/cms";
import { getPathname, config } from "@turbostarter/i18n";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import type { MetadataRoute } from "next";
const url = (path: string) => `${appConfig.url}${path}`;
const getEntry = (path: string) => ({
url: url(
getPathname({
path,
locale: appConfig.locale,
defaultLocale: appConfig.locale,
}),
),
alternates: {
languages: Object.fromEntries(
config.locales.map((locale) => [
locale,
url(
getPathname({
path,
locale,
defaultLocale: appConfig.locale,
}),
),
]),
),
},
});
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
...getEntry(pathsConfig.index),
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
{
...getEntry(pathsConfig.marketing.pricing),
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
...getEntry(pathsConfig.marketing.contact),
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
...getEntry(pathsConfig.marketing.blog.index),
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
...getContentItems({
collection: CollectionType.BLOG,
locale: appConfig.locale,
}).items.map<MetadataRoute.Sitemap[number]>((post) => ({
...getEntry(pathsConfig.marketing.blog.post(post.slug)),
lastModified: new Date(post.lastModifiedAt),
changeFrequency: "monthly",
priority: 0.7,
})),
...getContentItems({
collection: CollectionType.LEGAL,
locale: appConfig.locale,
}).items.map<MetadataRoute.Sitemap[number]>((post) => ({
...getEntry(pathsConfig.marketing.legal(post.slug)),
lastModified: new Date(post.lastModifiedAt),
changeFrequency: "yearly",
priority: 0.5,
})),
];
}