mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(web): simplify billing portal link (#40131)
This commit is contained in:
7 files changed
+130
-248
No files matched your search
@@ -34,6 +34,9 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
|
||||
limit: plan.total.vectorSpace,
|
||||
usage_unknown: vectorSpaceUsageUnknown,
|
||||
})
|
||||
queryClient.setQueryData(consoleQuery.billing.invoices.get.queryOptions().queryKey, {
|
||||
url: 'https://billing.example.com',
|
||||
})
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
queryClient,
|
||||
@@ -71,17 +74,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
@@ -97,10 +89,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
|
||||
@@ -78,14 +78,6 @@ vi.mock('@/service/use-education', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
@@ -93,10 +85,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({
|
||||
|
||||
@@ -7,19 +7,26 @@
|
||||
* Covers URL param reading, cookie persistence, API bind on mount,
|
||||
* cookie cleanup after successful bind, and error handling for 400 status.
|
||||
*/
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import * as React from 'react'
|
||||
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { renderHookWithConsoleQuery, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
|
||||
const render = (ui: React.ReactElement) =>
|
||||
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
const renderPSInfo = () =>
|
||||
renderHookWithConsoleQuery(() => usePSInfo(), {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── Module mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -28,15 +35,9 @@ vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBindPartnerStackInfo: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useBillingUrl: () => ({
|
||||
data: '',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: (...args: unknown[]) => mocks.request(...args),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
@@ -69,6 +70,8 @@ const clearCookie = () => {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
|
||||
}
|
||||
|
||||
const getBindRequest = () => mocks.request.mock.calls[0]?.[2]?.request as Request | undefined
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Partner Stack Flow', () => {
|
||||
beforeEach(() => {
|
||||
@@ -76,7 +79,7 @@ describe('Partner Stack Flow', () => {
|
||||
cleanup()
|
||||
clearCookie()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
mocks.request.mockResolvedValue(Response.json({}))
|
||||
})
|
||||
|
||||
// ─── 1. URL Param Reading ───────────────────────────────────────────────
|
||||
@@ -87,7 +90,7 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'click-456',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('partner-123')
|
||||
expect(result.current.psClickId).toBe('click-456')
|
||||
@@ -96,7 +99,7 @@ describe('Partner Stack Flow', () => {
|
||||
it('should fall back to cookie when URL params are not present', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
@@ -109,14 +112,14 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'url-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('url-partner')
|
||||
expect(result.current.psClickId).toBe('url-click')
|
||||
})
|
||||
|
||||
it('should return null for both values when no params and no cookie', () => {
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
@@ -131,7 +134,7 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
const cookieData = getCookieData()
|
||||
@@ -149,7 +152,7 @@ describe('Partner Stack Flow', () => {
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
// Should not call set because values haven't changed
|
||||
@@ -163,7 +166,7 @@ describe('Partner Stack Flow', () => {
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
@@ -176,7 +179,7 @@ describe('Partner Stack Flow', () => {
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
@@ -186,21 +189,22 @@ describe('Partner Stack Flow', () => {
|
||||
|
||||
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
|
||||
describe('Bind API flow', () => {
|
||||
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
|
||||
it('should bind the partner key and click ID through the billing API', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'bind-partner',
|
||||
clickId: 'bind-click',
|
||||
})
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1)
|
||||
const request = getBindRequest()
|
||||
expect(request?.method).toBe('PUT')
|
||||
expect(request?.url).toContain('/billing/partners/bind-partner/tenants')
|
||||
await expect(request?.clone().json()).resolves.toEqual({ click_id: 'bind-click' })
|
||||
})
|
||||
|
||||
it('should remove cookie after successful bind', async () => {
|
||||
@@ -210,7 +214,7 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'rm-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
@@ -220,14 +224,14 @@ describe('Partner Stack Flow', () => {
|
||||
})
|
||||
|
||||
it('should remove cookie on 400 error (already bound)', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 400 })
|
||||
mocks.request.mockRejectedValue({ status: 400 })
|
||||
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'err-partner',
|
||||
ps_xid: 'err-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
@@ -237,14 +241,14 @@ describe('Partner Stack Flow', () => {
|
||||
})
|
||||
|
||||
it('should not remove cookie on non-400 errors', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 500 })
|
||||
mocks.request.mockRejectedValue({ status: 500 })
|
||||
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'keep-partner',
|
||||
ps_xid: 'keep-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
@@ -259,12 +263,12 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call bind a second time (idempotency)', async () => {
|
||||
@@ -273,19 +277,19 @@ describe('Partner Stack Flow', () => {
|
||||
ps_xid: 'click-once',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderPSInfo()
|
||||
|
||||
// First bind
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second bind should be skipped (hasBind = true)
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -303,10 +307,8 @@ describe('Partner Stack Flow', () => {
|
||||
render(<PartnerStack />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'mount-partner',
|
||||
clickId: 'mount-click',
|
||||
})
|
||||
expect(mocks.request).toHaveBeenCalledTimes(1)
|
||||
expect(getBindRequest()?.url).toContain('/billing/partners/mount-partner/tenants')
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import Billing from '../index'
|
||||
|
||||
let currentBillingUrl: string | null = 'https://billing'
|
||||
let fetching = false
|
||||
let currentBillingUrl: string | undefined = 'https://billing.example.com'
|
||||
let isManager = true
|
||||
let enableBilling = true
|
||||
let billingUrlEnabled = false
|
||||
|
||||
const refetchMock = vi.fn()
|
||||
const openAsyncWindowMock = vi.fn()
|
||||
|
||||
type BillingUrlCallback = () => Promise<string | null>
|
||||
type BillingWindowOptions = {
|
||||
immediateUrl: string | null
|
||||
features: string
|
||||
onError: (err: Error) => void
|
||||
}
|
||||
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: (enabled: boolean) => {
|
||||
billingUrlEnabled = enabled
|
||||
return {
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}
|
||||
},
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(() => new Promise(() => {})),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => openAsyncWindowMock,
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: mocks.request,
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
@@ -51,113 +35,67 @@ vi.mock('../../plan', () => ({
|
||||
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
|
||||
}))
|
||||
|
||||
const renderBilling = () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
if (currentBillingUrl) {
|
||||
queryClient.setQueryData(consoleQuery.billing.invoices.get.queryOptions().queryKey, {
|
||||
url: currentBillingUrl,
|
||||
})
|
||||
}
|
||||
const { wrapper } = createConsoleQueryWrapper({ queryClient })
|
||||
|
||||
return render(<Billing />, { wrapper })
|
||||
}
|
||||
|
||||
describe('Billing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentBillingUrl = 'https://billing'
|
||||
fetching = false
|
||||
currentBillingUrl = 'https://billing.example.com'
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
billingUrlEnabled = false
|
||||
refetchMock.mockResolvedValue({ data: 'https://billing' })
|
||||
})
|
||||
|
||||
it('renders the billing portal as a keyboard-accessible external link for workspace managers', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderBilling()
|
||||
|
||||
const billingLink = screen.getByRole('link', { name: /billing\.viewBillingTitle/ })
|
||||
expect(billingLink).toHaveAttribute('href', currentBillingUrl)
|
||||
expect(billingLink).toHaveAttribute('target', '_blank')
|
||||
expect(billingLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
await user.tab()
|
||||
expect(billingLink).toHaveFocus()
|
||||
})
|
||||
|
||||
it('hides the billing action from non-manager members', () => {
|
||||
isManager = false
|
||||
|
||||
render(<Billing />)
|
||||
renderBilling()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the billing action to managers without billing permission keys', () => {
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(true)
|
||||
expect(screen.queryByText('billing.viewBillingTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the billing action when billing is disabled', () => {
|
||||
enableBilling = false
|
||||
render(<Billing />)
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(screen.queryByText('billing.viewBillingTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the billing action outside the tab order before the URL is available', async () => {
|
||||
const user = userEvent.setup()
|
||||
currentBillingUrl = undefined
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(screen.getByText('billing.viewBillingTitle')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
|
||||
screen.queryByRole('link', { name: /billing\.viewBillingTitle/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the billing window with the immediate url when the button is clicked', async () => {
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
|
||||
const [, options] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
|
||||
expect(options).toMatchObject({
|
||||
immediateUrl: currentBillingUrl,
|
||||
features: 'noopener,noreferrer',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the refetched url from the async callback', async () => {
|
||||
const newUrl = 'https://new-billing-url'
|
||||
refetchMock.mockResolvedValue({ data: newUrl })
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
|
||||
const [asyncCallback] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
|
||||
|
||||
// Execute the async callback passed to openAsyncWindow
|
||||
const result = await asyncCallback()
|
||||
expect(result).toBe(newUrl)
|
||||
expect(refetchMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when refetch returns no url', async () => {
|
||||
refetchMock.mockResolvedValue({ data: null })
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
|
||||
const [asyncCallback] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
|
||||
|
||||
// Execute the async callback when url is null
|
||||
const result = await asyncCallback()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('handles errors in onError callback', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
|
||||
const [, options] = openAsyncWindowMock.mock.calls[0] as OpenAsyncWindowCall
|
||||
|
||||
// Execute the onError callback
|
||||
const testError = new Error('Test error')
|
||||
options.onError(testError)
|
||||
expect(consoleError).toHaveBeenCalledWith('Failed to fetch billing url', testError)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('disables the button while billing url is fetching', () => {
|
||||
fetching = true
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
expect(actionButton)!.toBeDisabled()
|
||||
await user.tab()
|
||||
expect(document.body).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +1,38 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useBillingUrl } from '@/service/use-billing'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import PlanComp from '../plan'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { enableBilling } = useProviderContext()
|
||||
const {
|
||||
data: billingUrl,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenBilling = async () => {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
const url = (await refetch()).data
|
||||
if (url) return url
|
||||
return null
|
||||
},
|
||||
{
|
||||
immediateUrl: billingUrl,
|
||||
features: 'noopener,noreferrer',
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch billing url', err)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
const canManageBilling = enableBilling && isCurrentWorkspaceManager
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.billing.invoices.get.queryOptions({ enabled: canManageBilling }),
|
||||
)
|
||||
const billingUrl = billing?.url
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PlanComp loc="billing-page" />
|
||||
{enableBilling && isCurrentWorkspaceManager && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3"
|
||||
onClick={handleOpenBilling}
|
||||
disabled={isFetching}
|
||||
{canManageBilling && (
|
||||
<a
|
||||
className={cn(
|
||||
'mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3 outline-hidden',
|
||||
billingUrl &&
|
||||
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
href={billingUrl}
|
||||
target={billingUrl ? '_blank' : undefined}
|
||||
rel={billingUrl ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 text-left">
|
||||
<div className="system-md-semibold text-text-primary">
|
||||
@@ -55,13 +42,13 @@ const Billing: FC = () => {
|
||||
{t(($) => $.viewBillingDescription, { ns: 'billing' })}
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] backdrop-blur-[5px]">
|
||||
<span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] inset-ring-[0.5px] inset-ring-components-button-secondary-border backdrop-blur-[5px]">
|
||||
<span className="system-sm-medium leading-none">
|
||||
{t(($) => $.viewBillingAction, { ns: 'billing' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line size-4" />
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useCallback } from 'react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { useBindPartnerStackInfo } from '@/service/use-billing'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const usePSInfo = () => {
|
||||
const searchParams = useSearchParams()
|
||||
@@ -20,7 +21,9 @@ const usePSInfo = () => {
|
||||
const isPSChanged =
|
||||
psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId
|
||||
const [hasBind, { setTrue: setBind }] = useBoolean(false)
|
||||
const { mutateAsync } = useBindPartnerStackInfo()
|
||||
const { mutateAsync: bindPartnerStackInfo } = useMutation(
|
||||
consoleQuery.billing.partners.byPartnerKey.tenants.put.mutationOptions(),
|
||||
)
|
||||
// Save to top domain. cloud.dify.ai => .dify.ai
|
||||
const domain = globalThis.location?.hostname.replace('cloud', '')
|
||||
|
||||
@@ -45,9 +48,9 @@ const usePSInfo = () => {
|
||||
if (psPartnerKey && psClickId && !hasBind) {
|
||||
let shouldRemoveCookie = false
|
||||
try {
|
||||
await mutateAsync({
|
||||
partnerKey: psPartnerKey,
|
||||
clickId: psClickId,
|
||||
await bindPartnerStackInfo({
|
||||
params: { partner_key: psPartnerKey },
|
||||
body: { click_id: psClickId },
|
||||
})
|
||||
shouldRemoveCookie = true
|
||||
} catch (error: unknown) {
|
||||
@@ -56,7 +59,7 @@ const usePSInfo = () => {
|
||||
if (shouldRemoveCookie) Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
|
||||
setBind()
|
||||
}
|
||||
}, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync])
|
||||
}, [psPartnerKey, psClickId, hasBind, domain, setBind, bindPartnerStackInfo])
|
||||
return {
|
||||
psPartnerKey,
|
||||
psClickId,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
|
||||
export const useBindPartnerStackInfo = () => {
|
||||
return useMutation({
|
||||
mutationKey: consoleQuery.billing.partners.byPartnerKey.tenants.put.mutationKey(),
|
||||
mutationFn: (data: { partnerKey: string; clickId: string }) =>
|
||||
consoleClient.billing.partners.byPartnerKey.tenants.put({
|
||||
params: { partner_key: data.partnerKey },
|
||||
body: { click_id: data.clickId },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const useBillingUrl = (enabled: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: consoleQuery.billing.invoices.get.queryKey(),
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const res = await consoleClient.billing.invoices.get()
|
||||
return res.url
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user