feat: Implement billing plan selection UI with plan switcher and range options

This commit is contained in:
twwu 2025-08-14 15:01:38 +08:00
parent 72ea3b4d01
commit d8a9645e83
12 changed files with 256 additions and 40 deletions

View File

@ -60,6 +60,7 @@
@apply
border-[0.5px]
shadow-xs
backdrop-blur-[5px]
bg-components-button-secondary-bg
border-components-button-secondary-border
hover:bg-components-button-secondary-bg-hover
@ -69,6 +70,7 @@
.btn-secondary.btn-disabled {
@apply
backdrop-blur-sm
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
text-components-button-secondary-text-disabled;

View File

@ -0,0 +1,42 @@
import React from 'react'
import DifyLogo from '../../base/logo/dify-logo'
import { useTranslation } from 'react-i18next'
import Button from '../../base/button'
import { RiCloseLine } from '@remixicon/react'
type HeaderProps = {
onClose: () => void
}
const Header = ({
onClose,
}: HeaderProps) => {
const { t } = useTranslation()
return (
<div className='flex min-h-[105px] w-full justify-center px-10'>
<div className='relative flex max-w-[1680px] grow flex-col justify-end gap-y-1 border-x border-divider-accent p-6 pt-8'>
<div className='flex items-end'>
<div className='py-[5px]'>
<DifyLogo className='h-[27px] w-[60px]' />
</div>
<span className='overflow-visible bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent'>
{t('billing.plansCommon.title.plans')}
</span>
</div>
<p className='system-sm-regular text-text-tertiary'>
{t('billing.plansCommon.title.description')}
</p>
<Button
variant='secondary'
className='absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2'
onClick={onClose}
>
<RiCloseLine className='size-5' />
</Button>
</div>
</div>
)
}
export default React.memo(Header)

View File

@ -1,51 +1,63 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine, RiCloseLine, RiCloudFill, RiTerminalBoxFill } from '@remixicon/react'
import Link from 'next/link'
import Header from './header'
import PlanSwitcher from './plan-switcher'
import { PlanRange } from './plan-switcher/plan-range-switcher'
import { useKeyPress } from 'ahooks'
import { Plan, SelfHostedPlan } from '../type'
import TabSlider from '../../base/tab-slider'
import SelectPlanRange, { PlanRange } from './select-plan-range'
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'
// import { useTranslation } from 'react-i18next'
// import { RiArrowRightUpLine, RiCloseLine, RiCloudFill, RiTerminalBoxFill } from '@remixicon/react'
// import Link from 'next/link'
// import { Plan, SelfHostedPlan } from '../type'
// 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'
type Props = {
export type Category = 'cloud' | 'self'
type PricingProps = {
onCancel: () => void
}
const Pricing: FC<Props> = ({
const Pricing: FC<PricingProps> = ({
onCancel,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { isCurrentWorkspaceManager } = useAppContext()
const canPay = isCurrentWorkspaceManager
// const { t } = useTranslation()
// const { plan } = useProviderContext()
// const { isCurrentWorkspaceManager } = useAppContext()
// const canPay = isCurrentWorkspaceManager
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
const [currentPlan, setCurrentPlan] = React.useState<string>('cloud')
const [currentCategory, setCurrentCategory] = useState<Category>('cloud')
useKeyPress(['esc'], onCancel)
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
: 'https://dify.ai/pricing#plans-and-features'
// const pricingPageLanguage = useGetPricingPageLanguage()
// const pricingPageURL = pricingPageLanguage
// ? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
// : 'https://dify.ai/pricing#plans-and-features'
return createPortal(
<div
className='fixed inset-0 bottom-0 left-0 right-0 top-0 z-[1000] bg-background-overlay-backdrop p-4 backdrop-blur-[6px]'
className='fixed inset-0 bottom-0 left-0 right-0 top-0 z-[1000] overflow-auto bg-saas-background'
onClick={e => e.stopPropagation()}
>
<div className='relative h-full w-full overflow-auto rounded-2xl border border-effects-highlight bg-saas-background'>
<div
<div className='relative h-full min-w-[1200px]'>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
{/* <div
className='fixed right-7 top-7 z-[1001] flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover'
onClick={onCancel}
>
@ -137,8 +149,8 @@ const Pricing: FC<Props> = ({
<RiArrowRightUpLine className='size-4' />
</div>
</div>
</GridMask>
</div >
</GridMask> */}
</div>
</div >,
document.body,
)

View File

@ -0,0 +1,64 @@
import type { FC } from 'react'
import React from 'react'
import type { Category } from '../index'
import { useTranslation } from 'react-i18next'
import { Ri24HoursFill, RiTerminalBoxFill } from '@remixicon/react'
import Tab from './tab'
import Divider from '@/app/components/base/divider'
import type { PlanRange } from './plan-range-switcher'
import PlanRangeSwitcher from './plan-range-switcher'
type PlanSwitcherProps = {
currentCategory: Category
currentPlanRange: PlanRange
onChangeCategory: (category: Category) => void
onChangePlanRange: (value: PlanRange) => void
}
const PlanSwitcher: FC<PlanSwitcherProps> = ({
currentCategory,
currentPlanRange,
onChangeCategory,
onChangePlanRange,
}) => {
const { t } = useTranslation()
const tabs = {
cloud: {
value: 'cloud' as Category,
label: t('billing.plansCommon.cloud'),
Icon: Ri24HoursFill,
},
self: {
value: 'self' as Category,
label: t('billing.plansCommon.self'),
Icon: RiTerminalBoxFill,
},
}
return (
<div className='flex w-full justify-center border-t border-divider-accent px-10'>
<div className='flex max-w-[1680px] grow items-center justify-between border-x border-divider-accent p-1'>
<div className='flex items-center'>
<Tab<Category>
{...tabs.cloud}
isActive={currentCategory === tabs.cloud.value}
onClick={onChangeCategory}
/>
<Divider type='vertical' className='mx-2 h-4 bg-divider-accent' />
<Tab<Category>
{...tabs.self}
isActive={currentCategory === tabs.self.value}
onClick={onChangeCategory}
/>
</div>
<PlanRangeSwitcher
value={currentPlanRange}
onChange={onChangePlanRange}
/>
</div>
</div>
)
}
export default React.memo(PlanSwitcher)

View File

@ -0,0 +1,38 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '../../../base/switch'
export enum PlanRange {
monthly = 'monthly',
yearly = 'yearly',
}
type PlanRangeSwitcherProps = {
value: PlanRange
onChange: (value: PlanRange) => void
}
const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center justify-end gap-x-3 pr-5'>
<Switch
size='l'
defaultValue={value === PlanRange.yearly}
onChange={(v) => {
onChange(v ? PlanRange.yearly : PlanRange.monthly)
}}
/>
<span className='system-md-regular text-text-tertiary'>
{t('billing.plansCommon.annualBilling', { percent: 17 })}
</span>
</div>
)
}
export default React.memo(PlanRangeSwitcher)

View File

@ -0,0 +1,37 @@
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
type TabProps<T> = {
Icon: React.ComponentType<any>
value: T
label: string
isActive: boolean
onClick: (value: T) => void
}
const Tab = <T,>({
Icon,
value,
label,
isActive,
onClick,
}: TabProps<T>) => {
const handleClick = useCallback(() => {
onClick(value)
}, [onClick, value])
return (
<div
className={cn(
'flex cursor-pointer items-center justify-center gap-x-2 px-5 py-3 text-text-secondary',
isActive && 'text-saas-dify-blue-accessible',
)}
onClick={handleClick}
>
<Icon className='size-4' />
<span className='system-xl-semibold'>{label}</span>
</div>
)
}
export default React.memo(Tab) as typeof Tab

View File

@ -10,6 +10,8 @@ import './styles/globals.css'
import './styles/markdown.scss'
import GlobalPublicStoreProvider from '@/context/global-public-context'
import { DatasetAttr } from '@/types/feature'
import { Instrument_Serif } from 'next/font/google'
import cn from '@/utils/classnames'
export const viewport: Viewport = {
width: 'device-width',
@ -19,6 +21,13 @@ export const viewport: Viewport = {
userScalable: false,
}
const instrumentSerif = Instrument_Serif({
weight: ['400'],
style: ['normal', 'italic'],
subsets: ['latin'],
variable: '--font-instrument-serif',
})
const LocaleLayout = async ({
children,
}: {
@ -51,15 +60,15 @@ const LocaleLayout = async ({
}
return (
<html lang={locale ?? 'en'} className="h-full" suppressHydrationWarning>
<html lang={locale ?? 'en'} className={cn('h-full', instrumentSerif.variable)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#FFFFFF" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name='theme-color' content='#FFFFFF' />
<meta name='mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
</head>
<body
className="color-scheme h-full select-auto"
className='color-scheme h-full select-auto'
{...datasetMap}
>
<ThemeProvider

View File

@ -17,7 +17,10 @@ const translation = {
viewBilling: 'Manage billing and subscriptions',
buyPermissionDeniedTip: 'Please contact your enterprise administrator to subscribe',
plansCommon: {
title: 'Pricing that powers your AI journey',
title: {
plans: 'plans',
description: 'Select the plan that best fits your team\'s needs.',
},
freeTrialTipPrefix: 'Sign up and get a ',
freeTrialTip: 'free trial of 200 OpenAI calls. ',
freeTrialTipSuffix: 'No credit card required',
@ -33,7 +36,7 @@ const translation = {
year: 'year',
save: 'Save ',
free: 'Free',
annualBilling: 'Annual Billing',
annualBilling: 'Bill Annually Save {{percent}}%',
comparePlanAndFeatures: 'Compare plans & features',
priceTip: 'per workspace/',
currentPlan: 'Current Plan',

View File

@ -16,7 +16,10 @@ const translation = {
viewBilling: '管理账单及订阅',
buyPermissionDeniedTip: '请联系企业管理员订阅',
plansCommon: {
title: '为您的 AI 之旅提供动力的定价套餐',
title: {
plans: '方案',
description: '选择最适合您团队需求的方案。',
},
freeTrialTipPrefix: '注册即可',
freeTrialTip: '免费试用 200 个 OpenAI 消息额度',
freeTrialTipSuffix: '。无需信用卡',
@ -32,7 +35,7 @@ const translation = {
year: '年',
save: '节省',
free: '免费',
annualBilling: '按年计费',
annualBilling: '按年计费节省 {{percent}}%',
comparePlanAndFeatures: '对比套餐 & 功能特性',
priceTip: '每个团队空间/',
currentPlan: '当前计划',

View File

@ -87,6 +87,9 @@ const config = {
2: '0.02',
8: '0.08',
},
fontFamily: {
instrument: ['var(--font-instrument-serif)', 'serif'],
},
fontSize: {
'2xs': '0.625rem',
},
@ -129,6 +132,7 @@ const config = {
'tag-selector-mask-hover-bg': 'var(--color-tag-selector-mask-hover-bg)',
'pipeline-template-card-hover-bg': 'var(--color-pipeline-template-card-hover-bg)',
'pipeline-add-documents-title-bg': 'var(--color-pipeline-add-documents-title-bg)',
'billing-plan-title-bg': 'var(--color-billing-plan-title-bg)',
},
animation: {
'spin-slow': 'spin 2s linear infinite',

View File

@ -69,4 +69,5 @@ html[data-theme="dark"] {
--color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(58, 58, 64, 1) 60.27%, rgba(58, 58, 64, 0) 100%);
--color-pipeline-add-documents-title-bg: linear-gradient(92deg, rgba(54, 191, 250, 1) 0%, rgba(41, 109, 255, 1) 97.78%);
--color-background-gradient-bg-fill-chat-bubble-bg-3: #27314d;
--color-billing-plan-title-bg: linear-gradient(95deg, #0A68FF 29.47%, #03F 105.31%);
}

View File

@ -69,4 +69,5 @@ html[data-theme="light"] {
--color-pipeline-template-card-hover-bg: linear-gradient(0deg, rgba(249, 250, 251, 1) 60.27%, rgba(249, 250, 251, 0) 100%);
--color-pipeline-add-documents-title-bg: linear-gradient(92deg, rgba(11, 165, 236, 0.95) 0%, rgba(21, 90, 239, 0.95) 97.78%);
--color-background-gradient-bg-fill-chat-bubble-bg-3: #e1effe;
--color-billing-plan-title-bg: linear-gradient(95deg, #03F 29.47%, #03F 105.31%);
}