first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
25
apps/web/app/(marketing)/[locale]/(home)/page.tsx
Normal file
25
apps/web/app/(marketing)/[locale]/(home)/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { FaqSection } from "@marketing/home/components/FaqSection";
|
||||
import { Features } from "@marketing/home/components/Features";
|
||||
import { Hero } from "@marketing/home/components/Hero";
|
||||
import { Newsletter } from "@marketing/home/components/Newsletter";
|
||||
import { PricingSection } from "@marketing/home/components/PricingSection";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
|
||||
export default async function Home({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<PricingSection />
|
||||
<FaqSection />
|
||||
<Newsletter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
Normal file
5
apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAll() {
|
||||
notFound();
|
||||
}
|
||||
128
apps/web/app/(marketing)/[locale]/blog/[...path]/page.tsx
Normal file
128
apps/web/app/(marketing)/[locale]/blog/[...path]/page.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { LocaleLink, localeRedirect } from "@i18n/routing";
|
||||
import { PostContent } from "@marketing/blog/components/PostContent";
|
||||
import { getPostBySlug } from "@marketing/blog/utils/lib/posts";
|
||||
import { getBaseUrl } from "@repo/utils";
|
||||
import { getActivePathFromUrlParam } from "@shared/lib/content";
|
||||
import { getLocale, getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Image from "next/image";
|
||||
|
||||
type Params = {
|
||||
path: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: { params: Promise<Params> }) {
|
||||
const params = await props.params;
|
||||
|
||||
const { path } = params;
|
||||
|
||||
const locale = await getLocale();
|
||||
const slug = getActivePathFromUrlParam(path);
|
||||
const post = await getPostBySlug(slug, { locale });
|
||||
|
||||
return {
|
||||
title: post?.title,
|
||||
description: post?.excerpt,
|
||||
openGraph: {
|
||||
title: post?.title,
|
||||
description: post?.excerpt,
|
||||
images: post?.image
|
||||
? [
|
||||
post.image.startsWith("http")
|
||||
? post.image
|
||||
: new URL(post.image, getBaseUrl()).toString(),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage(props: { params: Promise<Params> }) {
|
||||
const { path, locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const slug = getActivePathFromUrlParam(path);
|
||||
const post = await getPostBySlug(slug, { locale });
|
||||
|
||||
if (!post) {
|
||||
return localeRedirect({ href: "/blog", locale });
|
||||
}
|
||||
|
||||
const { title, date, authorName, authorImage, tags, image, body } = post;
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl pt-32 pb-24">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-12">
|
||||
<LocaleLink href="/blog">
|
||||
← {t("blog.back")}
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
<h1 className="font-bold text-4xl">{title}</h1>
|
||||
|
||||
<div className="mt-4 flex items-center justify-start gap-6">
|
||||
{authorName && (
|
||||
<div className="flex items-center">
|
||||
{authorImage && (
|
||||
<div className="relative mr-2 size-8 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={authorImage}
|
||||
alt={authorName}
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-sm opacity-50">
|
||||
{authorName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mr-0 ml-auto">
|
||||
<p className="text-sm opacity-30">
|
||||
{Intl.DateTimeFormat("en-US").format(
|
||||
new Date(date),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tags && (
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="font-semibold text-primary text-xs uppercase tracking-wider"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div className="relative mt-6 aspect-16/9 overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pb-8">
|
||||
<PostContent content={body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/web/app/(marketing)/[locale]/blog/page.tsx
Normal file
39
apps/web/app/(marketing)/[locale]/blog/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { PostListItem } from "@marketing/blog/components/PostListItem";
|
||||
import { getAllPosts } from "@marketing/blog/utils/lib/posts";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: t("blog.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogListPage() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations();
|
||||
|
||||
const posts = await getAllPosts();
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl pt-32 pb-16">
|
||||
<div className="mb-12 pt-8 text-center">
|
||||
<h1 className="mb-2 font-bold text-5xl">{t("blog.title")}</h1>
|
||||
<p className="text-lg opacity-50">{t("blog.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{posts
|
||||
.filter((post) => post.published && locale === post.locale)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.date).getTime() -
|
||||
new Date(a.date).getTime(),
|
||||
)
|
||||
.map((post) => (
|
||||
<PostListItem post={post} key={post.path} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/web/app/(marketing)/[locale]/changelog/page.tsx
Normal file
35
apps/web/app/(marketing)/[locale]/changelog/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { ChangelogSection } from "@marketing/changelog/components/ChangelogSection";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function PricingPage() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl pt-32 pb-16">
|
||||
<div className="mb-12 text-balance pt-8 text-center">
|
||||
<h1 className="mb-2 font-bold text-5xl">
|
||||
{t("changelog.title")}
|
||||
</h1>
|
||||
<p className="text-lg opacity-50">
|
||||
{t("changelog.description")}
|
||||
</p>
|
||||
</div>
|
||||
<ChangelogSection
|
||||
items={[
|
||||
{
|
||||
date: "2024-03-01",
|
||||
changes: ["🚀 Improved performance"],
|
||||
},
|
||||
{
|
||||
date: "2024-02-01",
|
||||
changes: ["🎨 Updated design", "🐞 Fixed a bug"],
|
||||
},
|
||||
{
|
||||
date: "2024-01-01",
|
||||
changes: ["🎉 Added new feature", "🐞 Fixed a bug"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/web/app/(marketing)/[locale]/contact/page.tsx
Normal file
33
apps/web/app/(marketing)/[locale]/contact/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { ContactForm } from "@marketing/home/components/ContactForm";
|
||||
import { config } from "@repo/config";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
return {
|
||||
title: t("contact.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ContactPage() {
|
||||
if (!config.contactForm.enabled) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
return (
|
||||
<div className="container max-w-xl pt-32 pb-16">
|
||||
<div className="mb-12 pt-8 text-center">
|
||||
<h1 className="mb-2 font-bold text-5xl">
|
||||
{t("contact.title")}
|
||||
</h1>
|
||||
<p className="text-balance text-lg opacity-50">
|
||||
{t("contact.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { docsSource } from "../../../../docs-source";
|
||||
|
||||
export default async function DocumentationLayout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<{ locale: string }>;
|
||||
}>) {
|
||||
const t = await getTranslations();
|
||||
const { locale } = await params;
|
||||
|
||||
return (
|
||||
<div className="pt-[4.5rem]">
|
||||
<DocsLayout
|
||||
tree={docsSource.pageTree[locale]}
|
||||
disableThemeSwitch
|
||||
i18n
|
||||
nav={{
|
||||
title: <strong>{t("documentation.title")}</strong>,
|
||||
url: "/docs",
|
||||
}}
|
||||
sidebar={{
|
||||
defaultOpenLevel: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/app/(marketing)/[locale]/docs/[[...path]]/page.tsx
Normal file
93
apps/web/app/(marketing)/[locale]/docs/[[...path]]/page.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { MDXContent } from "@content-collections/mdx/react";
|
||||
import { config } from "@repo/config";
|
||||
import { File, Files, Folder } from "fumadocs-ui/components/files";
|
||||
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
|
||||
import { Step, Steps } from "fumadocs-ui/components/steps";
|
||||
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import { DocsBody, DocsPage } from "fumadocs-ui/page";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { docsSource } from "../../../../docs-source";
|
||||
|
||||
export default async function DocumentationPage(props: {
|
||||
params: Promise<{ path?: string[]; locale: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
setRequestLocale(params.locale);
|
||||
const page = docsSource.getPage(params.path, params.locale);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<DocsPage
|
||||
toc={page.data.toc}
|
||||
full={page.data.full}
|
||||
breadcrumb={{
|
||||
enabled: true,
|
||||
includePage: true,
|
||||
includeSeparator: true,
|
||||
}}
|
||||
tableOfContent={{
|
||||
enabled: true,
|
||||
}}
|
||||
>
|
||||
<DocsBody>
|
||||
<h1 className="text-foreground">{page.data.title}</h1>
|
||||
{page.data.description && (
|
||||
<p className="-mt-6 text-foreground/50 text-lg lg:text-xl">
|
||||
{page.data.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="prose dark:prose-invert max-w-full prose-a:text-foreground prose-p:text-foreground/80">
|
||||
<MDXContent
|
||||
code={page.data.body}
|
||||
// @ts-expect-error
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
Tabs,
|
||||
Tab,
|
||||
Steps,
|
||||
Step,
|
||||
File,
|
||||
Folder,
|
||||
Files,
|
||||
img: (props) => (
|
||||
<ImageZoom
|
||||
{...(props as any)}
|
||||
className="rounded-lg border-4 border-secondary/10"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return docsSource.getPages().flatMap((page) => ({
|
||||
path: page.slugs,
|
||||
locale: page.locale ?? config.i18n.defaultLocale,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ path?: string[]; locale: string }>;
|
||||
}) {
|
||||
const t = await getTranslations();
|
||||
const params = await props.params;
|
||||
const page = docsSource.getPage(params.path, params.locale);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${page.data.title} | ${t("documentation.title")}`,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
55
apps/web/app/(marketing)/[locale]/layout.tsx
Normal file
55
apps/web/app/(marketing)/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Footer } from "@marketing/shared/components/Footer";
|
||||
import { NavBar } from "@marketing/shared/components/NavBar";
|
||||
import { config } from "@repo/config";
|
||||
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||
import { Document } from "@shared/components/Document";
|
||||
import { I18nProvider as FumadocsI18nProvider } from "fumadocs-ui/i18n";
|
||||
import { RootProvider as FumadocsRootProvider } from "fumadocs-ui/provider";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
const locales = Object.keys(config.i18n.locales);
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function MarketingLayout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<{ params: Promise<{ locale: string }> }>) {
|
||||
const { locale } = await params;
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
if (!locales.includes(locale as any)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<Document locale={locale}>
|
||||
<FumadocsI18nProvider locale={locale}>
|
||||
<FumadocsRootProvider
|
||||
search={{
|
||||
enabled: true,
|
||||
options: {
|
||||
api: "/api/docs-search",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<SessionProvider>
|
||||
<NavBar />
|
||||
<main className="min-h-screen">{children}</main>
|
||||
<Footer />
|
||||
</SessionProvider>
|
||||
</NextIntlClientProvider>
|
||||
</FumadocsRootProvider>
|
||||
</FumadocsI18nProvider>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
68
apps/web/app/(marketing)/[locale]/legal/[...path]/page.tsx
Normal file
68
apps/web/app/(marketing)/[locale]/legal/[...path]/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { localeRedirect } from "@i18n/routing";
|
||||
import { PostContent } from "@marketing/blog/components/PostContent";
|
||||
import {
|
||||
getActivePathFromUrlParam,
|
||||
getLocalizedDocumentWithFallback,
|
||||
} from "@shared/lib/content";
|
||||
import { allLegalPages } from "content-collections";
|
||||
import { getLocale } from "next-intl/server";
|
||||
|
||||
type Params = {
|
||||
path: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const { path } = params;
|
||||
|
||||
const locale = await getLocale();
|
||||
const activePath = getActivePathFromUrlParam(path);
|
||||
const page = getLocalizedDocumentWithFallback(
|
||||
allLegalPages,
|
||||
activePath,
|
||||
locale,
|
||||
);
|
||||
|
||||
return {
|
||||
title: page?.title,
|
||||
openGraph: {
|
||||
title: page?.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage(props: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const { path } = params;
|
||||
|
||||
const locale = await getLocale();
|
||||
const activePath = getActivePathFromUrlParam(path);
|
||||
const page = getLocalizedDocumentWithFallback(
|
||||
allLegalPages,
|
||||
activePath,
|
||||
locale,
|
||||
);
|
||||
|
||||
if (!page) {
|
||||
localeRedirect({ href: "/", locale });
|
||||
}
|
||||
|
||||
const { title, body } = page;
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl pt-32 pb-24">
|
||||
<div className="mx-auto mb-12 max-w-2xl">
|
||||
<h1 className="text-center font-bold text-4xl">{title}</h1>
|
||||
</div>
|
||||
|
||||
<PostContent content={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/(marketing)/[locale]/not-found.tsx
Normal file
5
apps/web/app/(marketing)/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { NotFound } from "@marketing/shared/components/NotFound";
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
}
|
||||
73
apps/web/app/(saas)/app/(account)/admin/layout.tsx
Normal file
73
apps/web/app/(saas)/app/(account)/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||
import { Logo } from "@shared/components/Logo";
|
||||
import { Building2Icon, UsersIcon } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default async function AdminLayout({ children }: PropsWithChildren) {
|
||||
const t = await getTranslations();
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
if (session.user?.role !== "admin") {
|
||||
redirect("/app");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("admin.title")}
|
||||
subtitle={t("admin.description")}
|
||||
/>
|
||||
<SidebarContentLayout
|
||||
sidebar={
|
||||
<SettingsMenu
|
||||
menuItems={[
|
||||
{
|
||||
avatar: (
|
||||
<Logo
|
||||
className="size-8"
|
||||
withLabel={false}
|
||||
/>
|
||||
),
|
||||
title: t("admin.title"),
|
||||
items: [
|
||||
{
|
||||
title: t("admin.menu.users"),
|
||||
href: "/app/admin/users",
|
||||
icon: (
|
||||
<UsersIcon className="size-4 opacity-50" />
|
||||
),
|
||||
},
|
||||
...(config.organizations.enable
|
||||
? [
|
||||
{
|
||||
title: t(
|
||||
"admin.menu.organizations",
|
||||
),
|
||||
href: "/app/admin/organizations",
|
||||
icon: (
|
||||
<Building2Icon className="size-4 opacity-50" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SidebarContentLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { auth } from "@repo/auth";
|
||||
import { OrganizationForm } from "@saas/admin/component/organizations/OrganizationForm";
|
||||
import { getAdminPath } from "@saas/admin/lib/links";
|
||||
import { fullOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function OrganizationFormPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ backTo?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { backTo } = await searchParams;
|
||||
|
||||
const t = await getTranslations();
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: fullOrganizationQueryKey(id),
|
||||
queryFn: async () =>
|
||||
await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationId: id,
|
||||
},
|
||||
headers: await headers(),
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<div>
|
||||
<div className="mb-2 flex justify-start">
|
||||
<Button variant="link" size="sm" asChild className="px-0">
|
||||
<Link href={backTo ?? getAdminPath("/organizations")}>
|
||||
<ArrowLeftIcon className="mr-1.5 size-4" />
|
||||
{t("admin.organizations.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<OrganizationForm organizationId={id} />
|
||||
</div>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { OrganizationList } from "@saas/admin/component/organizations/OrganizationList";
|
||||
|
||||
export default function AdminOrganizationsPage() {
|
||||
return <OrganizationList />;
|
||||
}
|
||||
9
apps/web/app/(saas)/app/(account)/admin/users/page.tsx
Normal file
9
apps/web/app/(saas)/app/(account)/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { UserList } from "@saas/admin/component/users/UserList";
|
||||
|
||||
export default function AdminUserPage() {
|
||||
return (
|
||||
<div>
|
||||
<UserList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/app/(saas)/app/(account)/chatbot/page.tsx
Normal file
54
apps/web/app/(saas)/app/(account)/chatbot/page.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { AiChat } from "@saas/ai/components/AiChat";
|
||||
import { aiChatListQueryKey, aiChatQueryKey } from "@saas/ai/lib/api";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { getServerApiClient, getServerQueryClient } from "@shared/lib/server";
|
||||
|
||||
export default async function AiDemoPage() {
|
||||
const queryClient = getServerQueryClient();
|
||||
const apiClient = await getServerApiClient();
|
||||
|
||||
const chats = await (async () => {
|
||||
const response = await apiClient.ai.chats.$get({});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch chats");
|
||||
}
|
||||
|
||||
return response.json().then((data) => data.chats);
|
||||
})();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: aiChatListQueryKey(),
|
||||
queryFn: async () => chats,
|
||||
});
|
||||
|
||||
if (chats.length > 0) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: aiChatQueryKey(chats[0].id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.ai.chats[":id"].$get({
|
||||
param: {
|
||||
id: chats[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch chat");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="AI Chatbot"
|
||||
subtitle="This is an example chatbot built with the OpenAI API"
|
||||
/>
|
||||
|
||||
<AiChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
apps/web/app/(saas)/app/(account)/layout.tsx
Normal file
6
apps/web/app/(saas)/app/(account)/layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { AppWrapper } from "@saas/shared/components/AppWrapper";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default function UserLayout({ children }: PropsWithChildren) {
|
||||
return <AppWrapper>{children}</AppWrapper>;
|
||||
}
|
||||
26
apps/web/app/(saas)/app/(account)/page.tsx
Normal file
26
apps/web/app/(saas)/app/(account)/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import UserStart from "@saas/start/UserStart";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AppStartPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<PageHeader
|
||||
title={t("start.welcome", { name: session?.user.name })}
|
||||
subtitle={t("start.subtitle")}
|
||||
/>
|
||||
|
||||
<UserStart />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/app/(saas)/app/(account)/settings/billing/page.tsx
Normal file
40
apps/web/app/(saas)/app/(account)/settings/billing/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { ActivePlan } from "@saas/payments/components/ActivePlan";
|
||||
import { ChangePlan } from "@saas/payments/components/ChangePlan";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { getPurchases } from "@saas/payments/lib/server";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("settings.billing.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const session = await getSession();
|
||||
const purchases = await getPurchases();
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(),
|
||||
queryFn: () => purchases,
|
||||
});
|
||||
|
||||
const { activePlan } = createPurchasesHelper(purchases);
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
{activePlan && <ActivePlan />}
|
||||
<ChangePlan
|
||||
userId={session?.user.id}
|
||||
activePlanId={activePlan?.id}
|
||||
/>
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { DeleteAccountForm } from "@saas/settings/components/DeleteAccountForm";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("settings.account.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
<DeleteAccountForm />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
34
apps/web/app/(saas)/app/(account)/settings/general/page.tsx
Normal file
34
apps/web/app/(saas)/app/(account)/settings/general/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { ChangeEmailForm } from "@saas/settings/components/ChangeEmailForm";
|
||||
import { ChangeNameForm } from "@saas/settings/components/ChangeNameForm";
|
||||
import { UserAvatarForm } from "@saas/settings/components/UserAvatarForm";
|
||||
import { UserLanguageForm } from "@saas/settings/components/UserLanguageForm";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("settings.account.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
<UserAvatarForm />
|
||||
{config.i18n.enabled && <UserLanguageForm />}
|
||||
<ChangeNameForm />
|
||||
<ChangeEmailForm />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
77
apps/web/app/(saas)/app/(account)/settings/layout.tsx
Normal file
77
apps/web/app/(saas)/app/(account)/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
LockKeyholeIcon,
|
||||
SettingsIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { PropsWithChildren } from "react";
|
||||
export default async function SettingsLayout({ children }: PropsWithChildren) {
|
||||
const t = await getTranslations();
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: t("settings.menu.account.title"),
|
||||
avatar: (
|
||||
<UserAvatar
|
||||
name={session.user.name ?? ""}
|
||||
avatarUrl={session.user.image}
|
||||
/>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
title: t("settings.menu.account.general"),
|
||||
href: "/app/settings/general",
|
||||
icon: <SettingsIcon className="size-4 opacity-50" />,
|
||||
},
|
||||
{
|
||||
title: t("settings.menu.account.security"),
|
||||
href: "/app/settings/security",
|
||||
icon: <LockKeyholeIcon className="size-4 opacity-50" />,
|
||||
},
|
||||
...(config.users.enableBilling
|
||||
? [
|
||||
{
|
||||
title: t("settings.menu.account.billing"),
|
||||
href: "/app/settings/billing",
|
||||
icon: (
|
||||
<CreditCardIcon className="size-4 opacity-50" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("settings.menu.account.dangerZone"),
|
||||
href: "/app/settings/danger-zone",
|
||||
icon: <TriangleAlertIcon className="size-4 opacity-50" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("settings.account.title")}
|
||||
subtitle={t("settings.account.subtitle")}
|
||||
/>
|
||||
<SidebarContentLayout
|
||||
sidebar={<SettingsMenu menuItems={menuItems} />}
|
||||
>
|
||||
{children}
|
||||
</SidebarContentLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
apps/web/app/(saas)/app/(account)/settings/security/page.tsx
Normal file
68
apps/web/app/(saas)/app/(account)/settings/security/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { config } from "@repo/config";
|
||||
import { userAccountQueryKey, userPasskeyQueryKey } from "@saas/auth/lib/api";
|
||||
import {
|
||||
getSession,
|
||||
getUserAccounts,
|
||||
getUserPasskeys,
|
||||
} from "@saas/auth/lib/server";
|
||||
import { ActiveSessionsBlock } from "@saas/settings/components/ActiveSessionsBlock";
|
||||
import { ChangePasswordForm } from "@saas/settings/components/ChangePassword";
|
||||
import { ConnectedAccountsBlock } from "@saas/settings/components/ConnectedAccountsBlock";
|
||||
import { PasskeysBlock } from "@saas/settings/components/PasskeysBlock";
|
||||
import { SetPasswordForm } from "@saas/settings/components/SetPassword";
|
||||
import { TwoFactorBlock } from "@saas/settings/components/TwoFactorBlock";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("settings.account.security.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userAccounts = await getUserAccounts();
|
||||
|
||||
const userHasPassword = userAccounts?.some(
|
||||
(account) => account.provider === "credential",
|
||||
);
|
||||
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userAccountQueryKey,
|
||||
queryFn: () => getUserAccounts(),
|
||||
});
|
||||
|
||||
if (config.auth.enablePasskeys) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userPasskeyQueryKey,
|
||||
queryFn: () => getUserPasskeys(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
{config.auth.enablePasswordLogin &&
|
||||
(userHasPassword ? (
|
||||
<ChangePasswordForm />
|
||||
) : (
|
||||
<SetPasswordForm />
|
||||
))}
|
||||
{config.auth.enableSocialLogin && <ConnectedAccountsBlock />}
|
||||
{config.auth.enablePasskeys && <PasskeysBlock />}
|
||||
{config.auth.enableTwoFactor && <TwoFactorBlock />}
|
||||
<ActiveSessionsBlock />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default function WithoutOrganizationSlugLayout({
|
||||
children,
|
||||
}: PropsWithChildren) {
|
||||
return <AuthWrapper>{children}</AuthWrapper>;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getOrganizationList } from "@saas/auth/lib/server";
|
||||
import { CreateOrganizationForm } from "@saas/organizations/components/CreateOrganizationForm";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewOrganizationPage() {
|
||||
const organizations = await getOrganizationList();
|
||||
|
||||
if (
|
||||
!config.organizations.enable ||
|
||||
(!config.organizations.enableUsersToCreateOrganizations &&
|
||||
(!config.organizations.requireOrganization ||
|
||||
organizations.length > 0))
|
||||
) {
|
||||
return redirect("/app");
|
||||
}
|
||||
|
||||
return <CreateOrganizationForm />;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { auth } from "@repo/auth";
|
||||
import { getOrganizationById } from "@repo/database";
|
||||
import { OrganizationInvitationModal } from "@saas/organizations/components/OrganizationInvitationModal";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function OrganizationInvitationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ invitationId: string }>;
|
||||
}) {
|
||||
const { invitationId } = await params;
|
||||
|
||||
const invitation = await auth.api.getInvitation({
|
||||
query: {
|
||||
id: invitationId,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
redirect("/app");
|
||||
}
|
||||
|
||||
const organization = await getOrganizationById(invitation.organizationId);
|
||||
|
||||
return (
|
||||
<OrganizationInvitationModal
|
||||
organizationName={invitation.organizationName}
|
||||
organizationSlug={invitation.organizationSlug}
|
||||
logoUrl={organization?.logo || undefined}
|
||||
invitationId={invitationId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { AiChat } from "@saas/ai/components/AiChat";
|
||||
import { aiChatListQueryKey, aiChatQueryKey } from "@saas/ai/lib/api";
|
||||
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { getServerApiClient, getServerQueryClient } from "@shared/lib/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AiDemoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ organizationSlug: string }>;
|
||||
}) {
|
||||
const { organizationSlug } = await params;
|
||||
const organization = await getActiveOrganization(organizationSlug);
|
||||
const queryClient = getServerQueryClient();
|
||||
const apiClient = await getServerApiClient();
|
||||
|
||||
if (!organization) {
|
||||
redirect("/app");
|
||||
}
|
||||
|
||||
const organizationId = organization.id;
|
||||
|
||||
const chats = await (async () => {
|
||||
const response = await apiClient.ai.chats.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch chats");
|
||||
}
|
||||
|
||||
return (await response.json()).chats;
|
||||
})();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: aiChatListQueryKey(organizationId),
|
||||
queryFn: async () => chats,
|
||||
});
|
||||
|
||||
if (chats.length > 0) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: aiChatQueryKey(chats[0].id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.ai.chats[":id"].$get({
|
||||
param: {
|
||||
id: chats[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch chat");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="AI Chatbot"
|
||||
subtitle="This is an example chatbot built with the OpenAI API"
|
||||
/>
|
||||
|
||||
<AiChat organizationId={organizationId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||
import { activeOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { getPurchases } from "@saas/payments/lib/server";
|
||||
import { AppWrapper } from "@saas/shared/components/AppWrapper";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default async function OrganizationLayout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<{
|
||||
organizationSlug: string;
|
||||
}>;
|
||||
}>) {
|
||||
const { organizationSlug } = await params;
|
||||
|
||||
const organization = await getActiveOrganization(organizationSlug);
|
||||
|
||||
if (!organization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: activeOrganizationQueryKey(organizationSlug),
|
||||
queryFn: () => organization,
|
||||
});
|
||||
|
||||
if (config.users.enableBilling) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(organization.id),
|
||||
queryFn: () => getPurchases(organization.id),
|
||||
});
|
||||
}
|
||||
|
||||
return <AppWrapper>{children}</AppWrapper>;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||
import OrganizationStart from "@saas/organizations/components/OrganizationStart";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ organizationSlug: string }>;
|
||||
}) {
|
||||
const { organizationSlug } = await params;
|
||||
|
||||
const activeOrganization = await getActiveOrganization(
|
||||
organizationSlug as string,
|
||||
);
|
||||
|
||||
return {
|
||||
title: activeOrganization?.name,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OrganizationPage({
|
||||
params,
|
||||
}: { params: Promise<{ organizationSlug: string }> }) {
|
||||
const { organizationSlug } = await params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const activeOrganization = await getActiveOrganization(
|
||||
organizationSlug as string,
|
||||
);
|
||||
|
||||
if (!activeOrganization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={activeOrganization.name}
|
||||
subtitle={t("organizations.start.subtitle")}
|
||||
/>
|
||||
|
||||
<OrganizationStart />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||
import { getActiveOrganization } from "@saas/auth/lib/server";
|
||||
import { ActivePlan } from "@saas/payments/components/ActivePlan";
|
||||
import { ChangePlan } from "@saas/payments/components/ChangePlan";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { getPurchases } from "@saas/payments/lib/server";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("settings.billing.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BillingSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ organizationSlug: string }>;
|
||||
}) {
|
||||
const { organizationSlug } = await params;
|
||||
const organization = await getActiveOrganization(organizationSlug);
|
||||
|
||||
if (!organization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const purchases = await getPurchases(organization.id);
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(organization.id),
|
||||
queryFn: () => purchases,
|
||||
});
|
||||
|
||||
const { activePlan } = createPurchasesHelper(purchases);
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
{activePlan && <ActivePlan organizationId={organization.id} />}
|
||||
<ChangePlan
|
||||
organizationId={organization.id}
|
||||
activePlanId={activePlan?.id}
|
||||
/>
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { DeleteOrganizationForm } from "@saas/organizations/components/DeleteOrganizationForm";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("organizations.settings.dangerZone.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
return (
|
||||
<SettingsList>
|
||||
<DeleteOrganizationForm />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { ChangeOrganizationNameForm } from "@saas/organizations/components/ChangeOrganizationNameForm";
|
||||
import { OrganizationLogoForm } from "@saas/organizations/components/OrganizationLogoForm";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("organizations.settings.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
return (
|
||||
<SettingsList>
|
||||
<OrganizationLogoForm />
|
||||
<ChangeOrganizationNameForm />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { config } from "@repo/config";
|
||||
import { getActiveOrganization, getSession } from "@saas/auth/lib/server";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { SettingsMenu } from "@saas/settings/components/SettingsMenu";
|
||||
import { PageHeader } from "@saas/shared/components/PageHeader";
|
||||
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
Settings2Icon,
|
||||
TriangleAlertIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default async function SettingsLayout({
|
||||
children,
|
||||
params,
|
||||
}: PropsWithChildren<{
|
||||
params: Promise<{ organizationSlug: string }>;
|
||||
}>) {
|
||||
const t = await getTranslations();
|
||||
const session = await getSession();
|
||||
const { organizationSlug } = await params;
|
||||
const organization = await getActiveOrganization(organizationSlug);
|
||||
|
||||
if (!organization) {
|
||||
redirect("/app");
|
||||
}
|
||||
|
||||
const userIsOrganizationAdmin = isOrganizationAdmin(
|
||||
organization,
|
||||
session?.user,
|
||||
);
|
||||
|
||||
const organizationSettingsBasePath = `/app/${organizationSlug}/settings`;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: t("settings.menu.organization.title"),
|
||||
avatar: (
|
||||
<OrganizationLogo
|
||||
name={organization.name}
|
||||
logoUrl={organization.logo}
|
||||
/>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
title: t("settings.menu.organization.general"),
|
||||
href: `${organizationSettingsBasePath}/general`,
|
||||
icon: <Settings2Icon className="size-4 opacity-50" />,
|
||||
},
|
||||
{
|
||||
title: t("settings.menu.organization.members"),
|
||||
href: `${organizationSettingsBasePath}/members`,
|
||||
icon: <Users2Icon className="size-4 opacity-50" />,
|
||||
},
|
||||
...(config.organizations.enable &&
|
||||
config.organizations.enableBilling &&
|
||||
userIsOrganizationAdmin
|
||||
? [
|
||||
{
|
||||
title: t("settings.menu.organization.billing"),
|
||||
href: `${organizationSettingsBasePath}/billing`,
|
||||
icon: (
|
||||
<CreditCardIcon className="size-4 opacity-50" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
"settings.menu.organization.dangerZone",
|
||||
),
|
||||
href: `${organizationSettingsBasePath}/danger-zone`,
|
||||
icon: (
|
||||
<TriangleAlertIcon className="size-4 opacity-50" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("organizations.settings.title")}
|
||||
subtitle={t("organizations.settings.subtitle")}
|
||||
/>
|
||||
<SidebarContentLayout
|
||||
sidebar={<SettingsMenu menuItems={menuItems} />}
|
||||
>
|
||||
{children}
|
||||
</SidebarContentLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { getActiveOrganization, getSession } from "@saas/auth/lib/server";
|
||||
import { InviteMemberForm } from "@saas/organizations/components/InviteMemberForm";
|
||||
import { OrganizationMembersBlock } from "@saas/organizations/components/OrganizationMembersBlock";
|
||||
import { SettingsList } from "@saas/shared/components/SettingsList";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("organizations.settings.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OrganizationSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ organizationSlug: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
const { organizationSlug } = await params;
|
||||
const organization = await getActiveOrganization(organizationSlug);
|
||||
|
||||
if (!organization) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsList>
|
||||
{isOrganizationAdmin(organization, session?.user) && (
|
||||
<InviteMemberForm organizationId={organization.id} />
|
||||
)}
|
||||
<OrganizationMembersBlock organizationId={organization.id} />
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/(saas)/app/[...rest]/page.tsx
Normal file
5
apps/web/app/(saas)/app/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function CatchAll() {
|
||||
notFound();
|
||||
}
|
||||
71
apps/web/app/(saas)/app/choose-plan/page.tsx
Normal file
71
apps/web/app/(saas)/app/choose-plan/page.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { config } from "@repo/config";
|
||||
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||
import { getOrganizationList, getSession } from "@saas/auth/lib/server";
|
||||
import { PricingTable } from "@saas/payments/components/PricingTable";
|
||||
import { getPurchases } from "@saas/payments/lib/server";
|
||||
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("choosePlan.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ChoosePlanPage() {
|
||||
const t = await getTranslations();
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
let organizationId: string | undefined;
|
||||
if (config.organizations.enable && config.organizations.enableBilling) {
|
||||
const organization = (await getOrganizationList()).at(0);
|
||||
|
||||
if (!organization) {
|
||||
redirect("/app/new-organization");
|
||||
}
|
||||
|
||||
organizationId = organization.id;
|
||||
}
|
||||
|
||||
const purchases = await getPurchases(organizationId);
|
||||
const { activePlan } = createPurchasesHelper(purchases);
|
||||
|
||||
if (activePlan) {
|
||||
return redirect("/app");
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthWrapper contentClass="max-w-5xl">
|
||||
<div className="mb-4 text-center">
|
||||
<h1 className="text-center font-bold text-2xl lg:text-3xl">
|
||||
{t("choosePlan.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm lg:text-base">
|
||||
{t("choosePlan.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PricingTable
|
||||
{...(organizationId
|
||||
? {
|
||||
organizationId,
|
||||
}
|
||||
: {
|
||||
userId: session.user.id,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
||||
52
apps/web/app/(saas)/app/layout.tsx
Normal file
52
apps/web/app/(saas)/app/layout.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { config } from "@repo/config";
|
||||
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||
import { getOrganizationList, getSession } from "@saas/auth/lib/server";
|
||||
import { ActiveOrganizationProvider } from "@saas/organizations/components/ActiveOrganizationProvider";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { getPurchases } from "@saas/payments/lib/server";
|
||||
import { ConfirmationAlertProvider } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { getServerQueryClient } from "@shared/lib/server";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function Layout({ children }: PropsWithChildren) {
|
||||
const session = await getSession();
|
||||
|
||||
const queryClient = getServerQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: () => session,
|
||||
});
|
||||
|
||||
if (config.organizations.enable) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: organizationListQueryKey,
|
||||
queryFn: getOrganizationList,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.users.enableBilling) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(),
|
||||
queryFn: () => getPurchases(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<SessionProvider>
|
||||
<ActiveOrganizationProvider>
|
||||
<ConfirmationAlertProvider>
|
||||
{children}
|
||||
</ConfirmationAlertProvider>
|
||||
</ActiveOrganizationProvider>
|
||||
</SessionProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/(saas)/app/not-found.tsx
Normal file
5
apps/web/app/(saas)/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { NotFound } from "@marketing/shared/components/NotFound";
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
}
|
||||
35
apps/web/app/(saas)/app/onboarding/page.tsx
Normal file
35
apps/web/app/(saas)/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSession } from "@saas/auth/lib/server";
|
||||
import { OnboardingForm } from "@saas/onboarding/components/OnboardingForm";
|
||||
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("onboarding.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
if (!config.users.enableOnboarding || session.user.onboardingComplete) {
|
||||
return redirect("/app");
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<OnboardingForm />
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
||||
17
apps/web/app/(saas)/auth/forgot-password/page.tsx
Normal file
17
apps/web/app/(saas)/auth/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ForgotPasswordForm } from "@saas/auth/components/ForgotPasswordForm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("auth.forgotPassword.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordForm />;
|
||||
}
|
||||
14
apps/web/app/(saas)/auth/layout.tsx
Normal file
14
apps/web/app/(saas)/auth/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { SessionProvider } from "@saas/auth/components/SessionProvider";
|
||||
import { AuthWrapper } from "@saas/shared/components/AuthWrapper";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export default function AuthLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<AuthWrapper>{children}</AuthWrapper>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
17
apps/web/app/(saas)/auth/login/page.tsx
Normal file
17
apps/web/app/(saas)/auth/login/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { LoginForm } from "@saas/auth/components/LoginForm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("auth.login.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />;
|
||||
}
|
||||
17
apps/web/app/(saas)/auth/reset-password/page.tsx
Normal file
17
apps/web/app/(saas)/auth/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ResetPasswordForm } from "@saas/auth/components/ResetPasswordForm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("auth.resetPassword.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <ResetPasswordForm />;
|
||||
}
|
||||
48
apps/web/app/(saas)/auth/signup/page.tsx
Normal file
48
apps/web/app/(saas)/auth/signup/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { config } from "@repo/config";
|
||||
import { SignupForm } from "@saas/auth/components/SignupForm";
|
||||
import { getInvitation } from "@saas/auth/lib/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { withQuery } from "ufo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("auth.signup.title"),
|
||||
};
|
||||
}
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined;
|
||||
invitationId?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const { invitationId } = params;
|
||||
|
||||
if (!(config.auth.enableSignup || invitationId)) {
|
||||
return redirect(withQuery("/auth/login", params));
|
||||
}
|
||||
|
||||
if (invitationId) {
|
||||
const invitation = await getInvitation(invitationId);
|
||||
|
||||
if (
|
||||
!invitation ||
|
||||
invitation.status !== "pending" ||
|
||||
invitation.expiresAt.getTime() < new Date().getTime()
|
||||
) {
|
||||
return redirect(withQuery("/auth/login", params));
|
||||
}
|
||||
|
||||
return <SignupForm prefillEmail={invitation.email} />;
|
||||
}
|
||||
|
||||
return <SignupForm />;
|
||||
}
|
||||
17
apps/web/app/(saas)/auth/verify/page.tsx
Normal file
17
apps/web/app/(saas)/auth/verify/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { OtpForm } from "@saas/auth/components/OtpForm";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("auth.verify.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OtpForm />;
|
||||
}
|
||||
17
apps/web/app/(saas)/layout.tsx
Normal file
17
apps/web/app/(saas)/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Document } from "@shared/components/Document";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export default async function LocaleLayout({ children }: PropsWithChildren) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<Document locale={locale}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
11
apps/web/app/api/[[...rest]]/route.ts
Normal file
11
apps/web/app/api/[[...rest]]/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { app } from "@repo/api";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const handler = handle(app);
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const OPTIONS = handler;
|
||||
20
apps/web/app/api/docs-search/route.ts
Normal file
20
apps/web/app/api/docs-search/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { config } from "@repo/config";
|
||||
import { createI18nSearchAPI } from "fumadocs-core/search/server";
|
||||
import { docsSource } from "../../docs-source";
|
||||
|
||||
export const { GET } = createI18nSearchAPI("advanced", {
|
||||
i18n: {
|
||||
defaultLanguage: config.i18n.defaultLocale,
|
||||
languages: Object.keys(config.i18n.locales),
|
||||
},
|
||||
indexes: docsSource.getLanguages().flatMap((entry) =>
|
||||
entry.pages.map((page) => ({
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
structuredData: page.data.structuredData,
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
locale: entry.language,
|
||||
})),
|
||||
),
|
||||
});
|
||||
28
apps/web/app/docs-source.ts
Normal file
28
apps/web/app/docs-source.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { createMDXSource } from "@fumadocs/content-collections";
|
||||
import { config } from "@repo/config";
|
||||
import { allDocs, allDocsMetas } from "content-collections";
|
||||
import { loader } from "fumadocs-core/source";
|
||||
import { Home } from "lucide-react";
|
||||
import { createElement } from "react";
|
||||
|
||||
export const docsSource = loader({
|
||||
baseUrl: "/docs",
|
||||
i18n: {
|
||||
defaultLanguage: config.i18n.defaultLocale,
|
||||
languages: Object.keys(config.i18n.locales),
|
||||
},
|
||||
source: createMDXSource(allDocs, allDocsMetas),
|
||||
icon(icon) {
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
Home,
|
||||
};
|
||||
|
||||
if (icon in icons) {
|
||||
return createElement(icons[icon as keyof typeof icons]);
|
||||
}
|
||||
},
|
||||
});
|
||||
BIN
apps/web/app/favicon.ico
Normal file
BIN
apps/web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
42
apps/web/app/globals.css
Normal file
42
apps/web/app/globals.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
@import "@repo/tailwind-config/theme.css";
|
||||
@import "@repo/tailwind-config/tailwind-animate.css";
|
||||
|
||||
@source "../node_modules/fumadocs-ui/dist/**/*.js";
|
||||
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
pre.shiki {
|
||||
@apply mb-4 rounded-lg p-6;
|
||||
}
|
||||
|
||||
#nd-sidebar {
|
||||
@apply bg-card! top-[4.5rem] md:h-[calc(100dvh-4.5rem)]!;
|
||||
|
||||
button[data-search-full] {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
#nd-page .prose {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
a {
|
||||
@apply no-underline!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div[role="tablist"].bg-secondary {
|
||||
@apply bg-muted!;
|
||||
}
|
||||
|
||||
input[cmdk-input] {
|
||||
@apply border-none focus-visible:ring-0;
|
||||
}
|
||||
BIN
apps/web/app/icon.png
Normal file
BIN
apps/web/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
31
apps/web/app/image-proxy/[...path]/route.ts
Normal file
31
apps/web/app/image-proxy/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { config } from "@repo/config";
|
||||
import { getSignedUrl } from "@repo/storage";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const GET = async (
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) => {
|
||||
const { path } = await params;
|
||||
|
||||
const [bucket, filePath] = path;
|
||||
|
||||
if (!(bucket && filePath)) {
|
||||
return new Response("Invalid path", { status: 400 });
|
||||
}
|
||||
|
||||
if (bucket === config.storage.bucketNames.avatars) {
|
||||
const signedUrl = await getSignedUrl(filePath, {
|
||||
bucket,
|
||||
expiresIn: 60 * 60,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(signedUrl, {
|
||||
headers: { "Cache-Control": "max-age=3600" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
17
apps/web/app/layout.tsx
Normal file
17
apps/web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Metadata } from "next";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import "./globals.css";
|
||||
import "cropperjs/dist/cropper.css";
|
||||
import { config } from "@repo/config";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: config.appName,
|
||||
default: config.appName,
|
||||
template: `%s | ${config.appName}`,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
||||
10
apps/web/app/robots.ts
Normal file
10
apps/web/app/robots.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
43
apps/web/app/sitemap.ts
Normal file
43
apps/web/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { getAllPosts } from "@marketing/blog/utils/lib/posts";
|
||||
import { config } from "@repo/config";
|
||||
import { getBaseUrl } from "@repo/utils";
|
||||
import { allLegalPages } from "content-collections";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { docsSource } from "./docs-source";
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const locales = config.i18n.enabled
|
||||
? Object.keys(config.i18n.locales)
|
||||
: [config.i18n.defaultLocale];
|
||||
|
||||
const staticMarketingPages = ["", "/changelog"];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const posts = await getAllPosts();
|
||||
|
||||
return [
|
||||
...staticMarketingPages.flatMap((page) =>
|
||||
locales.map((locale) => ({
|
||||
url: new URL(`/${locale}${page}`, baseUrl).href,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
),
|
||||
...posts.map((post) => ({
|
||||
url: new URL(`/${post.locale}/blog/${post.path}`, baseUrl).href,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...allLegalPages.map((page) => ({
|
||||
url: new URL(`/${page.locale}/legal/${page.path}`, baseUrl).href,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
...docsSource.getLanguages().flatMap((locale) =>
|
||||
docsSource.getPages(locale.language).map((page) => ({
|
||||
url: new URL(
|
||||
`/${locale.language}/docs/${page.slugs.join("/")}`,
|
||||
baseUrl,
|
||||
).href,
|
||||
lastModified: new Date(),
|
||||
})),
|
||||
),
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue