From 2fa718417f11cc83f74b830459eb549e2cfbecd1 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 30 Oct 2025 23:23:50 +0000 Subject: [PATCH] feat(cloud): add free trial --- apps/web/src/components/Button.tsx | 74 ++++++++++++++++--- apps/web/src/components/SideNavigation.tsx | 52 ++++++++++++- .../api/stripe/create_checkout_session.ts | 3 + apps/web/src/providers/modal.tsx | 13 +++- .../members/components/InviteMemberForm.tsx | 14 ++-- .../components/UpgradeToProConfirmation.tsx | 10 ++- packages/auth/src/auth.ts | 36 +++++++++ 7 files changed, 177 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/Button.tsx b/apps/web/src/components/Button.tsx index d2331525..f7c858fd 100644 --- a/apps/web/src/components/Button.tsx +++ b/apps/web/src/components/Button.tsx @@ -12,6 +12,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { href?: string; fullWidth?: boolean; openInNewTab?: boolean; + iconOnly?: boolean; } const Button = ({ @@ -24,6 +25,7 @@ const Button = ({ href, fullWidth, openInNewTab, + iconOnly, ...props }: ButtonProps) => { const classes = twMerge( @@ -32,6 +34,15 @@ const Button = ({ size === "sm" && "text-xs", size === "lg" && "py-[0.65rem]", fullWidth && "w-full", + iconOnly && "p-0", + iconOnly && + (size === "xs" + ? "h-6 w-6" + : size === "sm" + ? "h-8 w-8" + : size === "lg" + ? "h-10 w-10" + : "h-9 w-9"), variant === "primary" && "bg-light-1000 dark:bg-dark-1000 dark:text-dark-50", variant === "secondary" && @@ -47,19 +58,60 @@ const Button = ({ {isLoading && ( - + )} -
- {iconLeft && {iconLeft}} - {children} - {iconRight && {iconRight}} -
+ {iconOnly ? ( +
+ {iconLeft ?? iconRight} +
+ ) : ( +
+ {fullWidth && !iconLeft && iconRight && ( + {iconRight} + )} + {iconLeft && ( + + {iconLeft} + + )} + + {children} + + {iconRight && ( + + {iconRight} + + )} + {fullWidth && !iconRight && iconLeft && ( + {iconLeft} + )} +
+ )}
); diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx index 016013a6..8dbce57f 100644 --- a/apps/web/src/components/SideNavigation.tsx +++ b/apps/web/src/components/SideNavigation.tsx @@ -2,14 +2,19 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; +import { env } from "next-runtime-env"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; +import { HiBolt } from "react-icons/hi2"; import { TbLayoutSidebarLeftCollapse, TbLayoutSidebarLeftExpand, } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; +import type { Subscription } from "@kan/shared/utils"; +import { hasActiveSubscription } from "@kan/shared/utils"; + import boardsIconDark from "~/assets/boards-dark.json"; import boardsIconLight from "~/assets/boards-light.json"; import membersIconDark from "~/assets/members-dark.json"; @@ -18,9 +23,13 @@ import settingsIconDark from "~/assets/settings-dark.json"; import settingsIconLight from "~/assets/settings-light.json"; import templatesIconDark from "~/assets/templates-dark.json"; import templatesIconLight from "~/assets/templates-light.json"; +import ButtonComponent from "~/components/Button"; import ReactiveButton from "~/components/ReactiveButton"; import UserMenu from "~/components/UserMenu"; import WorkspaceMenu from "~/components/WorkspaceMenu"; +import { useModal } from "~/providers/modal"; +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; interface SideNavigationProps { user: UserType; @@ -39,13 +48,23 @@ export default function SideNavigation({ onCloseSideNav, }: SideNavigationProps) { const router = useRouter(); + const { workspace } = useWorkspace(); const [isCollapsed, setIsCollapsed] = useState(false); const [isInitialised, setIsInitialised] = useState(false); + const { openModal } = useModal(); + + const { data: workspaceData } = api.workspace.byId.useQuery({ + workspacePublicId: workspace.publicId, + }); + + const subscriptions = workspaceData?.subscriptions as + | Subscription[] + | undefined; useEffect(() => { const savedState = localStorage.getItem("kan_sidebar-collapsed"); if (savedState !== null) { - setIsCollapsed(JSON.parse(savedState)); + setIsCollapsed(Boolean(JSON.parse(savedState))); } setIsInitialised(true); }, []); @@ -61,7 +80,9 @@ export default function SideNavigation({ const { pathname } = router; - const { theme, resolvedTheme } = useTheme(); + const { resolvedTheme } = useTheme(); + + const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud"; const isDarkMode = resolvedTheme === "dark"; @@ -148,7 +169,7 @@ export default function SideNavigation({ -
+
+ {isCloudEnv && !hasActiveSubscription(subscriptions, "pro") && ( +
+ {isCollapsed ? ( + } + variant="secondary" + href="/settings/workspace?upgrade=pro" + aria-label="Upgrade to Pro" + title="Upgrade to Pro" + iconOnly + onClick={() => openModal("UPGRADE_TO_PRO")} + /> + ) : ( + } + fullWidth + variant="secondary" + href="/settings/workspace?upgrade=pro" + onClick={() => openModal("UPGRADE_TO_PRO")} + > + {t`Upgrade to Pro`} + + )} +
+ )}
diff --git a/apps/web/src/pages/api/stripe/create_checkout_session.ts b/apps/web/src/pages/api/stripe/create_checkout_session.ts index f8325b12..7ac48fd2 100644 --- a/apps/web/src/pages/api/stripe/create_checkout_session.ts +++ b/apps/web/src/pages/api/stripe/create_checkout_session.ts @@ -94,6 +94,9 @@ export default async function handler( quantity: 1, }, ], + subscription_data: { + trial_period_days: 14, + }, success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, client_reference_id: workspacePublicId, diff --git a/apps/web/src/providers/modal.tsx b/apps/web/src/providers/modal.tsx index ebfb85b7..544cd4fe 100644 --- a/apps/web/src/providers/modal.tsx +++ b/apps/web/src/providers/modal.tsx @@ -59,7 +59,18 @@ export const ModalProvider: React.FC = ({ children }) => { entityLabel, closeOnClickOutside, }; - setModalStack((prev) => [...prev, newModal]); + setModalStack((prev) => { + const last = prev[prev.length - 1]; + if ( + last && + last.contentType === newModal.contentType && + last.entityId === newModal.entityId && + last.entityLabel === newModal.entityLabel + ) { + return prev; // prevent stacking duplicate modal on top + } + return [...prev, newModal]; + }); }, [], ); diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx index 77faa07b..00da7603 100644 --- a/apps/web/src/views/members/components/InviteMemberForm.tsx +++ b/apps/web/src/views/members/components/InviteMemberForm.tsx @@ -176,6 +176,13 @@ export function InviteMemberForm({ }; const handleInviteLinkToggle = async () => { + if ( + env("NEXT_PUBLIC_KAN_ENV") === "cloud" && + !hasTeamSubscription && + !hasProSubscription + ) + return handleUpgrade(); + setIsLoadingInviteLink(true); if (isShareInviteLinkEnabled && workspace.publicId) { @@ -342,11 +349,6 @@ export function InviteMemberForm({ : t`Create invite link` } isChecked={isShareInviteLinkEnabled} - disabled={ - env("NEXT_PUBLIC_KAN_ENV") === "cloud" && - !hasTeamSubscription && - !hasProSubscription - } onChange={handleInviteLinkToggle} />
@@ -354,7 +356,7 @@ export function InviteMemberForm({ !hasTeamSubscription && !hasProSubscription ? ( ) : ( + >{t`Start 14 day free trial`}
); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 0f056d6f..91dcaa57 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -170,12 +170,48 @@ export const initAuth = (db: dbClient) => { priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!, annualDiscountPriceId: process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!, + freeTrial: { + days: 14, + onTrialStart: async (subscription) => { + // Called when a trial starts + // @todo: send trial start email + // await sendTrialStartEmail(subscription.referenceId); + }, + onTrialEnd: async ({ subscription }, request) => { + // Called when a trial ends + // @todo: send trial end email + // await sendTrialEndEmail(user.email); + }, + onTrialExpired: async (subscription) => { + // Called when a trial expires without conversion + // @todo: send trial expired email + // await sendTrialExpiredEmail(subscription.referenceId); + }, + }, }, { name: "pro", priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!, annualDiscountPriceId: process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!, + freeTrial: { + days: 14, + onTrialStart: async (subscription) => { + // Called when a trial starts + // @todo: send trial start email + // await sendTrialStartEmail(subscription.referenceId); + }, + onTrialEnd: async ({ subscription }, request) => { + // Called when a trial ends + // @todo: send trial end email + // await sendTrialEndEmail(user.email); + }, + onTrialExpired: async (subscription) => { + // Called when a trial expires without conversion + // @todo: send trial expired email + // await sendTrialExpiredEmail(subscription.referenceId); + }, + }, }, ], authorizeReference: async (data) => {