<!-- start pr-codex -->
## PR-Codex overview
This PR updates loading states in various components to use `isPending` instead of `isLoading`.
### Detailed summary
- Replaced `isLoading` with `isPending` in multiple components for loading state handling.
> The following files were skipped due to too many changes: `apps/dashboard/src/contract-ui/tabs/manage/components/InstalledModulesTable.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayCustomers.ts`, `apps/dashboard/src/components/pay/PayConfig.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/server/chain-header.tsx`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/add-partner-form.client.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx`, `apps/dashboard/src/contract-ui/tabs/split/components/distribute-button.tsx`, `apps/dashboard/src/components/onboarding/LinkWallet.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx`, `apps/dashboard/package.json`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayPurchases.ts`, `apps/dashboard/src/components/engine/configuration/local-config.tsx.tsx`, `apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/server/partners-table.tsx`, `apps/dashboard/src/contract-ui/components/solidity-inputs/string-input.tsx`, `apps/dashboard/src/components/settings/Account/Billing/index.tsx`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/update-partner-form.client.tsx`, `apps/dashboard/src/contract-ui/tabs/nfts/page.tsx`, `apps/dashboard/src/components/configure-networks/Form/IconUpload.tsx`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/create/hooks/use-create-ecosystem.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/create/components/client/create-ecosystem-form.client.tsx`, `apps/dashboard/src/components/engine/engine-instances-table.tsx`, `apps/dashboard/src/components/engine/contract-subscription/contract-subscriptions-table.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx`, `apps/dashboard/src/pages/api/revalidate/publish.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-update-ecosystem.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-add-partner.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-delete-partner.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-update-partner.ts`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/live-stats.tsx`, `apps/dashboard/src/contract-ui/tabs/shared-components/marketplace-table.tsx`, `apps/dashboard/src/@3rdweb-sdk/react/hooks/query/useQueryWithNetwork.ts`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/pay/components/webhooks.client.tsx`, `apps/dashboard/src/components/contract-components/tables/deployed-contracts.tsx`, `apps/dashboard/src/contract-ui/tabs/proposals/components/proposal.tsx`, `apps/dashboard/src/components/app-layouts/app.tsx`, `apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/integration-permissions-toggle.client.tsx`, `apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx`, `apps/dashboard/src/components/cmd-k-search/index.tsx`, `apps/dashboard/src/@3rdweb-sdk/react/hooks/useLoggedInUser.ts`, `apps/dashboard/src/components/contract-components/hooks.ts`, `apps/dashboard/src/@3rdweb-sdk/react/hooks/useRegistry.ts`, `apps/dashboard/src/contract-ui/tabs/manage/components/ModuleForm.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx`, `apps/dashboard/src/components/storage/your-files.tsx`, `apps/dashboard/src/components/contract-functions/interactive-abi-function.tsx`, `apps/dashboard/src/components/ipfs-upload/dropzone.tsx`, `apps/dashboard/src/core-ui/nft-drawer/useNftDrawerTabs.tsx`, `apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts`, `pnpm-lock.yaml`, `apps/dashboard/src/contract-ui/hooks/useRouteConfig.tsx`, `apps/dashboard/src/@3rdweb-sdk/react/hooks/useEngine.ts`
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import {
|
|
AccountStatus,
|
|
useAccount,
|
|
useAccountUsage,
|
|
} from "@3rdweb-sdk/react/hooks/useApi";
|
|
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertIcon,
|
|
AlertTitle,
|
|
Flex,
|
|
IconButton,
|
|
Stack,
|
|
useDisclosure,
|
|
} from "@chakra-ui/react";
|
|
import { OnboardingModal } from "components/onboarding/Modal";
|
|
import { format } from "date-fns";
|
|
import { useTrack } from "hooks/analytics/useTrack";
|
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
|
import { useRouter } from "next/router";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { FiX } from "react-icons/fi";
|
|
import { Heading, Text, TrackedLinkButton } from "tw-components";
|
|
import { LazyOnboardingBilling } from "../../../../onboarding/LazyOnboardingBilling";
|
|
import { ManageBillingButton } from "../ManageButton";
|
|
import { RecurringPaymentFailureAlert } from "./RecurringPaymentFailureAlert";
|
|
|
|
type AlertConditionType = {
|
|
shouldShowAlert: boolean;
|
|
key: string;
|
|
title: string;
|
|
description: string;
|
|
status: "error" | "warning";
|
|
componentType:
|
|
| "serviceCutoff"
|
|
| "recurringPayment"
|
|
| "paymentVerification"
|
|
| "usage";
|
|
};
|
|
|
|
export const BillingAlerts = () => {
|
|
const { isLoggedIn } = useLoggedInUser();
|
|
const usageQuery = useAccountUsage();
|
|
const meQuery = useAccount({
|
|
refetchInterval: (account) =>
|
|
[
|
|
AccountStatus.InvalidPayment,
|
|
AccountStatus.InvalidPaymentMethod,
|
|
AccountStatus.PaymentVerification,
|
|
].includes(account.state.data?.status as AccountStatus)
|
|
? 1000
|
|
: false,
|
|
});
|
|
const { data: account } = meQuery;
|
|
const router = useRouter();
|
|
const trackEvent = useTrack();
|
|
|
|
const [dismissedAlerts, setDismissedAlerts] = useLocalStorage<
|
|
Record<string, number> | undefined
|
|
>(`dismissed-billing-alert-${account?.id}`, undefined, {});
|
|
|
|
const handleDismiss = useCallback(
|
|
(key: string) => {
|
|
const currentDismissedAlerts = dismissedAlerts || {};
|
|
|
|
const updatedAlerts = {
|
|
...currentDismissedAlerts,
|
|
[key]: Date.now(),
|
|
};
|
|
|
|
setDismissedAlerts(updatedAlerts);
|
|
|
|
trackEvent({
|
|
category: "billing",
|
|
action: "dismiss_alert",
|
|
label: key,
|
|
});
|
|
},
|
|
[dismissedAlerts, setDismissedAlerts, trackEvent],
|
|
);
|
|
|
|
// Alert shouldShowAlerts based on the possible states of the account
|
|
const alertConditions = useMemo<AlertConditionType[]>(() => {
|
|
const hasUsageData = !!usageQuery.data;
|
|
const { usage, limits, rateLimitedAt } = usageQuery.data || {};
|
|
|
|
// Define alert shouldShowAlerts including the directly computed ones
|
|
const paymentFailureAlerts: AlertConditionType[] = [
|
|
{
|
|
shouldShowAlert: account?.status === AccountStatus.PaymentVerification,
|
|
key: "verifyPaymentAlert",
|
|
title: "Your payment method requires verification",
|
|
description:
|
|
"Please verify your payment method to continue using our services without interruption.",
|
|
status: "error",
|
|
componentType: "paymentVerification",
|
|
},
|
|
{
|
|
shouldShowAlert: account?.status === AccountStatus.InvalidPaymentMethod,
|
|
key: "invalidPaymentMethodAlert",
|
|
title: "Your payment method is invalid",
|
|
description:
|
|
"Your current payment method is invalid. Please update your payment method to continue using our services.",
|
|
status: "error",
|
|
componentType: "paymentVerification",
|
|
},
|
|
...(account?.recurringPaymentFailures?.map((failure) => {
|
|
const serviceCutoffDate = failure.serviceCutoffDate
|
|
? format(new Date(failure.serviceCutoffDate), "MMMM d, yyyy")
|
|
: null;
|
|
|
|
const isPassedCutoff = new Date(failure.serviceCutoffDate) < new Date();
|
|
return {
|
|
shouldShowAlert: true,
|
|
key: failure.paymentFailureCode,
|
|
title: isPassedCutoff
|
|
? "Some of your services have been cancelled due to outstanding payments"
|
|
: "Your payment method failed",
|
|
description: failure.subscriptionDescription
|
|
? `${failure.subscriptionDescription} ${serviceCutoffDate ? `(due by ${serviceCutoffDate})` : ""}`
|
|
: "",
|
|
status: "error",
|
|
componentType: isPassedCutoff ? "serviceCutoff" : "recurringPayment",
|
|
} satisfies AlertConditionType;
|
|
}) ?? []),
|
|
];
|
|
|
|
// Directly compute usage and rate limit shouldShowAlerts within useMemo
|
|
const exceededUsage_50 =
|
|
hasUsageData &&
|
|
usage &&
|
|
limits &&
|
|
(usage.embeddedWallets.countWalletAddresses >=
|
|
limits.embeddedWallets / 2 ||
|
|
usage.storage.sumFileSizeBytes >= limits.storage / 2);
|
|
|
|
const exceededUsage_100 =
|
|
hasUsageData &&
|
|
usage &&
|
|
limits &&
|
|
(usage.embeddedWallets.countWalletAddresses >= limits.embeddedWallets ||
|
|
usage.storage.sumFileSizeBytes >= limits.storage);
|
|
|
|
const hasHardLimits = account?.status !== AccountStatus.ValidPayment;
|
|
const isFreePlan = account?.plan === "free";
|
|
const isGrowthPlan = account?.plan === "growth";
|
|
const usageAlerts: AlertConditionType[] = [
|
|
{
|
|
// Show alert if user has exceeded 50% of their usage limit and has not yet exceeded 100% of their usage limit and has hard limits
|
|
shouldShowAlert:
|
|
!!(exceededUsage_50 && !exceededUsage_100) && hasHardLimits,
|
|
key: "usage_50_alert",
|
|
title: "You are nearing your usage limit",
|
|
description:
|
|
"To prevent service interruptions, please add a valid payment method or upgrade your plan.",
|
|
status: "warning",
|
|
componentType: "usage",
|
|
},
|
|
{
|
|
// if the user has exceeded 100% of their usage limit and is has hard limits enforced
|
|
shouldShowAlert: !!exceededUsage_100 && hasHardLimits,
|
|
key: "usage_100_alert",
|
|
title: "You have exceeded your usage limit",
|
|
description:
|
|
"To continue using our services, add a valid payment method or upgrade your plan.",
|
|
status: "error",
|
|
componentType: "usage",
|
|
},
|
|
{
|
|
// if its NOT a free plan and the user has exceeded 100% of their usage limit
|
|
shouldShowAlert: !!exceededUsage_100 && !hasHardLimits,
|
|
key: "usage_100_alert",
|
|
title: "You have exceeded your usage limit",
|
|
// if free or growth plan, included the upgrade plan message
|
|
description: `Overages are now being charged.${isFreePlan || isGrowthPlan ? " Consider upgrading your plan to increase your included limits." : ""}`,
|
|
status: "warning",
|
|
componentType: "usage",
|
|
},
|
|
{
|
|
// only show RPC warning if the user has exceeded their RPC rate limit and has hard limits
|
|
shouldShowAlert: hasUsageData && !!rateLimitedAt?.rpc && hasHardLimits,
|
|
key: "rate_rpc_alert",
|
|
title: "You have exceeded your RPC rate limit",
|
|
description:
|
|
"To prevent service interruptions, add a valid payment method or upgrade your plan.",
|
|
status: "warning",
|
|
componentType: "usage",
|
|
},
|
|
{
|
|
// only show Storage warning if the user has exceeded their Storage rate limit and has hard limits
|
|
shouldShowAlert:
|
|
hasUsageData && !!rateLimitedAt?.storage && hasHardLimits,
|
|
key: "rate_storage_alert",
|
|
title: "You have exceeded your Storage Gateway rate limit",
|
|
description:
|
|
"To prevent service interruptions, add a valid payment method or upgrade your plan.",
|
|
status: "warning",
|
|
componentType: "usage",
|
|
},
|
|
];
|
|
|
|
return [...paymentFailureAlerts, ...usageAlerts];
|
|
}, [account, usageQuery.data]);
|
|
|
|
if (
|
|
!isLoggedIn ||
|
|
meQuery.isLoading ||
|
|
!account ||
|
|
usageQuery.isLoading ||
|
|
!usageQuery.data ||
|
|
router.pathname.includes("/support") ||
|
|
alertConditions.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const alerts = alertConditions
|
|
.map((alert, index) => {
|
|
const shouldShowAlert = alert.shouldShowAlert;
|
|
const isDismissed = alert.key in (dismissedAlerts ?? {});
|
|
const isDismissedMoreThanAWeekAgo =
|
|
(dismissedAlerts?.[alert.key] ?? 0) <
|
|
Date.now() - 1000 * 60 * 60 * 24 * 7;
|
|
if (shouldShowAlert && (!isDismissed || isDismissedMoreThanAWeekAgo)) {
|
|
switch (alert.componentType) {
|
|
case "recurringPayment": {
|
|
return (
|
|
<RecurringPaymentFailureAlert
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
|
key={index}
|
|
affectedServices={[alert.description].filter((v) => v)}
|
|
paymentFailureCode={alert.key}
|
|
/>
|
|
);
|
|
}
|
|
case "serviceCutoff": {
|
|
return (
|
|
<RecurringPaymentFailureAlert
|
|
isServiceCutoff={true}
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
|
key={index}
|
|
affectedServices={[alert.description].filter((v) => v)}
|
|
paymentFailureCode={alert.key}
|
|
/>
|
|
);
|
|
}
|
|
case "paymentVerification": {
|
|
return (
|
|
<BillingAlertNotification
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
|
key={index}
|
|
status={alert.status}
|
|
title={alert.title}
|
|
description={alert.description}
|
|
ctaText="Verify payment method"
|
|
ctaHref="/dashboard/settings/billing"
|
|
label="verifyPaymentAlert"
|
|
/>
|
|
);
|
|
}
|
|
case "usage": {
|
|
return (
|
|
<BillingAlertNotification
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
|
key={index}
|
|
status={alert.status}
|
|
onDismiss={() => handleDismiss(alert.key)}
|
|
title={alert.title}
|
|
description={alert.description}
|
|
ctaText="Upgrade your plan"
|
|
ctaHref="/dashboard/settings/billing"
|
|
label="upgradePlanAlert"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
})
|
|
.filter((v) => !!v);
|
|
|
|
if (alerts.length === 0) {
|
|
return null;
|
|
}
|
|
return <Stack className="py-6">{alerts}</Stack>;
|
|
};
|
|
|
|
type BillingAlertNotificationProps = {
|
|
status: "error" | "warning";
|
|
onDismiss?: () => void;
|
|
title: string;
|
|
description?: string;
|
|
showCTAs?: boolean;
|
|
ctaText?: string;
|
|
ctaHref?: string;
|
|
label?: string;
|
|
};
|
|
|
|
const BillingAlertNotification: React.FC<BillingAlertNotificationProps> = ({
|
|
status,
|
|
onDismiss,
|
|
title,
|
|
description = "To ensure there are no future interruptions to your services, please add your payment method.",
|
|
ctaText = "Add a payment method",
|
|
ctaHref = "/dashboard/settings/billing",
|
|
label = "addPaymentAlert",
|
|
showCTAs = true,
|
|
}) => {
|
|
// TODO: We should find a way to move this deeper into the
|
|
// TODO: ManageBillingButton component and set an optional field to override
|
|
const [paymentMethodSaving, setPaymentMethodSaving] = useState(false);
|
|
const meQuery = useAccount();
|
|
const { data: account } = meQuery;
|
|
|
|
const {
|
|
onOpen: onPaymentMethodOpen,
|
|
onClose: onPaymentMethodClose,
|
|
isOpen: isPaymentMethodOpen,
|
|
} = useDisclosure();
|
|
|
|
const handlePaymentAdded = () => {
|
|
setPaymentMethodSaving(true);
|
|
onPaymentMethodClose();
|
|
};
|
|
|
|
const isBilling = ctaHref === "/dashboard/settings/billing";
|
|
|
|
return (
|
|
<Alert
|
|
status={status}
|
|
borderRadius="md"
|
|
as={Flex}
|
|
alignItems="start"
|
|
justifyContent="space-between"
|
|
variant="left-accent"
|
|
bg="backgroundCardHighlight"
|
|
>
|
|
<>
|
|
<OnboardingModal
|
|
isOpen={isPaymentMethodOpen}
|
|
onClose={onPaymentMethodClose}
|
|
>
|
|
<LazyOnboardingBilling
|
|
onSave={handlePaymentAdded}
|
|
onCancel={onPaymentMethodClose}
|
|
/>
|
|
</OnboardingModal>
|
|
</>
|
|
|
|
<Flex>
|
|
<AlertIcon boxSize={4} mt={1} ml={1} />
|
|
<Flex flexDir="column" pl={1}>
|
|
<AlertTitle>
|
|
<Heading as="span" size="subtitle.sm">
|
|
{title}
|
|
</Heading>
|
|
</AlertTitle>
|
|
<AlertDescription mb={2} as={Flex} direction="column">
|
|
<Text mb={showCTAs ? "4" : "0"}>{description}</Text>
|
|
{showCTAs && (
|
|
<Flex>
|
|
{isBilling && account ? (
|
|
<ManageBillingButton
|
|
account={account}
|
|
loading={paymentMethodSaving}
|
|
loadingText="Verifying payment method"
|
|
buttonProps={{ colorScheme: "primary" }}
|
|
onClick={onPaymentMethodOpen}
|
|
/>
|
|
) : (
|
|
<TrackedLinkButton
|
|
href={ctaHref}
|
|
category="billing"
|
|
label={label}
|
|
fontWeight="medium"
|
|
colorScheme="blue"
|
|
size="sm"
|
|
>
|
|
{ctaText}
|
|
</TrackedLinkButton>
|
|
)}
|
|
<TrackedLinkButton
|
|
ml="4"
|
|
variant="outline"
|
|
href="/support"
|
|
category="billing"
|
|
label="support"
|
|
color="blue.500"
|
|
fontSize="small"
|
|
isExternal
|
|
>
|
|
Contact Support
|
|
</TrackedLinkButton>
|
|
</Flex>
|
|
)}
|
|
</AlertDescription>
|
|
</Flex>
|
|
</Flex>
|
|
|
|
{onDismiss && (
|
|
<IconButton
|
|
size="xs"
|
|
aria-label="Close announcement"
|
|
icon={<FiX />}
|
|
color="bgBlack"
|
|
variant="ghost"
|
|
opacity={0.6}
|
|
_hover={{ opacity: 1 }}
|
|
onClick={onDismiss}
|
|
/>
|
|
)}
|
|
</Alert>
|
|
);
|
|
};
|