From ac456c1a952faed4bbffeb1cdf140f73a3303fed Mon Sep 17 00:00:00 2001 From: twwu Date: Fri, 15 Aug 2025 11:08:36 +0800 Subject: [PATCH] feat: refactor billing plans components and add new PlanItem structure with tooltip support --- web/app/components/billing/pricing/index.tsx | 105 +------- .../components/billing/pricing/plan-item.tsx | 235 ------------------ .../billing/pricing/plan-item/button.tsx | 53 ++++ .../billing/pricing/plan-item/index.tsx | 132 ++++++++++ .../billing/pricing/plan-item/list/index.tsx | 75 ++++++ .../pricing/plan-item/list/item/index.tsx | 25 ++ .../pricing/plan-item/list/item/tooltip.tsx | 23 ++ .../billing/pricing/plans/index.tsx | 76 ++++++ web/i18n/en-US/billing.ts | 7 +- web/i18n/zh-Hans/billing.ts | 7 +- 10 files changed, 405 insertions(+), 333 deletions(-) delete mode 100644 web/app/components/billing/pricing/plan-item.tsx create mode 100644 web/app/components/billing/pricing/plan-item/button.tsx create mode 100644 web/app/components/billing/pricing/plan-item/index.tsx create mode 100644 web/app/components/billing/pricing/plan-item/list/index.tsx create mode 100644 web/app/components/billing/pricing/plan-item/list/item/index.tsx create mode 100644 web/app/components/billing/pricing/plan-item/list/item/tooltip.tsx create mode 100644 web/app/components/billing/pricing/plans/index.tsx diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index 28a43482e9..e65de95b8e 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -6,6 +6,9 @@ import Header from './header' import PlanSwitcher from './plan-switcher' import { PlanRange } from './plan-switcher/plan-range-switcher' import { useKeyPress } from 'ahooks' +import { useProviderContext } from '@/context/provider-context' +import { useAppContext } from '@/context/app-context' +import Plans from './plans' // import { useTranslation } from 'react-i18next' // import { RiArrowRightUpLine, RiCloseLine, RiCloudFill, RiTerminalBoxFill } from '@remixicon/react' // import Link from 'next/link' @@ -13,9 +16,7 @@ import { useKeyPress } from 'ahooks' // import TabSlider from '../../base/tab-slider' // import PlanItem from './plan-item' // import SelfHostedPlanItem from './self-hosted-plan-item' -// import { useProviderContext } from '@/context/provider-context' // import GridMask from '@/app/components/base/grid-mask' -// import { useAppContext } from '@/context/app-context' // import classNames from '@/utils/classnames' // import { useGetPricingPageLanguage } from '@/context/i18n' @@ -29,9 +30,9 @@ const Pricing: FC = ({ onCancel, }) => { // const { t } = useTranslation() - // const { plan } = useProviderContext() - // const { isCurrentWorkspaceManager } = useAppContext() - // const canPay = isCurrentWorkspaceManager + const { plan } = useProviderContext() + const { isCurrentWorkspaceManager } = useAppContext() + const canPay = isCurrentWorkspaceManager const [planRange, setPlanRange] = React.useState(PlanRange.monthly) const [currentCategory, setCurrentCategory] = useState('cloud') @@ -56,93 +57,13 @@ const Pricing: FC = ({ currentPlanRange={planRange} onChangePlanRange={setPlanRange} /> - - {/*
- -
- -
-
- {t('billing.plansCommon.title')} -
-
- {t('billing.plansCommon.freeTrialTipPrefix')} - {t('billing.plansCommon.freeTrialTip')} - {t('billing.plansCommon.freeTrialTipSuffix')} -
-
-
-
- - {t('billing.plansCommon.cloud')}
, - }, - { - value: 'self', - text:
- {t('billing.plansCommon.self')}
, - }]} - onChange={v => setCurrentPlan(v)} /> - - {currentPlan === 'cloud' && } -
-
-
- {currentPlan === 'cloud' && <> - - - - } - {currentPlan === 'self' && <> - - - - } -
-
- + + {/*
{t('billing.plansCommon.comparePlanAndFeatures')} diff --git a/web/app/components/billing/pricing/plan-item.tsx b/web/app/components/billing/pricing/plan-item.tsx deleted file mode 100644 index 07af0ffec8..0000000000 --- a/web/app/components/billing/pricing/plan-item.tsx +++ /dev/null @@ -1,235 +0,0 @@ -'use client' -import type { FC, ReactNode } from 'react' -import React from 'react' -import { useTranslation } from 'react-i18next' -import { RiApps2Line, RiBook2Line, RiBrain2Line, RiChatAiLine, RiFileEditLine, RiFolder6Line, RiGroupLine, RiHardDrive3Line, RiHistoryLine, RiProgress3Line, RiQuestionLine, RiSeoLine, RiTerminalBoxLine } from '@remixicon/react' -import type { BasicPlan } from '../type' -import { Plan } from '../type' -import { ALL_PLANS, NUM_INFINITE } from '../config' -import Toast from '../../base/toast' -import Tooltip from '../../base/tooltip' -import Divider from '../../base/divider' -import { ArCube1, Group2, Keyframe, SparklesSoft } from '../../base/icons/src/public/billing' -import { PlanRange } from './select-plan-range' -import cn from '@/utils/classnames' -import { useAppContext } from '@/context/app-context' -import { fetchSubscriptionUrls } from '@/service/billing' - -type Props = { - currentPlan: BasicPlan - plan: BasicPlan - planRange: PlanRange - canPay: boolean -} - -const KeyValue = ({ icon, label, tooltip }: { icon: ReactNode; label: string; tooltip?: ReactNode }) => { - return ( -
-
- {icon} -
-
{label}
- {tooltip && ( - -
- -
-
- )} -
- ) -} - -const priceClassName = 'leading-[125%] text-[28px] font-bold text-text-primary' -const style = { - [Plan.sandbox]: { - icon: , - description: 'text-util-colors-gray-gray-600', - btnStyle: 'bg-components-button-secondary-bg hover:bg-components-button-secondary-bg-hover border-[0.5px] border-components-button-secondary-border text-text-primary', - btnDisabledStyle: 'bg-components-button-secondary-bg-disabled hover:bg-components-button-secondary-bg-disabled border-components-button-secondary-border-disabled text-components-button-secondary-text-disabled', - }, - [Plan.professional]: { - icon: , - description: 'text-util-colors-blue-brand-blue-brand-600', - btnStyle: 'bg-components-button-primary-bg hover:bg-components-button-primary-bg-hover border border-components-button-primary-border text-components-button-primary-text', - btnDisabledStyle: 'bg-components-button-primary-bg-disabled hover:bg-components-button-primary-bg-disabled border-components-button-primary-border-disabled text-components-button-primary-text-disabled', - }, - [Plan.team]: { - icon: , - description: 'text-util-colors-indigo-indigo-600', - btnStyle: 'bg-components-button-indigo-bg hover:bg-components-button-indigo-bg-hover border border-components-button-primary-border text-components-button-primary-text', - btnDisabledStyle: 'bg-components-button-indigo-bg-disabled hover:bg-components-button-indigo-bg-disabled border-components-button-indigo-border-disabled text-components-button-primary-text-disabled', - }, -} -const PlanItem: FC = ({ - plan, - currentPlan, - planRange, -}) => { - const { t } = useTranslation() - const [loading, setLoading] = React.useState(false) - const i18nPrefix = `billing.plans.${plan}` - const isFreePlan = plan === Plan.sandbox - const isMostPopularPlan = plan === Plan.professional - const planInfo = ALL_PLANS[plan] - const isYear = planRange === PlanRange.yearly - const isCurrent = plan === currentPlan - const isPlanDisabled = planInfo.level <= ALL_PLANS[currentPlan].level - const { isCurrentWorkspaceManager } = useAppContext() - - const btnText = (() => { - if (isCurrent) - return t('billing.plansCommon.currentPlan') - - return ({ - [Plan.sandbox]: t('billing.plansCommon.startForFree'), - [Plan.professional]: t('billing.plansCommon.getStarted'), - [Plan.team]: t('billing.plansCommon.getStarted'), - })[plan] - })() - - const handleGetPayUrl = async () => { - if (loading) - return - - if (isPlanDisabled) - return - - if (isFreePlan) - return - - // Only workspace manager can buy plan - if (!isCurrentWorkspaceManager) { - Toast.notify({ - type: 'error', - message: t('billing.buyPermissionDeniedTip'), - className: 'z-[1001]', - }) - return - } - setLoading(true) - try { - const res = await fetchSubscriptionUrls(plan, isYear ? 'year' : 'month') - // Adb Block additional tracking block the gtag, so we need to redirect directly - window.location.href = res.url - } - finally { - setLoading(false) - } - } - return ( -
-
- {style[plan].icon} -
-
{t(`${i18nPrefix}.name`)}
- {isMostPopularPlan &&
-
- -
- {t('billing.plansCommon.mostPopular')} -
} -
-
{t(`${i18nPrefix}.description`)}
-
-
- {/* Price */} - {isFreePlan && ( -
{t('billing.plansCommon.free')}
- )} - {!isFreePlan && ( -
-
${isYear ? planInfo.price * 10 : planInfo.price}
-
- {isYear &&
{t('billing.plansCommon.save')}${planInfo.price * 2}
} -
- {t('billing.plansCommon.priceTip')} - {t(`billing.plansCommon.${!isYear ? 'month' : 'year'}`)}
-
-
- )} -
- -
- {btnText} -
-
- } - label={isFreePlan - ? t('billing.plansCommon.messageRequest.title', { count: planInfo.messageRequest }) - : t('billing.plansCommon.messageRequest.titlePerMonth', { count: planInfo.messageRequest })} - tooltip={t('billing.plansCommon.messageRequest.tooltip') as string} - /> - } - label={t('billing.plansCommon.modelProviders')} - /> - } - label={t('billing.plansCommon.teamWorkspace', { count: planInfo.teamWorkspace })} - /> - } - label={t('billing.plansCommon.teamMember', { count: planInfo.teamMembers })} - /> - } - label={t('billing.plansCommon.buildApps', { count: planInfo.buildApps })} - /> - - } - label={t('billing.plansCommon.documents', { count: planInfo.documents })} - tooltip={t('billing.plansCommon.documentsTooltip') as string} - /> - } - label={t('billing.plansCommon.vectorSpace', { size: planInfo.vectorSpace })} - tooltip={t('billing.plansCommon.vectorSpaceTooltip') as string} - /> - - } - label={t('billing.plansCommon.documentsRequestQuota', { count: planInfo.documentsRequestQuota })} - tooltip={t('billing.plansCommon.documentsRequestQuotaTooltip')} - /> - } - label={ - planInfo.apiRateLimit === NUM_INFINITE ? `${t('billing.plansCommon.unlimitedApiRate')}` - : `${t('billing.plansCommon.apiRateLimitUnit', { count: planInfo.apiRateLimit })} ${t('billing.plansCommon.apiRateLimit')}` - } - tooltip={planInfo.apiRateLimit === NUM_INFINITE ? null : t('billing.plansCommon.apiRateLimitTooltip') as string} - /> - } - label={[t(`billing.plansCommon.priority.${planInfo.documentProcessingPriority}`), t('billing.plansCommon.documentProcessingPriority')].join('')} - /> - - } - label={t('billing.plansCommon.annotatedResponse.title', { count: planInfo.annotatedResponse })} - tooltip={t('billing.plansCommon.annotatedResponse.tooltip') as string} - /> - } - label={t('billing.plansCommon.logsHistory', { days: planInfo.logHistory === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : `${planInfo.logHistory} ${t('billing.plansCommon.days')}` })} - /> -
-
- ) -} -export default React.memo(PlanItem) diff --git a/web/app/components/billing/pricing/plan-item/button.tsx b/web/app/components/billing/pricing/plan-item/button.tsx new file mode 100644 index 0000000000..fa19180de8 --- /dev/null +++ b/web/app/components/billing/pricing/plan-item/button.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import type { BasicPlan } from '../../type' +import { Plan } from '../../type' +import cn from '@/utils/classnames' +import { RiArrowRightLine } from '@remixicon/react' + +const BUTTON_CLASSNAME = { + [Plan.sandbox]: { + btnClassname: 'bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover text-text-primary', + btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + }, + [Plan.professional]: { + btnClassname: 'bg-saas-dify-blue-static hover:bg-saas-dify-blue-static-hover text-text-primary-on-surface', + btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + }, + [Plan.team]: { + btnClassname: 'bg-saas-background-inverted hover:bg-saas-background-inverted-hover text-background-default', + btnDisabledClassname: 'bg-components-button-tertiary-bg-disabled hover:bg-components-button-tertiary-bg-disabled text-text-disabled', + }, +} + +type ButtonProps = { + plan: BasicPlan + isPlanDisabled: boolean + btnText: string + handleGetPayUrl: () => void +} + +const Button = ({ + plan, + isPlanDisabled, + btnText, + handleGetPayUrl, +}: ButtonProps) => { + return ( + + ) +} + +export default React.memo(Button) diff --git a/web/app/components/billing/pricing/plan-item/index.tsx b/web/app/components/billing/pricing/plan-item/index.tsx new file mode 100644 index 0000000000..f435a9aaaa --- /dev/null +++ b/web/app/components/billing/pricing/plan-item/index.tsx @@ -0,0 +1,132 @@ +'use client' +import type { FC } from 'react' +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import type { BasicPlan } from '../../type' +import { Plan } from '../../type' +import { ALL_PLANS } from '../../config' +import Toast from '../../../base/toast' +import { PlanRange } from '../select-plan-range' +import { useAppContext } from '@/context/app-context' +import { fetchSubscriptionUrls } from '@/service/billing' +import List from './list' +import Button from './button' + +const ICON_MAP = { + [Plan.sandbox]:
, + [Plan.professional]:
, + [Plan.team]:
, +} + +type PlanItemProps = { + currentPlan: BasicPlan + plan: BasicPlan + planRange: PlanRange + canPay: boolean +} + +const PlanItem: FC = ({ + plan, + currentPlan, + planRange, +}) => { + const { t } = useTranslation() + const [loading, setLoading] = React.useState(false) + const i18nPrefix = `billing.plans.${plan}` + const isFreePlan = plan === Plan.sandbox + const isMostPopularPlan = plan === Plan.professional + const planInfo = ALL_PLANS[plan] + const isYear = planRange === PlanRange.yearly + const isCurrent = plan === currentPlan + const isPlanDisabled = planInfo.level <= ALL_PLANS[currentPlan].level + const { isCurrentWorkspaceManager } = useAppContext() + + const btnText = useMemo(() => { + if (isCurrent) + return t('billing.plansCommon.currentPlan') + + return ({ + [Plan.sandbox]: t('billing.plansCommon.startForFree'), + [Plan.professional]: t('billing.plansCommon.startBuilding'), + [Plan.team]: t('billing.plansCommon.getStarted'), + })[plan] + }, [isCurrent, plan, t]) + + const handleGetPayUrl = async () => { + if (loading) + return + + if (isPlanDisabled) + return + + if (isFreePlan) + return + + // Only workspace manager can buy plan + if (!isCurrentWorkspaceManager) { + Toast.notify({ + type: 'error', + message: t('billing.buyPermissionDeniedTip'), + className: 'z-[1001]', + }) + return + } + setLoading(true) + try { + const res = await fetchSubscriptionUrls(plan, isYear ? 'year' : 'month') + // Adb Block additional tracking block the gtag, so we need to redirect directly + window.location.href = res.url + } + finally { + setLoading(false) + } + } + return ( +
+
+
+ {ICON_MAP[plan]} +
+
+
{t(`${i18nPrefix}.name`)}
+ { + isMostPopularPlan && ( +
+ + {t('billing.plansCommon.mostPopular')} + +
+ ) + } +
+
{t(`${i18nPrefix}.description`)}
+
+
+
+ {/* Price */} + {isFreePlan && ( + {t('billing.plansCommon.free')} + )} + {!isFreePlan && ( + <> + {isYear && ${planInfo.price * 12}} + ${isYear ? planInfo.price * 10 : planInfo.price} + + {t('billing.plansCommon.priceTip')} + {t(`billing.plansCommon.${!isYear ? 'month' : 'year'}`)} + + + )} +
+
+ +
+ ) +} +export default React.memo(PlanItem) diff --git a/web/app/components/billing/pricing/plan-item/list/index.tsx b/web/app/components/billing/pricing/plan-item/list/index.tsx new file mode 100644 index 0000000000..d183e003c6 --- /dev/null +++ b/web/app/components/billing/pricing/plan-item/list/index.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { type BasicPlan, Plan } from '../../../type' +import Item from './item' +import { useTranslation } from 'react-i18next' +import { ALL_PLANS, NUM_INFINITE } from '../../../config' +import Divider from '@/app/components/base/divider' + +type ListProps = { + plan: BasicPlan +} + +const List = ({ + plan, +}: ListProps) => { + const { t } = useTranslation() + const isFreePlan = plan === Plan.sandbox + const planInfo = ALL_PLANS[plan] + + return ( +
+ + + + + + + + + + + + + + + +
+ ) +} + +export default React.memo(List) diff --git a/web/app/components/billing/pricing/plan-item/list/item/index.tsx b/web/app/components/billing/pricing/plan-item/list/item/index.tsx new file mode 100644 index 0000000000..8292a0297d --- /dev/null +++ b/web/app/components/billing/pricing/plan-item/list/item/index.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import Tooltip from './tooltip' + +type ItemProps = { + label: string + tooltip?: string +} + +const Item = ({ + label, + tooltip, +}: ItemProps) => { + return ( +
+ {label} + {tooltip && ( + + )} +
+ ) +} + +export default React.memo(Item) diff --git a/web/app/components/billing/pricing/plan-item/list/item/tooltip.tsx b/web/app/components/billing/pricing/plan-item/list/item/tooltip.tsx new file mode 100644 index 0000000000..84e0282993 --- /dev/null +++ b/web/app/components/billing/pricing/plan-item/list/item/tooltip.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { RiInfoI } from '@remixicon/react' + +type TooltipProps = { + content: string +} + +const Tooltip = ({ + content, +}: TooltipProps) => { + return ( +
+
+ {content} +
+
+ +
+
+ ) +} + +export default React.memo(Tooltip) diff --git a/web/app/components/billing/pricing/plans/index.tsx b/web/app/components/billing/pricing/plans/index.tsx new file mode 100644 index 0000000000..7983b401b5 --- /dev/null +++ b/web/app/components/billing/pricing/plans/index.tsx @@ -0,0 +1,76 @@ +import Divider from '@/app/components/base/divider' +import { type BasicPlan, Plan, type UsagePlanInfo } from '../../type' +import PlanItem from '../plan-item' +import type { PlanRange } from '../plan-switcher/plan-range-switcher' + +type PlansProps = { + plan: { + type: BasicPlan + usage: UsagePlanInfo + total: UsagePlanInfo + } + currentPlan: string + planRange: PlanRange + canPay: boolean +} + +const Plans = ({ + plan, + currentPlan, + planRange, + canPay, +}: PlansProps) => { + return ( +
+
+ { + currentPlan === 'cloud' && ( + <> + + + + + + + ) + } + { + // currentPlan === 'self' && <> + // + // + // + // + } +
+
+ ) +} + +export default Plans diff --git a/web/i18n/en-US/billing.ts b/web/i18n/en-US/billing.ts index daebcf5d64..ddc690712d 100644 --- a/web/i18n/en-US/billing.ts +++ b/web/i18n/en-US/billing.ts @@ -43,6 +43,7 @@ const translation = { contractSales: 'Contact sales', contractOwner: 'Contact team manager', startForFree: 'Start for Free', + startBuilding: 'Start Building', getStarted: 'Get Started', contactSales: 'Contact Sales', talkToSales: 'Talk to Sales', @@ -110,17 +111,17 @@ const translation = { sandbox: { name: 'Sandbox', for: 'Free Trial of Core Capabilities', - description: 'Free Trial of Core Capabilities', + description: 'Try core features for free.', }, professional: { name: 'Professional', for: 'For Independent Developers/Small Teams', - description: 'For Independent Developers/Small Teams', + description: 'For independent developers & small teams ready to build production AI applications.', }, team: { name: 'Team', for: 'For Medium-sized Teams', - description: 'For Medium-sized Teams', + description: 'For medium-sized teams requiring collaboration and higher throughput.', }, community: { name: 'Community', diff --git a/web/i18n/zh-Hans/billing.ts b/web/i18n/zh-Hans/billing.ts index 3086203182..6b4150a211 100644 --- a/web/i18n/zh-Hans/billing.ts +++ b/web/i18n/zh-Hans/billing.ts @@ -42,6 +42,7 @@ const translation = { contractSales: '联系销售', contractOwner: '联系团队管理员', startForFree: '免费开始', + startBuilding: '开始构建', getStarted: '立即开始', contactSales: '联系销售', talkToSales: '联系销售', @@ -109,17 +110,17 @@ const translation = { sandbox: { name: 'Sandbox', for: '核心能力的免费试用', - description: '核心功能免费试用', + description: '免费试用核心功能。', }, professional: { name: 'Professional', for: '适合独立开发者或小团队', - description: '对于独立开发者/小团队', + description: '适合准备构建生产级 AI 应用的独立开发者和小团队。', }, team: { name: 'Team', for: '适合中等规模的团队', - description: '对于中型团队', + description: '适合需要协作和更高吞吐量的中等规模团队。', }, community: { name: 'Community',