From ee507e08162dc316e84e56cda91173f9c56e4f5e Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 26 Mar 2024 01:34:19 +0800 Subject: [PATCH] Refactor code and improve usage of package dependencies This commit updates the naming convention of icons from Lucide-React, moving some package dependencies to "peerDependencies" in 'team-accounts', 'admin' and 'auth'. Additionally, it includes tweaks to the development server command in apps/web package.json and adds a logger reference to the shared package. Furthermore, cleanup work has been performed within the features and UI packages, and new scripts to interact with Stripe have been added to the root package.json. --- apps/web/.env.development | 3 +- .../personal-account-checkout-form.tsx | 41 +- .../(dashboard)/home/(user)/billing/page.tsx | 71 +- .../home/(user)/billing/return/page.tsx | 4 +- .../home/(user)/billing/server-actions.ts | 34 +- .../home/(user)/settings/loading.tsx | 3 - .../(dashboard)/home/(user)/settings/page.tsx | 17 +- .../[account]/_components/app-sidebar.tsx | 6 +- .../[account]/_components/dashboard-demo.tsx | 8 +- .../_components/mobile-app-navigation.tsx | 8 +- .../_components/billing-portal-form.tsx | 39 - .../team-account-checkout-form.tsx | 51 +- .../home/[account]/billing/page.tsx | 51 +- .../home/[account]/billing/server-actions.ts | 30 +- .../home/[account]/members/page.tsx | 4 +- .../app/(dashboard)/home/[account]/page.tsx | 6 +- .../home/_components/home-sidebar.tsx | 42 +- .../home/_lib/load-user-workspace.ts | 50 + .../site-header-account-section.tsx | 4 +- .../(marketing)/_components/site-header.tsx | 4 +- .../_components/site-navigation.tsx | 4 +- .../app/(marketing)/docs/[...slug]/page.tsx | 6 +- .../docs/_components/docs-card.tsx | 4 +- .../docs/_components/docs-navigation.tsx | 6 +- apps/web/app/(marketing)/faq/page.tsx | 4 +- apps/web/app/(marketing)/page.tsx | 6 +- apps/web/app/api/billing/webhook/route.ts | 6 +- apps/web/app/api/database/webhook.ts | 3 + apps/web/app/error.tsx | 4 +- apps/web/app/not-found.tsx | 4 +- apps/web/app/server-sitemap.xml/route.ts | 16 +- apps/web/config/billing.config.ts | 2 +- .../organization-account-sidebar.config.tsx | 15 +- .../personal-account-sidebar.config.tsx | 8 +- apps/web/next.config.mjs | 7 + apps/web/package.json | 2 +- package.json | 1 + .../src/components/billing-portal-card.tsx | 38 + .../src/components/billing-session-status.tsx | 8 +- .../src/components/current-plan-card.tsx | 69 +- .../src/components/embedded-checkout.tsx | 52 +- .../billing-gateway/src/components/index.ts | 1 + .../src/components/plan-picker.tsx | 4 +- .../billing-event-handler.service.ts | 29 +- .../billing-gateway-provider-factory.ts | 4 +- .../billing/src/components/pricing-table.tsx | 8 +- .../billing-webhook-handler.service.ts | 8 +- .../src/components/account-selector.tsx | 10 +- .../components/personal-account-dropdown.tsx | 20 +- .../account-danger-zone.tsx | 5 +- .../update-email-form-container.tsx | 2 +- .../update-password-container.tsx | 2 +- .../src/server/accounts-server-actions.ts | 69 - .../personal-accounts-server-actions.ts | 63 + .../src/server/services/accounts.service.ts | 46 - .../services/personal-accounts.service.ts | 17 + packages/features/admin/package.json | 24 +- .../admin/src/components/AdminHeader.tsx | 19 +- .../admin/src/components/AdminSidebar.tsx | 21 +- packages/features/auth/package.json | 1 - .../multi-factor-challenge-container.tsx | 4 +- .../components/oauth-provider-logo-image.tsx | 6 +- .../components/password-sign-up-container.tsx | 4 +- packages/features/team-accounts/package.json | 18 +- .../create-team-account-server-actions.ts | 58 + .../create-team-account-dialog.tsx} | 10 +- .../invitations/account-invitations-table.tsx | 4 +- .../invitations/delete-invitation-dialog.tsx | 4 +- .../members/account-members-table.tsx | 4 +- .../invite-members-dialog-container.tsx | 6 +- .../src/schema/create-team.schema.ts} | 2 +- .../src/schema/delete-invitation.schema.ts | 2 +- .../services/create-team-account.service.ts | 21 + packages/shared/package.json | 3 +- .../src/{logger.ts => logger/impl/pino.ts} | 0 packages/shared/src/logger/index.ts | 6 + packages/stripe/package.json | 3 +- .../components/stripe-embedded-checkout.tsx | 14 +- packages/stripe/src/services/stripe-sdk.ts | 15 +- .../stripe-webhook-handler.service.ts | 44 +- packages/supabase/src/database.types.ts | 1440 +++++++++-------- packages/supabase/src/hooks/use-user.ts | 6 +- packages/ui/package.json | 4 +- packages/ui/src/makerkit/data-table.tsx | 20 +- .../ui/src/makerkit/image-upload-input.tsx | 9 +- packages/ui/src/makerkit/image-uploader.tsx | 4 +- .../makerkit/mobile-navigation-dropdown.tsx | 6 +- .../src/makerkit/mobile-navigation-menu.tsx | 6 +- packages/ui/src/makerkit/sidebar.tsx | 4 +- pnpm-lock.yaml | 9 + supabase/migrations/20221215192558_schema.sql | 117 +- turbo.json | 14 +- 92 files changed, 1691 insertions(+), 1270 deletions(-) delete mode 100644 apps/web/app/(dashboard)/home/(user)/settings/loading.tsx delete mode 100644 apps/web/app/(dashboard)/home/[account]/billing/_components/billing-portal-form.tsx create mode 100644 apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts create mode 100644 apps/web/app/api/database/webhook.ts create mode 100644 packages/billing-gateway/src/components/billing-portal-card.tsx delete mode 100644 packages/features/accounts/src/server/accounts-server-actions.ts create mode 100644 packages/features/accounts/src/server/personal-accounts-server-actions.ts delete mode 100644 packages/features/accounts/src/server/services/accounts.service.ts create mode 100644 packages/features/accounts/src/server/services/personal-accounts.service.ts create mode 100644 packages/features/team-accounts/src/actions/create-team-account-server-actions.ts rename packages/features/{accounts/src/components/create-organization-account-dialog.tsx => team-accounts/src/components/create-team-account-dialog.tsx} (89%) rename packages/features/{accounts/src/schema/create-organization.schema.ts => team-accounts/src/schema/create-team.schema.ts} (52%) create mode 100644 packages/features/team-accounts/src/services/create-team-account.service.ts rename packages/shared/src/{logger.ts => logger/impl/pino.ts} (100%) create mode 100644 packages/shared/src/logger/index.ts diff --git a/apps/web/.env.development b/apps/web/.env.development index 44a227b8b..060ca404a 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -4,7 +4,6 @@ NEXT_PUBLIC_PRODUCT_NAME=Makerkit # SUPABASE NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true @@ -32,5 +31,5 @@ NEXT_PUBLIC_LOCALES_PATH=apps/web/public/locales # Please make sure to update these in the app's paths configuration as well SIGN_IN_PATH=/auth/sign-in SIGN_UP_PATH=/auth/sign-up -ORGANIZATION_ACCOUNTS_PATH=/home +TEAM_ACCOUNTS_HOME_PATH=/home INVITATION_PAGE_PATH=/join \ No newline at end of file diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index 9508394bd..cd22afa92 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -2,7 +2,10 @@ import { useState, useTransition } from 'react'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; + import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Card, CardContent, @@ -10,6 +13,7 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; +import { If } from '@kit/ui/if'; import billingConfig from '~/config/billing.config'; @@ -17,7 +21,8 @@ import { createPersonalAccountCheckoutSession } from '../server-actions'; export function PersonalAccountCheckoutForm() { const [pending, startTransition] = useTransition(); - const [checkoutToken, setCheckoutToken] = useState(null); + const [error, setError] = useState(false); + const [checkoutToken, setCheckoutToken] = useState(); // If the checkout token is set, render the embedded checkout component if (checkoutToken) { @@ -41,18 +46,26 @@ export function PersonalAccountCheckoutForm() { - + + + + + { startTransition(async () => { - const { checkoutToken } = - await createPersonalAccountCheckoutSession({ - planId, - }); + try { + const { checkoutToken } = + await createPersonalAccountCheckoutSession({ + planId, + }); - setCheckoutToken(checkoutToken); + setCheckoutToken(checkoutToken); + } catch (e) { + setError(true); + } }); }} /> @@ -61,3 +74,17 @@ export function PersonalAccountCheckoutForm() { ); } + +function ErrorAlert() { + return ( + + + + Sorry, we encountered an error. + + + We couldn't process your request. Please try again. + + + ); +} diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index 8f9460730..51135d1c0 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -1,10 +1,37 @@ +import { redirect } from 'next/navigation'; + +import { SupabaseClient } from '@supabase/supabase-js'; + +import { + BillingPortalCard, + CurrentPlanCard, +} from '@kit/billing-gateway/components'; +import { Database } from '@kit/supabase/database'; +import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { PersonalAccountCheckoutForm } from '~/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form'; +import { createPersonalAccountBillingPortalSession } from '~/(dashboard)/home/(user)/billing/server-actions'; +import billingConfig from '~/config/billing.config'; +import pathsConfig from '~/config/paths.config'; import { withI18n } from '~/lib/i18n/with-i18n'; -function PersonalAccountBillingPage() { +import { loadUserWorkspace } from '../../_lib/load-user-workspace'; +import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form'; + +type Subscription = Database['public']['Tables']['subscriptions']['Row']; + +async function PersonalAccountBillingPage() { + const client = getSupabaseServerComponentClient(); + const { session } = await loadUserWorkspace(); + + if (!session?.user) { + redirect(pathsConfig.auth.signIn); + } + + const [subscription, customerId] = await loadData(client, session.user.id); + return ( <> - +
+
+ } + > + {(subscription) => ( + + )} + + + +
+ + +
+
+
); } export default withI18n(PersonalAccountBillingPage); + +function loadData(client: SupabaseClient, userId: string) { + const subscription = client + .from('subscriptions') + .select('*') + .eq('account_id', userId) + .maybeSingle() + .then(({ data }) => data); + + const customer = client + .from('billing_customers') + .select('customer_id') + .eq('account_id', userId) + .maybeSingle() + .then(({ data }) => data?.customer_id); + + return Promise.all([subscription, customer]); +} diff --git a/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx index 6fbf074e1..9f2927a16 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/return/page.tsx @@ -1,3 +1,5 @@ // We reuse the page from the billing module // as there is no need to create a new one. -export * from '../return/page'; +import ReturnStripeSessionPage from '../../../[account]/billing/return/page'; + +export default ReturnStripeSessionPage; diff --git a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts index d4c34fb60..b0c81ca13 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts @@ -1,14 +1,16 @@ 'use server'; -import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { z } from 'zod'; import { getProductPlanPairFromId } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; +import { Logger } from '@kit/shared/logger'; +import { requireAuth } from '@kit/supabase/require-auth'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; +import appConfig from '~/config/app.config'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; @@ -22,13 +24,21 @@ export async function createPersonalAccountCheckoutSession(params: { planId: string; }) { const client = getSupabaseServerActionClient(); - const { data, error } = await client.auth.getUser(); + const { data, error } = await requireAuth(client); if (error ?? !data.user) { throw new Error('Authentication required'); } const planId = z.string().min(1).parse(params.planId); + + Logger.info( + { + planId, + }, + `Creating checkout session for plan ID`, + ); + const service = await getBillingGatewayProvider(client); const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId); @@ -61,6 +71,13 @@ export async function createPersonalAccountCheckoutSession(params: { customerId, }); + Logger.info( + { + userId: data.user.id, + }, + `Checkout session created. Returning checkout token to client...`, + ); + // return the checkout token to the client // so we can call the payment gateway to complete the checkout return { @@ -68,7 +85,7 @@ export async function createPersonalAccountCheckoutSession(params: { }; } -export async function createBillingPortalSession() { +export async function createPersonalAccountBillingPortalSession() { const client = getSupabaseServerActionClient(); const { data, error } = await client.auth.getUser(); @@ -95,18 +112,17 @@ export async function createBillingPortalSession() { } function getCheckoutSessionReturnUrl() { - const origin = headers().get('origin')!; - return new URL( pathsConfig.app.personalAccountBillingReturn, - origin, + appConfig.url, ).toString(); } function getBillingPortalReturnUrl() { - const origin = headers().get('origin')!; - - return new URL(pathsConfig.app.accountBilling, origin).toString(); + return new URL( + pathsConfig.app.personalAccountBilling, + appConfig.url, + ).toString(); } async function getCustomerIdFromAccountId(accountId: string) { diff --git a/apps/web/app/(dashboard)/home/(user)/settings/loading.tsx b/apps/web/app/(dashboard)/home/(user)/settings/loading.tsx deleted file mode 100644 index 4ea53181d..000000000 --- a/apps/web/app/(dashboard)/home/(user)/settings/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { GlobalLoader } from '@kit/ui/global-loader'; - -export default GlobalLoader; diff --git a/apps/web/app/(dashboard)/home/(user)/settings/page.tsx b/apps/web/app/(dashboard)/home/(user)/settings/page.tsx index cac56ae21..b49be99fd 100644 --- a/apps/web/app/(dashboard)/home/(user)/settings/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/settings/page.tsx @@ -4,6 +4,14 @@ import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; import { withI18n } from '~/lib/i18n/with-i18n'; +const features = { + enableAccountDeletion: featureFlagsConfig.enableAccountDeletion, +}; + +const paths = { + callback: pathsConfig.auth.callback, +}; + function PersonalAccountSettingsPage() { return (
- +
); } diff --git a/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx index 6de999cca..ef7fd5fc9 100644 --- a/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'; import type { Session } from '@supabase/supabase-js'; -import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react'; +import { ArrowLeftCircle, ArrowRightCircle } from 'lucide-react'; import { AccountSelector } from '@kit/accounts/account-selector'; import { Sidebar, SidebarContent } from '@kit/ui/sidebar'; @@ -138,13 +138,13 @@ function CollapsibleButton({ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} onClick={() => onClick(!collapsed)} > - - { switch (props.trend) { case 'up': - return ; + return ; case 'down': - return ; + return ; case 'stale': - return ; + return ; } }, [props.trend]); diff --git a/apps/web/app/(dashboard)/home/[account]/_components/mobile-app-navigation.tsx b/apps/web/app/(dashboard)/home/[account]/_components/mobile-app-navigation.tsx index 7e65eafcc..106a8be1e 100644 --- a/apps/web/app/(dashboard)/home/[account]/_components/mobile-app-navigation.tsx +++ b/apps/web/app/(dashboard)/home/[account]/_components/mobile-app-navigation.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { HomeIcon, LogOutIcon, MenuIcon } from 'lucide-react'; +import { Home, LogOut, Menu } from 'lucide-react'; import { AccountSelector } from '@kit/accounts/account-selector'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; @@ -67,7 +67,7 @@ export const MobileAppNavigation = ( return ( - + @@ -115,7 +115,7 @@ function SignOutDropdownItem( className={'flex h-12 w-full items-center space-x-4'} onClick={props.onSignOut} > - + @@ -135,7 +135,7 @@ function OrganizationsModal() { onSelect={(e) => e.preventDefault()} > - - - Visit the billing portal to manage your subscription (update - payment method, cancel subscription, etc.) - - - - - - ); -} diff --git a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx index 4ece16b65..dc39b3cce 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -2,6 +2,8 @@ import { useState, useTransition } from 'react'; +import { useParams } from 'next/navigation'; + import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components'; import { Card, @@ -16,6 +18,7 @@ import billingConfig from '~/config/billing.config'; import { createTeamAccountCheckoutSession } from '../server-actions'; export function TeamAccountCheckoutForm(params: { accountId: string }) { + const routeParams = useParams(); const [pending, startTransition] = useTransition(); const [checkoutToken, setCheckoutToken] = useState(null); @@ -31,34 +34,32 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) { // Otherwise, render the plan picker component return ( -
- - - Manage your Team Plan + + + Manage your Team Plan - - You can change your plan at any time. - - + You can change your plan at any time. + - - { - startTransition(async () => { - const { checkoutToken } = - await createTeamAccountCheckoutSession({ - planId, - accountId: params.accountId, - }); + + { + startTransition(async () => { + const slug = routeParams.account as string; - setCheckoutToken(checkoutToken); + const { checkoutToken } = await createTeamAccountCheckoutSession({ + planId, + accountId: params.accountId, + slug, }); - }} - /> - - -
+ + setCheckoutToken(checkoutToken); + }); + }} + /> + + ); } diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx index 192851602..1fd600aab 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -1,10 +1,16 @@ +import { + BillingPortalCard, + CurrentPlanCard, +} from '@kit/billing-gateway/components'; +import { Database } from '@kit/supabase/database'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/_lib/load-workspace'; -import { BillingPortalForm } from '~/(dashboard)/home/[account]/billing/_components/billing-portal-form'; +import { createBillingPortalSession } from '~/(dashboard)/home/[account]/billing/server-actions'; +import billingConfig from '~/config/billing.config'; import { withI18n } from '~/lib/i18n/with-i18n'; import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form'; @@ -18,7 +24,7 @@ interface Params { async function OrganizationAccountBillingPage({ params }: Params) { const workspace = await loadOrganizationWorkspace(params.account); const accountId = workspace.account.id; - const customerId = await loadCustomerIdFromAccount(accountId); + const [subscription, customerId] = await loadAccountData(accountId); return ( <> @@ -28,11 +34,27 @@ async function OrganizationAccountBillingPage({ params }: Params) { /> - +
+
+ } + > + {(data) => ( + + )} + - - - + +
+ + + + + +
+
+
); @@ -40,18 +62,21 @@ async function OrganizationAccountBillingPage({ params }: Params) { export default withI18n(OrganizationAccountBillingPage); -async function loadCustomerIdFromAccount(accountId: string) { +async function loadAccountData(accountId: string) { const client = getSupabaseServerComponentClient(); - const { data, error } = await client + const subscription = client + .from('subscriptions') + .select('*') + .eq('account_id', accountId) + .maybeSingle() + .then(({ data }) => data); + + const customerId = client .from('billing_customers') .select('customer_id') .eq('account_id', accountId) .maybeSingle(); - if (error) { - throw error; - } - - return data?.customer_id; + return Promise.all([subscription, customerId]); } diff --git a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts index 65f32d1d4..3c1701e11 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts @@ -1,6 +1,5 @@ 'use server'; -import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { z } from 'zod'; @@ -10,6 +9,7 @@ import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { requireAuth } from '@kit/supabase/require-auth'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; +import appConfig from '~/config/app.config'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; @@ -22,6 +22,7 @@ import pathsConfig from '~/config/paths.config'; export async function createTeamAccountCheckoutSession(params: { planId: string; accountId: string; + slug: string; }) { const client = getSupabaseServerActionClient(); @@ -57,7 +58,7 @@ export async function createTeamAccountCheckoutSession(params: { } // the return URL for the checkout session - const returnUrl = getCheckoutSessionReturnUrl(); + const returnUrl = getCheckoutSessionReturnUrl(params.slug); // find the customer ID for the account if it exists // (eg. if the account has been billed before) @@ -85,14 +86,15 @@ export async function createTeamAccountCheckoutSession(params: { }; } -export async function createBillingPortalSession(data: FormData) { +export async function createBillingPortalSession(formData: FormData) { const client = getSupabaseServerActionClient(); - const accountId = z + const { accountId, slug } = z .object({ accountId: z.string().min(1), + slug: z.string().min(1), }) - .parse(Object.fromEntries(data)).accountId; + .parse(Object.fromEntries(formData)); const { data: session, error } = await requireAuth(client); @@ -113,7 +115,7 @@ export async function createBillingPortalSession(data: FormData) { const service = await getBillingGatewayProvider(client); const customerId = await getCustomerIdFromAccountId(client, accountId); - const returnUrl = getBillingPortalReturnUrl(); + const returnUrl = getBillingPortalReturnUrl(slug); if (!customerId) { throw new Error('Customer not found'); @@ -128,16 +130,16 @@ export async function createBillingPortalSession(data: FormData) { return redirect(url); } -function getCheckoutSessionReturnUrl() { - const origin = headers().get('origin')!; - - return new URL(pathsConfig.app.accountBillingReturn, origin).toString(); +function getCheckoutSessionReturnUrl(accountSlug: string) { + return new URL(pathsConfig.app.accountBillingReturn, appConfig.url) + .toString() + .replace('[account]', accountSlug); } -function getBillingPortalReturnUrl() { - const origin = headers().get('origin')!; - - return new URL(pathsConfig.app.accountBilling, origin).toString(); +function getBillingPortalReturnUrl(accountSlug: string) { + return new URL(pathsConfig.app.accountBilling, appConfig.url) + .toString() + .replace('[account]', accountSlug); } /** diff --git a/apps/web/app/(dashboard)/home/[account]/members/page.tsx b/apps/web/app/(dashboard)/home/[account]/members/page.tsx index 01e5644b9..2ba595b87 100644 --- a/apps/web/app/(dashboard)/home/[account]/members/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/members/page.tsx @@ -1,4 +1,4 @@ -import { PlusCircleIcon } from 'lucide-react'; +import { PlusCircle } from 'lucide-react'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; import { @@ -99,7 +99,7 @@ async function OrganizationAccountMembersPage({ params }: Params) { diff --git a/apps/web/app/(dashboard)/home/[account]/page.tsx b/apps/web/app/(dashboard)/home/[account]/page.tsx index 33bc58cf1..820e36cea 100644 --- a/apps/web/app/(dashboard)/home/[account]/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/page.tsx @@ -1,6 +1,6 @@ import loadDynamic from 'next/dynamic'; -import { PlusIcon } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { Button } from '@kit/ui/button'; import { PageBody } from '@kit/ui/page'; @@ -11,7 +11,7 @@ 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'), + () => import('./_components/dashboard-demo'), { ssr: false, loading: () => ( @@ -50,7 +50,7 @@ function OrganizationAccountHomePage({ account={params.account} > diff --git a/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx b/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx index 80bb61a6f..77c2678dd 100644 --- a/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx +++ b/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx @@ -2,19 +2,16 @@ 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 { loadUserWorkspace } from '~/(dashboard)/home/_lib/load-user-workspace'; import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config'; export function HomeSidebar() { const collapsed = getSidebarCollapsed(); - - const [accounts, session] = use( - Promise.all([loadUserAccounts(), loadSession()]), - ); + const { session, accounts } = use(loadUserWorkspace()); return ( @@ -38,38 +35,3 @@ export function HomeSidebar() { function getSidebarCollapsed() { return cookies().get('sidebar-collapsed')?.value === 'true'; } - -async function loadSession() { - const client = getSupabaseServerComponentClient(); - - const { - data: { session }, - error, - } = await client.auth.getSession(); - - if (error) { - throw error; - } - - return session; -} - -async function loadUserAccounts() { - const client = getSupabaseServerComponentClient(); - - const { data: accounts, error } = await client - .from('user_accounts') - .select(`name, slug, picture_url`); - - if (error) { - throw error; - } - - return accounts.map(({ name, slug, picture_url }) => { - return { - label: name, - value: slug, - image: picture_url, - }; - }); -} diff --git a/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts b/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts new file mode 100644 index 000000000..eee102a8a --- /dev/null +++ b/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts @@ -0,0 +1,50 @@ +import { cache } from 'react'; + +import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; + +export const loadUserWorkspace = cache(async () => { + const [accounts, session] = await Promise.all([ + loadUserAccounts(), + loadSession(), + ]); + + return { + accounts, + session, + }; +}); + +async function loadSession() { + const client = getSupabaseServerComponentClient(); + + const { + data: { session }, + error, + } = await client.auth.getSession(); + + if (error) { + throw error; + } + + return session; +} + +async function loadUserAccounts() { + const client = getSupabaseServerComponentClient(); + + const { data: accounts, error } = await client + .from('user_accounts') + .select(`name, slug, picture_url`); + + if (error) { + throw error; + } + + return accounts.map(({ name, slug, picture_url }) => { + return { + label: name, + value: slug, + image: picture_url, + }; + }); +} diff --git a/apps/web/app/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/(marketing)/_components/site-header-account-section.tsx index e313b0cff..c36512ace 100644 --- a/apps/web/app/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/(marketing)/_components/site-header-account-section.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import type { Session } from '@supabase/supabase-js'; -import { ChevronRightIcon } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; @@ -60,7 +60,7 @@ function AuthButtons() { diff --git a/apps/web/app/(marketing)/_components/site-header.tsx b/apps/web/app/(marketing)/_components/site-header.tsx index cdccb4fb5..39d61e49d 100644 --- a/apps/web/app/(marketing)/_components/site-header.tsx +++ b/apps/web/app/(marketing)/_components/site-header.tsx @@ -4,7 +4,7 @@ import { SiteHeaderAccountSection } from '~/(marketing)/_components/site-header- import { SiteNavigation } from '~/(marketing)/_components/site-navigation'; import { AppLogo } from '~/components/app-logo'; -export async function SiteHeader(props: { session: Session | null }) { +export async function SiteHeader(props: { session?: Session | null }) { return (
@@ -19,7 +19,7 @@ export async function SiteHeader(props: { session: Session | null }) {
- +
diff --git a/apps/web/app/(marketing)/_components/site-navigation.tsx b/apps/web/app/(marketing)/_components/site-navigation.tsx index 4fe26e15b..8dd505e9c 100644 --- a/apps/web/app/(marketing)/_components/site-navigation.tsx +++ b/apps/web/app/(marketing)/_components/site-navigation.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { MenuIcon } from 'lucide-react'; +import { Menu } from 'lucide-react'; import { DropdownMenu, @@ -81,7 +81,7 @@ function MobileDropdown() { return ( - + diff --git a/apps/web/app/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/(marketing)/docs/[...slug]/page.tsx index 0a4223cae..0a2417aaa 100644 --- a/apps/web/app/(marketing)/docs/[...slug]/page.tsx +++ b/apps/web/app/(marketing)/docs/[...slug]/page.tsx @@ -3,7 +3,7 @@ import { cache } from 'react'; import { notFound } from 'next/navigation'; import { allDocumentationPages } from 'contentlayer/generated'; -import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { If } from '@kit/ui/if'; import { Mdx } from '@kit/ui/mdx'; @@ -77,7 +77,7 @@ function DocumentationPage({ params }: PageParams) { {(page) => ( } + before={} /> )} @@ -88,7 +88,7 @@ function DocumentationPage({ params }: PageParams) { {(page) => ( } + after={} /> )} diff --git a/apps/web/app/(marketing)/docs/_components/docs-card.tsx b/apps/web/app/(marketing)/docs/_components/docs-card.tsx index e57733685..a6b4836b9 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-card.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-card.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { ChevronRightIcon } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; export const DocsCard: React.FC< React.PropsWithChildren<{ @@ -36,7 +36,7 @@ export const DocsCard: React.FC< {link.label} - +
)} diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx index 1a9452662..266a7a00a 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { ChevronDownIcon, MenuIcon } from 'lucide-react'; +import { ChevronDown, Menu } from 'lucide-react'; import { isBrowser } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; @@ -53,7 +53,7 @@ const DocsNavLink: React.FC<{ - + )} @@ -218,7 +218,7 @@ function FloatingDocumentationNavigation({ className={'fixed bottom-5 right-5 z-10 h-16 w-16 rounded-full'} onClick={onClick} > - + ); diff --git a/apps/web/app/(marketing)/faq/page.tsx b/apps/web/app/(marketing)/faq/page.tsx index 312feeab5..7e1813cb1 100644 --- a/apps/web/app/(marketing)/faq/page.tsx +++ b/apps/web/app/(marketing)/faq/page.tsx @@ -1,4 +1,4 @@ -import { ChevronDownIcon } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; import { withI18n } from '~/lib/i18n/with-i18n'; @@ -108,7 +108,7 @@ function FaqItem({
-
diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 0c54e53d4..cedea559a 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; import Link from 'next/link'; -import { ChevronRightIcon } from 'lucide-react'; +import { ChevronRight } from 'lucide-react'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -135,7 +135,7 @@ function Home() {
@@ -273,7 +273,7 @@ function MainCallToActionButton() { Get Started - getSupabaseRouteHandlerClient({ admin: true }); + + const service = await getBillingEventHandlerService(clientProvider, provider); try { await service.handleWebhookEvent(request); diff --git a/apps/web/app/api/database/webhook.ts b/apps/web/app/api/database/webhook.ts new file mode 100644 index 000000000..efe1e2ecd --- /dev/null +++ b/apps/web/app/api/database/webhook.ts @@ -0,0 +1,3 @@ +export function POST(request: Request) { + console.log(request); +} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 613518207..a3e84ac15 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; -import { ArrowLeftIcon } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -41,7 +41,7 @@ const ErrorPage = () => {
diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index bbdd6a773..d41d92e3b 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; -import { ArrowLeftIcon } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -44,7 +44,7 @@ const NotFoundPage = () => { diff --git a/apps/web/app/server-sitemap.xml/route.ts b/apps/web/app/server-sitemap.xml/route.ts index 3baae3040..e1ca73c1c 100644 --- a/apps/web/app/server-sitemap.xml/route.ts +++ b/apps/web/app/server-sitemap.xml/route.ts @@ -4,7 +4,7 @@ import { getServerSideSitemap } from 'next-sitemap'; import appConfig from '~/config/app.config'; -const siteUrl = appConfig.url; +invariant(appConfig.url, 'No NEXT_PUBLIC_SITE_URL environment variable found'); export async function GET() { const urls = getSiteUrls(); @@ -15,35 +15,29 @@ export async function GET() { } function getSiteUrls() { - invariant(siteUrl, 'No NEXT_PUBLIC_SITE_URL found'); - - const urls = ['', 'faq', 'pricing']; + const urls = ['/', 'faq', 'pricing']; return urls.map((url) => { return { - loc: new URL(siteUrl, url).href, + loc: new URL(url, appConfig.url).href, lastmod: new Date().toISOString(), }; }); } function getPostsSitemap() { - invariant(siteUrl, 'No NEXT_PUBLIC_SITE_URL found'); - return allPosts.map((post) => { return { - loc: new URL(siteUrl, post.url).href, + loc: new URL(post.url, appConfig.url).href, lastmod: new Date().toISOString(), }; }); } function getDocsSitemap() { - invariant(siteUrl, 'No NEXT_PUBLIC_SITE_URL found'); - return allDocumentationPages.map((page) => { return { - loc: new URL(siteUrl, page.url).href, + loc: new URL(page.url, appConfig.url).href, lastmod: new Date().toISOString(), }; }); diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index 264757bab..ba86c99a0 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -12,7 +12,7 @@ export default createBillingSchema({ badge: `Value`, plans: [ { - id: 'starter-monthly', + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Starter Monthly', price: '9.99', interval: 'month', diff --git a/apps/web/config/organization-account-sidebar.config.tsx b/apps/web/config/organization-account-sidebar.config.tsx index 3dee549af..60fa02dce 100644 --- a/apps/web/config/organization-account-sidebar.config.tsx +++ b/apps/web/config/organization-account-sidebar.config.tsx @@ -1,9 +1,4 @@ -import { - CreditCardIcon, - LayoutDashboardIcon, - SettingsIcon, - UsersIcon, -} from 'lucide-react'; +import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react'; import { SidebarConfigSchema } from '@kit/ui/sidebar-schema'; @@ -16,7 +11,7 @@ const routes = (account: string) => [ { label: 'common:dashboardTabLabel', path: pathsConfig.app.accountHome.replace('[account]', account), - Icon: , + Icon: , end: true, }, { @@ -26,18 +21,18 @@ const routes = (account: string) => [ { label: 'common:settingsTabLabel', path: createPath(pathsConfig.app.accountSettings, account), - Icon: , + Icon: , }, { label: 'common:accountMembers', path: createPath(pathsConfig.app.accountMembers, account), - Icon: , + Icon: , }, featureFlagsConfig.enableOrganizationBilling ? { label: 'common:billingTabLabel', path: createPath(pathsConfig.app.accountBilling, account), - Icon: , + Icon: , } : undefined, ].filter(Boolean), diff --git a/apps/web/config/personal-account-sidebar.config.tsx b/apps/web/config/personal-account-sidebar.config.tsx index 00d749763..f7df14c21 100644 --- a/apps/web/config/personal-account-sidebar.config.tsx +++ b/apps/web/config/personal-account-sidebar.config.tsx @@ -1,4 +1,4 @@ -import { CreditCardIcon, HomeIcon, UserIcon } from 'lucide-react'; +import { CreditCard, Home, User } from 'lucide-react'; import { SidebarConfigSchema } from '@kit/ui/sidebar-schema'; @@ -11,13 +11,13 @@ const routes = [ { label: 'common:homeTabLabel', path: pathsConfig.app.home, - Icon: , + Icon: , end: true, }, { label: 'common:yourAccountTabLabel', path: pathsConfig.app.personalAccountSettings, - Icon: , + Icon: , }, ]; @@ -25,7 +25,7 @@ if (featureFlagsConfig.enablePersonalAccountBilling) { routes.push({ label: 'common:billingTabLabel', path: pathsConfig.app.personalAccountBilling, - Icon: , + Icon: , }); } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index ac16d8361..01a8e745d 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -18,6 +18,7 @@ const config = { '@kit/mailers', '@kit/billing', '@kit/billing-gateway', + '@kit/stripe', ], pageExtensions: ['ts', 'tsx'], images: { @@ -25,6 +26,12 @@ const config = { }, experimental: { mdxRs: true, + optimizePackageImports: [] + }, + modularizeImports: { + "lucide-react": { + transform: "lucide-react/dist/esm/icons/{{ kebabCase member }}", + } }, /** We already do linting and typechecking as separate tasks in CI */ eslint: { ignoreDuringBuilds: true }, diff --git a/apps/web/package.json b/apps/web/package.json index e77f2b9e9..a03eac9ca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "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 --turbo", + "dev": "pnpm with-env next dev", "lint": "next lint", "format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"", "start": "pnpm with-env next start", diff --git a/package.json b/package.json index 1fcbbe1b8..f27fb780c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "turbo lint --continue -- --cache --cache-location 'node_modules/.cache/.eslintcache' && manypkg check", "lint:fix": "turbo lint --continue -- --fix --cache --cache-location 'node_modules/.cache/.eslintcache' && manypkg fix", "typecheck": "turbo typecheck", + "stripe:listen": "pnpm --filter '@kit/stripe' start", "supabase:start": "turbo dev --filter @kit/supabase-config", "supabase:stop": "pnpm --filter '@kit/supabase-config' stop", "supabase:reset": "pnpm --filter '@kit/supabase-config' reset", diff --git a/packages/billing-gateway/src/components/billing-portal-card.tsx b/packages/billing-gateway/src/components/billing-portal-card.tsx new file mode 100644 index 000000000..16126443c --- /dev/null +++ b/packages/billing-gateway/src/components/billing-portal-card.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { ArrowRightIcon } from '@radix-ui/react-icons'; + +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; + +export function BillingPortalCard() { + return ( + + + Manage your Subscription + + + You can change your plan or cancel your subscription at any time. + + + + + + +

+ Visit the billing portal to manage your subscription (update payment + method, cancel subscription, etc.) +

+
+
+ ); +} diff --git a/packages/billing-gateway/src/components/billing-session-status.tsx b/packages/billing-gateway/src/components/billing-session-status.tsx index c553f6653..d37c7ffab 100644 --- a/packages/billing-gateway/src/components/billing-session-status.tsx +++ b/packages/billing-gateway/src/components/billing-session-status.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; -import { CheckIcon, ChevronRightIcon } from 'lucide-react'; +import { Check, ChevronRight } from 'lucide-react'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -55,9 +55,9 @@ function SuccessSessionStatus({ 'flex flex-col items-center justify-center space-y-4 text-center' } > - @@ -87,7 +87,7 @@ function SuccessSessionStatus({ - + diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-plan-card.tsx index fa5a3987e..4cfb05e9a 100644 --- a/packages/billing-gateway/src/components/current-plan-card.tsx +++ b/packages/billing-gateway/src/components/current-plan-card.tsx @@ -1 +1,68 @@ -export function CurrentPlanCard(props: React.PropsWithChildren<{}>) {} +import { formatDate } from 'date-fns'; +import { z } from 'zod'; + +import { BillingSchema, getProductPlanPairFromId } from '@kit/billing'; +import { Database } from '@kit/supabase/database'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { If } from '@kit/ui/if'; + +export function CurrentPlanCard({ + subscription, + config, +}: React.PropsWithChildren<{ + subscription: Database['public']['Tables']['subscriptions']['Row']; + config: z.infer; +}>) { + const { plan, product } = getProductPlanPairFromId( + config, + subscription.variant_id, + ); + + return ( + + + {product.name} + + {product.description} + + + +
+
+ Your Current Plan: {plan.name} +
+
+ +
+
+ Your Subscription is currently {subscription.status} +
+
+ + +
+
+ Cancellation Date:{' '} + {formatDate(subscription.period_ends_at, 'P')} +
+
+
+ + +
+
+ Next Billing Date:{' '} + {formatDate(subscription.period_ends_at, 'P')}{' '} +
+
+
+
+
+ ); +} diff --git a/packages/billing-gateway/src/components/embedded-checkout.tsx b/packages/billing-gateway/src/components/embedded-checkout.tsx index 6c6c60a76..6b0a0c7ae 100644 --- a/packages/billing-gateway/src/components/embedded-checkout.tsx +++ b/packages/billing-gateway/src/components/embedded-checkout.tsx @@ -1,9 +1,14 @@ -import { lazy } from 'react'; +import { Suspense, forwardRef, lazy, memo, useMemo } from 'react'; import { Database } from '@kit/supabase/database'; +import { LoadingOverlay } from '@kit/ui/loading-overlay'; type BillingProvider = Database['public']['Enums']['billing_provider']; +const Fallback = ( + Loading Checkout... +); + export function EmbeddedCheckout( props: React.PropsWithChildren<{ checkoutToken: string; @@ -11,7 +16,10 @@ export function EmbeddedCheckout( onClose?: () => void; }>, ) { - const CheckoutComponent = loadCheckoutComponent(props.provider); + const CheckoutComponent = useMemo( + () => memo(loadCheckoutComponent(props.provider)), + [], + ); return ( { - return import('@kit/stripe/components').then((c) => ({ - default: c.StripeCheckout, - })); + return buildLazyComponent(() => { + return import('@kit/stripe/components').then(({ StripeCheckout }) => { + return { + default: StripeCheckout, + }; + }); }); } @@ -43,3 +53,33 @@ function loadCheckoutComponent(provider: BillingProvider) { throw new Error(`Unsupported provider: ${provider as string}`); } } + +function buildLazyComponent< + Cmp extends React.ComponentType< + React.PropsWithChildren<{ + onClose?: (() => unknown) | undefined; + checkoutToken: string; + }> + >, +>( + load: () => Promise<{ + default: Cmp; + }>, + fallback = Fallback, +) { + let LoadedComponent: ReturnType | null = null; + + const LazyComponent = forwardRef((props, ref) => { + if (!LoadedComponent) { + LoadedComponent = lazy(load); + } + + return ( + + + + ); + }); + + return memo(LazyComponent); +} diff --git a/packages/billing-gateway/src/components/index.ts b/packages/billing-gateway/src/components/index.ts index b32a7a6cd..e9f9e8b3b 100644 --- a/packages/billing-gateway/src/components/index.ts +++ b/packages/billing-gateway/src/components/index.ts @@ -2,3 +2,4 @@ export * from './plan-picker'; export * from './current-plan-card'; export * from './embedded-checkout'; export * from './billing-session-status'; +export * from './billing-portal-card'; diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx index ad6b345d6..038055eff 100644 --- a/packages/billing-gateway/src/components/plan-picker.tsx +++ b/packages/billing-gateway/src/components/plan-picker.tsx @@ -1,7 +1,7 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ArrowRightIcon } from 'lucide-react'; +import { ArrowRight } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -212,7 +212,7 @@ export function PlanPicker( ) : ( <> Proceed to payment - + )} diff --git a/packages/billing-gateway/src/services/billing-event-handler/billing-event-handler.service.ts b/packages/billing-gateway/src/services/billing-event-handler/billing-event-handler.service.ts index 4ce1d3c90..e3519609e 100644 --- a/packages/billing-gateway/src/services/billing-event-handler/billing-event-handler.service.ts +++ b/packages/billing-gateway/src/services/billing-event-handler/billing-event-handler.service.ts @@ -6,7 +6,7 @@ import { Database } from '@kit/supabase/database'; export class BillingEventHandlerService { constructor( - private readonly client: SupabaseClient, + private readonly clientProvider: () => SupabaseClient, private readonly strategy: BillingWebhookHandlerService, ) {} @@ -19,6 +19,8 @@ export class BillingEventHandlerService { return this.strategy.handleWebhookEvent(event, { onSubscriptionDeleted: async (subscriptionId: string) => { + const client = this.clientProvider(); + // Handle the subscription deleted event // here we delete the subscription from the database Logger.info( @@ -29,7 +31,7 @@ export class BillingEventHandlerService { 'Processing subscription deleted event', ); - const { error } = await this.client + const { error } = await client .from('subscriptions') .delete() .match({ id: subscriptionId }); @@ -47,6 +49,8 @@ export class BillingEventHandlerService { ); }, onSubscriptionUpdated: async (subscription) => { + const client = this.clientProvider(); + const ctx = { namespace: 'billing', subscriptionId: subscription.id, @@ -58,7 +62,7 @@ export class BillingEventHandlerService { // Handle the subscription updated event // here we update the subscription in the database - const { error } = await this.client + const { error } = await client .from('subscriptions') .update(subscription) .match({ id: subscription.id }); @@ -77,9 +81,11 @@ export class BillingEventHandlerService { Logger.info(ctx, 'Successfully updated subscription'); }, - onCheckoutSessionCompleted: async (subscription) => { + onCheckoutSessionCompleted: async (subscription, customerId) => { // Handle the checkout session completed event // here we add the subscription to the database + const client = this.clientProvider(); + const ctx = { namespace: 'billing', subscriptionId: subscription.id, @@ -89,12 +95,21 @@ export class BillingEventHandlerService { Logger.info(ctx, 'Processing checkout session completed event...'); - const { error } = await this.client.rpc('add_subscription', { - subscription, + const { id, ...data } = subscription; + + const { error } = await client.rpc('add_subscription', { + ...data, + subscription_id: subscription.id, + customer_id: customerId, + price_amount: subscription.price_amount ?? 0, + period_starts_at: subscription.period_starts_at!, + period_ends_at: subscription.period_ends_at!, + trial_starts_at: subscription.trial_starts_at!, + trial_ends_at: subscription.trial_ends_at!, }); if (error) { - Logger.error(ctx, 'Failed to add subscription'); + Logger.error({ ...ctx, error }, 'Failed to add subscription'); throw new Error('Failed to add subscription'); } diff --git a/packages/billing-gateway/src/services/billing-event-handler/billing-gateway-provider-factory.ts b/packages/billing-gateway/src/services/billing-event-handler/billing-gateway-provider-factory.ts index b6b42c926..917567930 100644 --- a/packages/billing-gateway/src/services/billing-event-handler/billing-gateway-provider-factory.ts +++ b/packages/billing-gateway/src/services/billing-event-handler/billing-gateway-provider-factory.ts @@ -10,11 +10,11 @@ import { BillingEventHandlerFactoryService } from './billing-gateway-factory.ser * defined in the host application. */ export async function getBillingEventHandlerService( - client: ReturnType, + clientProvider: () => ReturnType, provider: Database['public']['Enums']['billing_provider'], ) { const strategy = await BillingEventHandlerFactoryService.GetProviderStrategy(provider); - return new BillingEventHandlerService(client, strategy); + return new BillingEventHandlerService(clientProvider, strategy); } diff --git a/packages/billing/src/components/pricing-table.tsx b/packages/billing/src/components/pricing-table.tsx index af439a099..1845cf409 100644 --- a/packages/billing/src/components/pricing-table.tsx +++ b/packages/billing/src/components/pricing-table.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { CheckCircleIcon, SparklesIcon } from 'lucide-react'; +import { CheckCircle, Sparkles } from 'lucide-react'; import { z } from 'zod'; import { Button } from '@kit/ui/button'; @@ -147,7 +147,7 @@ function PricingItem( )} > - + {props.product.badge} @@ -238,7 +238,7 @@ function ListItem({ children }: React.PropsWithChildren) { return (
  • - +
    {children} @@ -275,7 +275,7 @@ function PlanIntervalSwitcher( > - + diff --git a/packages/billing/src/services/billing-webhook-handler.service.ts b/packages/billing/src/services/billing-webhook-handler.service.ts index 3df854464..b91f15a89 100644 --- a/packages/billing/src/services/billing-webhook-handler.service.ts +++ b/packages/billing/src/services/billing-webhook-handler.service.ts @@ -2,11 +2,6 @@ import { Database } from '@kit/supabase/database'; type SubscriptionObject = Database['public']['Tables']['subscriptions']; -type SubscriptionInsertParams = Omit< - SubscriptionObject['Insert'], - 'billing_customer_id' ->; - type SubscriptionUpdateParams = SubscriptionObject['Update']; /** @@ -19,7 +14,8 @@ export abstract class BillingWebhookHandlerService { event: unknown, params: { onCheckoutSessionCompleted: ( - subscription: SubscriptionInsertParams, + subscription: SubscriptionObject['Row'], + customerId: string, ) => Promise; onSubscriptionUpdated: ( diff --git a/packages/features/accounts/src/components/account-selector.tsx b/packages/features/accounts/src/components/account-selector.tsx index f528ff657..3bca1663e 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons'; -import { CheckIcon, PlusIcon } from 'lucide-react'; +import { Check, Plus } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; import { Button } from '@kit/ui/button'; @@ -19,7 +19,7 @@ import { If } from '@kit/ui/if'; import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover'; import { cn } from '@kit/ui/utils'; -import { CreateOrganizationAccountDialog } from './create-organization-account-dialog'; +import { CreateTeamAccountDialog } from '../../../team-accounts/src/components/create-team-account-dialog'; interface AccountSelectorProps { accounts: Array<{ @@ -64,7 +64,7 @@ export function AccountSelector({ const Icon = (props: { item: string }) => { return ( - - + Create Organization @@ -208,7 +208,7 @@ export function AccountSelector({ - diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 5683cba77..465ac7bb6 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -7,11 +7,11 @@ import Link from 'next/link'; import type { Session } from '@supabase/gotrue-js'; import { - EllipsisVerticalIcon, - HomeIcon, - LogOutIcon, - MessageCircleQuestionIcon, - ShieldIcon, + EllipsisVertical, + Home, + LogOut, + MessageCircleQuestion, + Shield, } from 'lucide-react'; import { @@ -87,7 +87,7 @@ export function PersonalAccountDropdown({
  • -
    diff --git a/packages/ui/src/makerkit/sidebar.tsx b/packages/ui/src/makerkit/sidebar.tsx index 466b5105d..abb58c8ed 100644 --- a/packages/ui/src/makerkit/sidebar.tsx +++ b/packages/ui/src/makerkit/sidebar.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { cva } from 'class-variance-authority'; -import { ChevronDownIcon } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import { z } from 'zod'; import { Button } from '../shadcn/button'; @@ -111,7 +111,7 @@ export function SidebarGroup({ {label} -