Update org billing page with latest design

This commit is contained in:
Joshen Lim committed 2022-03-01 13:58:55 +08:00
1 parent b253238b9e
commit fe80274b28
8 files changed
+392 -302

No files matched your search

@@ -0,0 +1,29 @@
import { FC } from 'react'
import { Button, Typography } from '@supabase/ui'
interface Props {
organization: any
}
const AWSMarketplaceSubscription: FC<Props> = ({ organization }) => {
return (
<div className="flex flex-col space-y-4">
<Typography.Text>
This organization is subscribed via AWS Marketplace, with a limit of{' '}
{organization.project_limit} projects and expiring on{' '}
{organization.aws_marketplace.Entitlements[0].ExpirationDate.slice(0, 10)}.
</Typography.Text>
<Button>
<a
href="https://aws.amazon.com/marketplace/saas/ordering?productId=dc498450-cecf-44c2-8c99-c3c13f16e70a&offerId=d1htt16t3ygx5brtwhooms0ei"
target="_blank"
>
Manage AWS Marketplace subscription
</a>
</Button>
</div>
)
}
export default AWSMarketplaceSubscription
@@ -0,0 +1,49 @@
import { FC } from 'react'
import { Dictionary } from '@supabase/grid'
import { Input, Button } from '@supabase/ui'
import Panel from 'components/to-be-cleaned/Panel'
interface Props {
loading: boolean
address: Dictionary<any>
redirectToPortal: (url: string) => void
}
const BillingAddress: FC<Props> = ({ loading, address, redirectToPortal }) => {
const { city, country, line1, line2, postal_code, state } = address
return (
<div className="space-y-2">
<div>
<h4>Billing address</h4>
<p className="text-sm opacity-50">This will be reflected in every invoice</p>
</div>
<Panel
loading={loading}
footer={
<div>
<Button type="default" onClick={() => redirectToPortal('/customer/update')}>
Edit billing address
</Button>
</div>
}
>
<Panel.Content className="w-3/5 space-y-2">
<Input readOnly placeholder="Address line 1" value={line1 || ''} />
<Input readOnly placeholder="Address line 2" value={line2 || ''} />
<div className="flex items-center space-x-2">
<Input className="w-full" placeholder="Country" readOnly value={country || ''} />
<Input
className="w-full"
placeholder="Postal code"
readOnly
value={postal_code || ''}
/>
</div>
{city && <Input readOnly placeholder="City" value={city} />}
</Panel.Content>
</Panel>
</div>
)
}
export default BillingAddress
@@ -1,24 +1,17 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { FC, useState, useEffect } from 'react'
import {
Typography,
Badge,
Button,
IconTrash,
IconCreditCard,
Input,
IconPlus,
IconEdit2,
IconLoader,
} from '@supabase/ui'
import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { getURL } from 'lib/helpers'
import { post } from 'lib/common/fetch'
import Panel from 'components/to-be-cleaned/Panel'
import ProjectSubscription from './ProjectSubscription'
import AWSMarketplaceSubscription from './AWSMarketplaceSubscription'
import ProjectsSummary from './ProjectsSummary'
import CreditBalance from './CreditBalance'
import PaymentMethods from './PaymentMethods'
import BillingAddress from './BillingAddress'
import TaxID from './TaxID'
interface Props {
organization: any
@@ -35,7 +28,7 @@ const BillingSettings: FC<Props> = ({ organization, projects = [] }) => {
const [taxIds, setTaxIds] = useState<any>(null)
const [paymentMethods, setPaymentMethods] = useState<any>(null)
const { stripe_customer_id, stripe_customer_object, name: orgName, slug: orgSlug } = organization
const { stripe_customer_id, stripe_customer_object } = organization
const customerBalance = customer && customer.balance ? customer.balance / 100 : 0
const isCredit = customerBalance < 0
@@ -45,8 +38,6 @@ const BillingSettings: FC<Props> = ({ organization, projects = [] }) => {
? customerBalance.toString().replace('-', '')
: customerBalance
const { city, country, line1, line2, postal_code, state } = stripe_customer_object?.address ?? {}
useEffect(() => {
if (stripe_customer_id) {
getStripeAccount()
@@ -109,198 +100,27 @@ const BillingSettings: FC<Props> = ({ organization, projects = [] }) => {
return (
<article className="my-4 container max-w-4xl space-y-8">
{organization.aws_marketplace ? (
<div className="flex flex-col space-y-4">
<Typography.Text>
This organization is subscribed via AWS Marketplace, with a limit of{' '}
{organization.project_limit} projects and expiring on{' '}
{organization.aws_marketplace.Entitlements[0].ExpirationDate.slice(0, 10)}.
</Typography.Text>
<Button>
<a
href="https://aws.amazon.com/marketplace/saas/ordering?productId=dc498450-cecf-44c2-8c99-c3c13f16e70a&offerId=d1htt16t3ygx5brtwhooms0ei"
target="_blank"
>
Manage AWS Marketplace subscription
</a>
</Button>
</div>
<AWSMarketplaceSubscription organization={organization} />
) : (
<>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12 lg:col-span-7">
<Panel
loading={loading}
title={
<div className="w-full flex items-center justify-between">
<div className="flex flex-col">
<Typography.Title level={5}>Billing Information</Typography.Title>
<Typography.Text type="secondary">
These will be reflected in all invoices
</Typography.Text>
</div>
<Button
key="panel-footer"
type="outline"
icon={<IconEdit2 />}
onClick={() => redirectToPortal('/customer/update')}
>
Update
</Button>
</div>
}
>
<Panel.Content className="space-y-2">
<Input readOnly layout="horizontal" label="Country" value={country || ''} />
{city && <Input readOnly layout="horizontal" label="City" value={city} />}
<Input readOnly layout="horizontal" label="Address" value={line1 || ''} />
<Input readOnly layout="horizontal" label="" value={line2 || ''} />
<Input readOnly layout="horizontal" label="" value={postal_code || ''} />
</Panel.Content>
<Panel.Content className="space-y-2">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-4">
<Typography.Text>Tax IDs</Typography.Text>
</div>
<div className="col-span-8">
{!taxIds ? (
<div className="flex space-x-2 items-center">
<IconLoader className="animate-spin" size={14} />
<Typography.Text>Retrieving tax information...</Typography.Text>
</div>
) : (taxIds?.data ?? []).length === 0 ? (
<Typography.Text type="secondary">None</Typography.Text>
) : (
<>
{taxIds.data.map((taxId: any) => (
<Input readOnly value={taxId.value} />
))}
</>
)}
</div>
</div>
</Panel.Content>
</Panel>
</div>
<div className="col-span-12 lg:col-span-5">
<Panel
className="!mb-4"
loading={loading}
title={<Typography.Title level={5}>Balance</Typography.Title>}
>
<Panel.Content>
{isCredit && <Badge>You have credits available</Badge>}
{isDebt && <Badge color="red">Outstanding payments</Badge>}
<Typography.Title level={2} className="mb-0">
<Typography.Text type="secondary">
<span className="text-lg font-normal">$</span>
</Typography.Text>
<span>{balance}</span>
<Typography.Text type="secondary">
{isCredit && <span className="text-lg font-normal"> / credits</span>}
</Typography.Text>
</Typography.Title>
</Panel.Content>
</Panel>
<Panel
loading={loading}
title={
<div className="w-full flex items-center justify-between">
<Typography.Title level={5}>Payment methods</Typography.Title>
<Button
key="panel-footer"
type="outline"
icon={<IconPlus />}
onClick={() => redirectToPortal('/payment-methods')}
>
{(paymentMethods?.data ?? []).length >= 1 ? 'Add another' : 'Add new'}
</Button>
</div>
}
>
<Panel.Content>
{paymentMethods && paymentMethods.data.length >= 1 ? (
<div className="space-y-1">
{paymentMethods.data.map((paymentMethod: any) => {
return (
<div
key={paymentMethod.id}
className="rounded border border-border-secondary-light dark:border-border-secondary-dark p-3 flex items-center justify-between"
>
<div className="flex items-center space-x-4">
<img
src={`/img/payment-methods/${paymentMethod.card.brand
.replace(' ', '-')
.toLowerCase()}.png`}
width="32"
/>
<Typography.Text>
<span className="capitalize">{paymentMethod.card.brand}</span>{' '}
ending in{' '}
<span className="dark:text-white">{paymentMethod.card.last4}</span>
</Typography.Text>
</div>
<div>
<Typography.Text>
<span className="dark:text-white">
{paymentMethod.card.exp_month}
</span>
/
<span className="dark:text-white">
{paymentMethod.card.exp_year}
</span>
</Typography.Text>
</div>
<Button
type="outline"
icon={<IconTrash />}
onClick={() => redirectToPortal('/')}
>
Edit
</Button>
</div>
)
})}
</div>
) : (
<div className="p-4 flex items-center rounded border border-border-secondary-light dark:border-border-secondary-dark border-dashed">
<div className="flex items-center w-full justify-between space-x-2 hover:text-white hover:border-white">
<Typography.Text className="flex items-center space-x-2">
<div>
<IconCreditCard />
</div>
<div>No payment methods</div>
</Typography.Text>
</div>
</div>
)}
</Panel.Content>
<div className="flex items-center w-full justify-between space-x-2 hover:text-white hover:border-white"></div>
</Panel>
</div>
</div>
<div>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center px-16 py-12 border border-border-secondary-light dark:border-border-secondary-dark border-dashed rounded space-y-3">
<Typography.Text className="block" type="secondary">
You have no projects in organization {orgName} yet
</Typography.Text>
<div>
<Link href={`/new/${orgSlug}`}>
<Button type="outline">Create a project</Button>
</Link>
</div>
</div>
) : (
<Typography.Text className="block mb-4">Projects in organization</Typography.Text>
)}
<div className="space-y-8">
{projects.map((project) => {
return <ProjectSubscription key={project.id} project={project} />
})}
</div>
<div className="space-y-8">
<ProjectsSummary projects={projects} />
<CreditBalance balance={balance} isCredit={isCredit} isDebt={isDebt} />
<PaymentMethods
loading={loading}
paymentMethods={paymentMethods?.data ?? []}
redirectToPortal={redirectToPortal}
/>
<BillingAddress
loading={loading}
address={stripe_customer_object?.address ?? {}}
redirectToPortal={redirectToPortal}
/>
<TaxID
loading={loading}
taxIds={taxIds?.data ?? []}
redirectToPortal={redirectToPortal}
/>
</div>
</>
)}
@@ -0,0 +1,31 @@
import { FC } from 'react'
import { Badge } from '@supabase/ui'
interface Props {
balance: string | number
isCredit: boolean
isDebt: boolean
}
const CreditBalance: FC<Props> = ({ balance, isCredit, isDebt }) => {
return (
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<h4>Credit balance</h4>
{isCredit && <Badge>You have credits available</Badge>}
{isDebt && <Badge color="red">Outstanding payments</Badge>}
</div>
<p className="text-sm opacity-50">Charges will be deducted from your balance first</p>
</div>
<div className="flex items-end space-x-1">
{isDebt && <h4 className="opacity-50">-</h4>}
<h4 className="opacity-50">$</h4>
<h2 className="text-4xl">{balance}</h2>
{isCredit && <h4 className="opacity-50">/credits</h4>}
</div>
</div>
)
}
export default CreditBalance
@@ -0,0 +1,81 @@
import { FC } from 'react'
import Panel from 'components/to-be-cleaned/Panel'
import { IconPlus, IconEdit2, IconCreditCard, Button, Input, IconLoader } from '@supabase/ui'
interface Props {
loading: boolean
paymentMethods: any[]
redirectToPortal: (url: string) => void
}
const PaymentMethods: FC<Props> = ({ loading, paymentMethods, redirectToPortal }) => {
return (
<div className="space-y-2">
<div>
<h4>Payment methods</h4>
<p className="text-sm opacity-50">Charges will be deducted from the default card</p>
</div>
<Panel
loading={loading}
footer={
<div>
<Button
key="panel-footer"
type="default"
icon={<IconPlus />}
onClick={() => redirectToPortal('/payment-methods')}
>
Add new card
</Button>
</div>
}
>
<Panel.Content>
{loading && paymentMethods.length === 0 ? (
<div className="flex items-center space-x-4">
<IconLoader className="animate-spin" size={14} />
<p className="text-sm">Retrieving payment methods</p>
</div>
) : paymentMethods.length >= 1 ? (
<div className="space-y-2">
{paymentMethods.map((paymentMethod: any) => (
<div key={paymentMethod.id} className="flex items-center justify-between">
<div className="flex items-center space-x-8">
<img
src={`/img/payment-methods/${paymentMethod.card.brand
.replace(' ', '-')
.toLowerCase()}.png`}
width="32"
/>
<Input
readOnly
size="tiny"
value={`•••• •••• •••• ${paymentMethod.card.last4}`}
/>
<p>
{paymentMethod.card.exp_month}/{paymentMethod.card.exp_year}
</p>
</div>
<Button type="outline" icon={<IconEdit2 />} onClick={() => redirectToPortal('/')}>
Edit
</Button>
</div>
))}
</div>
) : (
<div>
<p className="flex items-center space-x-2 opacity-50">
<div>
<IconCreditCard />
</div>
<div>No payment methods</div>
</p>
</div>
)}
</Panel.Content>
</Panel>
</div>
)
}
export default PaymentMethods
@@ -1,95 +0,0 @@
import { FC, useState, useEffect } from 'react'
import dayjs from 'dayjs'
import { get as _get, maxBy } from 'lodash'
import utc from 'dayjs/plugin/utc'
import { useStore, useSubscriptionStats } from 'hooks'
import { API_URL } from 'lib/constants'
import { Dictionary } from '@supabase/grid'
import { get, post } from 'lib/common/fetch'
import { DATE_FORMAT, STRIPE_PRODUCT_IDS } from 'lib/constants'
import { Subscription, StripeSubscription, chargeableProducts } from 'components/interfaces/Billing'
dayjs.extend(utc)
interface Props {
project: any
}
const ProjectSubscription: FC<Props> = ({ project }) => {
const { ui } = useStore()
const subscriptionStats = useSubscriptionStats()
const [loading, setLoading] = useState<boolean>(true)
const [subscription, setSubscription] = useState<StripeSubscription>()
const [paygStats, setPaygStats] = useState<Dictionary<number>>()
useEffect(() => {
getSubscription()
}, [])
const getSubscription = async () => {
try {
setLoading(true)
const { data: subscription, error }: { data: StripeSubscription; error: any } = await post(
`${API_URL}/stripe/subscription`,
{
subscription_id: project.subscription_id,
}
)
if (error) throw error
setSubscription(subscription)
if (subscription.tier.prod_id === STRIPE_PRODUCT_IDS.PAYG) {
fetchPaygStatistics()
}
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to get project subscription: ${error.message}`,
})
} finally {
setLoading(false)
}
}
const fetchPaygStatistics = async () => {
const startDate = dayjs().utc().startOf('month').format(DATE_FORMAT)
const endDate = dayjs().utc().endOf('month').format(DATE_FORMAT)
const attributes =
'total_db_size_bytes,total_db_egress_bytes,total_storage_size_bytes,total_storage_egress'
const url = `${API_URL}/projects/${
project.ref
}/daily-stats?attribute=${attributes}&startDate=${encodeURIComponent(
startDate
)}&endDate=${encodeURIComponent(endDate)}&interval='1d'`
const { data } = await get(url)
const paygStats: any = {}
chargeableProducts.forEach((product: any) => {
product.features.forEach((feature: any) => {
paygStats[feature.attribute] = _get(maxBy(data, feature.attribute), feature.attribute)
})
})
setPaygStats(paygStats)
}
return (
<Subscription
showProjectName
loading={loading}
project={project}
// @ts-ignore
subscription={subscription}
paygStats={paygStats}
subscriptionStats={subscriptionStats}
// @ts-ignore
currentPeriodStart={subscription?.billing.current_period_start}
// @ts-ignore
currentPeriodEnd={subscription?.billing.current_period_end}
/>
)
}
export default ProjectSubscription
@@ -0,0 +1,116 @@
import Link from 'next/link'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { FC, useState, useEffect } from 'react'
import { IconChevronRight, IconLoader } from '@supabase/ui'
import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { post } from 'lib/common/fetch'
import Panel from 'components/to-be-cleaned/Panel'
import { StripeSubscription } from 'components/interfaces/Billing'
dayjs.extend(utc)
interface ProjectSummaryProps {
project: any
}
const ProjectSummary: FC<ProjectSummaryProps> = ({ project }) => {
const { ui } = useStore()
const [loading, setLoading] = useState(false)
const [subscription, setSubscription] = useState<StripeSubscription>()
const currentPeriodStart = subscription?.billing?.current_period_start ?? 0
const currentPeriodEnd = subscription?.billing?.current_period_end ?? 0
useEffect(() => {
getSubscription()
}, [])
const getSubscription = async () => {
try {
setLoading(true)
const { data: subscription, error }: { data: StripeSubscription; error: any } = await post(
`${API_URL}/stripe/subscription`,
{ subscription_id: project.subscription_id }
)
if (error) throw error
setSubscription(subscription)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to get project subscription: ${error.message}`,
})
} finally {
setLoading(false)
}
}
return (
<div className="w-full px-6 py-3 flex items-center">
<div className="w-[25%]">
<p>{project.name}</p>
</div>
{loading ? (
<div className="w-[75%] flex items-center justify-center">
<IconLoader size={14} className="animate-spin" />
</div>
) : (
<>
<div className="w-[20%]">
<p>{subscription?.tier.name ?? ''}</p>
</div>
<div className="w-[40%] space-x-2 flex items-center">
<p>{dayjs.unix(currentPeriodStart).utc().format('MMM D, YYYY')}</p>
<p>-</p>
<p>{dayjs.unix(currentPeriodEnd).utc().format('MMM D, YYYY')}</p>
</div>
<div className="flex items-center justify-end w-[15%]">
<Link href={`/project/${project.ref}/settings/billing`}>
<a className="flex items-center space-x-2 group">
<p className="transition group-hover:opacity-100 opacity-0 text-sm">View details</p>
<IconChevronRight />
</a>
</Link>
</div>
</>
)}
</div>
)
}
interface ProjectsSummaryProps {
projects: any
}
const ProjectsSummary: FC<ProjectsSummaryProps> = ({ projects }) => {
return (
<div className="space-y-2">
<h4>Projects at a glance</h4>
<Panel
title={
<div className="w-full flex items-center">
<div className="w-[25%]">
<p className="text-sm opacity-50">Name</p>
</div>
<div className="w-[20%]">
<p className="text-sm opacity-50">Plan</p>
</div>
<div className="w-[40%]">
<p className="text-sm opacity-50">Billing cycle</p>
</div>
<div className="w-[15%]" />
</div>
}
>
{projects.map((project: any) => (
<ProjectSummary key={project.ref} project={project} />
))}
</Panel>
</div>
)
}
export default ProjectsSummary
@@ -0,0 +1,59 @@
import { FC } from 'react'
import { Input, Button, IconLoader } from '@supabase/ui'
import Panel from 'components/to-be-cleaned/Panel'
interface Props {
loading: boolean
taxIds: any[]
redirectToPortal: (url: string) => void
}
const TaxID: FC<Props> = ({ loading, taxIds, redirectToPortal }) => {
return (
<div className="space-y-2">
<div>
<h4>Tax ID</h4>
<p className="text-sm opacity-50">
If you would like to include specific tax ID(s) to your invoices
</p>
</div>
<Panel
loading={loading}
footer={
<div>
<Button type="default" onClick={() => redirectToPortal('/customer/update')}>
Edit Tax IDs
</Button>
</div>
}
>
<Panel.Content className="w-3/5">
{loading && taxIds.length === 0 ? (
<div className="flex items-center space-x-4">
<IconLoader className="animate-spin" size={14} />
<p>Retrieving tax information</p>
</div>
) : taxIds.length >= 1 ? (
<div className="w-full space-y-2">
{taxIds.map((taxId: any) => {
const taxIdType = taxId.type.replace('_', ' ').toUpperCase()
return (
<div key={taxId.id} className="flex items-center space-x-6">
<p>{taxIdType}</p>
<Input readOnly value={taxId.value} />
</div>
)
})}
</div>
) : (
<div>
<p className="flex items-center space-x-2 opacity-50">No tax IDs</p>
</div>
)}
</Panel.Content>
</Panel>
</div>
)
}
export default TaxID