merge: upstream/main — latest MakerKit fixes and dependency updates

This commit is contained in:
Zaid Marzguioui
2026-04-01 10:56:45 +02:00
27 changed files with 1254 additions and 3062 deletions

View File

@@ -18,6 +18,7 @@ EMAIL_PASSWORD=password
# STRIPE # STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
STRIPE_UI_MODE=embedded_page # TESTS ONLY SUPPORT THIS MODE, KEEP AS IS
CONTACT_EMAIL=test@makerkit.dev CONTACT_EMAIL=test@makerkit.dev

View File

@@ -22,13 +22,15 @@ export function HomeAccountSelector(props: {
}>; }>;
userId: string; userId: string;
collapsed?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const context = useContext(SidebarContext); const context = useContext(SidebarContext);
const collapsed = props.collapsed ?? !context?.open;
return ( return (
<AccountSelector <AccountSelector
collapsed={!context?.open} collapsed={collapsed}
accounts={props.accounts} accounts={props.accounts}
features={features} features={features}
userId={props.userId} userId={props.userId}

View File

@@ -39,7 +39,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
return ( return (
<div className={'flex w-full flex-1 justify-between'}> <div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}> <div className={'flex items-center space-x-8'}>
<div>
<AppLogo /> <AppLogo />
</div>
<BorderedNavigationMenu> <BorderedNavigationMenu>
{routes.map((route) => ( {routes.map((route) => (
@@ -54,7 +56,9 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
</If> </If>
<If condition={featuresFlagConfig.enableTeamAccounts}> <If condition={featuresFlagConfig.enableTeamAccounts}>
<div>
<HomeAccountSelector userId={user.id} accounts={accounts} /> <HomeAccountSelector userId={user.id} accounts={accounts} />
</div>
</If> </If>
<div> <div>

View File

@@ -1,15 +1,12 @@
'use client'; 'use client';
import Link from 'next/link'; import { Menu } from 'lucide-react';
import { LogOut, Menu } from 'lucide-react';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -17,6 +14,10 @@ import {
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featuresFlagConfig from '~/config/feature-flags.config'; import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
@@ -27,25 +28,6 @@ import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { workspace: UserWorkspace }) { export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
const signOut = useSignOut(); const signOut = useSignOut();
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -60,6 +42,7 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
</DropdownMenuLabel> </DropdownMenuLabel>
<HomeAccountSelector <HomeAccountSelector
collapsed={false}
userId={props.workspace.user.id} userId={props.workspace.user.id}
accounts={props.workspace.accounts} accounts={props.workspace.accounts}
/> />
@@ -68,57 +51,16 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</If> </If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup> <DropdownMenuGroup>
<MobileNavRouteLinks
routes={personalAccountNavigationConfig.routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<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}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -1,12 +1,26 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
export function HomeLayoutPageHeader( import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
export async function HomeLayoutPageHeader(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description: string | React.ReactNode;
}>, }>,
) { ) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const displaySidebarTrigger =
(layoutStyleCookie ?? personalAccountNavigationConfig.style) === 'sidebar';
return ( return (
<PageHeader description={props.description}>{props.children}</PageHeader> <PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
); );
} }

View File

@@ -1,4 +1,6 @@
import 'server-only'; import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod'; import * as z from 'zod';
@@ -81,9 +83,12 @@ class UserBillingService {
`User requested a personal account checkout session. Contacting provider...`, `User requested a personal account checkout session. Contacting provider...`,
); );
let checkoutToken: string | null | undefined;
let url: string | null | undefined;
try { try {
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const checkout = await service.createCheckoutSession({
returnUrl, returnUrl,
accountId, accountId,
customerEmail: user.email, customerEmail: user.email,
@@ -93,6 +98,45 @@ class UserBillingService {
enableDiscountField: product.enableDiscountField, enableDiscountField: product.enableDiscountField,
}); });
checkoutToken = checkout.checkoutToken;
url = checkout.url;
} catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error: message,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
if (!url && !checkoutToken) {
throw new Error(
'Checkout session returned neither a URL nor a checkout token',
);
}
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
{
userId: user.id,
},
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
logger.info( logger.info(
{ {
userId: user.id, userId: user.id,
@@ -100,25 +144,9 @@ class UserBillingService {
`Checkout session created. Returning checkout token to client...`, `Checkout session created. Returning checkout token to client...`,
); );
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return { return {
checkoutToken, checkoutToken,
}; };
} catch (error) {
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`, { cause: error });
}
} }
/** /**

View File

@@ -52,7 +52,7 @@ async function SidebarLayout({ children }: React.PropsWithChildren) {
<HomeSidebar workspace={workspace} /> <HomeSidebar workspace={workspace} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<MobileNavigation workspace={workspace} /> <MobileNavigation workspace={workspace} />
</PageMobileNavigation> </PageMobileNavigation>
@@ -75,7 +75,7 @@ async function HeaderLayout({ children }: React.PropsWithChildren) {
<HomeMenuNavigation workspace={workspace} /> <HomeMenuNavigation workspace={workspace} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<MobileNavigation workspace={workspace} /> <MobileNavigation workspace={workspace} />
</PageMobileNavigation> </PageMobileNavigation>
@@ -92,7 +92,9 @@ function MobileNavigation({
}) { }) {
return ( return (
<> <>
<div>
<AppLogo /> <AppLogo />
</div>
<HomeMobileNavigation workspace={workspace} /> <HomeMobileNavigation workspace={workspace} />
</> </>

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useContext } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { useSidebar } from '@kit/ui/sidebar'; import { SidebarContext } from '@kit/ui/sidebar';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
@@ -23,7 +25,7 @@ export function TeamAccountAccountsSelector(params: {
}>; }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const ctx = useSidebar(); const ctx = useContext(SidebarContext);
return ( return (
<AccountSelector <AccountSelector

View File

@@ -1,32 +1,28 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import * as z from 'zod';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import {
MobileNavRouteLinks,
MobileNavSignOutItem,
} from '~/components/mobile-navigation-shared';
import featureFlagsConfig from '~/config/feature-flags.config'; import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
type Accounts = Array<{ type Accounts = Array<{
label: string | null; label: string | null;
@@ -35,7 +31,6 @@ type Accounts = Array<{
}>; }>;
const features = { const features = {
enableTeamAccounts: featureFlagsConfig.enableTeamAccounts,
enableTeamCreation: featureFlagsConfig.enableTeamCreation, enableTeamCreation: featureFlagsConfig.enableTeamCreation,
}; };
@@ -44,131 +39,23 @@ export const TeamAccountLayoutMobileNavigation = (
account: string; account: string;
userId: string; userId: string;
accounts: Accounts; accounts: Accounts;
config: z.output<typeof NavigationConfigSchema>;
}>, }>,
) => { ) => {
const router = useRouter();
const signOut = useSignOut(); const signOut = useSignOut();
const Links = props.config.routes.map(
(item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
},
);
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Menu className={'h-9'} /> <Menu className={'h-9'} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent className={'w-screen rounded-none'}>
<TeamAccountsModal <DropdownMenuGroup>
userId={props.userId} <DropdownMenuLabel>
accounts={props.accounts}
account={props.account}
/>
{Links}
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
}
/>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common.signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;
userId: string;
account: string;
}) {
const router = useRouter();
return (
<Dialog>
<DialogTrigger
render={
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onSelect={(e) => e.preventDefault()}
>
<Home className={'h-4'} />
<span>
<Trans i18nKey={'common.yourAccounts'} /> <Trans i18nKey={'common.yourAccounts'} />
</span> </DropdownMenuLabel>
</DropdownMenuItem>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'common.yourAccounts'} />
</DialogTitle>
</DialogHeader>
<div className={'py-6'}>
<AccountSelector <AccountSelector
className={'w-full max-w-full'} className={'w-full max-w-full'}
userId={props.userId} userId={props.userId}
@@ -187,8 +74,20 @@ function TeamAccountsModal(props: {
router.replace(path); router.replace(path);
}} }}
/> />
</div> </DropdownMenuGroup>
</DialogContent>
</Dialog> <DropdownMenuSeparator />
<DropdownMenuGroup>
<MobileNavRouteLinks
routes={getTeamAccountSidebarConfig(props.account).routes}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<MobileNavSignOutItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
); );
} };

View File

@@ -1,13 +1,28 @@
import { cookies } from 'next/headers';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
export function TeamAccountLayoutPageHeader( import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
export async function TeamAccountLayoutPageHeader(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description: string | React.ReactNode;
account: string; account: string;
}>, }>,
) { ) {
const cookieStore = await cookies();
const layoutStyleCookie = cookieStore.get('layout-style')?.value;
const defaultStyle = getTeamAccountSidebarConfig(props.account).style;
const displaySidebarTrigger =
(layoutStyleCookie ?? defaultStyle) === 'sidebar';
return ( return (
<PageHeader description={props.description}>{props.children}</PageHeader> <PageHeader
description={props.description}
displaySidebarTrigger={displaySidebarTrigger}
>
{props.children}
</PageHeader>
); );
} }

View File

@@ -44,7 +44,9 @@ export function TeamAccountNavigationMenu(props: {
return ( return (
<div className={'flex w-full flex-1 justify-between'}> <div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}> <div className={'flex items-center space-x-8'}>
<div>
<AppLogo /> <AppLogo />
</div>
<BorderedNavigationMenu> <BorderedNavigationMenu>
{routes.map((route) => ( {routes.map((route) => (
@@ -53,11 +55,12 @@ export function TeamAccountNavigationMenu(props: {
</BorderedNavigationMenu> </BorderedNavigationMenu>
</div> </div>
<div className={'flex items-center justify-end space-x-2.5'}> <div className={'flex items-center justify-end space-x-1'}>
<If condition={featureFlagsConfig.enableNotifications}> <If condition={featureFlagsConfig.enableNotifications}>
<TeamAccountNotifications accountId={account.id} userId={user.id} /> <TeamAccountNotifications accountId={account.id} userId={user.id} />
</If> </If>
<div>
<TeamAccountAccountsSelector <TeamAccountAccountsSelector
userId={user.id} userId={user.id}
selectedAccount={account.slug} selectedAccount={account.slug}
@@ -67,6 +70,7 @@ export function TeamAccountNavigationMenu(props: {
image: account.picture_url, image: account.picture_url,
}))} }))}
/> />
</div>
<div> <div>
<ProfileAccountDropdownContainer <ProfileAccountDropdownContainer

View File

@@ -1,4 +1,6 @@
import 'server-only'; import 'server-only';
import { redirect } from 'next/navigation';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import * as z from 'zod'; import * as z from 'zod';
@@ -106,9 +108,12 @@ class TeamBillingService {
`Creating checkout session...`, `Creating checkout session...`,
); );
let checkoutToken: string | null = null;
let url: string | null | undefined;
try { try {
// call the payment gateway to create the checkout session // call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({ const checkout = await service.createCheckoutSession({
accountId, accountId,
plan, plan,
returnUrl, returnUrl,
@@ -118,22 +123,37 @@ class TeamBillingService {
enableDiscountField: product.enableDiscountField, enableDiscountField: product.enableDiscountField,
}); });
// return the checkout token to the client checkoutToken = checkout.checkoutToken;
// so we can call the payment gateway to complete the checkout url = checkout.url;
return {
checkoutToken,
};
} catch (error) { } catch (error) {
const message = Error.isError(error) ? error.message : error;
logger.error( logger.error(
{ {
...ctx, ...ctx,
error, error: message,
}, },
`Error creating the checkout session`, `Error creating the checkout session`,
); );
throw new Error(`Checkout not created`, { cause: error }); throw new Error(`Checkout not created`, { cause: error });
} }
// if URL provided, we redirect to the provider's hosted page
if (url) {
logger.info(
ctx,
`Checkout session created. Redirecting to hosted page...`,
);
redirect(url);
}
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
} }
/** /**

View File

@@ -160,10 +160,10 @@ async function SidebarLayout({
/> />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}> <PageMobileNavigation>
<AppLogo /> <AppLogo />
<div className={'flex space-x-4'}> <div className={'flex'}>
<TeamAccountLayoutMobileNavigation <TeamAccountLayoutMobileNavigation
userId={data.user.id} userId={data.user.id}
accounts={accounts} accounts={accounts}
@@ -194,6 +194,12 @@ async function HeaderLayout({
const baseConfig = getTeamAccountSidebarConfig(account); const baseConfig = getTeamAccountSidebarConfig(account);
const config = injectAccountFeatureRoutes(baseConfig, account, features); const config = injectAccountFeatureRoutes(baseConfig, account, features);
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
label: name,
value: slug,
image: picture_url,
}));
return ( return (
<TeamAccountWorkspaceContextProvider value={data}> <TeamAccountWorkspaceContextProvider value={data}>
<Page style={'header'}> <Page style={'header'}>
@@ -201,6 +207,20 @@ async function HeaderLayout({
<TeamAccountNavigationMenu workspace={data} config={config} /> <TeamAccountNavigationMenu workspace={data} config={config} />
</PageNavigation> </PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<div>
<AppLogo />
</div>
<div>
<TeamAccountLayoutMobileNavigation
userId={data.user.id}
accounts={accounts}
account={account}
/>
</div>
</PageMobileNavigation>
{children} {children}
</Page> </Page>
</TeamAccountWorkspaceContextProvider> </TeamAccountWorkspaceContextProvider>

View File

@@ -0,0 +1,78 @@
'use client';
import Link from 'next/link';
import { LogOut } from 'lucide-react';
import { DropdownMenuItem, DropdownMenuSeparator } from '@kit/ui/dropdown-menu';
import { Trans } from '@kit/ui/trans';
export function MobileNavDropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon?: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem
render={
<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>
}
/>
);
}
export function MobileNavSignOutItem(props: { onSignOut: () => unknown }) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-5'} />
<span>
<Trans i18nKey={'auth.signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}
export function MobileNavRouteLinks(props: {
routes: Array<
| {
children: Array<{
path: string;
label: string;
Icon?: React.ReactNode;
}>;
}
| { divider: true }
>;
}) {
return props.routes.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => (
<MobileNavDropdownLink
key={child.path}
Icon={child.Icon}
path={child.path}
label={child.label}
/>
));
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
}

View File

@@ -31,6 +31,7 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
| `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys | | `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys |
| `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard | | `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys |
| `STRIPE_UI_MODE` | Checkout UI mode: `embedded_page` (default) or `hosted_page` (optional) | - |
{% alert type="error" title="Never commit secret keys" %} {% alert type="error" title="Never commit secret keys" %}
Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository. Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository.
@@ -187,6 +188,21 @@ When deploying to production, configure webhooks in the Stripe Dashboard:
Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window. Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window.
{% /alert %} {% /alert %}
## Checkout UI Mode
Stripe supports two checkout UI modes:
- **`embedded_page`** (default): Embeds the checkout form directly in your application as a dialog popup. Requires `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`.
- **`hosted_page`**: Redirects users to a Stripe-hosted checkout page. The publishable key is not required in this mode.
Configure this with the `STRIPE_UI_MODE` environment variable:
```bash
STRIPE_UI_MODE=hosted_page
```
If not set, it defaults to `embedded_page`.
## Free Trials Without Credit Card ## Free Trials Without Credit Card
Allow users to start a trial without entering payment information: Allow users to start a trial without entering payment information:

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "3.0.5", "version": "3.1.1",
"private": true, "private": true,
"author": { "author": {
"name": "MakerKit", "name": "MakerKit",
@@ -57,5 +57,5 @@
"engines": { "engines": {
"node": ">=20.10.0" "node": ">=20.10.0"
}, },
"packageManager": "pnpm@10.32.1" "packageManager": "pnpm@10.33.0"
} }

View File

@@ -33,7 +33,8 @@ export abstract class BillingStrategyProviderService {
abstract createCheckoutSession( abstract createCheckoutSession(
params: z.output<typeof CreateBillingCheckoutSchema>, params: z.output<typeof CreateBillingCheckoutSchema>,
): Promise<{ ): Promise<{
checkoutToken: string; checkoutToken: string | null;
url?: string | null;
}>; }>;
abstract cancelSubscription( abstract cancelSubscription(

View File

@@ -16,7 +16,7 @@ const { publishableKey } = StripeClientEnvSchema.parse({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
}); });
const stripePromise = loadStripe(publishableKey); const stripePromise = loadStripe(publishableKey as string);
export function StripeCheckout({ export function StripeCheckout({
checkoutToken, checkoutToken,

View File

@@ -1,11 +1,17 @@
import * as z from 'zod'; import * as z from 'zod';
const isHostedMode = process.env.STRIPE_UI_MODE === 'hosted_page';
export const StripeClientEnvSchema = z export const StripeClientEnvSchema = z
.object({ .object({
publishableKey: z.string().min(1), publishableKey: isHostedMode ? z.string().optional() : z.string().min(1),
}) })
.refine( .refine(
(schema) => { (schema) => {
if (isHostedMode || !schema.publishableKey) {
return true;
}
return schema.publishableKey.startsWith('pk_'); return schema.publishableKey.startsWith('pk_');
}, },
{ {

View File

@@ -9,6 +9,13 @@ import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
const enableTrialWithoutCreditCard = const enableTrialWithoutCreditCard =
process.env.STRIPE_ENABLE_TRIAL_WITHOUT_CC === 'true'; process.env.STRIPE_ENABLE_TRIAL_WITHOUT_CC === 'true';
const UI_MODE_VALUES = ['embedded_page', 'hosted_page'] as const;
const uiMode = z
.enum(UI_MODE_VALUES)
.default('embedded_page')
.parse(process.env.STRIPE_UI_MODE);
/** /**
* @name createStripeCheckout * @name createStripeCheckout
* @description Creates a Stripe Checkout session, and returns an Object * @description Creates a Stripe Checkout session, and returns an Object
@@ -68,11 +75,9 @@ export async function createStripeCheckout(
const urls = getUrls({ const urls = getUrls({
returnUrl: params.returnUrl, returnUrl: params.returnUrl,
uiMode,
}); });
// we use the embedded mode, so the user does not leave the page
const uiMode = 'embedded';
const customerData = customer const customerData = customer
? { ? {
customer, customer,
@@ -127,10 +132,20 @@ export async function createStripeCheckout(
}); });
} }
function getUrls(params: { returnUrl: string }) { function getUrls(params: {
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`; returnUrl: string;
uiMode: (typeof UI_MODE_VALUES)[number];
}) {
const url = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
if (params.uiMode === 'hosted_page') {
return {
success_url: url,
cancel_url: params.returnUrl,
};
}
return { return {
return_url: returnUrl, return_url: url,
}; };
} }

View File

@@ -47,9 +47,9 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
logger.info(ctx, 'Creating checkout session...'); logger.info(ctx, 'Creating checkout session...');
const { client_secret } = await createStripeCheckout(stripe, params); const { client_secret, url } = await createStripeCheckout(stripe, params);
if (!client_secret) { if (!client_secret && !url) {
logger.error(ctx, 'Failed to create checkout session'); logger.error(ctx, 'Failed to create checkout session');
throw new Error('Failed to create checkout session'); throw new Error('Failed to create checkout session');
@@ -57,7 +57,10 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
logger.info(ctx, 'Checkout session created successfully'); logger.info(ctx, 'Checkout session created successfully');
return { checkoutToken: client_secret }; return {
checkoutToken: client_secret ?? null,
url,
};
} }
/** /**

View File

@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema'; import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
const STRIPE_API_VERSION = '2026-02-25.clover'; const STRIPE_API_VERSION = '2026-03-25.dahlia';
/** /**
* @description returns a Stripe instance * @description returns a Stripe instance

View File

@@ -625,7 +625,8 @@ export const envVariables: EnvVariableModel[] = [
{ {
name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
displayName: 'Stripe Publishable Key', displayName: 'Stripe Publishable Key',
description: 'Your Stripe publishable key.', description:
'Your Stripe publishable key. Required when using embedded checkout (default), optional when STRIPE_UI_MODE is set to hosted_page.',
hint: `Ex. pk_test_123456789012345678901234`, hint: `Ex. pk_test_123456789012345678901234`,
category: 'Billing', category: 'Billing',
type: 'string', type: 'string',
@@ -635,7 +636,13 @@ export const envVariables: EnvVariableModel[] = [
variable: 'NEXT_PUBLIC_BILLING_PROVIDER', variable: 'NEXT_PUBLIC_BILLING_PROVIDER',
condition: (value) => value === 'stripe', condition: (value) => value === 'stripe',
message: message:
'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "stripe"', 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required when NEXT_PUBLIC_BILLING_PROVIDER is set to "stripe" and STRIPE_UI_MODE is not "hosted_page"',
},
{
variable: 'STRIPE_UI_MODE',
condition: (value) => value !== 'hosted_page',
message:
'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required when STRIPE_UI_MODE is not set to "hosted_page"',
}, },
], ],
validate: ({ value }) => { validate: ({ value }) => {
@@ -1391,6 +1398,21 @@ export const envVariables: EnvVariableModel[] = [
return z.coerce.boolean().optional().safeParse(value); return z.coerce.boolean().optional().safeParse(value);
}, },
}, },
{
name: 'STRIPE_UI_MODE',
displayName: 'Stripe Checkout UI Mode',
description:
'Controls whether Stripe Checkout uses an embedded page or a hosted page. Defaults to embedded_page.',
category: 'Billing',
type: 'enum',
values: ['embedded_page', 'hosted_page'],
validate: ({ value }) => {
return z
.enum(['embedded_page', 'hosted_page'])
.optional()
.safeParse(value);
},
},
{ {
name: 'NEXT_PUBLIC_THEME_COLOR', name: 'NEXT_PUBLIC_THEME_COLOR',
displayName: 'Theme Color', displayName: 'Theme Color',

View File

@@ -106,40 +106,40 @@
"test:unit": "vitest run" "test:unit": "vitest run"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "catalog:",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "catalog:",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"clsx": "^2.1.1", "clsx": "catalog:",
"cmdk": "^1.1.1", "cmdk": "catalog:",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "catalog:",
"input-otp": "^1.4.2", "input-otp": "catalog:",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"react-dropzone": "^15.0.0", "react-dropzone": "catalog:",
"react-resizable-panels": "catalog:", "react-resizable-panels": "catalog:",
"react-top-loading-bar": "^3.0.2", "react-top-loading-bar": "catalog:",
"recharts": "3.7.0", "recharts": "catalog:",
"tailwind-merge": "^3.5.0" "tailwind-merge": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@kit/i18n": "workspace:*", "@kit/i18n": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "catalog:", "@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"class-variance-authority": "^0.7.1", "class-variance-authority": "catalog:",
"date-fns": "^4.1.0", "date-fns": "catalog:",
"next": "catalog:", "next": "catalog:",
"next-intl": "^4.8.3", "next-intl": "catalog:",
"next-safe-action": "^8.1.8", "next-safe-action": "catalog:",
"next-themes": "0.4.6", "next-themes": "catalog:",
"react-day-picker": "^9.14.0", "react-day-picker": "catalog:",
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"shadcn": "catalog:", "shadcn": "catalog:",
"sonner": "^2.0.7", "sonner": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"vaul": "^1.1.2", "vaul": "catalog:",
"vitest": "catalog:", "vitest": "catalog:",
"zod": "catalog:" "zod": "catalog:"
} }

View File

@@ -60,7 +60,7 @@ export function PageMobileNavigation(
return ( return (
<div <div
className={cn( className={cn(
'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0', 'container flex w-full items-center justify-between px-0 py-2 group-data-[slot="sidebar-wrapper"]/sidebar-wrapper:border-b lg:hidden',
props.className, props.className,
)} )}
> >
@@ -73,30 +73,39 @@ function PageWithHeader(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props); const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return ( return (
<div className={cn('flex h-screen flex-1 flex-col', props.className)}>
<div <div
className={ className={cn(
props.contentContainerClassName ?? 'flex flex-1 flex-col space-y-4' 'bg-background flex min-h-screen flex-1 flex-col',
} props.className,
)}
>
<div
className={props.contentContainerClassName ?? 'flex flex-1 flex-col'}
> >
<div <div
className={cn( className={cn(
'bg-muted/40 dark:border-border dark:shadow-primary/10 flex h-14 items-center justify-between px-4 lg:justify-start lg:shadow-xs', 'bg-background/95 supports-[backdrop-filter]:bg-background/80 border-b',
{ {
'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true, 'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true,
}, },
)} )}
> >
<div className="container mx-auto flex h-14 w-full items-center">
<div <div
className={'hidden w-full flex-1 items-center space-x-8 lg:flex'} className={
'hidden w-full min-w-0 flex-1 items-center space-x-4 lg:flex lg:px-4'
}
> >
{Navigation} {Navigation}
</div> </div>
{MobileNavigation} {MobileNavigation}
</div> </div>
</div>
<div className={'container flex flex-1 flex-col'}>{Children}</div> <div className="container mx-auto flex w-full flex-1 flex-col">
{Children}
</div>
</div> </div>
</div> </div>
); );
@@ -113,7 +122,15 @@ export function PageBody(
} }
export function PageNavigation(props: React.PropsWithChildren) { export function PageNavigation(props: React.PropsWithChildren) {
return <div className={'bg-inherit'}>{props.children}</div>; return (
<div
className={
'flex flex-1 flex-col bg-inherit group-data-[slot="sidebar-wrapper"]/sidebar-wrapper:flex-initial'
}
>
{props.children}
</div>
);
} }
export function PageDescription(props: React.PropsWithChildren) { export function PageDescription(props: React.PropsWithChildren) {
@@ -147,16 +164,25 @@ export function PageHeader({
title, title,
description, description,
className, className,
displaySidebarTrigger = true,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
className?: string; className?: string;
title?: string | React.ReactNode; title?: string | React.ReactNode;
description?: string | React.ReactNode; description?: string | React.ReactNode;
displaySidebarTrigger?: boolean;
}>) { }>) {
return ( return (
<div className={cn('flex items-center justify-between py-4', className)}> <div
<div className={'flex flex-col gap-y-2'}> className={cn(
<div className="flex items-center gap-x-2.5"> 'flex flex-col gap-4 py-4 sm:py-5 lg:flex-row lg:items-center lg:justify-between',
className,
)}
>
<div className={'flex min-w-0 flex-col gap-y-2'}>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
<If condition={displaySidebarTrigger}>
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground h-4.5 w-4.5 cursor-pointer" /> <SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground h-4.5 w-4.5 cursor-pointer" />
</If>
<If condition={description}> <If condition={description}>
<Separator <Separator
@@ -173,8 +199,10 @@ export function PageHeader({
</If> </If>
</div> </div>
<div className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:justify-end">
{children} {children}
</div> </div>
</div>
); );
} }

3538
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,95 +2,89 @@ packages:
- apps/* - apps/*
- packages/** - packages/**
- tooling/* - tooling/*
catalog: catalog:
'@base-ui/react': ^1.3.0 '@base-ui/react': ^1.3.0
'@faker-js/faker': ^10.4.0 '@faker-js/faker': ^10.4.0
'@hookform/resolvers': ^5.2.2 '@hookform/resolvers': ^5.2.2
'@keystatic/core': 0.5.49 '@keystatic/core': 0.5.50
'@keystatic/next': ^5.0.4 '@keystatic/next': ^5.0.4
'@lemonsqueezy/lemonsqueezy.js': 4.0.0 '@lemonsqueezy/lemonsqueezy.js': 4.0.0
'@makerkit/data-loader-supabase-core': ^0.0.10 '@makerkit/data-loader-supabase-core': ^0.0.10
'@makerkit/data-loader-supabase-nextjs': ^1.2.5 '@makerkit/data-loader-supabase-nextjs': ^1.2.5
'@manypkg/cli': ^0.25.1 '@manypkg/cli': ^0.25.1
'@markdoc/markdoc': ^0.5.6 '@markdoc/markdoc': ^0.5.7
'@marsidev/react-turnstile': ^1.4.2 '@marsidev/react-turnstile': ^1.5.0
'@measured/puck': ^0.20.2
'@modelcontextprotocol/sdk': 1.28.0 '@modelcontextprotocol/sdk': 1.28.0
'@next/bundle-analyzer': 16.2.1 '@next/bundle-analyzer': 16.2.1
'@nosecone/next': 1.3.0 '@nosecone/next': 1.3.1
'@playwright/test': ^1.58.2 '@playwright/test': ^1.58.2
'@react-email/components': 1.0.10 '@react-email/components': 1.0.10
'@react-pdf/renderer': ^4.3.2
'@sentry/nextjs': 10.46.0 '@sentry/nextjs': 10.46.0
'@stripe/react-stripe-js': 5.6.1 '@stripe/react-stripe-js': 6.1.0
'@stripe/stripe-js': 8.11.0 '@stripe/stripe-js': 9.0.1
'@supabase/ssr': ^0.9.0 '@supabase/ssr': ^0.10.0
'@supabase/supabase-js': 2.100.0 '@supabase/supabase-js': 2.101.0
'@tailwindcss/postcss': ^4.2.2 '@tailwindcss/postcss': ^4.2.2
'@tanstack/react-query': 5.95.2 '@tanstack/react-query': 5.95.2
'@tanstack/react-table': ^8.21.3 '@tanstack/react-table': ^8.21.3
'@tiptap/pm': ^3.21.0 '@turbo/gen': ^2.9.1
'@tiptap/react': ^3.21.0
'@tiptap/starter-kit': ^3.21.0
'@turbo/gen': ^2.8.20
'@types/node': 25.5.0 '@types/node': 25.5.0
'@types/nodemailer': 7.0.11 '@types/nodemailer': 7.0.11
'@types/papaparse': ^5.5.2
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3 '@types/react-dom': 19.2.3
babel-plugin-react-compiler: 1.0.0 babel-plugin-react-compiler: 1.0.0
class-variance-authority: ^0.7.1 class-variance-authority: ^0.7.1
clsx: ^2.1.1 clsx: ^2.1.1
cmdk: ^1.1.1
cross-env: ^10.0.0 cross-env: ^10.0.0
cssnano: ^7.1.3 cssnano: ^7.1.4
date-fns: ^4.1.0 date-fns: ^4.1.0
dotenv: 17.3.1 dotenv: 17.3.1
exceljs: ^4.4.0 embla-carousel-react: ^8.6.0
iban: ^0.0.14 input-otp: ^1.4.2
lucide-react: 1.7.0 lucide-react: 1.7.0
nanoid: ^5.1.7 nanoid: ^5.1.7
next: 16.2.1 next: 16.2.1
next-intl: ^4.8.3 next-intl: ^4.8.3
next-runtime-env: 3.3.0 next-runtime-env: 3.3.0
next-safe-action: ^8.1.8 next-safe-action: ^8.3.0
next-sitemap: ^4.2.3 next-sitemap: ^4.2.3
next-themes: 0.4.6 next-themes: 0.4.6
node-html-parser: ^7.1.0 node-html-parser: ^7.1.0
nodemailer: 8.0.4 nodemailer: 8.0.4
oxfmt: ^0.42.0 oxfmt: ^0.42.0
oxlint: ^1.57.0 oxlint: ^1.57.0
papaparse: ^5.5.3
pino: 10.3.1 pino: 10.3.1
pino-pretty: 13.0.0 pino-pretty: 13.0.0
postgres: 3.4.8 postgres: 3.4.8
react: 19.2.4 react: 19.2.4
react-day-picker: ^9.14.0
react-dom: 19.2.4 react-dom: 19.2.4
react-dropzone: ^15.0.0
react-hook-form: 7.72.0 react-hook-form: 7.72.0
react-resizable-panels: ^4.7.6 react-resizable-panels: ^4.8.0
react-top-loading-bar: ^3.0.2
recharts: 3.7.0 recharts: 3.7.0
rxjs: ^7.8.2 rxjs: ^7.8.2
server-only: ^0.0.1 server-only: ^0.0.1
shadcn: 4.1.0 shadcn: 4.1.1
sonner: ^2.0.7 sonner: ^2.0.7
stripe: 20.4.1 stripe: 21.0.1
supabase: 2.84.4 supabase: 2.84.5
tailwind-merge: ^3.5.0 tailwind-merge: ^3.5.0
tailwindcss: 4.2.2 tailwindcss: 4.2.2
totp-generator: ^2.0.1 totp-generator: ^2.0.1
tsup: 8.5.1 tsup: 8.5.1
turbo: 2.8.20 turbo: 2.9.1
tw-animate-css: 1.4.0 tw-animate-css: 1.4.0
typescript: ^6.0.2 typescript: ^6.0.2
urlpattern-polyfill: ^10.1.0 urlpattern-polyfill: ^10.1.0
vitest: ^4.1.1 vaul: ^1.1.2
vitest: ^4.1.2
wp-types: ^4.69.0 wp-types: ^4.69.0
zod: 4.3.6 zod: 4.3.6
catalogMode: prefer catalogMode: prefer
cleanupUnusedCatalogs: true cleanupUnusedCatalogs: true
onlyBuiltDependencies: onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- '@sentry/cli' - '@sentry/cli'