Revert "Unify workspace dropdowns; Update layouts (#458)"

This reverts commit 4bc8448a1d.
This commit is contained in:
gbuomprisco
2026-03-11 14:47:47 +08:00
parent 4bc8448a1d
commit 4912e402a3
530 changed files with 11182 additions and 14382 deletions

View File

@@ -0,0 +1,30 @@
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('marketing:cookiePolicy'),
};
}
async function CookiePolicyPage() {
const { t } = await createI18nServerInstance();
return (
<div>
<SitePageHeader
title={t(`marketing:cookiePolicy`)}
subtitle={t(`marketing:cookiePolicyDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default withI18n(CookiePolicyPage);

View File

@@ -0,0 +1,30 @@
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('marketing:privacyPolicy'),
};
}
async function PrivacyPolicyPage() {
const { t } = await createI18nServerInstance();
return (
<div>
<SitePageHeader
title={t('marketing:privacyPolicy')}
subtitle={t('marketing:privacyPolicyDescription')}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default withI18n(PrivacyPolicyPage);

View File

@@ -0,0 +1,30 @@
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('marketing:termsOfService'),
};
}
async function TermsOfServicePage() {
const { t } = await createI18nServerInstance();
return (
<div>
<SitePageHeader
title={t(`marketing:termsOfService`)}
subtitle={t(`marketing:termsOfServiceDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default withI18n(TermsOfServicePage);

View File

@@ -8,10 +8,10 @@ export function SiteFooter() {
return (
<Footer
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
description={<Trans i18nKey="marketing.footerDescription" />}
description={<Trans i18nKey="marketing:footerDescription" />}
copyright={
<Trans
i18nKey="marketing.copyright"
i18nKey="marketing:copyright"
values={{
product: appConfig.name,
year: new Date().getFullYear(),
@@ -20,35 +20,35 @@ export function SiteFooter() {
}
sections={[
{
heading: <Trans i18nKey="marketing.about" />,
heading: <Trans i18nKey="marketing:about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing.blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing.contact" /> },
{ href: '/blog', label: <Trans i18nKey="marketing:blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing:contact" /> },
],
},
{
heading: <Trans i18nKey="marketing.product" />,
heading: <Trans i18nKey="marketing:product" />,
links: [
{
href: '/docs',
label: <Trans i18nKey="marketing.documentation" />,
label: <Trans i18nKey="marketing:documentation" />,
},
],
},
{
heading: <Trans i18nKey="marketing.legal" />,
heading: <Trans i18nKey="marketing:legal" />,
links: [
{
href: '/terms-of-service',
label: <Trans i18nKey="marketing.termsOfService" />,
label: <Trans i18nKey="marketing:termsOfService" />,
},
{
href: '/privacy-policy',
label: <Trans i18nKey="marketing.privacyPolicy" />,
label: <Trans i18nKey="marketing:privacyPolicy" />,
},
{
href: '/cookie-policy',
label: <Trans i18nKey="marketing.cookiePolicy" />,
label: <Trans i18nKey="marketing:cookiePolicy" />,
},
],
},

View File

@@ -31,7 +31,6 @@ const MobileModeToggle = dynamic(
const paths = {
home: pathsConfig.app.home,
profileSettings: pathsConfig.app.personalAccountSettings,
};
const features = {
@@ -79,28 +78,26 @@ function AuthButtons() {
<div className={'flex items-center gap-x-2'}>
<Button
nativeButton={false}
className={'hidden md:flex md:text-sm'}
render={
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth.signIn'} />
</Link>
}
asChild
variant={'outline'}
size={'sm'}
/>
>
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
<Button
nativeButton={false}
render={
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth.signUp'} />
</Link>
}
asChild
className="text-xs md:text-sm"
variant={'default'}
size={'sm'}
/>
>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
</Link>
</Button>
</div>
</div>
);

View File

@@ -9,7 +9,7 @@ import { SiteNavigation } from './site-navigation';
export function SiteHeader(props: { user?: JWTUserData | null }) {
return (
<Header
logo={<AppLogo className="mx-auto sm:mx-0" href="/" />}
logo={<AppLogo />}
navigation={<SiteNavigation />}
actions={<SiteHeaderAccountSection user={props.user ?? null} />}
/>

View File

@@ -15,23 +15,23 @@ import { SiteNavigationItem } from './site-navigation-item';
const links = {
Blog: {
label: 'marketing.blog',
label: 'marketing:blog',
path: '/blog',
},
Changelog: {
label: 'marketing.changelog',
label: 'marketing:changelog',
path: '/changelog',
},
Docs: {
label: 'marketing.documentation',
label: 'marketing:documentation',
path: '/docs',
},
Pricing: {
label: 'marketing.pricing',
label: 'marketing:pricing',
path: '/pricing',
},
FAQ: {
label: 'marketing.faq',
label: 'marketing:faq',
path: '/faq',
},
};
@@ -74,14 +74,11 @@ function MobileDropdown() {
const className = 'flex w-full h-full items-center';
return (
<DropdownMenuItem
key={item.path}
render={
<Link className={className} href={item.path}>
<Trans i18nKey={item.label} />
</Link>
}
/>
<DropdownMenuItem key={item.path} asChild>
<Link className={className} href={item.path}>
<Trans i18nKey={item.label} />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>

View File

@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post';
interface BlogPageProps {
@@ -73,4 +75,4 @@ async function BlogPost({ params }: BlogPageProps) {
);
}
export default BlogPost;
export default withI18n(BlogPost);

View File

@@ -25,7 +25,7 @@ export function BlogPagination(props: {
}}
>
<ArrowLeft className={'mr-2 h-4'} />
<Trans i18nKey={'marketing.blogPaginationPrevious'} />
<Trans i18nKey={'marketing:blogPaginationPrevious'} />
</Button>
</If>
@@ -36,7 +36,7 @@ export function BlogPagination(props: {
navigate(props.currentPage + 1);
}}
>
<Trans i18nKey={'marketing.blogPaginationNext'} />
<Trans i18nKey={'marketing:blogPaginationNext'} />
<ArrowRight className={'ml-2 h-4'} />
</Button>
</If>

View File

@@ -2,13 +2,14 @@ import { cache } from 'react';
import type { Metadata } from 'next';
import { getLocale, getTranslations } from 'next-intl/server';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { SitePageHeader } from '../_components/site-page-header';
import { BlogPagination } from './_components/blog-pagination';
@@ -23,8 +24,7 @@ const BLOG_POSTS_PER_PAGE = 10;
export const generateMetadata = async (
props: BlogPageProps,
): Promise<Metadata> => {
const t = await getTranslations('marketing');
const resolvedLanguage = await getLocale();
const { t, resolvedLanguage } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = BLOG_POSTS_PER_PAGE;
@@ -34,8 +34,8 @@ export const generateMetadata = async (
const { total } = await getContentItems(resolvedLanguage, limit, offset);
return {
title: t('blog'),
description: t('blogSubtitle'),
title: t('marketing:blog'),
description: t('marketing:blogSubtitle'),
pagination: {
previous: page > 0 ? `/blog?page=${page - 1}` : undefined,
next: offset + limit < total ? `/blog?page=${page + 1}` : undefined,
@@ -67,8 +67,7 @@ const getContentItems = cache(
);
async function BlogPage(props: BlogPageProps) {
const t = await getTranslations('marketing');
const language = await getLocale();
const { t, resolvedLanguage: language } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = BLOG_POSTS_PER_PAGE;
@@ -83,12 +82,15 @@ async function BlogPage(props: BlogPageProps) {
return (
<>
<SitePageHeader title={t('blog')} subtitle={t('blogSubtitle')} />
<SitePageHeader
title={t('marketing:blog')}
subtitle={t('marketing:blogSubtitle')}
/>
<div className={'container flex flex-col space-y-6 py-8'}>
<If
condition={posts.length > 0}
fallback={<Trans i18nKey="marketing.noPosts" />}
fallback={<Trans i18nKey="marketing:noPosts" />}
>
<PostsGridList>
{posts.map((post, idx) => {
@@ -109,7 +111,7 @@ async function BlogPage(props: BlogPageProps) {
);
}
export default BlogPage;
export default withI18n(BlogPage);
function PostsGridList({ children }: React.PropsWithChildren) {
return (

View File

@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n';
import { ChangelogDetail } from '../_components/changelog-detail';
interface ChangelogEntryPageProps {
@@ -105,4 +107,4 @@ async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) {
);
}
export default ChangelogEntryPage;
export default withI18n(ChangelogEntryPage);

View File

@@ -22,7 +22,7 @@ export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) {
className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<Trans i18nKey="marketing.changelog" />
<Trans i18nKey="marketing:changelog" />
</Link>
</div>
</div>

View File

@@ -24,8 +24,8 @@ function NavLink({ entry, direction }: NavLinkProps) {
const Icon = isPrevious ? ChevronLeft : ChevronRight;
const i18nKey = isPrevious
? 'marketing.changelogNavigationPrevious'
: 'marketing.changelogNavigationNext';
? 'marketing:changelogNavigationPrevious'
: 'marketing:changelogNavigationNext';
return (
<Link

View File

@@ -22,29 +22,24 @@ export function ChangelogPagination({
return (
<div className="flex justify-end gap-2">
{canGoToPreviousPage && (
<Button
render={<Link href={`/changelog?page=${previousPage}`} />}
variant="outline"
size="sm"
>
<ArrowLeft className="mr-2 h-3 w-3" />
<span>
<Trans i18nKey="marketing.changelogPaginationPrevious" />
</span>
<Button asChild variant="outline" size="sm">
<Link href={`/changelog?page=${previousPage}`}>
<ArrowLeft className="mr-2 h-3 w-3" />
<span>
<Trans i18nKey="marketing:changelogPaginationPrevious" />
</span>
</Link>
</Button>
)}
{canGoToNextPage && (
<Button
render={<Link href={`/changelog?page=${nextPage}`} />}
variant="outline"
size="sm"
>
<span>
<Trans i18nKey="marketing.changelogPaginationNext" />
</span>
<ArrowRight className="ml-2 h-3 w-3" />
<Button asChild variant="outline" size="sm">
<Link href={`/changelog?page=${nextPage}`}>
<span>
<Trans i18nKey="marketing:changelogPaginationNext" />
</span>
<ArrowRight className="ml-2 h-3 w-3" />
</Link>
</Button>
)}
</div>

View File

@@ -2,13 +2,14 @@ import { cache } from 'react';
import type { Metadata } from 'next';
import { getLocale, getTranslations } from 'next-intl/server';
import { createCmsClient } from '@kit/cms';
import { getLogger } from '@kit/shared/logger';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../_components/site-page-header';
import { ChangelogEntry } from './_components/changelog-entry';
import { ChangelogPagination } from './_components/changelog-pagination';
@@ -22,8 +23,7 @@ const CHANGELOG_ENTRIES_PER_PAGE = 50;
export const generateMetadata = async (
props: ChangelogPageProps,
): Promise<Metadata> => {
const t = await getTranslations('marketing');
const resolvedLanguage = await getLocale();
const { t, resolvedLanguage } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
@@ -33,8 +33,8 @@ export const generateMetadata = async (
const { total } = await getContentItems(resolvedLanguage, limit, offset);
return {
title: t('changelog'),
description: t('changelogSubtitle'),
title: t('marketing:changelog'),
description: t('marketing:changelogSubtitle'),
pagination: {
previous: page > 0 ? `/changelog?page=${page - 1}` : undefined,
next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined,
@@ -66,8 +66,7 @@ const getContentItems = cache(
);
async function ChangelogPage(props: ChangelogPageProps) {
const t = await getTranslations('marketing');
const language = await getLocale();
const { t, resolvedLanguage: language } = await createI18nServerInstance();
const searchParams = await props.searchParams;
const limit = CHANGELOG_ENTRIES_PER_PAGE;
@@ -83,14 +82,14 @@ async function ChangelogPage(props: ChangelogPageProps) {
return (
<>
<SitePageHeader
title={t('changelog')}
subtitle={t('changelogSubtitle')}
title={t('marketing:changelog')}
subtitle={t('marketing:changelogSubtitle')}
/>
<div className="container flex max-w-4xl flex-col space-y-12 py-12">
<If
condition={entries.length > 0}
fallback={<Trans i18nKey="marketing.noChangelogEntries" />}
fallback={<Trans i18nKey="marketing:noChangelogEntries" />}
>
<div className="space-y-0">
{entries.map((entry, index) => {
@@ -115,4 +114,4 @@ async function ChangelogPage(props: ChangelogPageProps) {
);
}
export default ChangelogPage;
export default withI18n(ChangelogPage);

View File

@@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -24,20 +23,13 @@ import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.sch
import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions';
export function ContactForm() {
const [pending, startTransition] = useTransition();
const [state, setState] = useState({
success: false,
error: false,
});
const { execute, isPending } = useAction(sendContactEmail, {
onSuccess: () => {
setState({ success: true, error: false });
},
onError: () => {
setState({ error: true, success: false });
},
});
const form = useForm({
resolver: zodResolver(ContactEmailSchema),
defaultValues: {
@@ -60,7 +52,15 @@ export function ContactForm() {
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
execute(data);
startTransition(async () => {
try {
await sendContactEmail(data);
setState({ success: true, error: false });
} catch {
setState({ error: true, success: false });
}
});
})}
>
<FormField
@@ -69,7 +69,7 @@ export function ContactForm() {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactName'} />
<Trans i18nKey={'marketing:contactName'} />
</FormLabel>
<FormControl>
@@ -88,7 +88,7 @@ export function ContactForm() {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactEmail'} />
<Trans i18nKey={'marketing:contactEmail'} />
</FormLabel>
<FormControl>
@@ -107,7 +107,7 @@ export function ContactForm() {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'marketing.contactMessage'} />
<Trans i18nKey={'marketing:contactMessage'} />
</FormLabel>
<FormControl>
@@ -124,8 +124,8 @@ export function ContactForm() {
}}
/>
<Button disabled={isPending} type={'submit'}>
<Trans i18nKey={'marketing.sendMessage'} />
<Button disabled={pending} type={'submit'}>
<Trans i18nKey={'marketing:sendMessage'} />
</Button>
</form>
</Form>
@@ -136,11 +136,11 @@ function SuccessAlert() {
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'marketing.contactSuccess'} />
<Trans i18nKey={'marketing:contactSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'marketing.contactSuccessDescription'} />
<Trans i18nKey={'marketing:contactSuccessDescription'} />
</AlertDescription>
</Alert>
);
@@ -150,11 +150,11 @@ function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'marketing.contactError'} />
<Trans i18nKey={'marketing:contactError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'marketing.contactErrorDescription'} />
<Trans i18nKey={'marketing:contactErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const ContactEmailSchema = z.object({
name: z.string().min(1).max(200),

View File

@@ -1,29 +1,30 @@
'use server';
import * as z from 'zod';
import { z } from 'zod';
import { getMailer } from '@kit/mailers';
import { publicActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { ContactEmailSchema } from '../contact-email.schema';
const contactEmail = z
.string({
error:
description: `The email where you want to receive the contact form submissions.`,
required_error:
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
})
.parse(process.env.CONTACT_EMAIL);
const emailFrom = z
.string({
error:
description: `The email sending address.`,
required_error:
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
})
.parse(process.env.EMAIL_SENDER);
export const sendContactEmail = publicActionClient
.schema(ContactEmailSchema)
.action(async ({ parsedInput: data }) => {
export const sendContactEmail = enhanceAction(
async (data) => {
const mailer = await getMailer();
await mailer.sendEmail({
@@ -42,4 +43,9 @@ export const sendContactEmail = publicActionClient
});
return {};
});
},
{
schema: ContactEmailSchema,
auth: false,
},
);

View File

@@ -1,25 +1,28 @@
import { getTranslations } from 'next-intl/server';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return {
title: t('contact'),
title: t('marketing:contact'),
};
}
async function ContactPage() {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return (
<div>
<SitePageHeader title={t(`contact`)} subtitle={t(`contactDescription`)} />
<SitePageHeader
title={t(`marketing:contact`)}
subtitle={t(`marketing:contactDescription`)}
/>
<div className={'container mx-auto'}>
<div
@@ -32,11 +35,11 @@ async function ContactPage() {
>
<div>
<Heading level={3}>
<Trans i18nKey={'marketing.contactHeading'} />
<Trans i18nKey={'marketing:contactHeading'} />
</Heading>
<p className={'text-muted-foreground'}>
<Trans i18nKey={'marketing.contactSubheading'} />
<Trans i18nKey={'marketing:contactSubheading'} />
</p>
</div>
@@ -48,4 +51,4 @@ async function ContactPage() {
);
}
export default ContactPage;
export default withI18n(ContactPage);

View File

@@ -7,6 +7,8 @@ import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { cn } from '@kit/ui/utils';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { DocsCards } from '../_components/docs-cards';
@@ -89,4 +91,4 @@ async function DocumentationPage({ params }: DocumentationPageProps) {
);
}
export default DocumentationPage;
export default withI18n(DocumentationPage);

View File

@@ -3,7 +3,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar';
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar';
import { cn, isRouteActive } from '@kit/ui/utils';
export function DocsNavLink({
@@ -12,18 +12,20 @@ export function DocsNavLink({
children,
}: React.PropsWithChildren<{ label: string; url: string }>) {
const currentPath = usePathname();
const isCurrent = isRouteActive(url, currentPath);
const isCurrent = isRouteActive(url, currentPath, true);
return (
<SidebarMenuItem>
<SidebarMenuButton
render={<Link href={url} />}
asChild
isActive={isCurrent}
className={cn('text-secondary-foreground transition-all')}
>
<span className="block max-w-full truncate">{label}</span>
<Link href={url}>
<span className="block max-w-full truncate">{label}</span>
{children}
{children}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -16,7 +16,7 @@ export function DocsNavigationCollapsible(
const prefix = props.prefix;
const isChildActive = props.node.children.some((child) =>
isRouteActive(prefix + '/' + child.url, currentPath),
isRouteActive(prefix + '/' + child.url, currentPath, false),
);
return (

View File

@@ -10,12 +10,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
} from '@kit/ui/sidebar';
} from '@kit/ui/shadcn-sidebar';
import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link';
import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible';
import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button';
import { FloatingDocumentationNavigation } from './floating-docs-navigation';
function Node({
node,
@@ -85,11 +85,13 @@ function NodeTrigger({
}) {
if (node.collapsible) {
return (
<CollapsibleTrigger render={<SidebarMenuItem />}>
<SidebarMenuButton>
{label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuItem>
<SidebarMenuButton>
{label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</SidebarMenuItem>
</CollapsibleTrigger>
);
}
@@ -135,10 +137,12 @@ export function DocsNavigation({
return (
<>
<Sidebar
variant={'sidebar'}
className={'sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'}
variant={'ghost'}
className={
'border-border/50 sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'
}
>
<SidebarGroup>
<SidebarGroup className="p-0">
<SidebarGroupContent>
<SidebarMenu className={'pb-48'}>
<Tree pages={pages} level={0} prefix={prefix} />
@@ -147,7 +151,17 @@ export function DocsNavigation({
</SidebarGroup>
</Sidebar>
<FloatingDocumentationNavigationButton />
<div className={'lg:hidden'}>
<FloatingDocumentationNavigation>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<Tree pages={pages} level={0} prefix={prefix} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</FloatingDocumentationNavigation>
</div>
</>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useEffectEvent, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { Menu } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
export function FloatingDocumentationNavigation(
props: React.PropsWithChildren,
) {
const activePath = usePathname();
const body = useMemo(() => {
return isBrowser() ? document.body : null;
}, []);
const [isVisible, setIsVisible] = useState(false);
const enableScrolling = useEffectEvent(
() => body && (body.style.overflowY = ''),
);
const disableScrolling = useEffectEvent(
() => body && (body.style.overflowY = 'hidden'),
);
// enable/disable body scrolling when the docs are toggled
useEffect(() => {
if (isVisible) {
disableScrolling();
} else {
enableScrolling();
}
}, [isVisible]);
// hide docs when navigating to another page
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsVisible(false);
}, [activePath]);
const onClick = () => {
setIsVisible(!isVisible);
};
return (
<>
<If condition={isVisible}>
<div
className={
'fixed top-0 left-0 z-10 h-screen w-full p-4' +
' dark:bg-background flex flex-col space-y-4 overflow-auto bg-white'
}
>
{props.children}
</div>
</If>
<Button
className={'fixed right-5 bottom-5 z-10 h-16 w-16 rounded-full'}
onClick={onClick}
>
<Menu className={'h-8'} />
</Button>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { getLocale } from 'next-intl/server';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { SidebarProvider } from '@kit/ui/sidebar';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
// local imports
import { DocsNavigation } from './_components/docs-navigation';
@@ -8,8 +8,8 @@ import { getDocs } from './_lib/server/docs.loader';
import { buildDocumentationTree } from './_lib/utils';
async function DocsLayout({ children }: React.PropsWithChildren) {
const locale = await getLocale();
const docs = await getDocs(locale);
const { resolvedLanguage } = await createI18nServerInstance();
const docs = await getDocs(resolvedLanguage);
const tree = buildDocumentationTree(docs);
return (

View File

@@ -1,21 +1,21 @@
import { getLocale, getTranslations } from 'next-intl/server';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../_components/site-page-header';
import { DocsCards } from './_components/docs-cards';
import { getDocs } from './_lib/server/docs.loader';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return {
title: t('documentation'),
title: t('marketing:documentation'),
};
};
async function DocsPage() {
const t = await getTranslations('marketing');
const locale = await getLocale();
const items = await getDocs(locale);
const { t, resolvedLanguage } = await createI18nServerInstance();
const items = await getDocs(resolvedLanguage);
// Filter out any docs that have a parentId, as these are children of other docs
const cards = items.filter((item) => !item.parentId);
@@ -23,8 +23,8 @@ async function DocsPage() {
return (
<div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
<SitePageHeader
title={t('documentation')}
subtitle={t('documentationSubtitle')}
title={t('marketing:documentation')}
subtitle={t('marketing:documentationSubtitle')}
/>
<div className={'relative flex size-full justify-center overflow-y-auto'}>
@@ -34,4 +34,4 @@ async function DocsPage() {
);
}
export default DocsPage;
export default withI18n(DocsPage);

View File

@@ -1,30 +1,31 @@
import Link from 'next/link';
import { ArrowRight, ChevronDown } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return {
title: t('faq'),
title: t('marketing:faq'),
};
};
async function FAQPage() {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
// replace this content with translations
const faqItems = [
{
// or: t('faq.question1')
// or: t('marketing:faq.question1')
question: `Do you offer a free trial?`,
// or: t('faq.answer1')
// or: t('marketing:faq.answer1')
answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`,
},
{
@@ -73,7 +74,10 @@ async function FAQPage() {
/>
<div className={'flex flex-col space-y-4 xl:space-y-8'}>
<SitePageHeader title={t('faq')} subtitle={t('faqSubtitle')} />
<SitePageHeader
title={t('marketing:faq')}
subtitle={t('marketing:faqSubtitle')}
/>
<div className={'container flex flex-col items-center space-y-8 pb-16'}>
<div className="divide-border flex w-full max-w-xl flex-col divide-y divide-dashed rounded-md border">
@@ -83,16 +87,14 @@ async function FAQPage() {
</div>
<div>
<Button
nativeButton={false}
render={<Link href={'/contact'} />}
variant={'link'}
>
<span>
<Trans i18nKey={'marketing.contactFaq'} />
</span>
<Button asChild variant={'outline'}>
<Link href={'/contact'}>
<span>
<Trans i18nKey={'marketing:contactFaq'} />
</span>
<ArrowRight className={'ml-2 w-4'} />
<ArrowRight className={'ml-2 w-4'} />
</Link>
</Button>
</div>
</div>
@@ -101,7 +103,7 @@ async function FAQPage() {
);
}
export default FAQPage;
export default withI18n(FAQPage);
function FaqItem({
item,

View File

@@ -3,6 +3,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header';
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) {
const client = getSupabaseServerClient();
@@ -19,4 +20,4 @@ async function SiteLayout(props: React.PropsWithChildren) {
);
}
export default SiteLayout;
export default withI18n(SiteLayout);

View File

@@ -20,6 +20,7 @@ import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
function Home() {
return (
@@ -29,13 +30,11 @@ function Home() {
pill={
<Pill label={'New'}>
<span>The SaaS Starter Kit for ambitious developers</span>
<PillActionButton
render={
<Link href={'/auth/sign-up'}>
<ArrowRightIcon className={'h-4 w-4'} />
</Link>
}
/>
<PillActionButton asChild>
<Link href={'/auth/sign-up'}>
<ArrowRightIcon className={'h-4 w-4'} />
</Link>
</PillActionButton>
</Pill>
}
title={
@@ -171,7 +170,7 @@ function Home() {
);
}
export default Home;
export default withI18n(Home);
function MainCallToActionButton() {
return (
@@ -180,7 +179,7 @@ function MainCallToActionButton() {
<Link href={'/auth/sign-up'}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common.getStarted'} />
<Trans i18nKey={'common:getStarted'} />
</span>
<ArrowRightIcon
@@ -195,7 +194,7 @@ function MainCallToActionButton() {
<CtaButton variant={'link'} className="h-10 text-sm">
<Link href={'/pricing'}>
<Trans i18nKey={'common.pricing'} />
<Trans i18nKey={'common:pricing'} />
</Link>
</CtaButton>
</div>

View File

@@ -1,16 +1,16 @@
import { getTranslations } from 'next-intl/server';
import { PricingTable } from '@kit/billing-gateway/marketing';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return {
title: t('pricing'),
title: t('marketing:pricing'),
};
};
@@ -20,11 +20,14 @@ const paths = {
};
async function PricingPage() {
const t = await getTranslations('marketing');
const { t } = await createI18nServerInstance();
return (
<div className={'flex flex-col space-y-8'}>
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
<SitePageHeader
title={t('marketing:pricing')}
subtitle={t('marketing:pricingSubtitle')}
/>
<div className={'container mx-auto pb-8 xl:pb-16'}>
<PricingTable paths={paths} config={billingConfig} />
@@ -33,4 +36,4 @@ async function PricingPage() {
);
}
export default PricingPage;
export default withI18n(PricingPage);

View File

@@ -1,30 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('cookiePolicy'),
};
}
async function CookiePolicyPage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t(`marketing.cookiePolicy`)}
subtitle={t(`marketing.cookiePolicyDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default CookiePolicyPage;

View File

@@ -1,30 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('privacyPolicy'),
};
}
async function PrivacyPolicyPage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t('privacyPolicy')}
subtitle={t('privacyPolicyDescription')}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default PrivacyPolicyPage;

View File

@@ -1,30 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
export async function generateMetadata() {
const t = await getTranslations('marketing');
return {
title: t('termsOfService'),
};
}
async function TermsOfServicePage() {
const t = await getTranslations('marketing');
return (
<div>
<SitePageHeader
title={t(`marketing.termsOfService`)}
subtitle={t(`marketing.termsOfServiceDescription`)}
/>
<div className={'container mx-auto py-8'}>
<div>Your terms of service content here</div>
</div>
</div>
);
}
export default TermsOfServicePage;

View File

@@ -1,22 +0,0 @@
'use client';
import { Menu } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { useSidebar } from '@kit/ui/sidebar';
export function FloatingDocumentationNavigationButton() {
const { toggleSidebar } = useSidebar();
return (
<Button
size="custom"
variant="custom"
className={
'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden'
}
onClick={toggleSidebar}
>
<Menu className={'text-primary-foreground size-6'} />
</Button>
);
}

View File

@@ -1,40 +0,0 @@
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import { SidebarNavigation } from '@kit/ui/sidebar-navigation';
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
interface HomeSidebarProps {
workspace: UserWorkspace;
}
export function HomeSidebar(props: HomeSidebarProps) {
const { workspace, user, accounts } = props.workspace;
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar variant="floating" collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-3'}>
<WorkspaceDropdown
user={user}
accounts={accounts}
workspace={workspace}
/>
<div className={'group-data-[collapsible=icon]:hidden'}>
<UserNotifications userId={user.id} />
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation config={personalAccountNavigationConfig} />
</SidebarContent>
</Sidebar>
);
}

View File

@@ -1,22 +0,0 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { BillingPortalCard } from '@kit/billing-gateway/components';
import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions';
export function PersonalBillingPortalForm() {
const { execute } = useAction(createPersonalAccountBillingPortalSession);
return (
<form
onSubmit={(e) => {
e.preventDefault();
execute();
}}
>
<BillingPortalCard />
</form>
);
}

View File

@@ -1,105 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { resolveProductPlan } from '@kit/billing-gateway';
import {
CurrentLifetimeOrderCard,
CurrentSubscriptionCard,
} from '@kit/billing-gateway/components';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form';
import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form';
import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('billingTab');
return {
title,
};
};
async function PersonalAccountBillingPage() {
const user = await requireUserInServerComponent();
const [subscription, order, customerId] =
await loadPersonalAccountBillingPageData(user.id);
const subscriptionVariantId = subscription?.items[0]?.variant_id;
const orderVariantId = order?.items[0]?.variant_id;
const subscriptionProductPlan =
subscription && subscriptionVariantId
? await resolveProductPlan(
billingConfig,
subscriptionVariantId,
subscription.currency,
)
: undefined;
const orderProductPlan =
order && orderVariantId
? await resolveProductPlan(billingConfig, orderVariantId, order.currency)
: undefined;
const hasBillingData = subscription || order;
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common.routes.billing'} />}
description={<AppBreadcrumbs />}
/>
<div className={'flex max-w-2xl flex-col space-y-4'}>
<If
condition={hasBillingData}
fallback={
<>
<PersonalAccountCheckoutForm customerId={customerId} />
</>
}
>
<div className={'flex w-full flex-col space-y-6'}>
<If condition={subscription}>
{(subscription) => {
return (
<CurrentSubscriptionCard
subscription={subscription}
product={subscriptionProductPlan!.product}
plan={subscriptionProductPlan!.plan}
/>
);
}}
</If>
<If condition={order}>
{(order) => {
return (
<CurrentLifetimeOrderCard
order={order}
product={orderProductPlan!.product}
plan={orderProductPlan!.plan}
/>
);
}}
</If>
</div>
</If>
<If condition={customerId}>{() => <PersonalBillingPortalForm />}</If>
</div>
</PageBody>
);
}
export default PersonalAccountBillingPage;

View File

@@ -1,29 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { HomeLayoutPageHeader } from './_components/home-page-header';
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('homePage');
return {
title,
};
};
function UserHomePage() {
return (
<PageBody>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common.routes.home'} />}
description={<Trans i18nKey={'common.homeTabDescription'} />}
/>
</PageBody>
);
}
export default UserHomePage;

View File

@@ -1,46 +0,0 @@
import { JWTUserData } from '@kit/supabase/types';
import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar';
import type { AccountModel } from '~/components/workspace-dropdown';
import { WorkspaceDropdown } from '~/components/workspace-dropdown';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications';
import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation';
export function TeamAccountLayoutSidebar(props: {
account: string;
accountId: string;
accounts: AccountModel[];
user: JWTUserData;
}) {
const { account, accounts, user } = props;
const config = getTeamAccountSidebarConfig(account);
const collapsible = config.sidebarCollapsedStyle;
return (
<Sidebar variant="floating" collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-1'}>
<WorkspaceDropdown
user={user}
accounts={accounts}
selectedAccount={account}
/>
<div className={'group-data-[collapsible=icon]:hidden'}>
<TeamAccountNotifications
userId={user.id}
accountId={props.accountId}
/>
</div>
</div>
</SidebarHeader>
<SidebarContent className="h-[calc(100%-160px)] overflow-y-auto">
<TeamAccountLayoutSidebarNavigation config={config} />
</SidebarContent>
</Sidebar>
);
}

View File

@@ -1,28 +0,0 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { BillingPortalCard } from '@kit/billing-gateway/components';
import { createBillingPortalSession } from '../_lib/server/server-actions';
export function TeamBillingPortalForm({
accountId,
slug,
}: {
accountId: string;
slug: string;
}) {
const { execute } = useAction(createBillingPortalSession);
return (
<form
onSubmit={(e) => {
e.preventDefault();
execute({ accountId, slug });
}}
>
<BillingPortalCard />
</form>
);
}

View File

@@ -1,131 +0,0 @@
import { PlusCircle } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
AccountInvitationsTable,
AccountMembersTable,
InviteMembersDialogContainer,
} from '@kit/team-accounts/components';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
// local imports
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
import { loadMembersPageData } from './_lib/server/members-page.loader';
interface TeamAccountMembersPageProps {
params: Promise<{ account: string }>;
}
export const generateMetadata = async () => {
const t = await getTranslations('teams');
const title = t('members.pageTitle');
return {
title,
};
};
async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) {
const client = getSupabaseServerClient();
const slug = (await params).account;
const [members, invitations, canAddMember, { user, account }] =
await loadMembersPageData(client, slug);
const canManageRoles = account.permissions.includes('roles.manage');
const canManageInvitations = account.permissions.includes('invites.manage');
const isPrimaryOwner = account.primary_owner_user_id === user.id;
const currentUserRoleHierarchy = account.role_hierarchy_level;
return (
<PageBody>
<TeamAccountLayoutPageHeader
title={<Trans i18nKey={'common.routes.members'} />}
description={<AppBreadcrumbs />}
account={account.slug}
/>
<div className={'flex w-full max-w-4xl flex-col space-y-4 pb-32'}>
<Card>
<CardHeader className={'flex flex-row justify-between'}>
<div className={'flex flex-col space-y-1.5'}>
<CardTitle>
<Trans i18nKey={'common.accountMembers'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'common.membersTabDescription'} />
</CardDescription>
</div>
<If condition={canManageInvitations && canAddMember}>
<InviteMembersDialogContainer
userRoleHierarchy={currentUserRoleHierarchy}
accountSlug={account.slug}
>
<Button size={'sm'} data-test={'invite-members-form-trigger'}>
<PlusCircle className={'w-4'} />
<span>
<Trans i18nKey={'teams.inviteMembersButton'} />
</span>
</Button>
</InviteMembersDialogContainer>
</If>
</CardHeader>
<CardContent>
<AccountMembersTable
userRoleHierarchy={currentUserRoleHierarchy}
currentUserId={user.id}
currentAccountId={account.id}
members={members}
isPrimaryOwner={isPrimaryOwner}
canManageRoles={canManageRoles}
/>
</CardContent>
</Card>
<Card>
<CardHeader className={'flex flex-row justify-between'}>
<div className={'flex flex-col space-y-1.5'}>
<CardTitle>
<Trans i18nKey={'teams.pendingInvitesHeading'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams.pendingInvitesDescription'} />
</CardDescription>
</div>
</CardHeader>
<CardContent>
<AccountInvitationsTable
permissions={{
canUpdateInvitation: canManageRoles,
canRemoveInvitation: canManageRoles,
currentUserRoleHierarchy,
}}
invitations={invitations}
/>
</CardContent>
</Card>
</div>
</PageBody>
);
}
export default TeamAccountMembersPage;

View File

@@ -1,35 +0,0 @@
'use client';
import {
BorderedNavigationMenu,
BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu';
import pathsConfig from '~/config/paths.config';
export function SettingsSubNavigation(props: { account: string }) {
const settingsPath = pathsConfig.app.accountSettings.replace(
'[account]',
props.account,
);
const profilePath = pathsConfig.app.accountProfileSettings.replace(
'[account]',
props.account,
);
return (
<BorderedNavigationMenu>
<BorderedNavigationMenuItem
path={settingsPath}
label={'common.routes.settings'}
highlightMatch={`/home/${props.account}/settings$`}
/>
<BorderedNavigationMenuItem
path={profilePath}
label={'common.routes.profile'}
/>
</BorderedNavigationMenu>
);
}

View File

@@ -1,39 +0,0 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
import { SettingsSubNavigation } from './_components/settings-sub-navigation';
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ account: string }>;
}
async function SettingsLayout({ children, params }: SettingsLayoutProps) {
const { account } = await params;
return (
<PageBody>
<div>
<TeamAccountLayoutPageHeader
account={account}
title={<Trans i18nKey={'teams.settings.pageTitle'} />}
description={<AppBreadcrumbs />}
/>
{featuresFlagConfig.enableTeamsOnly && (
<div className="mb-8">
<SettingsSubNavigation account={account} />
</div>
)}
</div>
{children}
</PageBody>
);
}
export default SettingsLayout;

View File

@@ -1,66 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import authConfig from '~/config/auth.config';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
const showEmailOption =
authConfig.providers.password ||
authConfig.providers.magicLink ||
authConfig.providers.otp;
const features = {
showLinkEmailOption: showEmailOption,
enablePasswordUpdate: authConfig.providers.password,
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enableAccountLinking: authConfig.enableIdentityLinking,
};
const providers = authConfig.providers.oAuth;
export const generateMetadata = async () => {
const t = await getTranslations('account');
const title = t('settingsTab');
return {
title,
};
};
interface TeamProfileSettingsPageProps {
params: Promise<{ account: string }>;
}
async function TeamProfileSettingsPage({
params,
}: TeamProfileSettingsPageProps) {
const [user, { account }] = await Promise.all([
requireUserInServerComponent(),
params,
]);
const profilePath = pathsConfig.app.accountProfileSettings.replace(
'[account]',
account,
);
const paths = {
callback: pathsConfig.auth.callback + `?next=${profilePath}`,
};
return (
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
providers={providers}
/>
</div>
);
}
export default TeamProfileSettingsPage;

View File

@@ -1,31 +0,0 @@
'use client';
import { CreateTeamAccountForm } from '@kit/team-accounts/components';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
export function CreateFirstTeamForm() {
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams.createFirstTeamHeading'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams.createFirstTeamDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<CreateTeamAccountForm submitLabel={'teams.getStarted'} />
</CardContent>
</Card>
);
}

View File

@@ -1,52 +0,0 @@
import { redirect } from 'next/navigation';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppLogo } from '~/components/app-logo';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { CreateFirstTeamForm } from './_components/create-first-team-form';
async function CreateTeamPage() {
const data = await loadData();
if (data.redirectTo) {
redirect(data.redirectTo);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-y-8">
<AppLogo />
<CreateFirstTeamForm />
</div>
);
}
export default CreateTeamPage;
async function loadData() {
await requireUserInServerComponent();
if (!featuresFlagConfig.enableTeamsOnly) {
return { redirectTo: pathsConfig.app.home };
}
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const accounts = await api.loadUserAccounts();
if (accounts.length > 0 && accounts[0]?.value) {
return {
redirectTo: pathsConfig.app.accountHome.replace(
'[account]',
accounts[0].value,
),
};
}
return { redirectTo: null };
}

View File

@@ -1,79 +0,0 @@
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { hasLocale } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { PublicEnvScript } from 'next-runtime-env';
import { routing } from '@kit/i18n/routing';
import { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
import { RootProviders } from '~/components/root-providers';
import { getFontsClassName } from '~/lib/fonts';
import { generateRootMetadata } from '~/lib/root-metadata';
import { getRootTheme } from '~/lib/root-theme';
export const generateMetadata = () => {
return generateRootMetadata();
};
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const [theme, nonce, messages] = await Promise.all([
getRootTheme(),
getCspNonce(),
getMessages({ locale }),
]);
const className = getRootClassName(theme);
return (
<html lang={locale} className={className} suppressHydrationWarning>
<head>
<PublicEnvScript nonce={nonce} />
</head>
<body>
<RootProviders
theme={theme}
locale={locale}
nonce={nonce}
messages={messages}
>
{children}
<Toaster richColors={true} theme={theme} position="top-center" />
</RootProviders>
</body>
</html>
);
}
function getRootClassName(theme: string) {
const fontsClassName = getFontsClassName(theme);
return cn(
'bg-background min-h-screen antialiased md:overscroll-y-none',
fontsClassName,
);
}
async function getCspNonce() {
const headersStore = await headers();
return headersStore.get('x-nonce') ?? undefined;
}

View File

@@ -1,26 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { ErrorPageContent } from '~/components/error-page-content';
export const generateMetadata = async () => {
const t = await getTranslations('common');
const title = t('notFound');
return {
title,
};
};
const NotFoundPage = async () => {
return (
<div className={'flex h-screen flex-1 flex-col'}>
<ErrorPageContent
statusCode={'common.pageNotFoundHeading'}
heading={'common.pageNotFound'}
subtitle={'common.pageNotFoundSubHeading'}
/>
</div>
);
};
export default NotFoundPage;

View File

@@ -15,7 +15,7 @@ import {
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
} from '@kit/ui/sidebar';
} from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
@@ -35,26 +35,25 @@ export function AdminSidebar() {
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton
isActive={path === '/admin'}
render={<Link className={'flex gap-2.5'} href={'/admin'} />}
>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
<SidebarMenuButton isActive={path === '/admin'} asChild>
<Link className={'flex gap-2.5'} href={'/admin'}>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path.includes('/admin/accounts')}
render={
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
}
/>
asChild
>
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@@ -3,7 +3,7 @@ import { use } from 'react';
import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/sidebar';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';

View File

@@ -8,6 +8,7 @@ import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
interface AuthCallbackErrorPageProps {
searchParams: Promise<{
@@ -27,11 +28,11 @@ async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
<div className={'flex flex-col space-y-4 py-4'}>
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'auth.authenticationErrorAlertHeading'} />
<Trans i18nKey={'auth:authenticationErrorAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={error ?? 'auth.authenticationErrorAlertBody'} />
<Trans i18nKey={error ?? 'auth:authenticationErrorAlertBody'} />
</AlertDescription>
</Alert>
@@ -52,7 +53,6 @@ function AuthCallbackForm(props: {
switch (props.code) {
case 'otp_expired':
return <ResendAuthLinkForm redirectPath={props.redirectPath} />;
default:
return <SignInButton signInPath={props.signInPath} />;
}
@@ -60,15 +60,12 @@ function AuthCallbackForm(props: {
function SignInButton(props: { signInPath: string }) {
return (
<Button
className={'w-full'}
render={
<Link href={props.signInPath}>
<Trans i18nKey={'auth.signIn'} />
</Link>
}
/>
<Button className={'w-full'} asChild>
<Link href={props.signInPath}>
<Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
);
}
export default AuthCallbackErrorPage;
export default withI18n(AuthCallbackErrorPage);

View File

@@ -1,19 +1,19 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const t = await getTranslations('auth');
const { t } = await createI18nServerInstance();
return {
title: t('passwordResetLabel'),
title: t('auth:passwordResetLabel'),
};
};
@@ -25,11 +25,11 @@ function PasswordResetPage() {
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.passwordResetLabel'} />
<Trans i18nKey={'auth:passwordResetLabel'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.passwordResetSubheading'} />
<Trans i18nKey={'auth:passwordResetSubheading'} />
</p>
</div>
@@ -37,20 +37,15 @@ function PasswordResetPage() {
<PasswordResetRequestContainer redirectPath={redirectPath} />
<div className={'flex justify-center text-xs'}>
<Button
nativeButton={false}
variant={'link'}
size={'sm'}
render={
<Link href={signIn}>
<Trans i18nKey={'auth.passwordRecoveredQuestion'} />
</Link>
}
/>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signIn}>
<Trans i18nKey={'auth:passwordRecoveredQuestion'} />
</Link>
</Button>
</div>
</div>
</>
);
}
export default PasswordResetPage;
export default withI18n(PasswordResetPage);

View File

@@ -1,7 +1,5 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { getSafeRedirectPath } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
@@ -10,6 +8,8 @@ import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
interface SignInPageProps {
searchParams: Promise<{
@@ -18,10 +18,10 @@ interface SignInPageProps {
}
export const generateMetadata = async () => {
const t = await getTranslations('auth');
const i18n = await createI18nServerInstance();
return {
title: t('signIn'),
title: i18n.t('auth:signIn'),
};
};
@@ -38,11 +38,11 @@ async function SignInPage({ searchParams }: SignInPageProps) {
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.signInHeading'} />
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.signInSubheading'} />
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
@@ -53,19 +53,14 @@ async function SignInPage({ searchParams }: SignInPageProps) {
/>
<div className={'flex justify-center'}>
<Button
nativeButton={false}
variant={'link'}
size={'sm'}
render={
<Link href={pathsConfig.auth.signUp} prefetch={true}>
<Trans i18nKey={'auth.doNotHaveAccountYet'} />
</Link>
}
/>
<Button asChild variant={'link'} size={'sm'}>
<Link href={pathsConfig.auth.signUp} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
}
export default SignInPage;
export default withI18n(SignInPage);

View File

@@ -1,7 +1,5 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
@@ -9,12 +7,14 @@ import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const t = await getTranslations('auth');
const i18n = await createI18nServerInstance();
return {
title: t('signUp'),
title: i18n.t('auth:signUp'),
};
};
@@ -28,11 +28,11 @@ async function SignUpPage() {
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth.signUpHeading'} />
<Trans i18nKey={'auth:signUpHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth.signUpSubheading'} />
<Trans i18nKey={'auth:signUpSubheading'} />
</p>
</div>
@@ -44,19 +44,14 @@ async function SignUpPage() {
/>
<div className={'flex justify-center'}>
<Button
render={
<Link href={pathsConfig.auth.signIn} prefetch={true}>
<Trans i18nKey={'auth.alreadyHaveAnAccount'} />
</Link>
}
variant={'link'}
size={'sm'}
nativeButton={false}
/>
<Button asChild variant={'link'} size={'sm'}>
<Link href={pathsConfig.auth.signIn} prefetch={true}>
<Trans i18nKey={'auth:alreadyHaveAnAccount'} />
</Link>
</Button>
</div>
</>
);
}
export default SignUpPage;
export default withI18n(SignUpPage);

View File

@@ -1,13 +1,13 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { getSafeRedirectPath } from '@kit/shared/utils';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Props {
searchParams: Promise<{
@@ -16,10 +16,10 @@ interface Props {
}
export const generateMetadata = async () => {
const t = await getTranslations('auth');
const i18n = await createI18nServerInstance();
return {
title: t('signIn'),
title: i18n.t('auth:signIn'),
};
};
@@ -51,4 +51,4 @@ async function VerifyPage(props: Props) {
);
}
export default VerifyPage;
export default withI18n(VerifyPage);

View File

@@ -22,10 +22,10 @@ const ErrorPage = ({
<SiteHeader user={user.data} />
<ErrorPageContent
statusCode={'common.errorPageHeading'}
heading={'common.genericError'}
subtitle={'common.genericErrorSubHeading'}
backLabel={'common.goBack'}
statusCode={'common:errorPageHeading'}
heading={'common:genericError'}
subtitle={'common:genericErrorSubHeading'}
backLabel={'common:goBack'}
reset={reset}
/>
</div>

View File

@@ -19,7 +19,7 @@ const GlobalErrorPage = ({
return (
<html lang="en">
<body>
<RootProviders messages={{}}>
<RootProviders>
<GlobalErrorContent reset={reset} />
</RootProviders>
</body>
@@ -35,10 +35,10 @@ function GlobalErrorContent({ reset }: { reset: () => void }) {
<SiteHeader user={user.data} />
<ErrorPageContent
statusCode={'common.errorPageHeading'}
heading={'common.genericError'}
subtitle={'common.genericErrorSubHeading'}
backLabel={'common.goBack'}
statusCode={'common:errorPageHeading'}
heading={'common:genericError'}
subtitle={'common:genericErrorSubHeading'}
backLabel={'common:goBack'}
reset={reset}
/>
</div>

View File

@@ -5,7 +5,7 @@ import { useContext } from 'react';
import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
import { SidebarContext } from '@kit/ui/sidebar';
import { SidebarContext } from '@kit/ui/shadcn-sidebar';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
@@ -22,6 +22,7 @@ export function HomeAccountSelector(props: {
}>;
userId: string;
collisionPadding?: number;
}) {
const router = useRouter();
const context = useContext(SidebarContext);
@@ -29,6 +30,7 @@ export function HomeAccountSelector(props: {
return (
<AccountSelector
collapsed={!context?.open}
collisionPadding={props.collisionPadding ?? 20}
accounts={props.accounts}
features={features}
userId={props.userId}

View File

@@ -31,16 +31,13 @@ export function HomeAccountsList() {
<div className="flex flex-col">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{accounts.map((account) => (
<CardButton
key={account.value}
render={
<Link href={`/home/${account.value}`}>
<CardButtonHeader>
<CardButtonTitle>{account.label}</CardButtonTitle>
</CardButtonHeader>
</Link>
}
/>
<CardButton key={account.value} asChild>
<Link href={`/home/${account.value}`}>
<CardButtonHeader>
<CardButtonTitle>{account.label}</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
))}
</div>
</div>
@@ -53,21 +50,17 @@ function HomeAccountsListEmptyState(props: {
return (
<div className={'flex flex-1'}>
<EmptyState>
<EmptyStateButton
render={
<HomeAddAccountButton
className={'mt-4'}
canCreateTeamAccount={props.canCreateTeamAccount}
/>
}
/>
<EmptyStateButton asChild>
<HomeAddAccountButton
className={'mt-4'}
canCreateTeamAccount={props.canCreateTeamAccount}
/>
</EmptyStateButton>
<EmptyStateHeading>
<Trans i18nKey={'account.noTeamsYet'} />
<Trans i18nKey={'account:noTeamsYet'} />
</EmptyStateHeading>
<EmptyStateText>
<Trans i18nKey={'account.createTeam'} />
<Trans i18nKey={'account:createTeam'} />
</EmptyStateText>
</EmptyState>
</div>

View File

@@ -32,7 +32,7 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
onClick={() => setIsAddingAccount(true)}
disabled={!canCreate}
>
<Trans i18nKey={'account.createTeamButtonLabel'} />
<Trans i18nKey={'account:createTeamButtonLabel'} />
</Button>
);
@@ -41,10 +41,9 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) {
{!canCreate && reason ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span className="cursor-not-allowed">{button}</span>}
/>
<TooltipTrigger asChild>
<span className="cursor-not-allowed">{button}</span>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={reason} defaults={reason} />
</TooltipContent>

View File

@@ -56,12 +56,13 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<If condition={featuresFlagConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common.yourAccounts'} />
<Trans i18nKey={'common:yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
collisionPadding={0}
/>
</DropdownMenuGroup>
@@ -86,21 +87,18 @@ function DropdownLink(
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
key={props.path}
/>
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
</DropdownMenuItem>
);
}
@@ -117,7 +115,7 @@ function SignOutDropdownItem(
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);

View File

@@ -0,0 +1,61 @@
import { If } from '@kit/ui/if';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { cn } from '@kit/ui/utils';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { UserNotifications } from '~/home/(user)/_components/user-notifications';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
import { HomeAccountSelector } from './home-account-selector';
interface HomeSidebarProps {
workspace: UserWorkspace;
}
export function HomeSidebar(props: HomeSidebarProps) {
const { workspace, user, accounts } = props.workspace;
const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle;
return (
<Sidebar collapsible={collapsible}>
<SidebarHeader className={'h-16 justify-center'}>
<div className={'flex items-center justify-between gap-x-3'}>
<If
condition={featuresFlagConfig.enableTeamAccounts}
fallback={
<AppLogo
className={cn(
'p-2 group-data-[minimized=true]/sidebar:max-w-full group-data-[minimized=true]/sidebar:py-0',
)}
/>
}
>
<HomeAccountSelector userId={user.id} accounts={accounts} />
</If>
<div className={'group-data-[minimized=true]/sidebar:hidden'}>
<UserNotifications userId={user.id} />
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation config={personalAccountNavigationConfig} />
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer user={user} account={workspace} />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,11 +1,10 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import dynamic from 'next/dynamic';
import { TriangleAlert } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { PlanPicker } from '@kit/billing-gateway/components';
import { useAppEvents } from '@kit/shared/events';
@@ -40,6 +39,7 @@ const EmbeddedCheckout = dynamic(
export function PersonalAccountCheckoutForm(props: {
customerId: string | null | undefined;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState(false);
const appEvents = useAppEvents();
@@ -47,20 +47,6 @@ export function PersonalAccountCheckoutForm(props: {
undefined,
);
const { execute, isPending } = useAction(
createPersonalAccountCheckoutSession,
{
onSuccess: ({ data }) => {
if (data?.checkoutToken) {
setCheckoutToken(data.checkoutToken);
}
},
onError: () => {
setError(true);
},
},
);
// only allow trial if the user is not already a customer
const canStartTrial = !props.customerId;
@@ -81,11 +67,11 @@ export function PersonalAccountCheckoutForm(props: {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'billing.planCardLabel'} />
<Trans i18nKey={'common:planCardLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'billing.planCardDescription'} />
<Trans i18nKey={'common:planCardDescription'} />
</CardDescription>
</CardHeader>
@@ -95,18 +81,27 @@ export function PersonalAccountCheckoutForm(props: {
</If>
<PlanPicker
pending={isPending}
pending={pending}
config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => {
appEvents.emit({
type: 'checkout.started',
payload: { planId },
});
startTransition(async () => {
try {
appEvents.emit({
type: 'checkout.started',
payload: { planId },
});
execute({
planId,
productId,
const { checkoutToken } =
await createPersonalAccountCheckoutSession({
planId,
productId,
});
setCheckoutToken(checkoutToken);
} catch {
setError(true);
}
});
}}
/>
@@ -119,14 +114,14 @@ export function PersonalAccountCheckoutForm(props: {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<TriangleAlert className={'h-4'} />
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'common.planPickerAlertErrorTitle'} />
<Trans i18nKey={'common:planPickerAlertErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common.planPickerAlertErrorDescription'} />
<Trans i18nKey={'common:planPickerAlertErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,4 +1,4 @@
import * as z from 'zod';
import { z } from 'zod';
export const PersonalAccountCheckoutSchema = z.object({
planId: z.string().min(1),

View File

@@ -2,7 +2,7 @@
import { redirect } from 'next/navigation';
import { authActionClient } from '@kit/next/safe-action';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import featureFlagsConfig from '~/config/feature-flags.config';
@@ -20,9 +20,8 @@ const enabled = featureFlagsConfig.enablePersonalAccountBilling;
* @name createPersonalAccountCheckoutSession
* @description Creates a checkout session for a personal account.
*/
export const createPersonalAccountCheckoutSession = authActionClient
.schema(PersonalAccountCheckoutSchema)
.action(async ({ parsedInput: data }) => {
export const createPersonalAccountCheckoutSession = enhanceAction(
async function (data) {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
@@ -31,14 +30,18 @@ export const createPersonalAccountCheckoutSession = authActionClient
const service = createUserBillingService(client);
return await service.createCheckoutSession(data);
});
},
{
schema: PersonalAccountCheckoutSchema,
},
);
/**
* @name createPersonalAccountBillingPortalSession
* @description Creates a billing Portal session for a personal account
*/
export const createPersonalAccountBillingPortalSession =
authActionClient.action(async () => {
export const createPersonalAccountBillingPortalSession = enhanceAction(
async () => {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
@@ -49,5 +52,7 @@ export const createPersonalAccountBillingPortalSession =
// get url to billing portal
const url = await service.createBillingPortalSession();
redirect(url);
});
return redirect(url);
},
{},
);

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getProductPlanPair } from '@kit/billing';
@@ -39,7 +39,7 @@ class UserBillingService {
async createCheckoutSession({
planId,
productId,
}: z.output<typeof PersonalAccountCheckoutSchema>) {
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
// get the authenticated user
const { data: user, error } = await requireUser(this.client);

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