feat: whyrating - initial project from turbostarter boilerplate
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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user