diff --git a/src/api/billing.ts b/src/api/billing.ts index a5be30fc23..9224c8176a 100644 --- a/src/api/billing.ts +++ b/src/api/billing.ts @@ -2,42 +2,13 @@ import type { TenantPaymentDetails } from 'src/types'; import pLimit from 'p-limit'; -import { supabaseClient } from 'src/context/GlobalProviders'; -import { FUNCTIONS, invokeSupabase, TABLES } from 'src/services/supabase'; -import { formatDateForApi } from 'src/utils/billing-utils'; +import { FUNCTIONS, invokeSupabase } from 'src/services/supabase'; const OPERATIONS = { SETUP_INTENT: 'setup-intent', GET_TENANT_PAYMENT_METHODS: 'get-tenant-payment-methods', DELETE_TENANT_PAYMENT_METHODS: 'delete-tenant-payment-method', SET_PRIMARY: 'set-tenant-primary-payment-method', - GET_TENANT_INVOICE: 'get-tenant-invoice', -}; - -export interface StripeInvoice { - id: string; - amount_due: number; - invoice_pdf: string; - hosted_invoice_url: string; - status: 'open' | 'paid' | 'void' | 'uncollectable'; -} - -export const getTenantInvoice = ( - tenant: string, - date_start: string, - date_end: string, - type: 'manual' | 'final' -) => { - return invokeSupabase<{ invoice?: StripeInvoice | null }>( - FUNCTIONS.BILLING, - { - operation: OPERATIONS.GET_TENANT_INVOICE, - tenant, - date_start, - date_end, - type, - } - ); }; export const getSetupIntentSecret = (tenant: string) => { @@ -88,43 +59,14 @@ export interface Invoice { processed_data_gb: number; task_usage_hours: number; }; + // Stripe-sourced fields carried on the GQL invoice node. Absent on previews + // and on invoices that haven't been issued in Stripe yet. + status?: string | null; + invoice_pdf?: string | null; + hosted_invoice_url?: string | null; + receipt_url?: string | null; } -const invoicesQuery = [ - 'billed_prefix', - 'date_start', - 'date_end', - 'line_items', - 'subtotal', - 'invoice_type', - 'extra', -].join(', '); - -export const getInvoicesBetween = ( - billed_prefix: string, - date_start: Date, - date_end: Date -) => { - const formattedStart = formatDateForApi(date_start); - const formattedEnd = formatDateForApi(date_end); - - return supabaseClient - .from(TABLES.INVOICES_EXT) - .select(invoicesQuery) - .filter('billed_prefix', 'eq', billed_prefix) - .or( - `invoice_type.eq.manual,and(${[ - `date_start.gte.${formattedStart}`, - `date_start.lte.${formattedEnd}`, - `date_end.gte.${formattedStart}`, - `date_end.lte.${formattedEnd}`, - ].join(',')})` - ) - .order('date_start', { ascending: false }) - .throwOnError() - .returns(); -}; - export interface MultiplePaymentMethods { responses: any[]; errors: any[]; diff --git a/src/api/gql/billing.ts b/src/api/gql/billing.ts new file mode 100644 index 0000000000..ac984273de --- /dev/null +++ b/src/api/gql/billing.ts @@ -0,0 +1,36 @@ +import { graphql } from 'src/gql-types'; + +// Upper bound on invoices fetched per tenant. A tenant accrues ~12 invoices a +// year, so this comfortably covers the rolling six-month window the UI shows +// plus any older manual invoices, which the hook filters down client-side. +export const BILLING_INVOICE_FETCH_LIMIT = 100; + +// `lineItems` and `extra` are opaque JSON scalars in the schema, so codegen +// types them as `unknown`; the hook casts them to the InvoiceLineItem[] / extra +// shapes the rest of the billing UI already expects. `billed_prefix` is not on +// the node because the tenant is implied by the `tenant(name:)` parent. +export const TENANT_BILLING_INVOICES_QUERY = graphql(` + query TenantBillingInvoices($tenant: String!, $first: Int) { + tenant(name: $tenant) { + billing { + invoices(first: $first) { + nodes { + dateStart + dateEnd + invoiceType + subtotal + lineItems + extra + status + invoicePdf + hostedInvoiceUrl + paymentDetails { + status + receiptUrl + } + } + } + } + } + } +`); diff --git a/src/app/guards/TenantGuard/index.tsx b/src/app/guards/TenantGuard/index.tsx index 56df5c858c..ef7e1d8a40 100644 --- a/src/app/guards/TenantGuard/index.tsx +++ b/src/app/guards/TenantGuard/index.tsx @@ -9,6 +9,7 @@ import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfo import useGlobalSearchParams, { GlobalSearchParams, } from 'src/hooks/searchParams/useGlobalSearchParams'; +import { useInitializeSelectedTenant } from 'src/hooks/useInitializeSelectedTenant'; // This is a way to very simply "hide" the flow where anyone // can create a tenant but allow us to test it out in prod. @@ -27,6 +28,8 @@ function TenantGuard({ children }: BaseComponentProps) { const mutate = useUserInfoSummaryStore((state) => state.mutate); const usedSSO = useUserStore((state) => state.userDetails?.usedSSO); + useInitializeSelectedTenant(); + const showOnboarding = !hasAnyAccess || showBeta; if (showOnboarding) { if (usedSSO && !showBeta) { diff --git a/src/components/admin/Billing/LoadError.tsx b/src/components/admin/Billing/LoadError.tsx index 630fb98d4c..34a430006d 100644 --- a/src/components/admin/Billing/LoadError.tsx +++ b/src/components/admin/Billing/LoadError.tsx @@ -3,14 +3,12 @@ import { Grid } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import AlertBox from 'src/components/shared/AlertBox'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; function BillingLoadError() { - const hydrationErrorsExist = useBillingStore( - (state) => state.hydrationErrorsExist - ); + const { errorExists } = useBillingInvoices(); - if (!hydrationErrorsExist) { + if (!errorExists) { return null; } diff --git a/src/components/admin/Billing/PricingTierDetails.tsx b/src/components/admin/Billing/PricingTierDetails.tsx index 20db091910..8102906886 100644 --- a/src/components/admin/Billing/PricingTierDetails.tsx +++ b/src/components/admin/Billing/PricingTierDetails.tsx @@ -13,7 +13,6 @@ function PricingTierDetails() { const [externalPaymentMethod, marketPlaceProvider] = useTenantUsesExternalPayment(selectedTenant); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); const paymentMethodExists = useBillingStore( (state) => state.paymentMethodExists ); @@ -38,7 +37,7 @@ function PricingTierDetails() { return 'admin.billing.message.freeTier'; }, [externalPaymentMethod, marketPlaceProvider, paymentMethodExists]); - if (!billingStoreHydrated || typeof paymentMethodExists !== 'boolean') { + if (typeof paymentMethodExists !== 'boolean') { return ( diff --git a/src/components/admin/Billing/TenantOptions.tsx b/src/components/admin/Billing/TenantOptions.tsx deleted file mode 100644 index 17466aaa7a..0000000000 --- a/src/components/admin/Billing/TenantOptions.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from 'react'; - -import TenantSelector from 'src/components/shared/TenantSelector'; -import { useBillingStore } from 'src/stores/Billing'; - -function TenantOptions() { - const resetBillingState = useBillingStore((state) => state.resetState); - - const updateStoreState = useCallback(() => { - resetBillingState(); - }, [resetBillingState]); - - return ; -} - -export default TenantOptions; diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 4e2892fc40..129e9d6c97 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -1,25 +1,16 @@ import type { AdminBillingProps } from 'src/components/admin/Billing/types'; -import { useEffect, useMemo } from 'react'; -import useConstant from 'use-constant'; - import { Divider, Grid, Typography } from '@mui/material'; -import { useShallow } from 'zustand/react/shallow'; - -import { endOfMonth, startOfMonth, subMonths } from 'date-fns'; import { ErrorBoundary } from 'react-error-boundary'; import { useIntl } from 'react-intl'; -import { useUnmount } from 'react-use'; -import { getInvoicesBetween } from 'src/api/billing'; import { authenticatedRoutes } from 'src/app/routes'; import DateRange from 'src/components/admin/Billing/DateRange'; import BillingLoadError from 'src/components/admin/Billing/LoadError'; import PaymentMethods from 'src/components/admin/Billing/PaymentMethods'; import PricingTierDetails from 'src/components/admin/Billing/PricingTierDetails'; import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; -import TenantOptions from 'src/components/admin/Billing/TenantOptions'; import AdminTabs from 'src/components/admin/Tabs'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import GraphStateWrapper from 'src/components/graphs/states/Wrapper'; @@ -28,14 +19,10 @@ import AlertBox from 'src/components/shared/AlertBox'; import CardWrapper from 'src/components/shared/CardWrapper'; import BillingHistoryTable from 'src/components/tables/Billing'; import BillingLineItemsTable from 'src/components/tables/BillLineItems'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import usePageTitle from 'src/hooks/usePageTitle'; import { logRocketEvent } from 'src/services/shared'; import { CustomEvents } from 'src/services/types'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; -import { useTenantStore } from 'src/stores/Tenant'; import { invoiceId, TOTAL_CARD_HEIGHT } from 'src/utils/billing-utils'; const routeTitle = authenticatedRoutes.admin.billing.title; @@ -52,79 +39,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { const intl = useIntl(); - const selectedTenant = useTenantStore((state) => state.selectedTenant); - - // Billing Store - // TODO (billing store) - // The `active` stuff could probably be removed now that other stuff is - // cleaned up - but leaving to make it easier - const [active, setActive] = useBillingStore( - useShallow((state) => [state.active, state.setActive]) - ); - const [hydrated, setHydrated] = useBillingStore( - useShallow((state) => [state.hydrated, state.setHydrated]) - ); - const setHydrationErrorsExist = useBillingStore( - (state) => state.setHydrationErrorsExist - ); - const setInvoices = useBillingStore((state) => state.setInvoices); - const setNetworkFailed = useBillingStore((state) => state.setNetworkFailed); - - const selectedInvoice = useBilling_selectedInvoice(); - - const resetBillingState = useBillingStore((state) => state.resetState); - - const currentMonth = useConstant(() => { - const today = new Date(); - - return endOfMonth(today); - }); - - const dateRange = useMemo(() => { - const startMonth = startOfMonth(subMonths(currentMonth, 5)); - - return { start: startMonth, end: currentMonth }; - }, [currentMonth]); - - useEffect(() => { - if (selectedTenant) { - void (async () => { - setNetworkFailed(null); - setActive(true); - try { - const response = await getInvoicesBetween( - selectedTenant, - dateRange.start, - dateRange.end - ); - if (response.error) { - throw new Error(response.error.message); - } - setNetworkFailed(null); - setHydrationErrorsExist(false); - setInvoices(response.data); - } catch (errorMessage: unknown) { - setNetworkFailed(`${errorMessage}`); - setHydrationErrorsExist(true); - setInvoices([]); - } finally { - setHydrated(true); - setActive(false); - } - })(); - } - }, [ - dateRange.end, - dateRange.start, - selectedTenant, - setActive, - setHydrated, - setHydrationErrorsExist, - setInvoices, - setNetworkFailed, - ]); - - useUnmount(() => resetBillingState()); + const { isLoading, selectedInvoice } = useBillingInvoices(); return ( <> @@ -138,13 +53,6 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - - - - @@ -178,7 +86,7 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - {!active && hydrated ? ( + {!isLoading ? ( - - - - - - diff --git a/src/components/fullPage/Error.tsx b/src/components/fullPage/Error.tsx index 7fa4957e34..aa34066e67 100644 --- a/src/components/fullPage/Error.tsx +++ b/src/components/fullPage/Error.tsx @@ -3,7 +3,7 @@ import type { ErrorDetails } from 'src/components/shared/Error/types'; import { useMemo } from 'react'; -import { Divider, Stack, Typography } from '@mui/material'; +import { Button, Divider, Stack, Typography } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import { useMount } from 'react-use'; @@ -11,6 +11,7 @@ import { useMount } from 'react-use'; import FullPageWrapper from 'src/app/FullPageWrapper'; import MessageWithLink from 'src/components/content/MessageWithLink'; import Error from 'src/components/shared/Error'; +import { supabaseClient } from 'src/context/GlobalProviders'; import { logRocketEvent } from 'src/services/shared'; import { CustomEvents } from 'src/services/types'; @@ -56,6 +57,27 @@ function FullPageError({ + + + + + + + diff --git a/src/components/graphs/TaskHoursByMonthGraph.tsx b/src/components/graphs/TaskHoursByMonthGraph.tsx index 581c0539c2..d53e769a95 100644 --- a/src/components/graphs/TaskHoursByMonthGraph.tsx +++ b/src/components/graphs/TaskHoursByMonthGraph.tsx @@ -29,7 +29,7 @@ import { } from 'src/components/graphs/tooltips'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; import { defaultOutlineColor } from 'src/context/Theme'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { CARD_AREA_HEIGHT, stripTimeFromDate } from 'src/utils/billing-utils'; const chartContainerId = 'task-hours-by-month'; @@ -39,8 +39,7 @@ function TaskHoursByMonthGraph() { const intl = useIntl(); const tooltipConfig = useTooltipConfig(); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const resizeListener = useRef(null); const [myChart, setMyChart] = useState(null); @@ -85,7 +84,7 @@ function TaskHoursByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -190,7 +189,7 @@ function TaskHoursByMonthGraph() { } }, [ invoices, - billingStoreHydrated, + isLoading, intl, months, myChart, diff --git a/src/components/graphs/UsageByMonthGraph.tsx b/src/components/graphs/UsageByMonthGraph.tsx index 0de4754ab3..20d6624c4a 100644 --- a/src/components/graphs/UsageByMonthGraph.tsx +++ b/src/components/graphs/UsageByMonthGraph.tsx @@ -27,7 +27,7 @@ import { useIntl } from 'react-intl'; import useLegendConfig from 'src/components/graphs/useLegendConfig'; import useTooltipConfig from 'src/components/graphs/useTooltipConfig'; import { eChartsColors } from 'src/context/Theme'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { CARD_AREA_HEIGHT, stripTimeFromDate } from 'src/utils/billing-utils'; const chartContainerId = 'data-by-month'; @@ -40,8 +40,7 @@ function UsageByMonthGraph() { const tooltipConfig = useTooltipConfig(); const legendConfig = useLegendConfig([{ name: 'Data' }, { name: 'Hours' }]); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, isLoading } = useBillingInvoices(); const [myChart, setMyChart] = useState(null); @@ -96,7 +95,7 @@ function UsageByMonthGraph() { }, [invoices, intl, today]); useEffect(() => { - if (billingStoreHydrated && invoices.length > 0) { + if (!isLoading && invoices.length > 0) { if (!myChart) { echarts.use([ GridComponent, @@ -126,7 +125,7 @@ function UsageByMonthGraph() { return undefined; }, [ invoices, - billingStoreHydrated, + isLoading, intl, legendConfig, months, diff --git a/src/components/graphs/states/Wrapper.tsx b/src/components/graphs/states/Wrapper.tsx index 00693b82a2..f81e410f4e 100644 --- a/src/components/graphs/states/Wrapper.tsx +++ b/src/components/graphs/states/Wrapper.tsx @@ -7,14 +7,15 @@ import { FormattedMessage } from 'react-intl'; import EmptyGraphState from 'src/components/graphs/states/Empty'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import { eChartsTooltipSX } from 'src/components/graphs/tooltips'; -import { useBillingStore } from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { hasLength } from 'src/utils/misc-utils'; function GraphStateWrapper({ children }: BaseComponentProps) { - const billingStoreActive = useBillingStore((state) => state.active); - const billingStoreHydrated = useBillingStore((state) => state.hydrated); - const networkFailed = useBillingStore((state) => state.networkFailed); - const billingHistory = useBillingStore((state) => state.invoices); + const { + invoices: billingHistory, + isLoading, + networkFailed, + } = useBillingInvoices(); if (networkFailed) { return ( @@ -29,7 +30,7 @@ function GraphStateWrapper({ children }: BaseComponentProps) { ); } - if (!billingStoreActive && billingStoreHydrated) { + if (!isLoading) { return hasLength(billingHistory) ? ( {children} ) : ( diff --git a/src/components/home/dashboard/index.tsx b/src/components/home/dashboard/index.tsx index ed59efb0a2..6112494686 100644 --- a/src/components/home/dashboard/index.tsx +++ b/src/components/home/dashboard/index.tsx @@ -1,20 +1,10 @@ -import { Box, Grid } from '@mui/material'; +import { Grid } from '@mui/material'; import EntityStatOverview from 'src/components/home/dashboard/EntityStatOverview'; -import TenantSelector from 'src/components/shared/TenantSelector'; export default function Dashboard() { return ( - - - - - - ); diff --git a/src/components/menus/HelpMenu.tsx b/src/components/menus/HelpMenu.tsx index c1855ce0a8..c9d458afed 100644 --- a/src/components/menus/HelpMenu.tsx +++ b/src/components/menus/HelpMenu.tsx @@ -1,18 +1,29 @@ -import { HelpCircle } from 'iconoir-react'; +import { Menu } from '@mui/material'; + import { FormattedMessage, useIntl } from 'react-intl'; -import IconMenu from 'src/components/menus/IconMenu'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; import ExternalLinkMenuItem from 'src/components/shared/ExternalLinkMenuItem'; -function HelpMenu() { +interface HelpMenuProps { + anchorEl: HTMLElement | null; + onClose: () => void; +} + +export function HelpMenu({ anchorEl, onClose }: HelpMenuProps) { const intl = useIntl(); return ( - } - identifier="help-menu" - tooltip={intl.formatMessage({ id: 'helpMenu.tooltip' })} + - + ); } - -export default HelpMenu; diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx new file mode 100644 index 0000000000..3db2b85488 --- /dev/null +++ b/src/components/menus/OrgMenu.tsx @@ -0,0 +1,132 @@ +import { + Dialog, + DialogContent, + DialogTitle, + MenuItem, + Popover, + Typography, +} from '@mui/material'; + +import { Check } from 'iconoir-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import PrefixSelector from 'src/components/inputs/PrefixedName/PrefixSelector'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; +import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfoSummaryStore'; +import { useEntitiesStore_tenantsWithAdmin } from 'src/stores/Entities/hooks'; +import { useTenantStore } from 'src/stores/Tenant'; + +interface OrgMenuProps { + anchorEl: HTMLElement | null; + onClose: () => void; +} + +export const OrgMenu = ({ anchorEl, onClose }: OrgMenuProps) => { + const intl = useIntl(); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const setSelectedTenant = useTenantStore( + (state) => state.setSelectedTenant + ); + // Same tenant list the old TenantSelector showed everyone (incl. estuary_support). + const tenantNames = useEntitiesStore_tenantsWithAdmin(); + const hasSupportAccess = useUserInfoSummaryStore( + (state) => state.hasSupportAccess + ); + + // Support users get the searchable dialog (every prefix); everyone else + // gets the popover list anchored to the trigger. + if (hasSupportAccess) { + return ( + + + + + + { + setSelectedTenant(newValue); + onClose(); + }} + options={tenantNames} + value={selectedTenant} + variantString="outlined" + /> + + + ); + } + + return ( + + + + + + {tenantNames.map((tenant) => { + const label = tenant.replace(/\/$/, ''); + const isSelected = tenant === selectedTenant; + + return ( + { + setSelectedTenant(tenant); + onClose(); + }} + sx={{ + borderRadius: 1, + fontSize: 13, + py: 0.75, + justifyContent: 'space-between', + }} + > + {label} + + {isSelected ? : null} + + ); + })} + + ); +}; diff --git a/src/components/menus/UserMenu.tsx b/src/components/menus/UserMenu.tsx index 7bf6a3ccd4..d2b52d23a0 100644 --- a/src/components/menus/UserMenu.tsx +++ b/src/components/menus/UserMenu.tsx @@ -1,97 +1,106 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ -import type { SxProps } from '@mui/material'; - -import { Stack, Typography } from '@mui/material'; -import Divider from '@mui/material/Divider'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import MenuItem from '@mui/material/MenuItem'; +import { + Divider, + ListItemIcon, + Menu, + MenuItem, + Stack, + Typography, + useTheme, +} from '@mui/material'; import { useShallow } from 'zustand/react/shallow'; -import { LogOut, Mail, ProfileCircle } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { HalfMoon, LogOut, SunLight } from 'iconoir-react'; +import { FormattedMessage } from 'react-intl'; -import IconMenu from 'src/components/menus/IconMenu'; -import UserAvatar from 'src/components/shared/UserAvatar'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; import { supabaseClient } from 'src/context/GlobalProviders'; +import { useColorMode } from 'src/context/Theme'; import { useUserStore } from 'src/context/User/useUserContextStore'; -interface Props { - iconColor: string; +interface UserMenuProps { + anchorEl: HTMLElement | null; + onClose: () => void; } -const nonInteractiveMenuStyling: SxProps = { - '&:hover': { - cursor: 'revert', - }, -}; - -const UserMenu = ({ iconColor }: Props) => { - const intl = useIntl(); +export const UserMenu = ({ anchorEl, onClose }: UserMenuProps) => { + const theme = useTheme(); + const colorMode = useColorMode(); const userDetails = useUserStore(useShallow((state) => state.userDetails)); - const handlers = { - logout: async () => { - await supabaseClient.auth.signOut(); - }, - }; - - if (userDetails) { - const { avatar, email, emailVerified, userName } = userDetails; - return ( - - } - identifier="account-menu" - tooltip={intl.formatMessage({ id: 'accountMenu.tooltip' })} - > - - - - - {userName} - - - - - - - - - {email} - - {emailVerified ? ( - - - - ) : null} - - - - - - { - void handlers.logout(); - }} - > - - - - - - - - ); - } else { + if (!userDetails) { return null; } -}; -export default UserMenu; + return ( + + + + + {userDetails.userName ?? userDetails.email} + + + {userDetails.email} + + + + + + + { + e.stopPropagation(); + colorMode.toggleColorMode(); + }} + > + + {theme.palette.mode === 'dark' ? ( + + ) : ( + + )} + + + + + + + { + void supabaseClient.auth.signOut(); + }} + > + + + + + + + ); +}; diff --git a/src/components/menus/shared.ts b/src/components/menus/shared.ts new file mode 100644 index 0000000000..8dfa7709b8 --- /dev/null +++ b/src/components/menus/shared.ts @@ -0,0 +1,13 @@ +import type { PopoverOrigin } from '@mui/material'; + +// Side-nav menus anchor to the top-left of their trigger and grow upward, so +// the menu's bottom-left corner lines up with the trigger's top-left corner. +export const sideNavMenuAnchorOrigin: PopoverOrigin = { + horizontal: 'left', + vertical: 'top', +}; + +export const sideNavMenuTransformOrigin: PopoverOrigin = { + horizontal: 'left', + vertical: 'bottom', +}; diff --git a/src/components/navigation/ListItemLink.tsx b/src/components/navigation/ListItemLink.tsx index 7dd1eef94c..1917588577 100644 --- a/src/components/navigation/ListItemLink.tsx +++ b/src/components/navigation/ListItemLink.tsx @@ -1,108 +1,62 @@ -import type { ReactNode } from 'react'; +import type { MouseEvent, ReactNode } from 'react'; import { - Badge, ListItemButton, ListItemIcon, ListItemText, Tooltip, } from '@mui/material'; -import { useIntl } from 'react-intl'; - import RouterLink from 'src/components/navigation/RouterLink'; -import { NavWidths } from 'src/context/Theme'; interface Props { icon: ReactNode; title: string; - link: string | any; isOpen?: boolean; - menuWidth?: number; - badgeContent?: number; - tooltipDelay?: number; + // Hover tooltip shown when collapsed; defaults to `title`. Set it when the + // tooltip should differ from the label (e.g. a toggle). + tooltip?: string; + // Pass `to` for a route link, or `onClick` for a button (e.g. a menu trigger). + to?: string; + onClick?: (event: MouseEvent) => void; } -const ListItemLink = ({ +export const ListItemLink = ({ icon, title, - link, isOpen, - menuWidth, - badgeContent, - tooltipDelay, + tooltip, + to, + onClick, }: Props) => { - const intl = useIntl(); - - const translatedTitle = intl.formatMessage({ - id: title, - }); - return (
  • - {menuWidth === NavWidths.FULL ? ( - + theme.palette.text.primary, }} > - {icon ? ( - - theme.palette.text.primary, - }} - > - {icon} - - ) : null} + {icon} + - - - - - ) : ( - - {icon ? ( - - theme.palette.text.primary, - }} - > - - {icon} - - - ) : null} - - - - )} + +
  • ); }; - -export default ListItemLink; diff --git a/src/components/navigation/ModeSwitch.tsx b/src/components/navigation/ModeSwitch.tsx deleted file mode 100644 index 8328dd40f0..0000000000 --- a/src/components/navigation/ModeSwitch.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - ListItemButton, - ListItemIcon, - ListItemText, - Tooltip, - useTheme, -} from '@mui/material'; - -import { HalfMoon, SunLight } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; - -import { useColorMode } from 'src/context/Theme'; - -function ModeSwitch() { - const intl = useIntl(); - const theme = useTheme(); - const colorMode = useColorMode(); - - return ( - - - - {theme.palette.mode === 'dark' ? ( - - ) : ( - - )} - - - - - - - - ); -} - -export default ModeSwitch; diff --git a/src/components/navigation/Navigation.tsx b/src/components/navigation/Navigation.tsx index 82ef0714bd..f90b4e49f3 100644 --- a/src/components/navigation/Navigation.tsx +++ b/src/components/navigation/Navigation.tsx @@ -1,31 +1,31 @@ -//TODO (UI / UX) - These icons are not final -import { - Box, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Stack, - Toolbar, - Tooltip, - useTheme, -} from '@mui/material'; +import { useState } from 'react'; + +import { Box, List, Stack, Toolbar, useTheme } from '@mui/material'; import MuiDrawer, { drawerClasses } from '@mui/material/Drawer'; +import { useShallow } from 'zustand/react/shallow'; + import { + Building, CloudDownload, CloudUpload, DatabaseScript, FastArrowLeft, + HelpCircle, HomeSimple, Settings, } from 'iconoir-react'; import { useIntl } from 'react-intl'; import { authenticatedRoutes } from 'src/app/routes'; -import ListItemLink from 'src/components/navigation/ListItemLink'; -import ModeSwitch from 'src/components/navigation/ModeSwitch'; +import { HelpMenu } from 'src/components/menus/HelpMenu'; +import { OrgMenu } from 'src/components/menus/OrgMenu'; +import { UserMenu } from 'src/components/menus/UserMenu'; +import { ListItemLink } from 'src/components/navigation/ListItemLink'; +import UserAvatar from 'src/components/shared/UserAvatar'; import { paperBackground } from 'src/context/Theme'; +import { useUserStore } from 'src/context/User/useUserContextStore'; +import { useTenantStore } from 'src/stores/Tenant'; interface NavigationProps { open: boolean; @@ -36,6 +36,12 @@ interface NavigationProps { const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { const intl = useIntl(); const theme = useTheme(); + const userDetails = useUserStore(useShallow((state) => state.userDetails)); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + + const [helpAnchor, setHelpAnchor] = useState(null); + const [userAnchor, setUserAnchor] = useState(null); + const [orgAnchor, setOrgAnchor] = useState(null); const openNavigation = () => { onNavigationToggle(true); @@ -78,33 +84,48 @@ const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { } - title={authenticatedRoutes.home.title} - link={authenticatedRoutes.home.path} + title={intl.formatMessage({ + id: authenticatedRoutes.home.title, + })} + to={authenticatedRoutes.home.path} /> } - title={authenticatedRoutes.captures.title} - link={authenticatedRoutes.captures.path} + title={intl.formatMessage({ + id: authenticatedRoutes.captures.title, + })} + to={authenticatedRoutes.captures.path} /> } - title={authenticatedRoutes.collections.title} - link={authenticatedRoutes.collections.path} + title={intl.formatMessage({ + id: authenticatedRoutes.collections.title, + })} + to={authenticatedRoutes.collections.path} /> } - title={authenticatedRoutes.materializations.title} - link={authenticatedRoutes.materializations.path} + title={intl.formatMessage({ + id: authenticatedRoutes.materializations.title, + })} + to={authenticatedRoutes.materializations.path} /> } - title={authenticatedRoutes.admin.title} - link={authenticatedRoutes.admin.path} + title={intl.formatMessage({ + id: authenticatedRoutes.admin.title, + })} + to={authenticatedRoutes.admin.path} /> @@ -112,51 +133,85 @@ const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { - - - + } + title={intl.formatMessage({ + id: 'navigation.collapse', + })} + tooltip={intl.formatMessage({ + id: 'navigation.tooltip.expand', + })} + onClick={openNavigation} + isOpen={open} + /> + } title={intl.formatMessage({ - id: 'navigation.toggle.ariaLabel', + id: 'helpMenu.tooltip', })} - placement="right-end" - enterDelay={open ? 1000 : undefined} - > - - - - + onClick={(e) => setHelpAnchor(e.currentTarget)} + isOpen={open} + /> + setHelpAnchor(null)} + /> - + + } + title={ + userDetails.userName ?? + userDetails.email + } + onClick={(e) => + setUserAnchor(e.currentTarget) + } + isOpen={open} + /> + setUserAnchor(null)} + /> + + } + title={ + selectedTenant + ? selectedTenant.replace(/\/$/, '') + : '' + } + onClick={(e) => + setOrgAnchor(e.currentTarget) + } + isOpen={open} + /> + setOrgAnchor(null)} /> - - + + ) : null} diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index 5d35e1a122..6b2de160a8 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -4,8 +4,6 @@ import { useTheme } from '@mui/material/styles'; import { HeaderPill } from 'src/components/AgentSkills/HeaderPill'; import CompanyLogo from 'src/components/graphics/CompanyLogo'; -import HelpMenu from 'src/components/menus/HelpMenu'; -import UserMenu from 'src/components/menus/UserMenu'; import PageTitle from 'src/components/navigation/PageTitle'; import SidePanelDocsOpenButton from 'src/components/sidePanelDocs/OpenButton'; import { UpdateAlert } from 'src/components/UpdateAlert'; @@ -45,10 +43,6 @@ const Topbar = () => { - - - - diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index 5c1dcbce40..f59884a866 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -1,12 +1,10 @@ -import type { StripeInvoice } from 'src/api/billing'; import type { TableColumns } from 'src/types'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Box, Button, - Skeleton, Table, TableContainer, tableRowClasses, @@ -15,18 +13,13 @@ import { import { CreditCard, Download } from 'iconoir-react'; import { useIntl } from 'react-intl'; -import { getTenantInvoice } from 'src/api/billing'; import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; import Rows from 'src/components/tables/BillLineItems/Rows'; import TotalLines from 'src/components/tables/BillLineItems/TotalLines'; import EntityTableBody from 'src/components/tables/EntityTable/TableBody'; import EntityTableHeader from 'src/components/tables/EntityTable/TableHeader'; import { getTableHeaderWithoutHeaderColor } from 'src/context/Theme'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; -import { useTenantStore } from 'src/stores/Tenant'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { TableStatuses } from 'src/types'; export const columns: TableColumns[] = [ @@ -55,39 +48,24 @@ export const columns: TableColumns[] = [ function BillingLineItemsTable() { const intl = useIntl(); - const selectedTenant = useTenantStore((state) => state.selectedTenant); - - const selectedInvoice = useBilling_selectedInvoice(); - - const hydrated = useBillingStore((state) => state.hydrated); - const invoices = useBillingStore((state) => state.invoices); + const { invoices, selectedInvoice, isLoading } = useBillingInvoices(); const dataRows = useMemo( () => , [selectedInvoice] ); - const [stripeInvoice, setStripeInvoice] = useState( - null + // Whether the selected invoice has any Stripe artifact (PDF, hosted page, + // receipt, or a status). When it has none, there's nothing to download or + // pay, so we show a single "No invoice available" button instead. + const hasInvoiceArtifact = Boolean( + selectedInvoice && + (selectedInvoice.invoice_pdf || + selectedInvoice.hosted_invoice_url || + selectedInvoice.receipt_url || + selectedInvoice.status) ); - useEffect(() => { - setStripeInvoice(null); - void (async () => { - if (selectedInvoice && selectedInvoice.invoice_type !== 'preview') { - const resp = await getTenantInvoice( - selectedTenant, - selectedInvoice.date_start, - selectedInvoice.date_end, - selectedInvoice.invoice_type - ); - if (resp.data?.invoice) { - setStripeInvoice(resp.data.invoice); - } - } - })(); - }, [selectedInvoice, selectedTenant]); - return ( <> @@ -120,7 +98,7 @@ function BillingLineItemsTable() { ? { status: TableStatuses.DATA_FETCHED } : { status: TableStatuses.NO_EXISTING_DATA } } - loading={!hydrated} + loading={isLoading} rows={dataRows} /> @@ -134,12 +112,16 @@ function BillingLineItemsTable() { alignItems: 'end', }} > - {selectedInvoice?.invoice_type !== 'preview' ? ( - hydrated ? ( + {selectedInvoice && + selectedInvoice.invoice_type !== 'preview' ? ( + hasInvoiceArtifact ? ( - {stripeInvoice?.status === 'open' ? ( + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( - ) : stripeInvoice?.status === 'paid' ? ( + ) : selectedInvoice.status === 'paid' ? ( ) ) : null} diff --git a/src/components/tables/Billing/index.tsx b/src/components/tables/Billing/index.tsx index 1f7b69a9fd..4b01a9d3c9 100644 --- a/src/components/tables/Billing/index.tsx +++ b/src/components/tables/Billing/index.tsx @@ -10,10 +10,7 @@ import Rows from 'src/components/tables/Billing/Rows'; import EntityTableBody from 'src/components/tables/EntityTable/TableBody'; import EntityTableHeader from 'src/components/tables/EntityTable/TableHeader'; import { getTableHeaderWithoutHeaderColor } from 'src/context/Theme'; -import { - useBilling_selectedInvoice, - useBillingStore, -} from 'src/stores/Billing'; +import { useBillingInvoices } from 'src/hooks/billing/useBillingInvoices'; import { TableStatuses } from 'src/types'; import { invoiceId } from 'src/utils/billing-utils'; @@ -46,12 +43,12 @@ export const columns: TableColumns[] = [ function BillingHistoryTable() { const intl = useIntl(); - const selectedInvoice = useBilling_selectedInvoice(); - - const active = useBillingStore((state) => state.active); - const hydrated = useBillingStore((state) => state.hydrated); - const networkFailed = useBillingStore((state) => state.networkFailed); - const billingHistory = useBillingStore((state) => state.invoices); + const { + invoices: billingHistory, + selectedInvoice, + isLoading, + networkFailed, + } = useBillingInvoices(); const dataRows = useMemo( () => @@ -95,7 +92,7 @@ function BillingHistoryTable() { ? { status: TableStatuses.NETWORK_FAILED } : { status: TableStatuses.NO_EXISTING_DATA } } - loading={Boolean(active || !hydrated)} + loading={isLoading} rows={dataRows} /> diff --git a/src/context/URQL.tsx b/src/context/URQL.tsx index e2a67e7cdb..78021250e4 100644 --- a/src/context/URQL.tsx +++ b/src/context/URQL.tsx @@ -62,6 +62,9 @@ function UrqlConfigProvider({ children }: BaseComponentProps) { PrefixRef: (_data) => null, StorageMapping: (data) => null, DataPlane: (data) => null, + Tenant: (_data) => null, + TenantBilling: (_data) => null, + Invoice: (_data) => null, }, updates: { Mutation: { diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index 27c65fbb9b..848c5862a8 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -19,6 +19,7 @@ type Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": typeof types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": typeof types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": typeof types.AlertTypeDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": typeof types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.DataPlanesDocument, @@ -44,6 +45,7 @@ const documents: Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": types.AlertTypeDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.DataPlanesDocument, @@ -98,6 +100,10 @@ export function graphql(source: "\n mutation UpdateAlertSubscriptionMutation( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n"): (typeof documents)["\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index b0db5e452b..7f7aa1a9dc 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -1786,6 +1786,14 @@ export type AlertTypeQueryVariables = Exact<{ [key: string]: never; }>; export type AlertTypeQuery = { __typename?: 'QueryRoot', alertTypes: Array<{ __typename?: 'AlertTypeInfo', alertType: AlertType, description: string, displayName: string, isDefault: boolean, isSystem: boolean }> }; +export type TenantBillingInvoicesQueryVariables = Exact<{ + tenant: Scalars['String']['input']; + first?: InputMaybe; +}>; + + +export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any, status?: string | null, invoicePdf?: string | null, hostedInvoiceUrl?: string | null, paymentDetails?: { __typename?: 'InvoicePaymentDetails', status: ChargeStatus, receiptUrl?: string | null } | null }> } } } | null }; + export type ConnectorsGridQueryVariables = Exact<{ filter?: InputMaybe; after?: InputMaybe; @@ -1928,6 +1936,7 @@ export const DeleteAlertSubscriptionMutationDocument = {"kind":"Document","defin export const AlertSubscriptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertSubscriptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertSubscriptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"}},{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const UpdateAlertSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAlertSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AlertType"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAlertSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"alertTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const AlertTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertType"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}}]}}]}}]} as unknown as DocumentNode; +export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"invoicePdf"}},{"kind":"Field","name":{"kind":"Name","value":"hostedInvoiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"paymentDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"receiptUrl"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorsGridDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorsGrid"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectorsFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"500"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommended"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSpec"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorTagDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorTagData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connector"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"imageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"connectorSpec"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fullImageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"defaultCaptureInterval"}},{"kind":"Field","name":{"kind":"Name","value":"disableBackfill"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"endpointSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"resourceSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]} as unknown as DocumentNode; export const DataPlanesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DataPlanes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataPlanes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"cloudProvider"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"fqdn"}},{"kind":"Field","name":{"kind":"Name","value":"cidrBlocks"}},{"kind":"Field","name":{"kind":"Name","value":"awsIamUserArn"}},{"kind":"Field","name":{"kind":"Name","value":"gcpServiceAccountEmail"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationClientId"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/hooks/billing/useBillingInvoices.ts b/src/hooks/billing/useBillingInvoices.ts new file mode 100644 index 0000000000..5cb8462d2a --- /dev/null +++ b/src/hooks/billing/useBillingInvoices.ts @@ -0,0 +1,147 @@ +import type { Invoice, InvoiceLineItem } from 'src/api/billing'; + +import { useMemo } from 'react'; +import useConstant from 'use-constant'; + +import { + compareDesc, + endOfMonth, + isWithinInterval, + startOfMonth, + subMonths, +} from 'date-fns'; +import { useQuery } from 'urql'; + +import { + BILLING_INVOICE_FETCH_LIMIT, + TENANT_BILLING_INVOICES_QUERY, +} from 'src/api/gql/billing'; +import { useBillingStore } from 'src/stores/Billing'; +import { useTenantStore } from 'src/stores/Tenant'; +import { invoiceId, stripTimeFromDate } from 'src/utils/billing-utils'; + +export interface UseBillingInvoicesResult { + invoices: Invoice[]; + // The invoice currently shown in the line-item/detail views: the stored + // selection if it still exists in this tenant's data, otherwise the newest + // invoice. Falling back this way means an org switch self-corrects without + // anyone resetting state. + selectedInvoice: Invoice | null; + isLoading: boolean; + networkFailed: boolean; + errorExists: boolean; +} + +interface DateWindow { + start: Date; + end: Date; +} + +// The GQL invoice node is camelCased and omits `billed_prefix` (the tenant is +// the query parent). Map it back to the shape the billing UI already consumes. +const mapInvoice = ( + node: { + dateStart: string; + dateEnd: string; + invoiceType: string; + subtotal: number; + lineItems: unknown; + extra: unknown; + status?: string | null; + invoicePdf?: string | null; + hostedInvoiceUrl?: string | null; + paymentDetails?: { receiptUrl?: string | null } | null; + }, + tenant: string +): Invoice => ({ + billed_prefix: tenant, + date_start: node.dateStart, + date_end: node.dateEnd, + invoice_type: node.invoiceType.toLowerCase() as Invoice['invoice_type'], + subtotal: node.subtotal, + line_items: (node.lineItems ?? []) as InvoiceLineItem[], + extra: (node.extra ?? undefined) as Invoice['extra'], + status: node.status, + invoice_pdf: node.invoicePdf, + hosted_invoice_url: node.hostedInvoiceUrl, + receipt_url: node.paymentDetails?.receiptUrl ?? null, +}); + +// Mirrors the predicate the previous PostgREST query enforced server-side: +// invoices whose start and end both fall inside the rolling window, plus any +// manual invoice regardless of date. +const isVisible = (invoice: Invoice, { start, end }: DateWindow): boolean => { + if (invoice.invoice_type === 'manual') { + return true; + } + + return ( + isWithinInterval(stripTimeFromDate(invoice.date_start), { + start, + end, + }) && + isWithinInterval(stripTimeFromDate(invoice.date_end), { start, end }) + ); +}; + +// Fetches the selected tenant's invoices via GraphQL. urql keys its cache on +// the query variables, so switching tenants re-runs the query and a previously +// viewed tenant is served from cache — the previous tenant's data is never +// shown. Server-side filtering on `billing.invoices` is narrower than the old +// PostgREST query (no "window OR manual" predicate), so the window/manual +// filter and newest-first sort are reproduced here. +export function useBillingInvoices(): UseBillingInvoicesResult { + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const selectedInvoiceId = useBillingStore( + (state) => state.selectedInvoiceId + ); + + const dateWindow = useConstant(() => { + const end = endOfMonth(new Date()); + + return { start: startOfMonth(subMonths(end, 5)), end }; + }); + + const [{ data, fetching, error }] = useQuery({ + query: TENANT_BILLING_INVOICES_QUERY, + variables: { + tenant: selectedTenant, + first: BILLING_INVOICE_FETCH_LIMIT, + }, + pause: !selectedTenant, + }); + + const invoices = useMemo(() => { + const nodes = data?.tenant?.billing.invoices.nodes ?? []; + + return nodes + .map((node) => mapInvoice(node, selectedTenant)) + .filter((invoice) => isVisible(invoice, dateWindow)) + .sort((a, b) => + compareDesc( + stripTimeFromDate(a.date_start), + stripTimeFromDate(b.date_start) + ) + ); + }, [data, dateWindow, selectedTenant]); + + const selectedInvoice = useMemo(() => { + if (invoices.length === 0) { + return null; + } + + return ( + invoices.find( + (invoice) => invoiceId(invoice) === selectedInvoiceId + ) ?? invoices[0] + ); + }, [invoices, selectedInvoiceId]); + + return { + invoices, + selectedInvoice, + isLoading: fetching, + networkFailed: Boolean(error?.networkError), + errorExists: Boolean(error), + }; +} diff --git a/src/hooks/useInitializeSelectedTenant.ts b/src/hooks/useInitializeSelectedTenant.ts new file mode 100644 index 0000000000..057ab84b2c --- /dev/null +++ b/src/hooks/useInitializeSelectedTenant.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; + +import useGlobalSearchParams, { + GlobalSearchParams, +} from 'src/hooks/searchParams/useGlobalSearchParams'; +import { useEntitiesStore_tenantsWithAdmin } from 'src/stores/Entities/hooks'; +import { useTenantStore } from 'src/stores/Tenant'; +import { hasLength } from 'src/utils/misc-utils'; + +// Ensures the app always has a tenant selected. Runs from TenantGuard, which +// mounts once for any user with tenant access, so the selection is set +// app-wide regardless of which page or menu is open. When the tenant list +// loads it honors a `?prefix=` deep link (e.g. the billing "add payment +// method" CTA) the first time it appears, then keeps a still-valid selection, +// otherwise falls back to the first available tenant. +export function useInitializeSelectedTenant() { + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const setSelectedTenant = useTenantStore( + (state) => state.setSelectedTenant + ); + const tenantNames = useEntitiesStore_tenantsWithAdmin(); + + const prefixParam = useGlobalSearchParams(GlobalSearchParams.PREFIX); + // The prefix value we've already applied. `?prefix=` isn't stripped from the + // URL, so without this a lingering param would re-assert the selection on + // every change and clobber an intentional org switch. Keyed by value, so a + // new deep-link prefix still applies once. + const appliedPrefixParam = useRef(null); + + useEffect(() => { + if (!hasLength(tenantNames)) { + return; + } + + if ( + hasLength(prefixParam) && + tenantNames.includes(prefixParam) && + prefixParam !== appliedPrefixParam.current + ) { + appliedPrefixParam.current = prefixParam; + + if (prefixParam !== selectedTenant) { + setSelectedTenant(prefixParam); + } + + return; + } + + if (!(selectedTenant && tenantNames.includes(selectedTenant))) { + setSelectedTenant(tenantNames[0]); + } + }, [prefixParam, selectedTenant, setSelectedTenant, tenantNames]); +} diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 624ac51642..cf911e2c4b 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -78,6 +78,8 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.tooltip.download_pdf': `Download invoice PDF`, 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, + 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, + 'admin.billing.table.line_items.tooltip.no_invoice': `No invoice available`, 'admin.billing.paymentMethods.header': `Payment Information`, 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`, diff --git a/src/lang/en-US/Navigation.ts b/src/lang/en-US/Navigation.ts index 304f759a81..f7481ad52f 100644 --- a/src/lang/en-US/Navigation.ts +++ b/src/lang/en-US/Navigation.ts @@ -2,15 +2,12 @@ import { CommonMessages } from 'src/lang/en-US/CommonMessages'; import { CTAs } from 'src/lang/en-US/CTAs'; export const Navigation: Record = { - 'navigation.toggle.ariaLabel': `Toggle Navigation`, - 'navigation.expand': `Expand Navigation`, - 'navigation.collapse': `Collapse Navigation`, + 'navigation.ariaLabel': `Main Navigation`, + 'navigation.ariaLabel.secondary': `Account and Help`, + 'navigation.tooltip.expand': `Expand Navigation`, + 'navigation.collapse': `Collapse`, - // Header - 'mainMenu.tooltip': `Open Main Menu`, - - 'helpMenu.ariaLabel': `Open Help Menu`, - 'helpMenu.tooltip': `Helpful Links`, + 'helpMenu.tooltip': `Help`, 'helpMenu.docs': `Docs`, 'helpMenu.docs.link': `https://docs.estuary.dev/`, 'helpMenu.slack': `Estuary Slack`, @@ -19,15 +16,13 @@ export const Navigation: Record = { 'helpMenu.support.link': `${CommonMessages['support.email']}`, 'helpMenu.contact': `${CTAs['cta.contactUs']}`, 'helpMenu.contact.link': `https://estuary.dev/contact-us`, - 'helpMenu.about': `About ${CommonMessages.productName}`, 'helpMenu.status': `Status`, 'helpMenu.status.link': `https://status.estuary.dev/`, - 'accountMenu.ariaLabel': `Open Account Menu`, - 'accountMenu.tooltip': `My Account`, - 'accountMenu.emailVerified': `verified`, + 'modeSwitch.label.light': `Light mode`, + 'modeSwitch.label.dark': `Dark mode`, - 'modeSwitch.label': `Toggle Color Mode`, + 'tenant.organization': `Organization`, 'updateAlert.cta': `Update`, 'updateAlert.title': `Dashboard Updated`, diff --git a/src/services/shared.ts b/src/services/shared.ts index 91b60a5d7c..3cfed1afac 100644 --- a/src/services/shared.ts +++ b/src/services/shared.ts @@ -20,6 +20,12 @@ export const getUserDetails = ( if (!isEmpty(user.user_metadata)) { email = user.user_metadata.email; + // Identity-provider-asserted flag, not Supabase's authoritative + // `email_confirmed_at`. Production only supports SSO and social login, + // so the provider always vouches for the email and this is true for + // practically every user (confirmed against prod) — not a meaningful UI + // or analytics signal. Use `email_confirmed_at` if you ever need real + // confirmation state. emailVerified = user.user_metadata.email_verified; avatar = user.user_metadata.avatar_url; userName = user.user_metadata.full_name ?? email; diff --git a/src/stores/Billing.ts b/src/stores/Billing.ts index 8f45e333c2..56e161313f 100644 --- a/src/stores/Billing.ts +++ b/src/stores/Billing.ts @@ -1,5 +1,3 @@ -import type { Invoice } from 'src/api/billing'; -import type { StoreWithHydration } from 'src/stores/extensions/Hydration'; import type { InvoiceId } from 'src/utils/billing-utils'; import type { NamedSet } from 'zustand/middleware'; @@ -9,42 +7,21 @@ import { devtools } from 'zustand/middleware'; import produce from 'immer'; import { isArray } from 'lodash'; -import { - getInitialHydrationData, - getStoreWithHydrationSettings, -} from 'src/stores/extensions/Hydration'; -import { invoiceId } from 'src/utils/billing-utils'; import { hasLength } from 'src/utils/misc-utils'; import { devtoolsOptions } from 'src/utils/store-utils'; -interface BillingState extends StoreWithHydration { +interface BillingState { selectedInvoiceId: InvoiceId | null; setSelectedInvoice: (value: InvoiceId) => void; - invoices: Invoice[]; - setInvoices: (value: Invoice[]) => void; - paymentMethodExists: boolean | null; setPaymentMethodExists: (value: any[] | undefined) => void; - - resetState: () => void; } -const getInitialStateData = (): Pick< - BillingState, - 'invoices' | 'paymentMethodExists' | 'selectedInvoiceId' -> => { +const getInitialState = (set: NamedSet): BillingState => { return { selectedInvoiceId: null, - invoices: [], paymentMethodExists: null, - }; -}; - -const getInitialState = (set: NamedSet): BillingState => { - return { - ...getInitialStateData(), - ...getStoreWithHydrationSettings('Billing', set), setSelectedInvoice: (value) => { set( @@ -56,20 +33,6 @@ const getInitialState = (set: NamedSet): BillingState => { ); }, - setInvoices: (value) => { - set( - produce((state: BillingState) => { - if (state.active) { - state.invoices = value; - state.selectedInvoiceId = - value.length > 0 ? invoiceId(value[0]) : null; - } - }), - false, - 'Billing Details Set' - ); - }, - setPaymentMethodExists: (value) => { set( produce((state: BillingState) => { @@ -80,28 +43,9 @@ const getInitialState = (set: NamedSet): BillingState => { 'Payment Exists Updated' ); }, - - resetState: () => { - set( - { ...getInitialStateData(), ...getInitialHydrationData() }, - false, - 'State Reset' - ); - }, }; }; export const useBillingStore = create()( devtools((set) => getInitialState(set), devtoolsOptions('billing')) ); - -// Selector Hooks -export const useBilling_selectedInvoice = () => { - return useBillingStore((state) => - state.selectedInvoiceId - ? (state.invoices.find( - (inv) => invoiceId(inv) === state.selectedInvoiceId - ) ?? null) - : null - ); -};