mirror of
https://github.com/langgenius/dify.git
synced 2026-08-15 04:59:46 +08:00
refactor(web): move education verification to dedicated routes (#40493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
8598af2a17
commit
78c7fb9c5c
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -245,7 +245,7 @@
|
||||
|
||||
# Frontend - Billing and Education
|
||||
/web/app/components/billing/ @iamjoel @zxhlyh
|
||||
/web/app/education-apply/ @iamjoel @zxhlyh
|
||||
/web/app/education/ @iamjoel @zxhlyh
|
||||
|
||||
# Frontend - Workspace
|
||||
/web/app/components/header/account-dropdown/workplace-selector/ @iamjoel @zxhlyh
|
||||
|
||||
@ -1162,12 +1162,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/public/common/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/public/education/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/public/files/index.ts": {
|
||||
@ -5393,25 +5388,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/role-selector.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/search-input.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/forgot-password/ChangePasswordForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -80,13 +80,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@ -95,12 +88,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
/**
|
||||
* Integration test: Education Verification Flow
|
||||
*
|
||||
* Tests the education plan verification flow in PlanComp:
|
||||
* PlanComp → handleVerify → show temporary pause notice
|
||||
*
|
||||
* Also covers education button visibility based on context flags.
|
||||
*/
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { cleanup, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { cleanup, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
@ -48,8 +39,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
@ -75,48 +64,12 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
title,
|
||||
content,
|
||||
email,
|
||||
showLink,
|
||||
}: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
content?: React.ReactNode
|
||||
email?: string
|
||||
showLink?: boolean
|
||||
}) =>
|
||||
isShow ? (
|
||||
<div data-testid="verify-state-modal">
|
||||
{title && <span data-testid="modal-title">{title}</span>}
|
||||
{content !== undefined && content !== null ? (
|
||||
<span data-testid="modal-content">{content}</span>
|
||||
) : null}
|
||||
{email && <span data-testid="modal-email">{email}</span>}
|
||||
{showLink && <span data-testid="modal-show-link">link</span>}
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
@ -184,7 +137,10 @@ describe('Education Verification Flow', () => {
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /toVerified/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/education/verify',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show verify button when already verified and not about to expire', () => {
|
||||
@ -209,32 +165,7 @@ describe('Education Verification Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Temporarily Paused Verification Flow ────────────────────────────
|
||||
describe('Temporarily paused verification flow', () => {
|
||||
it('should show the pause notice without starting verification', async () => {
|
||||
setupContexts({}, { enableEducationPlan: true })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(/educationDiscountPaused.title/i)
|
||||
expect(screen.getByTestId('modal-content')).toHaveTextContent(
|
||||
/educationDiscountPaused.description/i,
|
||||
)
|
||||
expect(screen.queryByTestId('modal-email')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('modal-show-link')).not.toBeInTheDocument()
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
// ─── 2. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
describe('Education and upgrade button coexistence', () => {
|
||||
it('should show both education verify and upgrade buttons for sandbox user', () => {
|
||||
setupContexts({ type: Plan.sandbox }, { enableEducationPlan: true })
|
||||
|
||||
@ -10,11 +10,15 @@ vi.mock('@/env', () => ({
|
||||
env: mockEnv,
|
||||
}))
|
||||
|
||||
const createRequest = (url: string) =>
|
||||
({
|
||||
const createRequest = (url: string) => {
|
||||
const nextUrl = new URL(url) as URL & { clone: () => URL }
|
||||
nextUrl.clone = () => new URL(nextUrl)
|
||||
|
||||
return {
|
||||
headers: new Headers(),
|
||||
nextUrl: new URL(url),
|
||||
}) as Parameters<typeof proxy>[0]
|
||||
nextUrl,
|
||||
} as Parameters<typeof proxy>[0]
|
||||
}
|
||||
|
||||
describe('proxy frame options', () => {
|
||||
afterEach(() => {
|
||||
@ -70,3 +74,26 @@ describe('proxy frame options', () => {
|
||||
expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxy education entry normalization', () => {
|
||||
it('redirects the legacy education action without leaking it into the canonical URL', () => {
|
||||
const response = proxy(
|
||||
createRequest('https://cloud.dify.ai/?action=getEducationVerify&utm_source=education-site'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(308)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://cloud.dify.ai/education/verify?utm_source=education-site',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redirect unrelated actions or paths', () => {
|
||||
const unrelatedAction = proxy(createRequest('https://cloud.dify.ai/?action=showSettings'))
|
||||
const unrelatedPath = proxy(
|
||||
createRequest('https://cloud.dify.ai/apps?action=getEducationVerify'),
|
||||
)
|
||||
|
||||
expect(unrelatedAction.status).toBe(200)
|
||||
expect(unrelatedPath.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import EducationApplyPage from '@/app/education-apply/education-apply-page'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export default function EducationApply() {
|
||||
const router = useRouter()
|
||||
const { enableEducationPlan, isFetchedPlanInfo } = useProviderContext()
|
||||
const { isLoading: isLoadingEducationStatus } = useQuery(
|
||||
consoleQuery.account.education.get.queryOptions({ enabled: enableEducationPlan }),
|
||||
)
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchedPlanInfo) return
|
||||
|
||||
if (!enableEducationPlan || !token) router.replace('/')
|
||||
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
|
||||
|
||||
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationStatus)
|
||||
return <FullScreenLoading />
|
||||
|
||||
return <EducationApplyPage />
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${url}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/home/page', () => ({
|
||||
HomePage: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: (url: string) => mocks.redirect(url),
|
||||
}))
|
||||
|
||||
describe('Home route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens billing for the legacy education verification action', async () => {
|
||||
const { default: Page } = await import('./page')
|
||||
|
||||
await expect(
|
||||
Page({
|
||||
searchParams: Promise.resolve({
|
||||
action: 'getEducationVerify',
|
||||
utm_source: 'education-email',
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/?settings=billing&utm_source=education-email')
|
||||
})
|
||||
})
|
||||
@ -1,47 +1,4 @@
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { HomePage } from '@/features/home/page'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
type HomeSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: Promise<HomeSearchParams>
|
||||
}
|
||||
|
||||
const LEGACY_EDUCATION_VERIFY_ACTION = 'getEducationVerify'
|
||||
const SETTINGS_QUERY_PARAM_NAME = 'settings'
|
||||
|
||||
const getFirstSearchParamValue = (value: string | string[] | undefined) => {
|
||||
if (Array.isArray(value)) return value[0]
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getEducationVerifyRedirectPath = (searchParams: HomeSearchParams) => {
|
||||
const redirectSearchParams = new URLSearchParams({
|
||||
[SETTINGS_QUERY_PARAM_NAME]: ACCOUNT_SETTING_TAB.BILLING,
|
||||
})
|
||||
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (key === 'action' || key === SETTINGS_QUERY_PARAM_NAME || value === undefined) return
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => redirectSearchParams.append(key, item))
|
||||
return
|
||||
}
|
||||
|
||||
redirectSearchParams.append(key, value)
|
||||
})
|
||||
|
||||
return `/?${redirectSearchParams.toString()}`
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const resolvedSearchParams = (await searchParams) ?? {}
|
||||
const action = getFirstSearchParamValue(resolvedSearchParams.action)
|
||||
|
||||
if (action === LEGACY_EDUCATION_VERIFY_ACTION)
|
||||
redirect(getEducationVerifyRedirectPath(resolvedSearchParams))
|
||||
|
||||
export default function Page() {
|
||||
return <HomePage />
|
||||
}
|
||||
|
||||
14
web/app/(education)/education/apply/page.tsx
Normal file
14
web/app/(education)/education/apply/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import EducationApplyRoute from '@/app/education/apply/application-entry'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string | string[] }>
|
||||
}) {
|
||||
const { token } = await searchParams
|
||||
|
||||
if (typeof token !== 'string' || token.length === 0) redirect('/education/verify')
|
||||
|
||||
return <EducationApplyRoute token={token} />
|
||||
}
|
||||
5
web/app/(education)/education/verify/page.tsx
Normal file
5
web/app/(education)/education/verify/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import EducationVerifyPage from '@/app/education/verify/verify-flow'
|
||||
|
||||
export default function Page() {
|
||||
return <EducationVerifyPage />
|
||||
}
|
||||
11
web/app/(education)/layout.tsx
Normal file
11
web/app/(education)/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ConsoleRuntimeProviders } from '@/app/(commonLayout)/providers'
|
||||
import EducationShell from '@/app/education/education-shell'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ConsoleRuntimeProviders>
|
||||
<EducationShell>{children}</EducationShell>
|
||||
</ConsoleRuntimeProviders>
|
||||
)
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@ -65,14 +64,10 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/education-apply/expire-notice', () => ({
|
||||
vi.mock('@/app/education/expire-notice', () => ({
|
||||
EducationExpireNotice: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/education-apply/external-action-boundary', () => ({
|
||||
EducationExternalActionBoundary: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => ({
|
||||
@ -244,7 +239,7 @@ describe('Apps', () => {
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return {
|
||||
|
||||
@ -6,8 +6,7 @@ import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EducationExpireNotice } from '@/app/education-apply/expire-notice'
|
||||
import { EducationExternalActionBoundary } from '@/app/education-apply/external-action-boundary'
|
||||
import { EducationExpireNotice } from '@/app/education/expire-notice'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
@ -233,9 +232,5 @@ const AppsContent = () => {
|
||||
}
|
||||
|
||||
export function Apps() {
|
||||
return (
|
||||
<EducationExternalActionBoundary>
|
||||
<AppsContent />
|
||||
</EducationExternalActionBoundary>
|
||||
)
|
||||
return <AppsContent />
|
||||
}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M12.5674 1.56341C12.5532 1.43246 12.4469 1.33346 12.3203 1.33333C12.1938 1.3332 12.0873 1.43196 12.0728 1.56288C12.0053 2.1724 11.8316 2.59056 11.5593 2.87418C11.287 3.1578 10.8856 3.33881 10.3004 3.40911C10.1747 3.42421 10.08 3.53514 10.0801 3.66693C10.0802 3.79872 10.1752 3.90944 10.3009 3.92427C10.8762 3.99215 11.2868 4.17312 11.566 4.45869C11.8437 4.74271 12.0207 5.16027 12.0721 5.76368C12.0836 5.89756 12.1913 6.00015 12.3203 6C12.4494 5.99984 12.5569 5.897 12.568 5.7631C12.6174 5.16988 12.7943 4.74291 13.0737 4.45176C13.3533 4.1606 13.7632 3.9763 14.3326 3.92496C14.4612 3.91336 14.56 3.80136 14.5601 3.66696C14.5602 3.53255 14.4617 3.42032 14.3332 3.40842C13.7539 3.35482 13.3531 3.17038 13.0804 2.88113C12.8063 2.5903 12.6325 2.16262 12.5674 1.56341Z",
|
||||
"fill": "#155AEF"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M8.15567 3.25831C8.11906 2.92157 7.84578 2.66702 7.52041 2.66667C7.19509 2.66633 6.92124 2.92029 6.88399 3.25695C6.71042 4.8243 6.2636 5.89953 5.56346 6.62885C4.86332 7.35814 3.83109 7.82361 2.32643 8.00441C2.00323 8.04321 1.75943 8.32847 1.75977 8.66734C1.7601 9.00627 2.00446 9.29094 2.32773 9.32907C3.80694 9.50361 4.86268 9.96901 5.58062 10.7033C6.29465 11.4337 6.74997 12.5073 6.88226 14.059C6.91164 14.4033 7.18869 14.6671 7.52047 14.6667C7.85231 14.6663 8.12879 14.4018 8.1574 14.0575C8.28412 12.5321 8.73909 11.4342 9.45781 10.6855C10.1766 9.93681 11.2305 9.46287 12.6949 9.33087C13.0255 9.30107 13.2794 9.01307 13.2798 8.66741C13.2801 8.32181 13.0269 8.03321 12.6964 8.00261C11.2068 7.86481 10.1761 7.39054 9.47497 6.64673C8.77001 5.89887 8.32322 4.79915 8.15567 3.25831Z",
|
||||
"fill": "#155AEF"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "SparklesSoftAccent"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './SparklesSoftAccent.json'
|
||||
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
}) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'SparklesSoftAccent'
|
||||
|
||||
export default Icon
|
||||
@ -6,4 +6,3 @@ export { default as Line3 } from './Line3'
|
||||
export { default as Notion } from './Notion'
|
||||
|
||||
export { default as SparklesSoft } from './SparklesSoft'
|
||||
export { default as SparklesSoftAccent } from './SparklesSoftAccent'
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Triangle.json'
|
||||
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
}) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Triangle'
|
||||
|
||||
export default Icon
|
||||
@ -1 +0,0 @@
|
||||
export { default as Triangle } from './Triangle'
|
||||
@ -1,22 +1,9 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { baseProviderContextValue } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import PlanComp from '../index'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createAccountStateModuleMock(() => ({
|
||||
userProfile: { email: 'user@example.com' },
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => ({
|
||||
@ -35,17 +22,6 @@ vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
isPending: false,
|
||||
mutateAsync: mocks.mutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/hooks/use-education-discount', () => ({
|
||||
useEducationDiscount: () => ({
|
||||
handleEducationDiscount: vi.fn(),
|
||||
@ -85,7 +61,7 @@ const renderPlan = (educationStatus = { allow_refresh: false, is_student: false
|
||||
return render(<PlanComp loc="billing-page" />, { wrapper })
|
||||
}
|
||||
|
||||
describe('PlanComp education discount pause', () => {
|
||||
describe('PlanComp education verification entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@ -93,37 +69,18 @@ describe('PlanComp education discount pause', () => {
|
||||
it('shows education verification before View Plan when the original eligibility allows it', () => {
|
||||
renderPlan()
|
||||
|
||||
const educationButton = screen.getByRole('button', { name: 'education.toVerified' })
|
||||
const educationLink = screen.getByRole('link', { name: 'education.toVerified' })
|
||||
const viewPlanButton = screen.getByRole('button', { name: 'View Plan' })
|
||||
|
||||
expect(
|
||||
educationButton.compareDocumentPosition(viewPlanButton) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
educationLink.compareDocumentPosition(viewPlanButton) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(educationLink).toHaveAttribute('href', '/education/verify')
|
||||
})
|
||||
|
||||
it('hides education verification for a verified account that is not expiring', () => {
|
||||
renderPlan({ allow_refresh: false, is_student: true })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'education.toVerified' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the pause notice instead of starting verification and closes it with OK', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPlan()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'education.toVerified' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('education.educationDiscountPaused.title')
|
||||
expect(dialog).toHaveTextContent('education.educationDiscountPaused.description')
|
||||
expect(dialog).toHaveTextContent('education.educationDiscountPaused.thanks')
|
||||
expect(dialog).toHaveTextContent('education.educationDiscountPaused.publishedAt')
|
||||
expect(within(dialog).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(dialog).getByRole('button')).toHaveAccessibleName('common.operation.ok')
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'education.toVerified' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,23 +1,19 @@
|
||||
'use client'
|
||||
import type { EducationStatusResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { RiBook2Line, RiFileEditLine, RiGroupLine } from '@remixicon/react'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useUnmountedRef } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import UsageInfo from '@/app/components/billing/usage-info'
|
||||
import VerifyStateModal from '@/app/education-apply/verify-state-modal'
|
||||
import { userProfileEmailAtom } from '@/context/account-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useEducationVerify } from '@/service/use-education'
|
||||
import { getDaysUntilEndOfMonth } from '@/utils/time'
|
||||
import Loading from '../../base/icons/src/public/thought/Loading'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
@ -37,9 +33,6 @@ const selectEducationPlanStatus = ({ allow_refresh, is_student }: EducationStatu
|
||||
isEducationAccount: is_student ?? false,
|
||||
})
|
||||
|
||||
// TODO: Remove this temporary gate once education applications and redemptions reopen.
|
||||
const EDUCATION_DISCOUNT_TEMPORARILY_PAUSED = true
|
||||
|
||||
const PlanComp: FC<Props> = ({ loc }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
@ -47,8 +40,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const router = useRouter()
|
||||
const userProfileEmail = useAtomValue(userProfileEmailAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { plan, enableEducationPlan } = useProviderContext()
|
||||
const { data: educationStatus } = useQuery(
|
||||
@ -73,28 +64,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const [showModal, setShowModal] = React.useState(false)
|
||||
const [showEducationDiscountPausedModal, setShowEducationDiscountPausedModal] =
|
||||
React.useState(false)
|
||||
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
|
||||
const { mutateAsync, isPending } = useEducationVerify()
|
||||
const unmountedRef = useUnmountedRef()
|
||||
const handleVerify = () => {
|
||||
if (EDUCATION_DISCOUNT_TEMPORARILY_PAUSED) {
|
||||
setShowEducationDiscountPausedModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPending) return
|
||||
mutateAsync()
|
||||
.then((res) => {
|
||||
if (unmountedRef.current) return
|
||||
router.push(`/education-apply?token=${res.token}`)
|
||||
})
|
||||
.catch(() => {
|
||||
setShowModal(true)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn">
|
||||
<div className="p-6 pb-2">
|
||||
@ -115,11 +85,10 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isCloudEdition && enableEducationPlan && (!isEducationAccount || isAboutToExpire) && (
|
||||
<Button variant="ghost" onClick={handleVerify} disabled={isPending}>
|
||||
<span className="i-ri-graduation-cap-line size-4" />
|
||||
<Link className={buttonVariants({ variant: 'ghost' })} href="/education/verify">
|
||||
<span className="i-ri-graduation-cap-line size-4" aria-hidden="true" />
|
||||
{t(($) => $.toVerified, { ns: 'education' })}
|
||||
{isPending && <Loading className="animate-spin-slow" />}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isCloudEdition &&
|
||||
enableEducationPlan &&
|
||||
@ -131,7 +100,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
onClick={handleEducationDiscount}
|
||||
disabled={isEducationDiscountLoading}
|
||||
>
|
||||
<span className="i-ri-graduation-cap-line size-4" />
|
||||
<span className="i-ri-graduation-cap-line size-4" aria-hidden="true" />
|
||||
{t(($) => $.useEducationDiscount, { ns: 'education' })}
|
||||
{isEducationDiscountLoading && <Loading className="animate-spin-slow" />}
|
||||
</Button>
|
||||
@ -185,34 +154,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
resetInDays={apiRateLimitResetInDays}
|
||||
/>
|
||||
</div>
|
||||
<VerifyStateModal
|
||||
isShow={showEducationDiscountPausedModal}
|
||||
title={t(($) => $['educationDiscountPaused.title'], { ns: 'education' })}
|
||||
content={
|
||||
<>
|
||||
<span className="block">
|
||||
{t(($) => $['educationDiscountPaused.description'], { ns: 'education' })}
|
||||
</span>
|
||||
<span className="mt-4 block">
|
||||
{t(($) => $['educationDiscountPaused.thanks'], { ns: 'education' })}
|
||||
</span>
|
||||
<span className="mt-4 block system-xs-regular">
|
||||
{t(($) => $['educationDiscountPaused.publishedAt'], { ns: 'education' })}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => setShowEducationDiscountPausedModal(false)}
|
||||
onCancel={() => setShowEducationDiscountPausedModal(false)}
|
||||
/>
|
||||
<VerifyStateModal
|
||||
showLink
|
||||
email={userProfileEmail}
|
||||
isShow={showModal}
|
||||
title={t(($) => $.rejectTitle, { ns: 'education' })}
|
||||
content={t(($) => $.rejectContent, { ns: 'education' })}
|
||||
onConfirm={() => setShowModal(false)}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -212,7 +212,7 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/education-apply/use-expire-notice', () => ({
|
||||
vi.mock('@/app/education/expire-notice/use-expire-notice', () => ({
|
||||
useEducationExpireNotice: () => [
|
||||
mockEducationExpireNotice.value
|
||||
? { accountId: 'user-1', expireAt: 1, expired: false, phase: 'expiring' }
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
settingsQueryParser,
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
import { useEducationExpireNotice } from '@/app/education-apply/use-expire-notice'
|
||||
import { useEducationExpireNotice } from '@/app/education/expire-notice/use-expire-notice'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { lazy, StrictMode, Suspense } from 'react'
|
||||
import { render } from '@/test/console/render'
|
||||
import { EducationExternalActionBoundary } from '../external-action-boundary'
|
||||
|
||||
const mockMutate = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockMutationState = vi.hoisted(() => ({ isError: false }))
|
||||
const mockSearchParams = vi.hoisted(() => ({
|
||||
value: new URLSearchParams('action=educationReVerify'),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => mockSearchParams.value,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: (loader: () => Promise<ComponentType>, options: { loading?: ComponentType }) => {
|
||||
const LazyComponent = lazy(async () => ({ default: await loader() }))
|
||||
const Loading = options.loading
|
||||
|
||||
return function DynamicComponent(props: Record<string, unknown>) {
|
||||
return (
|
||||
<Suspense fallback={Loading ? <Loading /> : null}>
|
||||
<LazyComponent {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
isError: mockMutationState.isError,
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/full-screen-loading', () => ({
|
||||
FullScreenLoading: () => <div>Loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../verify-state-modal', () => ({
|
||||
default: ({
|
||||
confirmText,
|
||||
isShow,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
confirmText: string
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}) =>
|
||||
isShow ? (
|
||||
<div role="dialog">
|
||||
<button type="button" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
describe('EducationExternalActionBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMutationState.isError = false
|
||||
mockSearchParams.value = new URLSearchParams('action=educationReVerify')
|
||||
})
|
||||
|
||||
it('ignores unsupported actions', () => {
|
||||
mockSearchParams.value = new URLSearchParams('action=unknown')
|
||||
|
||||
render(<EducationExternalActionBoundary>Apps</EducationExternalActionBoundary>)
|
||||
|
||||
expect(screen.getByText('Apps')).toBeInTheDocument()
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts re-verification once and replaces the command URL with the token page', async () => {
|
||||
mockMutate.mockImplementation(
|
||||
(_variables: undefined, options: { onSuccess: (result: { token: string }) => void }) =>
|
||||
options.onSuccess({ token: 'education-token' }),
|
||||
)
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<EducationExternalActionBoundary>Apps</EducationExternalActionBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/education-apply?token=education-token')
|
||||
})
|
||||
})
|
||||
|
||||
it('retries a failed verification only after explicit confirmation', async () => {
|
||||
mockMutationState.isError = true
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<EducationExternalActionBoundary>Apps</EducationExternalActionBoundary>)
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }))
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns to Apps when the verification error is dismissed', async () => {
|
||||
mockMutationState.isError = true
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<EducationExternalActionBoundary>Apps</EducationExternalActionBoundary>)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
|
||||
it('canonicalizes the active Education pricing link', async () => {
|
||||
mockSearchParams.value = new URLSearchParams('action=educationPricing&utm_source=email')
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<EducationExternalActionBoundary>Apps</EducationExternalActionBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps?utm_source=email&pricing=open')
|
||||
})
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,103 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SearchInput from '../search-input'
|
||||
|
||||
const educationMocks = vi.hoisted(() => ({
|
||||
schools: ['Alpha University', 'Beta College'],
|
||||
setSchools: vi.fn(),
|
||||
querySchoolsWithDebounced: vi.fn(),
|
||||
handleUpdateSchools: vi.fn(),
|
||||
hasNext: false,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEducation: () => educationMocks,
|
||||
}))
|
||||
const ControlledSearchInput = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return <SearchInput value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
describe('education-apply/search-input', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
educationMocks.schools = ['Alpha University', 'Beta College']
|
||||
educationMocks.hasNext = false
|
||||
})
|
||||
|
||||
it('keeps the search field editable when used as the popover trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationMocks.schools = []
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/,
|
||||
) as HTMLInputElement
|
||||
expect(input.type).toBe('text')
|
||||
|
||||
await user.type(input, 'Alpha')
|
||||
|
||||
expect(input).toHaveValue('Alpha')
|
||||
expect(educationMocks.setSchools).toHaveBeenCalledWith([])
|
||||
expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({
|
||||
keywords: 'Alpha',
|
||||
page: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the popover after selecting a school', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/),
|
||||
'A',
|
||||
)
|
||||
|
||||
expect(screen.getByText('Alpha University')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Beta College'))
|
||||
|
||||
expect(screen.getByDisplayValue('Beta College')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha University')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads the next page when the dropdown is scrolled to the bottom', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationMocks.hasNext = true
|
||||
|
||||
render(<ControlledSearchInput />)
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/),
|
||||
'A',
|
||||
)
|
||||
|
||||
const scrollContainer = screen.getByText('Alpha University').parentElement as HTMLDivElement
|
||||
Object.defineProperties(scrollContainer, {
|
||||
scrollTop: {
|
||||
value: 60,
|
||||
configurable: true,
|
||||
},
|
||||
scrollHeight: {
|
||||
value: 100,
|
||||
configurable: true,
|
||||
},
|
||||
clientHeight: {
|
||||
value: 40,
|
||||
configurable: true,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(educationMocks.handleUpdateSchools).toHaveBeenCalledWith({
|
||||
keywords: 'A',
|
||||
page: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,349 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Plan as PlanType } from '@/app/components/billing/type'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { DifyLogo } from '../components/base/logo/dify-logo'
|
||||
import AppliedEducationContent from './applied-education-content'
|
||||
import RoleSelector from './role-selector'
|
||||
import SearchInput from './search-input'
|
||||
import UserInfo from './user-info'
|
||||
|
||||
const AppliedEducationCase = {
|
||||
eligible: 'eligible',
|
||||
activeSubscription: 'activeSubscription',
|
||||
noPaymentPermission: 'noPaymentPermission',
|
||||
} as const
|
||||
|
||||
const EducationApplyAgeContent = () => {
|
||||
const { t } = useTranslation()
|
||||
const [schoolName, setSchoolName] = useState('')
|
||||
const [role, setRole] = useState('Student')
|
||||
const [ageChecked, setAgeChecked] = useState(false)
|
||||
const [inSchoolChecked, setInSchoolChecked] = useState(false)
|
||||
const [personalUseChecked, setPersonalUseChecked] = useState(false)
|
||||
const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false)
|
||||
const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false)
|
||||
const { isPending, mutateAsync: educationAdd } = useMutation(
|
||||
consoleQuery.account.education.post.mutationOptions(),
|
||||
)
|
||||
const { onPlanInfoChanged, plan } = useProviderContext()
|
||||
const { data: isEducationAccount = false } = useQuery(
|
||||
consoleQuery.account.education.get.queryOptions({
|
||||
select: ({ is_student }) => is_student ?? false,
|
||||
}),
|
||||
)
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const docLink = useDocLink()
|
||||
const { handleEducationDiscount } = useEducationDiscount()
|
||||
const router = useRouter()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const appliedEducationCase = (() => {
|
||||
if (!isCurrentWorkspaceManager) return AppliedEducationCase.noPaymentPermission
|
||||
|
||||
if (plan.type === Plan.sandbox) return AppliedEducationCase.eligible
|
||||
|
||||
return AppliedEducationCase.activeSubscription
|
||||
})()
|
||||
const handleSubmit = () => {
|
||||
educationAdd({
|
||||
body: {
|
||||
token: token || '',
|
||||
role,
|
||||
institution: schoolName,
|
||||
},
|
||||
}).then((res) => {
|
||||
if (res.message === 'success') {
|
||||
onPlanInfoChanged()
|
||||
setHasSubmittedEducation(true)
|
||||
} else {
|
||||
toast.error(t(($) => $.submitError, { ns: 'education' }))
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleOpenBillingPortal = async () => {
|
||||
if (isOpeningBillingPortal) return
|
||||
|
||||
setIsOpeningBillingPortal(true)
|
||||
try {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
const res = await consoleClient.billing.invoices.get()
|
||||
if (res.url) return res.url
|
||||
|
||||
throw new Error('Failed to open billing page')
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(err.message || String(err))
|
||||
},
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
setIsOpeningBillingPortal(false)
|
||||
}
|
||||
}
|
||||
const handleReturnHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
const renderBackToDifyButton = () => (
|
||||
<Button variant="ghost-accent" onClick={handleReturnHome}>
|
||||
<span className="i-ri-arrow-left-line size-4" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Button>
|
||||
)
|
||||
const handleSwitchWorkspace = async (tenantId: string) => {
|
||||
if (tenantId === currentWorkspace?.id) return
|
||||
|
||||
try {
|
||||
await switchWorkspaceMutation.mutateAsync({ body: { tenant_id: tenantId } })
|
||||
globalThis.location.reload()
|
||||
} catch {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppliedEducationAction = () => {
|
||||
if (appliedEducationCase === AppliedEducationCase.eligible) {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleEducationDiscount}>
|
||||
{t(($) => $.useEducationDiscount, { ns: 'education' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (appliedEducationCase === AppliedEducationCase.activeSubscription) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
|
||||
<span className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" />
|
||||
<div className="system-md-regular text-text-warning">
|
||||
<Trans
|
||||
i18nKey={($) => $['applied.activeSubscription.description']}
|
||||
ns="education"
|
||||
components={{
|
||||
stripeLink: (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-accent hover:underline disabled:cursor-not-allowed disabled:text-text-disabled"
|
||||
onClick={handleOpenBillingPortal}
|
||||
disabled={isOpeningBillingPortal}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderBackToDifyButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
|
||||
<span className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary" />
|
||||
<div className="system-md-regular text-text-warning">
|
||||
{t(($) => $['applied.noPaymentPermission.description'], { ns: 'education' })}
|
||||
</div>
|
||||
</div>
|
||||
{renderBackToDifyButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-31 overflow-y-auto bg-background-body p-6">
|
||||
<div className="mx-auto w-full max-w-352 rounded-2xl border border-effects-highlight bg-background-default-subtle">
|
||||
<div
|
||||
className="h-87.25 w-full overflow-hidden rounded-t-2xl bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: 'url(/education/bg.png)',
|
||||
}}
|
||||
></div>
|
||||
<div className="-mt-87.25 box-content flex h-7 items-center justify-between p-6">
|
||||
<DifyLogo alt="Dify" size="large" className="brightness-0 invert" />
|
||||
</div>
|
||||
<div className="mx-auto max-w-180 px-8 pb-45">
|
||||
<div className="mb-2 flex h-48 flex-col justify-end pt-3 pb-4 text-text-primary-on-surface">
|
||||
<div className="mb-2 title-5xl-bold shadow-xs">
|
||||
{t(($) => $.toVerified, { ns: 'education' })}
|
||||
</div>
|
||||
<div className="system-md-medium shadow-xs">
|
||||
{t(($) => $['toVerifiedTip.front'], { ns: 'education' })}
|
||||
|
||||
<span className="system-md-semibold underline">
|
||||
{t(($) => $['toVerifiedTip.coupon'], { ns: 'education' })}
|
||||
</span>
|
||||
|
||||
{t(($) => $['toVerifiedTip.end'], { ns: 'education' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-7">
|
||||
<UserInfo />
|
||||
</div>
|
||||
{isEducationAccount || hasSubmittedEducation ? (
|
||||
<div className="flex">
|
||||
<AppliedEducationWorkspaceContent
|
||||
currentWorkspace={currentWorkspace}
|
||||
plan={plan.type}
|
||||
action={renderAppliedEducationAction()}
|
||||
isSwitchingWorkspace={switchWorkspaceMutation.isPending}
|
||||
onSwitchWorkspace={(value) => {
|
||||
void handleSwitchWorkspace(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-7">
|
||||
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
|
||||
{t(($) => $['form.schoolName.title'], { ns: 'education' })}
|
||||
</div>
|
||||
<SearchInput value={schoolName} onChange={setSchoolName} />
|
||||
</div>
|
||||
<div className="mb-7">
|
||||
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
|
||||
{t(($) => $['form.schoolRole.title'], { ns: 'education' })}
|
||||
</div>
|
||||
<RoleSelector value={role} onChange={setRole} />
|
||||
</div>
|
||||
<div className="mb-7">
|
||||
<div className="mb-1 flex h-6 items-center system-md-semibold text-text-secondary">
|
||||
{t(($) => $['form.terms.title'], { ns: 'education' })}
|
||||
</div>
|
||||
<div className="mb-1 system-md-regular text-text-tertiary">
|
||||
{t(($) => $['form.terms.desc.front'], { ns: 'education' })}
|
||||
|
||||
<a
|
||||
href="https://dify.ai/terms"
|
||||
target="_blank"
|
||||
className="text-text-secondary hover:underline"
|
||||
>
|
||||
{t(($) => $['form.terms.desc.termsOfService'], { ns: 'education' })}
|
||||
</a>
|
||||
|
||||
{t(($) => $['form.terms.desc.and'], { ns: 'education' })}
|
||||
|
||||
<a
|
||||
href="https://dify.ai/privacy"
|
||||
target="_blank"
|
||||
className="text-text-secondary hover:underline"
|
||||
>
|
||||
{t(($) => $['form.terms.desc.privacyPolicy'], { ns: 'education' })}
|
||||
</a>
|
||||
{t(($) => $['form.terms.desc.end'], { ns: 'education' })}
|
||||
</div>
|
||||
<div className="py-2 system-md-regular text-text-primary">
|
||||
<label className="mb-2 flex">
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
checked={ageChecked}
|
||||
onCheckedChange={setAgeChecked}
|
||||
/>
|
||||
{t(($) => $['form.terms.option.age'], { ns: 'education' })}
|
||||
</label>
|
||||
<label className="mb-2 flex">
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
checked={inSchoolChecked}
|
||||
onCheckedChange={setInSchoolChecked}
|
||||
/>
|
||||
{t(($) => $['form.terms.option.inSchool'], { ns: 'education' })}
|
||||
</label>
|
||||
<label className="flex">
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
checked={personalUseChecked}
|
||||
onCheckedChange={setPersonalUseChecked}
|
||||
/>
|
||||
{t(($) => $['form.terms.option.personalUse'], { ns: 'education' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={
|
||||
!ageChecked ||
|
||||
!inSchoolChecked ||
|
||||
!personalUseChecked ||
|
||||
!schoolName ||
|
||||
!role ||
|
||||
isPending
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t(($) => $.submit, { ns: 'education' })}
|
||||
</Button>
|
||||
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]"></div>
|
||||
<a
|
||||
className="flex items-center system-xs-regular text-text-accent"
|
||||
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
|
||||
target="_blank"
|
||||
>
|
||||
{t(($) => $.learn, { ns: 'education' })}
|
||||
<span className="ml-1 i-ri-external-link-line size-3" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AppliedEducationWorkspaceBlockProps = {
|
||||
currentWorkspace: GetWorkspacesCurrentSummaryResponse
|
||||
plan: PlanType
|
||||
action: ReactNode
|
||||
isSwitchingWorkspace: boolean
|
||||
onSwitchWorkspace: (tenantId: string) => void
|
||||
}
|
||||
|
||||
function AppliedEducationWorkspaceContent({
|
||||
currentWorkspace,
|
||||
plan,
|
||||
action,
|
||||
isSwitchingWorkspace,
|
||||
onSwitchWorkspace,
|
||||
}: AppliedEducationWorkspaceBlockProps) {
|
||||
const { data: workspacesData } = useQuery(consoleQuery.workspaces.get.queryOptions())
|
||||
const workspaces = workspacesData?.workspaces ?? []
|
||||
|
||||
return (
|
||||
<AppliedEducationContent
|
||||
workspaces={workspaces}
|
||||
currentWorkspace={currentWorkspace}
|
||||
plan={plan}
|
||||
action={action}
|
||||
isSwitchingWorkspace={isSwitchingWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EducationApplyAge = () => <EducationApplyAgeContent />
|
||||
|
||||
export default EducationApplyAge
|
||||
|
||||
type AppliedEducationCase = (typeof AppliedEducationCase)[keyof typeof AppliedEducationCase]
|
||||
@ -1,46 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
|
||||
const EDUCATION_REVERIFY_ACTION = 'educationReVerify'
|
||||
const EDUCATION_PRICING_ACTION = 'educationPricing'
|
||||
|
||||
const EducationReverifyFlow = dynamic(
|
||||
() => import('./reverify-flow').then((module) => module.EducationReverifyFlow),
|
||||
{
|
||||
ssr: false,
|
||||
loading: FullScreenLoading,
|
||||
},
|
||||
)
|
||||
|
||||
function EducationPricingRedirect({ searchParamsString }: { searchParamsString: string }) {
|
||||
const router = useRouter()
|
||||
const redirectedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) return
|
||||
|
||||
redirectedRef.current = true
|
||||
const searchParams = new URLSearchParams(searchParamsString)
|
||||
searchParams.delete('action')
|
||||
searchParams.set('pricing', 'open')
|
||||
router.replace(`/apps?${searchParams.toString()}`)
|
||||
}, [router, searchParamsString])
|
||||
|
||||
return <FullScreenLoading />
|
||||
}
|
||||
|
||||
export function EducationExternalActionBoundary({ children }: { children: ReactNode }) {
|
||||
const searchParams = useSearchParams()
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (action === EDUCATION_REVERIFY_ACTION) return <EducationReverifyFlow />
|
||||
if (action === EDUCATION_PRICING_ACTION)
|
||||
return <EducationPricingRedirect searchParamsString={searchParams.toString()} />
|
||||
|
||||
return children
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import type { SearchParams } from './types'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useEducationAutocomplete } from '@/service/use-education'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useEducationVerify } from '@/service/use-education'
|
||||
import VerifyStateModal from './verify-state-modal'
|
||||
|
||||
export function EducationReverifyFlow() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const verificationStartedRef = useRef(false)
|
||||
const { isError, mutate } = useEducationVerify()
|
||||
|
||||
const startVerification = useCallback(() => {
|
||||
if (verificationStartedRef.current) return
|
||||
|
||||
verificationStartedRef.current = true
|
||||
mutate(undefined, {
|
||||
onSuccess: ({ token }) => {
|
||||
router.replace(`/education-apply?token=${token}`)
|
||||
},
|
||||
})
|
||||
}, [mutate, router])
|
||||
|
||||
useEffect(() => {
|
||||
startVerification()
|
||||
}, [startVerification])
|
||||
|
||||
if (!isError) return <FullScreenLoading />
|
||||
|
||||
return (
|
||||
<VerifyStateModal
|
||||
isShow
|
||||
title={t(($) => $['errorBoundary.title'], { ns: 'common' })}
|
||||
confirmText={t(($) => $['errorBoundary.tryAgain'], { ns: 'common' })}
|
||||
onConfirm={() => {
|
||||
verificationStartedRef.current = false
|
||||
startVerification()
|
||||
}}
|
||||
onCancel={() => router.replace('/apps')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RoleSelectorProps = {
|
||||
onChange: (value: string) => void
|
||||
value: string
|
||||
}
|
||||
|
||||
const RoleSelector = ({ onChange, value }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const options = [
|
||||
{
|
||||
key: 'Student',
|
||||
value: t(($) => $['form.schoolRole.option.student'], { ns: 'education' }),
|
||||
},
|
||||
{
|
||||
key: 'Teacher',
|
||||
value: t(($) => $['form.schoolRole.option.teacher'], { ns: 'education' }),
|
||||
},
|
||||
{
|
||||
key: 'School-Administrator',
|
||||
value: t(($) => $['form.schoolRole.option.administrator'], { ns: 'education' }),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
className="mr-6 flex h-5 cursor-pointer items-center system-md-regular text-text-primary"
|
||||
onClick={() => onChange(option.key)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 size-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
|
||||
@ -1,106 +0,0 @@
|
||||
import type { ChangeEventHandler, UIEventHandler } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useEducation } from './hooks'
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
[handleUpdateSchools, querySchoolsWithDebounced],
|
||||
)
|
||||
|
||||
const handleValueChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
setOpen(true)
|
||||
setSchools([])
|
||||
pageRef.current = 0
|
||||
const inputValue = e.target.value
|
||||
valueRef.current = inputValue
|
||||
onChange(inputValue)
|
||||
handleSearch(true)
|
||||
},
|
||||
[handleSearch, onChange, setSchools],
|
||||
)
|
||||
|
||||
const handleScroll: UIEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
const target = e.currentTarget
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 && hasNext) {
|
||||
pageRef.current += 1
|
||||
handleSearch()
|
||||
}
|
||||
},
|
||||
[handleSearch, hasNext],
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder={t(($) => $['form.schoolName.placeholder'], { ns: 'education' })}
|
||||
value={value}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!!schools.length && !!value ? (
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[var(--anchor-width)] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
>
|
||||
<div className="max-h-82.5 overflow-y-auto" onScroll={handleScroll}>
|
||||
{schools.map((school) => (
|
||||
<div
|
||||
key={school}
|
||||
className="flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
title={school}
|
||||
onClick={() => {
|
||||
onChange(school)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{school}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchInput
|
||||
@ -1,5 +0,0 @@
|
||||
export type SearchParams = {
|
||||
keywords?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type IConfirm = {
|
||||
className?: string
|
||||
isShow: boolean
|
||||
title: string
|
||||
content?: React.ReactNode
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
maskClosable?: boolean
|
||||
email?: string
|
||||
showLink?: boolean
|
||||
confirmText?: string
|
||||
}
|
||||
|
||||
function Confirm({
|
||||
isShow,
|
||||
title,
|
||||
content,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
maskClosable = true,
|
||||
showLink,
|
||||
email,
|
||||
confirmText,
|
||||
}: IConfirm) {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education')
|
||||
|
||||
const handleClick = () => {
|
||||
window.open(eduDocLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel()
|
||||
}}
|
||||
disablePointerDismissal={!maskClosable}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-full max-w-120.25 overflow-hidden border-none bg-transparent p-0 shadow-none"
|
||||
>
|
||||
<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 px-6 pt-6 pb-4">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">{title}</DialogTitle>
|
||||
{content != null && (
|
||||
<DialogDescription className="w-full system-md-regular text-text-tertiary">
|
||||
{content}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{email && (
|
||||
<div className="w-full space-y-1 px-6 py-3">
|
||||
<div className="py-1 system-sm-semibold text-text-secondary">
|
||||
{t(($) => $.emailLabel, { ns: 'education' })}
|
||||
</div>
|
||||
<div className="rounded-lg bg-components-input-bg-disabled px-3 py-2 system-sm-regular 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
|
||||
onClick={handleClick}
|
||||
href={eduDocLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
{t(($) => $.learn, { ns: 'education' })}
|
||||
<span className="i-ri-external-link-line size-3 text-text-accent"></span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={confirmText ? 'min-w-20!' : 'w-20!'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText || t(($) => $['operation.ok'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Confirm)
|
||||
48
web/app/education/apply/__tests__/application-entry.spec.tsx
Normal file
48
web/app/education/apply/__tests__/application-entry.spec.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import EducationApplyRoute from '../application-entry'
|
||||
|
||||
const mockRedirect = vi.hoisted(() => vi.fn(() => null as never))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: mockRedirect,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/education/paused-content', () => ({
|
||||
EducationPausedContent: () => <div>Education paused</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../application-form', () => ({
|
||||
default: ({ token }: { token: string }) => <div>Application form: {token}</div>,
|
||||
}))
|
||||
|
||||
describe('EducationApplyRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns to home when the education plan is unavailable', () => {
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper()
|
||||
seedFeatures(queryClient, { education: { enabled: false } })
|
||||
|
||||
render(<EducationApplyRoute token="education-token" />, { wrapper })
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/')
|
||||
})
|
||||
|
||||
it('renders the pause state for a direct token URL', () => {
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
educationStatus: { is_student: false },
|
||||
})
|
||||
seedFeatures(queryClient, {
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
education: { enabled: true },
|
||||
})
|
||||
|
||||
render(<EducationApplyRoute token="education-token" />, { wrapper })
|
||||
|
||||
expect(screen.getByText('Education paused')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Application form/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
140
web/app/education/apply/__tests__/institution-field.spec.tsx
Normal file
140
web/app/education/apply/__tests__/institution-field.spec.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import InstitutionField from '../institution-field'
|
||||
|
||||
const educationAutocompleteQueryMock = vi.hoisted(() => ({
|
||||
options: vi.fn(),
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
curr_page: 0,
|
||||
data: ['Alpha University', 'Beta College'],
|
||||
has_next: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useInfiniteQuery: (options: unknown) => {
|
||||
educationAutocompleteQueryMock.options(options)
|
||||
return educationAutocompleteQueryMock
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
account: {
|
||||
education: {
|
||||
autocomplete: {
|
||||
get: {
|
||||
infiniteOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('foxact/use-debounced-value', () => ({
|
||||
useDebouncedValue: <T,>(value: T) => value,
|
||||
}))
|
||||
|
||||
const ControlledInstitutionField = () => {
|
||||
const [value, setValue] = useState('')
|
||||
return <InstitutionField value={value} onValueChange={setValue} />
|
||||
}
|
||||
|
||||
describe('InstitutionField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
educationAutocompleteQueryMock.hasNextPage = false
|
||||
educationAutocompleteQueryMock.isFetchingNextPage = false
|
||||
educationAutocompleteQueryMock.isPending = false
|
||||
educationAutocompleteQueryMock.isSuccess = true
|
||||
educationAutocompleteQueryMock.data.pages[0]!.data = ['Alpha University', 'Beta College']
|
||||
})
|
||||
|
||||
it('uses a free-form institution name as the suggestions query', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/,
|
||||
) as HTMLInputElement
|
||||
expect(input.type).toBe('text')
|
||||
expect(input).toHaveAccessibleName('education.form.schoolName.title')
|
||||
|
||||
await user.type(input, 'Alpha')
|
||||
|
||||
expect(input).toHaveValue('Alpha')
|
||||
await waitFor(() => {
|
||||
const options = educationAutocompleteQueryMock.options.mock.lastCall?.[0] as {
|
||||
enabled: boolean
|
||||
input: (page: number) => unknown
|
||||
}
|
||||
expect(options.enabled).toBe(true)
|
||||
expect(options.input(0)).toEqual({
|
||||
query: { keywords: 'Alpha', limit: 40, page: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the suggestions without showing the empty state after keyboard selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
const input = screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/)
|
||||
await user.type(input, 'A')
|
||||
|
||||
expect(await screen.findByText('Alpha University')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{ArrowDown}{Enter}')
|
||||
|
||||
expect(input).toHaveValue('Alpha University')
|
||||
expect(screen.queryByText('education.form.schoolName.noResults')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps an unmatched institution name as free-form input', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationAutocompleteQueryMock.data.pages[0]!.data = []
|
||||
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
const input = screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/)
|
||||
await user.type(input, 'Dify Academy')
|
||||
|
||||
expect(await screen.findByText('education.form.schoolName.noResults')).toBeInTheDocument()
|
||||
expect(input).toHaveValue('Dify Academy')
|
||||
})
|
||||
|
||||
it('requests the next page when the suggestions reach the scroll boundary', async () => {
|
||||
const user = userEvent.setup()
|
||||
educationAutocompleteQueryMock.hasNextPage = true
|
||||
|
||||
render(<ControlledInstitutionField />)
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/(?:^|\.)form\.schoolName\.placeholder(?=$|:)/),
|
||||
'A',
|
||||
)
|
||||
|
||||
const scrollContainer = await screen.findByRole('listbox')
|
||||
Object.defineProperties(scrollContainer, {
|
||||
scrollTop: { value: 60, configurable: true },
|
||||
scrollHeight: { value: 100, configurable: true },
|
||||
clientHeight: { value: 40, configurable: true },
|
||||
})
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(educationAutocompleteQueryMock.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -2,11 +2,10 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { cleanup, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import EducationApplyPage from '@/app/education-apply/education-apply-page'
|
||||
import EducationApplyPage from '@/app/education/apply/application-form'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
|
||||
let mockProviderContext: Record<string, unknown> = {}
|
||||
let mockConsoleState: Record<string, unknown> = {}
|
||||
const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
|
||||
const mockEducationAdd = vi.hoisted(() => vi.fn())
|
||||
@ -26,10 +25,6 @@ const mockWorkspaces = vi.hoisted(() => [
|
||||
},
|
||||
])
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createAccountStateModuleMock(() => mockConsoleState)
|
||||
@ -46,7 +41,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams('token=education-token'),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/billing', () => ({
|
||||
@ -57,14 +51,6 @@ vi.mock('@/service/use-common', () => ({
|
||||
useLogout: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationAutocomplete: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ data: [], has_next: false }),
|
||||
isPending: false,
|
||||
data: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
@ -78,8 +64,22 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
features: {
|
||||
get: {
|
||||
queryKey: () => ['features'],
|
||||
},
|
||||
},
|
||||
account: {
|
||||
education: {
|
||||
autocomplete: {
|
||||
get: {
|
||||
infiniteOptions: (options: Record<string, unknown>) => ({
|
||||
queryKey: ['account', 'education', 'autocomplete'],
|
||||
queryFn: async () => ({ data: [], has_next: false }),
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
get: {
|
||||
key: () => ['account', 'education'],
|
||||
queryOptions: (options: Record<string, unknown> = {}) => ({
|
||||
@ -118,10 +118,6 @@ vi.mock('@/service/client', () => ({
|
||||
}))
|
||||
|
||||
const setupContext = (isCurrentWorkspaceManager: boolean) => {
|
||||
mockProviderContext = {
|
||||
plan: { type: Plan.sandbox },
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
}
|
||||
mockConsoleState = {
|
||||
currentWorkspace: { id: 'workspace-1', name: 'Workspace One' },
|
||||
isCurrentWorkspaceManager,
|
||||
@ -138,7 +134,7 @@ const renderPage = (isEducationAccount = true) => {
|
||||
educationStatus: { is_student: isEducationAccount },
|
||||
workspacePermissionKeys: null,
|
||||
})
|
||||
return render(<EducationApplyPage />, {
|
||||
return render(<EducationApplyPage token="education-token" plan={Plan.sandbox} />, {
|
||||
wrapper,
|
||||
})
|
||||
}
|
||||
@ -151,7 +147,7 @@ describe('EducationApplyPage billing boundary', () => {
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: window.location.href })
|
||||
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||
vi.stubGlobal('location', {
|
||||
href: 'https://console.example.com/education-apply?token=education-token',
|
||||
href: 'https://console.example.com/education/apply?token=education-token',
|
||||
reload: vi.fn(),
|
||||
} as unknown as Location)
|
||||
})
|
||||
@ -238,9 +234,15 @@ describe('EducationApplyPage billing boundary', () => {
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'education.submit' })
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('education.form.schoolName.placeholder'),
|
||||
screen.getByRole('combobox', { name: 'education.form.schoolName.title' }),
|
||||
'DifyUniversity',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'education.form.schoolRole.title' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'education.form.schoolRole.option.student' }),
|
||||
).toBeChecked()
|
||||
await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.age' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'education.form.terms.option.inSchool' }))
|
||||
|
||||
28
web/app/education/apply/application-entry.tsx
Normal file
28
web/app/education/apply/application-entry.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { EDUCATION_APPLICATIONS_PAUSED } from '../constants'
|
||||
import { EducationPausedContent } from '../paused-content'
|
||||
import EducationApplyPage from './application-form'
|
||||
|
||||
const selectEducationPlan = ({ billing, education }: GetFeaturesResponse) => ({
|
||||
enabled: education.enabled,
|
||||
plan: billing.subscription.plan,
|
||||
})
|
||||
|
||||
export default function EducationApplyRoute({ token }: { token: string }) {
|
||||
const featuresQuery = useQuery(
|
||||
consoleQuery.features.get.queryOptions({ select: selectEducationPlan }),
|
||||
)
|
||||
|
||||
if (featuresQuery.isPending) return null
|
||||
|
||||
if (!featuresQuery.data?.enabled) return redirect('/')
|
||||
|
||||
if (EDUCATION_APPLICATIONS_PAUSED) return <EducationPausedContent />
|
||||
|
||||
return <EducationApplyPage token={token} plan={featuresQuery.data.plan} />
|
||||
}
|
||||
324
web/app/education/apply/application-form.tsx
Normal file
324
web/app/education/apply/application-form.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
'use client'
|
||||
|
||||
import type { SubscriptionModel } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { EducationRole } from './types'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Field, FieldDescription, FieldItem, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import Link from '@/next/link'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import UserInfo from '../user-info'
|
||||
import AppliedEducationContent from './applied-education-content'
|
||||
import InstitutionField from './institution-field'
|
||||
import RoleSelector from './role-selector'
|
||||
|
||||
const REQUIRED_AGREEMENTS = ['age', 'inSchool', 'personalUse']
|
||||
|
||||
const AppliedEducationCase = {
|
||||
eligible: 'eligible',
|
||||
activeSubscription: 'activeSubscription',
|
||||
noPaymentPermission: 'noPaymentPermission',
|
||||
} as const
|
||||
|
||||
type EducationApplyPageProps = {
|
||||
plan: SubscriptionModel['plan']
|
||||
token: string
|
||||
}
|
||||
|
||||
const EducationApplyPage = ({ plan, token }: EducationApplyPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [schoolName, setSchoolName] = useState('')
|
||||
const [role, setRole] = useState<EducationRole>('Student')
|
||||
const [agreements, setAgreements] = useState<string[]>([])
|
||||
const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false)
|
||||
const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false)
|
||||
const { isPending, mutate: educationAdd } = useMutation(
|
||||
consoleQuery.account.education.post.mutationOptions(),
|
||||
)
|
||||
const { data: isEducationAccount = false } = useQuery(
|
||||
consoleQuery.account.education.get.queryOptions({
|
||||
select: ({ is_student }) => is_student ?? false,
|
||||
}),
|
||||
)
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const docLink = useDocLink()
|
||||
const { handleEducationDiscount } = useEducationDiscount()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
|
||||
|
||||
const appliedEducationCase = (() => {
|
||||
if (!isCurrentWorkspaceManager) return AppliedEducationCase.noPaymentPermission
|
||||
|
||||
if (plan === Plan.sandbox) return AppliedEducationCase.eligible
|
||||
|
||||
return AppliedEducationCase.activeSubscription
|
||||
})()
|
||||
const handleSubmit = () => {
|
||||
educationAdd(
|
||||
{
|
||||
body: {
|
||||
token,
|
||||
role,
|
||||
institution: schoolName,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res.message === 'success') {
|
||||
void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() })
|
||||
setHasSubmittedEducation(true)
|
||||
} else {
|
||||
toast.error(t(($) => $.submitError, { ns: 'education' }))
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
const handleOpenBillingPortal = async () => {
|
||||
if (isOpeningBillingPortal) return
|
||||
|
||||
setIsOpeningBillingPortal(true)
|
||||
try {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
const res = await consoleClient.billing.invoices.get()
|
||||
if (res.url) return res.url
|
||||
|
||||
throw new Error('Failed to open billing page')
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(err.message || String(err))
|
||||
},
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
setIsOpeningBillingPortal(false)
|
||||
}
|
||||
}
|
||||
const renderBackToDifyButton = () => (
|
||||
<Link className={buttonVariants({ variant: 'ghost-accent' })} href="/">
|
||||
<span className="i-ri-arrow-left-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Link>
|
||||
)
|
||||
const handleSwitchWorkspace = async (tenantId: string) => {
|
||||
if (tenantId === currentWorkspace?.id) return
|
||||
|
||||
try {
|
||||
await switchWorkspaceMutation.mutateAsync({ body: { tenant_id: tenantId } })
|
||||
globalThis.location.reload()
|
||||
} catch {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppliedEducationAction = () => {
|
||||
if (appliedEducationCase === AppliedEducationCase.eligible) {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleEducationDiscount}>
|
||||
{t(($) => $.useEducationDiscount, { ns: 'education' })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (appliedEducationCase === AppliedEducationCase.activeSubscription) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
|
||||
<span
|
||||
className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="system-md-regular text-text-warning">
|
||||
<Trans
|
||||
i18nKey={($) => $['applied.activeSubscription.description']}
|
||||
ns="education"
|
||||
components={{
|
||||
stripeLink: (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
|
||||
onClick={handleOpenBillingPortal}
|
||||
disabled={isOpeningBillingPortal}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderBackToDifyButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start gap-3">
|
||||
<div className="flex w-full items-start rounded-lg border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover px-3 py-2.5">
|
||||
<span
|
||||
className="mt-0.5 mr-2 i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="system-md-regular text-text-warning">
|
||||
{t(($) => $['applied.noPaymentPermission.description'], { ns: 'education' })}
|
||||
</div>
|
||||
</div>
|
||||
{renderBackToDifyButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-7">
|
||||
<UserInfo />
|
||||
</div>
|
||||
{isEducationAccount || hasSubmittedEducation ? (
|
||||
<div className="flex">
|
||||
<AppliedEducationWorkspaceContent
|
||||
currentWorkspace={currentWorkspace}
|
||||
plan={plan}
|
||||
action={renderAppliedEducationAction()}
|
||||
isSwitchingWorkspace={switchWorkspaceMutation.isPending}
|
||||
onSwitchWorkspace={(value) => {
|
||||
void handleSwitchWorkspace(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form onFormSubmit={handleSubmit}>
|
||||
<InstitutionField value={schoolName} onValueChange={setSchoolName} />
|
||||
<RoleSelector value={role} onChange={setRole} />
|
||||
<Field name="agreements" className="mb-7">
|
||||
<Fieldset
|
||||
render={
|
||||
<CheckboxGroup
|
||||
value={agreements}
|
||||
onValueChange={setAgreements}
|
||||
allValues={REQUIRED_AGREEMENTS}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FieldsetLegend className="flex h-6 items-center py-0 system-md-semibold text-text-secondary">
|
||||
{t(($) => $['form.terms.title'], { ns: 'education' })}
|
||||
</FieldsetLegend>
|
||||
<FieldDescription className="mb-1 py-0 system-md-regular text-text-tertiary">
|
||||
{t(($) => $['form.terms.desc.front'], { ns: 'education' })}
|
||||
|
||||
<a
|
||||
href="https://dify.ai/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-secondary hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
{t(($) => $['form.terms.desc.termsOfService'], { ns: 'education' })}
|
||||
</a>
|
||||
|
||||
{t(($) => $['form.terms.desc.and'], { ns: 'education' })}
|
||||
|
||||
<a
|
||||
href="https://dify.ai/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-secondary hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
{t(($) => $['form.terms.desc.privacyPolicy'], { ns: 'education' })}
|
||||
</a>
|
||||
{t(($) => $['form.terms.desc.end'], { ns: 'education' })}
|
||||
</FieldDescription>
|
||||
<div className="py-2 system-md-regular text-text-primary">
|
||||
<FieldItem>
|
||||
<FieldLabel className="mb-2 flex items-start gap-2 py-0">
|
||||
<Checkbox value="age" />
|
||||
{t(($) => $['form.terms.option.age'], { ns: 'education' })}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="mb-2 flex items-start gap-2 py-0">
|
||||
<Checkbox value="inSchool" />
|
||||
{t(($) => $['form.terms.option.inSchool'], { ns: 'education' })}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
<FieldItem>
|
||||
<FieldLabel className="flex items-start gap-2 py-0">
|
||||
<Checkbox value="personalUse" />
|
||||
{t(($) => $['form.terms.option.personalUse'], { ns: 'education' })}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isPending}
|
||||
disabled={agreements.length !== REQUIRED_AGREEMENTS.length || !schoolName || !role}
|
||||
>
|
||||
{t(($) => $.submit, { ns: 'education' })}
|
||||
</Button>
|
||||
<div className="mt-5 mb-4 h-px bg-linear-to-r from-[rgba(16,24,40,0.08)]" />
|
||||
<a
|
||||
className="flex items-center system-xs-regular text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $.learn, { ns: 'education' })}
|
||||
<span className="ml-1 i-ri-external-link-line size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AppliedEducationWorkspaceBlockProps = {
|
||||
currentWorkspace: GetWorkspacesCurrentSummaryResponse
|
||||
plan: SubscriptionModel['plan']
|
||||
action: ReactNode
|
||||
isSwitchingWorkspace: boolean
|
||||
onSwitchWorkspace: (tenantId: string) => void
|
||||
}
|
||||
|
||||
function AppliedEducationWorkspaceContent({
|
||||
currentWorkspace,
|
||||
plan,
|
||||
action,
|
||||
isSwitchingWorkspace,
|
||||
onSwitchWorkspace,
|
||||
}: AppliedEducationWorkspaceBlockProps) {
|
||||
const { data: workspacesData } = useQuery(consoleQuery.workspaces.get.queryOptions())
|
||||
const workspaces = workspacesData?.workspaces ?? []
|
||||
|
||||
return (
|
||||
<AppliedEducationContent
|
||||
workspaces={workspaces}
|
||||
currentWorkspace={currentWorkspace}
|
||||
plan={plan}
|
||||
action={action}
|
||||
isSwitchingWorkspace={isSwitchingWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EducationApplyPage
|
||||
|
||||
type AppliedEducationCase = (typeof AppliedEducationCase)[keyof typeof AppliedEducationCase]
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { SubscriptionModel } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type {
|
||||
GetWorkspacesCurrentSummaryResponse,
|
||||
TenantListItemResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Plan as PlanType } from '@/app/components/billing/type'
|
||||
import { Select, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@ -15,7 +15,7 @@ import { PlanBadge } from '@/app/components/header/plan-badge'
|
||||
type AppliedEducationContentProps = {
|
||||
workspaces: TenantListItemResponse[]
|
||||
currentWorkspace: GetWorkspacesCurrentSummaryResponse
|
||||
plan: PlanType
|
||||
plan: SubscriptionModel['plan']
|
||||
action: ReactNode
|
||||
isSwitchingWorkspace: boolean
|
||||
onSwitchWorkspace: (tenantId: string) => void
|
||||
@ -50,7 +50,7 @@ const AppliedEducationContent = ({
|
||||
<div className="rounded-lg border border-effects-highlight bg-background-default-subtle px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-state-success-solid text-text-primary-on-surface">
|
||||
<span className="i-ri-check-line size-3.5" />
|
||||
<span className="i-ri-check-line size-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-secondary">
|
||||
133
web/app/education/apply/institution-field.tsx
Normal file
133
web/app/education/apply/institution-field.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import type { AutocompleteChangeEventDetails } from '@langgenius/dify-ui/autocomplete'
|
||||
import type { UIEventHandler } from 'react'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteInput,
|
||||
AutocompleteInputGroup,
|
||||
AutocompleteItem,
|
||||
AutocompleteItemText,
|
||||
AutocompleteList,
|
||||
AutocompleteStatus,
|
||||
} from '@langgenius/dify-ui/autocomplete'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useDebouncedValue } from 'foxact/use-debounced-value'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const EDUCATION_AUTOCOMPLETE_PAGE_SIZE = 40
|
||||
|
||||
type InstitutionFieldProps = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
|
||||
const InstitutionField = ({ value, onValueChange }: InstitutionFieldProps) => {
|
||||
const { t } = useTranslation()
|
||||
const inputId = useId()
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300)
|
||||
const isSearchReady = !!searchQuery && searchQuery === debouncedSearchQuery
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isSuccess } =
|
||||
useInfiniteQuery({
|
||||
...consoleQuery.account.education.autocomplete.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
keywords: debouncedSearchQuery,
|
||||
limit: EDUCATION_AUTOCOMPLETE_PAGE_SIZE,
|
||||
page: Number(pageParam),
|
||||
},
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.has_next ? (lastPage.curr_page ?? pages.length - 1) + 1 : undefined,
|
||||
}),
|
||||
enabled: isSearchReady,
|
||||
})
|
||||
const suggestions = isSearchReady ? (data?.pages.flatMap((page) => page.data ?? []) ?? []) : []
|
||||
const isLoading = isPending || isFetchingNextPage
|
||||
const shouldOpenPopup = isPopupOpen && isSearchReady && (isLoading || isSuccess)
|
||||
const shouldShowEmpty = shouldOpenPopup && isSuccess && !isLoading && suggestions.length === 0
|
||||
const shouldShowLoading = shouldOpenPopup && isLoading
|
||||
|
||||
const handleValueChange = (inputValue: string, eventDetails: AutocompleteChangeEventDetails) => {
|
||||
onValueChange(inputValue)
|
||||
if (eventDetails.reason === 'item-press') {
|
||||
setSearchQuery('')
|
||||
setIsPopupOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearchQuery(inputValue)
|
||||
setIsPopupOpen(!!inputValue)
|
||||
}
|
||||
|
||||
const handleScroll: UIEventHandler<HTMLDivElement> = (event) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0
|
||||
if (!isAtBottom || !hasNextPage || isFetchingNextPage) return
|
||||
|
||||
void fetchNextPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<Field name="institution" className="mb-7">
|
||||
<FieldLabel
|
||||
className="flex h-6 items-center py-0 system-md-semibold text-text-secondary"
|
||||
htmlFor={inputId}
|
||||
>
|
||||
{t(($) => $['form.schoolName.title'], { ns: 'education' })}
|
||||
</FieldLabel>
|
||||
<Autocomplete
|
||||
items={suggestions}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
filter={null}
|
||||
mode="list"
|
||||
open={shouldOpenPopup}
|
||||
onOpenChange={setIsPopupOpen}
|
||||
>
|
||||
<AutocompleteInputGroup size="large">
|
||||
<AutocompleteInput
|
||||
id={inputId}
|
||||
size="large"
|
||||
placeholder={t(($) => $['form.schoolName.placeholder'], { ns: 'education' })}
|
||||
/>
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent
|
||||
popupClassName="w-(--anchor-width) max-w-(--available-width)"
|
||||
portalProps={{ keepMounted: true }}
|
||||
popupProps={{ 'aria-busy': isLoading || undefined }}
|
||||
>
|
||||
<AutocompleteList<string> onScroll={handleScroll}>
|
||||
{(institution) => (
|
||||
<AutocompleteItem key={institution} value={institution} title={institution}>
|
||||
<AutocompleteItemText>{institution}</AutocompleteItemText>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>
|
||||
{shouldShowEmpty ? t(($) => $['form.schoolName.noResults'], { ns: 'education' }) : null}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteStatus className="p-0">
|
||||
{shouldShowLoading ? (
|
||||
<>
|
||||
<span className="sr-only">{t(($) => $.loading, { ns: 'appApi' })}</span>
|
||||
<div aria-hidden="true">
|
||||
<Loading className="h-10" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</AutocompleteStatus>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstitutionField
|
||||
52
web/app/education/apply/role-selector.tsx
Normal file
52
web/app/education/apply/role-selector.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import type { EducationRole } from './types'
|
||||
import { Field, FieldItem, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
|
||||
import { Radio, RadioGroup } from '@langgenius/dify-ui/radio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RoleSelectorProps = {
|
||||
onChange: (value: EducationRole) => void
|
||||
value: EducationRole
|
||||
}
|
||||
|
||||
const RoleSelector = ({ onChange, value }: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const options: { key: EducationRole; value: string }[] = [
|
||||
{
|
||||
key: 'Student',
|
||||
value: t(($) => $['form.schoolRole.option.student'], { ns: 'education' }),
|
||||
},
|
||||
{
|
||||
key: 'Teacher',
|
||||
value: t(($) => $['form.schoolRole.option.teacher'], { ns: 'education' }),
|
||||
},
|
||||
{
|
||||
key: 'School-Administrator',
|
||||
value: t(($) => $['form.schoolRole.option.administrator'], { ns: 'education' }),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Field name="role" className="mb-7">
|
||||
<Fieldset
|
||||
render={
|
||||
<RadioGroup<EducationRole> className="gap-6" value={value} onValueChange={onChange} />
|
||||
}
|
||||
>
|
||||
<FieldsetLegend className="flex h-6 items-center py-0 system-md-semibold text-text-secondary">
|
||||
{t(($) => $['form.schoolRole.title'], { ns: 'education' })}
|
||||
</FieldsetLegend>
|
||||
{options.map((option) => (
|
||||
<FieldItem key={option.key}>
|
||||
<FieldLabel className="flex h-5 cursor-pointer items-center gap-2 py-0 system-md-regular text-text-primary">
|
||||
<Radio<EducationRole> value={option.key} />
|
||||
{option.value}
|
||||
</FieldLabel>
|
||||
</FieldItem>
|
||||
))}
|
||||
</Fieldset>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleSelector
|
||||
1
web/app/education/apply/types.ts
Normal file
1
web/app/education/apply/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type EducationRole = 'Student' | 'Teacher' | 'School-Administrator'
|
||||
1
web/app/education/constants.ts
Normal file
1
web/app/education/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const EDUCATION_APPLICATIONS_PAUSED = true
|
||||
48
web/app/education/education-shell.tsx
Normal file
48
web/app/education/education-shell.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
type EducationShellProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function EducationShell({ children }: EducationShellProps) {
|
||||
const { t } = useTranslation()
|
||||
const pageTitle = t(($) => $.toVerified, { ns: 'education' })
|
||||
useDocumentTitle(pageTitle)
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-auto bg-background-body p-6">
|
||||
<div className="mx-auto w-full max-w-352 rounded-2xl border border-effects-highlight bg-background-default-subtle">
|
||||
<div
|
||||
className="h-87.25 w-full overflow-hidden rounded-t-2xl bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: 'url(/education/bg.png)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="-mt-87.25 box-content flex h-7 items-center p-6">
|
||||
<DifyLogo alt="Dify" size="large" className="brightness-0 invert" />
|
||||
</div>
|
||||
<section className="mx-auto max-w-180 px-8 pb-45" aria-labelledby="education-page-title">
|
||||
<header className="mb-2 flex h-48 flex-col justify-end pt-3 pb-4 text-text-primary-on-surface">
|
||||
<h1 id="education-page-title" className="mb-2 title-5xl-bold shadow-xs">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<div className="system-md-medium shadow-xs">
|
||||
{t(($) => $['toVerifiedTip.front'], { ns: 'education' })}
|
||||
|
||||
<span className="system-md-semibold underline">
|
||||
{t(($) => $['toVerifiedTip.coupon'], { ns: 'education' })}
|
||||
</span>
|
||||
|
||||
{t(($) => $['toVerifiedTip.end'], { ns: 'education' })}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { EducationExpireNotice } from '../expire-notice'
|
||||
import { EducationExpireNotice } from '../index'
|
||||
import { resolveEducationExpireNotice } from '../use-expire-notice'
|
||||
|
||||
const mockEducationStatus = vi.hoisted(() => ({
|
||||
33
web/app/education/expire-notice/__tests__/modal.spec.tsx
Normal file
33
web/app/education/expire-notice/__tests__/modal.spec.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import ExpireNoticeModal from '../modal'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: () => '2026/08/20' }),
|
||||
}))
|
||||
|
||||
describe('ExpireNoticeModal', () => {
|
||||
it('navigates re-verification through the canonical Education route', () => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
|
||||
render(<ExpireNoticeModal expireAt={1787155200} expired={false} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('link', { name: 'education.notice.action.reVerify' })).toHaveAttribute(
|
||||
'href',
|
||||
'/education/verify',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -4,7 +4,7 @@ import { usePricingModal } from '@/hooks/use-query-params'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useEducationExpireNotice } from './use-expire-notice'
|
||||
|
||||
const ExpireNoticeModal = dynamic(() => import('./expire-notice-modal'), { ssr: false })
|
||||
const ExpireNoticeModal = dynamic(() => import('./modal'), { ssr: false })
|
||||
|
||||
export function EducationExpireNotice() {
|
||||
const [isPricingModalOpen] = usePricingModal()
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -10,9 +9,6 @@ import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useEducationVerify } from '@/service/use-education'
|
||||
import { SparklesSoftAccent } from '../components/base/icons/src/public/common'
|
||||
|
||||
type ExpireNoticeModalPayloadProps = {
|
||||
expireAt: number
|
||||
@ -34,16 +30,6 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
|
||||
const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education')
|
||||
const { formatTime } = useTimestamp()
|
||||
const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal)
|
||||
const { mutateAsync } = useEducationVerify()
|
||||
const router = useRouter()
|
||||
const handleVerify = async () => {
|
||||
const { token } = await mutateAsync()
|
||||
if (token) router.push(`/education-apply?token=${token}`)
|
||||
}
|
||||
const handleConfirm = async () => {
|
||||
await handleVerify()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -104,8 +90,8 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div>{t(($) => $.learn, { ns: 'education' })}</div>
|
||||
<RiExternalLinkLine className="size-3" />
|
||||
<span>{t(($) => $.learn, { ns: 'education' })}</span>
|
||||
<span className="i-ri-external-link-line size-3" aria-hidden="true" />
|
||||
</Link>
|
||||
<div className="flex space-x-2">
|
||||
{expired && deploymentEdition === 'CLOUD' ? (
|
||||
@ -116,19 +102,26 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<SparklesSoftAccent className="size-4" />
|
||||
<div className="text-components-button-secondary-accent-text">
|
||||
<span
|
||||
className="i-custom-public-common-sparkles-soft-accent size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-components-button-secondary-accent-text">
|
||||
{t(($) => $[`${i18nPrefix}.action.upgrade`], { ns: 'education' })}
|
||||
</div>
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose}>
|
||||
{t(($) => $[`${i18nPrefix}.action.dismiss`], { ns: 'education' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={handleConfirm}>
|
||||
<Link
|
||||
className={buttonVariants({ variant: 'primary' })}
|
||||
href="/education/verify"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t(($) => $[`${i18nPrefix}.action.reVerify`], { ns: 'education' })}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
53
web/app/education/paused-content.tsx
Normal file
53
web/app/education/paused-content.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { EducationStatusCard } from './status-card'
|
||||
import UserInfo from './user-info'
|
||||
|
||||
export function EducationPausedContent() {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-7">
|
||||
<UserInfo />
|
||||
</div>
|
||||
<EducationStatusCard
|
||||
icon={
|
||||
<span
|
||||
className="i-ri-pause-circle-fill size-6 text-text-warning-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
title={t(($) => $['educationDiscountPaused.title'], { ns: 'education' })}
|
||||
actions={
|
||||
<>
|
||||
<Link className={buttonVariants({ variant: 'secondary' })} href="/">
|
||||
<span className="i-ri-arrow-left-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Link>
|
||||
<a
|
||||
className={buttonVariants({ variant: 'ghost-accent' })}
|
||||
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $.learn, { ns: 'education' })}
|
||||
<span className="i-ri-external-link-line size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t(($) => $['educationDiscountPaused.description'], { ns: 'education' })}</p>
|
||||
<p className="mt-4">{t(($) => $['educationDiscountPaused.thanks'], { ns: 'education' })}</p>
|
||||
<p className="mt-4 system-xs-regular">
|
||||
{t(($) => $['educationDiscountPaused.publishedAt'], { ns: 'education' })}
|
||||
</p>
|
||||
</EducationStatusCard>
|
||||
</>
|
||||
)
|
||||
}
|
||||
29
web/app/education/status-card.tsx
Normal file
29
web/app/education/status-card.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type EducationStatusCardProps = {
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
icon: ReactNode
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
export function EducationStatusCard({ actions, children, icon, title }: EducationStatusCardProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-effects-highlight bg-background-default-subtle p-6 shadow-xs">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-background-section-burn">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="title-xl-semi-bold text-text-primary">{title}</h2>
|
||||
{children != null ? (
|
||||
<div className="mt-2 system-md-regular text-text-tertiary">{children}</div>
|
||||
) : null}
|
||||
{actions != null ? (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Triangle } from '@/app/components/base/icons/src/public/education'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
@ -15,9 +14,6 @@ const UserInfo = () => {
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
}
|
||||
|
||||
@ -27,7 +23,10 @@ const UserInfo = () => {
|
||||
<div className="flex h-5.5 items-center bg-components-panel-on-panel-item-bg pt-1 pl-2 system-2xs-semibold-uppercase text-text-accent-light-mode-only">
|
||||
{t(($) => $.currentSigned, { ns: 'education' })}
|
||||
</div>
|
||||
<Triangle className="h-5.5 w-4 text-components-panel-on-panel-item-bg" />
|
||||
<span
|
||||
className="i-custom-public-education-triangle h-5.5 w-4 text-components-panel-on-panel-item-bg"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
118
web/app/education/verify/__tests__/verify-flow.spec.tsx
Normal file
118
web/app/education/verify/__tests__/verify-flow.spec.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { EducationVerifyFlow } from '../verify-flow'
|
||||
|
||||
const mockReplace = vi.hoisted(() => vi.fn())
|
||||
const mockRedirect = vi.hoisted(() => vi.fn(() => null as never))
|
||||
const mockRequestVerification = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createAccountStateModuleMock(() => ({
|
||||
userProfile: { email: 'student@university.edu' },
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => path,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: mockRedirect,
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/education/user-info', () => ({
|
||||
default: () => <div>Student account</div>,
|
||||
}))
|
||||
|
||||
function renderFlow({
|
||||
applicationsPaused,
|
||||
allowRefresh = false,
|
||||
isEducationAccount = false,
|
||||
}: {
|
||||
applicationsPaused: boolean
|
||||
allowRefresh?: boolean
|
||||
isEducationAccount?: boolean
|
||||
}) {
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
educationStatus: {
|
||||
allow_refresh: allowRefresh,
|
||||
is_student: isEducationAccount,
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { education: { enabled: true } })
|
||||
|
||||
return render(
|
||||
<EducationVerifyFlow
|
||||
applicationsPaused={applicationsPaused}
|
||||
requestVerification={mockRequestVerification}
|
||||
/>,
|
||||
{ wrapper },
|
||||
)
|
||||
}
|
||||
|
||||
describe('EducationVerifyFlow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRequestVerification.mockResolvedValue({ token: 'education-token' })
|
||||
})
|
||||
|
||||
it('renders the pause state without requesting a verification token', () => {
|
||||
renderFlow({ applicationsPaused: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'education.educationDiscountPaused.title' }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockRequestVerification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an already verified account before the pause gate', () => {
|
||||
renderFlow({ applicationsPaused: true, isEducationAccount: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'education.applied.step1.description' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.settings.billing' })).toHaveAttribute(
|
||||
'href',
|
||||
'/?settings=billing',
|
||||
)
|
||||
})
|
||||
|
||||
it('requests one token on entry and replaces the route with the application form', async () => {
|
||||
mockRequestVerification.mockResolvedValue({ token: 'education token' })
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
educationStatus: { allow_refresh: false, is_student: false },
|
||||
})
|
||||
seedFeatures(queryClient, { education: { enabled: true } })
|
||||
|
||||
render(
|
||||
<StrictMode>
|
||||
<EducationVerifyFlow
|
||||
applicationsPaused={false}
|
||||
requestVerification={mockRequestVerification}
|
||||
/>
|
||||
</StrictMode>,
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRequestVerification).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/education/apply?token=education%20token')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the rejection state when verification returns no token', async () => {
|
||||
mockRequestVerification.mockResolvedValue({ token: null })
|
||||
|
||||
renderFlow({ applicationsPaused: false })
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'education.rejectTitle' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('student@university.edu')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
272
web/app/education/verify/verify-flow.tsx
Normal file
272
web/app/education/verify/verify-flow.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import type { EducationStatusResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { userProfileEmailAtom } from '@/context/account-state'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { redirect, useRouter } from '@/next/navigation'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { EDUCATION_APPLICATIONS_PAUSED } from '../constants'
|
||||
import { EducationPausedContent } from '../paused-content'
|
||||
import { EducationStatusCard } from '../status-card'
|
||||
import UserInfo from '../user-info'
|
||||
|
||||
class EducationVerificationRejectedError extends Error {}
|
||||
class EducationVerificationPausedError extends Error {}
|
||||
|
||||
type EducationVerificationRequest = () => Promise<{ token?: string | null }>
|
||||
|
||||
const selectEducationStatus = ({ allow_refresh, is_student }: EducationStatusResponse) => ({
|
||||
allowRefresh: allow_refresh ?? false,
|
||||
isEducationAccount: is_student ?? false,
|
||||
})
|
||||
|
||||
const selectEducationPlanEnabled = ({ education }: GetFeaturesResponse) => education.enabled
|
||||
|
||||
const requestEducationVerification: EducationVerificationRequest = () =>
|
||||
consoleClient.account.education.verify.get({}, { context: { silent: true } })
|
||||
|
||||
async function requestEducationVerificationToken(
|
||||
requestVerification: EducationVerificationRequest,
|
||||
) {
|
||||
try {
|
||||
const response = await requestVerification()
|
||||
if (!response.token) throw new EducationVerificationRejectedError()
|
||||
|
||||
return response.token
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
const body = (await error
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)) as { code?: unknown } | null
|
||||
if (body?.code === 'education_discount_temporarily_paused')
|
||||
throw new EducationVerificationPausedError()
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default function EducationVerifyPage() {
|
||||
return <EducationVerifyFlow applicationsPaused={EDUCATION_APPLICATIONS_PAUSED} />
|
||||
}
|
||||
|
||||
export function EducationVerifyFlow({
|
||||
applicationsPaused,
|
||||
requestVerification = requestEducationVerification,
|
||||
}: {
|
||||
applicationsPaused: boolean
|
||||
requestVerification?: EducationVerificationRequest
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const userEmail = useAtomValue(userProfileEmailAtom)
|
||||
const docLink = useDocLink()
|
||||
const verificationStartedRef = useRef(false)
|
||||
const featuresQuery = useQuery(
|
||||
consoleQuery.features.get.queryOptions({ select: selectEducationPlanEnabled }),
|
||||
)
|
||||
const enableEducationPlan = featuresQuery.data === true
|
||||
const educationStatusQuery = useQuery(
|
||||
consoleQuery.account.education.get.queryOptions({
|
||||
enabled: featuresQuery.isSuccess && enableEducationPlan,
|
||||
select: selectEducationStatus,
|
||||
}),
|
||||
)
|
||||
const {
|
||||
error: verificationError,
|
||||
isError: isVerificationError,
|
||||
mutate: verifyEducation,
|
||||
reset: resetVerification,
|
||||
} = useMutation({
|
||||
mutationKey: ['education', 'verification-token'],
|
||||
mutationFn: () => requestEducationVerificationToken(requestVerification),
|
||||
})
|
||||
|
||||
const startVerification = useCallback(() => {
|
||||
if (verificationStartedRef.current) return
|
||||
|
||||
verificationStartedRef.current = true
|
||||
verifyEducation(undefined, {
|
||||
onSuccess: (token) => {
|
||||
router.replace(`/education/apply?token=${encodeURIComponent(token)}`)
|
||||
},
|
||||
})
|
||||
}, [router, verifyEducation])
|
||||
|
||||
const educationStatus = educationStatusQuery.data
|
||||
const isAlreadyVerified =
|
||||
educationStatus?.isEducationAccount === true && !educationStatus.allowRefresh
|
||||
const canVerify =
|
||||
educationStatusQuery.isSuccess &&
|
||||
educationStatus !== undefined &&
|
||||
(!educationStatus.isEducationAccount || educationStatus.allowRefresh)
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationsPaused && canVerify) startVerification()
|
||||
}, [applicationsPaused, canVerify, startVerification])
|
||||
|
||||
if (featuresQuery.isPending) return <EducationVerifyLoading />
|
||||
|
||||
if (!enableEducationPlan) return redirect('/')
|
||||
|
||||
if (educationStatusQuery.isPending) return <EducationVerifyLoading />
|
||||
|
||||
if (educationStatusQuery.isError)
|
||||
return (
|
||||
<EducationVerifyError
|
||||
onRetry={() => {
|
||||
void educationStatusQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isAlreadyVerified) return <EducationVerifiedContent />
|
||||
|
||||
if (applicationsPaused || verificationError instanceof EducationVerificationPausedError)
|
||||
return <EducationPausedContent />
|
||||
|
||||
if (verificationError instanceof EducationVerificationRejectedError) {
|
||||
return (
|
||||
<EducationVerifyContent>
|
||||
<EducationStatusCard
|
||||
icon={
|
||||
<span
|
||||
className="i-ri-close-circle-fill size-6 text-text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
title={t(($) => $.rejectTitle, { ns: 'education' })}
|
||||
actions={
|
||||
<>
|
||||
<Link className={buttonVariants({ variant: 'secondary' })} href="/">
|
||||
<span className="i-ri-arrow-left-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Link>
|
||||
<a
|
||||
className={buttonVariants({ variant: 'ghost-accent' })}
|
||||
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(($) => $.learn, { ns: 'education' })}
|
||||
<span className="i-ri-external-link-line size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t(($) => $.rejectContent, { ns: 'education' })}</p>
|
||||
<div className="mt-4">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t(($) => $.emailLabel, { ns: 'education' })}
|
||||
</div>
|
||||
<div className="mt-1 rounded-lg bg-components-input-bg-disabled px-3 py-2 system-sm-regular text-components-input-text-filled-disabled">
|
||||
{userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</EducationStatusCard>
|
||||
</EducationVerifyContent>
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerificationError)
|
||||
return (
|
||||
<EducationVerifyError
|
||||
onRetry={() => {
|
||||
verificationStartedRef.current = false
|
||||
resetVerification()
|
||||
startVerification()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return <EducationVerifyLoading />
|
||||
}
|
||||
|
||||
function EducationVerifyContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-7">
|
||||
<UserInfo />
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationVerifyLoading() {
|
||||
return (
|
||||
<EducationStatusCard
|
||||
icon={<Loading />}
|
||||
title={<span className="block h-5 w-40 animate-pulse rounded bg-background-section-burn" />}
|
||||
>
|
||||
<span className="block h-4 w-full max-w-100 animate-pulse rounded bg-background-section-burn" />
|
||||
</EducationStatusCard>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationVerifiedContent() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<EducationVerifyContent>
|
||||
<EducationStatusCard
|
||||
icon={
|
||||
<span className="i-ri-checkbox-circle-fill size-6 text-text-success" aria-hidden="true" />
|
||||
}
|
||||
title={t(($) => $['applied.step1.description'], { ns: 'education' })}
|
||||
actions={
|
||||
<>
|
||||
<Link className={buttonVariants({ variant: 'primary' })} href="/?settings=billing">
|
||||
{t(($) => $['settings.billing'], { ns: 'common' })}
|
||||
</Link>
|
||||
<Link className={buttonVariants({ variant: 'ghost-accent' })} href="/">
|
||||
<span className="i-ri-arrow-left-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</EducationVerifyContent>
|
||||
)
|
||||
}
|
||||
|
||||
function EducationVerifyError({ onRetry }: { onRetry: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<EducationVerifyContent>
|
||||
<EducationStatusCard
|
||||
icon={
|
||||
<span
|
||||
className="i-ri-error-warning-fill size-6 text-text-warning-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
title={t(($) => $['errorBoundary.title'], { ns: 'common' })}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
{t(($) => $['errorBoundary.tryAgain'], { ns: 'common' })}
|
||||
</Button>
|
||||
<Link className={buttonVariants({ variant: 'ghost-accent' })} href="/">
|
||||
<span className="i-ri-arrow-left-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['applied.noPaymentPermission.returnHome'], { ns: 'education' })}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t(($) => $['errorBoundary.message'], { ns: 'common' })}
|
||||
</EducationStatusCard>
|
||||
</EducationVerifyContent>
|
||||
)
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "ينطبق الخصم التعليمي على خطة Professional السنوية فقط. الاحتفاظ بخطتك الحالية لن يتضمن الخصم.",
|
||||
"educationPricingConfirm.title": "الخطة التي اخترتها لا تدعم الخصم التعليمي",
|
||||
"emailLabel": "بريدك الإلكتروني الحالي",
|
||||
"form.schoolName.noResults": "لم يتم العثور على مدارس مطابقة. لا يزال بإمكانك إدخال الاسم الرسمي للمدرسة.",
|
||||
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
|
||||
"form.schoolName.title": "اسم مدرستك",
|
||||
"form.schoolRole.option.administrator": "مسؤول المدرسة",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Der Bildungsrabatt gilt nur für den jährlichen Professional-Plan. Wenn Sie Ihren aktuellen Plan behalten, ist der Rabatt nicht enthalten.",
|
||||
"educationPricingConfirm.title": "Ihr ausgewählter Plan unterstützt den Bildungsrabatt nicht",
|
||||
"emailLabel": "Ihre aktuelle E-Mail",
|
||||
"form.schoolName.noResults": "Keine passende Schule gefunden. Sie können den offiziellen Namen trotzdem eingeben.",
|
||||
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
|
||||
"form.schoolName.title": "Ihr Schulname",
|
||||
"form.schoolRole.option.administrator": "Schuladministrator",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "The education discount applies to the Professional annual plan only. Keeping your current plan won't include the discount.",
|
||||
"educationPricingConfirm.title": "Your selected plan doesn't support the education discount",
|
||||
"emailLabel": "Your current email",
|
||||
"form.schoolName.noResults": "No matching schools found. You can still enter the official school name.",
|
||||
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
|
||||
"form.schoolName.title": "Your School Name",
|
||||
"form.schoolRole.option.administrator": "School Administrator",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "El descuento educativo solo se aplica al plan Professional anual. Si mantienes tu plan actual, no se incluirá el descuento.",
|
||||
"educationPricingConfirm.title": "El plan seleccionado no admite el descuento educativo",
|
||||
"emailLabel": "Tu correo electrónico actual",
|
||||
"form.schoolName.noResults": "No se encontraron escuelas coincidentes. Aun así, puede ingresar el nombre oficial.",
|
||||
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
|
||||
"form.schoolName.title": "El nombre de tu escuela",
|
||||
"form.schoolRole.option.administrator": "Administrador escolar",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "تخفیف آموزشی فقط برای طرح سالانه Professional اعمال میشود. با حفظ طرح فعلی، این تخفیف شامل نمیشود.",
|
||||
"educationPricingConfirm.title": "طرح انتخابشده شما از تخفیف آموزشی پشتیبانی نمیکند",
|
||||
"emailLabel": "ایمیل فعلی شما",
|
||||
"form.schoolName.noResults": "مدرسه منطبقی یافت نشد. همچنان میتوانید نام رسمی مدرسه را وارد کنید.",
|
||||
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
|
||||
"form.schoolName.title": "نام مدرسه شما",
|
||||
"form.schoolRole.option.administrator": "مدیر مدرسه",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "La remise éducation s'applique uniquement au plan Professional annuel. En conservant votre plan actuel, la remise ne sera pas incluse.",
|
||||
"educationPricingConfirm.title": "Le plan sélectionné ne prend pas en charge la remise éducation",
|
||||
"emailLabel": "Votre email actuel",
|
||||
"form.schoolName.noResults": "Aucun établissement correspondant trouvé. Vous pouvez tout de même saisir son nom officiel.",
|
||||
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
|
||||
"form.schoolName.title": "Le nom de votre école",
|
||||
"form.schoolRole.option.administrator": "Administrateur scolaire",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "शिक्षा छूट केवल Professional वार्षिक प्लान पर लागू होती है। अपना वर्तमान प्लान रखने पर छूट शामिल नहीं होगी।",
|
||||
"educationPricingConfirm.title": "आपका चुना हुआ प्लान शिक्षा छूट का समर्थन नहीं करता",
|
||||
"emailLabel": "आपका वर्तमान ईमेल",
|
||||
"form.schoolName.noResults": "कोई मिलता-जुलता स्कूल नहीं मिला। आप फिर भी स्कूल का आधिकारिक नाम दर्ज कर सकते हैं।",
|
||||
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
|
||||
"form.schoolName.title": "आपके स्कूल का नाम",
|
||||
"form.schoolRole.option.administrator": "स्कूल प्रशासक",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Diskon pendidikan hanya berlaku untuk paket Professional tahunan. Jika tetap menggunakan paket saat ini, diskon tidak akan disertakan.",
|
||||
"educationPricingConfirm.title": "Paket yang Anda pilih tidak mendukung diskon pendidikan",
|
||||
"emailLabel": "Email Anda saat ini",
|
||||
"form.schoolName.noResults": "Tidak ada sekolah yang cocok. Anda tetap dapat memasukkan nama resmi sekolah.",
|
||||
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
|
||||
"form.schoolName.title": "Nama Sekolah Anda",
|
||||
"form.schoolRole.option.administrator": "Administrator Sekolah",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Lo sconto Education si applica solo al piano Professional annuale. Mantenendo il piano attuale, lo sconto non verrà incluso.",
|
||||
"educationPricingConfirm.title": "Il piano selezionato non supporta lo sconto Education",
|
||||
"emailLabel": "La tua email attuale",
|
||||
"form.schoolName.noResults": "Nessuna scuola corrispondente trovata. Puoi comunque inserire il nome ufficiale.",
|
||||
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
|
||||
"form.schoolName.title": "Il Nome della tua Scuola",
|
||||
"form.schoolRole.option.administrator": "Amministratore scolastico",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "教育割引は Professional 年間プランにのみ適用されます。現在のプランを維持すると、割引は適用されません。",
|
||||
"educationPricingConfirm.title": "選択したプランは教育割引に対応していません",
|
||||
"emailLabel": "現在のメールアドレス",
|
||||
"form.schoolName.noResults": "一致する学校が見つかりませんでした。正式な学校名をそのまま入力できます。",
|
||||
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
|
||||
"form.schoolName.title": "学校名",
|
||||
"form.schoolRole.option.administrator": "学校管理者",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "교육 할인은 Professional 연간 플랜에만 적용됩니다. 현재 플랜을 유지하면 할인이 포함되지 않습니다.",
|
||||
"educationPricingConfirm.title": "선택한 플랜은 교육 할인을 지원하지 않습니다",
|
||||
"emailLabel": "현재 이메일",
|
||||
"form.schoolName.noResults": "일치하는 학교를 찾지 못했습니다. 학교의 공식 명칭을 직접 입력할 수 있습니다.",
|
||||
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
|
||||
"form.schoolName.title": "당신의 학교 이름",
|
||||
"form.schoolRole.option.administrator": "학교 관리자",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "ສ່ວນຫຼຸດເພື່ອການສຶກສານຳໃຊ້ໄດ້ກັບແພັກເກດ Professional ແບບລາຍປີເທົັ້ນັ້ນ. ການໃຊ້ແພັກເກດປັດຈຸບັນຂອງທ່ານຕໍ່ໄປຈະບໍ່ລວມເອົາສ່ວນຫຼຸດນີ້.",
|
||||
"educationPricingConfirm.title": "ແພັກເກດທີ່ທ່ານເລືອກບໍ່ຮອງຮັບສ່ວນຫຼຸດເພື່ອການສຶກສາ",
|
||||
"emailLabel": "ອີເມວປັດຈຸບັນຂອງທ່ານ",
|
||||
"form.schoolName.noResults": "ບໍ່ພົບໂຮງຮຽນທີ່ກົງກັນ. ທ່ານຍັງສາມາດປ້ອນຊື່ທາງການຂອງໂຮງຮຽນໄດ້.",
|
||||
"form.schoolName.placeholder": "ປ້ອນຊື່ທາງການຂອງໂຮງຮຽນຂອງທ່ານ (ບໍ່ໃຊ້ຊື່ຫຍໍ້)",
|
||||
"form.schoolName.title": "ຊື່ໂຮງຮຽນຂອງທ່ານ",
|
||||
"form.schoolRole.option.administrator": "ຜູ້ບໍລິຫານໂຮງຮຽນ",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "De onderwijskorting is alleen van toepassing op het jaarlijkse Professional-abonnement. Als u uw huidige abonnement behoudt, is de korting niet inbegrepen.",
|
||||
"educationPricingConfirm.title": "Uw geselecteerde abonnement ondersteunt de onderwijskorting niet",
|
||||
"emailLabel": "Your current email",
|
||||
"form.schoolName.noResults": "Geen overeenkomende school gevonden. U kunt de officiële schoolnaam nog steeds invoeren.",
|
||||
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
|
||||
"form.schoolName.title": "Your School Name",
|
||||
"form.schoolRole.option.administrator": "School Administrator",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Zniżka edukacyjna dotyczy tylko rocznego planu Professional. Pozostanie przy obecnym planie nie obejmie zniżki.",
|
||||
"educationPricingConfirm.title": "Wybrany plan nie obsługuje zniżki edukacyjnej",
|
||||
"emailLabel": "Twój aktualny email",
|
||||
"form.schoolName.noResults": "Nie znaleziono pasującej szkoły. Nadal możesz wpisać jej oficjalną nazwę.",
|
||||
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
|
||||
"form.schoolName.title": "Nazwa Twojej Szkoły",
|
||||
"form.schoolRole.option.administrator": "Administrator szkoły",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "O desconto educacional se aplica apenas ao plano Professional anual. Manter seu plano atual não incluirá o desconto.",
|
||||
"educationPricingConfirm.title": "O plano selecionado não aceita o desconto educacional",
|
||||
"emailLabel": "Seu e-mail atual",
|
||||
"form.schoolName.noResults": "Nenhuma escola correspondente foi encontrada. Você ainda pode inserir o nome oficial.",
|
||||
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
|
||||
"form.schoolName.title": "O nome da sua escola",
|
||||
"form.schoolRole.option.administrator": "Administrador Escolar",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Reducerea educațională se aplică doar planului Professional anual. Dacă păstrezi planul curent, reducerea nu va fi inclusă.",
|
||||
"educationPricingConfirm.title": "Planul selectat nu acceptă reducerea educațională",
|
||||
"emailLabel": "Emailul tău curent",
|
||||
"form.schoolName.noResults": "Nu a fost găsită nicio școală corespunzătoare. Puteți introduce în continuare numele oficial.",
|
||||
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
|
||||
"form.schoolName.title": "Numele Școlii Tale",
|
||||
"form.schoolRole.option.administrator": "Administrator școlar",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Образовательная скидка применяется только к годовому плану Professional. Если оставить текущий план, скидка не будет включена.",
|
||||
"educationPricingConfirm.title": "Выбранный план не поддерживает образовательную скидку",
|
||||
"emailLabel": "Ваш текущий адрес электронной почты",
|
||||
"form.schoolName.noResults": "Подходящая школа не найдена. Вы всё равно можете ввести её официальное название.",
|
||||
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
|
||||
"form.schoolName.title": "Название вашей школы",
|
||||
"form.schoolRole.option.administrator": "Школьный администратор",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Izobraževalni popust velja samo za letni paket Professional. Če obdržite trenutni paket, popust ne bo vključen.",
|
||||
"educationPricingConfirm.title": "Izbrani paket ne podpira izobraževalnega popusta",
|
||||
"emailLabel": "Vaš trenutni elektronski naslov",
|
||||
"form.schoolName.noResults": "Ni bilo najdene ustrezne šole. Še vedno lahko vnesete njeno uradno ime.",
|
||||
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
|
||||
"form.schoolName.title": "Ime vaše šole",
|
||||
"form.schoolRole.option.administrator": "Šolski administrator",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "ส่วนลดการศึกษาใช้ได้เฉพาะกับแผน Professional รายปีเท่านั้น หากใช้แผนปัจจุบันต่อ จะไม่มีส่วนลดนี้รวมอยู่ด้วย",
|
||||
"educationPricingConfirm.title": "แผนที่คุณเลือกไม่รองรับส่วนลดการศึกษา",
|
||||
"emailLabel": "อีเมลปัจจุบันของคุณ",
|
||||
"form.schoolName.noResults": "ไม่พบโรงเรียนที่ตรงกัน คุณยังสามารถกรอกชื่อโรงเรียนอย่างเป็นทางการได้",
|
||||
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
|
||||
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",
|
||||
"form.schoolRole.option.administrator": "ผู้ดูแลโรงเรียน",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Eğitim indirimi yalnızca yıllık Professional planı için geçerlidir. Mevcut planınızı korursanız indirim dahil edilmez.",
|
||||
"educationPricingConfirm.title": "Seçtiğiniz plan eğitim indirimini desteklemiyor",
|
||||
"emailLabel": "Şu anki e-posta adresin",
|
||||
"form.schoolName.noResults": "Eşleşen bir okul bulunamadı. Okulun resmi adını yine de girebilirsiniz.",
|
||||
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
|
||||
"form.schoolName.title": "Okulunuzun Adı",
|
||||
"form.schoolRole.option.administrator": "Okul Yöneticisi",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Освітня знижка застосовується лише до річного плану Professional. Якщо залишити поточний план, знижку не буде включено.",
|
||||
"educationPricingConfirm.title": "Вибраний план не підтримує освітню знижку",
|
||||
"emailLabel": "Ваш поточний електронний лист",
|
||||
"form.schoolName.noResults": "Відповідний навчальний заклад не знайдено. Ви все одно можете ввести його офіційну назву.",
|
||||
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
|
||||
"form.schoolName.title": "Ваша назва школи",
|
||||
"form.schoolRole.option.administrator": "Шкільний адміністратор",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "Giảm giá giáo dục chỉ áp dụng cho gói Professional hằng năm. Nếu giữ gói hiện tại, giảm giá sẽ không được áp dụng.",
|
||||
"educationPricingConfirm.title": "Gói bạn chọn không hỗ trợ giảm giá giáo dục",
|
||||
"emailLabel": "Email hiện tại của bạn",
|
||||
"form.schoolName.noResults": "Không tìm thấy trường phù hợp. Bạn vẫn có thể nhập tên chính thức của trường.",
|
||||
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
|
||||
"form.schoolName.title": "Tên Trường Của Bạn",
|
||||
"form.schoolRole.option.administrator": "Quản trị viên trường học",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "教育优惠仅适用于 Professional 年付计划。保留当前计划将不包含该优惠。",
|
||||
"educationPricingConfirm.title": "你选择的计划不支持教育优惠",
|
||||
"emailLabel": "您当前的邮箱",
|
||||
"form.schoolName.noResults": "未找到匹配的学校,你仍可输入学校的官方全称。",
|
||||
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
|
||||
"form.schoolName.title": "您的学校名称",
|
||||
"form.schoolRole.option.administrator": "学校管理员",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"educationPricingConfirm.description": "教育優惠僅適用於 Professional 年付方案。保留目前方案將不包含此優惠。",
|
||||
"educationPricingConfirm.title": "你選擇的方案不支援教育優惠",
|
||||
"emailLabel": "您當前的電子郵件",
|
||||
"form.schoolName.noResults": "找不到符合的學校,你仍可輸入學校的正式全名。",
|
||||
"form.schoolName.placeholder": "請輸入您學校的正式全名",
|
||||
"form.schoolName.title": "你的學校名稱",
|
||||
"form.schoolRole.option.administrator": "校園行政人員",
|
||||
|
||||
@ -38,6 +38,12 @@ const nextConfig: NextConfig = {
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
// TODO(2026-11-11): Remove after external education CTAs and active campaign links use the canonical route.
|
||||
source: '/education-apply',
|
||||
destination: '/education/apply',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
output: 'standalone',
|
||||
|
||||
14
web/proxy.ts
14
web/proxy.ts
@ -19,6 +19,7 @@ const EMBEDDABLE_PATH_SEGMENTS = [
|
||||
]
|
||||
const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device']
|
||||
const FRAME_ANCESTORS_NONE = "frame-ancestors 'none';"
|
||||
const LEGACY_EDUCATION_ACTION = 'getEducationVerify'
|
||||
|
||||
const matchesPathSegment = (pathname: string, segments: string[]) =>
|
||||
segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`))
|
||||
@ -47,6 +48,19 @@ const wrapResponseWithFrameProtection = (response: NextResponse, pathname: strin
|
||||
}
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl
|
||||
|
||||
// TODO(2026-11-11): Remove after external education CTAs and active campaign links use the canonical route.
|
||||
if (pathname === '/' && request.nextUrl.searchParams.get('action') === LEGACY_EDUCATION_ACTION) {
|
||||
const destination = request.nextUrl.clone()
|
||||
destination.pathname = '/education/verify'
|
||||
destination.searchParams.delete('action')
|
||||
|
||||
return wrapResponseWithFrameProtection(
|
||||
NextResponse.redirect(destination, { status: 308 }),
|
||||
pathname,
|
||||
)
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.set(CURRENT_PATHNAME_HEADER, pathname)
|
||||
requestHeaders.set(CURRENT_SEARCH_HEADER, search)
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useEducationAutocomplete, useEducationVerify } from '../use-education'
|
||||
|
||||
const mockAutocompleteEducation = vi.hoisted(() => vi.fn())
|
||||
const mockVerifyEducation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
consoleClient: {
|
||||
account: {
|
||||
education: {
|
||||
autocomplete: {
|
||||
get: mockAutocompleteEducation,
|
||||
},
|
||||
verify: {
|
||||
get: mockVerifyEducation,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useEducationAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes an empty generated response for the search UI', async () => {
|
||||
mockAutocompleteEducation.mockResolvedValue({})
|
||||
const { result } = renderHook(() => useEducationAutocomplete(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync({ keywords: 'Dify' })).resolves.toEqual({
|
||||
curr_page: 0,
|
||||
data: [],
|
||||
has_next: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockAutocompleteEducation).toHaveBeenCalledWith({
|
||||
query: { keywords: 'Dify', limit: 40, page: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEducationVerify', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('requests a verification token silently through the generated client', async () => {
|
||||
mockVerifyEducation.mockResolvedValue({ token: 'education-token' })
|
||||
const { result } = renderHook(() => useEducationVerify(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).resolves.toEqual({ token: 'education-token' })
|
||||
})
|
||||
|
||||
expect(mockVerifyEducation).toHaveBeenCalledWith({}, { context: { silent: true } })
|
||||
})
|
||||
|
||||
it('rejects an invalid successful response without a token', async () => {
|
||||
mockVerifyEducation.mockResolvedValue({ token: null })
|
||||
const { result } = renderHook(() => useEducationVerify(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toThrow(
|
||||
'Education verification token is missing',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,41 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { consoleClient } from './client'
|
||||
|
||||
const NAME_SPACE = 'education'
|
||||
|
||||
export const useEducationVerify = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'education-verify'],
|
||||
mutationFn: async () => {
|
||||
const response = await consoleClient.account.education.verify.get(
|
||||
{},
|
||||
{ context: { silent: true } },
|
||||
)
|
||||
if (!response.token) throw new Error('Education verification token is missing')
|
||||
|
||||
return { token: response.token }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type SearchParams = {
|
||||
keywords?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
export const useEducationAutocomplete = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (searchParams: SearchParams) => {
|
||||
const { keywords = '', page = 0, limit = 40 } = searchParams
|
||||
const response = await consoleClient.account.education.autocomplete.get({
|
||||
query: { keywords, limit, page },
|
||||
})
|
||||
|
||||
return {
|
||||
curr_page: response.curr_page ?? page,
|
||||
data: response.data ?? [],
|
||||
has_next: response.has_next ?? false,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -2,6 +2,7 @@ import type {
|
||||
EducationStatusResponse,
|
||||
GetAccountProfileResponse,
|
||||
} from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type {
|
||||
GetSystemFeaturesLicenseResponse,
|
||||
GetSystemFeaturesResponse,
|
||||
@ -16,6 +17,7 @@ import type {
|
||||
} from '@testing-library/react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { DeepPartial } from '@/test/console/system-features'
|
||||
import { zGetFeaturesResponse } from '@dify/contracts/api/console/features/zod.gen'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { ensureAccountProfileQuery, seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
@ -122,6 +124,15 @@ export const seedEducationStatus = (
|
||||
return data
|
||||
}
|
||||
|
||||
export const seedFeatures = (
|
||||
queryClient: QueryClient,
|
||||
overrides: DeepPartial<GetFeaturesResponse> = {},
|
||||
): GetFeaturesResponse => {
|
||||
const data = zGetFeaturesResponse.parse(overrides)
|
||||
queryClient.setQueryData(consoleQuery.features.get.queryKey(), data)
|
||||
return data
|
||||
}
|
||||
|
||||
const ensureSystemFeatures = (queryClient: QueryClient) => {
|
||||
const queryKey = consoleQuery.systemFeatures.get.queryKey()
|
||||
const existingSystemFeatures = queryClient.getQueryData<GetSystemFeaturesResponse>(queryKey)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user