diff --git a/src/components/CreateFlexPlan/index.tsx b/src/components/CreateFlexPlan/index.tsx index 96c465a2..ba2ea691 100644 --- a/src/components/CreateFlexPlan/index.tsx +++ b/src/components/CreateFlexPlan/index.tsx @@ -2,33 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { AiOutlineInfoCircle } from 'react-icons/ai'; import { specialApiKeyName } from '@components/GetEndpoint'; -import { PriceQueriesChart } from '@components/IndexerDetails/PriceQueries'; import { ApproveContract } from '@components/ModalApproveToken'; import TokenTooltip from '@components/TokenTooltip/TokenTooltip'; import { useSQToken } from '@containers'; -import { SQT_TOKEN_ADDRESS, useAccount } from '@containers/Web3'; +import { useAccount } from '@containers/Web3'; import { useAddAllowance } from '@hooks/useAddAllowance'; import { GetUserApiKeys, - IGetHostingPlans, - IPostHostingPlansParams, + IGetUserSubscription, isConsumerHostError, useConsumerHostServices, } from '@hooks/useConsumerHostServices'; import { ProjectDetailsQuery } from '@hooks/useProjectFromQuery'; import { useSqtPrice } from '@hooks/useSqtPrice'; import { Steps, Typography } from '@subql/components'; -import { ProjectType as contractProjectType } from '@subql/contract-sdk'; -import { ProjectType } from '@subql/network-query'; import { formatSQT, useAsyncMemo, useGetDeploymentBoosterTotalAmountByDeploymentIdQuery } from '@subql/react-hooks'; import { parseError, TOKEN, tokenDecimals } from '@utils'; -import { Button, Checkbox, Divider, Form, InputNumber, Popover, Tooltip } from 'antd'; +import { Button, Checkbox, Divider, Form, InputNumber } from 'antd'; import BigNumberJs from 'bignumber.js'; import clsx from 'clsx'; -import { BigNumber } from 'ethers'; -import { formatUnits, parseEther } from 'ethers/lib/utils'; +import { parseEther } from 'ethers/lib/utils'; import { useWeb3Store } from 'src/stores'; @@ -38,49 +32,36 @@ interface IProps { project: Pick; deploymentId: string; prevApiKey?: GetUserApiKeys; - prevHostingPlan?: IGetHostingPlans; + prevSubscription?: IGetUserSubscription; onSuccess?: () => void; onBack?: () => void; } -const converFlexPlanPrice = (price: string) => { - return BigNumberJs(formatUnits(price, tokenDecimals[SQT_TOKEN_ADDRESS])).multipliedBy(1000); -}; - -const subqlProjectTypeToContractType = { - [ProjectType.LLM]: 0, // no for now - [ProjectType.RPC]: contractProjectType.RPC, - [ProjectType.SUBQUERY]: contractProjectType.SUBQUERY, - [ProjectType.SQ_DICT]: contractProjectType.SQ_DICT, - [ProjectType.SUBGRAPH]: contractProjectType.SUBGRAPH, -}; - // TODO: split the component to smaller components -const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, prevApiKey, onSuccess, onBack }) => { +const CreateFlexPlan: FC = ({ deploymentId, project, prevSubscription, prevApiKey, onSuccess, onBack }) => { const { address: account } = useAccount(); const { contracts } = useWeb3Store(); - const [form] = Form.useForm(); const [depositForm] = Form.useForm<{ amount: string }>(); const depositAmount = Form.useWatch('amount', depositForm); - const priceValue = Form.useWatch('price', form); - const maximumValue = Form.useWatch('maximum', form); const { consumerHostAllowance, consumerHostBalance, balance } = useSQToken(); const { addAllowance } = useAddAllowance(); const sqtPrice = useSqtPrice(); const mounted = useRef(false); const [currentStep, setCurrentStep] = React.useState(0); - const [selectedPlan, setSelectedPlan] = useState<'economy' | 'performance' | 'custom'>('custom'); const [nextBtnLoading, setNextBtnLoading] = useState(false); - const [displayTransactions, setDisplayTransactions] = useState<('allowance' | 'deposit' | 'createApiKey')[]>([]); + const [displayTransactions, setDisplayTransactions] = useState< + ('allowance' | 'deposit' | 'createApiKey' | 'subscribe')[] + >([]); const [transacitonNumbers, setTransactionNumbers] = useState<{ [key in string]: number }>({ allowance: 1, deposit: 2, createApiKey: 3, + subscribe: 4, }); - const [transactionStep, setTransactionStep] = useState<'allowance' | 'deposit' | 'createApiKey' | undefined>( - 'allowance', - ); + const [transactionStep, setTransactionStep] = useState< + 'allowance' | 'deposit' | 'createApiKey' | 'subscribe' | undefined + >('allowance'); const deploymentBooster = useGetDeploymentBoosterTotalAmountByDeploymentIdQuery({ variables: { @@ -92,34 +73,11 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr const [depositBalance] = useMemo(() => consumerHostBalance.result.data ?? [], [consumerHostBalance.result.data]); - const { - getProjects, - createNewApiKey, - createHostingPlanApi, - updateHostingPlanApi, - getUserApiKeysApi, - refreshUserInfo, - getChannelLimit, - getDominantPrice, - } = useConsumerHostServices({ - alert: true, - autoLogin: false, - }); - - const flexPlans = useAsyncMemo(async () => { - try { - const res = await getProjects({ - projectId: BigNumber.from(project.id).toString(), - deployment: deploymentId, - }); - - if (res.data?.indexers?.length) { - return res.data.indexers; - } - } catch (e) { - return []; - } - }, [project.id, deploymentId]); + const { createNewApiKey, createSubscription, getUserApiKeysApi, refreshUserInfo, getChannelLimit } = + useConsumerHostServices({ + alert: true, + autoLogin: false, + }); const estimatedChannelLimit = useAsyncMemo(async () => { try { @@ -153,70 +111,9 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr }, [estimatedChannelLimit]); const minDeposit = useMemo(() => { - // const sortedMinial = BigNumberJs(depositRequireFromConsumerHost).minus( - // formatSQT(depositBalance?.toString() || '0'), - // ); - // return sortedMinial.lte(0) ? 0 : sortedMinial.toNumber(); return 10000; }, [depositRequireFromConsumerHost, depositBalance]); - const estimatedPriceInfo = useMemo(() => { - if (!flexPlans.data || flexPlans.data.length === 0) { - return { - economy: BigNumberJs(0), - performance: BigNumberJs(0), - }; - } - - // ASC - const sortedFlexPlans = flexPlans.data.map((i) => converFlexPlanPrice(i.price)).sort((a, b) => (a.lt(b) ? -1 : 1)); - const maxPrice = sortedFlexPlans.at(-1); - - // if less than 3, both economy and performance should be the highest price - if (flexPlans.data?.length <= 3) { - return { - economy: maxPrice, - performance: maxPrice, - }; - } - - if (flexPlans.data?.length <= 5) { - return { - economy: sortedFlexPlans[2], - performance: maxPrice, - }; - } - - const economyIndex = Math.ceil(flexPlans.data.length * 0.4) < 2 ? 2 : Math.ceil(flexPlans.data.length * 0.4); - const performanceIndex = Math.ceil(flexPlans.data.length * 0.8) < 4 ? 4 : Math.ceil(flexPlans.data.length * 0.8); - - return { - economy: sortedFlexPlans[economyIndex], - performance: sortedFlexPlans[performanceIndex], - }; - }, [flexPlans]); - - const matchedCount = React.useMemo(() => { - if (!priceValue || !flexPlans.data?.length) return `Matched Node Operators: 0`; - const count = flexPlans.data.filter((i) => { - const prices1000 = converFlexPlanPrice(i.price); - return prices1000.lte(priceValue); - }).length; - return `Matched Node Operators: ${count}`; - }, [priceValue, flexPlans]); - - const enoughReq = useMemo(() => { - const priceVal = priceValue || (form.getFieldsValue(true)['price'] as string); - if (!priceVal || depositBalance?.eq(0) || !depositBalance) return 0; - - return BigNumberJs(formatSQT(depositBalance.toString())) - .div(BigNumberJs(priceVal.toString())) - .multipliedBy(1000) - .decimalPlaces(0) - ?.toNumber() - ?.toLocaleString(); - }, [depositBalance, priceValue, form, currentStep]); - const needAddAllowance = useMemo(() => { const allowance = consumerHostAllowance.result.data; if (allowance?.eq(0) && depositAmount && depositAmount !== 0) return true; @@ -230,35 +127,30 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr const needCreateApiKey = useMemo(() => !prevApiKey, [prevApiKey]); + const needSubscribe = useMemo(() => !prevSubscription, [prevSubscription]); + const nextBtnText = useMemo(() => { if (currentStep === 0) return 'Next'; - if (currentStep === 1) return 'Deposit SQT'; - - if (currentStep === 2) { - if (!displayTransactions.length) return 'Create Flex Plan'; + if (currentStep === 1) { + if (!displayTransactions.length) return 'Subscribe to Project'; if (transactionStep) { const currentStepNumber = transacitonNumbers[transactionStep]; return `Approve Transaction ${currentStepNumber}${ - currentStepNumber === displayTransactions.length ? ' and Create Flex Plan' : '' + currentStepNumber === displayTransactions.length ? ' and Subscribe' : '' }`; } - return 'Create Flex Plan'; + return 'Subscribe to Project'; } return 'Next'; }, [currentStep, displayTransactions, transacitonNumbers, transactionStep]); const suggestDeposit = useMemo(() => { - const inputEstimated = BigNumberJs(priceValue || '0') - .multipliedBy(20) - .multipliedBy(maximumValue || 2); - - if (inputEstimated.lt(depositRequireFromConsumerHost)) return depositRequireFromConsumerHost.toLocaleString(); - return inputEstimated.toNumber().toLocaleString(); - }, [depositRequireFromConsumerHost, priceValue, maximumValue]); + return depositRequireFromConsumerHost.toLocaleString(); + }, [depositRequireFromConsumerHost]); const renderTransactionDisplay = useMemo(() => { const allowanceDom = (index: number) => { @@ -279,7 +171,7 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr This grants permission for SubQuery to manage your Billing Account automatically to pay node operators for - charges incurred in this new Flex Plan + charges incurred in this Flex Plan @@ -329,7 +221,31 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr This is a transaction to open a state channel and generate a personal API key for your account to secure - your new Flex Plan endpoint + your Flex Plan endpoint + + + + ); + }; + + const subscribeDom = (index: number) => { + return ( +
+
+ + {!needSubscribe && } + {index}. Subscribe to Project + + + Subscribe to this project to get access to the Flex Plan endpoint
@@ -340,40 +256,24 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr allowance: allowanceDom, deposit: depositDom, createApiKey: createApiKeysDom, + subscribe: subscribeDom, }; return displayTransactions.map((i, index) => { return dicts[i](index + 1); }); - }, [displayTransactions, transactionStep, needCreateApiKey, needAddAllowance, needDepositMore, depositForm]); - - const suggestHostingPlanPrice = useAsyncMemo(async () => { - try { - const price = await getDominantPrice({ - ptype: subqlProjectTypeToContractType[project.type], - }); - - return formatSQT(BigNumberJs(price.data.avg_price).multipliedBy(1000).toString()); - } catch { - return ''; - } - }, [project.type]); + }, [ + displayTransactions, + transactionStep, + needCreateApiKey, + needAddAllowance, + needDepositMore, + needSubscribe, + depositForm, + ]); const handleNextStep = async (options?: { skipDeposit?: boolean }) => { if (currentStep === 0) { - if (!selectedPlan) return; - if (selectedPlan !== 'custom') { - form.setFieldValue('price', estimatedPriceInfo[selectedPlan]?.toString()); - form.setFieldValue('maximum', selectedPlan === 'economy' ? 8 : 15); - } else { - await form.validateFields(); - form.setFieldValue('maximum', form.getFieldValue('maximum') || 2); - } - - setCurrentStep(1); - } - - if (currentStep === 1) { const { skipDeposit = false } = options || {}; if (!skipDeposit) { await depositForm.validateFields(); @@ -392,15 +292,19 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr if (needCreateApiKey) { newDisplayTransactions.push('createApiKey'); } + if (needSubscribe) { + newDisplayTransactions.push('subscribe'); + } if (newDisplayTransactions.includes('allowance')) { setTransactionStep('allowance'); } else if (newDisplayTransactions.includes('deposit')) { setTransactionStep('deposit'); - } else { + } else if (newDisplayTransactions.includes('createApiKey')) { setTransactionStep('createApiKey'); + } else { + setTransactionStep('subscribe'); } - // TODO: make a enum // @ts-ignore setDisplayTransactions(newDisplayTransactions); @@ -413,14 +317,15 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr {} as { [key in string]: number }, ), ); - setCurrentStep(2); + setCurrentStep(1); } - if (currentStep === 2) { + if (currentStep === 1) { setNextBtnLoading(true); - const getNextStepAndSet = (transactionName: 'allowance' | 'deposit' | 'createApiKeys') => { + const getNextStepAndSet = (transactionName: 'allowance' | 'deposit' | 'createApiKey' | 'subscribe') => { const index = displayTransactions.findIndex((i) => i === transactionName) + 1; if (index < displayTransactions.length) { + // @ts-ignore setTransactionStep(displayTransactions[index]); } else { setTransactionStep(undefined); @@ -471,7 +376,28 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr } } - getNextStepAndSet('createApiKeys'); + getNextStepAndSet('createApiKey'); + + const currentStepNumber = transacitonNumbers['createApiKey']; + + if (currentStepNumber !== displayTransactions.length) { + return; + } + } + + if (needSubscribe) { + setTransactionStep('subscribe'); + + const projectIdNumber = parseInt(project.id.replace('0x', ''), 16); + const subscriptionRes = await createSubscription({ + project_id: projectIdNumber, + }); + + if (isConsumerHostError(subscriptionRes.data)) { + throw new Error(subscriptionRes.data.error); + } + + getNextStepAndSet('subscribe'); } try { @@ -480,29 +406,6 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr // don't care of this } - const price = form.getFieldsValue(true)['price']; - const maximum = form.getFieldsValue(true)['maximum']; - - const createOrUpdate = prevHostingPlan ? updateHostingPlanApi : createHostingPlanApi; - // if already created the plan, just update it. - const minExpiration = estimatedChannelLimit?.data?.channelMinExpiration || 3600 * 24 * 14; - const expiration = flexPlans?.data?.sort((a, b) => b.max_time - a.max_time)[0].max_time || 0; - const maximumValue = - (estimatedChannelLimit.data?.channelMaxNum || 0) < Math.ceil(maximum) - ? estimatedChannelLimit.data?.channelMaxNum - : Math.ceil(maximum); - const res = await createOrUpdate({ - deploymentId: deploymentId, - price: parseEther(`${price}`).div(1000).toString(), - maximum: maximumValue || 2, - expiration: expiration < minExpiration ? minExpiration : expiration, - id: prevHostingPlan?.id || '0', - }); - - if (isConsumerHostError(res.data)) { - throw new Error(res.data.error); - } - await onSuccess?.(); } catch (e) { parseError(e, { @@ -536,9 +439,6 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr = ({ deploymentId, project, prevHostingPlan, pr ]} > - {currentStep === 0 && ( - <> - - SubQuery will automatically allocate qualified Node Operators to your endpoint based on price and - performance. Please select the type of plan you would like (you can change this later). - - -
{ - setSelectedPlan('custom'); - if (selectedPlan !== 'custom') { - form.resetFields(); - } - }} - > - Enter a custom price - {selectedPlan === 'custom' && ( - <> - - Please enter a custom price, and an optional limit - - -
- - Maximum Price - - - - - - - The average price of per 1000 requests is {suggestHostingPlanPrice.data} {TOKEN}. - { - form.setFieldsValue({ - price: suggestHostingPlanPrice.data, - }); - }} - > - Set it - - -
- ) : ( - '' - ) - } - placement="topLeft" - > - - - - - - {matchedCount} - - - - - - )} - - - )} - - {/* need the Form element render, so can trace the state */} -
+
Every wallet has a Billing Account where you must deposit SQT that you authorise SubQuery to deduct for Flex Plan payments. If this Billing Account runs out of SQT, your Flex plan will automatically be cancelled and @@ -640,36 +458,17 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr You can easily withdraw unused SQT from this Billing Account at any time without any unlocking period. - We recommend ensuring that there is sufficient SQT in your billing account so that you don’t run out + We recommend ensuring that there is sufficient SQT in your billing account so that you don't run out unexpectedly. -
-
- Your selected plan: - - - {selectedPlan} - -
-
- - {form.getFieldValue('price')} {TOKEN} - - Per 1000 reqs - {sqtPrice !== '0' && (~US${estimatedUs(form.getFieldValue('price'))})} -
-
{depositBalance?.eq(0) || !depositBalance ? ( <> You must deposit SQT to open this billing account - You must deposit SQT to create this flex plan, we suggest {suggestDeposit} {TOKEN} + You must deposit SQT to subscribe to this project, we suggest {suggestDeposit} {TOKEN} ) : ( @@ -682,7 +481,7 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr {TOKEN} - This is enough to pay for {enoughReq} requests, we suggest {suggestDeposit} {TOKEN} + We suggest depositing at least {suggestDeposit} {TOKEN} )} @@ -742,13 +541,13 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr
- {currentStep === 2 && ( + {currentStep === 1 && ( <> {displayTransactions.length ? ( You must now approve {displayTransactions.length > 1 ? 'a few transactions' : 'a transaction'} using your - connected wallet to initiate this Flex Plan. You must approve all transactions if in order to create a - Flex Plan + connected wallet to subscribe to this project. You must approve all transactions in order to complete the + subscription. ) : ( '' @@ -777,7 +576,7 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr Back - {currentStep === 1 && + {currentStep === 0 && !BigNumberJs( deploymentBooster.data?.deploymentBoosterSummariesByConsumer?.aggregates?.sum?.totalAmount.toString() || '0', ).isZero() ? ( @@ -796,13 +595,7 @@ const CreateFlexPlan: FC = ({ deploymentId, project, prevHostingPlan, pr ) : ( '' )} -
diff --git a/src/components/DeploymentInfo/DeploymentInfo.tsx b/src/components/DeploymentInfo/DeploymentInfo.tsx index fcb0f3f3..061491c0 100644 --- a/src/components/DeploymentInfo/DeploymentInfo.tsx +++ b/src/components/DeploymentInfo/DeploymentInfo.tsx @@ -66,6 +66,11 @@ export const DeploymentInfo: React.FC = ({ project, deploymentId, type, m onClick={() => { onClick && onClick(); }} + style={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }} >
{project?.name && ( @@ -88,19 +93,19 @@ export const DeploymentInfo: React.FC = ({ project, deploymentId, type, m
)} -
- - {versionHeader} - - - - - {deploymentId - ? `${deploymentId.slice(0, 5)}...${deploymentId.slice(deploymentId.length - 5, deploymentId.length)}` - : '-'} + {deploymentId && ( +
+ + {versionHeader} - -
+ + + + {`${deploymentId.slice(0, 5)}...${deploymentId.slice(deploymentId.length - 5, deploymentId.length)}`} + + +
+ )} diff --git a/src/components/GetEndpoint/index.tsx b/src/components/GetEndpoint/index.tsx index c4ee1c13..99e95d12 100644 --- a/src/components/GetEndpoint/index.tsx +++ b/src/components/GetEndpoint/index.tsx @@ -10,7 +10,9 @@ import { useAccount } from '@containers/Web3'; import { GetUserApiKeys, IGetHostingPlans, + IGetUserSubscription, isConsumerHostError, + isNotSubscribed, useConsumerHostServices, } from '@hooks/useConsumerHostServices'; import { ProjectDetailsQuery } from '@hooks/useProjectFromQuery'; @@ -84,17 +86,18 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen const [freeOrFlexPlan, setFreeOrFlexPlan] = React.useState<'free' | 'flexPlan'>('flexPlan'); const [nextBtnLoading, setNextBtnLoading] = useState(false); - const [userHostingPlan, setUserHostingPlan] = useState([]); + const [currentSubscription, setCurrentSubscription] = useState(null); const [userApiKeys, setUserApiKeys] = useState([]); - const { getHostingPlanApi, checkIfHasLogin, getUserApiKeysApi, createNewApiKey } = useConsumerHostServices({ - alert: false, - autoLogin: false, - }); + const { getUserSubscriptionByProject, createSubscription, checkIfHasLogin, getUserApiKeysApi, createNewApiKey } = + useConsumerHostServices({ + alert: false, + autoLogin: false, + }); - const createdHostingPlan = useMemo(() => { - return userHostingPlan.find((plan) => plan.deployment.deployment === deploymentId && plan.is_actived); - }, [userHostingPlan]); + const hasActiveSubscription = useMemo(() => { + return currentSubscription?.is_active || false; + }, [currentSubscription]); const createdApiKey = useMemo(() => { return userApiKeys.find((key) => key.name === specialApiKeyName); @@ -104,16 +107,16 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen if (currentStep === 'select') { if (freeOrFlexPlan === 'free') return 'View Free Public Endpoint'; if (freeOrFlexPlan === 'flexPlan') { - if (createdHostingPlan) { + if (hasActiveSubscription) { return 'View Flex Plan Endpoint'; } - return 'Create Flex Plan'; + return 'Subscribe to Project'; } } if (currentStep === 'checkFree' || currentStep === 'checkEndpointWithApiKey') return 'Copy endpoint and Close'; - return 'Create Flex Plan'; - }, [freeOrFlexPlan, currentStep, createdHostingPlan]); + return 'Subscribe to Project'; + }, [freeOrFlexPlan, currentStep, hasActiveSubscription]); const httpEndpointWithApiKey = useMemo(() => { return getHttpEndpointWithApiKey(deploymentId, createdApiKey?.value || ''); @@ -263,13 +266,12 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen checkFree: makeEndpointResult(sponsoredProjects[project.id], true), createFlexPlan: ( { await checkIfHasLogin(); - await fetchHostingPlanAndApiKeys(); + await fetchSubscriptionAndApiKeys(); setCurrentStep('checkEndpointWithApiKey'); }} onBack={() => { @@ -285,37 +287,46 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen }[currentStep]; }, [freeOrFlexPlan, project, currentStep, deploymentId, account, httpEndpointWithApiKey, wsEndpointWithApiKey]); - const fetchHostingPlan = async () => { + const fetchSubscription = async () => { try { setNextBtnLoading(true); - const hostingPlan = await getHostingPlanApi({ - account, - }); + const projectIdNumber = parseInt(project.id.replace('0x', ''), 16); + const subscriptionRes = await getUserSubscriptionByProject(projectIdNumber); - if (!isConsumerHostError(hostingPlan.data)) { - setUserHostingPlan(hostingPlan.data); + if (!isConsumerHostError(subscriptionRes.data)) { + if (isNotSubscribed(subscriptionRes.data)) { + // No subscription found, create new subscription + const newSubscription = await createSubscription({ project_id: projectIdNumber }); - // no hosting plan then skip fetch api key, - if (!hostingPlan.data.find((i) => i.deployment.deployment === deploymentId && i.is_actived)) - return { - data: [], - }; + if (!isConsumerHostError(newSubscription.data)) { + setCurrentSubscription(newSubscription.data); + return { data: newSubscription.data }; + } else { + setCurrentSubscription(null); + return { data: null }; + } + } else { + // Subscription already exists + setCurrentSubscription(subscriptionRes.data); + return { data: subscriptionRes.data }; + } } else { - return { - data: [], - }; + setCurrentSubscription(null); + return { data: null }; } - - return hostingPlan; + } catch (e) { + parseError(e, { alert: true }); + setCurrentSubscription(null); + return { data: null }; } finally { setNextBtnLoading(false); } }; - const fetchHostingPlanAndApiKeys = async () => { + const fetchSubscriptionAndApiKeys = async () => { try { setNextBtnLoading(true); - const hostingPlan = await fetchHostingPlan(); + const subscription = await fetchSubscription(); let apiKeys = await getUserApiKeysApi(); if (!isConsumerHostError(apiKeys.data)) { @@ -331,7 +342,7 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen } } return { - hostingPlan, + subscription, apiKeys, }; } catch (e) { @@ -351,13 +362,13 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen if (freeOrFlexPlan === 'free') { setCurrentStep('checkFree'); } else { - const fetched = await fetchHostingPlanAndApiKeys(); + const fetched = await fetchSubscriptionAndApiKeys(); if (fetched) { - if (!isConsumerHostError(fetched.hostingPlan.data) && !isConsumerHostError(fetched.apiKeys.data)) { + if (fetched.subscription.data && !isConsumerHostError(fetched.apiKeys.data)) { if ( fetched.apiKeys?.data.find((key) => key.name === specialApiKeyName) && - fetched.hostingPlan?.data.find((plan) => plan.deployment.deployment === deploymentId && plan.is_actived) + fetched.subscription.data.is_active ) { setCurrentStep('checkEndpointWithApiKey'); } else { @@ -390,7 +401,7 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen const resetAllField = () => { setCurrentStep('select'); setFreeOrFlexPlan('flexPlan'); - setUserHostingPlan([]); + setCurrentSubscription(null); setUserApiKeys([]); beforeStep.current = 'select'; }; @@ -399,7 +410,7 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen useEffect(() => { if (account && open) { resetAllField(); - fetchHostingPlanAndApiKeys(); + fetchSubscriptionAndApiKeys(); } }, [account]); @@ -413,7 +424,7 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen setFreeOrFlexPlan('flexPlan'); await handleNextStep(); } - await fetchHostingPlan(); + await fetchSubscription(); setOpen(true); }} > @@ -430,7 +441,7 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen setFreeOrFlexPlan('flexPlan'); await handleNextStep(); } - await fetchHostingPlan(); + await fetchSubscription(); setOpen(true); }} > @@ -447,7 +458,6 @@ const GetEndpoint: FC = ({ deploymentId, project, actionBtn, initialOpen }} className={account ? '' : 'hideModalWrapper'} footer={ - // it's kind of chaos, but I don't want to handle the action out of the component. currentStep !== 'createFlexPlan' ? (
{currentStep !== 'select' && ( diff --git a/src/hooks/useConsumerHostServices.tsx b/src/hooks/useConsumerHostServices.tsx index 5ca52daa..196c1da8 100644 --- a/src/hooks/useConsumerHostServices.tsx +++ b/src/hooks/useConsumerHostServices.tsx @@ -450,6 +450,76 @@ export const useConsumerHostServices = ( return res; }, []); + const getUserSubscriptions = useCallback(async (): Promise< + AxiosResponse + > => { + const res = await instance.get('/users/subscriptions', { + headers: authHeaders.current, + }); + + return res; + }, []); + + // 新增: 创建订阅 + const createSubscription = useCallback( + async (params: { project_id: number }): Promise> => { + const res = await instance.post('/users/subscriptions', params, { + headers: authHeaders.current, + }); + + return res; + }, + [], + ); + + // New: Unsubscribe from a project + const unsubscribeProject = useCallback( + async (projectId: number): Promise> => { + const res = await instance.post<{ success: boolean; message: string } | ConsumerHostError>( + `/users/subscriptions/${projectId}/unsubscribe`, + {}, + { + headers: authHeaders.current, + }, + ); + + return res; + }, + [], + ); + + // Get user subscription for a specific project + const getUserSubscriptionByProject = useCallback( + async ( + projectId: number, + ): Promise> => { + const res = await instance.get( + `/users/subscriptions/${projectId}`, + { + headers: authHeaders.current, + }, + ); + + return res; + }, + [], + ); + + // Get user hosting plans for a specific project + const getUserHostingPlansByProject = useCallback( + async (projectId: number): Promise> => { + const res = await instance.get( + `/users/hosting-plans/project/${projectId}`, + { + headers: authHeaders.current, + }, + ); + + return res; + }, + [], + ); + useEffect(() => { checkIfHasLogin(); if (autoLogin) { @@ -469,6 +539,11 @@ export const useConsumerHostServices = ( getStatisticQueries: alertResDecorator(getStatisticQueries), getStatisticQueriesByPrice: alertResDecorator(getStatisticQueriesByPrice), getUserQueriesAggregation: alertResDecorator(getUserQueriesAggregation), + getUserSubscriptions: alertResDecorator(loginResDecorator(getUserSubscriptions)), + createSubscription: alertResDecorator(loginResDecorator(createSubscription)), + unsubscribeProject: alertResDecorator(loginResDecorator(unsubscribeProject)), + getUserSubscriptionByProject: alertResDecorator(loginResDecorator(getUserSubscriptionByProject)), + getUserHostingPlansByProject: alertResDecorator(loginResDecorator(getUserHostingPlansByProject)), getSpentInfo, getHostingPlanApi, getChannelLimit, @@ -604,6 +679,36 @@ export interface IIndexerFlexPlan { price_token: string; } +export interface IGetUserSubscription { + id: number; + user_id: number; + project_id: number; + auto_latest?: boolean; + max_versions?: number; + is_active: boolean; + created_at: string; + updated_at: string; + project?: { + id: number; + metadata: string; + }; +} + +// Return type when subscription is not found +export interface IGetUserSubscriptionNotFound { + subscribed: false; + project_id: number; + user_id: number; + message: string; +} + +// Type guard: checks if the user is not subscribed +export const isNotSubscribed = ( + res: IGetUserSubscription | IGetUserSubscriptionNotFound | ConsumerHostError, +): res is IGetUserSubscriptionNotFound => { + return 'subscribed' in res && res.subscribed === false; +}; + export type ConsumerHostError = | { code: '2000'; diff --git a/src/pages/consumer/MyFlexPlans/MyHostedPlan/MyHostedPlan.tsx b/src/pages/consumer/MyFlexPlans/MyHostedPlan/MyHostedPlan.tsx index ce8075d0..0d178903 100644 --- a/src/pages/consumer/MyFlexPlans/MyHostedPlan/MyHostedPlan.tsx +++ b/src/pages/consumer/MyFlexPlans/MyHostedPlan/MyHostedPlan.tsx @@ -6,11 +6,17 @@ import { AiOutlineCopy } from 'react-icons/ai'; import { LuArrowRightFromLine } from 'react-icons/lu'; import { useNavigate } from 'react-router'; import Copy from '@components/Copy'; +import { DeploymentMeta } from '@components/DeploymentInfo'; import { getHttpEndpointWithApiKey, getWsEndpointWithApiKey, specialApiKeyName } from '@components/GetEndpoint'; import { OutlineDot } from '@components/Icons/Icons'; import { useProjectMetadata } from '@containers'; import { useAccount } from '@containers/Web3'; -import { GetUserApiKeys, IGetHostingPlans, useConsumerHostServices } from '@hooks/useConsumerHostServices'; +import { + GetUserApiKeys, + IGetHostingPlans, + IGetUserSubscription, + useConsumerHostServices, +} from '@hooks/useConsumerHostServices'; import { isConsumerHostError } from '@hooks/useConsumerHostServices'; import CreateHostingFlexPlan, { CreateHostingFlexPlanRef, @@ -95,7 +101,9 @@ const MyHostedPlan: FC = () => { const navigate = useNavigate(); const { updateHostingPlanApi, - getHostingPlanApi, + getUserSubscriptions, + unsubscribeProject, + getUserHostingPlansByProject, loading: consumerHostLoading, } = useConsumerHostServices({ alert: true, @@ -111,64 +119,103 @@ const MyHostedPlan: FC = () => { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [fetchConnectLoading, setFetchConnectLoading] = useState(false); - const [createdHostingPlan, setCreatedHostingPlan] = useState<(IGetHostingPlans & { projectName: string | number })[]>( - [], - ); + const [expandLoading, setExpandLoading] = useState(false); + const [subscriptions, setSubscriptions] = useState([]); + const [hostingPlansMap, setHostingPlansMap] = useState< + Map + >(new Map()); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [currentEditInfo, setCurrentEditInfo] = useState(); const { getMetadataFromCid } = useProjectMetadata(); const ref = useRef(null); - const init = async () => { + + const initSubscriptions = async () => { try { setLoading(true); + const res = await getUserSubscriptions(); + if (!isConsumerHostError(res.data)) { + setSubscriptions(res.data); + } else { + setSubscriptions([]); + } + } catch (e) { + setSubscriptions([]); + } finally { + setLoading(false); + } + }; - const res = await getHostingPlanApi({ - account, - }); - const allMetadata = await Promise.allSettled( - res.data.map((i) => { - const cid = i.project.metadata.startsWith('Qm') - ? i.project.metadata - : bytes32ToCid(`0x${i.project.metadata}`); - return getMetadataFromCid(cid); - }), - ); - setCreatedHostingPlan( - res.data.map((raw, index) => { + const fetchHostingPlans = async (projectId: number) => { + try { + setExpandLoading(true); + const res = await getUserHostingPlansByProject(projectId); + if (!isConsumerHostError(res.data)) { + const allMetadata = await Promise.allSettled( + res.data.map((i) => { + const cid = i.project.metadata.startsWith('Qm') + ? i.project.metadata + : bytes32ToCid(`0x${i.project.metadata}`); + return getMetadataFromCid(cid); + }), + ); + + const plansWithNames = res.data.map((raw, index) => { const result = allMetadata[index]; const name = result.status === 'fulfilled' ? result.value.name : raw.id; return { ...raw, projectName: name, }; - }), - ); + }); + + setHostingPlansMap((prev) => new Map(prev).set(projectId, plansWithNames)); + } } catch (e) { - setCreatedHostingPlan([]); + parseError(e, { alert: true }); } finally { - setLoading(false); + setExpandLoading(false); } }; - useEffect(() => { - if (account) { - init(); + const handleExpand = async (expanded: boolean, record: IGetUserSubscription) => { + if (expanded) { + setExpandedRowKeys((prev) => [...prev, record.project_id]); + if (!hostingPlansMap.has(record.project_id)) { + await fetchHostingPlans(record.project_id); + } + } else { + setExpandedRowKeys((prev) => prev.filter((key) => key !== record.project_id)); } - }, [account]); + }; - return ( -
+ const handleUnsubscribe = async (projectId: number) => { + try { + setLoading(true); + const res = await unsubscribeProject(projectId); + if (!isConsumerHostError(res.data)) { + message.success('Unsubscribed successfully'); + await initSubscriptions(); + setHostingPlansMap((prev) => { + const newMap = new Map(prev); + newMap.delete(projectId); + return newMap; + }); + } + } finally { + setLoading(false); + } + }; + + const expandedRowRender = (record: IGetUserSubscription) => { + const plans = hostingPlansMap.get(record.project_id) || []; + + return ( record.id} - style={{ marginTop: 40 }} - loading={loading || consumerHostLoading} - dataSource={createdHostingPlan} + dataSource={plans} + pagination={false} columns={[ { - title: 'Project', - dataIndex: 'projectName', - }, - { - width: 150, title: 'Plan', dataIndex: 'price', render: (val: string) => { @@ -209,7 +256,7 @@ const MyHostedPlan: FC = () => { fixed: 'right', dataIndex: 'spent', width: 50, - render: (_, record) => { + render: (_, planRecord) => { return (
+ /> + ); + }; + + useEffect(() => { + if (account) { + initSubscriptions(); + } + }, [account]); + + return ( +
+ record.project_id} + style={{ marginTop: 40 }} + loading={loading || consumerHostLoading || expandLoading} + dataSource={subscriptions} + expandable={{ + expandedRowRender, + expandedRowKeys, + onExpand: handleExpand, + }} + columns={[ + { + title: 'Project', + dataIndex: ['project', 'name'], + render: (name: string, record) => { + return ; + }, + }, + { + title: 'Auto Latest', + dataIndex: 'auto_latest', + render: (val?: boolean) => { + return {val ? 'Yes' : 'No'}; + }, + }, + { + title: 'Status', + dataIndex: 'is_active', + render: (val: boolean) => { + return {val ? 'Active' : 'Inactive'}; + }, + }, + { + title: 'Action', + fixed: 'right', + width: 120, + render: (_, record) => { + return ( + + ); + }, + }, + ]} + /> { id={`${currentEditInfo?.deployment.project_id || ''}`} deploymentId={`${currentEditInfo?.deployment.deployment || ''}`} editInformation={currentEditInfo} - onSubmit={() => init()} - > + onSubmit={async () => { + if (currentEditInfo) { + await fetchHostingPlans(currentEditInfo.deployment.project_id); + } + }} + />