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:
@@ -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:
|
||||
|
||||
```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:
|
||||
|
||||
```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.
|
||||
@@ -1,28 +1,3 @@
|
||||
# Create T3 App
|
||||
# Your Application
|
||||
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
## 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.
|
||||
Write here everything about your application.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function UserSettingsLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
|
||||
|
||||
function PersonalAccountSettingsPage() {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use server';
|
||||
|
||||
import { URL } from 'next/dist/compiled/@edge-runtime/primitives';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
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 billingConfig from '~/config/billing.config';
|
||||
@@ -30,7 +29,7 @@ export async function createPersonalAccountCheckoutSession(params: {
|
||||
}
|
||||
|
||||
const planId = z.string().min(1).parse(params.planId);
|
||||
const service = await getGatewayProvider(client);
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
|
||||
|
||||
if (!productPlanPairFromId) {
|
||||
@@ -77,7 +76,7 @@ export async function createBillingPortalSession() {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getGatewayProvider(client);
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
|
||||
const accountId = data.user.id;
|
||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||
return <Page sidebar={<HomeSidebar />}>{children}</Page>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function UserHomePage() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
|
||||
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
|
||||
|
||||
export function AppHeader({
|
||||
children,
|
||||
title,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
|
||||
|
||||
import { SidebarDivider, SidebarGroup, SidebarItem } from '@kit/ui/sidebar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { getOrganizationAccountSidebarConfig } from '~/config/organization-account-sidebar.config';
|
||||
|
||||
export function AppSidebarNavigation({
|
||||
account,
|
||||
}: React.PropsWithChildren<{
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { Sidebar, SidebarContent } from '@kit/ui/sidebar';
|
||||
@@ -18,6 +15,10 @@ import {
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
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';
|
||||
|
||||
type AccountModel = {
|
||||
|
||||
@@ -4,9 +4,6 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
@@ -26,6 +23,10 @@ import {
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
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 = (
|
||||
props: React.PropsWithChildren<{
|
||||
slug: string;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { cache } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import 'server-only';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function OrganizationAccountBillingPage() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
|
||||
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { AppSidebar } from './(components)/app-sidebar';
|
||||
import { loadOrganizationWorkspace } from './(lib)/load-workspace';
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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 {
|
||||
@@ -19,6 +17,9 @@ import {
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import loadDynamic from 'next/dynamic';
|
||||
|
||||
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 { PageBody } from '@kit/ui/page';
|
||||
import Spinner from '@kit/ui/spinner';
|
||||
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(
|
||||
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
|
||||
{
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -4,12 +4,13 @@ import Link from 'next/link';
|
||||
|
||||
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import type { Stripe } from 'stripe';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
/**
|
||||
* Retrieves the session status for a Stripe checkout session.
|
||||
* Since we should only arrive here for a successful checkout, we only check
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import requireSession from '@/lib/user/require-session';
|
||||
|
||||
import { withI18n } from '@packages/i18n/with-i18n';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import createStripeClient from '@kit/stripe/get-stripe';
|
||||
|
||||
import requireSession from '@/lib/user/require-session';
|
||||
|
||||
import { BillingSessionStatus } from './components/billing-session-status';
|
||||
import RecoverCheckout from './components/recover-checkout';
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
|
||||
const features = {
|
||||
enableOrganizationAccounts: featureFlagsConfig.enableOrganizationAccounts,
|
||||
enableOrganizationCreation: featureFlagsConfig.enableOrganizationCreation,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { use } from 'react';
|
||||
|
||||
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 { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
|
||||
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() {
|
||||
const collapsed = getSidebarCollapsed();
|
||||
const accounts = use(loadUserAccounts());
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export function ProfileDropdownContainer(props: { collapsed: boolean }) {
|
||||
const userSession = useUserSession();
|
||||
const signOut = useSignOut();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { 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<{
|
||||
post: Post;
|
||||
}> = ({ post }) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
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 { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
preloadImage?: boolean;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
|
||||
import PostPreview from '~/(marketing)/blog/components/post-preview';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export function SiteHeaderAccountSection() {
|
||||
const signOut = useSignOut();
|
||||
const userSession = useUserSession();
|
||||
|
||||
@@ -4,15 +4,16 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
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 { DocsCards } from '~/(marketing)/docs/components/docs-cards';
|
||||
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
|
||||
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
const getPageBySlug = cache((slug: string) => {
|
||||
return allDocumentationPages.find((post) => post.resolvedPath === slug);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
@@ -2,11 +2,12 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-16'}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PricingTable } from '@kit/billing/components/pricing-table';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PricingTable } from '@kit/billing/components/pricing-table';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
|
||||
|
||||
export function withAdminSession<Args extends unknown[], Response>(
|
||||
fn: (...params: Args) => Response,
|
||||
) {
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const deleteOrganizationAction = withAdminSession(
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -15,8 +17,6 @@ import {
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
import { deleteOrganizationAction } from '../actions.server';
|
||||
|
||||
function DeleteOrganizationModal({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import Link from 'next/link';
|
||||
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 { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
@@ -15,10 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} 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';
|
||||
|
||||
type Data = {
|
||||
|
||||
@@ -2,15 +2,13 @@ import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import AdminHeader from '@packages/admin/components/AdminHeader';
|
||||
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 { getMembershipsByOrganizationUid } from '../../queries';
|
||||
import OrganizationsMembersTable from './components/OrganizationsMembersTable';
|
||||
|
||||
@@ -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;
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
function OrganizationsAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
import AdminGuard from '@/packages/admin/components/AdminGuard';
|
||||
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 appConfig from '@/config/app.config';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import OrganizationsTable from './components/OrganizationsTable';
|
||||
import { getOrganizations } from './queries';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@/database.types';
|
||||
|
||||
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
|
||||
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
|
||||
@@ -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 { PageBody } from '@/components/app/Page';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
|
||||
import AdminGuard from '../../packages/admin/components/AdminGuard';
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const banUser = withAdminSession(async ({ userId }) => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -20,8 +22,6 @@ import {
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
|
||||
import { banUser } from '../actions.server';
|
||||
|
||||
function BanUserModal({
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import useSupabase from '@kit/hooks/use-supabase';
|
||||
|
||||
import Spinner from '@/components/app/Spinner';
|
||||
|
||||
import useSupabase from '@kit/hooks/use-supabase';
|
||||
|
||||
function ImpersonateUserAuthSetter({
|
||||
tokens,
|
||||
}: React.PropsWithChildren<{
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -16,9 +19,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import LoadingOverlay from '@/components/app/LoadingOverlay';
|
||||
|
||||
import { impersonateUser } from '../actions.server';
|
||||
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -12,8 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
|
||||
function UserActionsDropdown({
|
||||
uid,
|
||||
isBanned,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
@@ -17,12 +20,6 @@ import {
|
||||
TableRow,
|
||||
} 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 AdminGuard from '../../../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../../../packages/admin/components/AdminHeader';
|
||||
|
||||
@@ -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;
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
function UsersAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
|
||||
@@ -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 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 AdminHeader from '../../../packages/admin/components/AdminHeader';
|
||||
import getPageFromQueryParams from '../utils/get-page-from-query-param';
|
||||
import UsersTable from './components/UsersTable';
|
||||
import { getUsers } from './queries';
|
||||
|
||||
interface UsersAdminPageProps {
|
||||
@@ -32,14 +30,7 @@ async function UsersAdminPage({ searchParams }: UsersAdminPageProps) {
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Users</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<UsersTable
|
||||
users={users}
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
perPage={perPage}
|
||||
/>
|
||||
</PageBody>
|
||||
<PageBody></PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { USERS_TABLE } from '@/lib/db-tables';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
export async function getUsers(ids: string[]) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
|
||||
50
apps/web/app/api/billing/webhook/route.ts
Normal file
50
apps/web/app/api/billing/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const searchParams = requestUrl.searchParams;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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 { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||
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 () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { SiteHeader } from '~/(marketing)/components/site-header';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SiteHeader } from '~/(marketing)/components/site-header';
|
||||
|
||||
const ErrorPage = () => {
|
||||
return (
|
||||
<div className={'flex h-screen flex-1 flex-col'}>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useCallback, useState, useTransition } from 'react';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
|
||||
import { EmailOtpContainer } from '@kit/auth/src/components/email-otp-container';
|
||||
import { OauthProviders } from '@kit/auth/src/components/oauth-providers';
|
||||
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 { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
|
||||
enum Mode {
|
||||
SignUp,
|
||||
SignIn,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
function InvitePageLayout({ children }: React.PropsWithChildren) {
|
||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Inter as SansFont } from 'next/font/google';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { Toaster } from '@kit/ui/sonner';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { RootProviders } from '~/components/root-providers';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
|
||||
import { Toaster } from '@kit/ui/sonner';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import '../styles/globals.css';
|
||||
|
||||
const sans = SansFont({
|
||||
@@ -51,7 +51,7 @@ function getClassName() {
|
||||
export const metadata = {
|
||||
title: appConfig.name,
|
||||
description: appConfig.description,
|
||||
metadataBase: new URL(appConfig.url!),
|
||||
metadataBase: new URL(appConfig.url),
|
||||
openGraph: {
|
||||
url: appConfig.url,
|
||||
siteName: appConfig.name,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
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 { Heading } from '@kit/ui/heading';
|
||||
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 = {
|
||||
title: `Page not found - ${appConfig.name}`,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import appConfig from '@/config/app.config';
|
||||
import { invariant } from '@epic-web/invariant';
|
||||
import { allDocumentationPages, allPosts } from 'contentlayer/generated';
|
||||
import { getServerSideSitemap } from 'next-sitemap';
|
||||
import { join } from 'path';
|
||||
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
const siteUrl = appConfig.url;
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 PasswordResetForm from '@kit/auth/src/components/password-reset-form';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
async function PasswordResetPage() {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
const user = await client.auth.getUser();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 { AuthChangeListener } from '@kit/supabase/components/auth-change-listener';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
|
||||
|
||||
export function RootProviders({
|
||||
lang,
|
||||
children,
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
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 routes = (account: string) => [
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const iconClasses = 'w-4';
|
||||
|
||||
const routes = [
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { NextRequest } from 'next/server';
|
||||
import { NextResponse, URLPattern } from 'next/server';
|
||||
|
||||
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 { createMiddlewareClient } from '@kit/supabase/middleware-client';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const CSRF_SECRET_COOKIE = 'csrfSecret';
|
||||
const NEXT_ACTION_HEADER = 'next-action';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
@@ -12,16 +14,17 @@ const config = {
|
||||
'@kit/i18n',
|
||||
'@kit/mailers',
|
||||
'@kit/billing',
|
||||
'@kit/billing-gateway'
|
||||
'@kit/billing-gateway',
|
||||
],
|
||||
pageExtensions: ['ts', 'tsx', 'mdx'],
|
||||
pageExtensions: ['ts', 'tsx'],
|
||||
experimental: {
|
||||
mdxRs: true,
|
||||
},
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
})(config);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true pnpm run build",
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@kit/prettier-config": "^0.1.0",
|
||||
"@kit/tailwind-config": "^0.1.0",
|
||||
"@kit/tsconfig": "^0.1.0",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@types/mdx": "^2.0.10",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
|
||||
@@ -56,5 +56,9 @@
|
||||
"label": "Member",
|
||||
"description": "Cannot invite members or change settings"
|
||||
}
|
||||
},
|
||||
"billingInterval": {
|
||||
"month": "Billed monthly",
|
||||
"year": "Billed yearly"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/stripe": "0.1.0",
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/supabase": "^0.1.0",
|
||||
"@kit/shared": "^0.1.0",
|
||||
"lucide-react": "^0.361.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
"@kit/tsconfig": "0.1.0",
|
||||
"@supabase/supabase-js": "^2.39.8"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -81,29 +82,39 @@ export function PlanPicker(
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
{intervals.map((interval) => {
|
||||
return (
|
||||
<div
|
||||
key={interval}
|
||||
className={'flex items-center space-x-2'}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('interval', interval);
|
||||
}}
|
||||
/>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
const selected = field.value === interval;
|
||||
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`common.billingInterval.${interval}`}
|
||||
defaults={interval}
|
||||
return (
|
||||
<label
|
||||
key={interval}
|
||||
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
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('planId', '');
|
||||
form.setValue('interval', interval);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`common:billingInterval.${interval}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -130,7 +141,10 @@ export function PlanPicker(
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel key={variant.id}>
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === variant.id}
|
||||
key={variant.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={variant.id}
|
||||
value={variant.id}
|
||||
@@ -144,9 +158,7 @@ export function PlanPicker(
|
||||
>
|
||||
<Label
|
||||
htmlFor={variant.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-1.5'
|
||||
}
|
||||
className={'flex flex-col justify-center space-y-2'}
|
||||
>
|
||||
<span className="font-bold">{item.name}</span>
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './billing-gateway-service';
|
||||
export * from './gateway-provider-factory';
|
||||
export * from './services/billing-gateway/billing-gateway.service';
|
||||
export * from './services/billing-gateway/billing-gateway-provider-factory';
|
||||
export * from './services/billing-event-handler/billing-gateway-provider-factory';
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
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
|
||||
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
|
||||
* 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>,
|
||||
) {
|
||||
const provider = await getBillingProvider(client);
|
||||
@@ -17,10 +17,9 @@
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/supabase": "0.1.0",
|
||||
"lucide-react": "^0.361.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './create-billing-schema';
|
||||
export * from './services/billing-strategy-provider.service';
|
||||
export * from './services/billing-webhook-handler.service';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -14,13 +14,6 @@
|
||||
"./personal-account-settings": "./src/components/personal-account-settings/index.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": {
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
@@ -29,7 +22,12 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageHeader } from '@/components/app/Page';
|
||||
import pathsConfig from '@/config/paths.config';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import pathsConfig from '@/config/paths.config';
|
||||
|
||||
import { PageHeader } from '@/components/app/Page';
|
||||
|
||||
function AdminHeader({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<PageHeader
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import Logo from '@/components/app/Logo';
|
||||
import { Sidebar, SidebarContent, SidebarItem } from '@/components/app/Sidebar';
|
||||
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
function AdminSidebar() {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user