Remove billing and checkout redirect buttons and related services

Deleted the billing-redirect-button, checkout-redirect-button, and embedded-stripe-checkout components. Additionally, removed the shadcn directory, which encompassed billing-related icons. This change streamlines the subscription settings interface and organizes the system's payment management. This update is a stepping stone towards improving the billing system's overall architecture.
This commit is contained in:
giancarlo
2024-03-25 11:39:41 +08:00
parent 78c704e54d
commit cb8b23e8c0
123 changed files with 1674 additions and 3071 deletions

View File

@@ -106,13 +106,13 @@ The configuration is defined in the `apps/web/config` folder. Here you can find
To install a Shadcn UI component, you can use the following command: To install a Shadcn UI component, you can use the following command:
```bash ```bash
npx shadcn-ui@latest add <component> --path=packages/ui/shadcn npx shadcn-ui@latest add <component> --path=packages/src/ui/shadcn
``` ```
For example, to install the `Button` component, you can use the following command: For example, to install the `Button` component, you can use the following command:
```bash ```bash
npx shadcn-ui@latest add button --path=packages/rsc/ui/shadcn npx shadcn-ui@latest add button --path=packages/src/ui/shadcn
``` ```
We pass the `--path` flag to specify the path where the component should be installed. We pass the `--path` flag to specify the path where the component should be installed.

View File

@@ -1,28 +1,3 @@
# Create T3 App # Your Application
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. Write here everything about your application.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function UserSettingsLayout(props: React.PropsWithChildren) { function UserSettingsLayout(props: React.PropsWithChildren) {
return ( return (
<> <>

View File

@@ -1,9 +1,9 @@
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
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 { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
function PersonalAccountSettingsPage() { function PersonalAccountSettingsPage() {
return ( return (
<div <div

View File

@@ -1,13 +1,12 @@
'use server'; 'use server';
import { URL } from 'next/dist/compiled/@edge-runtime/primitives';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { getProductPlanPairFromId } from '@kit/billing'; import { getProductPlanPairFromId } from '@kit/billing';
import { getGatewayProvider } from '@kit/billing-gateway'; import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';
@@ -30,7 +29,7 @@ export async function createPersonalAccountCheckoutSession(params: {
} }
const planId = z.string().min(1).parse(params.planId); const planId = z.string().min(1).parse(params.planId);
const service = await getGatewayProvider(client); const service = await getBillingGatewayProvider(client);
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId); const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
if (!productPlanPairFromId) { if (!productPlanPairFromId) {
@@ -77,7 +76,7 @@ export async function createBillingPortalSession() {
throw new Error('Authentication required'); throw new Error('Authentication required');
} }
const service = await getGatewayProvider(client); const service = await getBillingGatewayProvider(client);
const accountId = data.user.id; const accountId = data.user.id;
const customerId = await getCustomerIdFromAccountId(accountId); const customerId = await getCustomerIdFromAccountId(accountId);

View File

@@ -1,8 +1,8 @@
import { Page } from '@kit/ui/page';
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar'; import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { Page } from '@kit/ui/page';
function UserHomeLayout({ children }: React.PropsWithChildren) { function UserHomeLayout({ children }: React.PropsWithChildren) {
return <Page sidebar={<HomeSidebar />}>{children}</Page>; return <Page sidebar={<HomeSidebar />}>{children}</Page>;
} }

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function UserHomePage() { function UserHomePage() {
return ( return (
<> <>

View File

@@ -1,7 +1,7 @@
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
import { PageHeader } from '@kit/ui/page'; import { PageHeader } from '@kit/ui/page';
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
export function AppHeader({ export function AppHeader({
children, children,
title, title,

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar'; import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
export function AppSidebarNavigation({ export function AppSidebarNavigation({
account, account,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{

View File

@@ -3,9 +3,6 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react'; import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { Sidebar, SidebarContent } from '@kit/ui/sidebar'; import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
@@ -18,6 +15,10 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { AppSidebarNavigation } from './app-sidebar-navigation'; import { AppSidebarNavigation } from './app-sidebar-navigation';
type AccountModel = { type AccountModel = {

View File

@@ -4,9 +4,6 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react'; import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
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';
@@ -26,6 +23,10 @@ import {
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import featureFlagsConfig from '~/config/feature-flags.config';
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
import pathsConfig from '~/config/paths.config';
export const MobileAppNavigation = ( export const MobileAppNavigation = (
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
slug: string; slug: string;

View File

@@ -3,10 +3,11 @@ import { cache } from 'react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import 'server-only'; import 'server-only';
import pathsConfig from '~/config/paths.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
/** /**
* Load the organization workspace data. * Load the organization workspace data.
* We place this function into a separate file so it can be reused in multiple places across the server components. * We place this function into a separate file so it can be reused in multiple places across the server components.

View File

@@ -1,8 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
function OrganizationAccountBillingPage() { function OrganizationAccountBillingPage() {
return ( return (
<> <>

View File

@@ -1,9 +1,9 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie'; import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie'; import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
import { Page } from '@kit/ui/page'; import { Page } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
import { AppSidebar } from './(components)/app-sidebar'; import { AppSidebar } from './(components)/app-sidebar';
import { loadOrganizationWorkspace } from './(lib)/load-workspace'; import { loadOrganizationWorkspace } from './(lib)/load-workspace';

View File

@@ -1,6 +1,4 @@
import { PlusCircledIcon } from '@radix-ui/react-icons'; import { PlusCircledIcon } from '@radix-ui/react-icons';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { import {
@@ -19,6 +17,9 @@ import {
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import { withI18n } from '~/lib/i18n/with-i18n';
interface Params { interface Params {
params: { params: {
account: string; account: string;

View File

@@ -1,14 +1,15 @@
import loadDynamic from 'next/dynamic'; import loadDynamic from 'next/dynamic';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import Spinner from '@kit/ui/spinner'; import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
import { withI18n } from '~/lib/i18n/with-i18n';
const DashboardDemo = loadDynamic( const DashboardDemo = loadDynamic(
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'), () => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
{ {

View File

@@ -1,3 +0,0 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -1,87 +0,0 @@
import { use } from 'react';
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
import featureFlagsConfig from '~/config/feature-flags.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
TeamAccountDangerZone,
UpdateOrganizationForm,
} from '@kit/team-accounts/components';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
export const metadata = {
title: 'Organization Settings',
};
const allowOrganizationDelete = featureFlagsConfig.enableOrganizationDeletion;
interface Params {
params: {
account: string;
};
}
function OrganizationSettingsPage({ params }: Params) {
const { account, user } = use(loadOrganizationWorkspace(params.account));
return (
<>
<PageHeader
title={<Trans i18nKey={'organization:settingsPageTitle'} />}
description={<Trans i18nKey={'organization:settingsPageDescription'} />}
/>
<PageBody>
<div className={'mx-auto flex max-w-5xl flex-col space-y-4'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:generalTabLabel'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:generalTabLabelSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateOrganizationForm
accountId={account.id}
accountName={account.name}
/>
</CardContent>
</Card>
<If condition={allowOrganizationDelete}>
<Card className={'border-2 border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'organization:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'organization:dangerZoneSubheading'} />
</CardDescription>
</CardHeader>
<CardContent>
<TeamAccountDangerZone userId={user.id} account={account} />
</CardContent>
</Card>
</If>
</div>
</PageBody>
</>
);
}
export default withI18n(OrganizationSettingsPage);

View File

@@ -1,32 +0,0 @@
'use client';
import { ArrowUpRightIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
export function BillingPortalRedirectButton({
children,
customerId,
className,
}: React.PropsWithChildren<{
customerId: string;
className?: string;
}>) {
return (
<form action={createBillingPortalSessionAction}>
<input type={'hidden'} name={'customerId'} value={customerId} />
<Button
data-test={'manage-billing-redirect-button'}
variant={'outline'}
className={className}
>
<span className={'flex items-center space-x-2'}>
<span>{children}</span>
<ArrowUpRightIcon className={'h-3'} />
</span>
</Button>
</form>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { ChevronRightIcon } from 'lucide-react';
import { isBrowser } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
export function CheckoutRedirectButton({
children,
onCheckoutCreated,
...props
}): React.PropsWithChildren<{
disabled?: boolean;
stripePriceId?: string;
recommended?: boolean;
organizationUid: string;
onCheckoutCreated?: (clientSecret: string) => void;
}> {
const [state, formAction] = useFormState(createCheckoutAction, {
clientSecret: '',
});
useEffect(() => {
if (state.clientSecret && onCheckoutCreated) {
onCheckoutCreated(state.clientSecret);
}
}, [state.clientSecret, onCheckoutCreated]);
return (
<form data-test={'checkout-form'} action={formAction}>
<CheckoutFormData
organizationUid={props.organizationUid}
priceId={props.stripePriceId}
/>
<SubmitCheckoutButton
disabled={props.disabled}
recommended={props.recommended}
>
{children}
</SubmitCheckoutButton>
</form>
);
}
function SubmitCheckoutButton(
props: React.PropsWithChildren<{
recommended?: boolean;
disabled?: boolean;
}>,
) {
const { pending } = useFormStatus();
return (
<Button
className={cn({
'bg-primary text-primary-foreground dark:bg-white dark:text-gray-900':
props.recommended,
})}
variant={props.recommended ? 'custom' : 'outline'}
disabled={props.disabled ?? pending}
>
<span className={'flex items-center space-x-2'}>
<span>{props.children}</span>
<ChevronRightIcon className={'h-4'} />
</span>
</Button>
);
}
function CheckoutFormData(
props: React.PropsWithChildren<{
organizationUid: string | undefined;
priceId: string | undefined;
}>,
) {
return (
<>
<input
type="hidden"
name={'organizationUid'}
defaultValue={props.organizationUid}
/>
<input type="hidden" name={'returnUrl'} defaultValue={getReturnUrl()} />
<input type="hidden" name={'priceId'} defaultValue={props.priceId} />
</>
);
}
function getReturnUrl() {
return isBrowser()
? [window.location.origin, window.location.pathname].join('')
: undefined;
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Close as DialogPrimitiveClose } from '@radix-ui/react-dialog';
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { XIcon } from 'lucide-react';
import pricingConfig, {
StripeCheckoutDisplayMode,
} from '@/config/pricing.config';
import { cn } from '@/lib/utils';
import If from '@/components/app/If';
import LogoImage from '@/components/app/Logo/LogoImage';
import Trans from '@/components/app/Trans';
const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_PUBLISHABLE_KEY) {
throw new Error(
'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
);
}
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
export default function EmbeddedStripeCheckout({
clientSecret,
onClose,
}: React.PropsWithChildren<{
clientSecret: string;
onClose?: () => void;
}>) {
return (
<EmbeddedCheckoutPopup key={clientSecret} onClose={onClose}>
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret }}
>
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
</EmbeddedCheckoutProvider>
</EmbeddedCheckoutPopup>
);
}
function EmbeddedCheckoutPopup({
onClose,
children,
}: React.PropsWithChildren<{
onClose?: () => void;
}>) {
const [open, setOpen] = useState(true);
const displayMode = pricingConfig.displayMode;
const isPopup = displayMode === StripeCheckoutDisplayMode.Popup;
const isOverlay = displayMode === StripeCheckoutDisplayMode.Overlay;
const className = cn({
[`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
isPopup,
[`bg-background !flex flex-col flex-1 fixed top-0 !max-h-full !max-w-full left-0 w-screen h-screen border-transparent shadow-transparent py-4 px-8`]:
isOverlay,
});
const close = () => {
setOpen(false);
if (onClose) {
onClose();
}
};
return (
<Dialog
defaultOpen
open={open}
onOpenChange={(open) => {
if (!open && onClose) {
onClose();
}
setOpen(open);
}}
>
<DialogContent
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<If condition={isOverlay}>
<div className={'mb-8'}>
<div className={'flex items-center justify-between'}>
<LogoImage />
<Button onClick={close} variant={'outline'}>
<Trans i18nKey={'common:cancel'} />
</Button>
</div>
</div>
</If>
<If condition={isPopup}>
<DialogPrimitiveClose asChild>
<Button
size={'icon'}
className={'absolute right-4 top-2 flex items-center'}
aria-label={'Close Checkout'}
onClick={close}
>
<XIcon className={'h-6 text-gray-900'} />
<span className="sr-only">
<Trans i18nKey={'common:cancel'} />
</span>
</Button>
</DialogPrimitiveClose>
</If>
<div
className={cn({
[`flex-1 rounded-xl bg-white p-8`]: isOverlay,
})}
>
{children}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,112 +0,0 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import type Organization from '@/lib/organizations/types/organization';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import CheckoutRedirectButton from './checkout-redirect-button';
const EmbeddedStripeCheckout = dynamic(
() => import('./embedded-stripe-checkout'),
{
ssr: false,
},
);
const PlanSelectionForm: React.FC<{
organization: WithId<Organization>;
customerId: Maybe<string>;
}> = ({ organization, customerId }) => {
const [clientSecret, setClientSecret] = useState<string>();
const [retry, setRetry] = useState(0);
return (
<div className={'flex flex-col space-y-6'}>
<If condition={clientSecret}>
<EmbeddedStripeCheckout clientSecret={clientSecret!} />
</If>
<div className={'flex w-full flex-col justify-center space-y-8'}>
<PricingTable
CheckoutButton={(props) => {
return (
<ErrorBoundary
key={retry}
fallback={
<CheckoutErrorMessage
onRetry={() => setRetry((retry) => retry + 1)}
/>
}
>
<CheckoutRedirectButton
organizationUid={organization.uuid}
stripePriceId={props.stripePriceId}
recommended={props.recommended}
onCheckoutCreated={setClientSecret}
>
<Trans
i18nKey={'subscription:checkout'}
defaults={'Checkout'}
/>
</CheckoutRedirectButton>
</ErrorBoundary>
);
}}
/>
<If condition={customerId}>
<div className={'flex flex-col space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</If>
</div>
</div>
);
};
export default PlanSelectionForm;
function NoPermissionsAlert() {
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:noPermissionsAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:noPermissionsAlertBody'} />
</AlertDescription>
</Alert>
);
}
function CheckoutErrorMessage({ onRetry }: { onRetry: () => void }) {
return (
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-medium text-red-500'}>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</span>
<Button onClick={onRetry} variant={'ghost'}>
<Trans i18nKey={'common:retry'} />
</Button>
</div>
);
}

View File

@@ -1,99 +0,0 @@
'use client';
import React from 'react';
import type { ReadonlyURLSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import Trans from '@/components/app/Trans';
enum SubscriptionStatusQueryParams {
Success = 'success',
Cancel = 'cancel',
Error = 'error',
}
function PlansStatusAlertContainer() {
const status = useSubscriptionStatus();
if (status === undefined) {
return null;
}
return <PlansStatusAlert status={status as SubscriptionStatusQueryParams} />;
}
export default PlansStatusAlertContainer;
function PlansStatusAlert({
status,
}: {
status: SubscriptionStatusQueryParams;
}) {
switch (status) {
case SubscriptionStatusQueryParams.Cancel:
return (
<Alert variant={'warning'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCanceledAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCanceledAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Error:
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'subscription:unknownErrorAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:unknownErrorAlert'} />
</AlertDescription>
</Alert>
);
case SubscriptionStatusQueryParams.Success:
return (
<Alert variant={'success'}>
<AlertTitle>
<Trans i18nKey={'subscription:checkOutCompletedAlertHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'subscription:checkOutCompletedAlert'} />
</AlertDescription>
</Alert>
);
}
}
function useSubscriptionStatus() {
const params = useSearchParams();
return getStatus(params);
}
function getStatus(params: ReadonlyURLSearchParams | null) {
if (!params) {
return;
}
const error = params.has(SubscriptionStatusQueryParams.Error);
const canceled = params.has(SubscriptionStatusQueryParams.Cancel);
const success = params.has(SubscriptionStatusQueryParams.Success);
if (canceled) {
return SubscriptionStatusQueryParams.Cancel;
} else if (success) {
return SubscriptionStatusQueryParams.Success;
} else if (error) {
return SubscriptionStatusQueryParams.Error;
}
}

View File

@@ -1,57 +0,0 @@
'use client';
import useCurrentOrganization from '@/lib/organizations/hooks/use-current-organization';
import If from '@/components/app/If';
import Trans from '@/components/app/Trans';
import BillingPortalRedirectButton from './billing-redirect-button';
import PlanSelectionForm from './plan-selection-form';
import SubscriptionCard from './subscription-card';
const PlansContainer: React.FC = () => {
const organization = useCurrentOrganization();
if (!organization) {
return null;
}
const customerId = organization.subscription?.customerId;
const subscription = organization.subscription?.data;
if (!subscription) {
return (
<PlanSelectionForm customerId={customerId} organization={organization} />
);
}
return (
<div className={'flex flex-col space-y-4'}>
<div>
<div
className={'w-full divide-y rounded-xl border lg:w-9/12 xl:w-6/12'}
>
<div className={'p-6'}>
<SubscriptionCard subscription={subscription} />
</div>
<If condition={customerId}>
<div className={'flex justify-end p-6'}>
<div className={'flex flex-col items-end space-y-2'}>
<BillingPortalRedirectButton customerId={customerId as string}>
<Trans i18nKey={'subscription:manageBilling'} />
</BillingPortalRedirectButton>
<span className={'text-xs text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:manageBillingDescription'} />
</span>
</div>
</div>
</If>
</div>
</div>
</div>
);
};
export default PlansContainer;

View File

@@ -1,141 +0,0 @@
import React, { useMemo } from 'react';
import Heading from '@/components/ui/heading';
import { CheckCircleIcon, XCircleIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import SubscriptionStatusBadge from '~/(dashboard)/home/[account]/(components)/organizations/SubscriptionStatusBadge';
import pricingConfig from '@/config/pricing.config';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import If from '@/components/app/If';
import PricingTable from '@/components/app/PricingTable';
import Trans from '@/components/app/Trans';
import SubscriptionStatusAlert from './subscription-status-alert';
const SubscriptionCard: React.FC<{
subscription: OrganizationSubscription;
}> = ({ subscription }) => {
const details = useSubscriptionDetails(subscription.priceId);
const cancelAtPeriodEnd = subscription.cancelAtPeriodEnd;
const isActive = subscription.status === 'active';
const language = getI18n().language;
const dates = useMemo(() => {
const endDate = new Date(subscription.periodEndsAt);
const trialEndDate =
subscription.trialEndsAt && new Date(subscription.trialEndsAt);
return {
endDate: endDate.toLocaleDateString(language),
trialEndDate: trialEndDate
? trialEndDate.toLocaleDateString(language)
: null,
};
}, [language, subscription]);
if (!details) {
return null;
}
return (
<div
className={'flex space-x-2'}
data-test={'subscription-card'}
data-test-status={subscription.status}
>
<div className={'flex w-9/12 flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-4'}>
<Heading level={4}>
<span data-test={'subscription-name'}>
{details.product.name}
</span>
</Heading>
<div>
<SubscriptionStatusBadge subscription={subscription} />
</div>
</div>
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
{details.product.description}
</span>
</div>
<If condition={isActive}>
<RenewStatusDescription
dates={dates}
cancelAtPeriodEnd={cancelAtPeriodEnd}
/>
</If>
<SubscriptionStatusAlert subscription={subscription} values={dates} />
</div>
<div className={'w-3/12'}>
<span className={'flex items-center justify-end space-x-1'}>
<PricingTable.Price>{details.plan.price}</PricingTable.Price>
<span className={'lowercase text-gray-500 dark:text-gray-400'}>
/{details.plan.name}
</span>
</span>
</div>
</div>
);
};
function RenewStatusDescription(
props: React.PropsWithChildren<{
cancelAtPeriodEnd: boolean;
dates: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
return (
<span className={'flex items-center space-x-1.5 text-sm'}>
<If condition={props.cancelAtPeriodEnd}>
<XCircleIcon className={'h-5 text-yellow-700'} />
<span>
<Trans
i18nKey={'subscription:cancelAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
<If condition={!props.cancelAtPeriodEnd}>
<CheckCircleIcon className={'h-5 text-green-700'} />
<span>
<Trans
i18nKey={'subscription:renewAtPeriodEndDescription'}
values={props.dates}
/>
</span>
</If>
</span>
);
}
function useSubscriptionDetails(priceId: string) {
const products = pricingConfig.products;
return useMemo(() => {
for (const product of products) {
for (const plan of product.plans) {
if (plan.stripePriceId === priceId) {
return { plan, product };
}
}
}
}, [products, priceId]);
}
export default SubscriptionCard;

View File

@@ -1,68 +0,0 @@
import classNames from 'clsx';
import type { OrganizationSubscription } from '@/lib/organizations/types/organization-subscription';
import Trans from '@/components/app/Trans';
function SubscriptionStatusAlert(
props: React.PropsWithChildren<{
subscription: OrganizationSubscription;
values: {
endDate: string;
trialEndDate: string | null;
};
}>,
) {
const status = props.subscription.status;
let message = '';
let type: 'success' | 'error' | 'warn';
switch (status) {
case 'active':
message = 'subscription:status.active.description';
type = 'success';
break;
case 'trialing':
message = 'subscription:status.trialing.description';
type = 'success';
break;
case 'canceled':
message = 'subscription:status.canceled.description';
type = 'warn';
break;
case 'incomplete':
message = 'subscription:status.incomplete.description';
type = 'warn';
break;
case 'incomplete_expired':
message = 'subscription:status.incomplete_expired.description';
type = 'error';
break;
case 'unpaid':
message = 'subscription:status.unpaid.description';
type = 'error';
break;
case 'past_due':
message = 'subscription:status.past_due.description';
type = 'error';
break;
default:
return null;
}
return (
<span
className={classNames('text-sm', {
'text-orange-700 dark:text-gray-400': type === 'warn',
'text-red-700 dark:text-red-400': type === 'error',
'text-green-700 dark:text-green-400': type === 'success',
})}
>
<Trans i18nKey={message} values={props.values} />
</span>
);
}
export default SubscriptionStatusAlert;

View File

@@ -1,34 +0,0 @@
import Heading from '@/components/ui/heading';
import { withI18n } from '@packages/i18n/with-i18n';
import Trans from '@/components/app/Trans';
import PlansStatusAlertContainer from './components/plan-status-alert-container';
import PlansContainer from './components/plans-container';
export const metadata = {
title: 'Subscription',
};
const SubscriptionSettingsPage = () => {
return (
<div className={'flex w-full flex-col space-y-4'}>
<div className={'flex flex-col space-y-1 px-2'}>
<Heading level={4}>
<Trans i18nKey={'common:subscriptionSettingsTabLabel'} />
</Heading>
<span className={'text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'subscription:subscriptionTabSubheading'} />
</span>
</div>
<PlansStatusAlertContainer />
<PlansContainer />
</div>
);
};
export default withI18n(SubscriptionSettingsPage);

View File

@@ -4,12 +4,13 @@ import Link from 'next/link';
import { CheckIcon, ChevronRightIcon } from 'lucide-react'; import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import type { Stripe } from 'stripe'; import type { Stripe } from 'stripe';
import pathsConfig from '~/config/paths.config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
/** /**
* Retrieves the session status for a Stripe checkout session. * Retrieves the session status for a Stripe checkout session.
* Since we should only arrive here for a successful checkout, we only check * Since we should only arrive here for a successful checkout, we only check

View File

@@ -1,12 +1,12 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import requireSession from '@/lib/user/require-session';
import { withI18n } from '@packages/i18n/with-i18n'; import { withI18n } from '@packages/i18n/with-i18n';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import createStripeClient from '@kit/stripe/get-stripe'; import createStripeClient from '@kit/stripe/get-stripe';
import requireSession from '@/lib/user/require-session';
import { BillingSessionStatus } from './components/billing-session-status'; import { BillingSessionStatus } from './components/billing-session-status';
import RecoverCheckout from './components/recover-checkout'; import RecoverCheckout from './components/recover-checkout';

View File

@@ -2,11 +2,11 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { AccountSelector } from '@kit/accounts/account-selector';
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 { AccountSelector } from '@kit/accounts/account-selector';
const features = { const features = {
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts, enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation, enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,

View File

@@ -2,13 +2,13 @@ import { use } from 'react';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector'; import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector';
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown'; import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config'; import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
export function HomeSidebar() { export function HomeSidebar() {
const collapsed = getSidebarCollapsed(); const collapsed = getSidebarCollapsed();
const accounts = use(loadUserAccounts()); const accounts = use(loadUserAccounts());

View File

@@ -1,11 +1,11 @@
'use client'; 'use client';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session'; import { useUserSession } from '@kit/supabase/hooks/use-user-session';
import pathsConfig from '~/config/paths.config';
export function ProfileDropdownContainer(props: { collapsed: boolean }) { export function ProfileDropdownContainer(props: { collapsed: boolean }) {
const userSession = useUserSession(); const userSession = useUserSession();
const signOut = useSignOut(); const signOut = useSignOut();

View File

@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import Script from 'next/script'; import Script from 'next/script';
import { allPosts } from 'contentlayer/generated'; import { allPosts } from 'contentlayer/generated';
import appConfig from '~/config/app.config'; import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';

View File

@@ -1,10 +1,11 @@
import type { Post } from 'contentlayer/generated'; import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
const PostHeader: React.FC<{ const PostHeader: React.FC<{
post: Post; post: Post;
}> = ({ post }) => { }> = ({ post }) => {

View File

@@ -1,11 +1,12 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Post } from 'contentlayer/generated'; import type { Post } from 'contentlayer/generated';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
type Props = { type Props = {
post: Post; post: Post;
preloadImage?: boolean; preloadImage?: boolean;

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { allPosts } from 'contentlayer/generated'; import { allPosts } from 'contentlayer/generated';
import PostPreview from '~/(marketing)/blog/components/post-preview'; import PostPreview from '~/(marketing)/blog/components/post-preview';
import { SitePageHeader } from '~/(marketing)/components/site-page-header'; import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import appConfig from '~/config/app.config'; import appConfig from '~/config/app.config';

View File

@@ -3,13 +3,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
import pathsConfig from '~/config/paths.config';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUserSession } from '@kit/supabase/hooks/use-user-session'; import { useUserSession } from '@kit/supabase/hooks/use-user-session';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import pathsConfig from '~/config/paths.config';
export function SiteHeaderAccountSection() { export function SiteHeaderAccountSection() {
const signOut = useSignOut(); const signOut = useSignOut();
const userSession = useUserSession(); const userSession = useUserSession();

View File

@@ -4,15 +4,16 @@ import { notFound } from 'next/navigation';
import { allDocumentationPages } from 'contentlayer/generated'; import { allDocumentationPages } from 'contentlayer/generated';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
import { SitePageHeader } from '~/(marketing)/components/site-page-header'; import { SitePageHeader } from '~/(marketing)/components/site-page-header';
import { DocsCards } from '~/(marketing)/docs/components/docs-cards'; import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link'; import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree'; import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { If } from '@kit/ui/if';
import { Mdx } from '@kit/ui/mdx';
const getPageBySlug = cache((slug: string) => { const getPageBySlug = cache((slug: string) => {
return allDocumentationPages.find((post) => post.resolvedPath === slug); return allDocumentationPages.find((post) => post.resolvedPath === slug);
}); });

View File

@@ -1,4 +1,5 @@
import { allDocumentationPages } from 'contentlayer/generated'; import { allDocumentationPages } from 'contentlayer/generated';
import appConfig from '~/config/app.config'; import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';

View File

@@ -1,4 +1,5 @@
import { ChevronDownIcon } from 'lucide-react'; import { ChevronDownIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { SitePageHeader } from '../components/site-page-header'; import { SitePageHeader } from '../components/site-page-header';

View File

@@ -2,11 +2,12 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { withI18n } from '~/lib/i18n/with-i18n';
function Home() { function Home() {
return ( return (
<div className={'flex flex-col space-y-16'}> <div className={'flex flex-col space-y-16'}>

View File

@@ -1,9 +1,9 @@
import { PricingTable } from '@kit/billing/components/pricing-table';
import billingConfig from '~/config/billing.config'; import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { PricingTable } from '@kit/billing/components/pricing-table';
import { SitePageHeader } from '../components/site-page-header'; import { SitePageHeader } from '../components/site-page-header';
export const metadata = { export const metadata = {

View File

@@ -1,9 +1,9 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
export function withAdminSession<Args extends unknown[], Response>( export function withAdminSession<Args extends unknown[], Response>(
fn: (...params: Args) => Response, fn: (...params: Args) => Response,
) { ) {

View File

@@ -3,11 +3,11 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { withAdminSession } from '~/admin/lib/actions-utils';
const getClient = () => getSupabaseServerActionClient({ admin: true }); const getClient = () => getSupabaseServerActionClient({ admin: true });
export const deleteOrganizationAction = withAdminSession( export const deleteOrganizationAction = withAdminSession(

View File

@@ -4,6 +4,8 @@ import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type Organization from '@/lib/organizations/types/organization';
import useCsrfToken from '@kit/hooks/use-csrf-token'; import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -15,8 +17,6 @@ import {
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import type Organization from '@/lib/organizations/types/organization';
import { deleteOrganizationAction } from '../actions.server'; import { deleteOrganizationAction } from '../actions.server';
function DeleteOrganizationModal({ function DeleteOrganizationModal({

View File

@@ -1,7 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { getOrganizationByUid } from '@/lib/organizations/database/queries'; import { getOrganizationByUid } from '@/lib/organizations/database/queries';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard'; import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
import DeleteOrganizationModal from '../components/DeleteOrganizationModal'; import DeleteOrganizationModal from '../components/DeleteOrganizationModal';

View File

@@ -3,6 +3,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { DataTable } from '@/components/app/DataTable';
import type Membership from '@/lib/organizations/types/membership';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisVerticalIcon } from 'lucide-react'; import { EllipsisVerticalIcon } from 'lucide-react';
@@ -15,10 +17,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import type Membership from '@/lib/organizations/types/membership';
import { DataTable } from '@/components/app/DataTable';
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge'; import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
type Data = { type Data = {

View File

@@ -2,15 +2,13 @@ import { use } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
import AdminHeader from '@packages/admin/components/AdminHeader'; import AdminHeader from '@packages/admin/components/AdminHeader';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import getPageFromQueryParams from '../../../utils/get-page-from-query-param'; import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
import { getMembershipsByOrganizationUid } from '../../queries'; import { getMembershipsByOrganizationUid } from '../../queries';
import OrganizationsMembersTable from './components/OrganizationsMembersTable'; import OrganizationsMembersTable from './components/OrganizationsMembersTable';

View File

@@ -1,198 +0,0 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import pricingConfig from '@/config/pricing.config';
import { DataTable } from '@/components/app/DataTable';
import SubscriptionStatusBadge from '../../../(app)/[account]/components/organizations/SubscriptionStatusBadge';
import type { getOrganizations } from '../queries';
type Response = Awaited<ReturnType<typeof getOrganizations>>;
type Organizations = Response['organizations'];
const columns: ColumnDef<Organizations[0]>[] = [
{
header: 'ID',
accessorKey: 'id',
id: 'id',
size: 10,
},
{
header: 'UUID',
accessorKey: 'uuid',
id: 'uuid',
size: 200,
},
{
header: 'Name',
accessorKey: 'name',
id: 'name',
},
{
header: 'Subscription',
id: 'subscription',
cell: ({ row }) => {
const priceId = row.original?.subscription?.data?.priceId;
const plan = pricingConfig.products.find((product) => {
return product.plans.some((plan) => plan.stripePriceId === priceId);
});
if (plan) {
const price = plan.plans.find((plan) => plan.stripePriceId === priceId);
if (!price) {
return 'Unknown Price';
}
return `${plan.name} - ${price.name}`;
}
return '-';
},
},
{
header: 'Subscription Status',
id: 'subscription-status',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
if (!subscription) {
return '-';
}
return <SubscriptionStatusBadge subscription={subscription} />;
},
},
{
header: 'Subscription Period',
id: 'subscription-period',
cell: ({ row }) => {
const subscription = row.original?.subscription?.data;
const i18n = getI18n();
const language = i18n.language ?? 'en';
if (!subscription) {
return '-';
}
const canceled = subscription.cancelAtPeriodEnd;
const date = subscription.periodEndsAt;
const formattedDate = new Date(date).toLocaleDateString(language);
return canceled ? (
<span className={'text-orange-500'}>Stops on {formattedDate}</span>
) : (
<span className={'text-green-500'}>Renews on {formattedDate}</span>
);
},
},
{
header: 'Members',
id: 'members',
cell: ({ row }) => {
const memberships = row.original.memberships.filter((item) => !item.code);
const invites = row.original.memberships.length - memberships.length;
const uid = row.original.uuid;
const length = memberships.length;
return (
<Link
data-test={'organization-members-link'}
href={`organizations/${uid}/members`}
className={'cursor-pointer hover:underline'}
>
{length} member{length === 1 ? '' : 's'}{' '}
{invites ? `(${invites} invites)` : ''}
</Link>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const organization = row.original;
const uid = organization.uuid;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(uid)}
>
Copy UUID
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/organizations/${uid}/members`}>
View Members
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={'text-red-500'}
href={`/admin/organizations/${uid}/delete`}
>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function OrganizationsTable({
organizations,
pageCount,
perPage,
page,
}: React.PropsWithChildren<{
organizations: Organizations;
pageCount: number;
perPage: number;
page: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-organizations-table',
}}
pageSize={perPage}
pageIndex={page - 1}
pageCount={pageCount}
columns={columns}
data={organizations}
/>
);
}
export default OrganizationsTable;

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page'; import { PageBody } from '@/components/app/Page';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
function OrganizationsAdminPageError() { function OrganizationsAdminPageError() {
return ( return (
<PageBody> <PageBody>

View File

@@ -1,3 +1,5 @@
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import AdminGuard from '@/packages/admin/components/AdminGuard'; import AdminGuard from '@/packages/admin/components/AdminGuard';
import AdminHeader from '@/packages/admin/components/AdminHeader'; import AdminHeader from '@/packages/admin/components/AdminHeader';
@@ -5,10 +7,6 @@ import getSupabaseServerComponentClient from '@packages/supabase/server-componen
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import OrganizationsTable from './components/OrganizationsTable'; import OrganizationsTable from './components/OrganizationsTable';
import { getOrganizations } from './queries'; import { getOrganizations } from './queries';

View File

@@ -1,7 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/database.types'; import type { Database } from '@/database.types';
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables'; import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
import type { UserOrganizationData } from '@/lib/organizations/database/queries'; import type { UserOrganizationData } from '@/lib/organizations/database/queries';
import type MembershipRole from '@/lib/organizations/types/membership-role'; import type MembershipRole from '@/lib/organizations/types/membership-role';

View File

@@ -1,8 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config'; import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page'; import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import AdminDashboard from '../../packages/admin/components/AdminDashboard'; import AdminDashboard from '../../packages/admin/components/AdminDashboard';
import AdminGuard from '../../packages/admin/components/AdminGuard'; import AdminGuard from '../../packages/admin/components/AdminGuard';

View File

@@ -3,11 +3,11 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { withAdminSession } from '~/admin/lib/actions-utils';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { withAdminSession } from '~/admin/lib/actions-utils';
const getClient = () => getSupabaseServerActionClient({ admin: true }); const getClient = () => getSupabaseServerActionClient({ admin: true });
export const banUser = withAdminSession(async ({ userId }) => { export const banUser = withAdminSession(async ({ userId }) => {

View File

@@ -8,6 +8,8 @@ import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js'; import type { User } from '@supabase/gotrue-js';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import useCsrfToken from '@kit/hooks/use-csrf-token'; import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -20,8 +22,6 @@ import {
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import ErrorBoundary from '@/components/app/ErrorBoundary';
import { banUser } from '../actions.server'; import { banUser } from '../actions.server';
function BanUserModal({ function BanUserModal({

View File

@@ -4,10 +4,10 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import useSupabase from '@kit/hooks/use-supabase';
import Spinner from '@/components/app/Spinner'; import Spinner from '@/components/app/Spinner';
import useSupabase from '@kit/hooks/use-supabase';
function ImpersonateUserAuthSetter({ function ImpersonateUserAuthSetter({
tokens, tokens,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{

View File

@@ -6,6 +6,9 @@ import { useRouter } from 'next/navigation';
import type { User } from '@supabase/gotrue-js'; import type { User } from '@supabase/gotrue-js';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import useCsrfToken from '@kit/hooks/use-csrf-token'; import useCsrfToken from '@kit/hooks/use-csrf-token';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -16,9 +19,6 @@ import {
DialogTitle, DialogTitle,
} from '@kit/ui/dialog'; } from '@kit/ui/dialog';
import If from '@/components/app/If';
import LoadingOverlay from '@/components/app/LoadingOverlay';
import { impersonateUser } from '../actions.server'; import { impersonateUser } from '../actions.server';
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter'; import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import If from '@/components/app/If';
import { EllipsisVerticalIcon } from 'lucide-react'; import { EllipsisVerticalIcon } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -12,8 +13,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
import If from '@/components/app/If';
function UserActionsDropdown({ function UserActionsDropdown({
uid, uid,
isBanned, isBanned,

View File

@@ -1,5 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { PageBody } from '@/components/app/Page';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
@@ -17,12 +20,6 @@ import {
TableRow, TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
import configuration from '@/config/app.config';
import type MembershipRole from '@/lib/organizations/types/membership-role';
import { PageBody } from '@/components/app/Page';
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge'; import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
import AdminGuard from '../../../../packages/admin/components/AdminGuard'; import AdminGuard from '../../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../../packages/admin/components/AdminHeader'; import AdminHeader from '../../../../packages/admin/components/AdminHeader';

View File

@@ -1,239 +0,0 @@
'use client';
import Link from 'next/link';
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisIcon } from 'lucide-react';
import { getI18n } from 'react-i18next';
import type UserData from '@kit/session/types/user-data';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kit/ui/tooltip';
import { DataTable } from '@/components/app/DataTable';
import If from '@/components/app/If';
type UserRow = {
id: string;
email: string | undefined;
phone: string | undefined;
createdAt: string;
updatedAt: string | undefined;
lastSignInAt: string | undefined;
banDuration: string | undefined;
data: UserData;
};
const columns: ColumnDef<UserRow>[] = [
{
header: '',
id: 'avatar',
size: 10,
cell: ({ row }) => {
const user = row.original;
const data = user.data;
const displayName = data?.displayName;
const photoUrl = data?.photoUrl;
const displayText = displayName ?? user.email ?? user.phone ?? '';
return (
<Tooltip>
<TooltipTrigger>
<Avatar>
{photoUrl ? <AvatarImage src={photoUrl} /> : null}
<AvatarFallback>{displayText[0]}</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{displayText}</TooltipContent>
</Tooltip>
);
},
},
{
header: 'ID',
id: 'id',
size: 30,
cell: ({ row }) => {
const id = row.original.id;
return (
<Link className={'hover:underline'} href={`/admin/users/${id}`}>
{id}
</Link>
);
},
},
{
header: 'Email',
id: 'email',
cell: ({ row }) => {
const email = row.original.email;
return (
<span title={email} className={'block max-w-full truncate'}>
{email}
</span>
);
},
},
{
header: 'Name',
size: 50,
id: 'displayName',
cell: ({ row }) => {
return row.original.data?.displayName ?? '';
},
},
{
header: 'Created at',
id: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
const i18n = getI18n();
const language = i18n.language ?? 'en';
const createdAtLabel = date.toLocaleDateString(language);
return <span>{createdAtLabel}</span>;
},
},
{
header: 'Last sign in',
id: 'lastSignInAt',
cell: ({ row }) => {
const lastSignInAt = row.original.lastSignInAt;
if (!lastSignInAt) {
return <span>-</span>;
}
const date = new Date(lastSignInAt);
return <span suppressHydrationWarning>{date.toLocaleString()}</span>;
},
},
{
header: 'Status',
id: 'status',
cell: ({ row }) => {
const banDuration = row.original.banDuration;
if (!banDuration || banDuration === 'none') {
return (
<Badge className={'inline-flex'} color={'success'}>
Active
</Badge>
);
}
return (
<Badge className={'inline-flex'} color={'error'}>
Banned
</Badge>
);
},
},
{
header: '',
id: 'actions',
cell: ({ row }) => {
const user = row.original;
const banDuration = row.original.banDuration;
const isBanned = banDuration && banDuration !== 'none';
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={'icon'}>
<span className="sr-only">Open menu</span>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(user.id)}
>
Copy user ID
</DropdownMenuItem>
<If condition={!isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/impersonate`}>
Impersonate User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/5'
}
href={`/admin/users/${user.id}/ban`}
>
Ban User
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
className={
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/5'
}
href={`/admin/users/${user.id}/delete`}
>
Delete User
</Link>
</DropdownMenuItem>
</If>
<If condition={isBanned}>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/reactivate`}>
Reactivate User
</Link>
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
function UsersTable({
users,
page,
pageCount,
perPage,
}: React.PropsWithChildren<{
users: UserRow[];
pageCount: number;
page: number;
perPage: number;
}>) {
return (
<DataTable
tableProps={{
'data-test': 'admin-users-table',
}}
pageIndex={page - 1}
pageSize={perPage}
pageCount={pageCount}
data={users}
columns={columns}
/>
);
}
export default UsersTable;

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { PageBody } from '@/components/app/Page'; import { PageBody } from '@/components/app/Page';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
function UsersAdminPageError() { function UsersAdminPageError() {
return ( return (
<PageBody> <PageBody>

View File

@@ -1,15 +1,13 @@
import { PageBody } from '@/components/app/Page';
import appConfig from '@/config/app.config';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client'; import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import type UserData from '@kit/session/types/user-data'; import type UserData from '@kit/session/types/user-data';
import appConfig from '@/config/app.config';
import { PageBody } from '@/components/app/Page';
import AdminGuard from '../../../packages/admin/components/AdminGuard'; import AdminGuard from '../../../packages/admin/components/AdminGuard';
import AdminHeader from '../../../packages/admin/components/AdminHeader'; import AdminHeader from '../../../packages/admin/components/AdminHeader';
import getPageFromQueryParams from '../utils/get-page-from-query-param'; import getPageFromQueryParams from '../utils/get-page-from-query-param';
import UsersTable from './components/UsersTable';
import { getUsers } from './queries'; import { getUsers } from './queries';
interface UsersAdminPageProps { interface UsersAdminPageProps {
@@ -32,14 +30,7 @@ async function UsersAdminPage({ searchParams }: UsersAdminPageProps) {
<div className={'flex flex-1 flex-col'}> <div className={'flex flex-1 flex-col'}>
<AdminHeader>Users</AdminHeader> <AdminHeader>Users</AdminHeader>
<PageBody> <PageBody></PageBody>
<UsersTable
users={users}
page={page}
pageCount={pageCount}
perPage={perPage}
/>
</PageBody>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
import { USERS_TABLE } from '@/lib/db-tables'; import { USERS_TABLE } from '@/lib/db-tables';
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
export async function getUsers(ids: string[]) { export async function getUsers(ids: string[]) {
const client = getSupabaseServerComponentClient({ admin: true }); const client = getSupabaseServerComponentClient({ admin: true });

View File

@@ -0,0 +1,50 @@
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import billingConfig from '~/config/billing.config';
/**
* @description Handle the webhooks from Stripe related to checkouts
*/
export async function POST(request: Request) {
const client = getSupabaseRouteHandlerClient();
// we can infer the provider from the billing config or the request
// for simplicity, we'll use the billing config for now
// TODO: use dynamic provider from request?
const provider = billingConfig.provider;
Logger.info(
{
name: 'billing',
provider,
},
`Received billing webhook. Processing...`,
);
const service = await getBillingEventHandlerService(client, provider);
try {
await service.handleWebhookEvent(request);
Logger.info(
{
name: 'billing',
},
`Successfully processed billing webhook`,
);
return new Response('OK', { status: 200 });
} catch (e) {
Logger.error(
{
name: 'billing',
error: e,
},
`Failed to process billing webhook`,
);
return new Response('Error', { status: 500 });
}
}

View File

@@ -1,167 +0,0 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Stripe } from 'stripe';
import getSupabaseRouteHandlerClient from '@packages/supabase/route-handler-client';
import { Logger } from '@kit/logger';
import createStripeClient from '@kit/stripe/get-stripe';
import StripeWebhooks from '@kit/stripe/stripe-webhooks.enum';
import { setOrganizationSubscriptionData } from '@/lib/organizations/database/mutations';
import {
addSubscription,
deleteSubscription,
updateSubscriptionById,
} from '@/lib/subscriptions/mutations';
const STRIPE_SIGNATURE_HEADER = 'stripe-signature';
const webhookSecretKey = process.env.STRIPE_WEBHOOK_SECRET!;
const logName = 'stripe-webhook';
/**
* @description Handle the webhooks from Stripe related to checkouts
*/
export async function POST(request: Request) {
const signature = headers().get(STRIPE_SIGNATURE_HEADER);
Logger.info(`[Stripe] Received Stripe Webhook`);
if (!webhookSecretKey) {
Logger.error(
{
name: logName,
},
`The variable STRIPE_WEBHOOK_SECRET is unset. Please add the STRIPE_WEBHOOK_SECRET environment variable`,
);
return new Response(null, {
status: 500,
});
}
// verify signature header is not missing
if (!signature) {
return new Response('Invalid signature', {
status: 400,
});
}
const rawBody = await request.text();
const stripe = await createStripeClient();
// create an Admin client to write to the subscriptions table
const client = getSupabaseRouteHandlerClient({
admin: true,
});
try {
// build the event from the raw body and signature using Stripe
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecretKey,
);
Logger.info(
{
name: logName,
type: event.type,
},
`Processing Stripe Webhook...`,
);
switch (event.type) {
case StripeWebhooks.Completed: {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = session.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
await onCheckoutCompleted(client, session, subscription);
break;
}
case StripeWebhooks.SubscriptionDeleted: {
const subscription = event.data.object as Stripe.Subscription;
await deleteSubscription(client, subscription.id);
break;
}
case StripeWebhooks.SubscriptionUpdated: {
const subscription = event.data.object as Stripe.Subscription;
await updateSubscriptionById(client, subscription);
break;
}
}
return NextResponse.json({ success: true });
} catch (error) {
Logger.error(
{
error,
name: logName,
},
`Webhook handling failed`,
);
return new Response(null, {
status: 500,
});
}
}
/**
* @description When the checkout is completed, we store the order. The
* subscription is only activated if the order was paid successfully.
* Otherwise, we have to wait for a further webhook
*/
async function onCheckoutCompleted(
client: SupabaseClient,
session: Stripe.Checkout.Session,
subscription: Stripe.Subscription,
) {
const organizationUid = getOrganizationUidFromClientReference(session);
const customerId = session.customer as string;
// build organization subscription and set on the organization document
// we add just enough data in the DB, so we do not query
// Stripe for every bit of data
// if you need your DB record to contain further data
// add it to {@link buildOrganizationSubscription}
const { error, data } = await addSubscription(client, subscription);
if (error) {
return Promise.reject(
`Failed to add subscription to the database: ${error}`,
);
}
return setOrganizationSubscriptionData(client, {
organizationUid,
customerId,
subscriptionId: data.id,
});
}
/**
* @name getOrganizationUidFromClientReference
* @description Get the organization UUID from the client reference ID
* @param session
*/
function getOrganizationUidFromClientReference(
session: Stripe.Checkout.Session,
) {
return session.client_reference_id!;
}

View File

@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import pathsConfig from '~/config/paths.config';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client'; import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import pathsConfig from '~/config/paths.config';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url); const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams; const searchParams = requestUrl.searchParams;

View File

@@ -1,7 +1,7 @@
import { AppLogo } from '~/components/app-logo';
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>; return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
} }

View File

@@ -1,14 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset'; import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();

View File

@@ -1,15 +1,15 @@
import Link from 'next/link'; import Link from 'next/link';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignInMethodsContainer } from '@kit/auth/sign-in'; import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();

View File

@@ -1,15 +1,15 @@
import Link from 'next/link'; import Link from 'next/link';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();

View File

@@ -1,13 +1,13 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa'; import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();

View File

@@ -3,12 +3,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from 'lucide-react'; import { ArrowLeftIcon } from 'lucide-react';
import { SiteHeader } from '~/(marketing)/components/site-header';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/components/site-header';
const ErrorPage = () => { const ErrorPage = () => {
return ( return (
<div className={'flex h-screen flex-1 flex-col'}> <div className={'flex h-screen flex-1 flex-col'}>

View File

@@ -2,8 +2,6 @@
import { useCallback, useState, useTransition } from 'react'; import { useCallback, useState, useTransition } from 'react';
import authConfig from '~/config/auth.config';
import { EmailOtpContainer } from '@kit/auth/src/components/email-otp-container'; import { EmailOtpContainer } from '@kit/auth/src/components/email-otp-container';
import { OauthProviders } from '@kit/auth/src/components/oauth-providers'; import { OauthProviders } from '@kit/auth/src/components/oauth-providers';
import { PasswordSignInContainer } from '@kit/auth/src/components/password-sign-in-container'; import { PasswordSignInContainer } from '@kit/auth/src/components/password-sign-in-container';
@@ -14,6 +12,8 @@ import { If } from '@kit/ui/if';
import { LoadingOverlay } from '@kit/ui/loading-overlay'; import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
enum Mode { enum Mode {
SignUp, SignUp,
SignIn, SignIn,

View File

@@ -1,7 +1,7 @@
import { AppLogo } from '~/components/app-logo';
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
function InvitePageLayout({ children }: React.PropsWithChildren) { function InvitePageLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>; return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
} }

View File

@@ -1,13 +1,13 @@
import { Inter as SansFont } from 'next/font/google'; import { Inter as SansFont } from 'next/font/google';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
import { RootProviders } from '~/components/root-providers'; import { RootProviders } from '~/components/root-providers';
import appConfig from '~/config/app.config'; import appConfig from '~/config/app.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils';
import '../styles/globals.css'; import '../styles/globals.css';
const sans = SansFont({ const sans = SansFont({
@@ -51,7 +51,7 @@ function getClassName() {
export const metadata = { export const metadata = {
title: appConfig.name, title: appConfig.name,
description: appConfig.description, description: appConfig.description,
metadataBase: new URL(appConfig.url!), metadataBase: new URL(appConfig.url),
openGraph: { openGraph: {
url: appConfig.url, url: appConfig.url,
siteName: appConfig.name, siteName: appConfig.name,

View File

@@ -1,14 +1,15 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@radix-ui/react-icons'; import { ArrowLeftIcon } from '@radix-ui/react-icons';
import { SiteHeader } from '~/(marketing)/components/site-header';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/components/site-header';
import appConfig from '~/config/app.config';
import { withI18n } from '~/lib/i18n/with-i18n';
export const metadata = { export const metadata = {
title: `Page not found - ${appConfig.name}`, title: `Page not found - ${appConfig.name}`,
}; };

View File

@@ -1,10 +1,9 @@
import appConfig from '@/config/app.config';
import { invariant } from '@epic-web/invariant'; import { invariant } from '@epic-web/invariant';
import { allDocumentationPages, allPosts } from 'contentlayer/generated'; import { allDocumentationPages, allPosts } from 'contentlayer/generated';
import { getServerSideSitemap } from 'next-sitemap'; import { getServerSideSitemap } from 'next-sitemap';
import { join } from 'path'; import { join } from 'path';
import appConfig from '@/config/app.config';
const siteUrl = appConfig.url; const siteUrl = appConfig.url;
export async function GET() { export async function GET() {

View File

@@ -1,12 +1,12 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import PasswordResetForm from '@kit/auth/src/components/password-reset-form'; import PasswordResetForm from '@kit/auth/src/components/password-reset-form';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import pathsConfig from '~/config/paths.config';
import { withI18n } from '~/lib/i18n/with-i18n';
async function PasswordResetPage() { async function PasswordResetPage() {
const client = getSupabaseServerComponentClient(); const client = getSupabaseServerComponentClient();
const user = await client.auth.getUser(); const user = await client.auth.getUser();

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import pathsConfig from '~/config/paths.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
import { I18nProvider } from '@kit/i18n/provider'; import { I18nProvider } from '@kit/i18n/provider';
import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener'; import { AuthChangeListener } from '@kit/supabase/components/auth-change-listener';
import pathsConfig from '~/config/paths.config';
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
export function RootProviders({ export function RootProviders({
lang, lang,
children, children,

View File

@@ -4,11 +4,12 @@ import {
SettingsIcon, SettingsIcon,
UsersIcon, UsersIcon,
} from 'lucide-react'; } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { SidebarConfigSchema } from '@kit/ui/sidebar-schema'; import { SidebarConfigSchema } from '@kit/ui/sidebar-schema';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4'; const iconClasses = 'w-4';
const routes = (account: string) => [ const routes = (account: string) => [

View File

@@ -1,9 +1,10 @@
import { CreditCardIcon, HomeIcon, UserIcon } from 'lucide-react'; import { CreditCardIcon, HomeIcon, UserIcon } from 'lucide-react';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { SidebarConfigSchema } from '@kit/ui/sidebar-schema'; import { SidebarConfigSchema } from '@kit/ui/sidebar-schema';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4'; const iconClasses = 'w-4';
const routes = [ const routes = [

View File

@@ -2,12 +2,13 @@ import type { NextRequest } from 'next/server';
import { NextResponse, URLPattern } from 'next/server'; import { NextResponse, URLPattern } from 'next/server';
import csrf from 'edge-csrf'; import csrf from 'edge-csrf';
import appConfig from '~/config/app.config';
import pathsConfig from '~/config/paths.config';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { createMiddlewareClient } from '@kit/supabase/middleware-client'; import { createMiddlewareClient } from '@kit/supabase/middleware-client';
import appConfig from '~/config/app.config';
import pathsConfig from '~/config/paths.config';
const CSRF_SECRET_COOKIE = 'csrfSecret'; const CSRF_SECRET_COOKIE = 'csrfSecret';
const NEXT_ACTION_HEADER = 'next-action'; const NEXT_ACTION_HEADER = 'next-action';

View File

@@ -1,3 +1,5 @@
import withBundleAnalyzer from '@next/bundle-analyzer';
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
reactStrictMode: true, reactStrictMode: true,
@@ -12,16 +14,17 @@ const config = {
'@kit/i18n', '@kit/i18n',
'@kit/mailers', '@kit/mailers',
'@kit/billing', '@kit/billing',
'@kit/billing-gateway' '@kit/billing-gateway',
], ],
pageExtensions: ['ts', 'tsx', 'mdx'], pageExtensions: ['ts', 'tsx'],
experimental: { experimental: {
mdxRs: true, mdxRs: true,
}, },
/** We already do linting and typechecking as separate tasks in CI */ /** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
}; };
export default config; export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(config);

View File

@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true pnpm run build",
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev",
@@ -57,6 +58,7 @@
"@kit/prettier-config": "^0.1.0", "@kit/prettier-config": "^0.1.0",
"@kit/tailwind-config": "^0.1.0", "@kit/tailwind-config": "^0.1.0",
"@kit/tsconfig": "^0.1.0", "@kit/tsconfig": "^0.1.0",
"@next/bundle-analyzer": "^14.1.4",
"@types/mdx": "^2.0.10", "@types/mdx": "^2.0.10",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",

View File

@@ -56,5 +56,9 @@
"label": "Member", "label": "Member",
"description": "Cannot invite members or change settings" "description": "Cannot invite members or change settings"
} }
},
"billingInterval": {
"month": "Billed monthly",
"year": "Billed yearly"
} }
} }

View File

@@ -16,20 +16,20 @@
"peerDependencies": { "peerDependencies": {
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zod": "^3.22.4" "zod": "^3.22.4",
},
"dependencies": {
"@kit/ui": "0.1.0", "@kit/ui": "0.1.0",
"@kit/stripe": "0.1.0", "@kit/stripe": "0.1.0",
"@kit/billing": "0.1.0", "@kit/billing": "0.1.0",
"@kit/supabase": "^0.1.0", "@kit/supabase": "^0.1.0",
"@kit/shared": "^0.1.0",
"lucide-react": "^0.361.0" "lucide-react": "^0.361.0"
}, },
"devDependencies": { "devDependencies": {
"@kit/prettier-config": "0.1.0", "@kit/prettier-config": "0.1.0",
"@kit/eslint-config": "0.2.0", "@kit/eslint-config": "0.2.0",
"@kit/tailwind-config": "0.1.0", "@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0" "@kit/tsconfig": "0.1.0",
"@supabase/supabase-js": "^2.39.8"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View File

@@ -22,6 +22,7 @@ import {
RadioGroupItemLabel, RadioGroupItemLabel,
} from '@kit/ui/radio-group'; } from '@kit/ui/radio-group';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
export function PlanPicker( export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
@@ -81,29 +82,39 @@ export function PlanPicker(
<FormControl> <FormControl>
<RadioGroup name={field.name} value={field.value}> <RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}>
{intervals.map((interval) => { {intervals.map((interval) => {
const selected = field.value === interval;
return ( return (
<div <label
key={interval} key={interval}
className={'flex items-center space-x-2'} className={cn(
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
{
['border-border']: selected,
['hover:bg-muted']: !selected,
},
)}
> >
<RadioGroupItem <RadioGroupItem
id={interval} id={interval}
value={interval} value={interval}
onClick={() => { onClick={() => {
form.setValue('planId', '');
form.setValue('interval', interval); form.setValue('interval', interval);
}} }}
/> />
<span className={'text-sm font-bold'}> <span className={'text-sm font-bold'}>
<Trans <Trans
i18nKey={`common.billingInterval.${interval}`} i18nKey={`common:billingInterval.${interval}`}
defaults={interval}
/> />
</span> </span>
</div> </label>
); );
})} })}
</div>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -130,7 +141,10 @@ export function PlanPicker(
} }
return ( return (
<RadioGroupItemLabel key={variant.id}> <RadioGroupItemLabel
selected={field.value === variant.id}
key={variant.id}
>
<RadioGroupItem <RadioGroupItem
id={variant.id} id={variant.id}
value={variant.id} value={variant.id}
@@ -144,9 +158,7 @@ export function PlanPicker(
> >
<Label <Label
htmlFor={variant.id} htmlFor={variant.id}
className={ className={'flex flex-col justify-center space-y-2'}
'flex flex-col justify-center space-y-1.5'
}
> >
<span className="font-bold">{item.name}</span> <span className="font-bold">{item.name}</span>

View File

@@ -1,2 +1,3 @@
export * from './billing-gateway-service'; export * from './services/billing-gateway/billing-gateway.service';
export * from './gateway-provider-factory'; export * from './services/billing-gateway/billing-gateway-provider-factory';
export * from './services/billing-event-handler/billing-gateway-provider-factory';

View File

@@ -0,0 +1,106 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { BillingWebhookHandlerService } from '@kit/billing';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export class BillingEventHandlerService {
constructor(
private readonly client: SupabaseClient<Database>,
private readonly strategy: BillingWebhookHandlerService,
) {}
async handleWebhookEvent(request: Request) {
const event = await this.strategy.verifyWebhookSignature(request);
if (!event) {
throw new Error('Invalid signature');
}
return this.strategy.handleWebhookEvent(event, {
onSubscriptionDeleted: async (subscriptionId: string) => {
// Handle the subscription deleted event
// here we delete the subscription from the database
Logger.info(
{
namespace: 'billing',
subscriptionId,
},
'Processing subscription deleted event',
);
const { error } = await this.client
.from('subscriptions')
.delete()
.match({ id: subscriptionId });
if (error) {
throw new Error('Failed to delete subscription');
}
Logger.info(
{
namespace: 'billing',
subscriptionId,
},
'Successfully deleted subscription',
);
},
onSubscriptionUpdated: async (subscription) => {
const ctx = {
namespace: 'billing',
subscriptionId: subscription.id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
};
Logger.info(ctx, 'Processing subscription updated event');
// Handle the subscription updated event
// here we update the subscription in the database
const { error } = await this.client
.from('subscriptions')
.update(subscription)
.match({ id: subscription.id });
if (error) {
Logger.error(
{
error,
...ctx,
},
'Failed to update subscription',
);
throw new Error('Failed to update subscription');
}
Logger.info(ctx, 'Successfully updated subscription');
},
onCheckoutSessionCompleted: async (subscription) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const ctx = {
namespace: 'billing',
subscriptionId: subscription.id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
};
Logger.info(ctx, 'Processing checkout session completed event...');
const { error } = await this.client.rpc('add_subscription', {
subscription,
});
if (error) {
Logger.error(ctx, 'Failed to add subscription');
throw new Error('Failed to add subscription');
}
Logger.info(ctx, 'Successfully added subscription');
},
});
}
}

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
import { BillingProvider, BillingWebhookHandlerService } from '@kit/billing';
export class BillingEventHandlerFactoryService {
static async GetProviderStrategy(
provider: z.infer<typeof BillingProvider>,
): Promise<BillingWebhookHandlerService> {
switch (provider) {
case 'stripe': {
const { StripeWebhookHandlerService } = await import('@kit/stripe');
return new StripeWebhookHandlerService();
}
case 'paddle': {
throw new Error('Paddle is not supported yet');
}
case 'lemon-squeezy': {
throw new Error('Lemon Squeezy is not supported yet');
}
default:
throw new Error(`Unsupported billing provider: ${provider as string}`);
}
}
}

View File

@@ -0,0 +1,20 @@
import { Database } from '@kit/supabase/database';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { BillingEventHandlerService } from './billing-event-handler.service';
import { BillingEventHandlerFactoryService } from './billing-gateway-factory.service';
/**
* @description This function retrieves the billing provider from the database and returns a
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
* defined in the host application.
*/
export async function getBillingEventHandlerService(
client: ReturnType<typeof getSupabaseServerActionClient>,
provider: Database['public']['Enums']['billing_provider'],
) {
const strategy =
await BillingEventHandlerFactoryService.GetProviderStrategy(provider);
return new BillingEventHandlerService(client, strategy);
}

View File

@@ -1,15 +1,13 @@
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { BillingGatewayService } from './billing-gateway-service'; import { BillingGatewayService } from './billing-gateway.service';
/** /**
* @description This function retrieves the billing provider from the database and returns a * @description This function retrieves the billing provider from the database and returns a
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions * new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
* defined in the host application. * defined in the host application.
* @param {ReturnType<typeof getSupabaseServerActionClient>} client - The Supabase server action client.
*
*/ */
export async function getGatewayProvider( export async function getBillingGatewayProvider(
client: ReturnType<typeof getSupabaseServerActionClient>, client: ReturnType<typeof getSupabaseServerActionClient>,
) { ) {
const provider = await getBillingProvider(client); const provider = await getBillingProvider(client);

View File

@@ -17,10 +17,9 @@
"peerDependencies": { "peerDependencies": {
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zod": "^3.22.4" "zod": "^3.22.4",
},
"dependencies": {
"@kit/ui": "0.1.0", "@kit/ui": "0.1.0",
"@kit/supabase": "0.1.0",
"lucide-react": "^0.361.0" "lucide-react": "^0.361.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,2 +1,3 @@
export * from './create-billing-schema'; export * from './create-billing-schema';
export * from './services/billing-strategy-provider.service'; export * from './services/billing-strategy-provider.service';
export * from './services/billing-webhook-handler.service';

View File

@@ -0,0 +1,32 @@
import { Database } from '@kit/supabase/database';
type SubscriptionObject = Database['public']['Tables']['subscriptions'];
type SubscriptionInsertParams = Omit<
SubscriptionObject['Insert'],
'billing_customer_id'
>;
type SubscriptionUpdateParams = SubscriptionObject['Update'];
/**
* Represents an abstract class for handling billing webhook events.
*/
export abstract class BillingWebhookHandlerService {
abstract verifyWebhookSignature(request: Request): Promise<unknown>;
abstract handleWebhookEvent(
event: unknown,
params: {
onCheckoutSessionCompleted: (
subscription: SubscriptionInsertParams,
) => Promise<unknown>;
onSubscriptionUpdated: (
subscription: SubscriptionUpdateParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
},
): Promise<unknown>;
}

View File

@@ -14,13 +14,6 @@
"./personal-account-settings": "./src/components/personal-account-settings/index.ts", "./personal-account-settings": "./src/components/personal-account-settings/index.ts",
"./hooks/*": "./src/hooks/*.ts" "./hooks/*": "./src/hooks/*.ts"
}, },
"dependencies": {
"@kit/supabase": "0.1.0",
"@kit/ui": "0.1.0",
"@kit/shared": "0.1.0",
"lucide-react": "^0.360.0",
"@radix-ui/react-icons": "^1.3.0"
},
"devDependencies": { "devDependencies": {
"@kit/eslint-config": "0.2.0", "@kit/eslint-config": "0.2.0",
"@kit/prettier-config": "0.1.0", "@kit/prettier-config": "0.1.0",
@@ -29,7 +22,12 @@
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"@kit/supabase": "0.1.0",
"@kit/ui": "0.1.0",
"@kit/shared": "0.1.0",
"lucide-react": "^0.360.0",
"@radix-ui/react-icons": "^1.3.0"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"eslintConfig": { "eslintConfig": {

View File

@@ -1,13 +1,11 @@
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/app/Page';
import pathsConfig from '@/config/paths.config';
import { ArrowLeftIcon } from 'lucide-react'; import { ArrowLeftIcon } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import pathsConfig from '@/config/paths.config';
import { PageHeader } from '@/components/app/Page';
function AdminHeader({ children }: React.PropsWithChildren) { function AdminHeader({ children }: React.PropsWithChildren) {
return ( return (
<PageHeader <PageHeader

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
import Logo from '@/components/app/Logo'; import Logo from '@/components/app/Logo';
import { Sidebar, SidebarContent, SidebarItem } from '@/components/app/Sidebar'; import { Sidebar, SidebarContent, SidebarItem } from '@/components/app/Sidebar';
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
function AdminSidebar() { function AdminSidebar() {
return ( return (

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