diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index cc735ae67c..851621ee49 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -6,6 +6,7 @@ on: - "main" - "deploy/dev" - "deploy/enterprise" + - release/1.1.3-fix1 tags: - "*" diff --git a/web/app/(commonLayout)/apps/Apps.tsx b/web/app/(commonLayout)/apps/Apps.tsx index be5031ab43..d98851c4e9 100644 --- a/web/app/(commonLayout)/apps/Apps.tsx +++ b/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' diff --git a/web/app/(commonLayout)/apps/page.tsx b/web/app/(commonLayout)/apps/page.tsx index 85fe433446..4a146d9b65 100644 --- a/web/app/(commonLayout)/apps/page.tsx +++ b/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 ( diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx new file mode 100644 index 0000000000..873034452e --- /dev/null +++ b/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 +} diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index 6176fe58af..d09a8c2cfe 100644 --- a/web/app/account/account-page/index.tsx +++ b/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() {
-

{userProfile.name}

+

+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +

{userProfile.email}

diff --git a/web/app/account/avatar.tsx b/web/app/account/avatar.tsx index e37d15c6ae..63db0f37dc 100644 --- a/web/app/account/avatar.tsx +++ b/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() {
-
{userProfile.name}
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
{userProfile.email}
diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 87f14b0e31..01ba8c606d 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -182,7 +182,6 @@ const DatasetConfig: FC = () => { }, [setDatasetConfigs, datasetConfigsRef]) const handleUpdateCondition = useCallback((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) => { diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 03b04887d2..f4fc86101e 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/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) } diff --git a/web/app/components/base/icons/assets/public/education/triangle.svg b/web/app/components/base/icons/assets/public/education/triangle.svg new file mode 100644 index 0000000000..e52c5c5a43 --- /dev/null +++ b/web/app/components/base/icons/assets/public/education/triangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/public/education/Triangle.json b/web/app/components/base/icons/src/public/education/Triangle.json new file mode 100644 index 0000000000..92d7c82c43 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/public/education/Triangle.tsx b/web/app/components/base/icons/src/public/education/Triangle.tsx new file mode 100644 index 0000000000..34f2a50666 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Triangle' + +export default Icon diff --git a/web/app/components/base/icons/src/public/education/index.ts b/web/app/components/base/icons/src/public/education/index.ts new file mode 100644 index 0000000000..de505dbbdc --- /dev/null +++ b/web/app/components/base/icons/src/public/education/index.ts @@ -0,0 +1 @@ +export { default as Triangle } from './Triangle' diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx index a88e04889e..1e2e198775 100644 --- a/web/app/components/base/portal-to-follow-elem/index.tsx +++ b/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` + }, + }), ], }) diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index b6ca148ea3..7badb3666f 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/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 = ({ 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 = ({ 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 (
@@ -58,14 +80,22 @@ const PlanComp: FC = ({
{t(`billing.plans.${type}.for`)}
- {(plan.type as any) !== SelfHostedPlan.enterprise && ( - - )} +
+ {enableEducationPlan && !isEducationAccount && ( + + )} + {(plan.type as any) !== SelfHostedPlan.enterprise && ( + + )} +
{/* Plan detail */} @@ -92,6 +122,15 @@ const PlanComp: FC = ({ /> + setShowModal(false)} + onCancel={() => setShowModal(false)} + /> ) } diff --git a/web/app/components/billing/type.ts b/web/app/components/billing/type.ts index 01d9f2fe7e..28bce37098 100644 --- a/web/app/components/billing/type.ts +++ b/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 = { diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 2d20dd9d50..1a0cc96b98 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/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 }) => ( <> - - - {!isMobile && <> - {userProfile.name} - - } + +
-
{userProfile.name}
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
{userProfile.email}
@@ -101,7 +107,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
-
setShowAccountSettingModal({ payload: 'members' })}> @@ -123,7 +129,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
{systemFeatures.license.status === LicenseStatus.NONE && -
setAboutVisible(true)}> @@ -186,7 +192,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
handleLogout()}>
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 387e7bfb29..70b6ece5fd 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/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 = () => { diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index 7e46e000d5..13c587dc19 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -94,10 +94,10 @@ const Header = () => { }
-
+
- +
{ (isMobile && isShowNavMenu) && ( diff --git a/web/app/components/header/plan-badge/index.tsx b/web/app/components/header/plan-badge/index.tsx index 22f7a0fa05..37cbe2a710 100644 --- a/web/app/components/header/plan-badge/index.tsx +++ b/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 = ({ 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 = ({ plan, allowHover, sandboxAsUpgrade = fa if (plan === Plan.professional) { return
- + + {isEducationWorkspace && } pro
diff --git a/web/app/components/swr-initor.tsx b/web/app/components/swr-initor.tsx index 2a119df996..d6a7f23f3a 100644 --- a/web/app/components/swr-initor.tsx +++ b/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) { diff --git a/web/app/education-apply/constants.ts b/web/app/education-apply/constants.ts new file mode 100644 index 0000000000..a5672d19f9 --- /dev/null +++ b/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' diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx new file mode 100644 index 0000000000..8d822f2ab0 --- /dev/null +++ b/web/app/education-apply/education-apply-page.tsx @@ -0,0 +1,191 @@ +'use client' + +import { + useMemo, + 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' +import { getLocaleOnClient } from '@/i18n' + +const EducationApplyAge = () => { + const { t } = useTranslation() + const locale = getLocaleOnClient() + 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 void }>(undefined) + const { onPlanInfoChanged } = useProviderContext() + const updateEducationStatus = useInvalidateEducationStatus() + const { notify } = useToastContext() + const router = useRouter() + + const docLink = useMemo(() => { + if (locale === 'zh-Hans') + return 'https://docs.dify.ai/zh-hans/getting-started/dify-for-education' + if (locale === 'ja-JP') + return 'https://docs.dify.ai/ja-jp/getting-started/dify-for-education' + return 'https://docs.dify.ai/getting-started/dify-for-education' + }, [locale]) + + 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 ( +
+
+
+
+
+ dify logo +
+
+
+
{t('education.toVerified')}
+
+ {t('education.toVerifiedTip.front')}  + {t('education.toVerifiedTip.coupon')}  + {t('education.toVerifiedTip.end')} +
+
+
+ +
+
+
+ {t('education.form.schoolName.title')} +
+ +
+
+
+ {t('education.form.schoolRole.title')} +
+ +
+
+
+ {t('education.form.terms.title')} +
+
+ {t('education.form.terms.desc.front')}  + {t('education.form.terms.desc.termsOfService')}  + {t('education.form.terms.desc.and')}  + {t('education.form.terms.desc.privacyPolicy')} + {t('education.form.terms.desc.end')} +
+
+
+ setAgeChecked(!ageChecked)} + /> + {t('education.form.terms.option.age')} +
+
+ setInSchoolChecked(!inSchoolChecked)} + /> + {t('education.form.terms.option.inSchool')} +
+
+
+ +
+ + {t('education.learn')} + + +
+
+ {})} + onCancel={modalShow?.onConfirm || (() => {})} + /> +
+ ) +} + +export default EducationApplyAge diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts new file mode 100644 index 0000000000..01fb36c7ff --- /dev/null +++ b/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([]) + 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]) +} diff --git a/web/app/education-apply/role-selector.tsx b/web/app/education-apply/role-selector.tsx new file mode 100644 index 0000000000..b8448a0052 --- /dev/null +++ b/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 ( +
+ { + options.map(option => ( +
onChange(option.key)} + > +
+
+ {option.value} +
+ )) + } +
+ ) +} + +export default RoleSelector diff --git a/web/app/education-apply/search-input.tsx b/web/app/education-apply/search-input.tsx new file mode 100644 index 0000000000..800f49fc06 --- /dev/null +++ b/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 ( + + + + + + { + !!schools.length && value && ( +
+ { + schools.map((school, index) => ( +
{ + onChange(school) + setOpen(false) + }} + > + {school} +
+ )) + } +
+ ) + } +
+
+ ) +} + +export default SearchInput diff --git a/web/app/education-apply/types.ts b/web/app/education-apply/types.ts new file mode 100644 index 0000000000..ff435c6fc0 --- /dev/null +++ b/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 +} diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx new file mode 100644 index 0000000000..e1d60a5e94 --- /dev/null +++ b/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 ( +
+
+
+ {t('education.currentSigned')} +
+ +
+
+ +
+
+ {userProfile.name} +
+
+ {userProfile.email} +
+
+
+ +
+ ) +} + +export default UserInfo diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx new file mode 100644 index 0000000000..aace6a3bb1 --- /dev/null +++ b/web/app/education-apply/verify-state-modal.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useMemo, 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' +import { getLocaleOnClient } from '@/i18n' + +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 locale = getLocaleOnClient() + const dialogRef = useRef(null) + const [isVisible, setIsVisible] = useState(isShow) + + const docLink = useMemo(() => { + if (locale === 'zh-Hans') + return 'https://docs.dify.ai/zh-hans/getting-started/dify-for-education' + if (locale === 'ja-JP') + return 'https://docs.dify.ai/ja-jp/getting-started/dify-for-education' + return 'https://docs.dify.ai/getting-started/dify-for-education' + }, [locale]) + + const handleClick = () => { + window.open(docLink, '_blank', 'noopener,noreferrer') + } + + 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( +
{ + e.preventDefault() + e.stopPropagation() + }} + > +
+
+
+
{title}
+
{content}
+
+ {email && ( +
+
{t('education.emailLabel')}
+
{email}
+
+ )} +
+
+ {showLink && ( + <> + {t('education.learn')} + + + )} +
+ +
+
+
+
, document.body, + ) +} + +export default React.memo(Confirm) diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index 622077ee91..8641dce617 100644 --- a/web/context/modal-context.tsx +++ b/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 = { 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() diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index 11340f6acd..67c389167d 100644 --- a/web/context/provider-context.tsx +++ b/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({ modelProviders: [], @@ -70,6 +76,9 @@ const ProviderContext = createContext({ 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} diff --git a/web/i18n/en-US/education.ts b/web/i18n/en-US/education.ts new file mode 100644 index 0000000000..b3a13612ed --- /dev/null +++ b/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 Educational Verification 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 diff --git a/web/i18n/i18next-config.ts b/web/i18n/i18next-config.ts index 7215b7818e..eea15ac8be 100644 --- a/web/i18n/i18next-config.ts +++ b/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), }, }) diff --git a/web/i18n/ja-JP/education.ts b/web/i18n/ja-JP/education.ts new file mode 100644 index 0000000000..d51bac817d --- /dev/null +++ b/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 diff --git a/web/i18n/zh-Hans/education.ts b/web/i18n/zh-Hans/education.ts new file mode 100644 index 0000000000..ca4d2cb3cc --- /dev/null +++ b/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 diff --git a/web/public/education/bg.png b/web/public/education/bg.png new file mode 100644 index 0000000000..0c2e503f5f Binary files /dev/null and b/web/public/education/bg.png differ diff --git a/web/service/use-education.ts b/web/service/use-education.ts new file mode 100644 index 0000000000..4405db7478 --- /dev/null +++ b/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']) +} diff --git a/web/utils/index.ts b/web/utils/index.ts index 4a0adce113..263d415479 100644 --- a/web/utils/index.ts +++ b/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()) +}