Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 104 additions & 26 deletions apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,47 @@ const useActiveSubscription = () => {
queryKey: ["activeSubscription"],
queryFn: async () => {
const { data } = await subscription.list()
// We used to allow one time purchases, so we need to check for active subscriptions
return data?.find(
(sub) => (sub.status === "active" || sub.status === "trialing") && sub.stripeSubscriptionId,
)
// Find subscription: active, trialing, or canceled with valid period end
return data?.find((sub) => {
if (!sub.stripeSubscriptionId) return false

// Active or trialing subscriptions
if (sub.status === "active" || sub.status === "trialing") {
return true
}

// Canceled subscriptions that haven't expired yet
if (sub.status === "canceled" && sub.periodEnd) {
return new Date(sub.periodEnd) > new Date()
}

return false
})
},
enabled: !!userId,
})
}

const useBillingPortal = () => {
return useMutation({
mutationFn: async () => {
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ returnUrl }),
})
const data = await res.json()
if (data.code === 0 && data.data?.url) {
window.open(data.data.url, "_blank")
Comment on lines +131 to +135

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid popup blockers when opening billing portal

On the web (non‑Electron) path, window.open runs only after awaiting the portal API response; most browsers treat that as not directly user‑initiated and will block the popup, so “Manage Subscription” silently does nothing for users with popup blocking enabled. Consider opening a blank tab synchronously on click and setting its location after the fetch (or redirecting the current tab) to keep it within the user‑gesture window.

Useful? React with 👍 / 👎.

}
},
})
}

export function SettingPlan() {
const isPaymentEnabled = useIsPaymentEnabled()
const role = useUserRole()
Expand Down Expand Up @@ -361,10 +393,14 @@ const PlanAction = ({
onSelect?: () => void
isLoading?: boolean
}) => {
const { t } = useTranslation("settings")
const { data: activeSubscription } = useActiveSubscription()
const serverConfig = useServerConfigs()
const stripePortalLink = serverConfig?.STRIPE_PORTAL_LINK
const canManageSubscription = !!activeSubscription && !!stripePortalLink
const billingPortalMutation = useBillingPortal()
const canManageSubscription = !!activeSubscription?.stripeSubscriptionId

// Determine subscription status info
const isCanceled = activeSubscription?.status === "canceled"
const periodEnd = activeSubscription?.periodEnd ? new Date(activeSubscription.periodEnd) : null

const getButtonConfig = () => {
switch (actionType) {
Expand All @@ -378,7 +414,7 @@ const PlanAction = ({
}
case "current": {
return {
text: canManageSubscription ? "Manage Subscription" : "Current Plan",
text: canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan"),
icon: undefined,
variant: "outline" as const,
className: !canManageSubscription ? "text-text-secondary" : undefined,
Expand Down Expand Up @@ -433,25 +469,67 @@ const PlanAction = ({
}

return (
<Button
variant={buttonConfig.variant}
buttonClassName={cn(
"w-full h-9 text-sm font-medium transition-all duration-200",
buttonConfig.className,
<div className="flex flex-col gap-1.5">
{/* Subscription status info for current plan */}
{actionType === "current" && canManageSubscription && (
<div className="flex items-center justify-center gap-1.5 text-xs text-text-secondary">
<span
className={cn(
"inline-block size-1.5 rounded-full",
isCanceled
? "bg-yellow"
: activeSubscription?.status === "trialing"
? "bg-blue"
: "bg-green",
)}
/>
<span>
{isCanceled
? t("plan.canceled_expires", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: activeSubscription?.status === "trialing"
? t("plan.trial_ends", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: t("plan.renews", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</span>
</div>
)}
disabled={buttonConfig.disabled}
onClick={
actionType === "current" && canManageSubscription
? () => window.open(stripePortalLink, "_blank")
: onSelect
}
isLoading={isLoading}
>
<span className="flex items-center justify-center gap-1.5">
{buttonConfig.icon && <i className={cn(buttonConfig.icon, "text-sm")} />}
<span>{buttonConfig.text}</span>
</span>
</Button>
<Button
variant={buttonConfig.variant}
buttonClassName={cn(
"w-full h-9 text-sm font-medium transition-all duration-200",
buttonConfig.className,
)}
disabled={buttonConfig.disabled}
onClick={
actionType === "current" && canManageSubscription
? () => billingPortalMutation.mutate()
: onSelect
}
isLoading={isLoading || billingPortalMutation.isPending}
>
<span className="flex items-center justify-center gap-1.5">
{buttonConfig.icon && <i className={cn(buttonConfig.icon, "text-sm")} />}
<span>{buttonConfig.text}</span>
</span>
</Button>
</div>
)
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,5 @@
"vite-tsconfig-paths": "5.1.4"
},
"productName": "Folo",
"mainHash": "1c627eb0ea5a109a4fc2d7ac5a78777df546d831f6cced620b8120d41dc4e444"
"mainHash": "56f9664d0989dd416832d0e94f479340623da3641e1dcef5fb33311f21e9617f"
}
142 changes: 136 additions & 6 deletions apps/mobile/src/modules/settings/routes/Plan.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { useRoleEndAt, useUserRole } from "@follow/store/user/hooks"
import { useRoleEndAt, useUserRole, useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils"
import type { StatusConfigs } from "@follow-app/client-sdk"
import { useMutation } from "@tanstack/react-query"
import { useMutation, useQuery } from "@tanstack/react-query"
import dayjs from "dayjs"
import { openURL } from "expo-linking"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
Expand All @@ -17,6 +17,7 @@ import {
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import { Text } from "@/src/components/ui/typography/Text"
import { CheckLineIcon } from "@/src/icons/check_line"
import { followClient } from "@/src/lib/api-client"
import { authClient } from "@/src/lib/auth"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { proxyEnv } from "@/src/lib/proxy-env"
Expand Down Expand Up @@ -183,6 +184,48 @@ export const PlanScreen: NavigationControllerView = () => {
},
})

const userId = useWhoami()?.id
const activeSubscriptionQuery = useQuery({
queryKey: ["activeSubscription"],
queryFn: async () => {
const response = await authClient.subscription.list()
const { data } = response
return data?.find(
(sub: {
status: string
stripeSubscriptionId?: string | null
periodEnd?: Date | null
}) => {
if (!sub.stripeSubscriptionId) return false
if (sub.status === "active" || sub.status === "trialing") return true
if (sub.status === "canceled" && sub.periodEnd) {
return new Date(sub.periodEnd) > new Date()
}
return false
},
)
},
enabled: !!userId,
})

const billingPortalMutation = useMutation({
mutationFn: async () => {
const data = await followClient.request<{ code: number; data?: { url: string } }>(
"/billing/portal",
{
method: "POST",
body: { returnUrl: proxyEnv.WEB_URL },
},
)
if (data.code === 0 && data.data?.url) {
await openURL(data.data.url)
}
},
onError: () => {
toast.error(t("subscription.actions.manage_error"))
},
})

if (!isPaymentEnabled || sortedPlans.length === 0) {
return (
<SafeNavigationScrollView
Expand Down Expand Up @@ -255,7 +298,10 @@ export const PlanScreen: NavigationControllerView = () => {
})
: undefined
}
onManageSubscription={() => billingPortalMutation.mutate()}
isProcessing={isProcessing}
isManaging={billingPortalMutation.isPending}
activeSubscription={activeSubscriptionQuery.data}
/>
)
})}
Expand Down Expand Up @@ -390,18 +436,30 @@ const BillingToggle = ({
)
}

type ActiveSubscription = {
status: string
stripeSubscriptionId?: string | null
periodEnd?: Date | null
}

const PlanCard = ({
plan,
billingPeriod,
isCurrentPlan,
onUpgrade,
onManageSubscription,
isProcessing,
isManaging,
activeSubscription,
}: {
plan: PaymentPlan
billingPeriod: BillingPeriod
isCurrentPlan: boolean
onUpgrade?: () => void
onManageSubscription?: () => void
isProcessing?: boolean
isManaging?: boolean
activeSubscription?: ActiveSubscription
}) => {
const { t } = useTranslation("settings")

Expand Down Expand Up @@ -568,22 +626,39 @@ const PlanCard = ({
})}
</View>

<PlanAction actionType={actionType} onUpgrade={onUpgrade} isProcessing={isProcessing} />
<PlanAction
actionType={actionType}
onUpgrade={onUpgrade}
onManageSubscription={onManageSubscription}
isProcessing={isProcessing}
isManaging={isManaging}
activeSubscription={activeSubscription}
/>
</View>
)
}

const PlanAction = ({
actionType,
onUpgrade,
onManageSubscription,
isProcessing,
isManaging,
activeSubscription,
}: {
actionType: "current" | "upgrade" | "coming-soon" | null
onUpgrade?: () => void
onManageSubscription?: () => void
isProcessing?: boolean
isManaging?: boolean
activeSubscription?: ActiveSubscription
}) => {
const { t } = useTranslation("settings")

const canManageSubscription = !!activeSubscription?.stripeSubscriptionId
const isCanceled = activeSubscription?.status === "canceled"
const periodEnd = activeSubscription?.periodEnd ? new Date(activeSubscription.periodEnd) : null

if (actionType === "coming-soon") {
return (
<Text className="mt-5 rounded-full border border-opaque-separator/60 px-4 py-2 text-center text-sm text-secondary-label">
Expand All @@ -594,9 +669,64 @@ const PlanAction = ({

if (actionType === "current") {
return (
<Text className="mt-5 rounded-full border border-opaque-separator/60 px-4 py-2 text-center text-sm text-secondary-label">
{t("subscription.actions.current")}
</Text>
<View className="mt-5 gap-1.5">
{canManageSubscription && (
<View className="flex-row items-center justify-center gap-1.5">
<View
className={cn(
"size-1.5 rounded-full",
isCanceled
? "bg-yellow"
: activeSubscription?.status === "trialing"
? "bg-blue"
: "bg-green",
)}
/>
<Text className="text-xs text-secondary-label">
{isCanceled
? t("plan.canceled_expires", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: activeSubscription?.status === "trialing"
? t("plan.trial_ends", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: t("plan.renews", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</Text>
</View>
)}
<Pressable
accessibilityRole="button"
onPress={onManageSubscription}
disabled={!canManageSubscription || isManaging}
className={cn(
"h-11 items-center justify-center rounded-full border border-opaque-separator/60",
!canManageSubscription && "opacity-50",
)}
>
{isManaging ? (
<ActivityIndicator />
) : (
<Text className="text-base font-medium text-label">
{canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan")}
</Text>
)}
</Pressable>
</View>
)
}

Expand Down
Loading
Loading