chore: improve the progress of education pay (#35851)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel
2026-05-06 18:42:03 +08:00
committed by GitHub
parent f3c3534e33
commit d648ce6888
21 changed files with 905 additions and 176 deletions

View File

@@ -1921,11 +1921,6 @@
"count": 4
}
},
"web/app/components/billing/plan/index.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/billing/pricing/assets/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 12
@@ -5099,11 +5094,6 @@
"count": 5
}
},
"web/app/education-apply/verify-state-modal.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/forgot-password/ForgotPasswordForm.spec.tsx": {
"ts/no-explicit-any": {
"count": 5

View File

@@ -13,6 +13,7 @@ export const baseProviderContextValue: ProviderContextState = {
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

View File

@@ -1,10 +1,8 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import { useEffect } from 'react'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import RootLoading from '@/app/loading'
import { useProviderContext } from '@/context/provider-context'
import {
useRouter,
@@ -13,17 +11,24 @@ import {
export default function EducationApply() {
const router = useRouter()
const { enableEducationPlan } = useProviderContext()
const {
enableEducationPlan,
isFetchedPlanInfo,
isLoadingEducationAccountInfo,
} = useProviderContext()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const showEducationApplyPage = useMemo(() => {
return enableEducationPlan && token
}, [enableEducationPlan, token])
useEffect(() => {
if (!showEducationApplyPage)
if (!isFetchedPlanInfo)
return
if (!enableEducationPlan || !token)
router.replace('/')
}, [showEducationApplyPage, router])
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
return <RootLoading />
return <EducationApplyPage />
}

View File

@@ -31,6 +31,7 @@ const defaultProviderContext = {
isAPIKeySet: false,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

View File

@@ -0,0 +1,37 @@
'use client'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { fetchSubscriptionUrls } from '@/service/billing'
import { Plan } from '../type'
export const useEducationDiscount = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager } = useAppContext()
const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false)
const handleEducationDiscount = useCallback(async () => {
if (isEducationDiscountLoading)
return
if (!isCurrentWorkspaceManager) {
toast.error(t('buyPermissionDeniedTip', { ns: 'billing' }))
return
}
setIsEducationDiscountLoading(true)
try {
const res = await fetchSubscriptionUrls(Plan.professional, 'year')
window.location.href = res.url
}
finally {
setIsEducationDiscountLoading(false)
}
}, [isCurrentWorkspaceManager, isEducationDiscountLoading, t])
return {
handleEducationDiscount,
isEducationDiscountLoading,
}
}

View File

@@ -1,11 +1,15 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { fetchSubscriptionUrls } from '@/service/billing'
import { Plan, SelfHostedPlan } from '../../type'
import PlanComp from '../index'
let currentPath = '/billing'
const push = vi.fn()
let isCurrentWorkspaceManager = true
let assignedHref = ''
const originalLocation = window.location
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push }),
@@ -27,10 +31,16 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { email: 'user@example.com' },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceManager,
}),
}))
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn(),
}))
const fetchSubscriptionUrlsMock = vi.mocked(fetchSubscriptionUrls)
const mutateAsyncMock = vi.fn()
let isPending = false
vi.mock('@/service/use-education', () => ({
@@ -78,10 +88,26 @@ describe('PlanComp', () => {
},
}
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
beforeEach(() => {
vi.clearAllMocks()
currentPath = '/billing'
isPending = false
isCurrentWorkspaceManager = true
assignedHref = ''
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
@@ -90,6 +116,14 @@ describe('PlanComp', () => {
})
mutateAsyncMock.mockReset()
mutateAsyncMock.mockResolvedValue({ token: 'token' })
fetchSubscriptionUrlsMock.mockResolvedValue({ url: 'https://subscription.example' })
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
it('renders plan info and handles education verify success', async () => {
@@ -170,6 +204,49 @@ describe('PlanComp', () => {
expect(screen.getByText('education.toVerified'))!.toBeInTheDocument()
})
it('shows education discount button and keeps upgrade button for education accounts', async () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.sandbox },
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
fireEvent.click(screen.getByText('education.useEducationDiscount'))
await waitFor(() => {
expect(fetchSubscriptionUrlsMock).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
expect(screen.getByTestId('plan-upgrade-btn'))!.toBeInTheDocument()
})
it('does not show education discount button for non-sandbox education accounts', () => {
providerContextMock.mockReturnValue({
plan: planMock,
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument()
})
it('does not show education discount button for non-manager sandbox education accounts', () => {
isCurrentWorkspaceManager = false
providerContextMock.mockReturnValue({
plan: { ...planMock, type: Plan.sandbox },
enableEducationPlan: true,
allowRefreshEducationVerify: false,
isEducationAccount: true,
})
render(<PlanComp loc="billing-page" />)
expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument()
})
it('renders enterprise plan without upgrade button', () => {
providerContextMock.mockReturnValue({
plan: { ...planMock, type: SelfHostedPlan.enterprise },

View File

@@ -23,6 +23,7 @@ import { useEducationVerify } from '@/service/use-education'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import { Loading } from '../../base/icons/src/public/thought'
import { NUM_INFINITE } from '../config'
import { useEducationDiscount } from '../hooks/use-education-discount'
import { Plan, SelfHostedPlan } from '../type'
import UpgradeBtn from '../upgrade-btn'
import AppsInfo from '../usage-info/apps-info'
@@ -39,12 +40,13 @@ const PlanComp: FC<Props> = ({
const { t } = useTranslation()
const router = useRouter()
const path = usePathname()
const { userProfile } = useAppContext()
const { userProfile, isCurrentWorkspaceManager } = useAppContext()
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext()
const isAboutToExpire = allowRefreshEducationVerify
const {
type,
} = plan
const isEnterprisePlan = String(type) === SelfHostedPlan.enterprise
const {
usage,
@@ -65,6 +67,7 @@ const PlanComp: FC<Props> = ({
})()
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const unmountedRef = useUnmountedRef()
@@ -97,7 +100,7 @@ const PlanComp: FC<Props> = ({
{plan.type === Plan.team && (
<Team />
)}
{(plan.type as any) === SelfHostedPlan.enterprise && (
{isEnterprisePlan && (
<Enterprise />
)}
<div className="mt-1 flex items-center">
@@ -115,7 +118,14 @@ const PlanComp: FC<Props> = ({
{isPending && <Loading className="ml-1 animate-spin-slow" />}
</Button>
)}
{(plan.type as any) !== SelfHostedPlan.enterprise && (
{enableEducationPlan && isEducationAccount && type === Plan.sandbox && isCurrentWorkspaceManager && (
<Button variant="ghost" onClick={handleEducationDiscount} disabled={isEducationDiscountLoading}>
<RiGraduationCapLine className="mr-1 h-4 w-4" />
{t('useEducationDiscount', { ns: 'education' })}
{isEducationDiscountLoading && <Loading className="ml-1 animate-spin-slow" />}
</Button>
)}
{!isEnterprisePlan && (
<UpgradeBtn
className="shrink-0"
isPlain={type === Plan.team}

View File

@@ -60,6 +60,8 @@ describe('Pricing', () => {
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: false,
isEducationAccount: false,
})
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
})
@@ -72,6 +74,39 @@ describe('Pricing', () => {
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
})
it('should default to yearly billing for education accounts', () => {
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: true,
isEducationAccount: true,
})
render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('switch')).toBeChecked()
})
it('should not default to yearly billing for non-manager education accounts', () => {
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: false })
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
enableEducationPlan: true,
isEducationAccount: true,
})
render(<Pricing onCancel={vi.fn()} />)
expect(screen.getByRole('switch')).not.toBeChecked()
})
})
describe('Props', () => {

View File

@@ -39,9 +39,11 @@ const pricingScrollAreaClassNames = {
const Pricing: FC<PricingProps> = ({
onCancel,
}) => {
const { plan } = useProviderContext()
const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
const { isCurrentWorkspaceManager } = useAppContext()
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
const shouldDefaultToYearly = isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount
const [selectedPlanRange, setSelectedPlanRange] = React.useState<PlanRange>()
const planRange = selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = isCurrentWorkspaceManager
@@ -73,7 +75,7 @@ const Pricing: FC<PricingProps> = ({
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
onChangePlanRange={setSelectedPlanRange}
/>
<Plans
plan={plan}

View File

@@ -3,6 +3,7 @@ import { toast, ToastHost } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
@@ -15,6 +16,10 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn(),
}))
@@ -38,6 +43,7 @@ vi.mock('../../../assets', () => ({
}))
const mockUseAppContext = useAppContext as Mock
const mockUseProviderContext = useProviderContext as Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock
const mockBillingInvoices = consoleClient.billing.invoices as Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock
@@ -72,6 +78,10 @@ beforeEach(() => {
vi.clearAllMocks()
toast.dismiss()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: false,
isEducationAccount: false,
})
mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open()))
mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' })
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
@@ -260,6 +270,127 @@ describe('CloudPlanItem', () => {
})
})
it('should use education discount checkout for yearly professional plan when education account is active', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should show default CTA and hide warning when current user is not workspace manager', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay={false}
/>,
)
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'education.useEducationDiscount' })).not.toBeInTheDocument()
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should hide education unsupported warning when current user is not workspace manager', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay={false}
/>,
)
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument()
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
expect(button)!.not.toBeDisabled()
expect(screen.getByText('education.planNotSupportEducationDiscount'))!.toBeInTheDocument()
fireEvent.click(button)
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when canceled', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
})
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
expect(assignedHref).toBe('')
})
// Covers L62-63: loading guard prevents double click
it('should ignore second click while loading', async () => {
// Make the first fetch hang until we resolve it

View File

@@ -1,6 +1,5 @@
import type { BasicPlan } from '../../../type'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightLine } from '@remixicon/react'
import * as React from 'react'
import { Plan } from '../../../type'
@@ -24,6 +23,7 @@ type ButtonProps = {
isPlanDisabled: boolean
btnText: string
handleGetPayUrl: () => void
warningText?: string
}
const Button = ({
@@ -31,22 +31,30 @@ const Button = ({
isPlanDisabled,
btnText,
handleGetPayUrl,
warningText,
}: ButtonProps) => {
return (
<button
type="button"
disabled={isPlanDisabled}
className={cn(
'flex items-center gap-x-2 py-3 pr-4 pl-5 system-xl-semibold',
BUTTON_CLASSNAME[plan].btnClassname,
isPlanDisabled && BUTTON_CLASSNAME[plan].btnDisabledClassname,
isPlanDisabled && 'cursor-not-allowed',
<div className="relative">
<button
type="button"
disabled={isPlanDisabled}
className={cn(
'flex w-full items-center gap-x-2 py-3 pr-4 pl-5 system-xl-semibold',
BUTTON_CLASSNAME[plan].btnClassname,
isPlanDisabled && BUTTON_CLASSNAME[plan].btnDisabledClassname,
isPlanDisabled && 'cursor-not-allowed',
)}
onClick={handleGetPayUrl}
>
<span className="grow text-start">{btnText}</span>
{!isPlanDisabled && <span className="i-ri-arrow-right-line size-5 shrink-0" />}
</button>
{warningText && (
<div className="absolute top-full right-0 left-0 mt-1.5 text-left system-2xs-medium text-text-tertiary">
{warningText}
</div>
)}
onClick={handleGetPayUrl}
>
<span className="grow text-start">{btnText}</span>
{!isPlanDisabled && <RiArrowRightLine className="size-5 shrink-0" />}
</button>
</div>
)
}

View File

@@ -1,15 +1,26 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../../../type'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import { ALL_PLANS } from '../../../config'
import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
@@ -22,6 +33,10 @@ const ICON_MAP = {
[Plan.team]: <Team />,
}
type ConfirmType = {
type: 'info' | 'warning'
}
type CloudPlanItemProps = {
currentPlan: BasicPlan
plan: BasicPlan
@@ -33,6 +48,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
plan,
currentPlan,
planRange,
canPay,
}) => {
const { t } = useTranslation()
const [loading, setLoading] = React.useState(false)
@@ -45,9 +61,23 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const { isCurrentWorkspaceManager } = useAppContext()
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
? t('planNotSupportEducationDiscount', { ns: 'education' })
: undefined
const openAsyncWindow = useAsyncWindowOpen()
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
const btnText = useMemo(() => {
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
return t('useEducationDiscount', { ns: 'education' })
if (isCurrent)
return t('plansCommon.currentPlan', { ns: 'billing' })
@@ -56,15 +86,20 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
[Plan.professional]: t('plansCommon.startBuilding', { ns: 'billing' }),
[Plan.team]: t('plansCommon.getStarted', { ns: 'billing' }),
})[plan]
}, [isCurrent, plan, t])
}, [canPay, isCurrent, isEducationDiscountMode, isEducationDiscountSupportedPlan, plan, t])
const handleGetPayUrl = async () => {
if (loading)
const handlePayCurrentPlan = async () => {
if (loading || isEducationDiscountLoading)
return
if (isPlanDisabled)
return
if (isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrentPaidPlan) {
await handleEducationDiscount()
return
}
if (!isCurrentWorkspaceManager) {
toast.error(t('buyPermissionDeniedTip', { ns: 'billing' }))
return
@@ -96,6 +131,18 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
setLoading(false)
}
}
const handleGetPayUrl = async () => {
if (educationDiscountWarningText && !isPlanDisabled) {
setShowEducationPricingConfirm(true)
return
}
await handlePayCurrentPlan()
}
const handleContinueCurrentPlan = async () => {
setShowEducationPricingConfirm(false)
await handlePayCurrentPlan()
}
return (
<div className="flex min-w-0 flex-1 flex-col pb-3">
<div className="flex flex-col px-5 py-4">
@@ -146,9 +193,46 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
isPlanDisabled={isPlanDisabled}
btnText={btnText}
handleGetPayUrl={handleGetPayUrl}
warningText={educationDiscountWarningText}
/>
</div>
<List plan={plan} />
<AlertDialog
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
{showEducationPricingConfirm && <div className="fixed inset-0 z-1002 bg-background-overlay"></div>}
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('educationPricingConfirm.title', { ns: 'education' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('educationPricingConfirm.description', {
ns: 'education',
planName: selectedPlanName,
billingPeriod: selectedBillingPeriod,
})}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
onClick={() => setShowEducationPricingConfirm(false)}
disabled={loading}
>
{t('educationPricingConfirm.cancel', { ns: 'education' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleContinueCurrentPlan}
disabled={loading}
loading={loading}
>
{t('educationPricingConfirm.continue', { ns: 'education' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import type { Plan } from '@/app/components/billing/type'
import type { IWorkspace } from '@/models/common'
import {
Select,
SelectContent,
@@ -9,12 +10,58 @@ import {
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import PlanBadge from '@/app/components/header/plan-badge'
import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common'
import { basePath } from '@/utils/var'
type WorkplaceSelectorContentProps = {
workspaces: IWorkspace[]
popupClassName?: string
}
type WorkplaceSelectorItemProps = {
workspace: IWorkspace
}
const WorkplaceSelectorItem = memo(({
workspace,
}: WorkplaceSelectorItemProps) => (
<SelectItem value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))
WorkplaceSelectorItem.displayName = 'WorkplaceSelectorItem'
export const WorkplaceSelectorContent = memo(({
workspaces,
popupClassName = 'w-[280px] transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100',
}: WorkplaceSelectorContentProps) => {
const { t } = useTranslation()
return (
<SelectContent popupClassName={popupClassName}>
<SelectGroup>
<SelectLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectLabel>
{workspaces.map(workspace => (
<WorkplaceSelectorItem key={workspace.id} workspace={workspace} />
))}
</SelectGroup>
</SelectContent>
)
})
WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent'
const WorkplaceSelector = () => {
const { t } = useTranslation()
const { workspaces } = useWorkspacesContext()
@@ -55,24 +102,7 @@ const WorkplaceSelector = () => {
</div>
</div>
</SelectTrigger>
<SelectContent popupClassName="w-[280px]">
<SelectGroup>
<SelectLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectLabel>
{workspaces.map(workspace => (
<SelectItem key={workspace.id} value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
<WorkplaceSelectorContent workspaces={workspaces} />
</Select>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import {
Select,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector'
import PlanBadge from '@/app/components/header/plan-badge'
type AppliedEducationContentProps = {
workspaces: IWorkspace[]
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
onSwitchWorkspace: (tenantId: string) => void
}
const AppliedEducationContent = ({
workspaces,
currentWorkspace,
plan,
action,
onSwitchWorkspace,
}: AppliedEducationContentProps) => {
const { t } = useTranslation()
const currentWorkspaceInList = workspaces.find(workspace => workspace.current)
const workspacePlan = Object.values(Plan).includes(currentWorkspaceInList?.plan as Plan)
? currentWorkspaceInList?.plan as Plan
: Object.values(Plan).includes(plan as Plan)
? plan as Plan
: Plan.sandbox
const workspaceName = currentWorkspaceInList?.name || currentWorkspace?.name
const workspaceId = currentWorkspaceInList?.id || currentWorkspace?.id
return (
<div className="flex w-full flex-col gap-4">
<div className="rounded-lg border border-effects-highlight bg-background-default-subtle px-3">
<div className="flex items-center gap-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-state-success-solid text-text-primary-on-surface">
<span className="i-ri-check-line h-3.5 w-3.5" />
</div>
<div>
<div className="text-text-secondary">
{t('applied.step1.description', { ns: 'education' })}
</div>
</div>
</div>
</div>
<div className="rounded-lg px-3">
<div className="mb-3.5 flex items-center gap-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-components-icon-bg-blue-solid system-xs-semibold text-text-primary-on-surface">
2
</div>
<div>
<div className="system-xl-medium text-text-secondary">
{t('applied.step2.description', { ns: 'education' })}
</div>
</div>
</div>
<div className="ml-7">
<Select
value={workspaceId ?? ''}
onValueChange={(value) => {
if (value)
onSwitchWorkspace(value)
}}
>
<SelectTrigger className="h-12! w-fit max-w-full min-w-[280px] cursor-pointer justify-between rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-3! py-1.5! hover:bg-state-base-hover">
<span className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-icon-bg-blue-solid text-[14px]">
<span className="bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspaceName?.[0]?.toLocaleUpperCase()}
</span>
</span>
<span className="min-w-0 truncate system-md-semibold text-text-primary">{workspaceName}</span>
<PlanBadge plan={workspacePlan} />
</span>
</SelectTrigger>
<WorkplaceSelectorContent workspaces={workspaces} />
</Select>
<div className="mt-3 pr-5">
{action}
</div>
</div>
</div>
</div>
)
}
export default AppliedEducationContent

View File

@@ -1,57 +1,79 @@
'use client'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { RiExternalLinkLine } from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
import { Plan } from '@/app/components/billing/type'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import {
useRouter,
useSearchParams,
} from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { switchWorkspace } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import {
useEducationAdd,
useInvalidateEducationStatus,
} from '@/service/use-education'
import DifyLogo from '../components/base/logo/dify-logo'
import AppliedEducationContent from './applied-education-content'
import RoleSelector from './role-selector'
import SearchInput from './search-input'
import UserInfo from './user-info'
import Confirm from './verify-state-modal'
const EducationApplyAge = () => {
const AppliedEducationCase = {
eligible: 'eligible',
activeSubscription: 'activeSubscription',
noPaymentPermission: 'noPaymentPermission',
} as const
const EducationApplyAgeContent = () => {
const { t } = useTranslation()
const [schoolName, setSchoolName] = useState('')
const [role, setRole] = useState('Student')
const [ageChecked, setAgeChecked] = useState(false)
const [inSchoolChecked, setInSchoolChecked] = useState(false)
const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false)
const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false)
const {
isPending,
mutateAsync: educationAdd,
} = useEducationAdd({ onSuccess: noop })
const [modalShow, setShowModal] = useState<undefined | { title: string, desc: string, onConfirm?: () => void }>(undefined)
const { onPlanInfoChanged } = useProviderContext()
const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext()
const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
const updateEducationStatus = useInvalidateEducationStatus()
const router = useRouter()
const docLink = useDocLink()
const handleModalConfirm = () => {
setShowModal(undefined)
onPlanInfoChanged()
updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
router.replace('/')
}
const { handleEducationDiscount } = useEducationDiscount()
const router = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const queryClient = useQueryClient()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const appliedEducationCase = (() => {
if (!isCurrentWorkspaceManager)
return AppliedEducationCase.noPaymentPermission
if (plan.type === Plan.sandbox)
return AppliedEducationCase.eligible
return AppliedEducationCase.activeSubscription
})()
const handleSubmit = () => {
educationAdd({
token: token || '',
@@ -59,17 +81,113 @@ const EducationApplyAge = () => {
institution: schoolName,
}).then((res) => {
if (res.message === 'success') {
setShowModal({
title: t('successTitle', { ns: 'education' }),
desc: t('successContent', { ns: 'education' }),
onConfirm: handleModalConfirm,
})
onPlanInfoChanged()
updateEducationStatus()
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
setHasSubmittedEducation(true)
}
else {
toast.error(t('submitError', { ns: 'education' }))
}
})
}
const handleOpenBillingPortal = async () => {
if (isOpeningBillingPortal)
return
setIsOpeningBillingPortal(true)
try {
await openAsyncWindow(async () => {
const res = await consoleClient.billing.invoices()
if (res.url)
return res.url
throw new Error('Failed to open billing page')
}, {
onError: (err) => {
toast.error(err.message || String(err))
},
})
}
finally {
setIsOpeningBillingPortal(false)
}
}
const handleReturnHome = () => {
router.push('/')
}
const renderBackToDifyButton = () => (
<Button variant="ghost-accent" onClick={handleReturnHome}>
<span className="mr-1 i-ri-arrow-left-line h-4 w-4" />
{t('applied.noPaymentPermission.returnHome', { ns: 'education' })}
</Button>
)
const handleSwitchWorkspace = async (tenantId: string) => {
if (tenantId === currentWorkspace?.id)
return
try {
await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id: tenantId } })
await Promise.all([
queryClient.invalidateQueries({ queryKey: commonQueryKeys.currentWorkspace }),
queryClient.invalidateQueries({ queryKey: commonQueryKeys.workspaces }),
])
onPlanInfoChanged()
updateEducationStatus()
}
catch {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
}
}
const renderAppliedEducationAction = () => {
if (appliedEducationCase === AppliedEducationCase.eligible) {
return (
<Button variant="primary" onClick={handleEducationDiscount}>
{t('useEducationDiscount', { ns: 'education' })}
</Button>
)
}
if (appliedEducationCase === AppliedEducationCase.activeSubscription) {
return (
<div className="flex w-full flex-col items-start gap-3">
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
<span className="mt-0.5 mr-2 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning-secondary" />
<div className="system-md-regular text-text-warning">
<Trans
i18nKey="applied.activeSubscription.description"
ns="education"
components={{
stripeLink: (
<button
type="button"
className="text-text-accent hover:underline disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={handleOpenBillingPortal}
disabled={isOpeningBillingPortal}
/>
),
}}
/>
</div>
</div>
{renderBackToDifyButton()}
</div>
)
}
return (
<div className="flex w-full flex-col items-start gap-3">
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
<span className="mt-0.5 mr-2 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning-secondary" />
<div className="system-md-regular text-text-warning">
{t('applied.noPaymentPermission.description', { ns: 'education' })}
</div>
</div>
{renderBackToDifyButton()}
</div>
)
}
return (
<div className="fixed inset-0 z-31 overflow-y-auto bg-background-body p-6">
@@ -89,94 +207,141 @@ const EducationApplyAge = () => {
<div className="mb-2 title-5xl-bold shadow-xs">{t('toVerified', { ns: 'education' })}</div>
<div className="system-md-medium shadow-xs">
{t('toVerifiedTip.front', { ns: 'education' })}
&nbsp;
&nbsp;
<span className="system-md-semibold underline">{t('toVerifiedTip.coupon', { ns: 'education' })}</span>
&nbsp;
&nbsp;
{t('toVerifiedTip.end', { ns: 'education' })}
</div>
</div>
<div className="mb-7">
<UserInfo />
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolName.title', { ns: 'education' })}
</div>
<SearchInput
value={schoolName}
onChange={setSchoolName}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolRole.title', { ns: 'education' })}
</div>
<RoleSelector
value={role}
onChange={setRole}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.terms.title', { ns: 'education' })}
</div>
<div className="mb-1 system-md-regular text-text-tertiary">
{t('form.terms.desc.front', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/terms" target="_blank" rel="noopener noreferrer" className="text-text-secondary hover:underline">{t('form.terms.desc.termsOfService', { ns: 'education' })}</a>
&nbsp;
{t('form.terms.desc.and', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-secondary hover:underline">{t('form.terms.desc.privacyPolicy', { ns: 'education' })}</a>
{t('form.terms.desc.end', { ns: 'education' })}
</div>
<div className="py-2 system-md-regular text-text-primary">
<div className="mb-2 flex">
<Checkbox
className="mr-2 shrink-0"
checked={ageChecked}
onCheck={() => setAgeChecked(!ageChecked)}
/>
{t('form.terms.option.age', { ns: 'education' })}
</div>
<div className="flex">
<Checkbox
className="mr-2 shrink-0"
checked={inSchoolChecked}
onCheck={() => setInSchoolChecked(!inSchoolChecked)}
/>
{t('form.terms.option.inSchool', { ns: 'education' })}
</div>
</div>
</div>
<Button
variant="primary"
disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending}
onClick={handleSubmit}
>
{t('submit', { ns: 'education' })}
</Button>
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div>
<a
className="flex items-center system-xs-regular text-text-accent"
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
target="_blank"
rel="noopener noreferrer"
>
{t('learn', { ns: 'education' })}
<RiExternalLinkLine className="ml-1 h-3 w-3" />
</a>
{isEducationAccount || hasSubmittedEducation
? (
<div className="flex">
<AppliedEducationWorkspaceBlock
currentWorkspace={currentWorkspace}
plan={plan.type}
action={renderAppliedEducationAction()}
onSwitchWorkspace={(value) => {
void handleSwitchWorkspace(value)
}}
/>
</div>
)
: (
<>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolName.title', { ns: 'education' })}
</div>
<SearchInput
value={schoolName}
onChange={setSchoolName}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.schoolRole.title', { ns: 'education' })}
</div>
<RoleSelector
value={role}
onChange={setRole}
/>
</div>
<div className="mb-7">
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
{t('form.terms.title', { ns: 'education' })}
</div>
<div className="mb-1 system-md-regular text-text-tertiary">
{t('form.terms.desc.front', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/terms" target="_blank" className="text-text-secondary hover:underline">{t('form.terms.desc.termsOfService', { ns: 'education' })}</a>
&nbsp;
{t('form.terms.desc.and', { ns: 'education' })}
&nbsp;
<a href="https://dify.ai/privacy" target="_blank" className="text-text-secondary hover:underline">{t('form.terms.desc.privacyPolicy', { ns: 'education' })}</a>
{t('form.terms.desc.end', { ns: 'education' })}
</div>
<div className="py-2 system-md-regular text-text-primary">
<div className="mb-2 flex">
<Checkbox
className="mr-2 shrink-0"
checked={ageChecked}
onCheck={() => setAgeChecked(!ageChecked)}
/>
{t('form.terms.option.age', { ns: 'education' })}
</div>
<div className="flex">
<Checkbox
className="mr-2 shrink-0"
checked={inSchoolChecked}
onCheck={() => setInSchoolChecked(!inSchoolChecked)}
/>
{t('form.terms.option.inSchool', { ns: 'education' })}
</div>
</div>
</div>
<Button
variant="primary"
disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending}
onClick={handleSubmit}
>
{t('submit', { ns: 'education' })}
</Button>
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div>
<a
className="flex items-center system-xs-regular text-text-accent"
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
target="_blank"
>
{t('learn', { ns: 'education' })}
<span className="ml-1 i-ri-external-link-line h-3 w-3" />
</a>
</>
)}
</div>
</div>
<Confirm
isShow={!!modalShow}
title={modalShow?.title || ''}
content={modalShow?.desc}
onConfirm={modalShow?.onConfirm || noop}
onCancel={modalShow?.onConfirm || noop}
/>
</div>
)
}
type AppliedEducationWorkspaceBlockProps = {
currentWorkspace: ICurrentWorkspace
plan: PlanType
action: ReactNode
onSwitchWorkspace: (tenantId: string) => void
}
function AppliedEducationWorkspaceContent({
currentWorkspace,
plan,
action,
onSwitchWorkspace,
}: AppliedEducationWorkspaceBlockProps) {
const { workspaces } = useWorkspacesContext()
return (
<AppliedEducationContent
workspaces={workspaces}
currentWorkspace={currentWorkspace}
plan={plan}
action={action}
onSwitchWorkspace={onSwitchWorkspace}
/>
)
}
function AppliedEducationWorkspaceBlock(props: AppliedEducationWorkspaceBlockProps) {
return (
<WorkspaceProvider>
<AppliedEducationWorkspaceContent {...props} />
</WorkspaceProvider>
)
}
const EducationApplyAge = () => <EducationApplyAgeContent />
export default EducationApplyAge
type AppliedEducationCase = typeof AppliedEducationCase[keyof typeof AppliedEducationCase]

View File

@@ -3,7 +3,7 @@ import {
RiExternalLinkLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
@@ -18,6 +18,7 @@ type IConfirm = {
maskClosable?: boolean
email?: string
showLink?: boolean
confirmText?: string
}
function Confirm({
@@ -29,6 +30,7 @@ function Confirm({
maskClosable = true,
showLink,
email,
confirmText,
}: IConfirm) {
const { t } = useTranslation()
const docLink = useDocLink()
@@ -52,26 +54,24 @@ function Confirm({
}
}, [onCancel])
const handleClickOutside = (event: MouseEvent) => {
const handleClickOutside = useCallback((event: MouseEvent) => {
if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node))
onCancel()
}
}, [maskClosable, onCancel])
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [maskClosable])
}, [handleClickOutside])
useEffect(() => {
if (isShow) {
setIsVisible(true)
}
else {
const timer = setTimeout(() => setIsVisible(false), 200)
return () => clearTimeout(timer)
}
const timer = setTimeout(() => {
setIsVisible(isShow)
}, isShow ? 0 : 200)
return () => clearTimeout(timer)
}, [isShow])
if (!isVisible)
@@ -106,7 +106,7 @@ function Confirm({
</>
)}
</div>
<Button variant="primary" className="w-20!" onClick={onConfirm}>{t('operation.ok', { ns: 'common' })}</Button>
<Button variant="primary" className={confirmText ? 'min-w-20!' : 'w-20!'} onClick={onConfirm}>{confirmText || t('operation.ok', { ns: 'common' })}</Button>
</div>
</div>
</div>

View File

@@ -38,6 +38,7 @@ export const ProviderContextProvider = ({
const [plan, setPlan] = useState(defaultPlan)
const [isFetchedPlan, setIsFetchedPlan] = useState(false)
const [isFetchedPlanInfo, setIsFetchedPlanInfo] = useState(false)
const [enableBilling, setEnableBilling] = useState(true)
const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false)
const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
@@ -103,6 +104,9 @@ export const ProviderContextProvider = ({
setIsEducationWorkspace(false)
setEnableReplaceWebAppLogo(false)
}
finally {
setIsFetchedPlanInfo(true)
}
}
useEffect(() => {
fetchPlan()
@@ -150,6 +154,7 @@ export const ProviderContextProvider = ({
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
plan,
isFetchedPlan,
isFetchedPlanInfo,
enableBilling,
onPlanInfoChanged: fetchPlan,
enableReplaceWebAppLogo,

View File

@@ -20,6 +20,7 @@ export type ProviderContextState = {
reset: UsageResetInfo
}
isFetchedPlan: boolean
isFetchedPlanInfo: boolean
enableBilling: boolean
onPlanInfoChanged: () => void
enableReplaceWebAppLogo: boolean
@@ -53,6 +54,7 @@ export const baseProviderContextValue: ProviderContextState = {
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,

View File

@@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "You have an active subscription. You can use the education discount after your subscription expires. Confirm your subscription in <stripeLink>Stripe</stripeLink>.",
"applied.description": "Congratulations! You've successfully applied for the education discount.",
"applied.noPaymentPermission.description": "You don't have payment permission in this workspace. Please switch to a workspace where you can manage billing to use the education discount.",
"applied.noPaymentPermission.returnHome": "Back to Dify",
"applied.step1.description": "You've successfully applied for the education discount.",
"applied.step1.title": "Step 1",
"applied.step2.description": "Select the workspace you want to use the education discount with.",
"applied.step2.title": "Step 2",
"applied.tabs.activeSubscription": "In subscription",
"applied.tabs.eligible": "Can buy",
"applied.tabs.noPaymentPermission": "No payment permission",
"applied.title": "Education discount applied",
"applied.workspace.plan": "Paid plan",
"applied.workspace.title": "Current Workspace",
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "monthly",
"educationPricingConfirm.billingPeriod.yearly": "annual",
"educationPricingConfirm.cancel": "Cancel",
"educationPricingConfirm.continue": "Continue without discount",
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
"educationPricingConfirm.title": "Education discount not available",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",
@@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "Re-verify now to get a new coupon for the upcoming academic year. We'll add it to your account and you can use it for the next upgrade.",
"notice.stillInEducation.isAboutToExpire": "Re-verify now to get a new coupon for the upcoming academic year. It'll be saved to your account and ready to use at your next renewal.",
"notice.stillInEducation.title": "Still in education?",
"planNotSupportEducationDiscount": "Not eligible for education pricing",
"rejectContent": "Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 100% coupon for the Dify Professional Plan if you use this email address.",
"rejectTitle": "Your Dify Educational Verification Has Been Rejected",
"submit": "Submit",
@@ -40,5 +61,6 @@
"toVerified": "Get Education Verified",
"toVerifiedTip.coupon": "exclusive 100% coupon",
"toVerifiedTip.end": "for the Dify Professional Plan.",
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an"
"toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an",
"useEducationDiscount": "Use education discount"
}

View File

@@ -1,5 +1,25 @@
{
"applied.activeSubscription.description": "你当前有生效中的订阅。订阅到期后即可使用教育优惠。请前往 <stripeLink>Stripe</stripeLink> 确认你的订阅。",
"applied.description": "您已成功申请教育优惠。",
"applied.noPaymentPermission.description": "你没有此工作空间的付款权限。请切换到你可以管理账单的工作空间,以使用教育优惠。",
"applied.noPaymentPermission.returnHome": "返回 Dify",
"applied.step1.description": "您已成功申请教育优惠。",
"applied.step1.title": "第一步",
"applied.step2.description": "选择要使用教育优惠的 workspace。",
"applied.step2.title": "第二步",
"applied.tabs.activeSubscription": "在订阅中",
"applied.tabs.eligible": "能买",
"applied.tabs.noPaymentPermission": "无付款权限",
"applied.title": "教育优惠申请成功",
"applied.workspace.plan": "付费计划",
"applied.workspace.title": "当前 Workspace",
"currentSigned": "您当前登录的账户是",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用优惠继续",
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 的年付计划符合条件。",
"educationPricingConfirm.title": "教育优惠不适用于该计划",
"emailLabel": "您当前的邮箱",
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
"form.schoolName.title": "您的学校名称",
@@ -31,6 +51,7 @@
"notice.stillInEducation.expired": "立即重新认证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次升级时使用。",
"notice.stillInEducation.isAboutToExpire": "立即重新验证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次续订时使用。",
"notice.stillInEducation.title": "仍在就读?",
"planNotSupportEducationDiscount": "不适用教育优惠价格",
"rejectContent": "非常遗憾,您无法使用此电子邮件以获得教育版认证资格,也无法领取 Dify Professional 版的 100% 独家优惠券。",
"rejectTitle": "您的 Dify 教育版认证已被拒绝",
"submit": "提交",
@@ -40,5 +61,6 @@
"toVerified": "获取教育版认证",
"toVerifiedTip.coupon": "100% 独家优惠券",
"toVerifiedTip.end": "。",
"toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的"
"toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的",
"useEducationDiscount": "使用教育优惠"
}

View File

@@ -1,5 +1,12 @@
{
"applied.step2.description": "選擇要使用教育優惠的 workspace。",
"currentSigned": "當前以以下身份登入",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用優惠繼續",
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 的年付方案符合資格。",
"educationPricingConfirm.title": "教育優惠不適用於此方案",
"emailLabel": "您當前的電子郵件",
"form.schoolName.placeholder": "請輸入您學校的正式全名",
"form.schoolName.title": "你的學校名稱",