mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
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:
parent
f3c3534e33
commit
d648ce6888
@ -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
|
||||
|
||||
@ -13,6 +13,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
isFetchedPlanInfo: false,
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: noop,
|
||||
enableReplaceWebAppLogo: false,
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ const defaultProviderContext = {
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
isFetchedPlanInfo: false,
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: noop,
|
||||
enableReplaceWebAppLogo: false,
|
||||
|
||||
37
web/app/components/billing/hooks/use-education-discount.ts
Normal file
37
web/app/components/billing/hooks/use-education-discount.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
95
web/app/education-apply/applied-education-content.tsx
Normal file
95
web/app/education-apply/applied-education-content.tsx
Normal 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
|
||||
@ -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' })}
|
||||
|
||||
|
||||
<span className="system-md-semibold underline">{t('toVerifiedTip.coupon', { ns: 'education' })}</span>
|
||||
|
||||
|
||||
{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' })}
|
||||
|
||||
<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>
|
||||
|
||||
{t('form.terms.desc.and', { ns: 'education' })}
|
||||
|
||||
<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' })}
|
||||
|
||||
<a href="https://dify.ai/terms" target="_blank" className="text-text-secondary hover:underline">{t('form.terms.desc.termsOfService', { ns: 'education' })}</a>
|
||||
|
||||
{t('form.terms.desc.and', { ns: 'education' })}
|
||||
|
||||
<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]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": "使用教育优惠"
|
||||
}
|
||||
|
||||
@ -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": "你的學校名稱",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user