@@ -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) => (
+ 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' })}
-
-
-
-
- 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' })}
+
+
+
+
+ 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": "你的學校名稱",