feat(cloud): add free trial

This commit is contained in:
Henry
2025-10-30 23:23:50 +00:00
parent e8d52de5e4
commit 2fa718417f
7 changed files with 177 additions and 25 deletions

View File

@@ -12,6 +12,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
href?: string; href?: string;
fullWidth?: boolean; fullWidth?: boolean;
openInNewTab?: boolean; openInNewTab?: boolean;
iconOnly?: boolean;
} }
const Button = ({ const Button = ({
@@ -24,6 +25,7 @@ const Button = ({
href, href,
fullWidth, fullWidth,
openInNewTab, openInNewTab,
iconOnly,
...props ...props
}: ButtonProps) => { }: ButtonProps) => {
const classes = twMerge( const classes = twMerge(
@@ -32,6 +34,15 @@ const Button = ({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "lg" && "py-[0.65rem]", size === "lg" && "py-[0.65rem]",
fullWidth && "w-full", 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" && variant === "primary" &&
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50", "bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" && variant === "secondary" &&
@@ -47,19 +58,60 @@ const Button = ({
<span className="relative flex items-center justify-center"> <span className="relative flex items-center justify-center">
{isLoading && ( {isLoading && (
<span className="absolute"> <span className="absolute">
<LoadingSpinner size={size} /> <LoadingSpinner size={size === "xs" ? "sm" : size} />
</span> </span>
)} )}
<div {iconOnly ? (
className={twMerge( <div
"flex items-center", className={twMerge(
isLoading ? "invisible" : "visible", "flex items-center",
)} isLoading ? "invisible" : "visible",
> )}
{iconLeft && <span className="mr-2">{iconLeft}</span>} >
{children} {iconLeft ?? iconRight}
{iconRight && <span className="ml-1">{iconRight}</span>} </div>
</div> ) : (
<div
className={twMerge(
fullWidth
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
: "flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{fullWidth && !iconLeft && iconRight && (
<span className="col-start-1 opacity-0">{iconRight}</span>
)}
{iconLeft && (
<span
className={twMerge(
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
)}
>
{iconLeft}
</span>
)}
<span
className={twMerge(
fullWidth ? "col-start-2 justify-self-center text-center" : "",
)}
>
{children}
</span>
{iconRight && (
<span
className={twMerge(
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
)}
>
{iconRight}
</span>
)}
{fullWidth && !iconRight && iconLeft && (
<span className="col-start-3 opacity-0">{iconLeft}</span>
)}
</div>
)}
</span> </span>
); );

View File

@@ -2,14 +2,19 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Button } from "@headlessui/react"; import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import { import {
TbLayoutSidebarLeftCollapse, TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand, TbLayoutSidebarLeftExpand,
} from "react-icons/tb"; } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; 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 boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json"; import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.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 settingsIconLight from "~/assets/settings-light.json";
import templatesIconDark from "~/assets/templates-dark.json"; import templatesIconDark from "~/assets/templates-dark.json";
import templatesIconLight from "~/assets/templates-light.json"; import templatesIconLight from "~/assets/templates-light.json";
import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton"; import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu"; import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu"; import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
interface SideNavigationProps { interface SideNavigationProps {
user: UserType; user: UserType;
@@ -39,13 +48,23 @@ export default function SideNavigation({
onCloseSideNav, onCloseSideNav,
}: SideNavigationProps) { }: SideNavigationProps) {
const router = useRouter(); const router = useRouter();
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = 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(() => { useEffect(() => {
const savedState = localStorage.getItem("kan_sidebar-collapsed"); const savedState = localStorage.getItem("kan_sidebar-collapsed");
if (savedState !== null) { if (savedState !== null) {
setIsCollapsed(JSON.parse(savedState)); setIsCollapsed(Boolean(JSON.parse(savedState)));
} }
setIsInitialised(true); setIsInitialised(true);
}, []); }, []);
@@ -61,7 +80,9 @@ export default function SideNavigation({
const { pathname } = router; const { pathname } = router;
const { theme, resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const isDarkMode = resolvedTheme === "dark"; const isDarkMode = resolvedTheme === "dark";
@@ -148,7 +169,7 @@ export default function SideNavigation({
</ul> </ul>
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
<UserMenu <UserMenu
email={user.email ?? ""} email={user.email ?? ""}
imageUrl={user.image ?? undefined} imageUrl={user.image ?? undefined}
@@ -156,6 +177,31 @@ export default function SideNavigation({
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav} onCloseSideNav={onCloseSideNav}
/> />
{isCloudEnv && !hasActiveSubscription(subscriptions, "pro") && (
<div className={twMerge(isCollapsed && "flex justify-center")}>
{isCollapsed ? (
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href="/settings/workspace?upgrade=pro"
aria-label="Upgrade to Pro"
title="Upgrade to Pro"
iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href="/settings/workspace?upgrade=pro"
onClick={() => openModal("UPGRADE_TO_PRO")}
>
{t`Upgrade to Pro`}
</ButtonComponent>
)}
</div>
)}
</div> </div>
</nav> </nav>
</> </>

View File

@@ -94,6 +94,9 @@ export default async function handler(
quantity: 1, quantity: 1,
}, },
], ],
subscription_data: {
trial_period_days: 14,
},
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId, client_reference_id: workspacePublicId,

View File

@@ -59,7 +59,18 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
entityLabel, entityLabel,
closeOnClickOutside, 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];
});
}, },
[], [],
); );

View File

@@ -176,6 +176,13 @@ export function InviteMemberForm({
}; };
const handleInviteLinkToggle = async () => { const handleInviteLinkToggle = async () => {
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
setIsLoadingInviteLink(true); setIsLoadingInviteLink(true);
if (isShareInviteLinkEnabled && workspace.publicId) { if (isShareInviteLinkEnabled && workspace.publicId) {
@@ -342,11 +349,6 @@ export function InviteMemberForm({
: t`Create invite link` : t`Create invite link`
} }
isChecked={isShareInviteLinkEnabled} isChecked={isShareInviteLinkEnabled}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
onChange={handleInviteLinkToggle} onChange={handleInviteLinkToggle}
/> />
<div> <div>
@@ -354,7 +356,7 @@ export function InviteMemberForm({
!hasTeamSubscription && !hasTeamSubscription &&
!hasProSubscription ? ( !hasProSubscription ? (
<Button type="button" onClick={handleUpgrade}> <Button type="button" onClick={handleUpgrade}>
{t`Upgrade to Team Plan`} {t`Start 14 day free trial`}
</Button> </Button>
) : ( ) : (
<Button <Button

View File

@@ -48,9 +48,11 @@ export function UpgradeToProConfirmation({
return ( return (
<div className="p-5"> <div className="p-5">
<div className="flex w-full flex-col justify-between pb-4"> <div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-bold text-neutral-900 dark:text-dark-1000"> <div className="pb-4">
{t`Upgrade to Pro`} <h2 className="text-md font-bold text-neutral-900 dark:text-dark-1000">
</h2> {t`Upgrade to Pro`}
</h2>
</div>
<p className="mb-4 text-sm font-medium text-light-900 dark:text-dark-900"> <p className="mb-4 text-sm font-medium text-light-900 dark:text-dark-900">
{t`Supercharge your workspace for just $29/month. Here's what you'll get:`} {t`Supercharge your workspace for just $29/month. Here's what you'll get:`}
</p> </p>
@@ -106,7 +108,7 @@ export function UpgradeToProConfirmation({
<Button <Button
onClick={handleUpgrade} onClick={handleUpgrade}
iconRight={<HiBolt />} iconRight={<HiBolt />}
>{t`Upgrade`}</Button> >{t`Start 14 day free trial`}</Button>
</div> </div>
</div> </div>
); );

View File

@@ -170,12 +170,48 @@ export const initAuth = (db: dbClient) => {
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!, priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId: annualDiscountPriceId:
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!, 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", name: "pro",
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!, priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
annualDiscountPriceId: annualDiscountPriceId:
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!, 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) => { authorizeReference: async (data) => {