mirror of https://github.com/langgenius/dify.git
feat: Implement billing plan selection UI with plan switcher and range options
This commit is contained in:
parent
72ea3b4d01
commit
d8a9645e83
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '当前计划',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue