This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View file

@ -0,0 +1 @@
export * from "./provider/custom";

View file

@ -0,0 +1,17 @@
"use client";
export function AnalyticsScript() {
// return your script here
return null;
}
export function useAnalytics() {
const trackEvent = (event: string, data: Record<string, unknown>) => {
// call your analytics service to track a custom event here
console.info("tracking event", event, data);
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,17 @@
"use client";
import { GoogleAnalytics, sendGAEvent } from "@next/third-parties/google";
const googleTagId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID as string;
export function AnalyticsScript() {
return <GoogleAnalytics gaId={googleTagId} />;
}
export function useAnalytics() {
const trackEvent = sendGAEvent;
return {
trackEvent,
};
}

View file

@ -0,0 +1,29 @@
"use client";
// @ts-expect-error package is not installed per default
import mixpanel from "mixpanel-browser";
import { useEffect } from "react";
const mixpanelToken = process.env.NEXT_PUBLIC_MIXPANEL_TOKEN as string;
export function AnalyticsScript() {
useEffect(() => {
mixpanel.init(mixpanelToken, {
debug: true,
track_pageview: true,
persistence: "localStorage",
});
}, []);
return null;
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
mixpanel.track(event, data);
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,33 @@
"use client";
import Script from "next/script";
const pirschCode = process.env.NEXT_PUBLIC_PIRSCH_CODE as string;
export function AnalyticsScript() {
return (
<Script
defer
type="text/javascript"
src="https://api.pirsch.io/pirsch-extended.js"
id="pirschextendedjs"
data-code={pirschCode}
/>
);
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (typeof window === "undefined" || !(window as any).pirsch) {
return;
}
(window as any).pirsch(event, {
meta: data,
});
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,32 @@
"use client";
import Script from "next/script";
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL as string;
export function AnalyticsScript() {
return (
<Script
defer
type="text/javascript"
data-domain={plausibleUrl}
src="https://plausible.io/js/script.js"
/>
);
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (typeof window === "undefined" || !(window as any).plausible) {
return;
}
(window as any).plausible(event, {
props: data,
});
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,35 @@
"use client";
// @ts-expect-error package is not installed per default
import posthog from "posthog-js";
import { useEffect } from "react";
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY as string;
export function AnalyticsScript() {
useEffect(() => {
if (!posthogKey) {
return;
}
posthog.init(posthogKey, {
// use eu.i.posthog.com for european users
api_host: "https://i.posthog.com",
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well
});
}, []);
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (!posthogKey) {
return;
}
posthog.capture(event, data);
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,32 @@
"use client";
import Script from "next/script";
const umamiTrackingId = process.env.NEXT_PUBLIC_UMAMI_TRACKING_ID as string;
export function AnalyticsScript() {
return (
<Script
async
type="text/javascript"
data-website-id={umamiTrackingId}
src="https://analytics.eu.umami.is/script.js"
/>
);
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (typeof window === "undefined" || !(window as any).umami) {
return;
}
(window as any).umami.track(event, {
props: data,
});
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,20 @@
"use client";
// @ts-expect-error package is not installed per default
import { track } from "@vercel/analytics";
// @ts-expect-error package is not installed per default
import { Analytics } from "@vercel/analytics/react";
export function AnalyticsScript() {
return <Analytics />;
}
export function useAnalytics() {
const trackEvent = (event: string, data?: Record<string, unknown>) => {
track(event, data);
};
return {
trackEvent,
};
}

View file

@ -0,0 +1,14 @@
import "server-only";
import { config } from "@repo/config";
import type { Locale } from "@repo/i18n";
import { cookies } from "next/headers";
export async function getUserLocale() {
const cookie = (await cookies()).get(config.i18n.localeCookieName);
return cookie?.value ?? config.i18n.defaultLocale;
}
export async function setLocaleCookie(locale: Locale) {
(await cookies()).set(config.i18n.localeCookieName, locale);
}

View file

@ -0,0 +1,10 @@
"use server";
import { setLocaleCookie } from "@i18n/lib/locale-cookie";
import type { Locale } from "@repo/i18n";
import { revalidatePath } from "next/cache";
export async function updateLocale(locale: Locale) {
await setLocaleCookie(locale);
revalidatePath("/");
}

View file

@ -0,0 +1,22 @@
import { getUserLocale } from "@i18n/lib/locale-cookie";
import { routing } from "@i18n/routing";
import { config } from "@repo/config";
import { getMessagesForLocale } from "@repo/i18n";
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale) {
locale = await getUserLocale();
}
if (!(routing.locales.includes(locale) && config.i18n.enabled)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: await getMessagesForLocale(locale),
};
});

View file

@ -0,0 +1,20 @@
import { config } from "@repo/config";
import { createNavigation } from "next-intl/navigation";
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: Object.keys(config.i18n.locales),
defaultLocale: config.i18n.defaultLocale,
localeCookie: {
name: config.i18n.localeCookieName,
},
localePrefix: config.i18n.enabled ? "always" : "never",
localeDetection: config.i18n.enabled,
});
export const {
Link: LocaleLink,
redirect: localeRedirect,
usePathname: useLocalePathname,
useRouter: useLocaleRouter,
} = createNavigation(routing);

View file

@ -0,0 +1,17 @@
"use client";
import { MDXContent } from "@content-collections/mdx/react";
import { mdxComponents } from "../utils/mdx-components";
export function PostContent({ content }: { content: string }) {
return (
<div className="prose dark:prose-invert mx-auto mt-6 max-w-2xl">
<MDXContent
code={content}
components={{
a: mdxComponents.a,
}}
/>
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { LocaleLink } from "@i18n/routing";
import type { Post } from "@marketing/blog/types";
import Image from "next/image";
export function PostListItem({ post }: { post: Post }) {
const { title, excerpt, authorName, image, date, path, authorImage, tags } =
post;
return (
<div className="rounded-2xl border bg-card/50 p-6">
{image && (
<div className="-mx-4 -mt-4 relative mb-4 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"
/>
<LocaleLink
href={`/blog/${path}`}
className="absolute inset-0"
/>
</div>
)}
{tags && (
<div className="mb-2 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="font-semibold text-primary text-xs uppercase tracking-wider"
>
#{tag}
</span>
))}
</div>
)}
<LocaleLink
href={`/blog/${path}`}
className="font-semibold text-xl"
>
{title}
</LocaleLink>
{excerpt && <p className="opacity-50">{excerpt}</p>}
<div className="mt-4 flex items-center justify-between">
{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>
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
import type { Post as ContentCollectionsPost } from "content-collections";
export type Post = Omit<ContentCollectionsPost, "_meta">;

View file

@ -0,0 +1,25 @@
import type { Post } from "@marketing/blog/types";
import { allPosts } from "content-collections";
export async function getAllPosts(): Promise<Post[]> {
// ... add a custom loader here for your posts and map it to the post schema
return Promise.resolve(allPosts);
}
export async function getPostBySlug(
slug: string,
options?: {
locale?: string;
},
): Promise<Post | null> {
// ... add a custom loader here for your posts and map it to the post schema
return Promise.resolve(
allPosts.find(
(post) =>
post.path === slug &&
(!options?.locale || post.locale === options.locale),
) ?? null,
);
}

View file

@ -0,0 +1,103 @@
import { LocaleLink } from "@i18n/routing";
import { slugifyHeadline } from "@shared/lib/content";
import type { MDXComponents } from "mdx/types";
import type { ImageProps } from "next/image";
import Image from "next/image";
export const mdxComponents = {
a: (props) => {
const { href, children, ...rest } = props;
const isInternalLink =
href && (href.startsWith("/") || href.startsWith("#"));
return isInternalLink ? (
<LocaleLink href={href} {...rest}>
{children}
</LocaleLink>
) : (
<a target="_blank" rel="noopener noreferrer" href={href} {...rest}>
{children}
</a>
);
},
img: (props) =>
props.src ? (
<Image
{...(props as ImageProps)}
sizes="100vw"
style={{ width: "100%", height: "auto" }}
className="rounded-lg shadow"
loading="lazy"
/>
) : null,
h1: ({ children, ...rest }) => (
<h1
id={slugifyHeadline(children as string)}
className="mb-6 font-bold text-4xl"
{...rest}
>
{children}
</h1>
),
h2: ({ children, ...rest }) => (
<h2
id={slugifyHeadline(children as string)}
className="mb-4 font-bold text-2xl"
{...rest}
>
{children}
</h2>
),
h3: ({ children, ...rest }) => (
<h3
id={slugifyHeadline(children as string)}
className="mb-4 font-bold text-xl"
{...rest}
>
{children}
</h3>
),
h4: ({ children, ...rest }) => (
<h4
id={slugifyHeadline(children as string)}
className="mb-4 font-bold text-lg"
{...rest}
>
{children}
</h4>
),
h5: ({ children, ...rest }) => (
<h5
id={slugifyHeadline(children as string)}
className="mb-4 font-bold text-base"
{...rest}
>
{children}
</h5>
),
h6: ({ children, ...rest }) => (
<h6
id={slugifyHeadline(children as string)}
className="mb-4 font-bold text-sm"
{...rest}
>
{children}
</h6>
),
p: ({ children, ...rest }) => (
<p className="mb-6 text-foreground/60 leading-relaxed" {...rest}>
{children}
</p>
),
ul: ({ children, ...rest }) => (
<ul className="mb-6 list-inside list-disc space-y-2 pl-4" {...rest}>
{children}
</ul>
),
ol: ({ children, ...rest }) => (
<ol className="mb-6 list-inside list-decimal space-y-2 pl-4" {...rest}>
{children}
</ol>
),
li: ({ children, ...rest }) => <li {...rest}>{children}</li>,
} satisfies MDXComponents;

View file

@ -0,0 +1,31 @@
import { formatDate, formatDistance, parseISO } from "date-fns";
import type { ChangelogItem } from "../types";
export function ChangelogSection({ items }: { items: ChangelogItem[] }) {
return (
<section id="changelog">
<div className="mx-auto grid w-full max-w-xl grid-cols-1 gap-4 text-left">
{items?.map((item, i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<small
className="inline-block rounded-full border border-highlight/50 px-2 py-0.5 font-semibold text-highlight text-xs"
title={formatDate(
parseISO(item.date),
"yyyy-MM-dd",
)}
>
{formatDistance(parseISO(item.date), new Date(), {
addSuffix: true,
})}
</small>
<ul className="mt-4 list-disc space-y-2 pl-6">
{item.changes.map((change, j) => (
<li key={j}>{change}</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,6 @@
import type { ReactNode } from "react";
export type ChangelogItem = {
date: string;
changes: ReactNode[];
};

View file

@ -0,0 +1,132 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useContactFormMutation } from "@marketing/home/lib/api";
import {
type ContactFormValues,
contactFormSchema,
} from "@repo/api/src/routes/contact/types";
import { Alert, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import { Textarea } from "@ui/components/textarea";
import { MailCheckIcon, MailIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
export function ContactForm() {
const t = useTranslations();
const contactFormMutation = useContactFormMutation();
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: "",
email: "",
message: "",
},
});
const onSubmit = form.handleSubmit(async (values) => {
try {
await contactFormMutation.mutateAsync(values);
} catch {
form.setError("root", {
message: t("contact.form.notifications.error"),
});
}
});
return (
<div>
{form.formState.isSubmitSuccessful ? (
<Alert variant="success">
<MailCheckIcon />
<AlertTitle>
{t("contact.form.notifications.success")}
</AlertTitle>
</Alert>
) : (
<Form {...form}>
<form
onSubmit={onSubmit}
className="flex flex-col items-stretch gap-4"
>
{form.formState.errors.root?.message && (
<Alert variant="error">
<MailIcon />
<AlertTitle>
{form.formState.errors.root.message}
</AlertTitle>
</Alert>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contact.form.name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contact.form.email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contact.form.message")}
</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
loading={form.formState.isSubmitting}
>
{t("contact.form.submit")}
</Button>
</form>
</Form>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
import { cn } from "@ui/lib";
import { useTranslations } from "next-intl";
export function FaqSection({ className }: { className?: string }) {
const t = useTranslations();
const items = [
{
question: "What is the refund policy?",
answer: "We offer a 30-day money-back guarantee if you're not happy with our product.",
},
{
question: "How do I cancel my subscription?",
answer: "You can cancel your subscription by visiting the billing page.",
},
{
question: "Can I change my plan?",
answer: "Yes, you can change your plan at any time by visiting the billing page.",
},
{
question: "Do you offer a free trial?",
answer: "Yes, we offer a 14-day free trial.",
},
];
if (!items) {
return null;
}
return (
<section
className={cn("scroll-mt-20 border-t py-12 lg:py-16", className)}
id="faq"
>
<div className="container max-w-5xl">
<div className="mb-12 lg:text-center">
<h1 className="mb-2 font-bold text-4xl lg:text-5xl">
{t("faq.title")}
</h1>
<p className="text-lg opacity-50">{t("faq.description")}</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{items.map((item, i) => (
<div
key={`faq-item-${i}`}
className="rounded-lg bg-card border p-4 lg:p-6"
>
<h4 className="mb-2 font-semibold text-lg">
{item.question}
</h4>
<p className="text-foreground/60">{item.answer}</p>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,281 @@
"use client";
import { MobileIcon } from "@radix-ui/react-icons";
import { cn } from "@ui/lib";
import {
CloudIcon,
ComputerIcon,
PaperclipIcon,
StarIcon,
WandIcon,
} from "lucide-react";
import Image, { type StaticImageData } from "next/image";
import { type JSXElementConstructor, type ReactNode, useState } from "react";
import heroImage from "../../../../public/images/hero.svg";
export const featureTabs: Array<{
id: string;
title: string;
icon: JSXElementConstructor<any>;
subtitle?: string;
description?: ReactNode;
image?: StaticImageData;
imageBorder?: boolean;
stack?: {
title: string;
href: string;
icon: JSXElementConstructor<any>;
}[];
highlights?: {
title: string;
description: string;
icon: JSXElementConstructor<any>;
demoLink?: string;
docsLink?: string;
}[];
}> = [
{
id: "feature1",
title: "Feature 1",
icon: StarIcon,
subtitle: "Do more with our amazing SaaS.",
description:
"This is a brilliant feature. And below you can see some reasons why. This is basically just a dummy text.",
stack: [],
image: heroImage,
imageBorder: false,
highlights: [
{
title: "Benefit 1",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: WandIcon,
},
{
title: "Benefit 2",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: ComputerIcon,
},
{
title: "Benefit 3",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: MobileIcon,
},
],
},
{
id: "feature2",
title: "Feature 2",
icon: CloudIcon,
subtitle: "Your SaaS can also do this.",
description: "Another dummy text for another feature.",
stack: [],
image: heroImage,
imageBorder: false,
highlights: [
{
title: "Benefit 1",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: WandIcon,
},
{
title: "Benefit 2",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: ComputerIcon,
},
{
title: "Benefit 3",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: MobileIcon,
},
],
},
{
id: "feature3",
title: "Feature 3",
icon: PaperclipIcon,
subtitle: "We even got a third one.",
description:
"Of course your SaaS will have more features than this, but this is just a dummy text.",
stack: [],
image: heroImage,
imageBorder: false,
highlights: [
{
title: "Benefit 1",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: WandIcon,
},
{
title: "Benefit 2",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: ComputerIcon,
},
{
title: "Benefit 3",
description:
"This is an awesome benefit. And below you can see some reasons why. This is basically just a dummy text.",
icon: MobileIcon,
},
],
},
];
export function Features() {
const [selectedTab, setSelectedTab] = useState(featureTabs[0].id);
return (
<section id="features" className="scroll-my-20 pt-12 lg:pt-16">
<div className="container max-w-5xl">
<div className="mx-auto mb-6 lg:mb-0 lg:max-w-5xl lg:text-center">
<h2 className="font-bold text-4xl lg:text-5xl">
Features your clients will love
</h2>
<p className="mt-6 text-balance text-lg opacity-50">
In this section you can showcase all the features of
your SaaS provides and how they can benefit your
clients.
</p>
</div>
<div className="mt-8 mb-4 hidden justify-center lg:flex">
{featureTabs.map((tab) => {
return (
<button
type="button"
key={tab.id}
onClick={() => setSelectedTab(tab.id)}
className={cn(
"flex w-24 flex-col items-center gap-2 rounded-lg px-4 py-2 md:w-32",
selectedTab === tab.id
? "bg-primary/5 font-bold text-primary dark:bg-primary/10"
: "font-medium text-foreground/80",
)}
>
<tab.icon
className={cn(
"size-6 md:size-8",
selectedTab === tab.id
? "text-primary"
: "text-foreground opacity-30",
)}
/>
<span className="text-xs md:text-sm">
{tab.title}
</span>
</button>
);
})}
</div>
</div>
<div>
<div className="container max-w-5xl">
{featureTabs.map((tab) => {
const filteredStack = tab.stack || [];
const filteredHighlights = tab.highlights || [];
return (
<div
key={tab.id}
className={cn(
"border-t py-8 first:border-t-0 md:py-12 lg:border lg:first:border-t lg:rounded-3xl lg:p-6",
selectedTab === tab.id
? "block"
: "block lg:hidden",
)}
>
<div className="grid grid-cols-1 items-center gap-8 md:grid-cols-2 lg:gap-12">
<div>
<h3 className="font-normal text-2xl text-foreground/60 leading-normal md:text-3xl">
<strong className="text-secondary">
{tab.title}.{" "}
</strong>
{tab.subtitle}
</h3>
{tab.description && (
<p className="mt-4 text-foreground/60">
{tab.description}
</p>
)}
{filteredStack?.length > 0 && (
<div className="mt-4 flex flex-wrap gap-6">
{filteredStack.map(
(tool, k) => (
<a
href={tool.href}
target="_blank"
key={`stack-tool-${k}`}
className="flex items-center gap-2"
rel="noreferrer"
>
<tool.icon className="size-6" />
<strong className="block text-sm">
{tool.title}
</strong>
</a>
),
)}
</div>
)}
</div>
<div>
{tab.image && (
<Image
src={tab.image}
alt={tab.title}
className={cn(
" h-auto w-full max-w-xl",
{
"rounded-2xl border-4 border-secondary/10":
tab.imageBorder,
},
)}
/>
)}
</div>
</div>
{filteredHighlights.length > 0 && (
<div className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredHighlights.map(
(highlight, k) => (
<div
key={`highlight-${k}`}
className="flex flex-col items-stretch justify-between rounded-xl bg-card border p-4"
>
<div>
<highlight.icon
className="text-primary text-xl"
width="1em"
height="1em"
/>
<strong className="mt-2 block">
{highlight.title}
</strong>
<p className="mt-1 text-sm opacity-50">
{
highlight.description
}
</p>
</div>
</div>
),
)}
</div>
)}
</div>
);
})}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,180 @@
import { LocaleLink } from "@i18n/routing";
import { Button } from "@ui/components/button";
import { ArrowRightIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import heroImageDark from "../../../../public/images/hero-image-dark.png";
import heroImage from "../../../../public/images/hero-image.png";
export function Hero() {
return (
<div className="relative max-w-full overflow-x-hidden bg-linear-to-b from-0% from-card to-[50vh] to-background">
<div className="absolute left-1/2 z-10 ml-[-500px] h-[500px] w-[1000px] rounded-full bg-linear-to-r from-primary to-bg opacity-20 blur-[150px]" />
<div className="container relative z-20 pt-44 pb-12 text-center lg:pb-16">
<div className="mb-4 flex justify-center">
<div className="mx-auto flex flex-wrap items-center justify-center rounded-full border border-highlight/30 p-px px-4 py-1 font-normal text-highlight text-sm shadow-sm">
<span className="flex items-center gap-2 rounded-full font-semibold text-highlight">
<span className="size-2 rounded-full bg-highlight" />
New:
</span>
<span className="ml-1 block font-medium text-foreground">
Amazing feature of your SaaS
</span>
</div>
</div>
<h1 className="mx-auto max-w-3xl text-balance font-bold text-5xl lg:text-7xl">
Your revolutionary Next.js SaaS
</h1>
<p className="mx-auto mt-4 max-w-lg text-balance text-foreground/60 text-lg">
This is a demo application built with supastarter. It will
save you a lot of time and effort building your next SaaS.
</p>
<div className="mt-6 flex flex-col items-center justify-center gap-3 md:flex-row">
<Button size="lg" variant="primary" asChild>
<Link href="/auth/login">
Get started
<ArrowRightIcon className="ml-2 size-4" />
</Link>
</Button>
<Button variant="light" size="lg" asChild>
<LocaleLink href="/docs">Documentation</LocaleLink>
</Button>
</div>
<div className="mt-16 px-8 text-center">
<h5 className="font-semibold text-foreground/50 text-xs uppercase tracking-wider">
Built & shipped with these awesome tools
</h5>
<div className="mt-4 flex flex-col-reverse items-center justify-center gap-4 text-foreground/50 md:flex-row md:gap-8">
<a href="https://supastarter.dev">
<svg
viewBox="0 0 631 236"
className="h-8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>supastarter</title>
<path
d="M253.446 103.607C245.506 103.607 239.038 101.854 234.04 98.3478C229.043 94.8414 225.98 89.9043 224.851 83.5365L237.788 81.5415C238.594 84.927 240.448 87.6071 243.35 89.5819C246.292 91.5165 249.94 92.4837 254.292 92.4837C258.242 92.4837 261.325 91.6777 263.542 90.0656C265.799 88.4534 266.927 86.2368 266.927 83.4156C266.927 81.7632 266.524 80.4332 265.718 79.4256C264.952 78.3778 263.32 77.3903 260.821 76.4634C258.323 75.5364 254.514 74.3878 249.395 73.0175C243.793 71.5666 239.34 70.0149 236.035 68.3625C232.771 66.6698 230.433 64.7151 229.022 62.4985C227.652 60.2415 226.967 57.5211 226.967 54.3371C226.967 50.3875 228.015 46.9416 230.111 43.9995C232.206 41.0574 235.148 38.7803 238.937 37.1682C242.766 35.5561 247.239 34.75 252.358 34.75C257.355 34.75 261.809 35.5359 265.718 37.1077C269.627 38.6795 272.791 40.9163 275.209 43.8181C277.628 46.6796 279.078 50.0449 279.562 53.914L266.625 56.2717C266.182 53.1281 264.711 50.6494 262.212 48.8358C259.713 47.0222 256.469 46.0348 252.479 45.8736C248.65 45.7124 245.547 46.3572 243.169 47.8081C240.791 49.2187 239.602 51.1734 239.602 53.6722C239.602 55.1231 240.045 56.3523 240.932 57.3599C241.859 58.3674 243.632 59.3347 246.252 60.2617C248.872 61.1886 252.741 62.2969 257.859 63.5866C263.34 64.9972 267.693 66.569 270.917 68.3021C274.141 69.9948 276.439 72.0301 277.809 74.4079C279.22 76.7455 279.925 79.5868 279.925 82.932C279.925 89.3804 277.567 94.4384 272.852 98.106C268.177 101.774 261.708 103.607 253.446 103.607Z"
fill="currentColor"
/>
<path
d="M314.279 103.607C309.765 103.607 305.976 102.882 302.913 101.431C299.85 99.98 297.352 98.0858 295.417 95.7482C293.523 93.3704 292.072 90.791 291.064 88.0101C290.057 85.2292 289.372 82.5088 289.009 79.8488C288.646 77.1888 288.465 74.8513 288.465 72.8361V36.5636H301.281V68.6648C301.281 71.2039 301.483 73.8235 301.886 76.5238C302.329 79.1838 303.135 81.6624 304.304 83.9597C305.513 86.2569 307.186 88.1109 309.322 89.5215C311.498 90.9321 314.319 91.6374 317.785 91.6374C320.042 91.6374 322.178 91.2746 324.193 90.5492C326.208 89.7834 327.962 88.5744 329.453 86.9219C330.984 85.2695 332.173 83.0932 333.02 80.3929C333.906 77.6926 334.35 74.4079 334.35 70.5389L342.209 73.5011C342.209 79.4256 341.1 84.665 338.884 89.2192C336.667 93.7331 333.483 97.2596 329.332 99.7987C325.181 102.338 320.163 103.607 314.279 103.607ZM335.861 101.854V82.9924H334.35V36.5636H347.105V101.854H335.861Z"
fill="currentColor"
/>
<path
d="M391.084 103.668C384.837 103.668 379.598 102.156 375.366 99.1337C371.134 96.0707 367.93 91.9396 365.754 86.7406C363.577 81.5415 362.489 75.6775 362.489 69.1484C362.489 62.6194 363.557 56.7553 365.693 51.5563C367.87 46.3572 371.053 42.2665 375.245 39.2841C379.477 36.2614 384.676 34.75 390.842 34.75C396.968 34.75 402.248 36.2614 406.681 39.2841C411.155 42.2665 414.601 46.3572 417.019 51.5563C419.437 56.715 420.646 62.5791 420.646 69.1484C420.646 75.6775 419.437 81.5617 417.019 86.801C414.641 92.0001 411.235 96.111 406.802 99.1337C402.409 102.156 397.17 103.668 391.084 103.668ZM360.373 130.872V36.5636H371.618V83.5365H373.069V130.872H360.373ZM389.331 92.2419C393.361 92.2419 396.686 91.2142 399.306 89.1587C401.966 87.1033 403.941 84.3426 405.23 80.8765C406.56 77.3702 407.225 73.4608 407.225 69.1484C407.225 64.8763 406.56 61.0073 405.23 57.5412C403.941 54.0752 401.946 51.3144 399.245 49.259C396.545 47.2036 393.099 46.1758 388.908 46.1758C384.958 46.1758 381.693 47.1431 379.114 49.0776C376.575 51.0122 374.681 53.7125 373.431 57.1785C372.222 60.6445 371.618 64.6345 371.618 69.1484C371.618 73.6623 372.222 77.6523 373.431 81.1183C374.64 84.5844 376.555 87.3048 379.174 89.2797C381.794 91.2545 385.18 92.2419 389.331 92.2419Z"
fill="currentColor"
/>
<path
d="M448.805 103.668C443.969 103.668 439.919 102.781 436.654 101.008C433.39 99.1941 430.911 96.8163 429.218 93.8742C427.566 90.8918 426.74 87.6272 426.74 84.0806C426.74 80.7758 427.324 77.874 428.493 75.3752C429.662 72.8764 431.395 70.7605 433.692 69.0275C435.989 67.2542 438.81 65.8234 442.155 64.7353C445.057 63.8889 448.342 63.1433 452.009 62.4985C455.677 61.8536 459.526 61.2491 463.556 60.6848C467.627 60.1206 471.657 59.5564 475.647 58.9921L471.053 61.5312C471.133 56.4127 470.045 52.6243 467.788 50.1658C465.571 47.667 461.743 46.4177 456.302 46.4177C452.876 46.4177 449.732 47.2237 446.871 48.8358C444.009 50.4076 442.014 53.0273 440.886 56.6949L429.097 53.0676C430.709 47.4655 433.772 43.0121 438.286 39.7072C442.841 36.4024 448.886 34.75 456.423 34.75C462.267 34.75 467.345 35.7576 471.657 37.7727C476.01 39.7475 479.194 42.8912 481.209 47.2036C482.257 49.3396 482.902 51.5966 483.143 53.9744C483.385 56.3523 483.506 58.9115 483.506 61.6521V101.854H472.322V86.9219L474.498 88.8565C471.798 93.854 468.352 97.582 464.161 100.04C460.01 102.459 454.891 103.668 448.805 103.668ZM451.042 93.3301C454.629 93.3301 457.712 92.7054 460.292 91.456C462.871 90.1663 464.947 88.5341 466.518 86.5592C468.09 84.5844 469.118 82.5289 469.602 80.3929C470.287 78.4584 470.67 76.282 470.75 73.8638C470.871 71.4457 470.932 69.5111 470.932 68.0602L475.043 69.5716C471.053 70.1761 467.425 70.7202 464.161 71.2039C460.896 71.6875 457.934 72.1711 455.274 72.6548C452.654 73.0981 450.317 73.6422 448.261 74.287C446.528 74.8916 444.977 75.617 443.606 76.4634C442.276 77.3097 441.208 78.3375 440.402 79.5465C439.637 80.7556 439.254 82.2267 439.254 83.9597C439.254 85.6524 439.677 87.2242 440.523 88.6751C441.37 90.0857 442.659 91.2142 444.392 92.0605C446.125 92.9069 448.342 93.3301 451.042 93.3301Z"
fill="currentColor"
/>
<path
d="M256.278 215.015C247.597 215.015 240.544 213.062 235.119 209.156C229.694 205.249 226.395 199.759 225.223 192.684L241.239 190.21C242.064 193.683 243.886 196.417 246.708 198.413C249.529 200.41 253.088 201.408 257.385 201.408C261.161 201.408 264.069 200.67 266.109 199.195C268.192 197.676 269.234 195.614 269.234 193.01C269.234 191.404 268.843 190.123 268.062 189.169C267.324 188.17 265.675 187.215 263.114 186.304C260.553 185.393 256.625 184.242 251.33 182.853C245.427 181.291 240.74 179.62 237.267 177.84C233.795 176.018 231.3 173.869 229.78 171.395C228.261 168.921 227.502 165.926 227.502 162.411C227.502 158.027 228.652 154.208 230.952 150.952C233.253 147.697 236.465 145.201 240.588 143.465C244.711 141.686 249.572 140.796 255.171 140.796C260.64 140.796 265.479 141.642 269.69 143.335C273.943 145.028 277.372 147.437 279.976 150.562C282.58 153.687 284.186 157.354 284.794 161.564L268.778 164.429C268.387 161.434 267.02 159.069 264.676 157.333C262.376 155.596 259.294 154.62 255.432 154.403C251.742 154.186 248.769 154.75 246.512 156.096C244.255 157.398 243.127 159.242 243.127 161.629C243.127 162.975 243.583 164.125 244.494 165.08C245.406 166.035 247.228 166.99 249.963 167.945C252.741 168.899 256.864 170.071 262.333 171.46C267.932 172.893 272.402 174.542 275.744 176.408C279.13 178.231 281.56 180.423 283.036 182.984C284.555 185.544 285.315 188.648 285.315 192.294C285.315 199.368 282.732 204.924 277.567 208.96C272.446 212.997 265.349 215.015 256.278 215.015Z"
fill="currentColor"
/>
<path
d="M336.879 213.062C332.235 213.93 327.678 214.299 323.207 214.169C318.78 214.082 314.809 213.279 311.293 211.76C307.778 210.197 305.108 207.745 303.285 204.403C301.68 201.365 300.833 198.262 300.746 195.093C300.66 191.925 300.616 188.344 300.616 184.351V123.218H316.241V183.439C316.241 186.261 316.263 188.735 316.306 190.861C316.393 192.988 316.849 194.724 317.674 196.07C319.236 198.674 321.732 200.128 325.161 200.432C328.589 200.736 332.496 200.562 336.879 199.911V213.062ZM287.856 155.054V142.749H336.879V155.054H287.856Z"
fill="currentColor"
/>
<path
d="M367.754 215.015C362.676 215.015 358.379 214.06 354.863 212.151C351.348 210.197 348.679 207.615 346.856 204.403C345.076 201.191 344.186 197.654 344.186 193.791C344.186 190.406 344.751 187.367 345.879 184.676C347.008 181.942 348.744 179.598 351.087 177.645C353.431 175.649 356.469 174.021 360.202 172.762C363.023 171.851 366.322 171.026 370.098 170.288C373.917 169.55 378.041 168.878 382.468 168.27C386.938 167.619 391.604 166.925 396.465 166.187L390.866 169.377C390.91 164.516 389.825 160.935 387.611 158.635C385.398 156.334 381.665 155.184 376.413 155.184C373.245 155.184 370.185 155.922 367.233 157.398C364.282 158.873 362.22 161.412 361.048 165.015L346.725 160.523C348.462 154.576 351.76 149.802 356.621 146.2C361.526 142.597 368.123 140.796 376.413 140.796C382.663 140.796 388.154 141.816 392.885 143.856C397.659 145.896 401.196 149.238 403.497 153.882C404.755 156.356 405.515 158.895 405.775 161.499C406.036 164.06 406.166 166.859 406.166 169.898V213.062H392.429V197.828L394.707 200.301C391.539 205.38 387.828 209.112 383.575 211.499C379.364 213.843 374.091 215.015 367.754 215.015ZM370.879 202.515C374.438 202.515 377.476 201.886 379.994 200.627C382.511 199.368 384.508 197.828 385.983 196.005C387.503 194.182 388.523 192.467 389.043 190.861C389.868 188.865 390.324 186.586 390.411 184.025C390.541 181.421 390.606 179.316 390.606 177.71L395.424 179.143C390.693 179.88 386.634 180.531 383.249 181.096C379.864 181.66 376.956 182.202 374.525 182.723C372.094 183.201 369.946 183.743 368.08 184.351C366.257 185.002 364.716 185.761 363.457 186.63C362.199 187.498 361.222 188.496 360.528 189.624C359.877 190.753 359.551 192.077 359.551 193.596C359.551 195.332 359.985 196.873 360.853 198.218C361.721 199.52 362.98 200.562 364.629 201.343C366.322 202.124 368.405 202.515 370.879 202.515Z"
fill="currentColor"
/>
<path
d="M419.209 213.062V142.749H433.076V159.872L431.384 157.658C432.252 155.314 433.402 153.188 434.834 151.278C436.31 149.325 438.068 147.719 440.108 146.46C441.844 145.288 443.753 144.377 445.837 143.726C447.964 143.031 450.286 142.879 452.5 142.749C455 142.749 457.5 142.749 459.498 142.749V155.054C454 155.054 453 154.854 450 155.354C447 155.854 445.5 156.774 443.5 157.854C441.5 158.934 440.5 159.854 438.5 163.354C436.5 166.854 436.592 167.489 435.941 169.833C435.29 172.133 434.964 174.629 434.964 177.32V213.062H419.209Z"
fill="currentColor"
/>
<path
d="M508.521 213.062C503.877 213.93 499.32 214.299 494.849 214.169C490.422 214.082 486.451 213.279 482.935 211.76C479.42 210.197 476.75 207.745 474.927 204.403C473.321 201.365 472.475 198.262 472.388 195.093C472.301 191.925 472.258 188.344 472.258 184.351V123.218H487.883V183.439C487.883 186.261 487.905 188.735 487.948 190.861C488.035 192.988 488.491 194.724 489.315 196.07C490.878 198.674 493.374 200.128 496.802 200.432C500.231 200.736 504.138 200.562 508.521 199.911V213.062ZM459.498 155.054V142.749H508.521V155.054H459.498Z"
fill="currentColor"
/>
<path
d="M548.14 215.015C541.022 215.015 534.772 213.474 529.39 210.393C524.008 207.311 519.798 203.036 516.76 197.567C513.765 192.098 512.268 185.805 512.268 178.687C512.268 171.004 513.744 164.342 516.695 158.7C519.646 153.014 523.748 148.609 529 145.484C534.251 142.358 540.328 140.796 547.229 140.796C554.521 140.796 560.706 142.51 565.784 145.939C570.905 149.325 574.703 154.121 577.177 160.327C579.651 166.534 580.584 173.847 579.977 182.268H564.417V176.538C564.373 168.899 563.028 163.322 560.38 159.807C557.732 156.291 553.566 154.533 547.88 154.533C541.456 154.533 536.682 156.53 533.557 160.523C530.432 164.472 528.869 170.267 528.869 177.906C528.869 185.024 530.432 190.536 533.557 194.442C536.682 198.348 541.239 200.301 547.229 200.301C551.092 200.301 554.412 199.455 557.19 197.762C560.011 196.026 562.181 193.531 563.7 190.275L579.195 194.963C576.504 201.3 572.338 206.226 566.695 209.742C561.096 213.257 554.911 215.015 548.14 215.015ZM523.922 182.268V170.419H572.294V182.268H523.922Z"
fill="currentColor"
/>
<path
d="M590.597 213.062V142.749H604.464V159.872L602.771 157.658C603.639 155.314 604.789 153.188 606.222 151.278C607.697 149.325 609.455 147.719 611.495 146.46C613.231 145.288 615.141 144.377 617.224 143.726C619.351 143.031 621.521 142.619 623.735 142.489C625.948 142.315 628.097 142.402 630.18 142.749V157.398C628.097 156.79 625.688 156.595 622.954 156.812C620.263 157.029 617.832 157.788 615.662 159.09C613.492 160.262 611.712 161.76 610.323 163.583C608.978 165.405 607.98 167.489 607.329 169.833C606.677 172.133 606.352 174.629 606.352 177.32V213.062H590.597Z"
fill="currentColor"
/>
<path
d="M95.7907 166.232L68.7734 227.157M102.266 210.956L95.365 226.488M55.6706 190.252L48.7694 205.784M64.8725 169.543L46.752 161.491M46.752 161.491C46.752 161.491 7.7812 168.973 7.49269 162.645C7.20417 156.318 18.1315 131.726 24.7459 123.816C31.3603 115.906 70.9066 107.13 70.9066 107.13M46.752 161.491L70.9066 107.13M70.9066 107.13C70.9066 107.13 98.7288 59.0474 118.356 35.2215C137.984 11.3955 155.894 4.73319 164.307 8.47134C172.72 12.2095 179.833 29.9907 175.306 60.5262C170.779 91.0618 153.743 143.937 153.743 143.937M111.468 190.247L129.589 198.298M129.589 198.298C129.589 198.298 150.156 232.235 155.045 228.208C159.935 224.181 170.861 199.588 172.299 189.378C173.736 179.168 153.743 143.937 153.743 143.937M129.589 198.298L153.743 143.937M138.712 106.26C135.218 114.124 126.011 117.666 118.148 114.172C110.285 110.678 106.743 101.471 110.237 93.608C113.73 85.7448 122.937 82.2028 130.8 85.6967C138.663 89.1905 142.205 98.3972 138.712 106.26Z"
stroke="currentColor"
strokeWidth="14.1634"
strokeLinecap="round"
/>
</svg>
</a>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 fill-current"
viewBox="0 0 284 65"
>
<title>Vercel</title>
<path d="M141.68 16.25c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zm117.14-14.5c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zm-39.03 3.5c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9v-46h9zM37.59.25l36.95 64H.64l36.95-64zm92.38 5l-27.71 48-27.71-48h10.39l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10v14.8h-9v-34h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" />
</svg>
<svg
fill="none"
className="h-8"
viewBox="1.372 -.18543865 324.553 128.18543865"
xmlns="http://www.w3.org/2000/svg"
>
<title>Prisma</title>
<g fill="currentColor">
<path d="m199.202 85.75h8.638v-31.662h-8.638zm-.367-39.847c0-2.813 1.567-4.219 4.701-4.219 3.133 0 4.701 1.406 4.701 4.219 0 1.341-.392 2.384-1.175 3.13-.784.746-1.959 1.118-3.526 1.118-3.134 0-4.701-1.416-4.701-4.248z" />
<path
clipRule="evenodd"
d="m164.253 67.483c2.786-2.36 4.178-5.767 4.178-10.223 0-4.286-1.307-7.51-3.922-9.672-2.615-2.16-6.433-3.242-11.456-3.242h-13.225v41.404h8.779v-14.727h3.767c5.135 0 9.095-1.179 11.879-3.54zm-12.757-3.653h-2.889v-12.29h3.993c2.398 0 4.158.49 5.282 1.472 1.123.982 1.685 2.502 1.685 4.56 0 2.038-.67 3.591-2.011 4.658s-3.36 1.6-6.06 1.6z"
fillRule="evenodd"
/>
<path d="m194.62 53.748c-.774-.17-1.746-.255-2.917-.255-1.964 0-3.781.543-5.451 1.628a11.908 11.908 0 0 0 -3.98 4.291h-.424l-1.275-5.324h-6.542v31.662h8.638v-16.114c0-2.549.769-4.532 2.307-5.948 1.54-1.416 3.687-2.124 6.444-2.124 1.001 0 1.85.095 2.549.283zm40.245 30.02c2.257-1.7 3.385-4.172 3.385-7.42 0-1.567-.273-2.917-.821-4.05-.547-1.133-1.398-2.133-2.549-3.002-1.151-.868-2.964-1.802-5.438-2.803-2.775-1.114-4.573-1.955-5.394-2.521s-1.233-1.236-1.233-2.011c0-1.378 1.275-2.067 3.824-2.067 1.434 0 2.841.217 4.219.65 1.378.436 2.861.992 4.447 1.672l2.605-6.23c-3.606-1.661-7.316-2.492-11.13-2.492-4.003 0-7.093.769-9.273 2.308-2.183 1.539-3.273 3.714-3.273 6.527 0 1.643.26 3.026.78 4.149.518 1.124 1.349 2.12 2.493 2.988 1.14.869 2.931 1.813 5.365 2.832 1.699.718 3.059 1.345 4.079 1.883 1.019.539 1.737 1.02 2.153 1.445.415.425.622.977.622 1.657 0 1.812-1.567 2.718-4.702 2.718-1.529 0-3.299-.255-5.309-.764-2.012-.51-3.819-1.142-5.424-1.898v7.137a22.275 22.275 0 0 0 4.56 1.373c1.624.312 3.587.468 5.891.468 4.492 0 7.867-.85 10.123-2.55zm37.604 1.982h-8.638v-18.493c0-2.284-.383-3.998-1.146-5.14-.766-1.142-1.969-1.714-3.612-1.714-2.208 0-3.813.812-4.814 2.436s-1.501 4.295-1.501 8.015v14.896h-8.638v-31.662h6.599l1.161 4.05h.482c.849-1.454 2.077-2.592 3.681-3.413 1.605-.821 3.446-1.232 5.523-1.232 4.739 0 7.948 1.549 9.629 4.645h.764c.85-1.473 2.101-2.615 3.753-3.427s3.516-1.218 5.593-1.218c3.587 0 6.302.921 8.142 2.761 1.841 1.841 2.761 4.791 2.761 8.85v20.646h-8.666v-18.493c0-2.284-.383-3.998-1.146-5.14-.766-1.142-1.969-1.714-3.612-1.714-2.114 0-3.695.756-4.744 2.266-1.047 1.511-1.571 3.908-1.571 7.193z" />
<path
clipRule="evenodd"
d="m318.222 81.445 1.671 4.305h6.032v-21.099c0-3.776-1.133-6.589-3.398-8.439-2.266-1.85-5.523-2.776-9.771-2.776-4.436 0-8.477.954-12.121 2.861l2.86 5.834c3.417-1.53 6.391-2.294 8.921-2.294 3.285 0 4.928 1.605 4.928 4.814v1.388l-5.494.17c-4.739.17-8.283 1.053-10.635 2.648-2.35 1.596-3.525 4.074-3.525 7.434 0 3.21.873 5.683 2.619 7.42 1.747 1.737 4.139 2.605 7.18 2.605 2.473 0 4.479-.354 6.017-1.062 1.539-.708 3.035-1.977 4.489-3.809zm-4.22-10.252 3.342-.113v2.605c0 1.908-.6 3.437-1.799 4.588-1.198 1.152-2.799 1.728-4.8 1.728-2.794 0-4.191-1.218-4.191-3.653 0-1.7.613-2.964 1.841-3.795 1.227-.83 3.096-1.284 5.607-1.36zm-218.269 30.336-57.479 17c-1.756.52-3.439-.999-3.07-2.77l20.534-98.34c.384-1.838 2.926-2.13 3.728-.427l38.02 80.736c.717 1.523-.101 3.319-1.733 3.801zm9.857-4.01-44.022-93.482v-.002a7.062 7.062 0 0 0 -6.019-4.022c-2.679-.156-5.079 1.136-6.433 3.335l-47.744 77.33a7.233 7.233 0 0 0 .084 7.763l23.338 36.152c1.391 2.158 3.801 3.407 6.306 3.407.71 0 1.424-.1 2.126-.308l67.744-20.036a7.424 7.424 0 0 0 4.66-4.028 7.264 7.264 0 0 0 -.04-6.11z"
fillRule="evenodd"
/>
</g>
</svg>
<div className="aspect-512/64 h-5 w-auto">
<svg
width="100%"
viewBox="0 0 512 64"
className="fill-current"
xmlns="http://www.w3.org/2000/svg"
>
<title>Tailwind CSS</title>
<g>
<path d="M52.898 0C38.792 0 29.976 7.053 26.45 21.16C31.74 14.106 37.912 11.461 44.965 13.225C48.989 14.23 51.865 17.151 55.049 20.382C60.235 25.646 66.238 31.739 79.349 31.739C93.455 31.739 102.271 24.686 105.798 10.579C100.508 17.633 94.336 20.278 87.283 18.514C83.259 17.509 80.383 14.588 77.199 11.357C72.012 6.093 66.01 0 52.898 0ZM26.45 31.739C12.344 31.739 3.52798 38.792 0.000976562 52.899C5.29098 45.845 11.462 43.2 18.515 44.964C22.54 45.971 25.415 48.89 28.599 52.121C33.785 57.385 39.789 63.478 52.899 63.478C67.006 63.478 75.822 56.425 79.349 42.318C74.059 49.372 67.887 52.017 60.834 50.253C56.81 49.248 53.934 46.327 50.75 43.097C45.564 37.832 39.561 31.739 26.45 31.739Z" />
<path d="M158.687 26.7469H149.456V44.6149C149.456 49.3799 152.582 49.3049 158.687 49.0069V56.2289C146.329 57.7179 141.415 54.2929 141.415 44.6149V26.7469H134.565V19.0039H141.415V9.00386L149.455 6.62186V19.0039H158.687V26.7469ZM193.879 19.0039H201.919V56.2299H193.879V50.8699C191.049 54.8149 186.657 57.1979 180.849 57.1979C170.725 57.1979 162.312 48.6359 162.312 37.6179C162.312 26.5249 170.725 18.0379 180.85 18.0379C186.657 18.0379 191.05 20.4199 193.879 24.2909V19.0039ZM182.115 49.5299C188.815 49.5299 193.879 44.5409 193.879 37.6179C193.879 30.6949 188.816 25.7059 182.115 25.7059C175.414 25.7059 170.352 30.6939 170.352 37.6179C170.352 44.5419 175.415 49.5299 182.115 49.5299ZM215.32 13.4199C212.49 13.4199 210.183 11.0369 210.183 8.28186C210.186 6.92025 210.728 5.61517 211.691 4.65237C212.653 3.68957 213.958 3.1475 215.32 3.14486C216.682 3.1475 217.987 3.68957 218.949 4.65237C219.912 5.61517 220.454 6.92025 220.457 8.28186C220.457 11.0369 218.149 13.4199 215.32 13.4199ZM211.3 56.2279V19.0039H219.34V56.2299L211.3 56.2279ZM228.646 56.2279V1.88086H236.687V56.2289L228.646 56.2279ZM288.876 19.0029H297.363L285.675 56.2299H277.783L270.04 31.1399L262.223 56.2299H254.332L242.643 19.0049H251.13L258.352 44.6899L266.169 19.0049H273.837L281.58 44.6899L288.876 19.0029ZM307.34 13.4199C304.51 13.4199 302.203 11.0369 302.203 8.28186C302.206 6.92025 302.748 5.61517 303.711 4.65237C304.673 3.68957 305.978 3.1475 307.34 3.14486C308.702 3.1475 310.007 3.68957 310.969 4.65237C311.932 5.61517 312.474 6.92025 312.477 8.28186C312.477 11.0369 310.169 13.4199 307.34 13.4199ZM303.32 56.2279V19.0039H311.36V56.2299L303.32 56.2279ZM340.246 18.0349C348.585 18.0349 354.541 23.6939 354.541 33.3719V56.2279H346.501V34.1919C346.501 28.5339 343.224 25.5559 338.161 25.5559C332.876 25.5559 328.707 28.6829 328.707 36.2759V56.2299H320.667V19.0039H328.707V23.7689C331.164 19.8979 335.184 18.0359 340.247 18.0359L340.246 18.0349ZM392.66 4.11386H400.7V56.2299H392.66V50.8699C389.83 54.8149 385.438 57.1979 379.631 57.1979C369.506 57.1979 361.093 48.6359 361.093 37.6179C361.093 26.5249 369.506 18.0379 379.631 18.0379C385.438 18.0379 389.831 20.4199 392.66 24.2909V4.11386ZM380.896 49.5299C387.596 49.5299 392.659 44.5409 392.659 37.6179C392.659 30.6949 387.596 25.7059 380.896 25.7059C374.196 25.7059 369.133 30.6939 369.133 37.6179C369.133 44.5419 374.195 49.5299 380.896 49.5299ZM427.65 57.1979C416.408 57.1979 407.996 48.6359 407.996 37.6179C407.996 26.5249 416.408 18.0379 427.65 18.0379C434.946 18.0379 441.275 21.8339 444.253 27.6409L437.329 31.6609C435.691 28.1629 432.043 25.9289 427.576 25.9289C421.024 25.9289 416.036 30.9169 416.036 37.6179C416.036 44.3189 421.024 49.3059 427.576 49.3059C432.043 49.3059 435.691 46.9979 437.478 43.5739L444.401 47.5189C441.275 53.4009 434.946 57.1979 427.651 57.1979H427.65ZM457.653 29.2789C457.653 36.0539 477.68 31.9589 477.68 45.7329C477.68 53.1779 471.203 57.1979 463.163 57.1979C455.718 57.1979 450.357 53.8479 447.975 48.4879L454.899 44.4669C456.09 47.8169 459.068 49.8269 463.163 49.8269C466.736 49.8269 469.491 48.6369 469.491 45.6579C469.491 39.0319 449.464 42.7549 449.464 29.4279C449.464 22.4299 455.494 18.0379 463.088 18.0379C469.193 18.0379 474.256 20.8659 476.861 25.7799L470.087 29.5769C468.747 26.6739 466.141 25.3329 463.088 25.3329C460.185 25.3329 457.653 26.5989 457.653 29.2789V29.2789ZM491.975 29.2789C491.975 36.0539 512.002 31.9589 512.002 45.7329C512.002 53.1779 505.525 57.1979 497.484 57.1979C490.039 57.1979 484.679 53.8479 482.296 48.4879L489.22 44.4669C490.411 47.8169 493.39 49.8269 497.484 49.8269C501.058 49.8269 503.812 48.6369 503.812 45.6579C503.812 39.0319 483.785 42.7549 483.785 29.4279C483.785 22.4299 489.815 18.0379 497.41 18.0379C503.514 18.0379 508.577 20.8659 511.183 25.7799L504.408 29.5769C503.068 26.6739 500.462 25.3329 497.41 25.3329C494.506 25.3329 491.975 26.5989 491.975 29.2789V29.2789Z" />
</g>
</svg>
</div>
</div>
</div>
<div className="mx-auto mt-16 max-w-5xl rounded-2xl border bg-card/50 p-2 shadow-lg dark:shadow-foreground/10">
<Image
src={heroImage}
alt="Our application"
className="block rounded-xl dark:hidden"
priority
/>
<Image
src={heroImageDark}
alt="Our application"
className="hidden rounded-xl dark:block"
priority
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNewsletterSignupMutation } from "@marketing/home/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { CheckCircleIcon, KeyIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
email: z.string().email(),
});
type FormValues = z.infer<typeof formSchema>;
export function Newsletter() {
const t = useTranslations();
const newsletterSignupMutation = useNewsletterSignupMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
const onSubmit = form.handleSubmit(async ({ email }) => {
try {
await newsletterSignupMutation.mutateAsync({ email });
} catch {
form.setError("email", {
message: t("newsletter.hints.error.message"),
});
}
});
return (
<section className="py-16">
<div className="container">
<div className="mb-8 text-center">
<KeyIcon className="mx-auto mb-3 size-8 text-primary" />
<h1 className="font-bold text-3xl lg:text-4xl">
{t("newsletter.title")}
</h1>
<p className="mt-3 text-lg opacity-70">
{t("newsletter.subtitle")}
</p>
</div>
<div className="mx-auto max-w-lg">
{form.formState.isSubmitSuccessful ? (
<Alert variant="success">
<CheckCircleIcon />
<AlertTitle>
{t("newsletter.hints.success.title")}
</AlertTitle>
<AlertDescription>
{t("newsletter.hints.success.message")}
</AlertDescription>
</Alert>
) : (
<form onSubmit={onSubmit}>
<div className="flex items-start">
<Input
type="email"
required
placeholder={t("newsletter.email")}
{...form.register("email")}
/>
<Button
type="submit"
className="ml-4"
loading={form.formState.isSubmitting}
>
{t("newsletter.submit")}
</Button>
</div>
{form.formState.errors.email && (
<p className="mt-1 text-destructive text-xs">
{form.formState.errors.email.message}
</p>
)}
</form>
)}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { PricingTable } from "@saas/payments/components/PricingTable";
import { useTranslations } from "next-intl";
export function PricingSection() {
const t = useTranslations();
return (
<section id="pricing" className="scroll-mt-16 py-12 lg:py-16">
<div className="container max-w-5xl">
<div className="mb-6 lg:text-center">
<h1 className="font-bold text-4xl lg:text-5xl">
{t("pricing.title")}
</h1>
<p className="mt-3 text-lg opacity-50">
{t("pricing.description")}
</p>
</div>
<PricingTable />
</div>
</section>
);
}

View file

@ -0,0 +1,41 @@
import { apiClient } from "@shared/lib/api-client";
import { useMutation } from "@tanstack/react-query";
import type { InferRequestType } from "hono";
export const newsletterSignupMutationKey = ["newsletter-signup"] as const;
export const useNewsletterSignupMutation = () => {
return useMutation({
mutationKey: newsletterSignupMutationKey,
mutationFn: async (
form: InferRequestType<
typeof apiClient.newsletter.signup.$post
>["form"],
) => {
const response = await apiClient.newsletter.signup.$post({
form,
});
if (!response.ok) {
throw new Error("Failed to sign up to newsletter");
}
},
});
};
export const contactFormMutationKey = ["contact-form"] as const;
export const useContactFormMutation = () => {
return useMutation({
mutationKey: contactFormMutationKey,
mutationFn: async (
form: InferRequestType<typeof apiClient.contact.$post>["form"],
) => {
const response = await apiClient.contact.$post({
form,
});
if (!response.ok) {
throw new Error("Failed to send contact form");
}
},
});
};

View file

@ -0,0 +1,6 @@
import type { ReactNode } from "react";
export type FAQ = {
question: ReactNode;
answer: ReactNode;
};

View file

@ -0,0 +1,60 @@
import { LocaleLink } from "@i18n/routing";
import type { ContentStructureItem } from "@shared/lib/content";
function ContentMenuItem({
label,
path,
isPage,
children,
activePath,
}: ContentStructureItem & { activePath: string }) {
return (
<>
{isPage ? (
<LocaleLink
key={path}
className={`block rounded-full px-4 py-1 ${path === activePath ? "bg-primary/5 font-bold" : ""}`}
href={`/docs/${path}`}
>
{label}
</LocaleLink>
) : (
<span key={path} className="block px-4 py-1">
{label}
</span>
)}
{children && (
<div className="-mr-4 pl-4">
{children.map((subItem) => (
<ContentMenuItem
key={subItem.path}
{...subItem}
activePath={activePath}
/>
))}
</div>
)}
</>
);
}
export function ContentMenu({
items,
activePath,
}: {
items: ContentStructureItem[];
activePath: string;
}) {
return (
<ul className="-mx-4 list-none space-y-2 pr-4">
{items.map((item) => (
<ContentMenuItem
key={item.path}
{...item}
activePath={activePath}
/>
))}
</ul>
);
}

View file

@ -0,0 +1,46 @@
import { LocaleLink } from "@i18n/routing";
import { config } from "@repo/config";
import { Logo } from "@shared/components/Logo";
export function Footer() {
return (
<footer className="border-t py-8 text-foreground/60 text-sm">
<div className="container grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<Logo className="opacity-70 grayscale" />
<p className="mt-3 text-sm opacity-70">
© {new Date().getFullYear()} {config.appName}.{" "}
<a href="https://supastarter.dev">
Built with supastarter
</a>
.
</p>
</div>
<div className="flex flex-col gap-2">
<LocaleLink href="/blog" className="block">
Blog
</LocaleLink>
<a href="#features" className="block">
Features
</a>
<a href="/#pricing" className="block">
Pricing
</a>
</div>
<div className="flex flex-col gap-2">
<LocaleLink href="/legal/privacy-policy" className="block">
Privacy policy
</LocaleLink>
<LocaleLink href="/legal/terms" className="block">
Terms and conditions
</LocaleLink>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,218 @@
"use client";
import { LocaleLink, useLocalePathname } from "@i18n/routing";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { ColorModeToggle } from "@shared/components/ColorModeToggle";
import { LocaleSwitch } from "@shared/components/LocaleSwitch";
import { Logo } from "@shared/components/Logo";
import { Button } from "@ui/components/button";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "@ui/components/sheet";
import { cn } from "@ui/lib";
import { MenuIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import NextLink from "next/link";
import { Suspense, useEffect, useState } from "react";
import { useDebounceCallback } from "usehooks-ts";
export function NavBar() {
const t = useTranslations();
const { user } = useSession();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const localePathname = useLocalePathname();
const [isTop, setIsTop] = useState(true);
const debouncedScrollHandler = useDebounceCallback(
() => {
setIsTop(window.scrollY <= 10);
},
150,
{
maxWait: 150,
},
);
useEffect(() => {
window.addEventListener("scroll", debouncedScrollHandler);
debouncedScrollHandler();
return () => {
window.removeEventListener("scroll", debouncedScrollHandler);
};
}, [debouncedScrollHandler]);
useEffect(() => {
setMobileMenuOpen(false);
}, [localePathname]);
const isDocsPage = localePathname.startsWith("/docs");
const menuItems: {
label: string;
href: string;
}[] = [
{
label: t("common.menu.pricing"),
href: "/#pricing",
},
{
label: t("common.menu.faq"),
href: "/#faq",
},
{
label: t("common.menu.blog"),
href: "/blog",
},
{
label: t("common.menu.changelog"),
href: "/changelog",
},
...(config.contactForm.enabled
? [
{
label: t("common.menu.contact"),
href: "/contact",
},
]
: []),
{
label: t("common.menu.docs"),
href: "/docs",
},
];
const isMenuItemActive = (href: string) => localePathname.startsWith(href);
return (
<nav
className={cn(
"fixed top-0 left-0 z-50 w-full transition-shadow duration-200",
!isTop || isDocsPage
? "bg-card/80 shadow-sm backdrop-blur-lg"
: "shadow-none",
)}
data-test="navigation"
>
<div className="container">
<div
className={cn(
"flex items-center justify-stretch gap-6 transition-[padding] duration-200",
!isTop || isDocsPage ? "py-4" : "py-6",
)}
>
<div className="flex flex-1 justify-start">
<LocaleLink
href="/"
className="block hover:no-underline active:no-underline"
>
<Logo />
</LocaleLink>
</div>
<div className="hidden flex-1 items-center justify-center lg:flex">
{menuItems.map((menuItem) => (
<LocaleLink
key={menuItem.href}
href={menuItem.href}
className={cn(
"block px-3 py-2 font-medium text-foreground/80 text-sm",
isMenuItemActive(menuItem.href)
? "font-bold text-foreground"
: "",
)}
prefetch
>
{menuItem.label}
</LocaleLink>
))}
</div>
<div className="flex flex-1 items-center justify-end gap-3">
<ColorModeToggle />
{config.i18n.enabled && (
<Suspense>
<LocaleSwitch />
</Suspense>
)}
<Sheet
open={mobileMenuOpen}
onOpenChange={(open) => setMobileMenuOpen(open)}
>
<SheetTrigger asChild>
<Button
className="lg:hidden"
size="icon"
variant="light"
aria-label="Menu"
>
<MenuIcon className="size-4" />
</Button>
</SheetTrigger>
<SheetContent className="w-[280px]" side="right">
<SheetTitle />
<div className="flex flex-col items-start justify-center">
{menuItems.map((menuItem) => (
<LocaleLink
key={menuItem.href}
href={menuItem.href}
className={cn(
"block px-3 py-2 font-medium text-base text-foreground/80",
isMenuItemActive(menuItem.href)
? "font-bold text-foreground"
: "",
)}
prefetch
>
{menuItem.label}
</LocaleLink>
))}
<NextLink
key={user ? "start" : "login"}
href={user ? "/app" : "/auth/login"}
className="block px-3 py-2 text-base"
prefetch={!user}
>
{user
? t("common.menu.dashboard")
: t("common.menu.login")}
</NextLink>
</div>
</SheetContent>
</Sheet>
{config.ui.saas.enabled &&
(user ? (
<Button
key="dashboard"
className="hidden lg:flex"
asChild
variant="secondary"
>
<NextLink href="/app">
{t("common.menu.dashboard")}
</NextLink>
</Button>
) : (
<Button
key="login"
className="hidden lg:flex"
asChild
variant="secondary"
>
<NextLink href="/auth/login" prefetch>
{t("common.menu.login")}
</NextLink>
</Button>
))}
</div>
</div>
</div>
</nav>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { LocaleLink } from "@i18n/routing";
import { Button } from "@ui/components/button";
import { UndoIcon } from "lucide-react";
export function NotFound() {
return (
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="font-bold text-5xl">404</h1>
<p className="mt-2 text-2xl">Page not found</p>
<Button asChild className="mt-4">
<LocaleLink href="/">
<UndoIcon className="mr-2 size-4" /> Go to homepage
</LocaleLink>
</Button>
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
export function TableOfContents({
items,
}: {
items: { slug: string; content: string; lvl: number }[];
}) {
const t = useTranslations();
const scrollToSection = (id: string) => {
const scrollOffset = 80;
const element = document.getElementById(id);
if (element) {
const elementPositionY =
element.getBoundingClientRect().top +
window.scrollY -
scrollOffset;
window.scrollTo({ top: elementPositionY, behavior: "smooth" });
history.pushState({}, "", `#${id}`);
}
};
useEffect(() => {
if (location.hash) {
scrollToSection(location.hash.substring(1));
}
}, []);
return (
<div className="w-full max-w-64 self-start rounded-lg border p-4">
<h3 className="mb-2 font-semibold text-base">
{t("common.tableOfContents.title")}
</h3>
<nav className="list-none space-y-2">
{items.map((item) => (
<a
key={item.slug}
href={`#${item.slug}`}
className={`block text-sm ${`ml-${Math.max(0, item.lvl - 2) * 2}`}`}
onClick={(e) => {
e.preventDefault();
scrollToSection(item.slug);
}}
>
{item.content}
</a>
))}
</nav>
</div>
);
}

View file

@ -0,0 +1,37 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@ui/components/tooltip";
import { cn } from "@ui/lib";
import { CheckIcon, ClockIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export function EmailVerified({
verified,
className,
}: {
verified: boolean;
className?: string;
}) {
const t = useTranslations();
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipContent>
{verified
? t("admin.users.emailVerified.verified")
: t("admin.users.emailVerified.waiting")}
</TooltipContent>
<TooltipTrigger className={cn(className)}>
{verified ? (
<CheckIcon className="size-3 text-primary" />
) : (
<ClockIcon className="size-3" />
)}
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,154 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { adminOrganizationsQueryKey } from "@saas/admin/lib/api";
import { getAdminPath } from "@saas/admin/lib/links";
import { InviteMemberForm } from "@saas/organizations/components/InviteMemberForm";
import { OrganizationMembersBlock } from "@saas/organizations/components/OrganizationMembersBlock";
import {
fullOrganizationQueryKey,
useCreateOrganizationMutation,
useFullOrganizationQuery,
useUpdateOrganizationMutation,
} from "@saas/organizations/lib/api";
import { useRouter } from "@shared/hooks/router";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/components/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const organizationFormSchema = z.object({
name: z.string().min(1),
});
type OrganizationFormValues = z.infer<typeof organizationFormSchema>;
export function OrganizationForm({
organizationId,
}: {
organizationId: string;
}) {
const t = useTranslations();
const router = useRouter();
const { data: organization } = useFullOrganizationQuery(organizationId);
const updateOrganizationMutation = useUpdateOrganizationMutation();
const createOrganizationMutation = useCreateOrganizationMutation();
const queryClient = useQueryClient();
const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationFormSchema),
defaultValues: {
name: organization?.name ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ name }) => {
try {
const newOrganization = organization
? await updateOrganizationMutation.mutateAsync({
id: organization.id,
name,
updateSlug: organization.name !== name,
})
: await createOrganizationMutation.mutateAsync({
name,
});
if (!newOrganization) {
throw new Error("Could not save organization");
}
queryClient.setQueryData(
fullOrganizationQueryKey(organizationId),
newOrganization,
);
queryClient.invalidateQueries({
queryKey: adminOrganizationsQueryKey,
});
toast.success(t("admin.organizations.form.notifications.success"));
if (!organization) {
router.replace(
getAdminPath(`/organizations/${newOrganization.id}`),
);
}
} catch (error) {
toast.error(t("admin.organizations.form.notifications.error"));
}
});
return (
<div className="grid grid-cols-1 gap-4">
<Card>
<CardHeader>
<CardTitle>
{organization
? t("admin.organizations.form.updateTitle")
: t("admin.organizations.form.createTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={onSubmit}
className="grid grid-cols-1 gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("admin.organizations.form.name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
loading={
updateOrganizationMutation.isPending ||
createOrganizationMutation.isPending
}
>
{t("admin.organizations.form.save")}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
{organization && (
<>
<OrganizationMembersBlock
organizationId={organization.id}
/>
<InviteMemberForm organizationId={organization.id} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,294 @@
"use client";
import { authClient } from "@repo/auth/client";
import {
adminOrganizationsQueryKey,
useAdminOrganizationsQuery,
} from "@saas/admin/lib/api";
import { getAdminPath } from "@saas/admin/lib/links";
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
import { Pagination } from "@saas/shared/components/Pagination";
import { Spinner } from "@shared/components/Spinner";
import { useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@ui/components/button";
import { Card } from "@ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import { Input } from "@ui/components/input";
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
import { EditIcon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { withQuery } from "ufo";
import { useDebounceValue } from "usehooks-ts";
const ITEMS_PER_PAGE = 10;
export function OrganizationList() {
const t = useTranslations();
const { confirm } = useConfirmationAlert();
const queryClient = useQueryClient();
const [currentPage, setCurrentPage] = useQueryState(
"currentPage",
parseAsInteger.withDefault(1),
);
const [searchTerm, setSearchTerm] = useQueryState(
"query",
parseAsString.withDefault(""),
);
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounceValue(
searchTerm,
300,
{
leading: true,
trailing: false,
},
);
const getPathWithBackToParemeter = (path: string) => {
const searchParams = new URLSearchParams(window.location.search);
return withQuery(path, {
backTo: `${window.location.pathname}${searchParams.size ? `?${searchParams.toString()}` : ""}`,
});
};
const getOrganizationEditPath = (id: string) => {
return getPathWithBackToParemeter(getAdminPath(`/organizations/${id}`));
};
useEffect(() => {
setDebouncedSearchTerm(searchTerm);
}, [searchTerm]);
const { data, isLoading } = useAdminOrganizationsQuery({
itemsPerPage: ITEMS_PER_PAGE,
currentPage,
searchTerm: debouncedSearchTerm,
});
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearchTerm]);
const deleteOrganization = async (id: string) => {
toast.promise(
async () => {
const { error } = await authClient.organization.delete({
organizationId: id,
});
if (error) {
throw error;
}
},
{
loading: t("admin.organizations.deleteOrganization.deleting"),
success: () => {
queryClient.invalidateQueries({
queryKey: adminOrganizationsQueryKey,
});
return t("admin.organizations.deleteOrganization.deleted");
},
error: t("admin.organizations.deleteOrganization.notDeleted"),
},
);
};
const columns: ColumnDef<
NonNullable<typeof data>["organizations"][number]
>[] = useMemo(
() => [
{
accessorKey: "user",
header: "",
accessorFn: (row) => row.name,
cell: ({
row: {
original: { id, name, logo, membersCount },
},
}) => (
<div className="flex items-center gap-2">
<OrganizationLogo name={name} logoUrl={logo} />
<div className="leading-tight">
<Link
href={getOrganizationEditPath(id)}
className="block font-bold"
>
{name}
</Link>
<small>
{t("admin.organizations.membersCount", {
count: membersCount,
})}
</small>
</div>
</div>
),
},
{
accessorKey: "actions",
header: "",
cell: ({
row: {
original: { id },
},
}) => {
return (
<div className="flex flex-row justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link
href={getOrganizationEditPath(id)}
className="flex items-center"
>
<EditIcon className="mr-2 size-4" />
{t("admin.organizations.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
confirm({
title: t(
"admin.organizations.confirmDelete.title",
),
message: t(
"admin.organizations.confirmDelete.message",
),
confirmLabel: t(
"admin.organizations.confirmDelete.confirm",
),
destructive: true,
onConfirm: () =>
deleteOrganization(id),
})
}
>
<span className="flex items-center text-destructive hover:text-destructive">
<TrashIcon className="mr-2 size-4" />
{t("admin.organizations.delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
],
[],
);
const organizations = useMemo(
() => data?.organizations ?? [],
[data?.organizations],
);
const table = useReactTable({
data: organizations,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
});
return (
<Card className="p-6">
<div className="mb-4 flex items-center justify-between gap-6">
<h2 className="font-semibold text-2xl">
{t("admin.organizations.title")}
</h2>
<Button asChild>
<Link href={getAdminPath("/organizations/new")}>
<PlusIcon className="mr-1.5 size-4" />
{t("admin.organizations.create")}
</Link>
</Button>
</div>
<Input
type="search"
placeholder={t("admin.organizations.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-4"
/>
<div className="rounded-md border">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-2 group-first:rounded-t-md group-last:rounded-b-md"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Spinner className="mr-2 size-4 text-primary" />
{t("admin.organizations.loading")}
</div>
) : (
<p>No results.</p>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{!!data?.total && data.total > ITEMS_PER_PAGE && (
<Pagination
className="mt-4"
totalItems={data.total}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
onChangeCurrentPage={setCurrentPage}
/>
)}
</Card>
);
}

View file

@ -0,0 +1,373 @@
"use client";
import { authClient } from "@repo/auth/client";
import { adminUsersQueryKey, useAdminUsersQuery } from "@saas/admin/lib/api";
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
import { Pagination } from "@saas/shared/components/Pagination";
import { Spinner } from "@shared/components/Spinner";
import { UserAvatar } from "@shared/components/UserAvatar";
import { useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@ui/components/button";
import { Card } from "@ui/components/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import { Input } from "@ui/components/input";
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
import {
MoreVerticalIcon,
Repeat1Icon,
ShieldCheckIcon,
ShieldXIcon,
SquareUserRoundIcon,
TrashIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useDebounceValue } from "usehooks-ts";
import { EmailVerified } from "../EmailVerified";
const ITEMS_PER_PAGE = 10;
export function UserList() {
const t = useTranslations();
const queryClient = useQueryClient();
const { confirm } = useConfirmationAlert();
const [currentPage, setCurrentPage] = useQueryState(
"currentPage",
parseAsInteger.withDefault(1),
);
const [searchTerm, setSearchTerm] = useQueryState(
"query",
parseAsString.withDefault(""),
);
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounceValue(
searchTerm,
300,
{
leading: true,
trailing: false,
},
);
useEffect(() => {
setDebouncedSearchTerm(searchTerm);
}, [searchTerm]);
const { data, isLoading, refetch } = useAdminUsersQuery({
itemsPerPage: ITEMS_PER_PAGE,
currentPage,
searchTerm: debouncedSearchTerm,
});
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearchTerm]);
const impersonateUser = async (
userId: string,
{ name }: { name: string },
) => {
const toastId = toast.loading(
t("admin.users.impersonation.impersonating", {
name,
}),
);
await authClient.admin.impersonateUser({
userId,
});
await refetch();
toast.dismiss(toastId);
window.location.href = new URL(
"/app",
window.location.origin,
).toString();
};
const deleteUser = async (id: string) => {
toast.promise(
async () => {
const { error } = await authClient.admin.removeUser({
userId: id,
});
if (error) {
throw error;
}
},
{
loading: t("admin.users.deleteUser.deleting"),
success: () => {
return t("admin.users.deleteUser.deleted");
},
error: t("admin.users.deleteUser.notDeleted"),
},
);
};
const resendVerificationMail = async (email: string) => {
toast.promise(
async () => {
const { error } = await authClient.sendVerificationEmail({
email,
});
if (error) {
throw error;
}
},
{
loading: t("admin.users.resendVerificationMail.submitting"),
success: () => {
return t("admin.users.resendVerificationMail.success");
},
error: t("admin.users.resendVerificationMail.error"),
},
);
};
const assignAdminRole = async (id: string) => {
await authClient.admin.setRole({
userId: id,
role: "admin",
});
await queryClient.invalidateQueries({
queryKey: adminUsersQueryKey,
});
};
const removeAdminRole = async (id: string) => {
await authClient.admin.setRole({
userId: id,
role: "user",
});
await queryClient.invalidateQueries({
queryKey: adminUsersQueryKey,
});
};
const columns: ColumnDef<
NonNullable<
Awaited<ReturnType<typeof authClient.admin.listUsers>>["data"]
>["users"][number]
>[] = useMemo(
() => [
{
accessorKey: "user",
header: "",
accessorFn: (row) => row.name,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<UserAvatar
name={row.original.name ?? row.original.email}
avatarUrl={row.original.image}
/>
<div className="leading-tight">
<strong className="block">
{row.original.name ?? row.original.email}
</strong>
<small className="flex items-center gap-1 text-foreground/60">
<span className="block">
{!!row.original.name && row.original.email}
</span>
<EmailVerified
verified={row.original.emailVerified}
/>
<strong className="block">
{row.original.role === "admin"
? "Admin"
: ""}
</strong>
</small>
</div>
</div>
),
},
{
accessorKey: "actions",
header: "",
cell: ({ row }) => {
return (
<div className="flex flex-row justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
impersonateUser(row.original.id, {
name: row.original.name ?? "",
})
}
>
<SquareUserRoundIcon className="mr-2 size-4" />
{t("admin.users.impersonate")}
</DropdownMenuItem>
{!row.original.emailVerified && (
<DropdownMenuItem
onClick={() =>
resendVerificationMail(
row.original.email,
)
}
>
<Repeat1Icon className="mr-2 size-4" />
{t(
"admin.users.resendVerificationMail.title",
)}
</DropdownMenuItem>
)}
{row.original.role !== "admin" ? (
<DropdownMenuItem
onClick={() =>
assignAdminRole(row.original.id)
}
>
<ShieldCheckIcon className="mr-2 size-4" />
{t("admin.users.assignAdminRole")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
removeAdminRole(row.original.id)
}
>
<ShieldXIcon className="mr-2 size-4" />
{t("admin.users.removeAdminRole")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() =>
confirm({
title: t(
"admin.users.confirmDelete.title",
),
message: t(
"admin.users.confirmDelete.message",
),
confirmLabel: t(
"admin.users.confirmDelete.confirm",
),
destructive: true,
onConfirm: () =>
deleteUser(row.original.id),
})
}
>
<span className="flex items-center text-destructive hover:text-destructive">
<TrashIcon className="mr-2 size-4" />
{t("admin.users.delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
],
[],
);
const users = useMemo(() => data?.users ?? [], [data?.users]);
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
});
return (
<Card className="p-6">
<h2 className="mb-4 font-semibold text-2xl">
{t("admin.users.title")}
</h2>
<Input
type="search"
placeholder={t("admin.users.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-4"
/>
<div className="rounded-md border">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-2 group-first:rounded-t-md group-last:rounded-b-md"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Spinner className="mr-2 size-4 text-primary" />
{t("admin.users.loading")}
</div>
) : (
<p>No results.</p>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data?.total && data.total > ITEMS_PER_PAGE && (
<Pagination
className="mt-4"
totalItems={data.total}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
onChangeCurrentPage={setCurrentPage}
/>
)}
</Card>
);
}

View file

@ -0,0 +1,82 @@
import { apiClient } from "@shared/lib/api-client";
import { createQueryKeyWithParams } from "@shared/lib/query-client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
/*
* Admin users
*/
type FetchAdminUsersParams = {
itemsPerPage: number;
currentPage: number;
searchTerm: string;
};
export const adminUsersQueryKey = ["admin", "users"];
export const fetchAdminUsers = async ({
itemsPerPage,
currentPage,
searchTerm,
}: FetchAdminUsersParams) => {
const response = await apiClient.admin.users.$get({
query: {
limit: itemsPerPage.toString(),
offset: ((currentPage - 1) * itemsPerPage).toString(),
query: searchTerm,
},
});
if (!response.ok) {
throw new Error("Could not fetch users");
}
return response.json();
};
export const useAdminUsersQuery = (params: FetchAdminUsersParams) => {
return useQuery({
queryKey: createQueryKeyWithParams(adminUsersQueryKey, params),
queryFn: () => fetchAdminUsers(params),
retry: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
};
/*
* Admin organizations
*/
type FetchAdminOrganizationsParams = {
itemsPerPage: number;
currentPage: number;
searchTerm: string;
};
export const adminOrganizationsQueryKey = ["admin", "organizations"];
export const fetchAdminOrganizations = async ({
itemsPerPage,
currentPage,
searchTerm,
}: FetchAdminOrganizationsParams) => {
const response = await apiClient.admin.organizations.$get({
query: {
limit: itemsPerPage.toString(),
offset: ((currentPage - 1) * itemsPerPage).toString(),
query: searchTerm,
},
});
if (!response.ok) {
throw new Error("Could not fetch organizations");
}
return response.json();
};
export const useAdminOrganizationsQuery = (
params: FetchAdminOrganizationsParams,
) => {
return useQuery({
queryKey: createQueryKeyWithParams(adminOrganizationsQueryKey, params),
queryFn: () => fetchAdminOrganizations(params),
retry: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
};

View file

@ -0,0 +1,5 @@
import { joinRelativeURL } from "ufo";
export function getAdminPath(path: string) {
return joinRelativeURL("/app/admin", path);
}

View file

@ -0,0 +1,199 @@
"use client";
import {
aiChatListQueryKey,
useAiChatListQuery,
useAiChatQuery,
useCreateAiChatMutation,
} from "@saas/ai/lib/api";
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Textarea } from "@ui/components/textarea";
import { cn } from "@ui/lib";
import { type Message, useChat } from "ai/react";
import { EllipsisIcon, PlusIcon, SendIcon } from "lucide-react";
import { useFormatter } from "next-intl";
import { useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo } from "react";
export function AiChat({ organizationId }: { organizationId?: string }) {
const formatter = useFormatter();
const queryClient = useQueryClient();
const { data: chats, status: chatsStatus } =
useAiChatListQuery(organizationId);
const [chatId, setChatId] = useQueryState("chatId");
const { data: currentChat } = useAiChatQuery(chatId ?? "new");
const createChatMutation = useCreateAiChatMutation();
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
setMessages,
} = useChat({
api: `/api/ai/chats/${chatId}/messages`,
credentials: "include",
initialMessages: [],
});
useEffect(() => {
if (currentChat?.messages?.length) {
setMessages(currentChat.messages as unknown as Message[]);
}
}, [currentChat]);
const createNewChat = useCallback(async () => {
const newChat = await createChatMutation.mutateAsync({
organizationId,
});
await queryClient.invalidateQueries({
queryKey: aiChatListQueryKey(organizationId),
});
setChatId(newChat.id);
}, [createChatMutation]);
useEffect(() => {
(async () => {
if (chatId || chatsStatus !== "success") {
return;
}
if (chats?.length) {
setChatId(chats[0].id);
} else {
await createNewChat();
setMessages([]);
}
})();
}, [chatsStatus]);
const hasChat =
chatsStatus === "success" && !!chats?.length && !!currentChat?.id;
const sortedChats = useMemo(() => {
return (
chats?.sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
) ?? []
);
}, [chats]);
return (
<SidebarContentLayout
sidebar={
<div>
<Button
variant="light"
size="sm"
className="mb-4 flex w-full items-center gap-2"
loading={createChatMutation.isPending}
onClick={createNewChat}
>
<PlusIcon className="size-4" />
New chat
</Button>
{sortedChats.map((chat) => (
<div className="relative" key={chat.id}>
<Button
variant="link"
onClick={() => setChatId(chat.id)}
className={cn(
"block h-auto w-full py-2 text-left text-foreground hover:no-underline",
chat.id === chatId &&
"bg-primary/10 font-bold text-primary",
)}
>
<span className="w-full overflow-hidden">
<span className="block truncate">
{chat.title ??
(chat.messages?.at(0) as any)
?.content ??
"Untitled chat"}
</span>
<small className="block font-normal">
{formatter.dateTime(
new Date(chat.createdAt),
{
dateStyle: "short",
timeStyle: "short",
},
)}
</small>
</span>
</Button>
</div>
))}
</div>
}
>
<div className="-mt-8 flex h-[calc(100vh-10rem)] flex-col">
<div className="flex flex-1 flex-col gap-2 overflow-y-auto py-8">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex flex-col gap-2",
message.role === "user"
? "items-end"
: "items-start",
)}
>
<div
className={cn(
"flex max-w-2xl items-center gap-2 whitespace-pre-wrap rounded-lg px-4 py-2 text-foreground",
message.role === "user"
? "bg-primary/10"
: "bg-secondary/10",
)}
>
{message.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex max-w-2xl items-center gap-2 rounded-lg bg-secondary/10 px-4 py-2 text-foreground">
<EllipsisIcon className="size-6 animate-pulse" />
</div>
</div>
)}
</div>
<form
onSubmit={handleSubmit}
className="relative shrink-0 rounded-lg border-none bg-card py-6 pr-14 pl-6 text-lg shadow-sm focus:outline-hidden focus-visible:ring-0"
>
<Textarea
value={input}
onChange={handleInputChange}
disabled={!hasChat}
placeholder="Chat with your AI..."
className="min-h-8 rounded-none border-none bg-transparent p-0 focus:outline-hidden focus-visible:ring-0 shadow-none"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<Button
type="submit"
size="icon"
variant="secondary"
className="absolute right-3 bottom-3"
disabled={!hasChat}
>
<SendIcon className="size-4" />
</Button>
</form>
</div>
</SidebarContentLayout>
);
}

View file

@ -0,0 +1,103 @@
import { apiClient } from "@shared/lib/api-client";
import { useMutation, useQuery } from "@tanstack/react-query";
export const aiChatListQueryKey = (organizationId?: string) =>
organizationId
? (["ai-chat-list", organizationId] as const)
: (["ai-chat-list"] as const);
export const useAiChatListQuery = (organizationId?: string) =>
useQuery({
queryKey: aiChatListQueryKey(organizationId),
queryFn: async () => {
const response = await apiClient.ai.chats.$get({
query: {
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch AI chat list");
}
return response.json().then((data) => data.chats);
},
});
export const aiChatQueryKey = (id: string) => ["ai-chat", id];
export const useAiChatQuery = (id: string) =>
useQuery({
queryKey: aiChatQueryKey(id),
queryFn: async () => {
if (id === "new") {
return null;
}
const response = await apiClient.ai.chats[":id"].$get({
param: {
id,
},
});
if (!response.ok) {
throw new Error("Failed to fetch AI chat");
}
return response.json().then((res) => res.chat);
},
});
export const useCreateAiChatMutation = () => {
return useMutation({
mutationFn: async ({
title,
organizationId,
}: {
title?: string;
organizationId?: string;
}) => {
const response = await apiClient.ai.chats.$post({
json: {
title,
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to create AI chat");
}
return response.json().then((res) => res.chat);
},
});
};
export const updateAiChatMutation = () => {
return useMutation({
mutationFn: async ({ id, title }: { id: string; title?: string }) => {
const response = await apiClient.ai.chats[":id"].$put({
param: { id },
json: { title },
});
if (!response.ok) {
throw new Error("Failed to update AI chat");
}
return response.json().then((res) => res.chat);
},
});
};
export const deleteAiChatMutation = () => {
return useMutation({
mutationFn: async (id: string) => {
const response = await apiClient.ai.chats[":id"].$delete({
param: { id },
});
if (!response.ok) {
throw new Error("Failed to delete AI chat");
}
},
});
};

View file

@ -0,0 +1,135 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
import { authClient } from "@repo/auth/client";
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
email: z.string().email(),
});
type FormValues = z.infer<typeof formSchema>;
export function ForgotPasswordForm() {
const t = useTranslations();
const { getAuthErrorMessage } = useAuthErrorMessages();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});
const onSubmit = form.handleSubmit(async ({ email }) => {
try {
const redirectTo = new URL(
"/auth/reset-password",
window.location.origin,
).toString();
const { error } = await authClient.forgetPassword({
email,
redirectTo,
});
if (error) {
throw error;
}
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
});
return (
<>
<h1 className="font-bold text-xl md:text-2xl">
{t("auth.forgotPassword.title")}
</h1>
<p className="mt-1 mb-6 text-foreground/60">
{t("auth.forgotPassword.message")}{" "}
</p>
{form.formState.isSubmitSuccessful ? (
<Alert variant="success">
<MailboxIcon />
<AlertTitle>
{t("auth.forgotPassword.hints.linkSent.title")}
</AlertTitle>
<AlertDescription>
{t("auth.forgotPassword.hints.linkSent.message")}
</AlertDescription>
</Alert>
) : (
<Form {...form}>
<form
className="flex flex-col items-stretch gap-4"
onSubmit={onSubmit}
>
{form.formState.errors.root && (
<Alert variant="error">
<AlertTriangleIcon />
<AlertTitle>
{form.formState.errors.root.message}
</AlertTitle>
</Alert>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.forgotPassword.email")}
</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button loading={form.formState.isSubmitting}>
{t("auth.forgotPassword.submit")}
</Button>
</form>
</Form>
)}
<div className="mt-6 text-center text-sm">
<Link href="/auth/login">
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
{t("auth.forgotPassword.backToSignin")}
</Link>
</div>
</>
);
}

View file

@ -0,0 +1,357 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
import { sessionQueryKey } from "@saas/auth/lib/api";
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
import { useRouter } from "@shared/hooks/router";
import { useQueryClient } from "@tanstack/react-query";
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import {
AlertTriangleIcon,
ArrowRightIcon,
EyeIcon,
EyeOffIcon,
KeyIcon,
MailboxIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm } from "react-hook-form";
import { withQuery } from "ufo";
import { z } from "zod";
import {
type OAuthProvider,
oAuthProviders,
} from "../constants/oauth-providers";
import { useSession } from "../hooks/use-session";
import { LoginModeSwitch } from "./LoginModeSwitch";
import { SocialSigninButton } from "./SocialSigninButton";
const formSchema = z.union([
z.object({
mode: z.literal("magic-link"),
email: z.string().email(),
}),
z.object({
mode: z.literal("password"),
email: z.string().email(),
password: z.string().min(1),
}),
]);
type FormValues = z.infer<typeof formSchema>;
export function LoginForm() {
const t = useTranslations();
const { getAuthErrorMessage } = useAuthErrorMessages();
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { user, loaded: sessionLoaded } = useSession();
const [showPassword, setShowPassword] = useState(false);
const invitationId = searchParams.get("invitationId");
const email = searchParams.get("email");
const redirectTo = searchParams.get("redirectTo");
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: email ?? "",
password: "",
mode: config.auth.enablePasswordLogin ? "password" : "magic-link",
},
});
const redirectPath = invitationId
? `/app/organization-invitation/${invitationId}`
: (redirectTo ?? config.auth.redirectAfterSignIn);
useEffect(() => {
if (sessionLoaded && user) {
router.replace(redirectPath);
}
}, [user, sessionLoaded]);
const onSubmit: SubmitHandler<FormValues> = async (values) => {
try {
if (values.mode === "password") {
const { data, error } = await authClient.signIn.email({
...values,
});
if (error) {
throw error;
}
if ((data as any).twoFactorRedirect) {
router.replace(
withQuery(
"/auth/verify",
Object.fromEntries(searchParams.entries()),
),
);
return;
}
queryClient.invalidateQueries({
queryKey: sessionQueryKey,
});
router.replace(redirectPath);
} else {
const { error } = await authClient.signIn.magicLink({
...values,
callbackURL: redirectPath,
});
if (error) {
throw error;
}
}
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
};
const signInWithPasskey = async () => {
try {
await authClient.signIn.passkey();
router.replace(redirectPath);
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
};
const signinMode = form.watch("mode");
return (
<div>
<h1 className="font-bold text-xl md:text-2xl">
{t("auth.login.title")}
</h1>
<p className="mt-1 mb-6 text-foreground/60">
{t("auth.login.subtitle")}
</p>
{form.formState.isSubmitSuccessful &&
signinMode === "magic-link" ? (
<Alert variant="success">
<MailboxIcon />
<AlertTitle>
{t("auth.login.hints.linkSent.title")}
</AlertTitle>
<AlertDescription>
{t("auth.login.hints.linkSent.message")}
</AlertDescription>
</Alert>
) : (
<>
{invitationId && (
<OrganizationInvitationAlert className="mb-6" />
)}
<Form {...form}>
<form
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
{config.auth.enableMagicLink &&
config.auth.enablePasswordLogin && (
<LoginModeSwitch
activeMode={signinMode}
onChange={(mode) =>
form.setValue(
"mode",
mode as typeof signinMode,
)
}
/>
)}
{form.formState.isSubmitted &&
form.formState.errors.root?.message && (
<Alert variant="error">
<AlertTriangleIcon />
<AlertTitle>
{form.formState.errors.root.message}
</AlertTitle>
</Alert>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.signup.email")}
</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="email"
/>
</FormControl>
</FormItem>
)}
/>
{config.auth.enablePasswordLogin &&
signinMode === "password" && (
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex justify-between gap-4">
<FormLabel>
{t(
"auth.signup.password",
)}
</FormLabel>
<Link
href="/auth/forgot-password"
className="text-foreground/60 text-xs"
>
{t(
"auth.login.forgotPassword",
)}
</Link>
</div>
<FormControl>
<div className="relative">
<Input
type={
showPassword
? "text"
: "password"
}
className="pr-10"
{...field}
autoComplete="current-password"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword,
)
}
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
>
{showPassword ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
</FormControl>
</FormItem>
)}
/>
)}
<Button
className="w-full"
type="submit"
variant="secondary"
loading={form.formState.isSubmitting}
>
{signinMode === "magic-link"
? t("auth.login.sendMagicLink")
: t("auth.login.submit")}
</Button>
</form>
</Form>
{(config.auth.enablePasskeys ||
(config.auth.enableSignup &&
config.auth.enableSocialLogin)) && (
<>
<div className="relative my-6 h-4">
<hr className="relative top-2" />
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
{t("auth.login.continueWith")}
</p>
</div>
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
{config.auth.enableSignup &&
config.auth.enableSocialLogin &&
Object.keys(oAuthProviders).map(
(providerId) => (
<SocialSigninButton
key={providerId}
provider={
providerId as OAuthProvider
}
/>
),
)}
{config.auth.enablePasskeys && (
<Button
variant="light"
className="w-full sm:col-span-2"
onClick={() => signInWithPasskey()}
>
<KeyIcon className="mr-1.5 size-4 text-primary" />
{t("auth.login.loginWithPasskey")}
</Button>
)}
</div>
</>
)}
{config.auth.enableSignup && (
<div className="mt-6 text-center text-sm">
<span className="text-foreground/60">
{t("auth.login.dontHaveAnAccount")}{" "}
</span>
<Link
href={withQuery(
"/auth/signup",
Object.fromEntries(searchParams.entries()),
)}
>
{t("auth.login.createAnAccount")}
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
</Link>
</div>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
import { useTranslations } from "next-intl";
export function LoginModeSwitch({
activeMode,
onChange,
className,
}: {
activeMode: "password" | "magic-link";
onChange: (mode: string) => void;
className?: string;
}) {
const t = useTranslations();
return (
<Tabs value={activeMode} onValueChange={onChange} className={className}>
<TabsList className="w-full">
<TabsTrigger value="password" className="flex-1">
{t("auth.login.modes.password")}
</TabsTrigger>
<TabsTrigger value="magic-link" className="flex-1">
{t("auth.login.modes.magicLink")}
</TabsTrigger>
</TabsList>
</Tabs>
);
}

View file

@ -0,0 +1,169 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import { AlertTriangleIcon, ArrowLeftIcon } from "lucide-react";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
import { useRouter } from "@shared/hooks/router";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@ui/components/input-otp";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
code: z.string().min(6).max(6),
});
type FormValues = z.infer<typeof formSchema>;
export function OtpForm() {
const t = useTranslations();
const router = useRouter();
const { getAuthErrorMessage } = useAuthErrorMessages();
const searchParams = useSearchParams();
const invitationId = searchParams.get("invitationId");
const redirectTo = searchParams.get("redirectTo");
const redirectPath = invitationId
? `/app/organization-invitation/${invitationId}`
: (redirectTo ?? config.auth.redirectAfterSignIn);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
code: "",
},
});
const onSubmit = form.handleSubmit(async ({ code }) => {
try {
const { error } = await authClient.twoFactor.verifyTotp({
code,
});
if (error) {
throw error;
}
router.replace(redirectPath);
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
});
return (
<>
<h1 className="font-bold text-xl md:text-2xl">
{t("auth.verify.title")}
</h1>
<p className="mt-1 mb-4 text-foreground/60">
{t("auth.verify.message")}
</p>
<Form {...form}>
<form
className="flex flex-col items-stretch gap-4"
onSubmit={onSubmit}
>
{form.formState.errors.root && (
<Alert variant="error">
<AlertTriangleIcon />
<AlertTitle>
{form.formState.errors.root.message}
</AlertTitle>
</Alert>
)}
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>{t("auth.verify.code")}</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
autoComplete="one-time-code"
onChange={(value) => {
field.onChange(value);
onSubmit();
}}
>
<InputOTPGroup>
<InputOTPSlot
className="size-10 text-lg"
index={0}
/>
<InputOTPSlot
className="size-10 text-lg"
index={1}
/>
<InputOTPSlot
className="size-10 text-lg"
index={2}
/>
</InputOTPGroup>
<InputOTPSeparator className="opacity-40" />
<InputOTPGroup>
<InputOTPSlot
className="size-10 text-lg"
index={3}
/>
<InputOTPSlot
className="size-10 text-lg"
index={4}
/>
<InputOTPSlot
className="size-10 text-lg"
index={5}
/>
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button loading={form.formState.isSubmitting}>
{t("auth.verify.submit")}
</Button>
</form>
</Form>
<div className="mt-6 text-center text-sm">
<Link href="/auth/login">
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
{t("auth.verify.backToSignin")}
</Link>
</div>
</>
);
}

View file

@ -0,0 +1,139 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
import { useSession } from "@saas/auth/hooks/use-session";
import { useRouter } from "@shared/hooks/router";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { PasswordInput } from "@ui/components/password-input";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
export function ResetPasswordForm() {
const t = useTranslations();
const { user } = useSession();
const router = useRouter();
const { getAuthErrorMessage } = useAuthErrorMessages();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
password: "",
},
});
const onSubmit = form.handleSubmit(async ({ password }) => {
try {
const { error } = await authClient.resetPassword({
token: token ?? undefined,
newPassword: password,
});
if (error) {
throw error;
}
if (user) {
router.push(config.auth.redirectAfterSignIn);
}
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
});
return (
<>
<h1 className="font-bold text-xl md:text-2xl">
{t("auth.resetPassword.title")}
</h1>
<p className="mt-1 mb-6 text-foreground/60">
{t("auth.resetPassword.message")}{" "}
</p>
{form.formState.isSubmitSuccessful ? (
<Alert variant="success">
<MailboxIcon />
<AlertTitle>
{t("auth.resetPassword.hints.success")}
</AlertTitle>
</Alert>
) : (
<Form {...form}>
<form
className="flex flex-col items-stretch gap-4"
onSubmit={onSubmit}
>
{form.formState.errors.root && (
<Alert variant="error">
<AlertTriangleIcon />
<AlertTitle>
{form.formState.errors.root.message}
</AlertTitle>
</Alert>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.resetPassword.newPassword")}
</FormLabel>
<FormControl>
<PasswordInput
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button loading={form.formState.isSubmitting}>
{t("auth.resetPassword.submit")}
</Button>
</form>
</Form>
)}
<div className="mt-6 text-center text-sm">
<Link href="/auth/login">
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
{t("auth.resetPassword.backToSignin")}
</Link>
</div>
</>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { authClient } from "@repo/auth/client";
import { sessionQueryKey, useSessionQuery } from "@saas/auth/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { SessionContext } from "../lib/session-context";
export function SessionProvider({
children,
}: {
children: ReactNode;
}) {
const queryClient = useQueryClient();
const { data: session } = useSessionQuery();
const [loaded, setLoaded] = useState(!!session);
useEffect(() => {
if (session && !loaded) {
setLoaded(true);
}
}, [session]);
return (
<SessionContext.Provider
value={{
loaded,
session: session?.session ?? null,
user: session?.user ?? null,
reloadSession: async () => {
const { data: newSession, error } =
await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(
error.message || "Failed to fetch session",
);
}
queryClient.setQueryData(sessionQueryKey, () => newSession);
},
}}
>
{children}
</SessionContext.Provider>
);
}

View file

@ -0,0 +1,282 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
import { useFormErrors } from "@shared/hooks/form-errors";
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import {
AlertTriangleIcon,
ArrowRightIcon,
EyeIcon,
EyeOffIcon,
MailboxIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm } from "react-hook-form";
import { withQuery } from "ufo";
import { z } from "zod";
import {
type OAuthProvider,
oAuthProviders,
} from "../constants/oauth-providers";
import { SocialSigninButton } from "./SocialSigninButton";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
name: z.string().min(1),
});
type FormValues = z.infer<typeof formSchema>;
export function SignupForm({ prefillEmail }: { prefillEmail?: string }) {
const t = useTranslations();
const router = useRouter();
const { zodErrorMap } = useFormErrors();
const { getAuthErrorMessage } = useAuthErrorMessages();
const searchParams = useSearchParams();
const [showPassword, setShowPassword] = useState(false);
const invitationId = searchParams.get("invitationId");
const email = searchParams.get("email");
const redirectTo = searchParams.get("redirectTo");
const form = useForm<FormValues>({
resolver: zodResolver(formSchema, {
errorMap: zodErrorMap,
}),
values: {
name: "",
email: prefillEmail ?? email ?? "",
password: "",
},
});
const invitationOnlyMode = !config.auth.enableSignup && invitationId;
const redirectPath = invitationId
? `/app/organization-invitation/${invitationId}`
: (redirectTo ?? config.auth.redirectAfterSignIn);
const onSubmit: SubmitHandler<FormValues> = async ({
email,
password,
name,
}) => {
try {
const { error } = await authClient.signUp.email({
email,
password,
name,
callbackURL: redirectPath,
});
if (error) {
throw error;
}
if (invitationOnlyMode) {
const { error } =
await authClient.organization.acceptInvitation({
invitationId,
});
if (error) {
throw error;
}
router.push(config.auth.redirectAfterSignIn);
}
} catch (e) {
form.setError("root", {
message: getAuthErrorMessage(
e && typeof e === "object" && "code" in e
? (e.code as string)
: undefined,
),
});
}
};
return (
<div>
<h1 className="font-bold text-xl md:text-2xl">
{t("auth.signup.title")}
</h1>
<p className="mt-1 mb-6 text-foreground/60">
{t("auth.signup.message")}
</p>
{form.formState.isSubmitSuccessful && !invitationOnlyMode ? (
<Alert variant="success">
<MailboxIcon />
<AlertTitle>
{t("auth.signup.hints.verifyEmail")}
</AlertTitle>
</Alert>
) : (
<>
{invitationId && (
<OrganizationInvitationAlert className="mb-6" />
)}
<Form {...form}>
<form
className="flex flex-col items-stretch gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
{form.formState.isSubmitted &&
form.formState.errors.root && (
<Alert variant="error">
<AlertTriangleIcon />
<AlertDescription>
{form.formState.errors.root.message}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.signup.name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.signup.email")}
</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="email"
readOnly={!!prefillEmail}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("auth.signup.password")}
</FormLabel>
<FormControl>
<div className="relative">
<Input
type={
showPassword
? "text"
: "password"
}
className="pr-10"
{...field}
autoComplete="new-password"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword,
)
}
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
>
{showPassword ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button loading={form.formState.isSubmitting}>
{t("auth.signup.submit")}
</Button>
</form>
</Form>
{config.auth.enableSignup &&
config.auth.enableSocialLogin && (
<>
<div className="relative my-6 h-4">
<hr className="relative top-2" />
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
{t("auth.login.continueWith")}
</p>
</div>
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
{Object.keys(oAuthProviders).map(
(providerId) => (
<SocialSigninButton
key={providerId}
provider={
providerId as OAuthProvider
}
/>
),
)}
</div>
</>
)}
</>
)}
<div className="mt-6 text-center text-sm">
<span className="text-foreground/60">
{t("auth.signup.alreadyHaveAccount")}{" "}
</span>
<Link
href={withQuery(
"/auth/login",
Object.fromEntries(searchParams.entries()),
)}
>
{t("auth.signup.signIn")}
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { Button } from "@ui/components/button";
import { useQueryState } from "nuqs";
import { parseAsString } from "nuqs";
import { oAuthProviders } from "../constants/oauth-providers";
export function SocialSigninButton({
provider,
className,
}: {
provider: keyof typeof oAuthProviders;
className?: string;
}) {
const [invitationId] = useQueryState("invitationId", parseAsString);
const providerData = oAuthProviders[provider];
const redirectPath = invitationId
? `/app/organization-invitation/${invitationId}`
: config.auth.redirectAfterSignIn;
const onSignin = () => {
const callbackURL = new URL(redirectPath, window.location.origin);
authClient.signIn.social({
provider,
callbackURL: callbackURL.toString(),
});
};
return (
<Button
onClick={() => onSignin()}
variant="light"
type="button"
className={className}
>
{providerData.icon && (
<i className="mr-2 text-primary">
<providerData.icon className="size-4" />
</i>
)}
{providerData.name}
</Button>
);
}

View file

@ -0,0 +1,37 @@
import type { JSXElementConstructor } from "react";
type IconProps = {
className?: string;
};
export const oAuthProviders = {
vk: {
name: "ВКонтакте",
icon: ({ ...props }: IconProps) => (
<svg width="20" height="20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 48C0 25.3726 0 14.0589 7.02944 7.02944C14.0589 0 25.3726 0 48 0H52C74.6274 0 85.9411 0 92.9706 7.02944C100 14.0589 100 25.3726 100 48V52C100 74.6274 100 85.9411 92.9706 92.9706C85.9411 100 74.6274 100 52 100H48C25.3726 100 14.0589 100 7.02944 92.9706C0 85.9411 0 74.6274 0 52V48Z" fill="#0077FF"/>
<path d="M53.2083 72.042C30.4167 72.042 17.4168 56.417 16.8751 30.417H28.2917C28.6667 49.5003 37.0833 57.5836 43.7499 59.2503V30.417H54.5002V46.8752C61.0836 46.1669 67.9994 38.667 70.3328 30.417H81.0831C79.2914 40.5837 71.7914 48.0836 66.458 51.1669C71.7914 53.6669 80.3335 60.2086 83.5835 72.042H71.7498C69.2081 64.1253 62.8752 58.0003 54.5002 57.1669V72.042H53.2083Z" fill="white"/>
</svg>
),
},
github: {
name: "Github",
icon: ({ ...props }: IconProps) => (
<svg viewBox="0 0 496 512" {...props}>
<title>Github</title>
<path
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
fill="currentColor"
/>
</svg>
),
},
} as const satisfies Record<
string,
{
name: string;
icon: JSXElementConstructor<React.SVGProps<SVGSVGElement>>;
}
>;
export type OAuthProvider = keyof typeof oAuthProviders;

View file

@ -0,0 +1,50 @@
import type { AuthClientErrorCodes } from "@repo/auth/client";
import { useTranslations } from "next-intl";
export function useAuthErrorMessages() {
const t = useTranslations();
const authErrorMessages: Partial<AuthClientErrorCodes> = {
INVALID_EMAIL_OR_PASSWORD: t("auth.errors.invalidEmailOrPassword"),
USER_NOT_FOUND: t("auth.errors.userNotFound"),
FAILED_TO_CREATE_USER: t("auth.errors.failedToCreateUser"),
FAILED_TO_CREATE_SESSION: t("auth.errors.failedToCreateSession"),
FAILED_TO_UPDATE_USER: t("auth.errors.failedToUpdateUser"),
FAILED_TO_GET_SESSION: t("auth.errors.failedToGetSession"),
INVALID_PASSWORD: t("auth.errors.invalidPassword"),
INVALID_EMAIL: t("auth.errors.invalidEmail"),
INVALID_TOKEN: t("auth.errors.invalidToken"),
CREDENTIAL_ACCOUNT_NOT_FOUND: t(
"auth.errors.credentialAccountNotFound",
),
EMAIL_CAN_NOT_BE_UPDATED: t("auth.errors.emailCanNotBeUpdated"),
EMAIL_NOT_VERIFIED: t("auth.errors.emailNotVerified"),
FAILED_TO_GET_USER_INFO: t("auth.errors.failedToGetUserInfo"),
ID_TOKEN_NOT_SUPPORTED: t("auth.errors.idTokenNotSupported"),
PASSWORD_TOO_LONG: t("auth.errors.passwordTooLong"),
PASSWORD_TOO_SHORT: t("auth.errors.passwordTooShort"),
PROVIDER_NOT_FOUND: t("auth.errors.providerNotFound"),
SOCIAL_ACCOUNT_ALREADY_LINKED: t(
"auth.errors.socialAccountAlreadyLinked",
),
USER_EMAIL_NOT_FOUND: t("auth.errors.userEmailNotFound"),
USER_ALREADY_EXISTS: t("auth.errors.userAlreadyExists"),
INVALID_INVITATION: t("auth.errors.invalidInvitation"),
SESSION_EXPIRED: t("auth.errors.sessionExpired"),
FAILED_TO_UNLINK_LAST_ACCOUNT: t(
"auth.errors.failedToUnlinkLastAccount",
),
ACCOUNT_NOT_FOUND: t("auth.errors.accountNotFound"),
};
const getAuthErrorMessage = (errorCode: string | undefined) => {
return (
authErrorMessages[errorCode as keyof typeof authErrorMessages] ||
t("auth.errors.unknown")
);
};
return {
getAuthErrorMessage,
};
}

View file

@ -0,0 +1,12 @@
import { useContext } from "react";
import { SessionContext } from "../lib/session-context";
export const useSession = () => {
const sessionContext = useContext(SessionContext);
if (sessionContext === undefined) {
throw new Error("useSession must be used within SessionProvider");
}
return sessionContext;
};

View file

@ -0,0 +1,60 @@
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useQuery } from "@tanstack/react-query";
export const sessionQueryKey = ["user", "session"] as const;
export const useSessionQuery = () => {
return useQuery({
queryKey: sessionQueryKey,
queryFn: async () => {
const { data, error } = await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(error.message || "Failed to fetch session");
}
return data;
},
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
retry: false,
enabled: config.ui.saas.enabled,
});
};
export const userAccountQueryKey = ["user", "accounts"] as const;
export const useUserAccountsQuery = () => {
return useQuery({
queryKey: userAccountQueryKey,
queryFn: async () => {
const { data, error } = await authClient.listAccounts();
if (error) {
throw error;
}
return data;
},
});
};
export const userPasskeyQueryKey = ["user", "passkeys"] as const;
export const useUserPasskeysQuery = () => {
return useQuery({
queryKey: userPasskeyQueryKey,
queryFn: async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) {
throw error;
}
return data;
},
});
};

View file

@ -0,0 +1,75 @@
import "server-only";
import { auth } from "@repo/auth";
import { getInvitationById } from "@repo/database";
import { headers } from "next/headers";
import { cache } from "react";
export const getSession = cache(async () => {
const session = await auth.api.getSession({
headers: await headers(),
query: {
disableCookieCache: true,
},
});
return session;
});
export const getActiveOrganization = cache(async (slug: string) => {
try {
const activeOrganization = await auth.api.getFullOrganization({
query: {
organizationSlug: slug,
},
headers: await headers(),
});
return activeOrganization;
} catch (error) {
return null;
}
});
export const getOrganizationList = cache(async () => {
try {
const organizationList = await auth.api.listOrganizations({
headers: await headers(),
});
return organizationList;
} catch (error) {
return [];
}
});
export const getUserAccounts = cache(async () => {
try {
const userAccounts = await auth.api.listUserAccounts({
headers: await headers(),
});
return userAccounts;
} catch (error) {
return [];
}
});
export const getUserPasskeys = cache(async () => {
try {
const userPasskeys = await auth.api.listPasskeys({
headers: await headers(),
});
return userPasskeys;
} catch (error) {
return [];
}
});
export const getInvitation = cache(async (id: string) => {
try {
return await getInvitationById(id);
} catch (error) {
return null;
}
});

View file

@ -0,0 +1,12 @@
import type { Session } from "@repo/auth";
import React from "react";
export const SessionContext = React.createContext<
| {
session: Session["session"] | null;
user: Session["user"] | null;
loaded: boolean;
reloadSession: () => Promise<void>;
}
| undefined
>(undefined);

View file

@ -0,0 +1,72 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useRouter } from "@shared/hooks/router";
import { clearCache } from "@shared/lib/cache";
import { Progress } from "@ui/components/progress";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { withQuery } from "ufo";
import { OnboardingStep1 } from "./OnboardingStep1";
export function OnboardingForm() {
const t = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const stepSearchParam = searchParams.get("step");
const redirectTo = searchParams.get("redirectTo");
const onboardingStep = stepSearchParam
? Number.parseInt(stepSearchParam, 10)
: 1;
const setStep = (step: number) => {
router.replace(
withQuery(window.location.search ?? "", {
step,
}),
);
};
const onCompleted = async () => {
await authClient.updateUser({
onboardingComplete: true,
});
await clearCache();
router.replace(redirectTo ?? "/app");
};
const steps = [
{
component: <OnboardingStep1 onCompleted={() => onCompleted()} />,
},
];
return (
<div>
<h1 className="font-bold text-xl md:text-2xl">
{t("onboarding.title")}
</h1>
<p className="mt-2 mb-6 text-foreground/60">
{t("onboarding.message")}
</p>
{steps.length > 1 && (
<div className="mb-6 flex items-center gap-3">
<Progress
value={(onboardingStep / steps.length) * 100}
className="h-2"
/>
<span className="shrink-0 text-foreground/60 text-xs">
{t("onboarding.step", {
step: onboardingStep,
total: steps.length,
})}
</span>
</div>
)}
{steps[onboardingStep - 1].component}
</div>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { UserAvatarUpload } from "@saas/settings/components/UserAvatarUpload";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import { ArrowRightIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
name: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
export function OnboardingStep1({ onCompleted }: { onCompleted: () => void }) {
const t = useTranslations();
const { user } = useSession();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.name ?? "",
},
});
useEffect(() => {
if (user) {
form.setValue("name", user.name ?? "");
}
}, [user]);
const onSubmit: SubmitHandler<FormValues> = async ({ name }) => {
form.clearErrors("root");
try {
await authClient.updateUser({
name,
});
onCompleted();
} catch (e) {
form.setError("root", {
type: "server",
message: t("onboarding.notifications.accountSetupFailed"),
});
}
};
return (
<div>
<Form {...form}>
<form
className="flex flex-col items-stretch gap-8"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("onboarding.account.name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
<FormItem className="flex items-center justify-between gap-4">
<div>
<FormLabel>
{t("onboarding.account.avatar")}
</FormLabel>
<FormDescription>
{t("onboarding.account.avatarDescription")}
</FormDescription>
</div>
<FormControl>
<UserAvatarUpload
onSuccess={() => {
return;
}}
onError={() => {
return;
}}
/>
</FormControl>
</FormItem>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("onboarding.continue")}
<ArrowRightIcon className="ml-2 size-4" />
</Button>
</form>
</Form>
</div>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { authClient } from "@repo/auth/client";
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { sessionQueryKey } from "@saas/auth/lib/api";
import {
activeOrganizationQueryKey,
useActiveOrganizationQuery,
} from "@saas/organizations/lib/api";
import { purchasesQueryKey } from "@saas/payments/lib/api";
import { useRouter } from "@shared/hooks/router";
import { apiClient } from "@shared/lib/api-client";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import nProgress from "nprogress";
import { type ReactNode, useEffect, useState } from "react";
import { ActiveOrganizationContext } from "../lib/active-organization-context";
export function ActiveOrganizationProvider({
children,
}: {
children: ReactNode;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const { session, user } = useSession();
const params = useParams();
const activeOrganizationSlug = params.organizationSlug as string;
const { data: activeOrganization } = useActiveOrganizationQuery(
activeOrganizationSlug,
{
enabled: !!activeOrganizationSlug,
},
);
const refetchActiveOrganization = async () => {
await queryClient.refetchQueries({
queryKey: activeOrganizationQueryKey(activeOrganizationSlug),
});
};
const setActiveOrganization = async (organizationSlug: string | null) => {
nProgress.start();
const { data: newActiveOrganization } =
await authClient.organization.setActive(
organizationSlug
? {
organizationSlug,
}
: {
organizationId: null,
},
);
if (!newActiveOrganization) {
nProgress.done();
return;
}
await queryClient.setQueryData(
activeOrganizationQueryKey(newActiveOrganization.slug),
newActiveOrganization,
);
if (config.organizations.enableBilling) {
await queryClient.prefetchQuery({
queryKey: purchasesQueryKey(newActiveOrganization.id),
queryFn: async () => {
const response = await apiClient.payments.purchases.$get({
query: {
organizationId: newActiveOrganization.id,
},
});
if (!response.ok) {
throw new Error("Failed to fetch purchases");
}
return response.json();
},
});
}
await queryClient.setQueryData(sessionQueryKey, (data: any) => {
return {
...data,
session: {
...data?.session,
activeOrganizationId: newActiveOrganization.id,
},
};
});
router.push(`/app/${newActiveOrganization.slug}`);
};
const [loaded, setLoaded] = useState(activeOrganization !== undefined);
useEffect(() => {
if (!loaded && activeOrganization !== undefined) {
setLoaded(true);
}
}, [activeOrganization]);
const activeOrganizationUserRole = activeOrganization?.members.find(
(member) => member.userId === session?.userId,
)?.role;
return (
<ActiveOrganizationContext.Provider
value={{
loaded,
activeOrganization: activeOrganization ?? null,
activeOrganizationUserRole: activeOrganizationUserRole ?? null,
isOrganizationAdmin:
!!activeOrganization &&
!!user &&
isOrganizationAdmin(activeOrganization, user),
setActiveOrganization,
refetchActiveOrganization,
}}
>
{children}
</ActiveOrganizationContext.Provider>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { organizationListQueryKey } from "@saas/organizations/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(3),
});
type FormSchema = z.infer<typeof formSchema>;
export function ChangeOrganizationNameForm() {
const t = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { activeOrganization } = useActiveOrganization();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: activeOrganization?.name ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ name }) => {
if (!activeOrganization) {
return;
}
try {
const { error } = await authClient.organization.update({
organizationId: activeOrganization.id,
data: {
name,
},
});
if (error) {
throw error;
}
toast.success(
t(
"organizations.settings.notifications.organizationNameUpdated",
),
);
queryClient.invalidateQueries({
queryKey: organizationListQueryKey,
});
router.refresh();
} catch {
toast.error(
t(
"organizations.settings.notifications.organizationNameNotUpdated",
),
);
}
});
return (
<SettingsItem title={t("organizations.settings.changeName.title")}>
<form onSubmit={onSubmit}>
<Input {...form.register("name")} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
disabled={
!(
form.formState.isValid &&
form.formState.dirtyFields.name
)
}
loading={form.formState.isSubmitting}
>
{t("settings.save")}
</Button>
</div>
</form>
</SettingsItem>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import {
organizationListQueryKey,
useCreateOrganizationMutation,
} from "@saas/organizations/lib/api";
import { useRouter } from "@shared/hooks/router";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(3).max(32),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateOrganizationForm({
defaultName,
}: {
defaultName?: string;
}) {
const t = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { setActiveOrganization } = useActiveOrganization();
const createOrganizationMutation = useCreateOrganizationMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: defaultName ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ name }) => {
try {
const newOrganization =
await createOrganizationMutation.mutateAsync({
name,
});
if (!newOrganization) {
throw new Error("Failed to create organization");
}
await setActiveOrganization(newOrganization.id);
await queryClient.invalidateQueries({
queryKey: organizationListQueryKey,
});
router.replace(`/app/${newOrganization.slug}`);
} catch (e) {
toast.error(t("organizations.createForm.notifications.error"));
}
});
return (
<div className="mx-auto w-full max-w-md">
<h1 className="font-bold text-xl md:text-2xl">
{t("organizations.createForm.title")}
</h1>
<p className="mt-2 mb-6 text-foreground/60">
{t("organizations.createForm.subtitle")}
</p>
<Form {...form}>
<form onSubmit={onSubmit}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("organizations.createForm.name")}
</FormLabel>
<FormControl>
<Input {...field} autoComplete="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className="mt-6 w-full"
type="submit"
loading={form.formState.isSubmitting}
>
{t("organizations.createForm.submit")}
</Button>
</form>
</Form>
</div>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function DeleteOrganizationForm() {
const t = useTranslations();
const router = useRouter();
const { confirm } = useConfirmationAlert();
const { refetch: reloadOrganizations } = useOrganizationListQuery();
const { activeOrganization, setActiveOrganization } =
useActiveOrganization();
if (!activeOrganization) {
return null;
}
const handleDelete = async () => {
confirm({
title: t("organizations.settings.deleteOrganization.title"),
message: t(
"organizations.settings.deleteOrganization.confirmation",
),
destructive: true,
onConfirm: async () => {
const { data, error } = await authClient.organization.delete({
organizationId: activeOrganization.id,
});
if (error) {
toast.error(
t(
"organizations.settings.notifications.organizationNotDeleted",
),
);
return;
}
toast.success(
t(
"organizations.settings.notifications.organizationDeleted",
),
);
await setActiveOrganization(null);
await reloadOrganizations();
router.replace("/app");
},
});
};
return (
<>
<SettingsItem
danger
title={t("organizations.settings.deleteOrganization.title")}
description={t(
"organizations.settings.deleteOrganization.description",
)}
>
<div className="mt-4 flex justify-end">
<Button variant="error" onClick={handleDelete}>
{t("organizations.settings.deleteOrganization.submit")}
</Button>
</div>
</SettingsItem>
</>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { OrganizationRoleSelect } from "@saas/organizations/components/OrganizationRoleSelect";
import { fullOrganizationQueryKey } from "@saas/organizations/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@ui/components/form";
import { Input } from "@ui/components/input";
import {} from "@ui/components/select";
import { useTranslations } from "next-intl";
import type { SubmitHandler } from "react-hook-form";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
role: z.enum(["member", "owner", "admin"]),
});
type FormValues = z.infer<typeof formSchema>;
export function InviteMemberForm({
organizationId,
}: {
organizationId: string;
}) {
const t = useTranslations();
const queryClient = useQueryClient();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
role: "member",
},
});
const onSubmit: SubmitHandler<FormValues> = async (values) => {
try {
const { error } = await authClient.organization.inviteMember({
...values,
organizationId,
});
if (error) {
throw error;
}
form.reset();
queryClient.invalidateQueries({
queryKey: fullOrganizationQueryKey(organizationId),
});
toast.success(
t(
"organizations.settings.members.inviteMember.notifications.success.title",
),
);
} catch {
toast.error(
t(
"organizations.settings.members.inviteMember.notifications.error.title",
),
);
}
};
return (
<SettingsItem
title={t("organizations.settings.members.inviteMember.title")}
description={t(
"organizations.settings.members.inviteMember.description",
)}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="@container"
>
<div className="flex @md:flex-row flex-col gap-2">
<div className="flex-1">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"organizations.settings.members.inviteMember.email",
)}
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<div>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"organizations.settings.members.inviteMember.role",
)}
</FormLabel>
<FormControl>
<OrganizationRoleSelect
value={field.value}
onSelect={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
>
{t(
"organizations.settings.members.inviteMember.submit",
)}
</Button>
</div>
</form>
</Form>
</SettingsItem>
);
}

View file

@ -0,0 +1,18 @@
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
import { MailCheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export function OrganizationInvitationAlert({
className,
}: { className?: string }) {
const t = useTranslations();
return (
<Alert variant="primary" className={className}>
<MailCheckIcon />
<AlertTitle>{t("organizations.invitationAlert.title")}</AlertTitle>
<AlertDescription>
{t("organizations.invitationAlert.description")}
</AlertDescription>
</Alert>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { authClient } from "@repo/auth/client";
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
import { organizationListQueryKey } from "@saas/organizations/lib/api";
import { useRouter } from "@shared/hooks/router";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { CheckIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
export function OrganizationInvitationModal({
invitationId,
organizationName,
organizationSlug,
logoUrl,
}: {
invitationId: string;
organizationName: string;
organizationSlug: string;
logoUrl?: string;
}) {
const t = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [submitting, setSubmitting] = useState<false | "accept" | "reject">(
false,
);
const onSelectAnswer = async (accept: boolean) => {
setSubmitting(accept ? "accept" : "reject");
try {
if (accept) {
const { error } =
await authClient.organization.acceptInvitation({
invitationId,
});
if (error) {
throw error;
}
await queryClient.invalidateQueries({
queryKey: organizationListQueryKey,
});
router.replace(`/app/${organizationSlug}`);
} else {
const { error } =
await authClient.organization.rejectInvitation({
invitationId,
});
if (error) {
throw error;
}
router.replace("/app");
}
} catch (error) {
// TODO: handle error
} finally {
setSubmitting(false);
}
};
return (
<div>
<h1 className="font-bold text-xl md:text-2xl">
{t("organizations.invitationModal.title")}
</h1>
<p className="mt-1 mb-6 text-foreground/60">
{t("organizations.invitationModal.description", {
organizationName,
})}
</p>
<div className="mb-6 flex items-center gap-3 rounded-lg border p-2">
<OrganizationLogo
name={organizationName}
logoUrl={logoUrl}
className="size-12"
/>
<div>
<strong className="font-medium text-lg">
{organizationName}
</strong>
</div>
</div>
<div className="flex gap-2">
<Button
className="flex-1"
variant="light"
onClick={() => onSelectAnswer(false)}
disabled={!!submitting}
loading={submitting === "reject"}
>
<XIcon className="mr-1.5 size-4" />
{t("organizations.invitationModal.decline")}
</Button>
<Button
className="flex-1"
onClick={() => onSelectAnswer(true)}
disabled={!!submitting}
loading={submitting === "accept"}
>
<CheckIcon className="mr-1.5 size-4" />
{t("organizations.invitationModal.accept")}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,237 @@
"use client";
import type { ActiveOrganization } from "@repo/auth";
import { authClient } from "@repo/auth/client";
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
import { useSession } from "@saas/auth/hooks/use-session";
import {
fullOrganizationQueryKey,
useFullOrganizationQuery,
} from "@saas/organizations/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
import { cn } from "@ui/lib";
import {
CheckIcon,
ClockIcon,
MailXIcon,
MoreVerticalIcon,
XIcon,
} from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { useMemo } from "react";
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
import { toast } from "sonner";
export function OrganizationInvitationsList({
organizationId,
}: {
organizationId: string;
}) {
const t = useTranslations();
const queryClient = useQueryClient();
const { user } = useSession();
const formatter = useFormatter();
const { data: organization } = useFullOrganizationQuery(organizationId);
const canUserEditInvitations = isOrganizationAdmin(organization, user);
const invitations = useMemo(
() =>
organization?.invitations
?.filter((invitation) => invitation.status === "pending")
.sort(
(a, b) =>
new Date(a.expiresAt).getTime() -
new Date(b.expiresAt).getTime(),
),
[organization?.invitations],
);
const revokeInvitation = (invitationId: string) => {
toast.promise(
async () => {
const { error } =
await authClient.organization.cancelInvitation({
invitationId,
});
if (error) {
throw error;
}
},
{
loading: t(
"organizations.settings.members.notifications.revokeInvitation.loading.description",
),
success: () => {
queryClient.invalidateQueries({
queryKey: fullOrganizationQueryKey(organizationId),
});
return t(
"organizations.settings.members.notifications.revokeInvitation.success.description",
);
},
error: t(
"organizations.settings.members.notifications.revokeInvitation.error.description",
),
},
);
};
const columns: ColumnDef<
NonNullable<ActiveOrganization["invitations"]>[number]
>[] = [
{
accessorKey: "email",
accessorFn: (row) => row.email,
cell: ({ row }) => {
const InvitationStatusIcon = {
pending: ClockIcon,
accepted: CheckIcon,
rejected: XIcon,
canceled: XIcon,
}[row.original.status];
return (
<div className="leading-normal">
<strong
className={cn("block", {
"opacity-50":
row.original.status === "canceled",
})}
>
{row.original.email}
</strong>
<small className="flex flex-wrap gap-1 text-foreground/60">
<span className="flex items-center gap-0.5">
<InvitationStatusIcon className="size-3" />
{t(
`organizations.settings.members.invitations.invitationStatus.${row.original.status}`,
)}
</span>
<span>-</span>
<span>
{t(
"organizations.settings.members.invitations.expiresAt",
{
date: formatter.dateTime(
new Date(row.original.expiresAt),
{
dateStyle: "medium",
timeStyle: "short",
},
),
},
)}
</span>
</small>
</div>
);
},
},
{
accessorKey: "actions",
cell: ({ row }) => {
const isPending = row.original.status === "pending";
const isAccepted = row.original.status === "accepted";
return (
<div className="flex flex-row justify-end gap-2">
<OrganizationRoleSelect
value={row.original.role}
disabled
onSelect={() => {
return;
}}
/>
{canUserEditInvitations && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
disabled={!isPending}
onClick={() =>
revokeInvitation(row.original.id)
}
>
<MailXIcon className="mr-2 size-4" />
{t(
"organizations.settings.members.invitations.revoke",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
},
},
];
const table = useReactTable({
data: invitations ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t(
"organizations.settings.members.invitations.empty",
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { config } from "@repo/config";
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar";
import BoringAvatar from "boring-avatars";
import { forwardRef, useMemo } from "react";
import { useIsClient } from "usehooks-ts";
export const OrganizationLogo = forwardRef<
HTMLSpanElement,
{
name: string;
logoUrl?: string | null;
className?: string;
}
>(({ name, logoUrl, className }, ref) => {
const isClient = useIsClient();
const avatarColors = useMemo(() => {
if (typeof window === "undefined") {
return [];
}
const styles = getComputedStyle(window.document.documentElement);
return [
styles.getPropertyValue("--color-primary"),
styles.getPropertyValue("--color-accent"),
styles.getPropertyValue("--color-highlight"),
];
}, []);
const logoSrc = useMemo(
() =>
logoUrl
? logoUrl.startsWith("http")
? logoUrl
: `/image-proxy/${config.storage.bucketNames.avatars}/${logoUrl}`
: undefined,
[logoUrl],
);
if (!isClient) {
return null;
}
return (
<Avatar ref={ref} className={className}>
<AvatarImage src={logoSrc} />
<AvatarFallback>
<BoringAvatar
size={96}
name={name}
variant="sunset"
colors={avatarColors}
square
/>
</AvatarFallback>
</Avatar>
);
});
OrganizationLogo.displayName = "OrganizationLogo";

View file

@ -0,0 +1,122 @@
"use client";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { organizationListQueryKey } from "@saas/organizations/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useSignedUploadUrlMutation } from "@saas/shared/lib/api";
import { Spinner } from "@shared/components/Spinner";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { v4 as uuid } from "uuid";
import { CropImageDialog } from "../../settings/components/CropImageDialog";
import { OrganizationLogo } from "./OrganizationLogo";
export function OrganizationLogoForm() {
const t = useTranslations();
const [uploading, setUploading] = useState(false);
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [image, setImage] = useState<File | null>(null);
const { activeOrganization, refetchActiveOrganization } =
useActiveOrganization();
const queryClient = useQueryClient();
const getSignedUploadUrlMutation = useSignedUploadUrlMutation();
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setImage(acceptedFiles[0]);
setCropDialogOpen(true);
},
accept: {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
},
multiple: false,
});
if (!activeOrganization) {
return null;
}
const onCrop = async (croppedImageData: Blob | null) => {
if (!croppedImageData) {
return;
}
setUploading(true);
try {
const path = `${activeOrganization.id}-${uuid()}.png`;
const { signedUrl } = await getSignedUploadUrlMutation.mutateAsync({
path,
bucket: config.storage.bucketNames.avatars,
});
const response = await fetch(signedUrl, {
method: "PUT",
body: croppedImageData,
headers: {
"Content-Type": "image/png",
},
});
if (!response.ok) {
throw new Error("Failed to upload image");
}
const { error } = await authClient.organization.update({
organizationId: activeOrganization.id,
data: {
logo: path,
},
});
if (error) {
throw error;
}
toast.success(t("settings.account.avatar.notifications.success"));
refetchActiveOrganization();
queryClient.invalidateQueries({
queryKey: organizationListQueryKey,
});
} catch (e) {
toast.error(t("settings.account.avatar.notifications.error"));
} finally {
setUploading(false);
}
};
return (
<SettingsItem
title={t("organizations.settings.logo.title")}
description={t("organizations.settings.logo.description")}
>
<div className="relative size-24 rounded-full" {...getRootProps()}>
<input {...getInputProps()} />
<OrganizationLogo
className="size-24 cursor-pointer text-xl"
logoUrl={activeOrganization.logo}
name={activeOrganization.name ?? ""}
/>
{uploading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-card/90">
<Spinner />
</div>
)}
</div>
<CropImageDialog
image={image}
open={cropDialogOpen}
onOpenChange={setCropDialogOpen}
onCrop={onCrop}
/>
</SettingsItem>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { OrganizationInvitationsList } from "./OrganizationInvitationsList";
import { OrganizationMembersList } from "./OrganizationMembersList";
export function OrganizationMembersBlock({
organizationId,
}: {
organizationId: string;
}) {
const t = useTranslations();
const [activeTab, setActiveTab] = useState("members");
return (
<SettingsItem
title={t("organizations.settings.members.title")}
description={t("organizations.settings.members.description")}
>
<Tabs value={activeTab} onValueChange={(tab) => setActiveTab(tab)}>
<TabsList className="mb-4">
<TabsTrigger value="members">
{t("organizations.settings.members.activeMembers")}
</TabsTrigger>
<TabsTrigger value="invitations">
{t("organizations.settings.members.pendingInvitations")}
</TabsTrigger>
</TabsList>
<TabsContent value="members">
<OrganizationMembersList organizationId={organizationId} />
</TabsContent>
<TabsContent value="invitations">
<OrganizationInvitationsList
organizationId={organizationId}
/>
</TabsContent>
</Tabs>
</SettingsItem>
);
}

View file

@ -0,0 +1,273 @@
"use client";
import type { OrganizationMemberRole } from "@repo/auth";
import { authClient } from "@repo/auth/client";
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
import { useSession } from "@saas/auth/hooks/use-session";
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
import {
fullOrganizationQueryKey,
useFullOrganizationQuery,
} from "@saas/organizations/lib/api";
import { UserAvatar } from "@shared/components/UserAvatar";
import { useQueryClient } from "@tanstack/react-query";
import type {
ColumnDef,
ColumnFiltersState,
SortingState,
} from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
import { LogOutIcon, MoreVerticalIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
export function OrganizationMembersList({
organizationId,
}: {
organizationId: string;
}) {
const t = useTranslations();
const queryClient = useQueryClient();
const { user } = useSession();
const { data: organization } = useFullOrganizationQuery(organizationId);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const memberRoles = useOrganizationMemberRoles();
const userIsOrganizationAdmin = isOrganizationAdmin(organization, user);
const updateMemberRole = async (
memberId: string,
role: OrganizationMemberRole,
) => {
toast.promise(
async () => {
await authClient.organization.updateMemberRole({
memberId,
role,
organizationId,
});
},
{
loading: t(
"organizations.settings.members.notifications.updateMembership.loading.description",
),
success: () => {
queryClient.invalidateQueries({
queryKey: fullOrganizationQueryKey(organizationId),
});
return t(
"organizations.settings.members.notifications.updateMembership.success.description",
);
},
error: t(
"organizations.settings.members.notifications.updateMembership.error.description",
),
},
);
};
const removeMember = async (memberId: string) => {
toast.promise(
async () => {
await authClient.organization.removeMember({
memberIdOrEmail: memberId,
organizationId,
});
},
{
loading: t(
"organizations.settings.members.notifications.removeMember.loading.description",
),
success: () => {
queryClient.invalidateQueries({
queryKey: fullOrganizationQueryKey(organizationId),
});
return t(
"organizations.settings.members.notifications.removeMember.success.description",
);
},
error: t(
"organizations.settings.members.notifications.removeMember.error.description",
),
},
);
};
const columns: ColumnDef<
NonNullable<typeof organization>["members"][number]
>[] = [
{
accessorKey: "user",
header: "",
accessorFn: (row) => row.user,
cell: ({ row }) =>
row.original.user ? (
<div className="flex items-center gap-2">
<UserAvatar
name={
row.original.user.name ??
row.original.user.email
}
avatarUrl={row.original.user?.image}
/>
<div>
<strong className="block">
{row.original.user.name}
</strong>
<small className="text-foreground/60">
{row.original.user.email}
</small>
</div>
</div>
) : null,
},
{
accessorKey: "actions",
header: "",
cell: ({ row }) => {
return (
<div className="flex flex-row justify-end gap-2">
{userIsOrganizationAdmin ? (
<>
<OrganizationRoleSelect
value={row.original.role}
onSelect={async (value) =>
updateMemberRole(row.original.id, value)
}
disabled={
!userIsOrganizationAdmin ||
row.original.role === "owner"
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{row.original.userId !== user?.id && (
<DropdownMenuItem
disabled={
!isOrganizationAdmin(
organization,
user,
)
}
className="text-destructive"
onClick={async () =>
removeMember(
row.original.id,
)
}
>
<TrashIcon className="mr-2 size-4" />
{t(
"organizations.settings.members.removeMember",
)}
</DropdownMenuItem>
)}
{row.original.userId === user?.id && (
<DropdownMenuItem
className="text-destructive"
onClick={async () =>
removeMember(
row.original.id,
)
}
>
<LogOutIcon className="mr-2 size-4" />
{t(
"organizations.settings.members.leaveOrganization",
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<span className="font-medium text-foreground/60 text-sm">
{
memberRoles[
row.original
.role as keyof typeof memberRoles
]
}
</span>
)}
</div>
);
},
},
];
const table = useReactTable({
data: organization?.members ?? [],
columns,
manualPagination: true,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
return (
<div className="rounded-md border">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,43 @@
import type { OrganizationMemberRole } from "@repo/auth";
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select";
export function OrganizationRoleSelect({
value,
onSelect,
disabled,
}: {
value: OrganizationMemberRole;
onSelect: (value: OrganizationMemberRole) => void;
disabled?: boolean;
}) {
const organizationMemberRoles = useOrganizationMemberRoles();
const roleOptions = Object.entries(organizationMemberRoles).map(
([value, label]) => ({
value,
label,
}),
);
return (
<Select value={value} onValueChange={onSelect} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -0,0 +1,160 @@
"use client";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
import { ActivePlanBadge } from "@saas/payments/components/ActivePlanBadge";
import { UserAvatar } from "@shared/components/UserAvatar";
import { useRouter } from "@shared/hooks/router";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu";
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { OrganizationLogo } from "./OrganizationLogo";
export function OrganzationSelect({ className }: { className?: string }) {
const t = useTranslations();
const { user } = useSession();
const router = useRouter();
const { activeOrganization, setActiveOrganization } =
useActiveOrganization();
const { data: allOrganizations } = useOrganizationListQuery();
if (!user) {
return null;
}
return (
<div className={className}>
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center justify-between gap-2 rounded-md border p-2 text-left outline-none focus-visible:bg-primary/10 focus-visible:ring-none">
<div className="flex flex-1 items-center justify-start gap-2 text-sm overflow-hidden">
{activeOrganization ? (
<>
<OrganizationLogo
name={activeOrganization.name}
logoUrl={activeOrganization.logo}
className="hidden size-6 sm:block"
/>
<span className="block flex-1 truncate">
{activeOrganization.name}
</span>
{config.organizations.enableBilling && (
<ActivePlanBadge
organizationId={activeOrganization.id}
/>
)}
</>
) : (
<>
<UserAvatar
className="hidden size-6 sm:block"
name={user.name ?? ""}
avatarUrl={user.image}
/>
<span className="block truncate">
{t(
"organizations.organizationSelect.personalAccount",
)}
</span>
{config.users.enableBilling && (
<ActivePlanBadge />
)}
</>
)}
</div>
<ChevronsUpDownIcon className="block size-4 opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
{!config.organizations.requireOrganization && (
<>
<DropdownMenuRadioGroup
value={activeOrganization?.id ?? user.id}
onValueChange={(value: string) => {
if (value === user.id) {
router.replace("/app");
}
}}
>
<DropdownMenuLabel className="text-foreground/60 text-xs">
{t(
"organizations.organizationSelect.personalAccount",
)}
</DropdownMenuLabel>
<DropdownMenuRadioItem
value={user.id}
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
>
<div className="flex flex-1 items-center justify-start gap-2">
<UserAvatar
className="size-8"
name={user.name ?? ""}
avatarUrl={user.image}
/>
{user.name}
</div>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuRadioGroup
value={activeOrganization?.slug}
onValueChange={(organizationSlug: string) =>
setActiveOrganization(organizationSlug)
}
>
<DropdownMenuLabel className="text-foreground/60 text-xs">
{t(
"organizations.organizationSelect.organizations",
)}
</DropdownMenuLabel>
{allOrganizations?.map((organization) => (
<DropdownMenuRadioItem
key={organization.slug}
value={organization.slug}
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
>
<div className="flex flex-1 items-center justify-start gap-2">
<OrganizationLogo
className="size-8"
name={organization.name}
logoUrl={organization.logo}
/>
{organization.name}
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{config.organizations.enableUsersToCreateOrganizations && (
<DropdownMenuGroup>
<DropdownMenuItem
asChild
className="text-primary! cursor-pointer text-sm"
>
<Link href="/app/new-organization">
<PlusIcon className="mr-2 size-6 rounded-md bg-primary/20 p-1" />
{t(
"organizations.organizationSelect.createNewOrganization",
)}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { StatsTile } from "@saas/start/components/StatsTile";
import { Card } from "@ui/components/card";
import { useTranslations } from "next-intl";
export default function OrganizationStart() {
const t = useTranslations();
return (
<div className="@container">
<div className="grid @2xl:grid-cols-3 gap-4">
<StatsTile
title="New clients"
value={344}
valueFormat="number"
trend={0.12}
/>
<StatsTile
title="Revenue"
value={5243}
valueFormat="currency"
trend={0.6}
/>
<StatsTile
title="Churn"
value={0.03}
valueFormat="percentage"
trend={-0.3}
/>
</div>
<Card className="mt-6">
<div className="flex h-64 items-center justify-center p-8 text-foreground/60">
Place your content here...
</div>
</Card>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { config } from "@repo/config";
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
import { Card } from "@ui/components/card";
import { ChevronRightIcon, PlusCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
export function OrganizationsGrid() {
const t = useTranslations();
const { setActiveOrganization } = useActiveOrganization();
const { data: allOrganizations } = useOrganizationListQuery();
return (
<div className="@container">
<h2 className="mb-2 font-semibold text-lg">
{t("organizations.organizationsGrid.title")}
</h2>
<div className="grid @2xl:grid-cols-3 @lg:grid-cols-2 grid-cols-1 gap-4">
{allOrganizations?.map((organization) => (
<Card
key={organization.id}
className="flex cursor-pointer items-center gap-4 overflow-hidden p-4"
onClick={() => setActiveOrganization(organization.slug)}
>
<OrganizationLogo
name={organization.name}
logoUrl={organization.logo}
className="size-12"
/>
<span className="flex items-center gap-1 text-base leading-tight">
<span className="block font-medium">
{organization.name}
</span>
<ChevronRightIcon className="size-4" />
</span>
</Card>
))}
{config.organizations.enableUsersToCreateOrganizations && (
<Link
href="/app/new-organization"
className="flex h-full items-center justify-center gap-2 rounded-2xl bg-primary/5 p-4 text-primary transition-colors duration-150 hover:bg-primary/10"
>
<PlusCircleIcon />
<span className="font-medium text-sm">
{t(
"organizations.organizationsGrid.createNewOrganization",
)}
</span>
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,12 @@
import type { OrganizationMemberRole } from "@repo/auth";
import { useTranslations } from "next-intl";
export function useOrganizationMemberRoles() {
const t = useTranslations();
return {
member: t("organizations.roles.member"),
owner: t("organizations.roles.owner"),
admin: t("organizations.roles.admin"),
} satisfies Record<OrganizationMemberRole, string>;
}

View file

@ -0,0 +1,23 @@
import { useContext } from "react";
import { ActiveOrganizationContext } from "../lib/active-organization-context";
export const useActiveOrganization = () => {
const activeOrganizationContext = useContext(ActiveOrganizationContext);
type ActiveOrganizationContextType = NonNullable<
typeof activeOrganizationContext
>;
if (activeOrganizationContext === undefined) {
return {
activeOrganization: null,
setActiveOrganization: () => Promise.resolve(),
refetchActiveOrganization: () => Promise.resolve(),
activeOrganizationUserRole: null,
isOrganizationAdmin: false,
loaded: true,
} satisfies ActiveOrganizationContextType;
}
return activeOrganizationContext;
};

View file

@ -0,0 +1,18 @@
import type { ActiveOrganization } from "@repo/auth";
import React from "react";
export const ActiveOrganizationContext = React.createContext<
| {
activeOrganization: ActiveOrganization | null;
activeOrganizationUserRole:
| ActiveOrganization["members"][number]["role"]
| null;
isOrganizationAdmin: boolean;
loaded: boolean;
setActiveOrganization: (
organizationId: string | null,
) => Promise<void>;
refetchActiveOrganization: () => Promise<void>;
}
| undefined
>(undefined);

View file

@ -0,0 +1,159 @@
import type { OrganizationMetadata } from "@repo/auth";
import { authClient } from "@repo/auth/client";
import { apiClient } from "@shared/lib/api-client";
import { useMutation, useQuery } from "@tanstack/react-query";
export const organizationListQueryKey = ["user", "organizations"] as const;
export const useOrganizationListQuery = () => {
return useQuery({
queryKey: organizationListQueryKey,
queryFn: async () => {
const { data, error } = await authClient.organization.list();
if (error) {
throw new Error(
error.message || "Failed to fetch organizations",
);
}
return data;
},
});
};
export const activeOrganizationQueryKey = (slug: string) =>
["user", "activeOrganization", slug] as const;
export const useActiveOrganizationQuery = (
slug: string,
options?: {
enabled?: boolean;
},
) => {
return useQuery({
queryKey: activeOrganizationQueryKey(slug),
queryFn: async () => {
const { data, error } =
await authClient.organization.getFullOrganization({
query: {
organizationSlug: slug,
},
});
if (error) {
throw new Error(
error.message || "Failed to fetch active organization",
);
}
return data;
},
enabled: options?.enabled,
});
};
export const fullOrganizationQueryKey = (id: string) =>
["fullOrganization", id] as const;
export const useFullOrganizationQuery = (id: string) => {
return useQuery({
queryKey: fullOrganizationQueryKey(id),
queryFn: async () => {
const { data, error } =
await authClient.organization.getFullOrganization({
query: {
organizationId: id,
},
});
if (error) {
throw new Error(
error.message || "Failed to fetch full organization",
);
}
return data;
},
});
};
export const generateOrganizationSlug = async (name: string) => {
const response = await apiClient.organizations["generate-slug"].$get({
query: {
name,
},
});
if (!response.ok) {
throw new Error("Failed to generate organization slug");
}
const { slug } = await response.json();
return slug;
};
/*
* Create organization
*/
export const createOrganizationMutationKey = ["create-organization"] as const;
export const useCreateOrganizationMutation = () => {
return useMutation({
mutationKey: createOrganizationMutationKey,
mutationFn: async ({
name,
metadata,
}: {
name: string;
metadata?: OrganizationMetadata;
}) => {
const { error, data } = await authClient.organization.create({
name,
slug: await generateOrganizationSlug(name),
metadata,
});
if (error) {
throw error;
}
return data;
},
});
};
/*
* Update organization
*/
export const updateOrganizationMutationKey = ["update-organization"] as const;
export const useUpdateOrganizationMutation = () => {
return useMutation({
mutationKey: updateOrganizationMutationKey,
mutationFn: async ({
id,
name,
metadata,
updateSlug,
}: {
id: string;
name: string;
metadata?: OrganizationMetadata;
updateSlug?: boolean;
}) => {
const { error, data } = await authClient.organization.update({
organizationId: id,
data: {
name,
slug: updateSlug
? await generateOrganizationSlug(name)
: undefined,
metadata,
},
});
if (error) {
throw error;
}
return data;
},
});
};

View file

@ -0,0 +1,110 @@
"use client";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { usePurchases } from "@saas/payments/hooks/purchases";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import {} from "@ui/components/table";
import { BadgeCheckIcon, CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { CustomerPortalButton } from "../../settings/components/CustomerPortalButton";
import { SubscriptionStatusBadge } from "../../settings/components/SubscriptionStatusBadge";
export function ActivePlan({
organizationId,
}: {
organizationId?: string;
seats?: number;
}) {
const t = useTranslations();
const { planData } = usePlanData();
const { activePlan } = usePurchases(organizationId);
if (!activePlan) {
return null;
}
const activePlanData = planData[activePlan.id as keyof typeof planData];
if (!activePlanData) {
return null;
}
const price = "price" in activePlan ? activePlan.price : null;
return (
<SettingsItem title={t("settings.billing.activePlan.title")}>
<div className="rounded-lg border p-4">
<div className="">
<div className="flex items-center gap-2">
<BadgeCheckIcon className="size-6 text-primary" />
<h4 className="font-bold text-lg text-primary">
<span>{activePlanData.title}</span>
</h4>
{activePlan.status && (
<SubscriptionStatusBadge
status={activePlan.status}
/>
)}
</div>
{!!activePlanData.features?.length && (
<ul className="mt-2 grid list-none gap-2 text-sm">
{activePlanData.features.map((feature, key) => (
<li
key={key}
className="flex items-center justify-start"
>
<CheckIcon className="mr-2 size-4 text-primary" />
<span>{feature}</span>
</li>
))}
</ul>
)}
{price && (
<strong
className="mt-2 block font-medium text-2xl lg:text-3xl"
data-test="price-table-plan-price"
>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
}).format(price.amount)}
{"interval" in price && (
<span className="font-normal text-xs opacity-60">
{" / "}
{price.interval === "month"
? t("pricing.month", {
count: price.intervalCount ?? 1,
})
: t("pricing.year", {
count: price.intervalCount ?? 1,
})}
</span>
)}
{organizationId &&
"seatBased" in price &&
price.seatBased && (
<span className="font-normal text-xs opacity-60">
{" / "}
{t("pricing.perSeat")}
</span>
)}
</strong>
)}
</div>
{"purchaseId" in activePlan && activePlan.purchaseId && (
<div className="mt-4 flex justify-end">
<div className="flex w-full flex-col flex-wrap gap-2 md:flex-row">
<CustomerPortalButton
purchaseId={activePlan.purchaseId}
/>
</div>
</div>
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { usePurchases } from "@saas/payments/hooks/purchases";
import { Badge } from "@ui/components/badge";
export function ActivePlanBadge({
organizationId,
}: {
organizationId?: string;
}) {
const { planData } = usePlanData();
const { activePlan } = usePurchases(organizationId);
if (!activePlan) {
return null;
}
const activePlanData = planData[activePlan.id as keyof typeof planData];
if (!activePlanData) {
return null;
}
return (
<Badge
status="info"
className="flex items-center gap-1 px-1.5 text-xs normal-case"
>
{activePlanData.title}
</Badge>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { PricingTable } from "@saas/payments/components/PricingTable";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useTranslations } from "next-intl";
export function ChangePlan({
organizationId,
userId,
activePlanId,
}: {
organizationId?: string;
userId?: string;
activePlanId?: string;
}) {
const t = useTranslations();
return (
<SettingsItem
title={t("settings.billing.changePlan.title")}
description={t("settings.billing.changePlan.description")}
>
<PricingTable
organizationId={organizationId}
userId={userId}
activePlanId={activePlanId}
/>
</SettingsItem>
);
}

View file

@ -0,0 +1,300 @@
"use client";
import { LocaleLink } from "@i18n/routing";
import { type Config, config } from "@repo/config";
import { usePlanData } from "@saas/payments/hooks/plan-data";
import { useCreateCheckoutLinkMutation } from "@saas/payments/lib/api";
import type { PlanId } from "@saas/payments/types";
import { useLocaleCurrency } from "@shared/hooks/locale-currency";
import { useRouter } from "@shared/hooks/router";
import { Button } from "@ui/components/button";
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
import { cn } from "@ui/lib";
import {
ArrowRightIcon,
BadgePercentIcon,
CheckIcon,
PhoneIcon,
StarIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
const plans = config.payments.plans as Config["payments"]["plans"];
export function PricingTable({
className,
userId,
organizationId,
activePlanId,
}: {
className?: string;
userId?: string;
organizationId?: string;
activePlanId?: string;
}) {
const t = useTranslations();
const router = useRouter();
const localeCurrency = useLocaleCurrency();
const [loading, setLoading] = useState<PlanId | false>(false);
const [interval, setInterval] = useState<"month" | "year">("month");
const { planData } = usePlanData();
const createCheckoutLinkMutation = useCreateCheckoutLinkMutation();
const onSelectPlan = async (planId: PlanId, productId?: string) => {
if (!(userId || organizationId)) {
router.push("/auth/signup");
return;
}
const plan = plans[planId];
const price = plan.prices?.find(
(price) => price.productId === productId,
);
if (!price) {
return;
}
setLoading(planId);
try {
const { checkoutLink } =
await createCheckoutLinkMutation.mutateAsync({
type:
price.type === "one-time" ? "one-time" : "subscription",
productId: price.productId,
organizationId,
redirectUrl: window.location.href,
});
window.location.href = checkoutLink;
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const filteredPlans = Object.entries(plans).filter(
([planId]) =>
planId !== activePlanId && (!activePlanId || planId !== "free"),
);
const hasSubscriptions = filteredPlans.some(([_, plan]) =>
plan.prices?.some((price) => price.type === "recurring"),
);
return (
<div className={cn("@container", className)}>
{hasSubscriptions && (
<div className="mb-6 flex @xl:justify-center">
<Tabs
value={interval}
onValueChange={(value) =>
setInterval(value as typeof interval)
}
data-test="price-table-interval-tabs"
>
<TabsList className="border-foreground/10">
<TabsTrigger value="month">
{t("pricing.monthly")}
</TabsTrigger>
<TabsTrigger value="year">
{t("pricing.yearly")}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
<div
className={cn("grid grid-cols-1 gap-4", {
"@xl:grid-cols-2": filteredPlans.length >= 2,
"@3xl:grid-cols-3": filteredPlans.length >= 3,
"@4xl:grid-cols-4": filteredPlans.length >= 4,
})}
>
{filteredPlans
.filter(([planId]) => planId !== activePlanId)
.map(([planId, plan]) => {
const { isFree, isEnterprise, prices, recommended } =
plan;
const { title, description, features } =
planData[planId as keyof typeof planData];
let price = prices?.find(
(price) =>
!price.hidden &&
(price.type === "one-time" ||
price.interval === interval) &&
price.currency === localeCurrency,
);
if (isFree) {
price = {
amount: 0,
currency: localeCurrency,
interval,
productId: "",
type: "recurring",
};
}
if (!(price || isEnterprise)) {
return null;
}
return (
<div
key={planId}
className={cn("rounded-3xl border p-6", {
"border-2 border-primary": recommended,
})}
data-test="price-table-plan"
>
<div className="flex h-full flex-col justify-between gap-4">
<div>
{recommended && (
<div className="-mt-9 flex justify-center">
<div className="mb-2 flex h-6 w-auto items-center gap-1.5 rounded-full bg-primary px-2 py-1 font-semibold text-primary-foreground text-xs">
<StarIcon className="size-3" />
{t("pricing.recommended")}
</div>
</div>
)}
<h3
className={cn(
"my-0 font-semibold text-2xl",
{
"font-bold text-primary":
recommended,
},
)}
>
{title}
</h3>
{description && (
<div className="prose mt-2 text-foreground/60 text-sm">
{description}
</div>
)}
{!!features?.length && (
<ul className="mt-4 grid list-none gap-2 text-sm">
{features.map(
(feature, key) => (
<li
key={key}
className="flex items-center justify-start"
>
<CheckIcon className="mr-2 size-4 text-primary" />
<span>
{feature}
</span>
</li>
),
)}
</ul>
)}
{price &&
"trialPeriodDays" in price &&
price.trialPeriodDays && (
<div className="mt-4 flex items-center justify-start font-medium text-primary text-sm opacity-80">
<BadgePercentIcon className="mr-2 size-4" />
{t("pricing.trialPeriod", {
days: price.trialPeriodDays,
})}
</div>
)}
</div>
<div>
{price && (
<strong
className="block font-medium text-2xl lg:text-3xl"
data-test="price-table-plan-price"
>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 0,
}).format(price.amount)}
{"interval" in price && (
<span className="font-normal text-xs opacity-60">
{" / "}
{interval === "month"
? t(
"pricing.month",
{
count:
price.intervalCount ??
1,
},
)
: t(
"pricing.year",
{
count:
price.intervalCount ??
1,
},
)}
</span>
)}
{organizationId &&
"seatBased" in price &&
price.seatBased && (
<span className="font-normal text-xs opacity-60">
{" / "}
{t(
"pricing.perSeat",
)}
</span>
)}
</strong>
)}
{isEnterprise ? (
<Button
className="mt-4 w-full"
variant="light"
asChild
>
<LocaleLink href="/contact">
<PhoneIcon className="mr-2 size-4" />
{t("pricing.contactSales")}
</LocaleLink>
</Button>
) : (
<Button
className="mt-4 w-full"
variant={
recommended
? "primary"
: "secondary"
}
onClick={() =>
onSelectPlan(
planId as PlanId,
price?.productId,
)
}
loading={loading === planId}
>
{userId || organizationId
? t("pricing.choosePlan")
: t("pricing.getStarted")}
<ArrowRightIcon className="ml-2 size-4" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import type { config } from "@repo/config";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
type ProductReferenceId = keyof (typeof config)["payments"]["plans"];
export function usePlanData() {
const t = useTranslations();
const planData: Record<
ProductReferenceId,
{
title: string;
description: ReactNode;
features: ReactNode[];
}
> = {
free: {
title: t("pricing.products.free.title"),
description: t("pricing.products.free.description"),
features: [
t("pricing.products.free.features.anotherFeature"),
t("pricing.products.free.features.limitedSupport"),
],
},
pro: {
title: t("pricing.products.pro.title"),
description: t("pricing.products.pro.description"),
features: [
t("pricing.products.pro.features.anotherFeature"),
t("pricing.products.pro.features.fullSupport"),
],
},
enterprise: {
title: t("pricing.products.enterprise.title"),
description: t("pricing.products.enterprise.description"),
features: [
t("pricing.products.enterprise.features.unlimitedProjects"),
t("pricing.products.enterprise.features.enterpriseSupport"),
],
},
lifetime: {
title: t("pricing.products.lifetime.title"),
description: t("pricing.products.lifetime.description"),
features: [
t("pricing.products.lifetime.features.noRecurringCosts"),
t("pricing.products.lifetime.features.extendSupport"),
],
},
};
return { planData };
}

View file

@ -0,0 +1,20 @@
import { type Config, config } from "@repo/config";
import { createPurchasesHelper } from "@repo/payments/lib/helper";
import { usePurchasesQuery } from "@saas/payments/lib/api";
const plans = config.payments.plans as Config["payments"]["plans"];
export const usePurchases = (organizationId?: string) => {
const { data: purchases } = usePurchasesQuery(organizationId);
const { activePlan, hasSubscription, hasPurchase } = createPurchasesHelper(
purchases ?? [],
);
return { purchases, activePlan, hasSubscription, hasPurchase };
};
export const useUserPurchases = () => usePurchases();
export const useOrganizationPurchases = (organizationId: string) =>
usePurchases(organizationId);

View file

@ -0,0 +1,80 @@
import { apiClient } from "@shared/lib/api-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { InferRequestType } from "hono";
export const createCustomerPortalLinkMutationKey = [
"create-customer-portal-link",
] as const;
export const useCreateCustomerPortalLinkMutation = () => {
return useMutation({
mutationKey: createCustomerPortalLinkMutationKey,
mutationFn: async (
query: InferRequestType<
(typeof apiClient.payments)["create-customer-portal-link"]["$post"]
>["query"],
) => {
const response = await apiClient.payments[
"create-customer-portal-link"
].$post({
query,
});
if (!response.ok) {
throw new Error("Failed to create customer portal link");
}
return response.json();
},
});
};
export const createCheckoutLinkMutationKey = ["create-checkout-link"] as const;
export const useCreateCheckoutLinkMutation = () => {
return useMutation({
mutationKey: createCheckoutLinkMutationKey,
mutationFn: async (
query: InferRequestType<
(typeof apiClient.payments)["create-checkout-link"]["$post"]
>["query"],
) => {
const response = await apiClient.payments[
"create-checkout-link"
].$post({
query,
});
if (!response.ok) {
throw new Error("Failed to create checkout link");
}
return response.json();
},
});
};
export const purchasesQueryKey = (organizationId?: string) =>
organizationId
? ["organization", "purchases", organizationId]
: (["user", "purchases"] as const);
export const usePurchasesQuery = (
organizationId?: string,
options?: { enabled?: boolean },
) => {
return useQuery({
queryKey: purchasesQueryKey(organizationId),
queryFn: async () => {
const response = await apiClient.payments.purchases.$get({
query: {
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch purchases");
}
return response.json();
},
...options,
});
};

View file

@ -0,0 +1,16 @@
import type { PlanId } from "@saas/payments/types";
import React from "react";
export const PurchasesContext = React.createContext<
| {
activeSubscription: {
id: string;
purchaseId?: string;
} | null;
loaded: boolean;
hasSubscription: (planIds?: PlanId[] | PlanId) => boolean;
refetchPurchases: () => Promise<void>;
hasPurchase: (planId: PlanId) => boolean;
}
| undefined
>(undefined);

View file

@ -0,0 +1,17 @@
import { getServerApiClient } from "@shared/lib/server";
import { cache } from "react";
export const getPurchases = cache(async (organizationId?: string) => {
const apiClient = await getServerApiClient();
const response = await apiClient.payments.purchases.$get({
query: {
organizationId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch purchases");
}
return response.json();
});

View file

@ -0,0 +1,3 @@
import type { config } from "@repo/config";
export type PlanId = keyof typeof config.payments.plans;

View file

@ -0,0 +1,109 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { ComputerIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function ActiveSessionsBlock() {
const t = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { session: currentSession } = useSession();
const { data: sessions, isPending } = useQuery({
queryKey: ["active-sessions"],
queryFn: async () => {
const { data, error } = await authClient.listSessions();
if (error) {
throw error;
}
return data;
},
});
const revokeSession = (token: string) => {
authClient.revokeSession(
{
token,
},
{
onSuccess: () => {
toast.success(
t(
"settings.account.security.activeSessions.notifications.revokeSession.success",
),
);
queryClient.invalidateQueries({
queryKey: ["active-sessions"],
});
router.refresh();
},
},
);
};
return (
<SettingsItem
title={t("settings.account.security.activeSessions.title")}
description={t(
"settings.account.security.activeSessions.description",
)}
>
<div className="grid grid-cols-1 gap-2">
{isPending ? (
<>
<div className="flex gap-2">
<Skeleton className="size-6 shrink-0" />
<div className="flex-1">
<Skeleton className="mb-0.5 h-4 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Skeleton className="size-9 shrink-0" />
</div>
</>
) : (
sessions?.map((session) => (
<div
key={session.id}
className="flex justify-between gap-4"
>
<div className="flex gap-2">
<ComputerIcon className="size-6 shrink-0 text-primary/50" />
<div>
<strong className="block text-sm">
{session.id === currentSession?.id
? t(
"settings.account.security.activeSessions.currentSession",
)
: session.ipAddress}
</strong>
<small className="block text-foreground/60 text-xs leading-tight">
{session.userAgent}
</small>
</div>
</div>
<Button
variant="light"
size="icon"
className="shrink-0"
onClick={() => revokeSession(session.token)}
>
<XIcon className="size-4" />
</Button>
</div>
))
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
});
type FormSchema = z.infer<typeof formSchema>;
export function ChangeEmailForm() {
const { user, reloadSession } = useSession();
const t = useTranslations();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user?.email ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ email }) => {
const { error } = await authClient.changeEmail({
newEmail: email,
});
if (error) {
toast.error(t("settings.account.changeEmail.notifications.error"));
return;
}
toast.success(t("settings.account.changeEmail.notifications.success"));
reloadSession();
});
return (
<SettingsItem
title={t("settings.account.changeEmail.title")}
description={t("settings.account.changeEmail.description")}
>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Input type="email" {...form.register("email")} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
form.formState.dirtyFields.email
)
}
>
{t("settings.save")}
</Button>
</div>
</form>
</SettingsItem>
);
}

View file

@ -0,0 +1,72 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(3),
});
type FormSchema = z.infer<typeof formSchema>;
export function ChangeNameForm() {
const { user, reloadSession } = useSession();
const t = useTranslations();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.name ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ name }) => {
const { error } = await authClient.updateUser({
name,
});
if (error) {
toast.error(t("settings.account.changeName.notifications.error"));
return;
}
toast.success(t("settings.account.changeName.notifications.success"));
reloadSession();
form.reset({
name,
});
});
return (
<SettingsItem title={t("settings.account.changeName.title")}>
<form onSubmit={onSubmit}>
<Input type="text" {...form.register("name")} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
form.formState.dirtyFields.name
)
}
>
{t("settings.save")}
</Button>
</div>
</form>
</SettingsItem>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { PasswordInput } from "@ui/components/password-input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
export function ChangePasswordForm() {
const t = useTranslations();
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
},
});
const onSubmit = form.handleSubmit(async (values) => {
const { error } = await authClient.changePassword({
...values,
revokeOtherSessions: true,
});
if (error) {
toast.error(
t(
"settings.account.security.changePassword.notifications.error",
),
);
return;
}
toast.success(
t("settings.account.security.changePassword.notifications.success"),
);
form.reset({});
router.refresh();
});
return (
<SettingsItem
title={t("settings.account.security.changePassword.title")}
>
<Form {...form}>
<form onSubmit={onSubmit}>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"settings.account.security.changePassword.currentPassword",
)}
</FormLabel>
<FormControl>
<PasswordInput
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"settings.account.security.changePassword.newPassword",
)}
</FormLabel>
<FormControl>
<PasswordInput
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
Object.keys(form.formState.dirtyFields)
.length
)
}
>
{t("settings.save")}
</Button>
</div>
</div>
</form>
</Form>
</SettingsItem>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { authClient } from "@repo/auth/client";
import {
type OAuthProvider,
oAuthProviders,
} from "@saas/auth/constants/oauth-providers";
import { useUserAccountsQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { CheckCircle2Icon, LinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export function ConnectedAccountsBlock() {
const t = useTranslations();
const { data, isPending } = useUserAccountsQuery();
const isProviderLinked = (provider: OAuthProvider) =>
data?.some((account) => account.provider === provider);
const linkProvider = (provider: OAuthProvider) => {
const callbackURL = window.location.href;
if (!isProviderLinked(provider)) {
authClient.linkSocial({
provider,
callbackURL,
});
}
};
return (
<SettingsItem
title={t("settings.account.security.connectedAccounts.title")}
>
<div className="grid grid-cols-1 divide-y">
{Object.entries(oAuthProviders).map(
([provider, providerData]) => {
const isLinked = isProviderLinked(
provider as OAuthProvider,
);
return (
<div
key={provider}
className="flex h-14 items-center justify-between gap-2 py-2"
>
<div className="flex items-center gap-2">
<providerData.icon className="size-4 text-primary/50" />
<span className="text-sm">
{providerData.name}
</span>
</div>
{isPending ? (
<Skeleton className="h-10 w-28" />
) : isLinked ? (
<CheckCircle2Icon className="size-6 text-success" />
) : (
<Button
variant={isLinked ? "outline" : "light"}
onClick={() =>
linkProvider(
provider as OAuthProvider,
)
}
>
<LinkIcon className="mr-1.5 size-4" />
<span>
{t(
"settings.account.security.connectedAccounts.connect",
)}
</span>
</Button>
)}
</div>
);
},
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import { Button } from "@ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog";
import { useMemo, useRef } from "react";
import type { ReactCropperElement } from "react-cropper";
import Cropper from "react-cropper";
export function CropImageDialog({
image,
open,
onOpenChange,
onCrop,
}: {
image: File | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCrop: (croppedImage: Blob | null) => void;
}) {
const cropperRef = useRef<ReactCropperElement>(null);
const getCroppedImage = async () => {
const cropper = cropperRef.current?.cropper;
const imageBlob = await new Promise<Blob | null>((resolve) => {
cropper
?.getCroppedCanvas({
maxWidth: 256,
maxHeight: 256,
})
.toBlob(resolve);
});
return imageBlob;
};
const imageSrc = useMemo(
() => image && URL.createObjectURL(image),
[image],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle />
</DialogHeader>
<div>
{imageSrc && (
<Cropper
src={imageSrc}
style={{ width: "100%" }}
initialAspectRatio={1}
aspectRatio={1}
guides={true}
ref={cropperRef}
/>
)}
</div>
<DialogFooter>
<Button
onClick={async () => {
onCrop(await getCroppedImage());
onOpenChange(false);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { useCreateCustomerPortalLinkMutation } from "@saas/payments/lib/api";
import { Button } from "@ui/components/button";
import { CreditCardIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function CustomerPortalButton({ purchaseId }: { purchaseId: string }) {
const t = useTranslations();
const createCustomerPortalMutation = useCreateCustomerPortalLinkMutation();
const createCustomerPortal = async () => {
try {
const { customerPortalLink } =
await createCustomerPortalMutation.mutateAsync({
purchaseId,
redirectUrl: window.location.href,
});
window.location.href = customerPortalLink;
} catch {
toast.error(
t(
"settings.billing.createCustomerPortal.notifications.error.title",
),
);
}
};
return (
<Button
variant="light"
size="sm"
onClick={() => createCustomerPortal()}
loading={createCustomerPortalMutation.isPending}
>
<CreditCardIcon className="mr-2 size-4" />
{t("settings.billing.createCustomerPortal.label")}
</Button>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useMutation } from "@tanstack/react-query";
import {} from "@ui/components/alert-dialog";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function DeleteAccountForm() {
const t = useTranslations();
const { reloadSession } = useSession();
const { confirm } = useConfirmationAlert();
const deleteUserMutation = useMutation({
mutationFn: async () => {
const { error } = await authClient.deleteUser({});
if (error) {
throw error;
}
},
onSuccess: () => {
toast.success(
t("settings.account.deleteAccount.notifications.success"),
);
reloadSession();
},
onError: () => {
toast.error(
t("settings.account.deleteAccount.notifications.error"),
);
},
});
const confirmDelete = () => {
confirm({
title: t("settings.account.deleteAccount.title"),
message: t("settings.account.deleteAccount.confirmation"),
onConfirm: async () => {
await deleteUserMutation.mutateAsync();
},
});
};
return (
<>
<SettingsItem
danger
title={t("settings.account.deleteAccount.title")}
description={t("settings.account.deleteAccount.description")}
>
<div className="mt-4 flex justify-end">
<Button variant="error" onClick={() => confirmDelete()}>
{t("settings.account.deleteAccount.submit")}
</Button>
</div>
</SettingsItem>
</>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useUserPasskeysQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { KeyIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { toast } from "sonner";
export function PasskeysBlock() {
const t = useTranslations();
const queryClient = useQueryClient();
const formatter = useFormatter();
const { data: passkeys, isPending } = useUserPasskeysQuery();
const addPasskey = async () => {
await authClient.passkey.addPasskey({
fetchOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["passkeys"] });
toast.success(
t(
"settings.account.security.passkeys.notifications.addPasskey.success.title",
),
);
},
onError: () => {
toast.error(
t(
"settings.account.security.passkeys.notifications.addPasskey.error.title",
),
);
},
},
});
};
const deletePasskey = (id: string) => {
toast.promise(
async () => {
await authClient.passkey.deletePasskey({
id,
});
},
{
loading: t(
"settings.account.security.passkeys.notifications.deletePasskey.loading.title",
),
success: t(
"settings.account.security.passkeys.notifications.deletePasskey.success.title",
),
error: t(
"settings.account.security.passkeys.notifications.deletePasskey.error.title",
),
},
);
};
return (
<SettingsItem
title={t("settings.account.security.passkeys.title")}
description={t("settings.account.security.passkeys.description")}
>
<div className="grid grid-cols-1 gap-2">
{isPending ? (
<>
<div className="flex gap-2">
<Skeleton className="size-6 shrink-0" />
<div className="flex-1">
<Skeleton className="mb-0.5 h-4 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Skeleton className="size-9 shrink-0" />
</div>
</>
) : (
passkeys?.map((passkey) => (
<div key={passkey.id} className="flex gap-2">
<KeyIcon className="size-6 shrink-0 text-primary/50" />
<div className="flex-1">
<strong className="block text-sm">
{passkey.deviceType} {passkey.name}
</strong>
<small className="block text-foreground/60 text-xs leading-tight">
{formatter.dateTime(
new Date(passkey.createdAt),
)}
</small>
</div>
<Button
variant="light"
size="icon"
className="shrink-0"
onClick={() => deletePasskey(passkey.id)}
>
<TrashIcon className="size-4" />
</Button>
</div>
))
)}
<div className="flex justify-start">
<Button variant="light" onClick={addPasskey}>
<PlusIcon className="mr-1.5 size-4" />
Add passkey
</Button>
</div>
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
export function SetPasswordForm() {
const t = useTranslations();
const { user } = useSession();
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
if (!user) {
return;
}
setSubmitting(true);
await authClient.forgetPassword(
{
email: user.email,
redirectTo: `${window.location.origin}/auth/reset-password`,
},
{
onSuccess: () => {
toast.success(
t(
"settings.account.security.setPassword.notifications.success",
),
);
},
onError: () => {
toast.error(
t(
"settings.account.security.setPassword.notifications.error",
),
);
},
onResponse: () => {
setSubmitting(false);
},
},
);
};
return (
<SettingsItem
title={t("settings.account.security.setPassword.title")}
description={t("settings.account.security.setPassword.description")}
>
<div className="flex justify-end">
<Button type="submit" loading={submitting} onClick={onSubmit}>
{t("settings.account.security.setPassword.submit")}
</Button>
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { cn } from "@ui/lib";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
export function SettingsMenu({
menuItems,
}: {
menuItems: {
title: string;
avatar: ReactNode;
items: {
title: string;
href: string;
icon?: ReactNode;
}[];
}[];
}) {
const pathname = usePathname();
const isActiveMenuItem = (href: string) => pathname.includes(href);
return (
<div className="space-y-8">
{menuItems.map((item, i) => (
<div key={i}>
<div className="flex items-center justify-start gap-2">
{item.avatar}
<h2 className="font-semibold text-foreground/60 text-xs">
{item.title}
</h2>
</div>
<ul className="mt-2 flex list-none flex-row gap-6 lg:mt-4 lg:flex-col lg:gap-2">
{item.items.map((subitem, k) => (
<li key={k}>
<Link
href={subitem.href}
className={cn(
"lg:-ml-0.5 flex items-center gap-2 border-b-2 py-1.5 text-sm lg:border-b-0 lg:border-l-2 lg:pl-2",
isActiveMenuItem(subitem.href)
? "border-primary font-bold"
: "border-transparent",
)}
data-active={isActiveMenuItem(subitem.href)}
>
<span className="shrink-0">
{subitem.icon}
</span>
<span>{subitem.title}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import type { BadgeProps } from "@ui/components/badge";
import { Badge } from "@ui/components/badge";
import { useTranslations } from "next-intl";
export function SubscriptionStatusBadge({
status,
}: {
status: string;
className?: string;
}) {
const t = useTranslations();
const badgeLabels: Record<string, string> = {
active: t("settings.billing.activePlan.status.active"),
canceled: t("settings.billing.activePlan.status.canceled"),
expired: t("settings.billing.activePlan.status.expired"),
incomplete: t("settings.billing.activePlan.status.incomplete"),
past_due: t("settings.billing.activePlan.status.past_due"),
paused: t("settings.billing.activePlan.status.paused"),
trialing: t("settings.billing.activePlan.status.trialing"),
unpaid: t("settings.billing.activePlan.status.unpaid"),
};
const badgeColors: Record<string, BadgeProps["status"]> = {
active: "success",
canceled: "error",
expired: "error",
incomplete: "warning",
past_due: "warning",
paused: "warning",
trialing: "info",
unpaid: "error",
};
return <Badge status={badgeColors[status]}>{badgeLabels[status]}</Badge>;
}

View file

@ -0,0 +1,290 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { useUserAccountsQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Card } from "@ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog";
import { FormItem } from "@ui/components/form";
import { Input } from "@ui/components/input";
import { Label } from "@ui/components/label";
import { PasswordInput } from "@ui/components/password-input";
import {
ArrowRightIcon,
CheckIcon,
ShieldCheckIcon,
TabletSmartphoneIcon,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
import QRCode from "react-qr-code";
import { toast } from "sonner";
export function TwoFactorBlock() {
const t = useTranslations();
const { user, reloadSession } = useSession();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogView, setDialogView] = useState<"password" | "totp-url">(
"password",
);
const [totpURI, setTotpURI] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const { data: accounts } = useUserAccountsQuery();
useEffect(() => {
setPassword("");
}, [dialogOpen]);
const totpURISecret = useMemo(() => {
if (!totpURI) {
return null;
}
const url = new URL(totpURI);
return url.searchParams.get("secret") || null;
}, [totpURI]);
const verifyPassword = async () => {
setDialogView("password");
setDialogOpen(true);
};
const enableTwoFactorMutation = useMutation({
mutationKey: ["enableTwoFactor"],
mutationFn: async () => {
const { data, error } = await authClient.twoFactor.enable({
password,
});
if (error) {
throw error;
}
setTotpURI(data.totpURI);
setDialogView("totp-url");
},
onError: () => {
toast.error(
t(
"settings.account.security.twoFactor.notifications.enable.error.title",
),
);
},
});
const disableTwoFactorMutation = useMutation({
mutationKey: ["disableTwoFactor"],
mutationFn: async () => {
const { data, error } = await authClient.twoFactor.disable({
password,
});
if (error) {
throw error;
}
setDialogOpen(false);
toast.success(
t(
"settings.account.security.twoFactor.notifications.disable.success.title",
),
);
reloadSession();
},
onError: () => {
toast.error(
t(
"settings.account.security.twoFactor.notifications.enable.error.title",
),
);
},
});
const verifyTwoFactorMutation = useMutation({
mutationKey: ["verifyTwoFactor"],
mutationFn: async () => {
const { error } = await authClient.twoFactor.verifyTotp({
code: totpCode,
});
if (error) {
throw error;
}
toast.success(
t(
"settings.account.security.twoFactor.notifications.verify.success.title",
),
);
reloadSession();
setDialogOpen(false);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (user?.twoFactorEnabled) {
disableTwoFactorMutation.mutate();
return;
}
if (dialogView === "password") {
enableTwoFactorMutation.mutate();
return;
}
verifyTwoFactorMutation.mutate();
};
if (!accounts?.some((account) => account.provider === "credential")) {
return null;
}
return (
<SettingsItem
title={t("settings.account.security.twoFactor.title")}
description={t("settings.account.security.twoFactor.description")}
>
{user?.twoFactorEnabled ? (
<div className="flex items-start flex-col gap-4">
<div className="flex items-center gap-1.5">
<ShieldCheckIcon className="size-6 text-green-500" />
<p className="text-sm text-foreground">
{t("settings.account.security.twoFactor.enabled")}
</p>
</div>
<Button variant="light" onClick={verifyPassword}>
<XIcon className="mr-1.5 size-4" />
{t("settings.account.security.twoFactor.disable")}
</Button>
</div>
) : (
<div className="flex justify-start">
<Button variant="light" onClick={verifyPassword}>
<TabletSmartphoneIcon className="mr-1.5 size-4" />
{t("settings.account.security.twoFactor.enable")}
</Button>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogView === "password"
? t(
"settings.account.security.twoFactor.dialog.password.title",
)
: t(
"settings.account.security.twoFactor.dialog.totpUrl.title",
)}
</DialogTitle>
</DialogHeader>
{dialogView === "password" ? (
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4">
<p className="text-sm text-foreground/60">
{t(
"settings.account.security.twoFactor.dialog.password.description",
)}
</p>
<FormItem>
<Label className="block">
{t(
"settings.account.security.twoFactor.dialog.password.label",
)}
</Label>
<PasswordInput
value={password}
onChange={(value) => setPassword(value)}
/>
</FormItem>
</div>
<div className="mt-4">
<Button
type="submit"
variant="secondary"
className="w-full"
loading={
enableTwoFactorMutation.isPending ||
disableTwoFactorMutation.isPending
}
>
{t("common.actions.continue")}
<ArrowRightIcon className="ml-1.5 size-4" />
</Button>
</div>
</form>
) : (
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4">
<p className="text-sm text-foreground/60">
{t(
"settings.account.security.twoFactor.dialog.totpUrl.description",
)}
</p>
<Card className="flex flex-col items-center gap-4 p-6">
<QRCode value={totpURI} />
{totpURISecret && (
<p className="text-xs text-muted-foreground text-center">
{totpURISecret}
</p>
)}
</Card>
<hr />
<div className="grid grid-cols-1 gap-4">
<FormItem>
<Label className="block">
{t(
"settings.account.security.twoFactor.dialog.totpUrl.code",
)}
</Label>
<Input
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value)
}
/>
</FormItem>
</div>
</div>
<div className="mt-4">
<Button
type="submit"
variant="secondary"
className="w-full"
loading={verifyTwoFactorMutation.isPending}
>
<CheckIcon className="mr-1.5 size-4" />
{t("common.actions.verify")}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
</SettingsItem>
);
}

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