Merge remote-tracking branch 'origin/main'

# Conflicts:
#	apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx
#	packages/features/course-management/src/server/api.ts
#	packages/features/event-management/src/server/api.ts
#	packages/supabase/src/get-supabase-client-keys.ts
#	pnpm-lock.yaml
This commit is contained in:
T. Zehetbauer
2026-04-01 13:22:17 +02:00
34 changed files with 1608 additions and 1361 deletions

View File

@@ -4,7 +4,9 @@ WORKDIR /app
# --- Install + Build in one stage --- # --- Install + Build in one stage ---
FROM base AS builder FROM base AS builder
ARG CACHE_BUST=6 # CACHE_BUST: change this value to force a full rebuild (busts Docker layer cache)
ARG CACHE_BUST=11
RUN echo "Cache bust: ${CACHE_BUST}"
COPY . . COPY . .
RUN pnpm install --no-frozen-lockfile RUN pnpm install --no-frozen-lockfile
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -30,6 +32,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/ ./ COPY --from=builder /app/ ./
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# Ensure Next.js cache directories are writable by the nextjs user
RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000

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,129 +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}
@@ -185,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

@@ -161,10 +161,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}
@@ -195,6 +195,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'}>
@@ -202,6 +208,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

@@ -80,7 +80,10 @@ async function adminMiddleware(request: NextRequest, response: NextResponse) {
return; return;
} }
const { data, error } = await getUser(request, response); const { data, error } = await getUser(request, response).catch(() => ({
data: null as any,
error: new Error('Supabase unreachable'),
}));
// If user is not logged in, redirect to sign in page. // If user is not logged in, redirect to sign in page.
// This should never happen, but just in case. // This should never happen, but just in case.
@@ -121,7 +124,14 @@ async function getPatterns() {
{ {
pattern: new URLPattern({ pathname: '/auth/*?' }), pattern: new URLPattern({ pathname: '/auth/*?' }),
handler: async (req: NextRequest, res: NextResponse) => { handler: async (req: NextRequest, res: NextResponse) => {
const { data } = await getUser(req, res); let data;
try {
({ data } = await getUser(req, res));
} catch {
// Supabase unreachable — treat as logged out, let the page render
return;
}
// the user is logged out, so we don't need to do anything // the user is logged out, so we don't need to do anything
if (!data?.claims) { if (!data?.claims) {
@@ -148,7 +158,16 @@ async function getPatterns() {
{ {
pattern: new URLPattern({ pathname: '/home/*?' }), pattern: new URLPattern({ pathname: '/home/*?' }),
handler: async (req: NextRequest, res: NextResponse) => { handler: async (req: NextRequest, res: NextResponse) => {
const { data } = await getUser(req, res); let data;
try {
({ data } = await getUser(req, res));
} catch {
// Supabase unreachable — redirect to sign in
const signIn = pathsConfig.auth.signIn;
return NextResponse.redirect(new URL(signIn, req.nextUrl.origin).href);
}
const { origin, pathname: next } = req.nextUrl; const { origin, pathname: next } = req.nextUrl;
// If user is not logged in, redirect to sign in page. // If user is not logged in, redirect to sign in page.

View File

@@ -339,12 +339,10 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost:3000} NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost:3000}
# Browser-side: external domain (baked at build time, re-stated here for SSR) # Same URL for browser AND server — keeps Supabase cookie names consistent
NEXT_PUBLIC_SUPABASE_URL: ${API_EXTERNAL_URL:-http://localhost:8000} NEXT_PUBLIC_SUPABASE_URL: ${API_EXTERNAL_URL:-http://localhost:8000}
NEXT_PUBLIC_SUPABASE_PUBLIC_KEY: ${SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_PUBLIC_KEY: ${SUPABASE_ANON_KEY}
NEXT_PUBLIC_DEFAULT_LOCALE: de NEXT_PUBLIC_DEFAULT_LOCALE: de
# Server-side: Docker-internal URL (avoids hairpin NAT / DNS issues)
SUPABASE_INTERNAL_URL: http://supabase-kong:8000
SUPABASE_SECRET_KEY: ${SUPABASE_SERVICE_ROLE_KEY} SUPABASE_SECRET_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret} SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de} EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de}

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

@@ -1,11 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database'; import type { CreateCourseInput, EnrollParticipantInput } from '../schema/course.schema';
import type {
CreateCourseInput,
EnrollParticipantInput,
} from '../schema/course.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -14,25 +10,11 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
return { return {
// --- Courses --- // --- Courses ---
async listCourses( async listCourses(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
accountId: string, let query = client.from('courses').select('*', { count: 'exact' })
opts?: { .eq('account_id', accountId).order('start_date', { ascending: false });
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status); if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search) if (opts?.search) query = query.or(`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`);
query = query.or(
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
);
const page = opts?.page ?? 1; const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25; const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1); query = query.range((page - 1) * pageSize, page * pageSize - 1);
@@ -42,38 +24,20 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
}, },
async getCourse(courseId: string) { async getCourse(courseId: string) {
const { data, error } = await client const { data, error } = await client.from('courses').select('*').eq('id', courseId).single();
.from('courses')
.select('*')
.eq('id', courseId)
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async createCourse(input: CreateCourseInput) { async createCourse(input: CreateCourseInput) {
const { data, error } = await client const { data, error } = await client.from('courses').insert({
.from('courses') account_id: input.accountId, course_number: input.courseNumber || null, name: input.name,
.insert({ description: input.description || null, category_id: input.categoryId || null, instructor_id: input.instructorId || null,
account_id: input.accountId, location_id: input.locationId || null, start_date: input.startDate || null, end_date: input.endDate || null,
course_number: input.courseNumber, fee: input.fee, reduced_fee: input.reducedFee ?? null, capacity: input.capacity,
name: input.name, min_participants: input.minParticipants, status: input.status,
description: input.description, registration_deadline: input.registrationDeadline || null, notes: input.notes || null,
category_id: input.categoryId, }).select().single();
instructor_id: input.instructorId,
location_id: input.locationId,
start_date: input.startDate,
end_date: input.endDate,
fee: input.fee,
reduced_fee: input.reducedFee,
capacity: input.capacity,
min_participants: input.minParticipants,
status: input.status,
registration_deadline: input.registrationDeadline,
notes: input.notes,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
@@ -81,161 +45,96 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
// --- Enrollment --- // --- Enrollment ---
async enrollParticipant(input: EnrollParticipantInput) { async enrollParticipant(input: EnrollParticipantInput) {
// Check capacity // Check capacity
const { count } = await client const { count } = await client.from('course_participants').select('*', { count: 'exact', head: true })
.from('course_participants') .eq('course_id', input.courseId).in('status', ['enrolled']);
.select('*', { count: 'exact', head: true })
.eq('course_id', input.courseId)
.in('status', ['enrolled']);
const course = await this.getCourse(input.courseId); const course = await this.getCourse(input.courseId);
const status = const status = (count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
const { data, error } = await client const { data, error } = await client.from('course_participants').insert({
.from('course_participants') course_id: input.courseId, member_id: input.memberId,
.insert({ first_name: input.firstName, last_name: input.lastName,
course_id: input.courseId, email: input.email, phone: input.phone, status,
member_id: input.memberId, }).select().single();
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
status,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async cancelEnrollment(participantId: string) { async cancelEnrollment(participantId: string) {
const { error } = await client const { error } = await client.from('course_participants')
.from('course_participants')
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() }) .update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
.eq('id', participantId); .eq('id', participantId);
if (error) throw error; if (error) throw error;
}, },
async getParticipants(courseId: string) { async getParticipants(courseId: string) {
const { data, error } = await client const { data, error } = await client.from('course_participants').select('*')
.from('course_participants') .eq('course_id', courseId).order('enrolled_at');
.select('*')
.eq('course_id', courseId)
.order('enrolled_at');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
// --- Sessions --- // --- Sessions ---
async getSessions(courseId: string) { async getSessions(courseId: string) {
const { data, error } = await client const { data, error } = await client.from('course_sessions').select('*')
.from('course_sessions') .eq('course_id', courseId).order('session_date');
.select('*')
.eq('course_id', courseId)
.order('session_date');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async createSession(input: { async createSession(input: { courseId: string; sessionDate: string; startTime: string; endTime: string; locationId?: string }) {
courseId: string; const { data, error } = await client.from('course_sessions').insert({
sessionDate: string; course_id: input.courseId, session_date: input.sessionDate,
startTime: string; start_time: input.startTime, end_time: input.endTime, location_id: input.locationId,
endTime: string; }).select().single();
locationId?: string;
}) {
const { data, error } = await client
.from('course_sessions')
.insert({
course_id: input.courseId,
session_date: input.sessionDate,
start_time: input.startTime,
end_time: input.endTime,
location_id: input.locationId,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
// --- Attendance --- // --- Attendance ---
async getAttendance(sessionId: string) { async getAttendance(sessionId: string) {
const { data, error } = await client const { data, error } = await client.from('course_attendance').select('*').eq('session_id', sessionId);
.from('course_attendance')
.select('*')
.eq('session_id', sessionId);
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async markAttendance( async markAttendance(sessionId: string, participantId: string, present: boolean) {
sessionId: string, const { error } = await client.from('course_attendance').upsert({
participantId: string, session_id: sessionId, participant_id: participantId, present,
present: boolean, }, { onConflict: 'session_id,participant_id' });
) {
const { error } = await client.from('course_attendance').upsert(
{
session_id: sessionId,
participant_id: participantId,
present,
},
{ onConflict: 'session_id,participant_id' },
);
if (error) throw error; if (error) throw error;
}, },
// --- Categories, Instructors, Locations --- // --- Categories, Instructors, Locations ---
async listCategories(accountId: string) { async listCategories(accountId: string) {
const { data, error } = await client const { data, error } = await client.from('course_categories').select('*')
.from('course_categories') .eq('account_id', accountId).order('sort_order');
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async listInstructors(accountId: string) { async listInstructors(accountId: string) {
const { data, error } = await client const { data, error } = await client.from('course_instructors').select('*')
.from('course_instructors') .eq('account_id', accountId).order('last_name');
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async listLocations(accountId: string) { async listLocations(accountId: string) {
const { data, error } = await client const { data, error } = await client.from('course_locations').select('*')
.from('course_locations') .eq('account_id', accountId).order('name');
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
// --- Statistics --- // --- Statistics ---
async getStatistics(accountId: string) { async getStatistics(accountId: string) {
const { data: courses } = await client const { data: courses } = await client.from('courses').select('status').eq('account_id', accountId);
.from('courses') const { count: totalParticipants } = await client.from('course_participants')
.select('status')
.eq('account_id', accountId);
const { count: totalParticipants } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.in( .in('course_id', (courses ?? []).map((c: any) => c.id));
'course_id',
(courses ?? []).map((c: any) => c.id),
);
const stats = { const stats = { totalCourses: 0, openCourses: 0, completedCourses: 0, totalParticipants: totalParticipants ?? 0 };
totalCourses: 0, for (const c of (courses ?? [])) {
openCourses: 0,
completedCourses: 0,
totalParticipants: totalParticipants ?? 0,
};
for (const c of courses ?? []) {
stats.totalCourses++; stats.totalCourses++;
if (c.status === 'open' || c.status === 'running') stats.openCourses++; if (c.status === 'open' || c.status === 'running') stats.openCourses++;
if (c.status === 'completed') stats.completedCourses++; if (c.status === 'completed') stats.completedCourses++;
@@ -244,70 +143,30 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
}, },
// --- Create methods for CRUD --- // --- Create methods for CRUD ---
async createCategory(input: { async createCategory(input: { accountId: string; name: string; description?: string; parentId?: string }) {
accountId: string; const { data, error } = await client.from('course_categories').insert({
name: string; account_id: input.accountId, name: input.name, description: input.description,
description?: string;
parentId?: string;
}) {
const { data, error } = await client
.from('course_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
parent_id: input.parentId, parent_id: input.parentId,
}) }).select().single();
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async createInstructor(input: { async createInstructor(input: { accountId: string; firstName: string; lastName: string; email?: string; phone?: string; qualifications?: string; hourlyRate?: number }) {
accountId: string; const { data, error } = await client.from('course_instructors').insert({
firstName: string; account_id: input.accountId, first_name: input.firstName, last_name: input.lastName,
lastName: string; email: input.email, phone: input.phone, qualifications: input.qualifications,
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
}) {
const { data, error } = await client
.from('course_instructors')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
qualifications: input.qualifications,
hourly_rate: input.hourlyRate, hourly_rate: input.hourlyRate,
}) }).select().single();
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async createLocation(input: { async createLocation(input: { accountId: string; name: string; address?: string; room?: string; capacity?: number }) {
accountId: string; const { data, error } = await client.from('course_locations').insert({
name: string; account_id: input.accountId, name: input.name, address: input.address,
address?: string; room: input.room, capacity: input.capacity,
room?: string; }).select().single();
capacity?: number;
}) {
const { data, error } = await client
.from('course_locations')
.insert({
account_id: input.accountId,
name: input.name,
address: input.address,
room: input.room,
capacity: input.capacity,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },

View File

@@ -1,6 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database'; import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateEventInput } from '../schema/event.schema'; import type { CreateEventInput } from '../schema/event.schema';
@@ -11,28 +10,16 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
const db = client; const db = client;
return { return {
async listEvents( async listEvents(accountId: string, opts?: { status?: string; page?: number }) {
accountId: string, let query = client.from('events').select('*', { count: 'exact' })
opts?: { status?: string; page?: number }, .eq('account_id', accountId).order('event_date', { ascending: false });
) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status); if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1; const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1); query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query; const { data, error, count } = await query;
if (error) throw error; if (error) throw error;
const total = count ?? 0; const total = count ?? 0;
return { return { data: data ?? [], total, page, pageSize: PAGE_SIZE, totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
}, },
async getRegistrationCounts(eventIds: string[]) { async getRegistrationCounts(eventIds: string[]) {
@@ -53,131 +40,71 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
}, },
async getEvent(eventId: string) { async getEvent(eventId: string) {
const { data, error } = await client const { data, error } = await client.from('events').select('*').eq('id', eventId).single();
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async createEvent(input: CreateEventInput) { async createEvent(input: CreateEventInput) {
const { data, error } = await client const { data, error } = await client.from('events').insert({
.from('events') account_id: input.accountId, name: input.name, description: input.description || null,
.insert({ event_date: input.eventDate || null, event_time: input.eventTime || null, end_date: input.endDate || null,
account_id: input.accountId, location: input.location || null, capacity: input.capacity, min_age: input.minAge ?? null,
name: input.name, max_age: input.maxAge ?? null, fee: input.fee, status: input.status,
description: input.description, registration_deadline: input.registrationDeadline || null,
event_date: input.eventDate, contact_name: input.contactName || null, contact_email: input.contactEmail || null, contact_phone: input.contactPhone || null,
event_time: input.eventTime, }).select().single();
end_date: input.endDate,
location: input.location,
capacity: input.capacity,
min_age: input.minAge,
max_age: input.maxAge,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline,
contact_name: input.contactName,
contact_email: input.contactEmail,
contact_phone: input.contactPhone,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async registerForEvent(input: { async registerForEvent(input: { eventId: string; firstName: string; lastName: string; email?: string; parentName?: string }) {
eventId: string;
firstName: string;
lastName: string;
email?: string;
parentName?: string;
}) {
// Check capacity // Check capacity
const event = await this.getEvent(input.eventId); const event = await this.getEvent(input.eventId);
if (event.capacity) { if (event.capacity) {
const { count } = await client const { count } = await client.from('event_registrations').select('*', { count: 'exact', head: true })
.from('event_registrations') .eq('event_id', input.eventId).in('status', ['pending', 'confirmed']);
.select('*', { count: 'exact', head: true })
.eq('event_id', input.eventId)
.in('status', ['pending', 'confirmed']);
if ((count ?? 0) >= event.capacity) { if ((count ?? 0) >= event.capacity) {
throw new Error('Event is full'); throw new Error('Event is full');
} }
} }
const { data, error } = await client const { data, error } = await client.from('event_registrations').insert({
.from('event_registrations') event_id: input.eventId, first_name: input.firstName, last_name: input.lastName,
.insert({ email: input.email, parent_name: input.parentName, status: 'confirmed',
event_id: input.eventId, }).select().single();
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
parent_name: input.parentName,
status: 'confirmed',
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },
async getRegistrations(eventId: string) { async getRegistrations(eventId: string) {
const { data, error } = await client const { data, error } = await client.from('event_registrations').select('*')
.from('event_registrations') .eq('event_id', eventId).order('created_at');
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
// Holiday passes // Holiday passes
async listHolidayPasses(accountId: string) { async listHolidayPasses(accountId: string) {
const { data, error } = await client const { data, error } = await client.from('holiday_passes').select('*')
.from('holiday_passes') .eq('account_id', accountId).order('year', { ascending: false });
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async getPassActivities(passId: string) { async getPassActivities(passId: string) {
const { data, error } = await client const { data, error } = await client.from('holiday_pass_activities').select('*')
.from('holiday_pass_activities') .eq('pass_id', passId).order('activity_date');
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error; if (error) throw error;
return data ?? []; return data ?? [];
}, },
async createHolidayPass(input: { async createHolidayPass(input: { accountId: string; name: string; year: number; description?: string; price?: number; validFrom?: string; validUntil?: string }) {
accountId: string; const { data, error } = await client.from('holiday_passes').insert({
name: string; account_id: input.accountId, name: input.name, year: input.year,
year: number; description: input.description, price: input.price ?? 0,
description?: string; valid_from: input.validFrom, valid_until: input.validUntil,
price?: number; }).select().single();
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error; if (error) throw error;
return data; return data;
}, },

View File

@@ -8,5 +8,6 @@ import { defaultLocale } from './default-locale';
*/ */
export const locales: string[] = [ export const locales: string[] = [
defaultLocale, defaultLocale,
'de', // German — primary locale for MyEasyCMS // Add other locales here as needed. defaultLocale ('de') is already included above.
// 'en', // English (uncomment to enable)
]; ];

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

@@ -2,18 +2,8 @@ import * as z from 'zod';
/** /**
* Returns and validates the Supabase client keys from the environment. * Returns and validates the Supabase client keys from the environment.
*
* On the server, prefers SUPABASE_INTERNAL_URL (Docker-internal)
* over NEXT_PUBLIC_SUPABASE_URL (external domain) to avoid
* hairpin NAT / DNS issues in containerized deployments.
*/ */
export function getSupabaseClientKeys() { export function getSupabaseClientKeys() {
const isServer = typeof window === 'undefined';
const url = isServer
? process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL
: process.env.NEXT_PUBLIC_SUPABASE_URL;
return z return z
.object({ .object({
url: z.string({ url: z.string({
@@ -24,7 +14,7 @@ export function getSupabaseClientKeys() {
}), }),
}) })
.parse({ .parse({
url, url: process.env.NEXT_PUBLIC_SUPABASE_URL,
publicKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY, publicKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY,
}); });
} }

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>
); );
} }

1738
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,58 +2,60 @@ 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
'@measured/puck': ^0.20.2 '@measured/puck': ^0.20.2
'@marsidev/react-turnstile': ^1.5.0
'@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 '@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
'@types/papaparse': ^5.5.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 '@tiptap/pm': ^3.21.0
'@tiptap/react': ^3.21.0 '@tiptap/react': ^3.21.0
'@tiptap/starter-kit': ^3.21.0 '@tiptap/starter-kit': ^3.21.0
'@turbo/gen': ^2.8.20 '@turbo/gen': ^2.9.1
'@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
embla-carousel-react: ^8.6.0
exceljs: ^4.4.0 exceljs: ^4.4.0
input-otp: ^1.4.2
iban: ^0.0.14 iban: ^0.0.14
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
@@ -65,32 +67,33 @@ catalog:
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'