Browse Source

Feat/edu version (#17157)

Co-authored-by: JzoNg <jzongcode@gmail.com>
zxhlyh 3 months ago
parent
commit
044504cf98
36 changed files with 1089 additions and 51 deletions
  1. 3 1
      web/app/(commonLayout)/apps/Apps.tsx
  2. 3 0
      web/app/(commonLayout)/apps/page.tsx
  3. 29 0
      web/app/(commonLayout)/education-apply/page.tsx
  4. 15 2
      web/app/account/account-page/index.tsx
  5. 15 1
      web/app/account/avatar.tsx
  6. 0 1
      web/app/components/app/configuration/dataset-config/index.tsx
  7. 0 1
      web/app/components/base/date-and-time-picker/date-picker/index.tsx
  8. 3 0
      web/app/components/base/icons/assets/public/education/triangle.svg
  9. 27 0
      web/app/components/base/icons/src/public/education/Triangle.json
  10. 16 0
      web/app/components/base/icons/src/public/education/Triangle.tsx
  11. 1 0
      web/app/components/base/icons/src/public/education/index.ts
  12. 9 0
      web/app/components/base/portal-to-follow-elem/index.tsx
  13. 48 9
      web/app/components/billing/plan/index.tsx
  14. 4 0
      web/app/components/billing/type.ts
  15. 35 29
      web/app/components/header/account-dropdown/index.tsx
  16. 1 1
      web/app/components/header/account-dropdown/workplace-selector/index.tsx
  17. 2 2
      web/app/components/header/index.tsx
  18. 6 2
      web/app/components/header/plan-badge/index.tsx
  19. 9 0
      web/app/components/swr-initor.tsx
  20. 2 0
      web/app/education-apply/constants.ts
  21. 180 0
      web/app/education-apply/education-apply-page.tsx
  22. 67 0
      web/app/education-apply/hooks.ts
  23. 53 0
      web/app/education-apply/role-selector.tsx
  24. 121 0
      web/app/education-apply/search-input.tsx
  25. 11 0
      web/app/education-apply/types.ts
  26. 61 0
      web/app/education-apply/user-info.tsx
  27. 108 0
      web/app/education-apply/verify-state-modal.tsx
  28. 10 1
      web/context/modal-context.tsx
  29. 19 1
      web/context/provider-context.tsx
  30. 47 0
      web/i18n/en-US/education.ts
  31. 13 0
      web/i18n/i18next-config.ts
  32. 47 0
      web/i18n/ja-JP/education.ts
  33. 48 0
      web/i18n/zh-Hans/education.ts
  34. BIN
      web/public/education/bg.png
  35. 67 0
      web/service/use-education.ts
  36. 9 0
      web/utils/index.ts

+ 3 - 1
web/app/(commonLayout)/apps/Apps.tsx

@@ -1,7 +1,9 @@
 'use client'
 
 import { useCallback, useEffect, useRef, useState } from 'react'
-import { useRouter } from 'next/navigation'
+import {
+  useRouter,
+} from 'next/navigation'
 import useSWRInfinite from 'swr/infinite'
 import { useTranslation } from 'react-i18next'
 import { useDebounceFn } from 'ahooks'

+ 3 - 0
web/app/(commonLayout)/apps/page.tsx

@@ -7,9 +7,12 @@ import style from '../list.module.css'
 import Apps from './Apps'
 import AppContext from '@/context/app-context'
 import { LicenseStatus } from '@/types/feature'
+import { useEducationInit } from '@/app/education-apply/hooks'
 
 const AppList = () => {
   const { t } = useTranslation()
+  useEducationInit()
+
   const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures)
 
   return (

+ 29 - 0
web/app/(commonLayout)/education-apply/page.tsx

@@ -0,0 +1,29 @@
+'use client'
+
+import {
+  useEffect,
+  useMemo,
+} from 'react'
+import {
+  useRouter,
+  useSearchParams,
+} from 'next/navigation'
+import EducationApplyPage from '@/app/education-apply/education-apply-page'
+import { useProviderContext } from '@/context/provider-context'
+
+export default function EducationApply() {
+  const router = useRouter()
+  const { enableEducationPlan, isEducationAccount } = useProviderContext()
+  const searchParams = useSearchParams()
+  const token = searchParams.get('token')
+  const showEducationApplyPage = useMemo(() => {
+    return enableEducationPlan && !isEducationAccount && token
+  }, [enableEducationPlan, isEducationAccount, token])
+
+  useEffect(() => {
+    if (!showEducationApplyPage)
+      router.replace('/')
+  }, [showEducationApplyPage, router])
+
+  return <EducationApplyPage />
+}

+ 15 - 2
web/app/account/account-page/index.tsx

@@ -1,7 +1,9 @@
 'use client'
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
-
+import {
+  RiGraduationCapFill,
+} from '@remixicon/react'
 import { useContext } from 'use-context-selector'
 import DeleteAccount from '../delete-account'
 import s from './index.module.css'
@@ -12,10 +14,12 @@ import Modal from '@/app/components/base/modal'
 import Button from '@/app/components/base/button'
 import { updateUserProfile } from '@/service/common'
 import { useAppContext } from '@/context/app-context'
+import { useProviderContext } from '@/context/provider-context'
 import { ToastContext } from '@/app/components/base/toast'
 import AppIcon from '@/app/components/base/app-icon'
 import { IS_CE_EDITION } from '@/config'
 import Input from '@/app/components/base/input'
+import PremiumBadge from '@/app/components/base/premium-badge'
 
 const titleClassName = `
   system-sm-semibold text-text-secondary
@@ -30,6 +34,7 @@ export default function AccountPage() {
   const { t } = useTranslation()
   const { systemFeatures } = useAppContext()
   const { mutateUserProfile, userProfile, apps } = useAppContext()
+  const { isEducationAccount } = useProviderContext()
   const { notify } = useContext(ToastContext)
   const [editNameModalVisible, setEditNameModalVisible] = useState(false)
   const [editName, setEditName] = useState('')
@@ -135,7 +140,15 @@ export default function AccountPage() {
       <div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
         <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} />
         <div className='ml-4'>
-          <p className='system-xl-semibold text-text-primary'>{userProfile.name}</p>
+          <p className='system-xl-semibold text-text-primary'>
+            {userProfile.name}
+            {isEducationAccount && (
+              <PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
+                <RiGraduationCapFill className='w-3 h-3 mr-1' />
+                <span className='system-2xs-medium'>EDU</span>
+              </PremiumBadge>
+            )}
+          </p>
           <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>
         </div>
       </div>

+ 15 - 1
web/app/account/avatar.tsx

@@ -2,11 +2,16 @@
 import { useTranslation } from 'react-i18next'
 import { Fragment } from 'react'
 import { useRouter } from 'next/navigation'
+import {
+  RiGraduationCapFill,
+} from '@remixicon/react'
 import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
 import Avatar from '@/app/components/base/avatar'
 import { logout } from '@/service/common'
 import { useAppContext } from '@/context/app-context'
+import { useProviderContext } from '@/context/provider-context'
 import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
+import PremiumBadge from '@/app/components/base/premium-badge'
 
 export type IAppSelector = {
   isMobile: boolean
@@ -16,6 +21,7 @@ export default function AppSelector() {
   const router = useRouter()
   const { t } = useTranslation()
   const { userProfile } = useAppContext()
+  const { isEducationAccount } = useProviderContext()
 
   const handleLogout = async () => {
     await logout({
@@ -68,7 +74,15 @@ export default function AppSelector() {
                   <div className='p-1'>
                     <div className='flex flex-nowrap items-center px-3 py-2'>
                       <div className='grow'>
-                        <div className='system-md-medium break-all text-text-primary'>{userProfile.name}</div>
+                        <div className='system-md-medium break-all text-text-primary'>
+                          {userProfile.name}
+                          {isEducationAccount && (
+                            <PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
+                              <RiGraduationCapFill className='w-3 h-3 mr-1' />
+                              <span className='system-2xs-medium'>EDU</span>
+                            </PremiumBadge>
+                          )}
+                        </div>
                         <div className='system-xs-regular break-all text-text-tertiary'>{userProfile.email}</div>
                       </div>
                       <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />

+ 0 - 1
web/app/components/app/configuration/dataset-config/index.tsx

@@ -182,7 +182,6 @@ const DatasetConfig: FC = () => {
   }, [setDatasetConfigs, datasetConfigsRef])
 
   const handleUpdateCondition = useCallback<HandleUpdateCondition>((id, newCondition) => {
-    console.log(newCondition, 'newCondition')
     const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || []
     const index = conditions.findIndex(c => c.id === id)
     const newInputs = produce(datasetConfigsRef.current!, (draft) => {

+ 0 - 1
web/app/components/base/date-and-time-picker/date-picker/index.tsx

@@ -130,7 +130,6 @@ const DatePicker = ({
 
   const handleConfirmDate = () => {
     // debugger
-    console.log(selectedDate, selectedDate?.tz(timezone))
     onChange(selectedDate ? selectedDate.tz(timezone) : undefined)
     setIsOpen(false)
   }

+ 3 - 0
web/app/components/base/icons/assets/public/education/triangle.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path id="Rectangle 979" d="M0 0H16L9.91493 16.7339C8.76529 19.8955 5.76063 22 2.39658 22H0V0Z" fill="white"/>
+</svg>

+ 27 - 0
web/app/components/base/icons/src/public/education/Triangle.json

@@ -0,0 +1,27 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "22",
+			"viewBox": "0 0 16 22",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"id": "Rectangle 979",
+					"d": "M0 0H16L9.91493 16.7339C8.76529 19.8955 5.76063 22 2.39658 22H0V0Z",
+					"fill": "white"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Triangle"
+}

+ 16 - 0
web/app/components/base/icons/src/public/education/Triangle.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Triangle.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Triangle'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/public/education/index.ts

@@ -0,0 +1 @@
+export { default as Triangle } from './Triangle'

+ 9 - 0
web/app/components/base/portal-to-follow-elem/index.tsx

@@ -6,6 +6,7 @@ import {
   flip,
   offset,
   shift,
+  size,
   useDismiss,
   useFloating,
   useFocus,
@@ -27,6 +28,7 @@ export type PortalToFollowElemOptions = {
   open?: boolean
   offset?: number | OffsetOptions
   onOpenChange?: (open: boolean) => void
+  triggerPopupSameWidth?: boolean
 }
 
 export function usePortalToFollowElem({
@@ -34,6 +36,7 @@ export function usePortalToFollowElem({
   open,
   offset: offsetValue = 0,
   onOpenChange: setControlledOpen,
+  triggerPopupSameWidth,
 }: PortalToFollowElemOptions = {}) {
   const setOpen = setControlledOpen
 
@@ -50,6 +53,12 @@ export function usePortalToFollowElem({
         padding: 5,
       }),
       shift({ padding: 5 }),
+      size({
+        apply({ rects, elements }) {
+          if (triggerPopupSameWidth)
+            elements.floating.style.width = `${rects.reference.width}px`
+        },
+      }),
     ],
   })
 

+ 48 - 9
web/app/components/billing/plan/index.tsx

@@ -2,10 +2,12 @@
 import type { FC } from 'react'
 import React from 'react'
 import { useTranslation } from 'react-i18next'
+import { useRouter } from 'next/navigation'
 import {
   RiBook2Line,
   RiBox3Line,
   RiFileEditLine,
+  RiGraduationCapLine,
   RiGroup3Line,
   RiGroupLine,
   RiSquareLine,
@@ -15,7 +17,13 @@ import VectorSpaceInfo from '../usage-info/vector-space-info'
 import AppsInfo from '../usage-info/apps-info'
 import UpgradeBtn from '../upgrade-btn'
 import { useProviderContext } from '@/context/provider-context'
+import { useAppContext } from '@/context/app-context'
+import Button from '@/app/components/base/button'
 import UsageInfo from '@/app/components/billing/usage-info'
+import VerifyStateModal from '@/app/education-apply/verify-state-modal'
+import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
+import { useEducationVerify } from '@/service/use-education'
+import { useModalContextSelector } from '@/context/modal-context'
 
 type Props = {
   loc: string
@@ -25,7 +33,9 @@ const PlanComp: FC<Props> = ({
   loc,
 }) => {
   const { t } = useTranslation()
-  const { plan } = useProviderContext()
+  const router = useRouter()
+  const { userProfile } = useAppContext()
+  const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
   const {
     type,
   } = plan
@@ -35,6 +45,18 @@ const PlanComp: FC<Props> = ({
     total,
   } = plan
 
+  const [showModal, setShowModal] = React.useState(false)
+  const { mutateAsync } = useEducationVerify()
+  const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
+  const handleVerify = () => {
+    mutateAsync().then((res) => {
+      localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+      router.push(`/education-apply?token=${res.token}`)
+      setShowAccountSettingModal(null)
+    }).catch(() => {
+      setShowModal(true)
+    })
+  }
   return (
     <div className='rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'>
       <div className='p-6 pb-2'>
@@ -58,14 +80,22 @@ const PlanComp: FC<Props> = ({
             </div>
             <div className='system-xs-regular text-util-colors-gray-gray-600'>{t(`billing.plans.${type}.for`)}</div>
           </div>
-          {(plan.type as any) !== SelfHostedPlan.enterprise && (
-            <UpgradeBtn
-              className='shrink-0'
-              isPlain={type === Plan.team}
-              isShort
-              loc={loc}
-            />
-          )}
+          <div className='flex shrink-0 items-center gap-1'>
+            {enableEducationPlan && !isEducationAccount && (
+              <Button variant='ghost' onClick={handleVerify}>
+                <RiGraduationCapLine className='mr-1 h-4 w-4'/>
+                {t('education.toVerified')}
+              </Button>
+            )}
+            {(plan.type as any) !== SelfHostedPlan.enterprise && (
+              <UpgradeBtn
+                className='shrink-0'
+                isPlain={type === Plan.team}
+                isShort
+                loc={loc}
+              />
+            )}
+          </div>
         </div>
       </div>
       {/* Plan detail */}
@@ -92,6 +122,15 @@ const PlanComp: FC<Props> = ({
         />
 
       </div>
+      <VerifyStateModal
+        showLink
+        email={userProfile.email}
+        isShow={showModal}
+        title={t('education.rejectTitle')}
+        content={t('education.rejectContent')}
+        onConfirm={() => setShowModal(false)}
+        onCancel={() => setShowModal(false)}
+      />
     </div>
   )
 }

+ 4 - 0
web/app/components/billing/type.ts

@@ -87,6 +87,10 @@ export type CurrentPlanInfoBackend = {
   can_replace_logo: boolean
   model_load_balancing_enabled: boolean
   dataset_operator_enabled: boolean
+  education: {
+    enabled: boolean
+    activated: boolean
+  }
 }
 
 export type SubscriptionItem = {

+ 35 - 29
web/app/components/header/account-dropdown/index.tsx

@@ -3,7 +3,18 @@ import { useTranslation } from 'react-i18next'
 import { Fragment, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useContext, useContextSelector } from 'use-context-selector'
-import { RiAccountCircleLine, RiArrowDownSLine, RiArrowRightUpLine, RiBookOpenLine, RiGithubLine, RiInformation2Line, RiLogoutBoxRLine, RiMap2Line, RiSettings3Line, RiStarLine } from '@remixicon/react'
+import {
+  RiAccountCircleLine,
+  RiArrowRightUpLine,
+  RiBookOpenLine,
+  RiGithubLine,
+  RiGraduationCapFill,
+  RiInformation2Line,
+  RiLogoutBoxRLine,
+  RiMap2Line,
+  RiSettings3Line,
+  RiStarLine,
+} from '@remixicon/react'
 import Link from 'next/link'
 import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
 import Indicator from '../indicator'
@@ -11,21 +22,19 @@ import AccountAbout from '../account-about'
 import GithubStar from '../github-star'
 import Support from './support'
 import Compliance from './compliance'
-import classNames from '@/utils/classnames'
+import PremiumBadge from '@/app/components/base/premium-badge'
 import I18n from '@/context/i18n'
 import Avatar from '@/app/components/base/avatar'
 import { logout } from '@/service/common'
 import AppContext, { useAppContext } from '@/context/app-context'
+import { useProviderContext } from '@/context/provider-context'
 import { useModalContext } from '@/context/modal-context'
 import { LanguagesSupported } from '@/i18n/language'
 import { LicenseStatus } from '@/types/feature'
 import { IS_CLOUD_EDITION } from '@/config'
+import cn from '@/utils/classnames'
 
-export type IAppSelector = {
-  isMobile: boolean
-}
-
-export default function AppSelector({ isMobile }: IAppSelector) {
+export default function AppSelector() {
   const itemClassName = `
     flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
     rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
@@ -37,6 +46,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
   const { locale } = useContext(I18n)
   const { t } = useTranslation()
   const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
+  const { isEducationAccount } = useProviderContext()
   const { setShowAccountSettingModal } = useModalContext()
 
   const handleLogout = async () => {
@@ -58,20 +68,8 @@ export default function AppSelector({ isMobile }: IAppSelector) {
         {
           ({ open }) => (
             <>
-              <MenuButton
-                className={`
-                    inline-flex items-center
-                    rounded-[20px] py-1 pl-1 pr-2.5 text-sm
-                  text-text-secondary hover:bg-state-base-hover
-                    mobile:px-1
-                    ${open && 'bg-state-base-hover'}
-                  `}
-              >
-                <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='mr-0 sm:mr-2' size={32} />
-                {!isMobile && <>
-                  {userProfile.name}
-                  <RiArrowDownSLine className="ml-1 h-3 w-3 text-text-tertiary" />
-                </>}
+              <MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}>
+                <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
               </MenuButton>
               <Transition
                 as={Fragment}
@@ -92,7 +90,15 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                   <MenuItem disabled>
                     <div className='flex flex-nowrap items-center py-[13px] pl-3 pr-2'>
                       <div className='grow'>
-                        <div className='system-md-medium break-all text-text-primary'>{userProfile.name}</div>
+                        <div className='system-md-medium break-all text-text-primary'>
+                          {userProfile.name}
+                          {isEducationAccount && (
+                            <PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
+                              <RiGraduationCapFill className='w-3 h-3 mr-1' />
+                              <span className='system-2xs-medium'>EDU</span>
+                            </PremiumBadge>
+                          )}
+                        </div>
                         <div className='system-xs-regular break-all text-text-tertiary'>{userProfile.email}</div>
                       </div>
                       <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} className='mr-3' />
@@ -101,7 +107,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                   <div className="px-1 py-1">
                     <MenuItem>
                       <Link
-                        className={classNames(itemClassName, 'group',
+                        className={cn(itemClassName, 'group',
                           'data-[active]:bg-state-base-hover',
                         )}
                         href='/account'
@@ -112,7 +118,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                       </Link>
                     </MenuItem>
                     <MenuItem>
-                      <div className={classNames(itemClassName,
+                      <div className={cn(itemClassName,
                         'data-[active]:bg-state-base-hover',
                       )} onClick={() => setShowAccountSettingModal({ payload: 'members' })}>
                         <RiSettings3Line className='size-4 shrink-0 text-text-tertiary' />
@@ -123,7 +129,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                   <div className='p-1'>
                     <MenuItem>
                       <Link
-                        className={classNames(itemClassName, 'group justify-between',
+                        className={cn(itemClassName, 'group justify-between',
                           'data-[active]:bg-state-base-hover',
                         )}
                         href={
@@ -141,7 +147,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                   <div className='p-1'>
                     <MenuItem>
                       <Link
-                        className={classNames(itemClassName, 'group justify-between',
+                        className={cn(itemClassName, 'group justify-between',
                           'data-[active]:bg-state-base-hover',
                         )}
                         href='https://roadmap.dify.ai'
@@ -153,7 +159,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                     </MenuItem>
                     {systemFeatures.license.status === LicenseStatus.NONE && <MenuItem>
                       <Link
-                        className={classNames(itemClassName, 'group justify-between',
+                        className={cn(itemClassName, 'group justify-between',
                           'data-[active]:bg-state-base-hover',
                         )}
                         href='https://github.com/langgenius/dify'
@@ -169,7 +175,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                     {
                       document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
                         <MenuItem>
-                          <div className={classNames(itemClassName, 'justify-between',
+                          <div className={cn(itemClassName, 'justify-between',
                             'data-[active]:bg-state-base-hover',
                           )} onClick={() => setAboutVisible(true)}>
                             <RiInformation2Line className='size-4 shrink-0 text-text-tertiary' />
@@ -186,7 +192,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
                   <MenuItem>
                     <div className='p-1' onClick={() => handleLogout()}>
                       <div
-                        className={classNames(itemClassName, 'group justify-between',
+                        className={cn(itemClassName, 'group justify-between',
                           'data-[active]:bg-state-base-hover',
                         )}
                       >

+ 1 - 1
web/app/components/header/account-dropdown/workplace-selector/index.tsx

@@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next'
 import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
 import { RiArrowDownSLine } from '@remixicon/react'
 import cn from '@/utils/classnames'
+import PlanBadge from '@/app/components/header/plan-badge'
 import { switchWorkspace } from '@/service/common'
 import { useWorkspacesContext } from '@/context/workspace-context'
 import { ToastContext } from '@/app/components/base/toast'
-import PlanBadge from '../../plan-badge'
 import type { Plan } from '@/app/components/billing/type'
 
 const WorkplaceSelector = () => {

+ 2 - 2
web/app/components/header/index.tsx

@@ -94,10 +94,10 @@ const Header = () => {
       }
       <div className='flex shrink-0 items-center'>
         <EnvNav />
-        <div className='mr-3'>
+        <div className='mr-2'>
           <PluginsNav />
         </div>
-        <AccountDropdown isMobile={isMobile} />
+        <AccountDropdown />
       </div>
       {
         (isMobile && isShowNavMenu) && (

+ 6 - 2
web/app/components/header/plan-badge/index.tsx

@@ -1,6 +1,9 @@
 import { useProviderContext } from '@/context/provider-context'
 import type { FC } from 'react'
 import { useTranslation } from 'react-i18next'
+import {
+  RiGraduationCapFill,
+} from '@remixicon/react'
 import { SparklesSoft } from '../../base/icons/src/public/common'
 import PremiumBadge from '../../base/premium-badge'
 import { Plan } from '../../billing/type'
@@ -13,7 +16,7 @@ type PlanBadgeProps = {
 }
 
 const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = false, onClick }) => {
-  const { isFetchedPlan } = useProviderContext()
+  const { isFetchedPlan, isEducationWorkspace } = useProviderContext()
   const { t } = useTranslation()
 
   if (!isFetchedPlan) return null
@@ -39,7 +42,8 @@ const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = fa
   if (plan === Plan.professional) {
     return <PremiumBadge className='select-none' size='s' color='blue' allowHover={allowHover} onClick={onClick}>
       <div className='system-2xs-medium-uppercase'>
-        <span className='p-1'>
+        <span className='p-1 inline-flex items-center gap-1'>
+          {isEducationWorkspace && <RiGraduationCapFill className='w-3 h-3' />}
           pro
         </span>
       </div>

+ 9 - 0
web/app/components/swr-initor.tsx

@@ -5,6 +5,10 @@ import { useCallback, useEffect, useState } from 'react'
 import type { ReactNode } from 'react'
 import { usePathname, useRouter, useSearchParams } from 'next/navigation'
 import { fetchSetupStatus } from '@/service/common'
+import {
+  EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
+  EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
+} from '@/app/education-apply/constants'
 
 type SwrInitorProps = {
   children: ReactNode
@@ -41,6 +45,11 @@ const SwrInitor = ({
 
   useEffect(() => {
     (async () => {
+      const action = searchParams.get('action')
+
+      if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
+        localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
+
       try {
         const isFinished = await isSetupFinished()
         if (!isFinished) {

+ 2 - 0
web/app/education-apply/constants.ts

@@ -0,0 +1,2 @@
+export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify'
+export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying'

+ 180 - 0
web/app/education-apply/education-apply-page.tsx

@@ -0,0 +1,180 @@
+'use client'
+
+import {
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { RiExternalLinkLine } from '@remixicon/react'
+import {
+  useRouter,
+  useSearchParams,
+} from 'next/navigation'
+import UserInfo from './user-info'
+import SearchInput from './search-input'
+import RoleSelector from './role-selector'
+import Confirm from './verify-state-modal'
+import Button from '@/app/components/base/button'
+import Checkbox from '@/app/components/base/checkbox'
+import {
+  useEducationAdd,
+  useInvalidateEducationStatus,
+} from '@/service/use-education'
+import { useProviderContext } from '@/context/provider-context'
+import { useToastContext } from '@/app/components/base/toast'
+import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
+
+const EducationApplyAge = () => {
+  const { t } = useTranslation()
+  const [schoolName, setSchoolName] = useState('')
+  const [role, setRole] = useState('Student')
+  const [ageChecked, setAgeChecked] = useState(false)
+  const [inSchoolChecked, setInSchoolChecked] = useState(false)
+  const {
+    isPending,
+    mutateAsync: educationAdd,
+  } = useEducationAdd({ onSuccess: () => {} })
+  const [modalShow, setShowModal] = useState<undefined | { title: string; desc: string; onConfirm?: () => void }>(undefined)
+  const { onPlanInfoChanged } = useProviderContext()
+  const updateEducationStatus = useInvalidateEducationStatus()
+  const { notify } = useToastContext()
+  const router = useRouter()
+
+  const handleModalConfirm = () => {
+    setShowModal(undefined)
+    onPlanInfoChanged()
+    updateEducationStatus()
+    localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+    router.replace('/')
+  }
+
+  const searchParams = useSearchParams()
+  const token = searchParams.get('token')
+  const handleSubmit = () => {
+    educationAdd({
+      token: token || '',
+      role,
+      institution: schoolName,
+    }).then((res) => {
+      if (res.message === 'success') {
+        setShowModal({
+          title: t('education.successTitle'),
+          desc: t('education.successContent'),
+          onConfirm: handleModalConfirm,
+        })
+      }
+      else {
+        notify({
+          type: 'error',
+          message: t('education.submitError'),
+        })
+      }
+    })
+  }
+
+  return (
+    <div className='fixed inset-0 z-[31] overflow-y-auto bg-background-body p-6'>
+      <div className='mx-auto w-full max-w-[1408px] rounded-2xl border border-effects-highlight bg-background-default-subtle'>
+        <div
+          className="h-[349px] w-full overflow-hidden rounded-t-2xl bg-cover bg-center bg-no-repeat"
+          style={{
+            backgroundImage: 'url(/education/bg.png)',
+          }}
+        >
+        </div>
+        <div className='mt-[-349px] flex h-[88px] items-center justify-between px-8 py-6'>
+          <img
+            src='/logo/logo-site-dark.png'
+            alt='dify logo'
+            className='h-10'
+          />
+        </div>
+        <div className='mx-auto max-w-[720px] px-8 pb-[180px]'>
+          <div className='mb-2 flex h-[192px] flex-col justify-end pb-4 pt-3 text-text-primary-on-surface'>
+            <div className='title-5xl-bold mb-2 shadow-xs'>{t('education.toVerified')}</div>
+            <div className='system-md-medium shadow-xs'>
+              {t('education.toVerifiedTip.front')}&nbsp;
+              <span className='system-md-semibold underline'>{t('education.toVerifiedTip.coupon')}</span>&nbsp;
+              {t('education.toVerifiedTip.end')}
+            </div>
+          </div>
+          <div className='mb-7'>
+            <UserInfo />
+          </div>
+          <div className='mb-7'>
+            <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'>
+              {t('education.form.schoolName.title')}
+            </div>
+            <SearchInput
+              value={schoolName}
+              onChange={setSchoolName}
+            />
+          </div>
+          <div className='mb-7'>
+            <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'>
+              {t('education.form.schoolRole.title')}
+            </div>
+            <RoleSelector
+              value={role}
+              onChange={setRole}
+            />
+          </div>
+          <div className='mb-7'>
+            <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'>
+              {t('education.form.terms.title')}
+            </div>
+            <div className='system-md-regular mb-1 text-text-tertiary'>
+              {t('education.form.terms.desc.front')}&nbsp;
+              <a href='https://dify.ai/terms' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.termsOfService')}</a>&nbsp;
+              {t('education.form.terms.desc.and')}&nbsp;
+              <a href='https://dify.ai/privacy' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.privacyPolicy')}</a>
+              {t('education.form.terms.desc.end')}
+            </div>
+            <div className='system-md-regular py-2 text-text-primary'>
+              <div className='mb-2 flex'>
+                <Checkbox
+                  className='mr-2 shrink-0'
+                  checked={ageChecked}
+                  onCheck={() => setAgeChecked(!ageChecked)}
+                />
+                {t('education.form.terms.option.age')}
+              </div>
+              <div className='flex'>
+                <Checkbox
+                  className='mr-2 shrink-0'
+                  checked={inSchoolChecked}
+                  onCheck={() => setInSchoolChecked(!inSchoolChecked)}
+                />
+                {t('education.form.terms.option.inSchool')}
+              </div>
+            </div>
+          </div>
+          <Button
+            variant='primary'
+            disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending}
+            onClick={handleSubmit}
+          >
+            {t('education.submit')}
+          </Button>
+          <div className='mb-4 mt-5 h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]'></div>
+          <a
+            className='system-xs-regular flex items-center text-text-accent'
+            href='https://docs.dify.ai/getting-started/dify-for-education'
+            target='_blank'
+          >
+            {t('education.learn')}
+            <RiExternalLinkLine className='ml-1 h-3 w-3' />
+          </a>
+        </div>
+      </div>
+      <Confirm
+        isShow={!!modalShow}
+        title={modalShow?.title || ''}
+        content={modalShow?.desc}
+        onConfirm={modalShow?.onConfirm || (() => {})}
+        onCancel={modalShow?.onConfirm || (() => {})}
+      />
+    </div>
+  )
+}
+
+export default EducationApplyAge

+ 67 - 0
web/app/education-apply/hooks.ts

@@ -0,0 +1,67 @@
+import {
+  useCallback,
+  useEffect,
+  useState,
+} from 'react'
+import { useDebounceFn } from 'ahooks'
+import { useSearchParams } from 'next/navigation'
+import type { SearchParams } from './types'
+import {
+  EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
+  EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
+} from './constants'
+import { useEducationAutocomplete } from '@/service/use-education'
+import { useModalContextSelector } from '@/context/modal-context'
+
+export const useEducation = () => {
+  const {
+    mutateAsync,
+    isPending,
+    data,
+  } = useEducationAutocomplete()
+
+  const [prevSchools, setPrevSchools] = useState<string[]>([])
+  const handleUpdateSchools = useCallback((searchParams: SearchParams) => {
+    if (searchParams.keywords) {
+      mutateAsync(searchParams).then((res) => {
+        const currentPage = searchParams.page || 0
+        const resSchools = res.data
+        if (currentPage > 0)
+          setPrevSchools(prevSchools => [...(prevSchools || []), ...resSchools])
+        else
+          setPrevSchools(resSchools)
+      })
+    }
+  }, [mutateAsync])
+
+  const { run: querySchoolsWithDebounced } = useDebounceFn((searchParams: SearchParams) => {
+    handleUpdateSchools(searchParams)
+  }, {
+    wait: 300,
+  })
+
+  return {
+    schools: prevSchools,
+    setSchools: setPrevSchools,
+    querySchoolsWithDebounced,
+    handleUpdateSchools,
+    isLoading: isPending,
+    hasNext: data?.has_next,
+  }
+}
+
+export const useEducationInit = () => {
+  const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
+  const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+  const searchParams = useSearchParams()
+  const educationVerifyAction = searchParams.get('action')
+
+  useEffect(() => {
+    if (educationVerifying === 'yes' || educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) {
+      setShowAccountSettingModal({ payload: 'billing' })
+
+      if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
+        localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
+    }
+  }, [setShowAccountSettingModal, educationVerifying, educationVerifyAction])
+}

+ 53 - 0
web/app/education-apply/role-selector.tsx

@@ -0,0 +1,53 @@
+import { useTranslation } from 'react-i18next'
+import cn from '@/utils/classnames'
+
+type RoleSelectorProps = {
+  onChange: (value: string) => void
+  value: string
+}
+
+const RoleSelector = ({
+  onChange,
+  value,
+}: RoleSelectorProps) => {
+  const { t } = useTranslation()
+  const options = [
+    {
+      key: 'Student',
+      value: t('education.form.schoolRole.option.student'),
+    },
+    {
+      key: 'Teacher',
+      value: t('education.form.schoolRole.option.teacher'),
+    },
+    {
+      key: 'School-Administrator',
+      value: t('education.form.schoolRole.option.administrator'),
+    },
+  ]
+
+  return (
+    <div className='flex'>
+      {
+        options.map(option => (
+          <div
+            key={option.key}
+            className='system-md-regular mr-6 flex h-5 cursor-pointer items-center text-text-primary'
+            onClick={() => onChange(option.key)}
+          >
+            <div
+              className={cn(
+                'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
+                option.key === value && 'border-[5px] border-components-radio-border-checked ',
+              )}
+            >
+            </div>
+            {option.value}
+          </div>
+        ))
+      }
+    </div>
+  )
+}
+
+export default RoleSelector

+ 121 - 0
web/app/education-apply/search-input.tsx

@@ -0,0 +1,121 @@
+import {
+  useCallback,
+  useRef,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import { useEducation } from './hooks'
+import Input from '@/app/components/base/input'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+
+type SearchInputProps = {
+  value?: string
+  onChange: (value: string) => void
+}
+const SearchInput = ({
+  value,
+  onChange,
+}: SearchInputProps) => {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+  const {
+    schools,
+    setSchools,
+    querySchoolsWithDebounced,
+    handleUpdateSchools,
+    hasNext,
+  } = useEducation()
+  const pageRef = useRef(0)
+  const valueRef = useRef(value)
+
+  const handleSearch = useCallback((debounced?: boolean) => {
+    const keywords = valueRef.current
+    const page = pageRef.current
+    if (debounced) {
+      querySchoolsWithDebounced({
+        keywords,
+        page,
+      })
+      return
+    }
+
+    handleUpdateSchools({
+      keywords,
+      page,
+    })
+  }, [querySchoolsWithDebounced, handleUpdateSchools])
+
+  const handleValueChange = useCallback((e: any) => {
+    setOpen(true)
+    setSchools([])
+    pageRef.current = 0
+    const inputValue = e.target.value
+    valueRef.current = inputValue
+    onChange(inputValue)
+    handleSearch(true)
+  }, [onChange, handleSearch, setSchools])
+
+  const handleScroll = useCallback((e: Event) => {
+    const target = e.target as HTMLDivElement
+    const {
+      scrollTop,
+      scrollHeight,
+      clientHeight,
+    } = target
+    if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 && hasNext) {
+      pageRef.current += 1
+      handleSearch()
+    }
+  }, [handleSearch, hasNext])
+
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom'
+      offset={4}
+      triggerPopupSameWidth
+    >
+      <PortalToFollowElemTrigger className='block w-full'>
+        <Input
+          className='w-full'
+          placeholder={t('education.form.schoolName.placeholder')}
+          value={value}
+          onChange={handleValueChange}
+        />
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent className='z-[32]'>
+        {
+          !!schools.length && value && (
+            <div
+              className='max-h-[330px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1'
+              onScroll={handleScroll as any}
+            >
+              {
+                schools.map((school, index) => (
+                  <div
+                    key={index}
+                    className='system-md-regular flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover'
+                    title={school}
+                    onClick={() => {
+                      onChange(school)
+                      setOpen(false)
+                    }}
+                  >
+                    {school}
+                  </div>
+                ))
+              }
+            </div>
+          )
+        }
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default SearchInput

+ 11 - 0
web/app/education-apply/types.ts

@@ -0,0 +1,11 @@
+export type SearchParams = {
+  keywords?: string
+  page?: number
+  limit?: number
+}
+
+export type EducationAddParams = {
+  token: string
+  institution: string
+  role: string
+}

+ 61 - 0
web/app/education-apply/user-info.tsx

@@ -0,0 +1,61 @@
+import { useTranslation } from 'react-i18next'
+import { useRouter } from 'next/navigation'
+import Button from '@/app/components/base/button'
+import { useAppContext } from '@/context/app-context'
+import { logout } from '@/service/common'
+import Avatar from '@/app/components/base/avatar'
+import { Triangle } from '@/app/components/base/icons/src/public/education'
+
+const UserInfo = () => {
+  const router = useRouter()
+  const { t } = useTranslation()
+  const { userProfile } = useAppContext()
+
+  const handleLogout = async () => {
+    await logout({
+      url: '/logout',
+      params: {},
+    })
+
+    localStorage.removeItem('setup_status')
+    localStorage.removeItem('console_token')
+    localStorage.removeItem('refresh_token')
+
+    router.push('/signin')
+  }
+
+  return (
+    <div className='relative flex items-center justify-between rounded-xl border-[4px] border-components-panel-on-panel-item-bg bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 pb-6 pl-6 pr-8 pt-9 shadow-shadow-shadow-5'>
+      <div className='absolute left-0 top-0 flex items-center'>
+        <div className='system-2xs-semibold-uppercase flex h-[22px] items-center bg-components-panel-on-panel-item-bg pl-2 pt-1 text-text-accent-light-mode-only'>
+          {t('education.currentSigned')}
+        </div>
+        <Triangle className='h-[22px] w-4 text-components-panel-on-panel-item-bg' />
+      </div>
+      <div className='flex items-center'>
+        <Avatar
+          className='mr-4'
+          avatar={userProfile.avatar_url}
+          name={userProfile.name}
+          size={48}
+        />
+        <div className='pt-1.5'>
+          <div className='system-md-semibold text-text-primary'>
+            {userProfile.name}
+          </div>
+          <div className='system-sm-regular text-text-secondary'>
+            {userProfile.email}
+          </div>
+        </div>
+      </div>
+      <Button
+        variant='secondary'
+        onClick={handleLogout}
+      >
+        {t('common.userProfile.logout')}
+      </Button>
+    </div>
+  )
+}
+
+export default UserInfo

+ 108 - 0
web/app/education-apply/verify-state-modal.tsx

@@ -0,0 +1,108 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import {
+  RiExternalLinkLine,
+} from '@remixicon/react'
+import Button from '@/app/components/base/button'
+
+export type IConfirm = {
+  className?: string
+  isShow: boolean
+  title: string
+  content?: React.ReactNode
+  onConfirm: () => void
+  onCancel: () => void
+  maskClosable?: boolean
+  email?: string
+  showLink?: boolean
+}
+
+function Confirm({
+  isShow,
+  title,
+  content,
+  onConfirm,
+  onCancel,
+  maskClosable = true,
+  showLink,
+  email,
+}: IConfirm) {
+  const { t } = useTranslation()
+  const dialogRef = useRef<HTMLDivElement>(null)
+  const [isVisible, setIsVisible] = useState(isShow)
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape')
+        onCancel()
+    }
+
+    document.addEventListener('keydown', handleKeyDown)
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown)
+    }
+  }, [onCancel])
+
+  const handleClickOutside = (event: MouseEvent) => {
+    if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node))
+      onCancel()
+  }
+
+  useEffect(() => {
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside)
+    }
+  }, [maskClosable])
+
+  useEffect(() => {
+    if (isShow) {
+      setIsVisible(true)
+    }
+    else {
+      const timer = setTimeout(() => setIsVisible(false), 200)
+      return () => clearTimeout(timer)
+    }
+  }, [isShow])
+
+  if (!isVisible)
+    return null
+
+  return createPortal(
+    <div className={'fixed inset-0 z-[10000000] flex items-center justify-center bg-background-overlay'}
+      onClick={(e) => {
+        e.preventDefault()
+        e.stopPropagation()
+      }}
+    >
+      <div ref={dialogRef} className={'relative w-full max-w-[481px] overflow-hidden'}>
+        <div className='shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg'>
+          <div className='flex flex-col items-start gap-2 self-stretch pb-4 pl-6 pr-6 pt-6'>
+            <div className='title-2xl-semi-bold text-text-primary'>{title}</div>
+            <div className='system-md-regular w-full text-text-tertiary'>{content}</div>
+          </div>
+          {email && (
+            <div className='w-full space-y-1 px-6 py-3'>
+              <div className='system-sm-semibold py-1 text-text-secondary'>{t('education.emailLabel')}</div>
+              <div className='system-sm-regular rounded-lg bg-components-input-bg-disabled px-3 py-2 text-components-input-text-filled-disabled'>{email}</div>
+            </div>
+          )}
+          <div className='flex items-center justify-between gap-2 self-stretch p-6'>
+            <div className='flex items-center gap-1'>
+              {showLink && (
+                <>
+                  <a href='' className='system-xs-regular cursor-pointer text-text-accent'>{t('education.learn')}</a>
+                  <RiExternalLinkLine className='h-3 w-3 text-text-accent' />
+                </>
+              )}
+            </div>
+            <Button variant='primary' className='!w-20' onClick={onConfirm}>{t('common.operation.ok')}</Button>
+          </div>
+        </div>
+      </div>
+    </div>, document.body,
+  )
+}
+
+export default React.memo(Confirm)

+ 10 - 1
web/context/modal-context.tsx

@@ -17,7 +17,9 @@ import type {
   ModelLoadBalancingConfigEntry,
   ModelProvider,
 } from '@/app/components/header/account-setting/model-provider-page/declarations'
-
+import {
+  EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
+} from '@/app/education-apply/constants'
 import Pricing from '@/app/components/billing/pricing'
 import type { ModerationConfig, PromptVariable } from '@/models/debug'
 import type {
@@ -33,6 +35,7 @@ import type { OpeningStatement } from '@/app/components/base/features/types'
 import type { InputVar } from '@/app/components/workflow/types'
 import type { UpdatePluginPayload } from '@/app/components/plugins/types'
 import UpdatePlugin from '@/app/components/plugins/update-plugin'
+import { removeSpecificQueryParam } from '@/utils'
 
 export type ModalState<T> = {
   payload: T
@@ -121,6 +124,12 @@ export const ModalContextProvider = ({
   const [showPricingModal, setShowPricingModal] = useState(searchParams.get('show-pricing') === '1')
   const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
   const handleCancelAccountSettingModal = () => {
+    const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+
+    if (educationVerifying === 'yes')
+      localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+
+    removeSpecificQueryParam('action')
     setShowAccountSettingModal(null)
     if (showAccountSettingModal?.onCancelCallback)
       showAccountSettingModal?.onCancelCallback()

+ 19 - 1
web/context/provider-context.tsx

@@ -22,6 +22,9 @@ import { fetchCurrentPlanInfo } from '@/service/billing'
 import { parseCurrentPlan } from '@/app/components/billing/utils'
 import { defaultPlan } from '@/app/components/billing/config'
 import Toast from '@/app/components/base/toast'
+import {
+  useEducationStatus,
+} from '@/service/use-education'
 
 type ProviderContextState = {
   modelProviders: ModelProvider[]
@@ -40,6 +43,9 @@ type ProviderContextState = {
   enableReplaceWebAppLogo: boolean
   modelLoadBalancingEnabled: boolean
   datasetOperatorEnabled: boolean
+  enableEducationPlan: boolean
+  isEducationWorkspace: boolean
+  isEducationAccount: boolean
 }
 const ProviderContext = createContext<ProviderContextState>({
   modelProviders: [],
@@ -70,6 +76,9 @@ const ProviderContext = createContext<ProviderContextState>({
   enableReplaceWebAppLogo: false,
   modelLoadBalancingEnabled: false,
   datasetOperatorEnabled: false,
+  enableEducationPlan: false,
+  isEducationWorkspace: false,
+  isEducationAccount: false,
 })
 
 export const useProviderContext = () => useContext(ProviderContext)
@@ -97,13 +106,19 @@ export const ProviderContextProvider = ({
   const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
   const [datasetOperatorEnabled, setDatasetOperatorEnabled] = useState(false)
 
+  const [enableEducationPlan, setEnableEducationPlan] = useState(false)
+  const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
+  const { data: isEducationAccount } = useEducationStatus(!enableEducationPlan)
+
   const fetchPlan = async () => {
     const data = await fetchCurrentPlanInfo()
     const enabled = data.billing.enabled
     setEnableBilling(enabled)
+    setEnableEducationPlan(data.education.enabled)
+    setIsEducationWorkspace(data.education.activated)
     setEnableReplaceWebAppLogo(data.can_replace_logo)
     if (enabled) {
-      setPlan(parseCurrentPlan(data))
+      setPlan(parseCurrentPlan(data) as any)
       setIsFetchedPlan(true)
     }
     if (data.model_load_balancing_enabled)
@@ -155,6 +170,9 @@ export const ProviderContextProvider = ({
       enableReplaceWebAppLogo,
       modelLoadBalancingEnabled,
       datasetOperatorEnabled,
+      enableEducationPlan,
+      isEducationWorkspace,
+      isEducationAccount: isEducationAccount?.result || false,
     }}>
       {children}
     </ProviderContext.Provider>

+ 47 - 0
web/i18n/en-US/education.ts

@@ -0,0 +1,47 @@
+const translation = {
+  toVerified: 'Get Education Verified',
+  toVerifiedTip: {
+    front: 'You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an',
+    coupon: 'exclusive 50% coupon',
+    end: 'for the Dify Professional Plan.',
+  },
+  currentSigned: 'CURRENTLY SIGNED IN AS',
+  form: {
+    schoolName: {
+      title: 'Your School Name',
+      placeholder: 'Enter the official, unabbreviated name of your school',
+    },
+    schoolRole: {
+      title: 'Your School Role',
+      option: {
+        student: 'Student',
+        teacher: 'Teacher',
+        administrator: 'School Administrator',
+      },
+    },
+    terms: {
+      title: 'Terms & Agreements',
+      desc: {
+        front: 'Your information and use of Education Verified status are subject to our',
+        and: 'and',
+        end: '. By submitting:',
+        termsOfService: 'Terms of Service',
+        privacyPolicy: 'Privacy Policy',
+      },
+      option: {
+        age: 'I confirm I am at least 18 years old',
+        inSchool: 'I confirm I am enrolled or employed at the institution provided. Dify may request proof of enrollment/employment. If I misrepresent my eligibility, I agree to pay any fees initially waived based on my education status.',
+      },
+    },
+  },
+  submit: 'Submit',
+  submitError: 'Form submission failed. Please try again later.',
+  learn: 'Learn how to get education verified',
+  successTitle: 'You Have Got Dify Education Verified',
+  successContent: 'We have issued a 50% discount coupon for the Dify Professional plan to your account. The coupon is valid for one year, please use it within the validity period.',
+  rejectTitle: 'Your Dify Education Verified Has Been Rejected',
+  rejectContent: 'Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 50% coupon for the Dify Professional Plan if you use this email address.',
+  emailLabel: 'Your current email',
+}
+
+export default translation

+ 13 - 0
web/i18n/i18next-config.ts

@@ -4,6 +4,18 @@ import { initReactI18next } from 'react-i18next'
 
 import { LanguagesSupported } from '@/i18n/language'
 
+const requireSilent = (lang: string) => {
+  let res
+  try {
+    res = require(`./${lang}/education`).default
+  }
+  catch {
+    res = require('./en-US/education').default
+  }
+
+  return res
+}
+
 const loadLangResources = (lang: string) => ({
   translation: {
     common: require(`./${lang}/common`).default,
@@ -31,6 +43,7 @@ const loadLangResources = (lang: string) => ({
     plugin: require(`./${lang}/plugin`).default,
     pluginTags: require(`./${lang}/plugin-tags`).default,
     time: require(`./${lang}/time`).default,
+    education: requireSilent(lang),
   },
 })
 

+ 47 - 0
web/i18n/ja-JP/education.ts

@@ -0,0 +1,47 @@
+const translation = {
+  toVerified: '教育認証を取得',
+  toVerifiedTip: {
+    front: '現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Difyプロフェッショナルプランの',
+    coupon: '50%割引クーポン',
+    end: 'を受け取ることができます。',
+  },
+  currentSigned: '現在ログイン中のアカウントは',
+  form: {
+    schoolName: {
+      title: '学校名',
+      placeholder: '学校の正式名称(省略不可)を入力してください。',
+    },
+    schoolRole: {
+      title: '学校での役割',
+      option: {
+        student: '学生',
+        teacher: '教師',
+        administrator: '学校管理者',
+      },
+    },
+    terms: {
+      title: '利用規約と同意事項',
+      desc: {
+        front: 'お客様の情報および 教育認証ステータス の利用は、当社の ',
+        and: 'および',
+        end: 'に従うものとします。送信することで以下を確認します:',
+        termsOfService: '利用規約',
+        privacyPolicy: 'プライバシーポリシー',
+      },
+      option: {
+        age: '18歳以上であることを確認します。',
+        inSchool: '提供した教育機関に在籍または勤務している ことを確認します。Difyは在籍/雇用証明の提出を求める場合があります。不正な情報を申告した場合、教育認証に基づき免除された費用を支払うことに同意します。',
+      },
+    },
+  },
+  submit: '送信',
+  submitError: 'フォームの送信に失敗しました。しばらくしてから再度ご提出ください。',
+  learn: '教育認証の取得方法はこちら',
+  successTitle: 'Dify教育認証を取得しました!',
+  successContent: 'お客様のアカウントに Difyプロフェッショナルプランの50%割引クーポン を発行しました。有効期間は 1年間 ですので、期限内にご利用ください。',
+  rejectTitle: 'Dify教育認証が拒否されました',
+  rejectContent: '申し訳ございませんが、このメールアドレスでは 教育認証 の資格を取得できず、Difyプロフェッショナルプランの50%割引クーポン を受け取ることはできません。',
+  emailLabel: '現在のメールアドレス',
+}
+
+export default translation

+ 48 - 0
web/i18n/zh-Hans/education.ts

@@ -0,0 +1,48 @@
+const translation = {
+  toVerified: '获取教育版认证',
+  toVerifiedTip: {
+    front: '您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify  Professional 版的',
+    coupon: '50% 独家优惠券',
+    end: '。',
+  },
+  currentSigned: '您当前登录的账户是',
+  form: {
+    schoolName: {
+      title: '您的学校名称',
+      placeholder: '请输入您的学校的官方全称(不得缩写)',
+    },
+    schoolRole: {
+      title: '您在学校的身份',
+      option: {
+        student: '学生',
+        teacher: '教师',
+        administrator: '学校管理员',
+      },
+    },
+    terms: {
+      title: '条款与协议',
+      desc: {
+        front: '您的信息和教育版认证资格的使用需遵守我们的',
+        and: '和',
+        end: '。提交即表示:',
+        termsOfService: '服务条款',
+        privacyPolicy: '隐私政策',
+      },
+      option: {
+        age: '我确认我已年满 18 周岁。',
+        inSchool: '我确认我目前已在提供的学校入学或受雇。Dify 可能会要求提供入学/雇佣证明。如我虚报资格,我同意支付因教育版认证而被减免的费用。',
+      },
+    },
+  },
+  submit: '提交',
+  submitError: '提交表单失败,请稍后重新提交问卷。',
+  learn: '了解如何获取教育版认证',
+  successTitle: '您已成功获得 Dify 教育版认证!',
+  successContent: '我们已向您的账户发放 Dify Professional 版 50% 折扣优惠券。该优惠券有效期为一年,请在有效期内使用。',
+  rejectTitle: '您的 Dify 教育版认证已被拒绝',
+  rejectContent: '非常遗憾,您无法使用此电子邮件以获得教育版认证资格,也无法领取 Dify Professional 版的 50% 独家优惠券。',
+  emailLabel: '您当前的邮箱',
+
+}
+
+export default translation

BIN
web/public/education/bg.png


+ 67 - 0
web/service/use-education.ts

@@ -0,0 +1,67 @@
+import { get, post } from './base'
+import {
+  useMutation,
+  useQuery,
+} from '@tanstack/react-query'
+import { useInvalid } from './use-base'
+import type { EducationAddParams } from '@/app/education-apply/types'
+
+const NAME_SPACE = 'education'
+
+export const useEducationVerify = () => {
+  return useMutation({
+    mutationKey: [NAME_SPACE, 'education-verify'],
+    mutationFn: () => {
+      return get<{ token: string }>('/account/education/verify', {}, { silent: true })
+    },
+  })
+}
+
+export const useEducationAdd = ({
+  onSuccess,
+}: {
+  onSuccess?: () => void
+}) => {
+  return useMutation({
+    mutationKey: [NAME_SPACE, 'education-add'],
+    mutationFn: (params: EducationAddParams) => {
+      return post<{ message: string }>('/account/education', {
+        body: params,
+      })
+    },
+    onSuccess,
+  })
+}
+
+type SearchParams = {
+  keywords?: string
+  page?: number
+  limit?: number
+}
+export const useEducationAutocomplete = () => {
+  return useMutation({
+    mutationFn: (searchParams: SearchParams) => {
+      const {
+        keywords = '',
+        page = 0,
+        limit = 40,
+      } = searchParams
+      return get<{ data: string[]; has_next: boolean; curr_page: number }>(`/account/education/autocomplete?keywords=${keywords}&page=${page}&limit=${limit}`)
+    },
+  })
+}
+
+export const useEducationStatus = (disable?: boolean) => {
+  return useQuery({
+    enabled: !disable,
+    queryKey: [NAME_SPACE, 'education-status'],
+    queryFn: () => {
+      return get<{ result: boolean }>('/account/education')
+    },
+    retry: false,
+  })
+}
+
+export const useInvalidateEducationStatus = () => {
+  return useInvalid([NAME_SPACE, 'education-status'])
+}

+ 9 - 0
web/utils/index.ts

@@ -90,3 +90,12 @@ export const canFindTool = (providerId: string, oldToolId?: string) => {
     || providerId === `langgenius/${oldToolId}/${oldToolId}`
     || providerId === `langgenius/${oldToolId}_tool/${oldToolId}`
 }
+
+export const removeSpecificQueryParam = (key: string | string[]) => {
+  const url = new URL(window.location.href)
+  if (Array.isArray(key))
+    key.forEach(k => url.searchParams.delete(k))
+  else
+    url.searchParams.delete(key)
+  window.history.replaceState(null, '', url.toString())
+}