feat: SaaS price plan frontend (#1683)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
Joel
2023-12-03 22:10:16 +08:00
committed by GitHub
parent 053102f433
commit 75a6122173
73 changed files with 2919 additions and 266 deletions

View File

@@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import UpgradeBtn from '../upgrade-btn'
import AppsInfo from '../usage-info/apps-info'
import s from './style.module.css'
import GridMask from '@/app/components/base/grid-mask'
const AppsFull: FC = () => {
const { t } = useTranslation()
return (
<GridMask wrapperClassName='rounded-lg' canvasClassName='rounded-lg' gradientClassName='rounded-lg'>
<div className='mt-6 px-3.5 py-4 border-2 border-solid border-transparent rounded-lg shadow-md flex flex-col transition-all duration-200 ease-in-out cursor-pointer'>
<div className='flex justify-between items-center'>
<div className={cn(s.textGradient, 'leading-[24px] text-base font-semibold')}>
<div>{t('billing.apps.fullTipLine1')}</div>
<div>{t('billing.apps.fullTipLine2')}</div>
</div>
<div className='flex'>
<UpgradeBtn />
</div>
</div>
<AppsInfo className='mt-4' />
</div>
</GridMask>
)
}
export default React.memo(AppsFull)

View File

@@ -0,0 +1,7 @@
.textGradient {
background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}

View File

@@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import UpgradeBtn from '../upgrade-btn'
import s from './style.module.css'
import GridMask from '@/app/components/base/grid-mask'
const AppsFull: FC = () => {
const { t } = useTranslation()
return (
<GridMask wrapperClassName='rounded-lg' canvasClassName='rounded-lg' gradientClassName='rounded-lg'>
<div className='col-span-1 px-3.5 pt-3.5 border-2 border-solid border-transparent rounded-lg shadow-xs min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg'>
<div className={cn(s.textGradient, 'leading-[24px] text-base font-semibold')}>
<div>{t('billing.apps.fullTipLine1')}</div>
<div>{t('billing.apps.fullTipLine2')}</div>
</div>
<div className='flex mt-8'>
<UpgradeBtn />
</div>
</div>
</GridMask>
)
}
export default React.memo(AppsFull)

View File

@@ -0,0 +1,7 @@
.textGradient {
background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}

View File

@@ -0,0 +1,42 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import PlanComp from '../plan'
import { ReceiptList } from '../../base/icons/src/vender/line/financeAndECommerce'
import { LinkExternal01 } from '../../base/icons/src/vender/line/general'
import { fetchBillingUrl } from '@/service/billing'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
const Billing: FC = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const [billingUrl, setBillingUrl] = React.useState('')
const { enableBilling } = useProviderContext()
useEffect(() => {
if (!enableBilling && !isCurrentWorkspaceManager)
return
(async () => {
const { url } = await fetchBillingUrl()
setBillingUrl(url)
})()
}, [isCurrentWorkspaceManager])
return (
<div>
<PlanComp />
{enableBilling && isCurrentWorkspaceManager && billingUrl && (
<a className='mt-5 flex px-6 justify-between h-12 items-center bg-gray-50 rounded-xl cursor-pointer' href={billingUrl} target='_blank'>
<div className='flex items-center'>
<ReceiptList className='w-4 h-4 text-gray-700' />
<div className='ml-2 text-sm font-normal text-gray-700'>{t('billing.viewBilling')}</div>
</div>
<LinkExternal01 className='w-3 h-3' />
</a>
)}
</div>
)
}
export default React.memo(Billing)

View File

@@ -0,0 +1,64 @@
import { Plan, type PlanInfo, Priority } from '@/app/components/billing/type'
const supportModelProviders = 'OpenAI/Anthropic/Azure OpenAI/ Llama2/Hugging Face/Replicate'
export const NUM_INFINITE = 99999999
export const contactSalesUrl = 'mailto:business@dify.ai'
export const ALL_PLANS: Record<Plan, PlanInfo> = {
sandbox: {
level: 1,
price: 0,
modelProviders: supportModelProviders,
teamMembers: 1,
buildApps: 10,
vectorSpace: 10,
documentProcessingPriority: Priority.standard,
logHistory: 30,
},
professional: {
level: 2,
price: 59,
modelProviders: supportModelProviders,
teamMembers: 3,
buildApps: 50,
vectorSpace: 200,
documentProcessingPriority: Priority.priority,
logHistory: NUM_INFINITE,
},
team: {
level: 3,
price: 159,
modelProviders: supportModelProviders,
teamMembers: NUM_INFINITE,
buildApps: NUM_INFINITE,
vectorSpace: 1000,
documentProcessingPriority: Priority.topPriority,
logHistory: NUM_INFINITE,
},
enterprise: {
level: 4,
price: 0,
modelProviders: supportModelProviders,
teamMembers: NUM_INFINITE,
buildApps: NUM_INFINITE,
vectorSpace: NUM_INFINITE,
documentProcessingPriority: Priority.topPriority,
logHistory: NUM_INFINITE,
},
}
export const defaultPlan = {
type: Plan.sandbox,
usage: {
vectorSpace: 1,
buildApps: 1,
teamMembers: 1,
},
total: {
vectorSpace: 10,
buildApps: 10,
teamMembers: 1,
},
}

View File

@@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import UpgradeBtn from '../upgrade-btn'
import { Plan } from '../type'
import { useProviderContext } from '@/context/provider-context'
type Props = {
onClick: () => void
}
const HeaderBillingBtn: FC<Props> = ({
onClick,
}) => {
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
const {
type,
} = plan
const name = (() => {
if (type === Plan.professional)
return 'pro'
return type
})()
const classNames = (() => {
if (type === Plan.professional)
return 'border-[#E0F2FE] hover:border-[#B9E6FE] bg-[#E0F2FE] text-[#026AA2]'
if (type === Plan.team)
return 'border-[#E0EAFF] hover:border-[#C7D7FE] bg-[#E0EAFF] text-[#3538CD]'
return ''
})()
if (!enableBilling || !isFetchedPlan)
return null
if (type === Plan.sandbox)
return <UpgradeBtn onClick={onClick} isShort />
return (
<div onClick={onClick} className={cn(classNames, 'flex items-center h-[22px] px-2 rounded-md border text-xs font-semibold uppercase cursor-pointer')}>
{name}
</div>
)
}
export default React.memo(HeaderBillingBtn)

View File

@@ -0,0 +1,92 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { Plan } from '../type'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import AppsInfo from '../usage-info/apps-info'
import UpgradeBtn from '../upgrade-btn'
import { useProviderContext } from '@/context/provider-context'
const typeStyle = {
[Plan.sandbox]: {
textClassNames: 'text-gray-900',
bg: 'linear-gradient(113deg, rgba(255, 255, 255, 0.51) 3.51%, rgba(255, 255, 255, 0.00) 111.71%), #EAECF0',
},
[Plan.professional]: {
textClassNames: 'text-[#026AA2]',
bg: 'linear-gradient(113deg, rgba(255, 255, 255, 0.51) 3.51%, rgba(255, 255, 255, 0.00) 111.71%), #E0F2FE',
},
[Plan.team]: {
textClassNames: 'text-[#3538CD]',
bg: 'linear-gradient(113deg, rgba(255, 255, 255, 0.51) 3.51%, rgba(255, 255, 255, 0.00) 111.71%), #E0EAFF',
},
[Plan.enterprise]: {
textClassNames: 'text-[#DC6803]',
bg: 'linear-gradient(113deg, rgba(255, 255, 255, 0.51) 3.51%, rgba(255, 255, 255, 0.00) 111.71%), #FFEED3',
},
}
type Props = {
loc?: string
}
const PlanComp: FC<Props> = ({
loc,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const {
type,
} = plan
const isInHeader = loc === 'header'
return (
<div
className='rounded-xl border border-white select-none'
style={{
background: typeStyle[type].bg,
boxShadow: '5px 7px 12px 0px rgba(0, 0, 0, 0.06)',
}}
>
<div className='flex justify-between px-6 py-5 items-center'>
<div>
<div
className='leading-[18px] text-xs font-normal opacity-70'
style={{
color: 'rgba(0, 0, 0, 0.64)',
}}
>
{t('billing.currentPlan')}
</div>
<div className={cn(typeStyle[type].textClassNames, 'leading-[125%] text-lg font-semibold uppercase')}>
{t(`billing.plans.${type}.name`)}
</div>
</div>
{(!isInHeader || (isInHeader && type !== Plan.sandbox)) && (
<UpgradeBtn
className='flex-shrink-0'
isPlain={type !== Plan.sandbox}
/>
)}
</div>
{/* Plan detail */}
<div className='rounded-xl bg-white px-6 py-3'>
<VectorSpaceInfo className='py-3' />
<AppsInfo className='py-3' />
{isInHeader && type === Plan.sandbox && (
<UpgradeBtn
className='flex-shrink-0 my-3'
isFull
size='lg'
isPlain={type !== Plan.sandbox}
/>
)}
</div>
</div>
)
}
export default React.memo(PlanComp)

View File

@@ -0,0 +1,74 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Plan } from '../type'
import SelectPlanRange, { PlanRange } from './select-plan-range'
import PlanItem from './plan-item'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'
import { useProviderContext } from '@/context/provider-context'
import GridMask from '@/app/components/base/grid-mask'
type Props = {
onCancel: () => void
}
const Pricing: FC<Props> = ({
onCancel,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
return createPortal(
<div
className='fixed inset-0 flex bg-white z-[1000] overflow-auto'
onClick={e => e.stopPropagation()}
>
<GridMask wrapperClassName='grow'>
<div className='grow width-[0] mt-6 p-6 flex flex-col items-center'>
<div className='mb-3 leading-[38px] text-[30px] font-semibold text-gray-900'>
{t('billing.plansCommon.title')}
</div>
<SelectPlanRange
value={planRange}
onChange={setPlanRange}
/>
<div className='mt-8 pb-6 w-full justify-center flex-nowrap flex space-x-3'>
<PlanItem
currentPlan={plan.type}
plan={Plan.sandbox}
planRange={planRange}
/>
<PlanItem
currentPlan={plan.type}
plan={Plan.professional}
planRange={planRange}
/>
<PlanItem
currentPlan={plan.type}
plan={Plan.team}
planRange={planRange}
/>
<PlanItem
currentPlan={plan.type}
plan={Plan.enterprise}
planRange={planRange}
/>
</div>
</div>
</GridMask>
<div
className='fixed top-6 right-6 flex items-center justify-center w-10 h-10 bg-black/[0.05] rounded-full backdrop-blur-[2px] cursor-pointer z-[1001]'
onClick={onCancel}
>
<XClose className='w-4 h-4 text-gray-900' />
</div>
</div>,
document.body,
)
}
export default React.memo(Pricing)

View File

@@ -0,0 +1,221 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { Plan } from '../type'
import { ALL_PLANS, NUM_INFINITE, contactSalesUrl } from '../config'
import Toast from '../../base/toast'
import { PlanRange } from './select-plan-range'
import { useAppContext } from '@/context/app-context'
import { fetchSubscriptionUrls } from '@/service/billing'
type Props = {
currentPlan: Plan
plan: Plan
planRange: PlanRange
}
const KeyValue = ({ label, value }: { label: string; value: string | number | JSX.Element }) => {
return (
<div className='mt-3.5 leading-[125%] text-[13px] font-medium'>
<div className='text-gray-500'>{label}</div>
<div className='mt-0.5 text-gray-900'>{value}</div>
</div>
)
}
const priceClassName = 'leading-[32px] text-[28px] font-bold text-gray-900'
const style = {
[Plan.sandbox]: {
bg: 'bg-[#F2F4F7]',
title: 'text-gray-900',
hoverAndActive: '',
},
[Plan.professional]: {
bg: 'bg-[#E0F2FE]',
title: 'text-[#026AA2]',
hoverAndActive: 'hover:shadow-lg hover:!text-white hover:!bg-[#0086C9] hover:!border-[#026AA2] active:!text-white active:!bg-[#026AA2] active:!border-[#026AA2]',
},
[Plan.team]: {
bg: 'bg-[#E0EAFF]',
title: 'text-[#3538CD]',
hoverAndActive: 'hover:shadow-lg hover:!text-white hover:!bg-[#444CE7] hover:!border-[#3538CD] active:!text-white active:!bg-[#3538CD] active:!border-[#3538CD]',
},
[Plan.enterprise]: {
bg: 'bg-[#FFEED3]',
title: 'text-[#DC6803]',
hoverAndActive: 'hover:shadow-lg hover:!text-white hover:!bg-[#F79009] hover:!border-[#DC6803] active:!text-white active:!bg-[#DC6803] active:!border-[#DC6803]',
},
}
const PlanItem: FC<Props> = ({
plan,
currentPlan,
planRange,
}) => {
const { t } = useTranslation()
const [loading, setLoading] = React.useState(false)
const i18nPrefix = `billing.plans.${plan}`
const isFreePlan = plan === Plan.sandbox
const isEnterprisePlan = plan === Plan.enterprise
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.getStartedWith')}<span className='capitalize'>&nbsp;{plan}</span></>,
[Plan.team]: <>{t('billing.plansCommon.getStartedWith')}<span className='capitalize'>&nbsp;{plan}</span></>,
[Plan.enterprise]: t('billing.plansCommon.talkToSales'),
})[plan]
})()
const comingSoon = (
<div className='leading-[12px] text-[9px] font-semibold text-[#3538CD] uppercase'>{t('billing.plansCommon.comingSoon')}</div>
)
const supportContent = (() => {
switch (plan) {
case Plan.sandbox:
return t('billing.plansCommon.supportItems.communityForums')
case Plan.professional:
return t('billing.plansCommon.supportItems.emailSupport')
case Plan.team:
return (
<div>
<div>{t('billing.plansCommon.supportItems.priorityEmail')}</div>
<div className='mt-3.5 flex items-center space-x-1'>
<div>+ {t('billing.plansCommon.supportItems.logoChange')}</div>
<div>{comingSoon}</div>
</div>
<div className='mt-3.5 flex items-center space-x-1'>
<div>+ {t('billing.plansCommon.supportItems.personalizedSupport')}</div>
<div>{comingSoon}</div>
</div>
</div>
)
case Plan.enterprise:
return (
<div>
<div>{t('billing.plansCommon.supportItems.personalizedSupport')}</div>
<div className='mt-3.5 flex items-center space-x-1'>
<div>+ {t('billing.plansCommon.supportItems.dedicatedAPISupport')}</div>
</div>
<div className='mt-3.5 flex items-center space-x-1'>
<div>+ {t('billing.plansCommon.supportItems.customIntegration')}</div>
</div>
</div>
)
default:
return ''
}
})()
const handleGetPayUrl = async () => {
if (loading)
return
if (isPlanDisabled)
return
if (isFreePlan)
return
if (isEnterprisePlan) {
window.location.href = contactSalesUrl
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')
window.location.href = res.url
}
finally {
setLoading(false)
}
}
return (
<div className={cn(isMostPopularPlan ? 'bg-[#0086C9] p-0.5' : 'pt-7', 'flex flex-col min-w-[290px] w-[290px] h-[712px] rounded-xl')}>
{isMostPopularPlan && (
<div className='flex items-center h-7 justify-center leading-[12px] text-xs font-medium text-[#F5F8FF]'>{t('billing.plansCommon.mostPopular')}</div>
)}
<div className={cn(style[plan].bg, 'grow px-6 pt-6 rounded-[10px]')}>
<div className={cn(style[plan].title, 'mb-1 leading-[125%] text-lg font-semibold')}>{t(`${i18nPrefix}.name`)}</div>
<div className={cn(isFreePlan ? 'text-[#FB6514]' : 'text-gray-500', 'mb-4 h-8 leading-[125%] text-[13px] font-normal')}>{t(`${i18nPrefix}.description`)}</div>
{/* Price */}
{isFreePlan && (
<div className={priceClassName}>{t('billing.plansCommon.free')}</div>
)}
{isEnterprisePlan && (
<div className={priceClassName}>{t('billing.plansCommon.contactSales')}</div>
)}
{!isFreePlan && !isEnterprisePlan && (
<div className='flex items-end h-9'>
<div className={priceClassName}>${isYear ? planInfo.price * 10 : planInfo.price}</div>
<div className='ml-1'>
{isYear && <div className='leading-[18px] text-xs font-medium text-[#F26725]'>{t('billing.plansCommon.save')}${planInfo.price * 2}</div>}
<div className='leading-[18px] text-[15px] font-normal text-gray-500'>/{t(`billing.plansCommon.${!isYear ? 'month' : 'year'}`)}</div>
</div>
</div>
)}
<div
className={cn(isMostPopularPlan && !isCurrent && '!bg-[#444CE7] !text-white !border !border-[#3538CD] shadow-sm', isPlanDisabled ? 'opacity-30' : `${style[plan].hoverAndActive} cursor-pointer`, 'mt-4 flex h-11 items-center justify-center border-[2px] border-gray-900 rounded-3xl text-sm font-semibold text-gray-900')}
onClick={handleGetPayUrl}
>
{btnText}
</div>
<div className='my-4 h-[1px] bg-black/5'></div>
<div className='leading-[125%] text-[13px] font-normal text-gray-900'>
{t(`${i18nPrefix}.includesTitle`)}
</div>
<KeyValue
label={t('billing.plansCommon.modelProviders')}
value={planInfo.modelProviders}
/>
<KeyValue
label={t('billing.plansCommon.teamMembers')}
value={planInfo.teamMembers === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : planInfo.teamMembers}
/>
<KeyValue
label={t('billing.plansCommon.buildApps')}
value={planInfo.buildApps === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : planInfo.buildApps}
/>
<KeyValue
label={t('billing.plansCommon.vectorSpace')}
value={planInfo.vectorSpace === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : (planInfo.vectorSpace >= 1000 ? `${planInfo.vectorSpace / 1000}G` : `${planInfo.vectorSpace}MB`)}
/>
<KeyValue
label={t('billing.plansCommon.documentProcessingPriority')}
value={t(`billing.plansCommon.priority.${planInfo.documentProcessingPriority}`) as string}
/>
<KeyValue
label={t('billing.plansCommon.logsHistory')}
value={planInfo.logHistory === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : `${planInfo.logHistory} ${t('billing.plansCommon.days')}`}
/>
<KeyValue
label={t('billing.plansCommon.support')}
value={supportContent}
/>
</div>
</div>
)
}
export default React.memo(PlanItem)

View File

@@ -0,0 +1,55 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
export enum PlanRange {
monthly = 'monthly',
yearly = 'yearly',
}
type Props = {
value: PlanRange
onChange: (value: PlanRange) => void
}
const ITem: FC<{ isActive: boolean; value: PlanRange; text: string; onClick: (value: PlanRange) => void }> = ({ isActive, value, text, onClick }) => {
return (
<div
className={cn(isActive ? 'bg-[#155EEF] text-white' : 'text-gray-900', 'flex items-center px-8 h-11 rounded-[32px] cursor-pointer text-[15px] font-medium')}
onClick={() => onClick(value)}
>
{text}
</div>
)
}
const ArrowIcon = (
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="38" viewBox="0 0 26 38" fill="none">
<path d="M20.5005 3.49991C23.5 18 18.7571 25.2595 2.92348 31.9599" stroke="#F26725" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2.21996 32.2756L8.37216 33.5812" stroke="#F26725" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2.22168 32.2764L3.90351 27.4459" stroke="#F26725" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
const SelectPlanRange: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div>
<div className='mb-4 leading-[18px] text-sm font-medium text-[#F26725]'>{t('billing.plansCommon.yearlyTip')}</div>
<div className='inline-flex relative p-1 rounded-full bg-[#F5F8FF] border border-black/5'>
<ITem isActive={value === PlanRange.monthly} value={PlanRange.monthly} text={t('billing.plansCommon.planRange.monthly') as string} onClick={onChange} />
<ITem isActive={value === PlanRange.yearly} value={PlanRange.yearly} text={t('billing.plansCommon.planRange.yearly') as string} onClick={onChange} />
<div className='absolute right-0 top-[-16px] '>
{ArrowIcon}
</div>
</div>
</div>
)
}
export default React.memo(SelectPlanRange)

View File

@@ -0,0 +1,60 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DocumentProcessingPriority,
Plan,
} from '../type'
import { useProviderContext } from '@/context/provider-context'
import {
ZapFast,
ZapNarrow,
} from '@/app/components/base/icons/src/vender/solid/general'
import TooltipPlus from '@/app/components/base/tooltip-plus'
const PriorityLabel = () => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const priority = useMemo(() => {
if (plan.type === Plan.sandbox)
return DocumentProcessingPriority.standard
if (plan.type === Plan.professional)
return DocumentProcessingPriority.priority
if (plan.type === Plan.team || plan.type === Plan.enterprise)
return DocumentProcessingPriority.topPriority
}, [plan])
return (
<TooltipPlus popupContent={
<div>
<div className='mb-1 text-xs font-semibold text-gray-700'>{`${t('billing.plansCommon.documentProcessingPriority')}: ${t(`billing.plansCommon.priority.${priority}`)}`}</div>
{
priority !== DocumentProcessingPriority.topPriority && (
<div className='text-xs text-gray-500'>{t('billing.plansCommon.documentProcessingPriorityTip')}</div>
)
}
</div>
}>
<span className={`
flex items-center ml-1 px-[5px] h-[18px] rounded border border-[#C7D7FE]
text-[10px] font-medium text-[#3538CD]
`}>
{
plan.type === Plan.professional && (
<ZapNarrow className='mr-0.5 w-3 h-3' />
)
}
{
(plan.type === Plan.team || plan.type === Plan.enterprise) && (
<ZapFast className='mr-0.5 w-3 h-3' />
)
}
{t(`billing.plansCommon.priority.${priority}`)}
</span>
</TooltipPlus>
)
}
export default PriorityLabel

View File

@@ -0,0 +1,22 @@
type ProgressBarProps = {
percent: number
color: string
}
const ProgressBar = ({
percent = 0,
color = '#2970FF',
}: ProgressBarProps) => {
return (
<div className='bg-[#F2F4F7] rounded-[4px]'>
<div
className='h-2 rounded-[4px]'
style={{
width: `${Math.min(percent, 100)}%`,
backgroundColor: color,
}}
/>
</div>
)
}
export default ProgressBar

View File

@@ -0,0 +1,59 @@
export enum Plan {
sandbox = 'sandbox',
professional = 'professional',
team = 'team',
enterprise = 'enterprise',
}
export enum Priority {
standard = 'standard',
priority = 'priority',
topPriority = 'top-priority',
}
export type PlanInfo = {
level: number
price: number
modelProviders: string
teamMembers: number
buildApps: number
vectorSpace: number
documentProcessingPriority: Priority
logHistory: number
}
export type UsagePlanInfo = Pick<PlanInfo, 'vectorSpace' | 'buildApps' | 'teamMembers'>
export enum DocumentProcessingPriority {
standard = 'standard',
priority = 'priority',
topPriority = 'top-priority',
}
export type CurrentPlanInfoBackend = {
enabled: boolean
subscription: {
plan: Plan
}
members: {
size: number
limit: number // total. 0 means unlimited
}
apps: {
size: number
limit: number // total. 0 means unlimited
}
vector_space: {
size: number
limit: number // total. 0 means unlimited
}
docs_processing: DocumentProcessingPriority
}
export type SubscriptionItem = {
plan: Plan
url: string
}
export type SubscriptionUrlsBackend = {
url: string
}

View File

@@ -0,0 +1,68 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { GoldCoin } from '../../base/icons/src/vender/solid/FinanceAndECommerce'
import { Sparkles } from '../../base/icons/src/public/billing'
import s from './style.module.css'
import { useModalContext } from '@/context/modal-context'
type Props = {
className?: string
isFull?: boolean
size?: 'md' | 'lg'
isPlain?: boolean
isShort?: boolean
onClick?: () => void
}
const PlainBtn = ({ className, onClick }: { className?: string; onClick: () => {} }) => {
const { t } = useTranslation()
return (
<div
className={cn(className, 'flex items-center h-8 px-3 rounded-lg border border-gray-200 bg-white shadow-sm cursor-pointer')}
onClick={onClick}
>
<div className='leading-[18px] text-[13px] font-medium text-gray-700'>
{t('billing.upgradeBtn.plain')}
</div>
</div>
)
}
const UpgradeBtn: FC<Props> = ({
className,
isPlain = false,
isFull = false,
isShort = false,
size = 'md',
onClick,
}) => {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
if (isPlain)
return <PlainBtn onClick={onClick || setShowPricingModal as any} className={className} />
return (
<div
className={cn(
s.upgradeBtn,
className,
isFull ? 'justify-center' : 'px-3',
size === 'lg' ? 'h-10' : 'h-9',
'relative flex items-center cursor-pointer border rounded-[20px] border-[#0096EA] text-white',
)}
onClick={onClick || setShowPricingModal}
>
<GoldCoin className='mr-1 w-3.5 h-3.5' />
<div className='text-xs font-normal'>{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}</div>
<Sparkles
className='absolute -right-1 -top-2 w-4 h-5 bg-cover'
/>
</div>
)
}
export default React.memo(UpgradeBtn)

View File

@@ -0,0 +1,9 @@
.upgradeBtn {
background: linear-gradient(99deg, rgba(255, 255, 255, 0.12) 7.16%, rgba(255, 255, 255, 0.00) 85.47%), linear-gradient(280deg, #00B2FF 12.96%, #132BFF 90.95%);
box-shadow: 0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(0, 162, 253, 0.12);
}
.upgradeBtn:hover {
background: linear-gradient(99deg, rgba(255, 255, 255, 0.12) 7.16%, rgba(255, 255, 255, 0.00) 85.47%), linear-gradient(280deg, #02C2FF 12.96%, #001AFF 90.95%);
box-shadow: 0px 4px 6px -2px rgba(16, 18, 40, 0.08), 0px 12px 16px -4px rgba(0, 209, 255, 0.08);
}

View File

@@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ChatBot } from '../../base/icons/src/vender/line/communication'
import UsageInfo from '../usage-info'
import { useProviderContext } from '@/context/provider-context'
type Props = {
className?: string
}
const AppsInfo: FC<Props> = ({
className,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const {
usage,
total,
} = plan
return (
<UsageInfo
className={className}
Icon={ChatBot}
name={t('billing.plansCommon.buildApps')}
usage={usage.buildApps}
total={total.buildApps}
/>
)
}
export default React.memo(AppsInfo)

View File

@@ -0,0 +1,75 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { InfoCircle } from '../../base/icons/src/vender/line/general'
import ProgressBar from '../progress-bar'
import { NUM_INFINITE } from '../config'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string
Icon: any
name: string
tooltip?: string
usage: number
total: number
unit?: string
}
const LOW = 50
const MIDDLE = 80
const UsageInfo: FC<Props> = ({
className,
Icon,
name,
tooltip,
usage,
total,
unit = '',
}) => {
const { t } = useTranslation()
const percent = usage / total * 100
const color = (() => {
if (percent < LOW)
return '#155EEF'
if (percent < MIDDLE)
return '#F79009'
return '#F04438'
})()
return (
<div className={className}>
<div className='flex justify-between h-5 items-center'>
<div className='flex items-center'>
<Icon className='w-4 h-4 text-gray-700' />
<div className='mx-1 leading-5 text-sm font-medium text-gray-700'>{name}</div>
{tooltip && (
<Tooltip htmlContent={<div className='w-[180px]'>
{tooltip}
</div>} selector='config-var-tooltip'>
<InfoCircle className='w-[14px] h-[14px] text-gray-400' />
</Tooltip>
)}
</div>
<div className='flex items-center leading-[18px] text-[13px] font-normal'>
<div style={{
color: percent < LOW ? '#344054' : color,
}}>{usage}{unit}</div>
<div className='mx-1 text-gray-300'>/</div>
<div className='text-gray-500'>{total === NUM_INFINITE ? t('billing.plansCommon.unlimited') : `${total}${unit}`}</div>
</div>
</div>
<div className='mt-2'>
<ProgressBar
percent={percent}
color={color}
/>
</div>
</div>
)
}
export default React.memo(UsageInfo)

View File

@@ -0,0 +1,34 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ArtificialBrain } from '../../base/icons/src/vender/line/development'
import UsageInfo from '../usage-info'
import { useProviderContext } from '@/context/provider-context'
type Props = {
className?: string
}
const VectorSpaceInfo: FC<Props> = ({
className,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const {
usage,
total,
} = plan
return (
<UsageInfo
className={className}
Icon={ArtificialBrain}
name={t('billing.plansCommon.vectorSpace')}
tooltip={t('billing.plansCommon.vectorSpaceTooltip') as string}
usage={usage.vectorSpace}
total={total.vectorSpace}
unit='MB'
/>
)
}
export default React.memo(VectorSpaceInfo)

View File

@@ -0,0 +1,25 @@
import type { CurrentPlanInfoBackend } from '../type'
import { NUM_INFINITE } from '@/app/components/billing/config'
const parseLimit = (limit: number) => {
if (limit === 0)
return NUM_INFINITE
return limit
}
export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
return {
type: data.subscription.plan,
usage: {
vectorSpace: data.vector_space.size,
buildApps: data.apps?.size || 0,
teamMembers: data.members.size,
},
total: {
vectorSpace: parseLimit(data.vector_space.limit),
buildApps: parseLimit(data.apps?.limit) || 0,
teamMembers: parseLimit(data.members.limit),
},
}
}

View File

@@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import UpgradeBtn from '../upgrade-btn'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import s from './style.module.css'
import { useProviderContext } from '@/context/provider-context'
import GridMask from '@/app/components/base/grid-mask'
const VectorSpaceFull: FC = () => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { total } = plan
return (
<GridMask wrapperClassName='border border-gray-200 rounded-xl' canvasClassName='rounded-xl' gradientClassName='rounded-xl'>
<div className='py-5 px-6'>
<div className='flex justify-between items-center'>
<div className={cn(s.textGradient, 'leading-[24px] text-base font-semibold')}>
<div>{t('billing.vectorSpace.fullTip')}</div>
<div>{t('billing.vectorSpace.fullSolution')}</div>
</div>
<UpgradeBtn />
</div>
<VectorSpaceInfo className='pt-4' />
</div>
</GridMask>
)
}
export default React.memo(VectorSpaceFull)

View File

@@ -0,0 +1,7 @@
.textGradient {
background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}