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:
27
apps/web/src/app/[locale]/(apps)/agent/layout.tsx
Normal file
27
apps/web/src/app/[locale]/(apps)/agent/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/[locale]/(apps)/agent/page.tsx
Normal file
5
apps/web/src/app/[locale]/(apps)/agent/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Agent } from "~/modules/agent";
|
||||
|
||||
export default function AgentPage() {
|
||||
return <Agent />;
|
||||
}
|
||||
63
apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx
Normal file
63
apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
30
apps/web/src/app/[locale]/(apps)/chat/layout.tsx
Normal file
30
apps/web/src/app/[locale]/(apps)/chat/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(apps)/chat/page.tsx
Normal file
23
apps/web/src/app/[locale]/(apps)/chat/page.tsx
Normal 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} />;
|
||||
}
|
||||
125
apps/web/src/app/[locale]/(apps)/demo/page.tsx
Normal file
125
apps/web/src/app/[locale]/(apps)/demo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/app/[locale]/(apps)/demo/scroll-test/page.tsx
Normal file
130
apps/web/src/app/[locale]/(apps)/demo/scroll-test/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx
Normal file
77
apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx
Normal 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,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/[locale]/(apps)/image/history/page.tsx
Normal file
23
apps/web/src/app/[locale]/(apps)/image/history/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/app/[locale]/(apps)/image/layout.tsx
Normal file
18
apps/web/src/app/[locale]/(apps)/image/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/app/[locale]/(apps)/image/page.tsx
Normal file
40
apps/web/src/app/[locale]/(apps)/image/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/app/[locale]/(apps)/layout.tsx
Normal file
14
apps/web/src/app/[locale]/(apps)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx
Normal file
12
apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx
Normal 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>;
|
||||
}
|
||||
57
apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx
Normal file
57
apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx
Normal 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;
|
||||
14
apps/web/src/app/[locale]/(apps)/pdf/layout.tsx
Normal file
14
apps/web/src/app/[locale]/(apps)/pdf/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/app/[locale]/(apps)/pdf/page.tsx
Normal file
22
apps/web/src/app/[locale]/(apps)/pdf/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/app/[locale]/(apps)/tts/layout.tsx
Normal file
27
apps/web/src/app/[locale]/(apps)/tts/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/app/[locale]/(apps)/tts/page.tsx
Normal file
37
apps/web/src/app/[locale]/(apps)/tts/page.tsx
Normal 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} />;
|
||||
}
|
||||
121
apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
Normal file
121
apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
Normal 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 });
|
||||
}
|
||||
128
apps/web/src/app/[locale]/(marketing)/blog/page.tsx
Normal file
128
apps/web/src/app/[locale]/(marketing)/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/app/[locale]/(marketing)/contact/page.tsx
Normal file
34
apps/web/src/app/[locale]/(marketing)/contact/page.tsx
Normal 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);
|
||||
12
apps/web/src/app/[locale]/(marketing)/layout.tsx
Normal file
12
apps/web/src/app/[locale]/(marketing)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx
Normal file
76
apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx
Normal 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 });
|
||||
}
|
||||
43
apps/web/src/app/[locale]/(marketing)/page.tsx
Normal file
43
apps/web/src/app/[locale]/(marketing)/page.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PricingSectionSkeleton } from "~/modules/billing/pricing/section";
|
||||
|
||||
const PricingLoadingPage = () => {
|
||||
return <PricingSectionSkeleton />;
|
||||
};
|
||||
|
||||
export default PricingLoadingPage;
|
||||
13
apps/web/src/app/[locale]/(marketing)/pricing/page.tsx
Normal file
13
apps/web/src/app/[locale]/(marketing)/pricing/page.tsx
Normal 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;
|
||||
94
apps/web/src/app/[locale]/admin/customers/page.tsx
Normal file
94
apps/web/src/app/[locale]/admin/customers/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/app/[locale]/admin/layout.tsx
Normal file
61
apps/web/src/app/[locale]/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/app/[locale]/admin/organizations/[id]/page.tsx
Normal file
89
apps/web/src/app/[locale]/admin/organizations/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/app/[locale]/admin/organizations/page.tsx
Normal file
86
apps/web/src/app/[locale]/admin/organizations/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/app/[locale]/admin/page.tsx
Normal file
93
apps/web/src/app/[locale]/admin/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/app/[locale]/admin/users/[id]/page.tsx
Normal file
90
apps/web/src/app/[locale]/admin/users/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/app/[locale]/admin/users/page.tsx
Normal file
89
apps/web/src/app/[locale]/admin/users/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/app/[locale]/auth/error/page.tsx
Normal file
38
apps/web/src/app/[locale]/auth/error/page.tsx
Normal 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;
|
||||
65
apps/web/src/app/[locale]/auth/join/page.tsx
Normal file
65
apps/web/src/app/[locale]/auth/join/page.tsx
Normal 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 />;
|
||||
}
|
||||
35
apps/web/src/app/[locale]/auth/layout.tsx
Normal file
35
apps/web/src/app/[locale]/auth/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/app/[locale]/auth/login/page.tsx
Normal file
28
apps/web/src/app/[locale]/auth/login/page.tsx
Normal 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;
|
||||
24
apps/web/src/app/[locale]/auth/password/forgot/page.tsx
Normal file
24
apps/web/src/app/[locale]/auth/password/forgot/page.tsx
Normal 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;
|
||||
32
apps/web/src/app/[locale]/auth/password/update/page.tsx
Normal file
32
apps/web/src/app/[locale]/auth/password/update/page.tsx
Normal 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;
|
||||
50
apps/web/src/app/[locale]/auth/register/page.tsx
Normal file
50
apps/web/src/app/[locale]/auth/register/page.tsx
Normal 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;
|
||||
69
apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
Normal file
69
apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/app/[locale]/dashboard/(user)/page.tsx
Normal file
67
apps/web/src/app/[locale]/dashboard/(user)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx
Normal file
33
apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/app/[locale]/dashboard/[organization]/page.tsx
Normal file
53
apps/web/src/app/[locale]/dashboard/[organization]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal file
34
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/app/[locale]/error.tsx
Normal file
51
apps/web/src/app/[locale]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/app/[locale]/layout.tsx
Normal file
41
apps/web/src/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/app/api/[...route]/route.ts
Normal file
14
apps/web/src/app/api/[...route]/route.ts
Normal 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,
|
||||
};
|
||||
BIN
apps/web/src/app/apple-icon.png
Normal file
BIN
apps/web/src/app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
35
apps/web/src/app/global-error.tsx
Normal file
35
apps/web/src/app/global-error.tsx
Normal 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
BIN
apps/web/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
15
apps/web/src/app/layout.tsx
Normal file
15
apps/web/src/app/layout.tsx
Normal 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;
|
||||
}
|
||||
33
apps/web/src/app/not-found.tsx
Normal file
33
apps/web/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/opengraph-image.png
Normal file
BIN
apps/web/src/app/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
14
apps/web/src/app/robots.ts
Normal file
14
apps/web/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
80
apps/web/src/app/sitemap.ts
Normal file
80
apps/web/src/app/sitemap.ts
Normal 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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
4
apps/web/src/assets/styles/globals.css
Normal file
4
apps/web/src/assets/styles/globals.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "@turbostarter/ui/globals.css";
|
||||
@import "@turbostarter/ui-web/globals.css";
|
||||
|
||||
@source "../../../../../packages/ui";
|
||||
11
apps/web/src/config/app.ts
Normal file
11
apps/web/src/config/app.ts
Normal 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;
|
||||
23
apps/web/src/config/auth.ts
Normal file
23
apps/web/src/config/auth.ts
Normal 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;
|
||||
104
apps/web/src/config/paths.ts
Normal file
104
apps/web/src/config/paths.ts
Normal 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,
|
||||
};
|
||||
14
apps/web/src/lib/api/client.tsx
Normal file
14
apps/web/src/lib/api/client.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
13
apps/web/src/lib/api/server.ts
Normal file
13
apps/web/src/lib/api/server.ts
Normal 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",
|
||||
}),
|
||||
});
|
||||
9
apps/web/src/lib/api/utils.ts
Normal file
9
apps/web/src/lib/api/utils.ts
Normal 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}`;
|
||||
};
|
||||
10
apps/web/src/lib/auth/client.ts
Normal file
10
apps/web/src/lib/auth/client.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
63
apps/web/src/lib/auth/server.ts
Normal file
63
apps/web/src/lib/auth/server.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
35
apps/web/src/lib/constants.ts
Normal file
35
apps/web/src/lib/constants.ts
Normal 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;
|
||||
116
apps/web/src/lib/metadata.ts
Normal file
116
apps/web/src/lib/metadata.ts
Normal 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" },
|
||||
],
|
||||
};
|
||||
30
apps/web/src/lib/providers/analytics.tsx
Normal file
30
apps/web/src/lib/providers/analytics.tsx
Normal 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>;
|
||||
};
|
||||
25
apps/web/src/lib/providers/monitoring.tsx
Normal file
25
apps/web/src/lib/providers/monitoring.tsx
Normal 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}</>;
|
||||
};
|
||||
34
apps/web/src/lib/providers/providers.tsx
Normal file
34
apps/web/src/lib/providers/providers.tsx
Normal 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";
|
||||
50
apps/web/src/lib/providers/theme.tsx
Normal file
50
apps/web/src/lib/providers/theme.tsx
Normal 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";
|
||||
30
apps/web/src/lib/query/client.tsx
Normal file
30
apps/web/src/lib/query/client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/lib/query/server.ts
Normal file
5
apps/web/src/lib/query/server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { createQueryClient } from "./utils";
|
||||
|
||||
export const getQueryClient = cache(createQueryClient);
|
||||
33
apps/web/src/lib/query/utils.ts
Normal file
33
apps/web/src/lib/query/utils.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal file
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal file
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal file
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
301
apps/web/src/modules/admin/lib/api.ts
Normal file
301
apps/web/src/modules/admin/lib/api.ts
Normal 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;
|
||||
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal file
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user