feat: turbostarter boilerplate

Production-ready Next.js boilerplate with:
- Runtime env validation (fail-fast on missing vars)
- Feature-gated config (S3, Stripe, email, OAuth)
- Docker + Coolify deployment pipeline
- PostgreSQL + pgvector, MinIO S3, Better Auth
- TypeScript strict mode (no ignoreBuildErrors)
- i18n (en/es), AI modules, billing, monitoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-02 17:29:12 +00:00
commit 3527e732d4
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,
})),
];
}

View File

@@ -0,0 +1,4 @@
@import "@turbostarter/ui/globals.css";
@import "@turbostarter/ui-web/globals.css";
@source "../../../../../packages/ui";

View File

@@ -0,0 +1,11 @@
import env from "env.config";
export const appConfig = {
name: env.NEXT_PUBLIC_PRODUCT_NAME,
url: env.NEXT_PUBLIC_URL,
locale: env.NEXT_PUBLIC_DEFAULT_LOCALE,
theme: {
mode: env.NEXT_PUBLIC_THEME_MODE,
color: env.NEXT_PUBLIC_THEME_COLOR,
},
} as const;

View File

@@ -0,0 +1,23 @@
import env from "env.config";
import { SocialProvider, authConfigSchema } from "@turbostarter/auth";
import type { AuthConfig } from "@turbostarter/auth";
/** Coerce env value to boolean (handles both parsed booleans and raw strings) */
const toBool = (val: unknown, fallback: boolean): boolean => {
if (typeof val === "boolean") return val;
if (val === "true" || val === "1") return true;
if (val === "false" || val === "0") return false;
return fallback;
};
export const authConfig = authConfigSchema.parse({
providers: {
password: toBool(env.NEXT_PUBLIC_AUTH_PASSWORD, true),
magicLink: toBool(env.NEXT_PUBLIC_AUTH_MAGIC_LINK, false),
passkey: toBool(env.NEXT_PUBLIC_AUTH_PASSKEY, true),
anonymous: toBool(env.NEXT_PUBLIC_AUTH_ANONYMOUS, true),
oAuth: [SocialProvider.APPLE, SocialProvider.GOOGLE, SocialProvider.GITHUB],
},
}) satisfies AuthConfig;

View File

@@ -0,0 +1,104 @@
const ADMIN_PREFIX = "/admin";
const AUTH_PREFIX = "/auth";
const BLOG_PREFIX = "/blog";
const DASHBOARD_PREFIX = "/dashboard";
const LEGAL_PREFIX = "/legal";
const API_PREFIX = "/api";
// AI apps routes (no prefix - top-level routes)
const APPS_CHAT = "/chat";
const APPS_IMAGE = "/image";
const APPS_TTS = "/tts";
const APPS_PDF = "/pdf";
const APPS_AGENT = "/agent";
const DEMO_PREFIX = "/demo";
const pathsConfig = {
index: "/",
demo: {
index: DEMO_PREFIX,
scrollTest: `${DEMO_PREFIX}/scroll-test`,
},
apps: {
chat: {
index: APPS_CHAT,
chat: (id: string) => `${APPS_CHAT}/${id}`,
},
image: {
index: APPS_IMAGE,
history: `${APPS_IMAGE}/history`,
detail: (id: string) => `${APPS_IMAGE}/${id}`,
generation: (id: string) => `${APPS_IMAGE}/generation/${id}`,
},
tts: APPS_TTS,
pdf: {
index: APPS_PDF,
detail: (id: string) => `${APPS_PDF}/${id}`,
chat: (id: string) => `${APPS_PDF}/${id}`,
},
agent: APPS_AGENT,
},
admin: {
index: ADMIN_PREFIX,
users: {
index: `${ADMIN_PREFIX}/users`,
user: (id: string) => `${ADMIN_PREFIX}/users/${id}`,
},
organizations: {
index: `${ADMIN_PREFIX}/organizations`,
organization: (slug: string) => `${ADMIN_PREFIX}/organizations/${slug}`,
},
customers: {
index: `${ADMIN_PREFIX}/customers`,
customer: (id: string) => `${ADMIN_PREFIX}/customers/${id}`,
},
},
marketing: {
pricing: "/pricing",
contact: "/contact",
blog: {
index: BLOG_PREFIX,
post: (slug: string) => `${BLOG_PREFIX}/${slug}`,
},
legal: (slug: string) => `${LEGAL_PREFIX}/${slug}`,
},
auth: {
login: `${AUTH_PREFIX}/login`,
register: `${AUTH_PREFIX}/register`,
join: `${AUTH_PREFIX}/join`,
forgotPassword: `${AUTH_PREFIX}/password/forgot`,
updatePassword: `${AUTH_PREFIX}/password/update`,
error: `${AUTH_PREFIX}/error`,
},
dashboard: {
user: {
index: DASHBOARD_PREFIX,
ai: `${DASHBOARD_PREFIX}/ai`,
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
security: `${DASHBOARD_PREFIX}/settings/security`,
billing: `${DASHBOARD_PREFIX}/settings/billing`,
},
},
organization: (slug: string) => ({
index: `${DASHBOARD_PREFIX}/${slug}`,
settings: {
index: `${DASHBOARD_PREFIX}/${slug}/settings`,
},
members: `${DASHBOARD_PREFIX}/${slug}/members`,
}),
},
} as const;
export {
pathsConfig,
DASHBOARD_PREFIX,
ADMIN_PREFIX,
BLOG_PREFIX,
AUTH_PREFIX,
API_PREFIX,
LEGAL_PREFIX,
};

View File

@@ -0,0 +1,14 @@
import { hc } from "hono/client";
import { getBaseUrl } from "./utils";
import type { AppRouter } from "@turbostarter/api";
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: {
"x-client-platform": "web-client",
},
init: {
credentials: "include",
},
});

View File

@@ -0,0 +1,13 @@
import { hc } from "hono/client";
import { headers } from "next/headers";
import { getBaseUrl } from "./utils";
import type { AppRouter } from "@turbostarter/api";
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: async () => ({
...Object.fromEntries((await headers()).entries()),
"x-client-platform": "web-server",
}),
});

View File

@@ -0,0 +1,9 @@
import env from "env.config";
export const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin;
if (env.NEXT_PUBLIC_URL) return env.NEXT_PUBLIC_URL;
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
// eslint-disable-next-line no-restricted-properties, turbo/no-undeclared-env-vars
return `http://localhost:${process.env.PORT ?? 3000}`;
};

View File

@@ -0,0 +1,10 @@
import { createClient } from "@turbostarter/auth/client/web";
export const authClient = createClient({
fetchOptions: {
headers: {
"x-client-platform": "web-client",
},
throw: true,
},
});

View File

@@ -0,0 +1,63 @@
import { headers } from "next/headers";
import { cache } from "react";
import { auth } from "@turbostarter/auth/server";
import { logger } from "@turbostarter/shared/logger";
const getHeaders = async () => {
const newHeaders = new Headers(await headers());
newHeaders.set("x-client-platform", "web-server");
return newHeaders;
};
export const getSession = cache(async () => {
const data = await auth.api.getSession({
headers: await getHeaders(),
});
return {
session: data?.session ?? null,
user: data?.user ?? null,
};
});
export const getOrganization = cache(
async ({ id, slug }: { slug?: string; id?: string }) => {
try {
return await auth.api.getFullOrganization({
query: {
organizationId: id,
organizationSlug: slug,
},
headers: await getHeaders(),
});
} catch (error) {
logger.error(error);
return null;
}
},
);
export const getInvitation = cache(async ({ id }: { id: string }) => {
try {
return await auth.api.getInvitation({
query: {
id,
},
headers: await getHeaders(),
});
} catch {
return null;
}
});
export const getUser = cache(async ({ id }: { id: string }) => {
try {
return await auth.api.getUser({
query: { id },
headers: await getHeaders(),
});
} catch {
return null;
}
});

View File

@@ -0,0 +1,35 @@
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
/**
* Main application navigation items for the sidebar.
* Each app has a title (i18n key), href, and icon.
*/
export const APPS = [
{
title: "chat",
href: pathsConfig.apps.chat.index,
icon: Icons.MessagesSquare,
},
{
title: "image",
href: pathsConfig.apps.image.index,
icon: Icons.Image,
},
{
title: "tts",
href: pathsConfig.apps.tts,
icon: Icons.AudioLines,
},
{
title: "pdf",
href: pathsConfig.apps.pdf.index,
icon: Icons.FileText,
},
{
title: "agent",
href: pathsConfig.apps.agent,
icon: Icons.Sparkles,
},
] as const;

View File

@@ -0,0 +1,116 @@
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { appConfig } from "~/config/app";
import type { TranslationKey } from "@turbostarter/i18n";
import type { Metadata, Viewport } from "next";
type OpenGraphType =
| "article"
| "book"
| "music.song"
| "music.album"
| "music.playlist"
| "music.radio_station"
| "profile"
| "website"
| "video.tv_show"
| "video.other"
| "video.movie"
| "video.episode";
interface SeoProps {
readonly title?: TranslationKey | (string & Record<never, never>);
readonly description?: TranslationKey | (string & Record<never, never>);
readonly image?: string;
readonly url?: string;
readonly canonical?: string;
readonly type?: OpenGraphType;
readonly images?: {
url: string;
width: number;
height: number;
alt?: string;
}[];
}
const SITE_NAME_SEPARATOR = " | ";
export const SITE_NAME_TEMPLATE = `%s${SITE_NAME_SEPARATOR}${appConfig.name}`;
const DEFAULT_IMAGE = {
url: `${appConfig.url}/opengraph-image.png`,
width: 1200,
height: 630,
alt: appConfig.name,
};
export const getMetadata =
(
{
title,
description = "common:product.description",
url,
canonical,
images = [DEFAULT_IMAGE],
type = "website",
} = {} as SeoProps,
) =>
async ({
params,
}: {
params?: Promise<{ locale: string }>;
}): Promise<Metadata> => {
const { t, i18n } = await getTranslation({
locale: (await params)?.locale,
});
const common = {
...(title && {
title: isKey(title, i18n) ? (t(title) as string) : title,
}),
description: isKey(description, i18n)
? (t(description) as string)
: description,
};
return {
...common,
openGraph: {
...common,
url: url ?? canonical ?? appConfig.url,
siteName: appConfig.name,
type,
images,
},
...{
...(canonical && {
alternates: {
canonical,
},
}),
},
twitter: {
card: "summary_large_image" as const,
images,
},
};
};
export const DEFAULT_METADATA: Metadata = {
...(await getMetadata()({
params: Promise.resolve({ locale: appConfig.locale }),
})),
title: {
template: SITE_NAME_TEMPLATE,
default: appConfig.name,
},
metadataBase: appConfig.url ? new URL(appConfig.url) : null,
};
export const DEFAULT_VIEWPORT: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#030712" },
],
};

View File

@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { identify, Provider, reset } from "@turbostarter/analytics-web";
import { authClient } from "~/lib/auth/client";
export const AnalyticsProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
if (session.data?.user) {
const { id, email, name } = session.data.user;
identify(id, { email, name });
} else {
reset();
}
}, [session]);
return <Provider>{children}</Provider>;
};

View File

@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { identify } from "@turbostarter/monitoring-web";
import { authClient } from "~/lib/auth/client";
export const MonitoringProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
identify(session.data?.user ?? null);
}, [session]);
return <>{children}</>;
};

View File

@@ -0,0 +1,34 @@
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { memo } from "react";
import { I18nProvider } from "@turbostarter/i18n";
import { appConfig } from "~/config/app";
import { QueryClientProvider } from "~/lib/query/client";
import { AnalyticsProvider } from "./analytics";
import { MonitoringProvider } from "./monitoring";
import { ThemeProvider } from "./theme";
interface ProvidersProps {
readonly children: React.ReactNode;
readonly locale: string;
}
export const Providers = memo<ProvidersProps>(({ children, locale }) => {
return (
<I18nProvider locale={locale} defaultLocale={appConfig.locale}>
<QueryClientProvider>
<NuqsAdapter>
<AnalyticsProvider>
<MonitoringProvider>
<ThemeProvider>{children}</ThemeProvider>
</MonitoringProvider>
</AnalyticsProvider>
</NuqsAdapter>
</QueryClientProvider>
</I18nProvider>
);
});
Providers.displayName = "Providers";

View File

@@ -0,0 +1,50 @@
"use client";
import { ThemeProvider as NextThemeProvider } from "next-themes";
import { memo, useEffect } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { appConfig } from "~/config/app";
import type { ThemeConfig } from "@turbostarter/ui";
export const useThemeConfig = create<{
config: Omit<ThemeConfig, "mode">;
setConfig: (config: Omit<ThemeConfig, "mode">) => void;
}>()(
persist(
(set) => ({
config: appConfig.theme,
setConfig: (config) => set({ config }),
}),
{
name: "theme-config",
},
),
);
interface ThemeProviderProps {
readonly children: React.ReactNode;
}
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
const config = useThemeConfig((s) => s.config);
useEffect(() => {
document.body.dataset.theme = config.color;
}, [config.color]);
return (
<NextThemeProvider
attribute="class"
defaultTheme={appConfig.theme.mode}
enableSystem
disableTransitionOnChange
>
{children}
</NextThemeProvider>
);
});
ThemeProvider.displayName = "ThemeProvider";

View File

@@ -0,0 +1,30 @@
"use client";
import { QueryClientProvider as TanstackQueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./utils";
import type { QueryClient } from "@tanstack/react-query";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
export const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};
export function QueryClientProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<TanstackQueryClientProvider client={queryClient}>
{props.children}
<ReactQueryDevtools />
</TanstackQueryClientProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { cache } from "react";
import { createQueryClient } from "./utils";
export const getQueryClient = cache(createQueryClient);

View File

@@ -0,0 +1,33 @@
import {
QueryClient,
defaultShouldDehydrateQuery,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { logger } from "@turbostarter/shared/logger";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
mutations: {
onError: (error: Error | { error: Error }) => {
if ("error" in error) {
error = error.error;
}
logger.error(error);
toast.error(error.message);
},
},
},
});

View File

@@ -0,0 +1,218 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@turbostarter/ui-web/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@turbostarter/ui-web/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { Textarea } from "@turbostarter/ui-web/textarea";
import { api } from "~/lib/api/client";
const formSchema = z.object({
action: z.enum(["set", "add", "deduct"]),
amount: z.coerce.number().int().positive("Amount must be positive"),
reason: z.string().max(500).optional(),
});
interface CreditsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
customer: {
id: string;
credits: number;
user?: { name: string | null } | null;
};
}
export function CreditsDialog({
open,
onOpenChange,
customer,
}: CreditsDialogProps) {
const queryClient = useQueryClient();
const form = useForm({
resolver: standardSchemaResolver(formSchema),
defaultValues: {
action: "add" as const,
amount: 100,
reason: "",
},
});
const mutation = useMutation({
mutationFn: async (data: z.output<typeof formSchema>) => {
const res = await api.admin.customers[":id"].credits.$patch({
param: { id: customer.id },
json: data,
});
if (!res.ok) throw new Error("Failed to update credits");
return res.json();
},
onSuccess: (result) => {
toast.success(
`Credits updated: ${result.previousBalance}${result.newBalance}`,
);
void queryClient.invalidateQueries({ queryKey: ["admin", "customers"] });
onOpenChange(false);
form.reset();
},
onError: () => {
toast.error("Failed to update credits");
},
});
const watchAction = form.watch("action");
const watchAmount = form.watch("amount");
const previewBalance = () => {
const amount = Number(watchAmount) || 0;
switch (watchAction) {
case "set":
return amount;
case "add":
return customer.credits + amount;
case "deduct":
return Math.max(0, customer.credits - amount);
}
};
const handleSubmit = (values: z.output<typeof formSchema>) => {
mutation.mutate(values);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage Credits</DialogTitle>
<DialogDescription>
{customer.user?.name ?? "Customer"} - Current balance:{" "}
<strong>{customer.credits}</strong>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(handleSubmit)(e);
}}
>
<div className="space-y-4 py-4">
<FormField
control={form.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>Action</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="add">Add credits</SelectItem>
<SelectItem value="deduct">Deduct credits</SelectItem>
<SelectItem value="set">Set to exact amount</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<Input
type="number"
min={1}
placeholder="100"
{...field}
value={field.value as number}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Support credit, promo, etc."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="rounded-md bg-muted p-3 text-sm">
<span className="text-muted-foreground">New balance: </span>
<strong>{previewBalance()}</strong>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Updating..." : "Update Credits"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,290 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { BillingStatus, config } from "@turbostarter/billing";
import { isKey, useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { admin } from "~/modules/admin/lib/api";
import { TurboLink } from "~/modules/common/turbo-link";
import { CreditsDialog } from "../components/credits-dialog";
import { invalidateCustomers } from "../server/invalidate";
import { UpdateCustomerPlanModal } from "../update-customer-plan";
import type { ColumnDef } from "@tanstack/react-table";
import type { User } from "@turbostarter/auth";
import type { Customer } from "@turbostarter/billing";
export const CustomerActions = ({
customer,
}: {
customer: Customer & { user: Pick<User, "name">; credits: number };
}) => {
const { t } = useTranslation(["common", "admin", "billing"]);
const queryClient = useQueryClient();
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const deleteCustomer = useMutation({
...admin.mutations.customers.delete,
onSuccess: async () => {
await invalidateCustomers();
await queryClient.invalidateQueries(
admin.queries.users.getPlans({
id: customer.userId,
}),
);
toast.success(t("customers.customer.delete.success"));
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<UpdateCustomerPlanModal
customer={customer}
key={`update-plan-${customer.id}`}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("updatePlan")}
</DropdownMenuItem>
</UpdateCustomerPlanModal>
<DropdownMenuItem onSelect={() => setCreditsDialogOpen(true)}>
<Icons.HandCoins className="mr-2 size-4" />
{t("manageCredits", { defaultValue: "Manage Credits" })}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(() => {
const isPending =
deleteCustomer.isPending &&
deleteCustomer.variables.id === customer.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
deleteCustomer.mutate({
id: customer.id,
})
}
disabled={isPending}
key={`remove-${customer.id}`}
>
{t("delete")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
<CreditsDialog
open={creditsDialogOpen}
onOpenChange={setCreditsDialogOpen}
customer={customer}
/>
</>
);
};
export const useColumns = (): ColumnDef<
Customer & { user: Pick<User, "name" | "image">; credits: number }
>[] => {
const { t, i18n } = useTranslation(["common", "billing"]);
const { data: session } = authClient.useSession();
return [
{
id: "q",
accessorKey: "q",
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableHiding: false,
enableColumnFilter: true,
},
{
id: "user.name",
accessorKey: "user.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.users.user(row.original.userId)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.user.image ?? undefined}
alt={row.original.user.name}
/>
<AvatarFallback>
<Icons.UserRound className="w-5" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.user.name}
</span>
{row.original.userId === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "customerId",
accessorKey: "customerId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("id")} />
),
meta: {
label: t("id"),
},
},
{
id: "plan",
accessorKey: "plan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:plan")} />
),
cell: ({ row }) => {
const plan = config.plans.find((plan) => plan.id === row.original.plan);
if (!plan) {
return <span>-</span>;
}
return (
<Badge variant="outline">
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
</Badge>
);
},
meta: {
label: t("common:plan"),
variant: "multiSelect",
options: Object.values(config.plans).map((plan) => ({
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
value: plan.id,
})),
},
enableColumnFilter: true,
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:status")} />
),
cell: ({ row }) => {
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
if (!row.original.status) {
return <span>-</span>;
}
return (
<Badge variant="secondary">
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
</Badge>
);
},
meta: {
label: t("common:status"),
variant: "multiSelect",
options: Object.values(BillingStatus).map((status) => {
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
return {
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
value: status,
};
}),
},
enableColumnFilter: true,
},
{
id: "credits",
accessorKey: "credits",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Credits" />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<Icons.HandCoins className="size-4 text-muted-foreground" />
<span className="font-medium tabular-nums">
{row.original.credits.toLocaleString()}
</span>
</div>
);
},
meta: {
label: "Credits",
},
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<CustomerActions customer={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,53 @@
"use client";
import { use } from "react";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
import type { GetCustomersResponse } from "@turbostarter/api/schema";
interface CustomersDataTableProps {
readonly promise: Promise<Awaited<GetCustomersResponse>>;
readonly perPage: number;
}
export const CustomersDataTable = ({
promise,
perPage,
}: CustomersDataTableProps) => {
const columns = useColumns();
const { data, total } = use(promise);
const { table } = useDataTable({
persistance: "searchParams",
data,
columns,
pageCount: Math.ceil(total / perPage),
initialState: {
sorting: [
{
id: "user.name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
shallow: false,
clearOnDefault: true,
enableRowSelection: false,
});
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,9 @@
"use server";
import { revalidatePath } from "next/cache";
import { pathsConfig } from "~/config/paths";
// eslint-disable-next-line @typescript-eslint/require-await
export const invalidateCustomers = async () =>
revalidatePath(pathsConfig.admin.customers.index);

View File

@@ -0,0 +1,224 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { updateCustomerInputSchema } from "@turbostarter/api/schema";
import { BillingStatus, config } from "@turbostarter/billing";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { admin } from "~/modules/admin/lib/api";
import { invalidateCustomers } from "./server/invalidate";
import type { UpdateCustomerInput } from "@turbostarter/api/schema";
import type { User } from "@turbostarter/auth";
import type { Customer } from "@turbostarter/billing";
import type { UseFormReturn } from "react-hook-form";
interface UpdateCustomerPlanModalProps {
readonly customer: Customer & { user: Pick<User, "name"> };
readonly children: React.ReactNode;
}
const UpdateCustomerPlanForm = ({
form,
onSubmit,
}: {
form: UseFormReturn<Pick<UpdateCustomerInput, "plan" | "status">>;
onSubmit: (
data: Pick<UpdateCustomerInput, "plan" | "status">,
) => void | Promise<void>;
}) => {
const { t, i18n } = useTranslation(["common", "admin", "billing"]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common:plan")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value ?? undefined}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("common:plan")} />
</SelectTrigger>
<SelectContent>
{config.plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{isKey(plan.name, i18n, "billing")
? t(plan.name)
: plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("customers.customer.updatePlan.plan.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common:status")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value ?? undefined}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("common:status")} />
</SelectTrigger>
<SelectContent>
{Object.values(BillingStatus).map((status) => {
const statusKey = `status.${status
.toLowerCase()
.replace(/_([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
)}`;
return (
<SelectItem key={status} value={status}>
{isKey(statusKey, i18n, "billing")
? t(statusKey)
: statusKey}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("customers.customer.updatePlan.status.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</ModalClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("update")
)}
</Button>
</ModalFooter>
</form>
</Form>
);
};
export const UpdateCustomerPlanModal = ({
customer,
children,
}: UpdateCustomerPlanModalProps) => {
const { t } = useTranslation(["common", "admin", "billing"]);
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const form = useForm({
resolver: standardSchemaResolver(
updateCustomerInputSchema.pick({ plan: true, status: true }),
),
defaultValues: {
plan: customer.plan ?? undefined,
status: customer.status ?? undefined,
},
});
const updateCustomer = useMutation({
...admin.mutations.customers.update,
onSuccess: async () => {
await invalidateCustomers();
await queryClient.invalidateQueries(
admin.queries.users.getPlans({
id: customer.userId,
}),
);
toast.success(t("customers.customer.updatePlan.success"));
setOpen(false);
form.reset();
},
});
const onSubmit = async (
data: Pick<UpdateCustomerInput, "plan" | "status">,
) => {
await updateCustomer.mutateAsync({ id: customer.id, ...data });
};
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("customers.customer.updatePlan.title", {
name: customer.user.name,
})}
</ModalTitle>
<ModalDescription>
{t("customers.customer.updatePlan.description")}
</ModalDescription>
</ModalHeader>
<UpdateCustomerPlanForm form={form} onSubmit={onSubmit} />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,29 @@
import { cn } from "@turbostarter/ui";
export const DetailsList = ({
className,
...props
}: React.ComponentProps<"ul">) => {
return (
<ul
className={cn(
"xl:*:px- -m-px grid grid-cols-1 gap-y-8 @lg/details:grid-cols-2 @lg/details:gap-y-0 @3xl/details:grid-cols-3",
className,
)}
{...props}
/>
);
};
export const DetailsListItem = ({
className,
...props
}: React.ComponentProps<"li">) => (
<li
className={cn(
"px-1 @lg/details:border @lg/details:px-6 @lg/details:py-8 @3xl/details:px-8 @3xl/details:py-10 [&:not(:first-child)]:-ms-px [&:not(:first-child)]:-mt-px",
className,
)}
{...props}
/>
);

View File

@@ -0,0 +1,90 @@
import { memo } from "react";
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenuItem,
SidebarRail,
} from "@turbostarter/ui-web/sidebar";
import { SidebarMenu } from "@turbostarter/ui-web/sidebar";
import {
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "@turbostarter/ui-web/sidebar";
import { Sidebar } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { SidebarLink } from "~/modules/common/layout/dashboard/sidebar-link";
import { TurboLink } from "~/modules/common/turbo-link";
import { UserNavigation } from "~/modules/user/user-navigation";
import type { User } from "@turbostarter/auth";
import type { Icon } from "@turbostarter/ui-web/icons";
interface AdminSidebarProps {
readonly user: User;
readonly menu: {
label: string;
items: {
title: string;
href: string;
icon: Icon;
}[];
}[];
}
export const AdminSidebar = memo<AdminSidebarProps>(async ({ user, menu }) => {
const { t, i18n } = await getTranslation({ ns: "common" });
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<TurboLink
href={pathsConfig.index}
className="flex items-center gap-3 p-2 transition-[padding] group-data-[collapsible=icon]:p-0.5"
>
<Icons.Logo className="text-primary h-8 transition-[width,height]" />
<Icons.LogoText className="text-foreground h-4 group-data-[collapsible=icon]:hidden" />
</TurboLink>
</SidebarHeader>
<SidebarContent>
{menu.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel className="uppercase">
{isKey(group.label, i18n, "common")
? t(group.label)
: group.label}
</SidebarGroupLabel>
<SidebarMenu>
{group.items.map((item) => {
const title = isKey(item.title, i18n, "common")
? t(item.title)
: item.title;
return (
<SidebarMenuItem key={item.href}>
<SidebarLink href={item.href} tooltip={title}>
<item.icon />
<span>{title}</span>
</SidebarLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<UserNavigation user={user} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
});

View File

@@ -0,0 +1,301 @@
import {
getInvitationsResponseSchema,
getMembersResponseSchema,
getOrganizationResponseSchema,
getUserAccountsResponseSchema,
getUserInvitationsResponseSchema,
getUserMembershipsResponseSchema,
getUserPlansResponseSchema,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import type { User } from "@turbostarter/auth";
import type { InferRequestType } from "hono/client";
const KEY = "admin";
const queries = {
users: {
get: ({ id }: { id: string }) => ({
queryKey: [KEY, "users", id],
queryFn: () =>
authClient.admin.getUser({
query: { id },
fetchOptions: { throw: true },
}) as Promise<User>,
}),
getAccounts: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["accounts"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "accounts", query],
queryFn: () =>
handle(api.admin.users[":id"].accounts.$get, {
schema: getUserAccountsResponseSchema,
})({
query,
param: {
id,
},
}),
}),
getPlans: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["plans"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "plans", query],
queryFn: () =>
handle(api.admin.users[":id"].plans.$get, {
schema: getUserPlansResponseSchema,
})({ query, param: { id } }),
}),
getMemberships: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["memberships"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "memberships", query],
queryFn: () =>
handle(api.admin.users[":id"].memberships.$get, {
schema: getUserMembershipsResponseSchema,
})({ query, param: { id } }),
}),
getInvitations: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "invitations", query],
queryFn: () =>
handle(api.admin.users[":id"].invitations.$get, {
schema: getUserInvitationsResponseSchema,
})({ query, param: { id } }),
}),
getSessions: ({ id }: { id: string }) => ({
queryKey: [KEY, "users", id, "sessions"],
queryFn: () =>
authClient.admin.listUserSessions({
userId: id,
fetchOptions: { throw: true },
}),
}),
},
organizations: {
get: ({ id }: { id: string }) => ({
queryKey: [KEY, "organizations", id],
queryFn: () =>
handle(api.admin.organizations[":id"].$get, {
schema: getOrganizationResponseSchema,
})({
param: { id },
}),
}),
getMembers: ({
id,
...query
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [
...queries.organizations.get({ id }).queryKey,
"members",
query,
],
queryFn: () =>
handle(api.admin.organizations[":id"].members.$get, {
schema: getMembersResponseSchema,
})({
query,
param: {
id,
},
}),
}),
getInvitations: ({
id,
...query
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [
...queries.organizations.get({ id }).queryKey,
"invitations",
query,
],
queryFn: () =>
handle(api.admin.organizations[":id"].invitations.$get, {
schema: getInvitationsResponseSchema,
})({ query, param: { id } }),
}),
},
};
const mutations = {
users: {
ban: {
mutationKey: [KEY, "users", "ban"],
mutationFn: (params: Parameters<typeof authClient.admin.banUser>[0]) =>
authClient.admin.banUser(params),
},
unban: {
mutationKey: [KEY, "users", "unban"],
mutationFn: (params: Parameters<typeof authClient.admin.unbanUser>[0]) =>
authClient.admin.unbanUser(params),
},
delete: {
mutationKey: [KEY, "users", "delete"],
mutationFn: (params: Parameters<typeof authClient.admin.removeUser>[0]) =>
authClient.admin.removeUser(params),
},
impersonate: {
mutationKey: [KEY, "users", "impersonate"],
mutationFn: (
params: Parameters<typeof authClient.admin.impersonateUser>[0],
) => authClient.admin.impersonateUser(params),
},
stopImpersonating: {
mutationKey: [KEY, "users", "impersonate", "stop"],
mutationFn: () => authClient.admin.stopImpersonating(),
},
update: {
mutationKey: [KEY, "users", "update"],
mutationFn: (params: Parameters<typeof authClient.admin.updateUser>[0]) =>
authClient.admin.updateUser(params),
},
setPassword: {
mutationKey: [KEY, "users", "setPassword"],
mutationFn: (
params: Parameters<typeof authClient.admin.setUserPassword>[0],
) => authClient.admin.setUserPassword(params),
},
accounts: {
delete: {
mutationKey: [KEY, "users", "accounts", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.users)[":id"]["accounts"][":accountId"]["$delete"]
>["param"],
) =>
handle(api.admin.users[":id"].accounts[":accountId"].$delete)({
param,
}),
},
},
sessions: {
revoke: {
mutationKey: [KEY, "users", "sessions", "revoke"],
mutationFn: (
params: Parameters<typeof authClient.admin.revokeUserSession>[0],
) => authClient.admin.revokeUserSession(params),
},
revokeAll: {
mutationKey: [KEY, "users", "sessions", "revokeAll"],
mutationFn: (
params: Parameters<typeof authClient.admin.revokeUserSessions>[0],
) => authClient.admin.revokeUserSessions(params),
},
},
},
organizations: {
delete: {
mutationKey: [KEY, "organizations", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["$delete"]
>["param"],
) =>
handle(api.admin.organizations[":id"].$delete)({
param,
}),
},
update: {
mutationKey: [KEY, "organizations", "update"],
mutationFn: ({
id,
...json
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["$patch"]
>["json"] & { id: string }) =>
handle(api.admin.organizations[":id"].$patch)({ param: { id }, json }),
},
members: {
remove: {
mutationKey: [KEY, "organizations", "members", "remove"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$delete"]
>["param"],
) =>
handle(api.admin.organizations[":id"].members[":memberId"].$delete)({
param,
}),
},
update: {
mutationKey: [KEY, "organizations", "members", "update"],
mutationFn: ({
id,
memberId,
...json
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$patch"]
>["json"] & { id: string; memberId: string }) =>
handle(api.admin.organizations[":id"].members[":memberId"].$patch)({
param: { id, memberId },
json,
}),
},
},
invitations: {
delete: {
mutationKey: [KEY, "organizations", "invitations", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["invitations"][":invitationId"]["$delete"]
>["param"],
) =>
handle(
api.admin.organizations[":id"].invitations[":invitationId"].$delete,
)({
param,
}),
},
},
},
customers: {
delete: {
mutationKey: [KEY, "customers", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.customers)[":id"]["$delete"]
>["param"],
) => handle(api.admin.customers[":id"].$delete)({ param }),
},
update: {
mutationKey: [KEY, "customers", "update"],
mutationFn: ({
id,
...json
}: InferRequestType<
(typeof api.admin.customers)[":id"]["$patch"]
>["json"] & { id: string }) =>
handle(api.admin.customers[":id"].$patch)({
param: { id },
json,
}),
},
},
};
export const admin = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,115 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
import type { ColumnDef } from "@tanstack/react-table";
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
type Row = GetOrganizationsResponse["data"][number];
export const useColumns = (options?: {
max?: { members: number };
}): ColumnDef<Row>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "q",
accessorKey: "q",
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableHiding: false,
enableColumnFilter: true,
},
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.organizations.organization(row.original.id)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.logo ?? undefined}
alt={row.original.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{row.original.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.name}
</span>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "slug",
accessorKey: "slug",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("slug")} />
),
cell: ({ row }) => <span>/{row.original.slug}</span>,
meta: {
label: "Slug",
},
},
{
id: "members",
accessorKey: "members",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("members")} />
),
cell: ({ row }) => {
return <span>{row.original.members}</span>;
},
meta: {
label: t("members"),
variant: "range",
range: [0, options?.max?.members ?? 100],
},
enableColumnFilter: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
];
};

View File

@@ -0,0 +1,53 @@
"use client";
import { use } from "react";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
interface OrganizationsDataTableProps {
readonly promise: Promise<GetOrganizationsResponse>;
readonly perPage: number;
}
export const OrganizationsDataTable = ({
promise,
perPage,
}: OrganizationsDataTableProps) => {
const { data, total, max } = use<GetOrganizationsResponse>(promise);
const columns = useColumns({ max });
const { table } = useDataTable({
persistance: "searchParams",
data,
columns,
pageCount: Math.ceil(total / perPage),
initialState: {
sorting: [
{
id: "name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
shallow: false,
clearOnDefault: true,
enableRowSelection: false,
});
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,118 @@
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
ModalClose,
} from "@turbostarter/ui-web/modal";
import { admin } from "~/modules/admin/lib/api";
interface DeleteProps {
readonly id: string;
}
export const Delete = ({ id }: DeleteProps) => {
const { t } = useTranslation("common");
const [mutation] = useMutationState({
filters: {
mutationKey: admin.mutations.organizations.delete.mutationKey,
},
select: (mutation) => ({
status: mutation.state.status,
variables: mutation.state.variables as { id: string },
}),
});
const isPending =
mutation?.status === "pending" && mutation.variables.id === id;
return (
<ConfirmModal organizationId={id}>
<Button variant="destructive" disabled={isPending}>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
<Icons.Trash />
)}
{t("delete")}
</Button>
</ConfirmModal>
);
};
const ConfirmModal = ({
children,
organizationId,
}: {
children: React.ReactNode;
organizationId: string;
}) => {
const { t } = useTranslation(["common", "admin"]);
const router = useRouter();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id: organizationId }),
);
const deleteOrganization = useMutation({
...admin.mutations.organizations.delete,
onSuccess: () => {
toast.success(t("organizations.organization.delete.success"));
router.back();
},
});
const handleDelete = () => {
deleteOrganization.mutate({ id: organizationId });
};
const isPending =
deleteOrganization.isPending &&
deleteOrganization.variables.id === organizationId;
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("organizations.organization.delete.title", {
name: organization?.name,
})}
</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("organizations.organization.delete.disclaimer")}
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</ModalClose>
<Button
onClick={handleDelete}
variant="destructive"
disabled={isPending}
>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("delete")
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,231 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { updateOrganizationSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import {
DetailsList,
DetailsListItem,
} from "~/modules/admin/layout/details-list";
import { admin } from "~/modules/admin/lib/api";
import type { UpdateOrganizationPayload } from "@turbostarter/auth";
const Name = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const update = useMutation({
...admin.mutations.organizations.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.get({ id }),
);
toast.success(
t("organizations.organization.details.name.update.success"),
);
},
});
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ name: true }),
),
defaultValues: {
name: organization?.name,
},
});
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "name">) => {
await update.mutateAsync({ id, ...data });
};
const debouncedOnSubmit = useDebounceCallback(
form.handleSubmit(onSubmit),
2000,
{
trailing: true,
leading: false,
},
);
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("name")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(e.target.value);
void debouncedOnSubmit();
}}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
const Slug = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const update = useMutation({
...admin.mutations.organizations.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.get({ id }),
);
toast.success(
t("organizations.organization.details.slug.update.success"),
);
},
});
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ slug: true }),
),
defaultValues: {
slug: organization?.slug ?? "",
},
});
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "slug">) => {
await update.mutateAsync({ id, ...data });
};
const debouncedOnSubmit = useDebounceCallback(
form.handleSubmit(onSubmit),
2000,
{
trailing: true,
leading: false,
},
);
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("slug")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<div className="relative">
<Input
className="peer ps-6"
{...field}
onChange={(e) => {
field.onChange(e.target.value);
void debouncedOnSubmit();
}}
disabled={form.formState.isSubmitting}
/>
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm peer-disabled:opacity-50">
/
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
interface OrganizationDetailsProps {
readonly id: string;
}
export const OrganizationDetails = ({ id }: OrganizationDetailsProps) => {
const { t, i18n } = useTranslation(["common", "admin"]);
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const details = [
{
id: "name",
component: <Name id={id} />,
visible: true,
},
{
id: "slug",
component: <Slug id={id} />,
visible: true,
},
{
id: "createdAt",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("createdAt")}</span>
<span>{organization?.createdAt.toLocaleString(i18n.language)}</span>
</div>
),
visible: true,
},
];
return (
<section className="@container/details w-full overflow-hidden">
<DetailsList>
{details
.filter((detail) => detail.visible)
.map((detail) => (
<DetailsListItem key={detail.id}>
{detail.component}
</DetailsListItem>
))}
</DetailsList>
</section>
);
};

View File

@@ -0,0 +1,59 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { admin } from "~/modules/admin/lib/api";
import {
DashboardHeader,
DashboardHeaderTitle,
DashboardHeaderDescription,
} from "~/modules/common/layout/dashboard/header";
import { Delete } from "./actions/delete";
interface OrganizationHeaderProps {
readonly id: string;
}
export const OrganizationHeader = ({ id }: OrganizationHeaderProps) => {
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
return (
<DashboardHeader>
<div className="flex min-w-0 items-center gap-4">
<Avatar className="size-12">
<AvatarImage
src={organization?.logo ?? undefined}
alt={organization?.name ?? ""}
/>
<AvatarFallback>
<span className="text-muted-foreground text-lg uppercase">
{organization?.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<DashboardHeaderTitle className="truncate">
{organization?.name}
</DashboardHeaderTitle>
<div className="flex items-center gap-2">
<DashboardHeaderDescription>
/{organization?.slug}
</DashboardHeaderDescription>
</div>
</div>
</div>
<Delete id={id} />
</DashboardHeader>
);
};

View File

@@ -0,0 +1,193 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { toast } from "sonner";
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Avatar, AvatarFallback } from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { admin } from "~/modules/admin/lib/api";
import type { ColumnDef } from "@tanstack/react-table";
import type { Invitation } from "@turbostarter/auth";
export const InvitationActions = ({
invitation,
}: {
invitation: Invitation;
}) => {
const { t } = useTranslation(["common", "organization"]);
const queryClient = useQueryClient();
const cancelInvitation = useMutation({
...admin.mutations.organizations.invitations.delete,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getInvitations({
id: invitation.organizationId,
}),
);
toast.success(t("invitations.cancel.success"));
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(() => {
const isPending =
cancelInvitation.isPending &&
cancelInvitation.variables.invitationId === invitation.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
cancelInvitation.mutate({
id: invitation.organizationId,
invitationId: invitation.id,
})
}
disabled={isPending}
key={`cancel-${invitation.id}`}
>
{t("cancel")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
);
};
export const useColumns = (): ColumnDef<Invitation>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "email",
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("email")} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
<Icons.UserRound className="w-4" />
</AvatarFallback>
</Avatar>
<span className="truncate font-medium">{row.original.email}</span>
</div>
);
},
enableHiding: false,
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableColumnFilter: true,
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("role")} />
),
cell: ({ row }) => {
return <Badge variant="outline">{t(row.original.role)}</Badge>;
},
meta: {
label: t("role"),
variant: "multiSelect",
options: Object.values(MemberRole).map((role) => ({
label: t(role),
value: role,
})),
},
enableColumnFilter: true,
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("status")} />
),
cell: ({ row }) => {
return <Badge variant="secondary">{t(row.original.status)}</Badge>;
},
meta: {
label: t("status"),
variant: "multiSelect",
options: Object.values(InvitationStatus).map((status) => ({
label: t(status),
value: status,
})),
},
enableColumnFilter: true,
},
{
id: "expiresAt",
accessorKey: "expiresAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("expiresAt")} />
</div>
),
cell: ({ row }) => {
return (
<div
className={cn("ml-auto text-right", {
"text-destructive": dayjs(row.original.expiresAt).isBefore(
dayjs(),
),
})}
>
{new Date(row.original.expiresAt).toLocaleString(i18n.language, {
hour: "2-digit",
minute: "2-digit",
year: "numeric",
month: "numeric",
day: "2-digit",
})}
</div>
);
},
meta: {
label: t("expiresAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<InvitationActions invitation={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,61 @@
"use client";
import { pickBy } from "@turbostarter/shared/utils";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface InvitationsDataTableProps {
readonly organizationId: string;
}
export const InvitationsDataTable = ({
organizationId,
}: InvitationsDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "expiresAt",
desc: true,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.organizations.getInvitations({
id: organizationId,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={5}
filterCount={4}
cellWidths={["20rem", "5rem", "5rem", "10rem", "2.5rem"]}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,209 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { admin } from "~/modules/admin/lib/api";
import { TurboLink } from "~/modules/common/turbo-link";
import { UpdateMemberRoleModal } from "../update-member-role";
import type { ColumnDef } from "@tanstack/react-table";
import type { Member } from "@turbostarter/auth";
export const MemberActions = ({ member }: { member: Member }) => {
const { t } = useTranslation(["common", "organization", "auth"]);
const queryClient = useQueryClient();
const removeMember = useMutation({
...admin.mutations.organizations.members.remove,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getMembers({
id: member.organizationId,
}),
);
await queryClient.invalidateQueries(
admin.queries.users.getMemberships({
id: member.userId,
}),
);
toast.success(t("members.remove.success"));
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<UpdateMemberRoleModal member={member} key={`update-role-${member.id}`}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("updateRole")}
</DropdownMenuItem>
</UpdateMemberRoleModal>
<DropdownMenuSeparator />
{(() => {
const isPending =
removeMember.isPending &&
removeMember.variables.memberId === member.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
removeMember.mutate({
id: member.organizationId,
memberId: member.id,
})
}
disabled={isPending}
key={`remove-${member.id}`}
>
{t("remove")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
);
};
export const useColumns = (): ColumnDef<Member>[] => {
const { t, i18n } = useTranslation("common");
const { data: session } = authClient.useSession();
return [
{
id: "q",
accessorKey: "q",
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableHiding: false,
enableColumnFilter: true,
},
{
id: "user.name",
accessorKey: "user.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.users.user(row.original.userId)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.user.image ?? undefined}
alt={row.original.user.name}
/>
<AvatarFallback>
<Icons.UserRound className="w-5" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.user.name}
</span>
{row.original.userId === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "user.email",
accessorKey: "user.email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("email")} />
),
meta: {
label: t("email"),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("role")} />
),
cell: ({ row }) => {
return <Badge variant="outline">{t(row.original.role)}</Badge>;
},
meta: {
label: t("role"),
variant: "multiSelect",
options: Object.values(MemberRole).map((role) => ({
label: t(role),
value: role,
})),
},
enableColumnFilter: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("joinedAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{new Date(row.original.createdAt).toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("joinedAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<MemberActions member={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,62 @@
"use client";
import { pickBy } from "@turbostarter/shared/utils";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface MembersDataTableProps {
readonly organizationId: string;
}
export const MembersDataTable = ({ organizationId }: MembersDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "user.name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.organizations.getMembers({
id: organizationId,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={5}
filterCount={3}
cellWidths={["20rem", "10rem", "7rem", "4rem", "2.5rem"]}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,161 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { MemberRole, updateMemberSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalTrigger,
ModalTitle,
ModalHeader,
ModalContent,
ModalDescription,
ModalClose,
ModalFooter,
} from "@turbostarter/ui-web/modal";
import {
Select,
SelectItem,
SelectValue,
SelectContent,
SelectTrigger,
} from "@turbostarter/ui-web/select";
import { admin } from "~/modules/admin/lib/api";
import type { Member, UpdateMemberPayload } from "@turbostarter/auth";
interface UpdateMemberRoleModalProps {
readonly member: Member;
readonly children: React.ReactNode;
}
export const UpdateMemberRoleModal = ({
member,
children,
}: UpdateMemberRoleModalProps) => {
const { t } = useTranslation(["common", "admin", "organization"]);
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const updateMember = useMutation({
...admin.mutations.organizations.members.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getMembers({
id: member.organizationId,
}),
);
await queryClient.invalidateQueries(
admin.queries.users.getMemberships({
id: member.userId,
}),
);
toast.success(t("members.update.role.success"));
setOpen(false);
form.reset();
},
});
const form = useForm({
resolver: standardSchemaResolver(updateMemberSchema.pick({ role: true })),
defaultValues: {
role: member.role,
},
});
const onSubmit = async (data: Pick<UpdateMemberPayload, "role">) => {
await updateMember.mutateAsync({
id: member.organizationId,
memberId: member.id,
...data,
});
};
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("members.update.role.title", {
name: member.user.name,
})}
</ModalTitle>
<ModalDescription>
{t("members.update.role.description")}
</ModalDescription>
</ModalHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="px-4 md:px-0">
<FormLabel>{t("role")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("role")} />
</SelectTrigger>
<SelectContent>
{Object.values(MemberRole).map((role) => (
<SelectItem key={role} value={role}>
{t(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("members.update.role.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</ModalClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("update")
)}
</Button>
</ModalFooter>
</form>
</Form>
</ModalContent>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More