From d648ce6888357c4fb82e2c4e64bed57f26efbb05 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 6 May 2026 18:42:03 +0800 Subject: [PATCH] chore: improve the progress of education pay (#35851) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 10 - web/__mocks__/provider-context.ts | 1 + .../(commonLayout)/education-apply/page.tsx | 25 +- .../apikey-info-panel.test-utils.tsx | 1 + .../billing/hooks/use-education-discount.ts | 37 ++ .../billing/plan/__tests__/index.spec.tsx | 79 +++- web/app/components/billing/plan/index.tsx | 16 +- .../billing/pricing/__tests__/index.spec.tsx | 35 ++ web/app/components/billing/pricing/index.tsx | 8 +- .../cloud-plan-item/__tests__/index.spec.tsx | 131 +++++++ .../pricing/plans/cloud-plan-item/button.tsx | 36 +- .../pricing/plans/cloud-plan-item/index.tsx | 90 ++++- .../workplace-selector/index.tsx | 66 +++- .../applied-education-content.tsx | 95 +++++ .../education-apply/education-apply-page.tsx | 365 +++++++++++++----- .../education-apply/verify-state-modal.tsx | 24 +- web/context/provider-context-provider.tsx | 5 + web/context/provider-context.ts | 2 + web/i18n/en-US/education.json | 24 +- web/i18n/zh-Hans/education.json | 24 +- web/i18n/zh-Hant/education.json | 7 + 21 files changed, 905 insertions(+), 176 deletions(-) create mode 100644 web/app/components/billing/hooks/use-education-discount.ts create mode 100644 web/app/education-apply/applied-education-content.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cd37f0ed89..2d099669d1 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index d3296bacd0..10fac8d8b6 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -13,6 +13,7 @@ export const baseProviderContextValue: ProviderContextState = { isAPIKeySet: true, plan: defaultPlan, isFetchedPlan: false, + isFetchedPlanInfo: false, enableBilling: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx index 44ba5ee8ad..82e47d5c0b 100644 --- a/web/app/(commonLayout)/education-apply/page.tsx +++ b/web/app/(commonLayout)/education-apply/page.tsx @@ -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 return } diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 5d3c008989..1be3799480 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -31,6 +31,7 @@ const defaultProviderContext = { isAPIKeySet: false, plan: defaultPlan, isFetchedPlan: false, + isFetchedPlanInfo: false, enableBilling: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, diff --git a/web/app/components/billing/hooks/use-education-discount.ts b/web/app/components/billing/hooks/use-education-discount.ts new file mode 100644 index 0000000000..dedad4707e --- /dev/null +++ b/web/app/components/billing/hooks/use-education-discount.ts @@ -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, + } +} diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index 27f6b3005d..e9e0fd7012 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -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() + + 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() + + 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() + + expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument() + }) + it('renders enterprise plan without upgrade button', () => { providerContextMock.mockReturnValue({ plan: { ...planMock, type: SelfHostedPlan.enterprise }, diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 49d4ffa779..498736475c 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -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 = ({ 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 = ({ })() 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 = ({ {plan.type === Plan.team && ( )} - {(plan.type as any) === SelfHostedPlan.enterprise && ( + {isEnterprisePlan && ( )}
@@ -115,7 +118,14 @@ const PlanComp: FC = ({ {isPending && } )} - {(plan.type as any) !== SelfHostedPlan.enterprise && ( + {enableEducationPlan && isEducationAccount && type === Plan.sandbox && isCurrentWorkspaceManager && ( + + )} + {!isEnterprisePlan && ( { 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() + + 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() + + expect(screen.getByRole('switch')).not.toBeChecked() + }) }) describe('Props', () => { diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index cd88be5fb3..6d9b0f67cf 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -39,9 +39,11 @@ const pricingScrollAreaClassNames = { const Pricing: FC = ({ onCancel, }) => { - const { plan } = useProviderContext() + const { plan, enableEducationPlan, isEducationAccount } = useProviderContext() const { isCurrentWorkspaceManager } = useAppContext() - const [planRange, setPlanRange] = React.useState(PlanRange.monthly) + const shouldDefaultToYearly = isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount + const [selectedPlanRange, setSelectedPlanRange] = React.useState() + const planRange = selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly) const [currentCategory, setCurrentCategory] = useState(CategoryEnum.CLOUD) const canPay = isCurrentWorkspaceManager @@ -73,7 +75,7 @@ const Pricing: FC = ({ currentCategory={currentCategory} onChangeCategory={setCurrentCategory} currentPlanRange={planRange} - onChangePlanRange={setPlanRange} + onChangePlanRange={setSelectedPlanRange} /> ({ 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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 diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx index 8115646748..5e3f1cab0d 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx @@ -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 ( - + {warningText && ( +
+ {warningText} +
)} - onClick={handleGetPayUrl} - > - {btnText} - {!isPlanDisabled && } - +
) } diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 53d5025f08..d3dc47b29f 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -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]: , } +type ConfirmType = { + type: 'info' | 'warning' +} + type CloudPlanItemProps = { currentPlan: BasicPlan plan: BasicPlan @@ -33,6 +48,7 @@ const CloudPlanItem: FC = ({ plan, currentPlan, planRange, + canPay, }) => { const { t } = useTranslation() const [loading, setLoading] = React.useState(false) @@ -45,9 +61,23 @@ const CloudPlanItem: FC = ({ 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 = ({ [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 = ({ setLoading(false) } } + const handleGetPayUrl = async () => { + if (educationDiscountWarningText && !isPlanDisabled) { + setShowEducationPricingConfirm(true) + return + } + + await handlePayCurrentPlan() + } + const handleContinueCurrentPlan = async () => { + setShowEducationPricingConfirm(false) + await handlePayCurrentPlan() + } return (
@@ -146,9 +193,46 @@ const CloudPlanItem: FC = ({ isPlanDisabled={isPlanDisabled} btnText={btnText} handleGetPayUrl={handleGetPayUrl} + warningText={educationDiscountWarningText} />
+ + {showEducationPricingConfirm &&
} + +
+ + {t('educationPricingConfirm.title', { ns: 'education' })} + + + {t('educationPricingConfirm.description', { + ns: 'education', + planName: selectedPlanName, + billingPeriod: selectedBillingPeriod, + })} + +
+ + setShowEducationPricingConfirm(false)} + disabled={loading} + > + {t('educationPricingConfirm.cancel', { ns: 'education' })} + + + {t('educationPricingConfirm.continue', { ns: 'education' })} + + +
+
) } diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx index ae6f6729fd..a86da65797 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx @@ -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) => ( + +
+ + {workspace.name[0]?.toLocaleUpperCase()} + +
+ {workspace.name} + +
+)) +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 ( + + + + {t('userProfile.workspace', { ns: 'common' })} + + {workspaces.map(workspace => ( + + ))} + + + ) +}) +WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent' + const WorkplaceSelector = () => { const { t } = useTranslation() const { workspaces } = useWorkspacesContext() @@ -55,24 +102,7 @@ const WorkplaceSelector = () => { - - - - {t('userProfile.workspace', { ns: 'common' })} - - {workspaces.map(workspace => ( - -
- - {workspace.name[0]?.toLocaleUpperCase()} - -
- {workspace.name} - -
- ))} -
-
+ ) } diff --git a/web/app/education-apply/applied-education-content.tsx b/web/app/education-apply/applied-education-content.tsx new file mode 100644 index 0000000000..c3ff35b1b9 --- /dev/null +++ b/web/app/education-apply/applied-education-content.tsx @@ -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 ( +
+
+
+
+ +
+
+
+ {t('applied.step1.description', { ns: 'education' })} +
+
+
+
+
+
+
+ 2 +
+
+
+ {t('applied.step2.description', { ns: 'education' })} +
+
+
+
+ +
+ {action} +
+
+
+
+ ) +} + +export default AppliedEducationContent diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 7998af6e66..555e82e1d8 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -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 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 = () => ( + + ) + 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 ( + + ) + } + + if (appliedEducationCase === AppliedEducationCase.activeSubscription) { + return ( +
+
+ +
+ + ), + }} + /> +
+
+ {renderBackToDifyButton()} +
+ ) + } + + return ( +
+
+ +
+ {t('applied.noPaymentPermission.description', { ns: 'education' })} +
+
+ {renderBackToDifyButton()} +
+ ) + } return (
@@ -89,94 +207,141 @@ const EducationApplyAge = () => {
{t('toVerified', { ns: 'education' })}
{t('toVerifiedTip.front', { ns: 'education' })} -  +   {t('toVerifiedTip.coupon', { ns: 'education' })} -  +   {t('toVerifiedTip.end', { ns: 'education' })}
-
-
- {t('form.schoolName.title', { ns: 'education' })} -
- -
-
-
- {t('form.schoolRole.title', { ns: 'education' })} -
- -
-
-
- {t('form.terms.title', { ns: 'education' })} -
-
- {t('form.terms.desc.front', { ns: 'education' })} -  - {t('form.terms.desc.termsOfService', { ns: 'education' })} -  - {t('form.terms.desc.and', { ns: 'education' })} -  - {t('form.terms.desc.privacyPolicy', { ns: 'education' })} - {t('form.terms.desc.end', { ns: 'education' })} -
-
-
- setAgeChecked(!ageChecked)} - /> - {t('form.terms.option.age', { ns: 'education' })} -
-
- setInSchoolChecked(!inSchoolChecked)} - /> - {t('form.terms.option.inSchool', { ns: 'education' })} -
-
-
- -
- - {t('learn', { ns: 'education' })} - - + {isEducationAccount || hasSubmittedEducation + ? ( +
+ { + void handleSwitchWorkspace(value) + }} + /> +
+ ) + : ( + <> +
+
+ {t('form.schoolName.title', { ns: 'education' })} +
+ +
+
+
+ {t('form.schoolRole.title', { ns: 'education' })} +
+ +
+
+
+ {t('form.terms.title', { ns: 'education' })} +
+
+ {t('form.terms.desc.front', { ns: 'education' })} +   + {t('form.terms.desc.termsOfService', { ns: 'education' })} +   + {t('form.terms.desc.and', { ns: 'education' })} +   + {t('form.terms.desc.privacyPolicy', { ns: 'education' })} + {t('form.terms.desc.end', { ns: 'education' })} +
+
+
+ setAgeChecked(!ageChecked)} + /> + {t('form.terms.option.age', { ns: 'education' })} +
+
+ setInSchoolChecked(!inSchoolChecked)} + /> + {t('form.terms.option.inSchool', { ns: 'education' })} +
+
+
+ +
+ + {t('learn', { ns: 'education' })} + + + + )} - ) } +type AppliedEducationWorkspaceBlockProps = { + currentWorkspace: ICurrentWorkspace + plan: PlanType + action: ReactNode + onSwitchWorkspace: (tenantId: string) => void +} + +function AppliedEducationWorkspaceContent({ + currentWorkspace, + plan, + action, + onSwitchWorkspace, +}: AppliedEducationWorkspaceBlockProps) { + const { workspaces } = useWorkspacesContext() + + return ( + + ) +} + +function AppliedEducationWorkspaceBlock(props: AppliedEducationWorkspaceBlockProps) { + return ( + + + + ) +} + +const EducationApplyAge = () => + export default EducationApplyAge + +type AppliedEducationCase = typeof AppliedEducationCase[keyof typeof AppliedEducationCase] diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index d51a815297..9103e94f36 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -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({ )} - + diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 0af0f24b9a..160a559a77 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -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, diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts index 5d6c49c6b2..29a1c01f1e 100644 --- a/web/context/provider-context.ts +++ b/web/context/provider-context.ts @@ -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, diff --git a/web/i18n/en-US/education.json b/web/i18n/en-US/education.json index a0fb01c014..e26b1cc24d 100644 --- a/web/i18n/en-US/education.json +++ b/web/i18n/en-US/education.json @@ -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 Stripe.", + "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" } diff --git a/web/i18n/zh-Hans/education.json b/web/i18n/zh-Hans/education.json index 7fa38cd82e..657d265424 100644 --- a/web/i18n/zh-Hans/education.json +++ b/web/i18n/zh-Hans/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "你当前有生效中的订阅。订阅到期后即可使用教育优惠。请前往 Stripe 确认你的订阅。", + "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": "使用教育优惠" } diff --git a/web/i18n/zh-Hant/education.json b/web/i18n/zh-Hant/education.json index 4324e7dd86..9d24800ae5 100644 --- a/web/i18n/zh-Hant/education.json +++ b/web/i18n/zh-Hant/education.json @@ -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": "你的學校名稱",