feat(cloud): add free trial
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user