Billing Page component

This commit is contained in:
Sergey Garin 2024-04-30 16:06:54 +03:00
parent 38d7fbb94d
commit eea6f534cf
25 changed files with 866 additions and 48 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M0 5c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2H0Zm0 2h16v4a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V7Zm14 2h-3v1h3V9Z" clip-rule="evenodd"/><path fill="#000" d="M0 5h16v2H0z" opacity=".3"/></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -27,6 +27,7 @@ export const SET_USERNAME_PATH = '/set-username'
/** Path to page in which the currently active payment plan can be managed. */
export const SUBSCRIBE_PATH = '/subscribe'
export const SUBSCRIBE_SUCCESS_PATH = '/subscribe/success'
/** A {@link RegExp} matching all paths. */
export const ALL_PATHS_REGEX = new RegExp(
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +

View File

@ -14,12 +14,20 @@ export const ALERT_STYLES = twv.tv({
fullWidth: { true: 'w-full' },
variant: {
custom: '',
outline: 'border border-2 bg-transparent border-primary/30 text-primary',
neutral: 'border border-2 bg-gray-100 border-gray-800 text-primary',
error: 'border border-2 bg-red-100 border-danger text-primary',
info: 'border border-2 bg-blue-100 border-blue-800 text-blue-800',
success: 'border border-2 bg-green-100 border-green-800 text-green-800',
warning: 'border border-2 bg-yellow-100 border-yellow-800 text-yellow-800',
outline: 'bg-transparent border-primary/30 text-primary',
neutral: 'bg-gray-100 border-gray-800 text-primary',
error: 'bg-red-100 border-danger text-primary',
info: 'bg-blue-100 border-blue-800 text-blue-800',
success: 'bg-green-100 border-green-800 text-green-800',
warning: 'bg-yellow-100 border-yellow-800 text-yellow-800',
},
border: {
custom: '',
none: '',
small: 'border',
medium: 'border-2',
large: 'border-[3px]',
xlarge: 'border-4',
},
rounded: {
none: 'rounded-none',
@ -42,6 +50,7 @@ export const ALERT_STYLES = twv.tv({
variant: 'error',
size: 'medium',
rounded: 'large',
border: 'medium',
},
})
@ -60,7 +69,8 @@ export const Alert = React.forwardRef(function Alert(
props: AlertProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props
const { children, className, variant, size, rounded, fullWidth, border, ...containerProps } =
props
if (variant === 'error') {
containerProps.tabIndex = -1
@ -69,7 +79,7 @@ export const Alert = React.forwardRef(function Alert(
return (
<div
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth, border })}
ref={mergeRefs.mergeRefs(ref, e => {
if (variant === 'error') {
e?.focus()

View File

@ -207,6 +207,9 @@ export const Form = React.forwardRef(function Form<
ref={ref}
onSubmit={event => {
event.preventDefault()
// since events in react bubble over the VDOM tree
// we need to stop the event propagation to prevent the event to bubble up
// to parent forms and trigger their submit handlers
event.stopPropagation()
if (isOffline && !canSubmitOffline) {

View File

@ -60,9 +60,9 @@ export const TEXT_STYLE = twv.tv({
},
transform: {
none: '',
capitalize: 'text-capitalize',
lowercase: 'text-lowercase',
uppercase: 'text-uppercase',
capitalize: 'capitalize',
lowercase: 'lowercase',
uppercase: 'uppercase',
},
truncate: {
/* eslint-disable @typescript-eslint/naming-convention */
@ -199,6 +199,7 @@ export const Text = React.forwardRef(function Text(
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
// eslint-disable-next-line @typescript-eslint/naming-convention
Heading: React.FC<HeadingProps>
InsideTextProvider: React.FC<React.PropsWithChildren>
}
/**
@ -219,3 +220,14 @@ Text.Heading = React.forwardRef(function Heading(
const { level = 1, ...textProps } = props
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
})
/**
* Component that passes down over the children the information that they are inside a Text component
*/
Text.InsideTextProvider = function InsideTextProvider({ children }: React.PropsWithChildren) {
return (
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
{children}
</textProvider.TextProvider>
)
}

View File

@ -1,7 +1,7 @@
/** @file A styled settings section. */
import * as React from 'react'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -21,11 +21,8 @@ export interface SettingsSectionProps extends Readonly<React.PropsWithChildren>
/** A styled settings section. */
export default function SettingsSection(props: SettingsSectionProps) {
const { title, noFocusArea = false, className, children } = props
const heading = (
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
{title}
</aria.Heading>
)
const heading = <ariaComponents.Text.Heading level={2}>{title}</ariaComponents.Text.Heading>
return noFocusArea ? (
<div className={tailwindMerge.twMerge('flex flex-col gap-settings-section-header', className)}>

View File

@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
import AccountSettingsTab from '#/layouts/Settings/AccountSettingsTab'
import ActivityLogSettingsTab from '#/layouts/Settings/ActivityLogSettingsTab'
import BillingSettingsTab from '#/layouts/Settings/BillingSettingsTab'
import KeyboardShortcutsSettingsTab from '#/layouts/Settings/KeyboardShortcutsSettingsTab'
import MembersSettingsTab from '#/layouts/Settings/MembersSettingsTab'
import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab'
@ -80,6 +81,10 @@ export default function Settings(props: SettingsProps) {
content = backend == null ? null : <ActivityLogSettingsTab backend={backend} />
break
}
case SettingsTab.billingAndPlans: {
content = <BillingSettingsTab />
break
}
default: {
// This case should be removed when all settings tabs are implemented.
content = <></>
@ -99,7 +104,13 @@ export default function Settings(props: SettingsProps) {
<div className="mt-4 flex flex-1 flex-col gap-6 overflow-hidden px-page-x">
<aria.Heading level={1} className="flex items-center px-heading-x">
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
<ariaComponents.Button
icon={BurgerMenuIcon}
className="sm:hidden"
variant="icon"
size="small"
/>
<aria.Popover UNSTABLE_portalContainer={root}>
<SettingsSidebar
isMenu

View File

@ -0,0 +1,109 @@
/**
* @file
*
* The billing settings tab.
*/
import * as React from 'react'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as components from './components'
/**
* The billing settings tab.
*/
export default function BillingSettingsTab() {
const { getText } = text.useText()
return (
<div className="flex w-full flex-col gap-8 md:grid md:grid-cols-2">
<section>
<ariaComponents.Text.Heading level="2" className="mb-2.5">
{getText('billingSummary')}
</ariaComponents.Text.Heading>
<components.BillingSummary
plan="enterprise"
nextPayment={100}
currency="USD"
nextInvoiceDate="2021-09-01"
/>
</section>
<section>
<ariaComponents.Text.Heading level="2" className="mb-2.5">
{getText('paymentMethod')}
</ariaComponents.Text.Heading>
<components.PaymentMethod
currentPaymentMethod={{
id: '1',
type: 'Mastercard',
expMonth: 12,
expYear: 2023,
last4: '1234',
}}
paymentMethods={[
{
id: '1',
type: 'Mastercard',
expMonth: 12,
expYear: 2023,
last4: '1234',
},
{
id: '2',
type: 'Visa',
expMonth: 12,
expYear: 2023,
last4: '5678',
},
]}
/>
</section>
<section className="col-span-full">
<ariaComponents.Text.Heading level="2" className="mb-2.5">
{getText('yourPlan')}
</ariaComponents.Text.Heading>
<components.PlanDetails plan="team" nextPlan="enterprise" />
</section>
<section className="col-span-full">
<ariaComponents.Text.Heading level="2" id="BillingInvoices" className="mb-2.5">
{getText('invoiceHistory')}
</ariaComponents.Text.Heading>
<components.InvoicesTable
titleId="BillingInvoices"
items={[
{
id: '1',
date: '2021-08-01',
amount: 100,
status: 'Paid',
description: 'This is a description',
},
{
id: '2',
date: '2021-07-01',
amount: 100,
status: 'Paid',
description: 'This is a description',
},
{
id: '3',
date: '2021-06-01',
amount: 100,
description: 'This is a description',
status: 'Paid',
},
]}
/>
</section>
</div>
)
}

View File

@ -0,0 +1,75 @@
import * as React from 'react'
import * as twMerge from 'tailwind-merge'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as dateTime from '#/utilities/dateTime'
import type * as types from './types'
/**
*
*/
export interface BillingSummaryProps {
readonly className?: string
readonly plan: types.PlanType
readonly nextPayment: number
readonly nextInvoiceDate: dateTime.Rfc3339DateTime
readonly currency: string
}
/**
*
*/
export function BillingSummary(props: BillingSummaryProps) {
const { plan, className = '', nextPayment, currency = 'USD', nextInvoiceDate } = props
const { getText, locale } = textProvider.useText()
return (
<div
className={twMerge.twMerge(
'-ml-5 -mt-1 table table-auto border-spacing-x-5 border-spacing-y-1',
className
)}
>
<div className="table-row">
<ariaComponents.Text weight="medium" className="table-cell">
{getText('currentPlan')}
</ariaComponents.Text>
<ariaComponents.Text weight="bold" className="table-cell">
{getText(plan)}
</ariaComponents.Text>
</div>
<div className="table-row">
<ariaComponents.Text weight="medium" className="table-cell">
{getText('nextPayment')}
</ariaComponents.Text>
<ariaComponents.Text weight="bold" className="table-cell">
{nextPayment.toLocaleString(locale, {
style: 'currency',
currency: currency,
})}
</ariaComponents.Text>
</div>
<div className="table-row">
<ariaComponents.Text weight="medium" className="table-cell">
{getText('nextInvoiceDate')}
</ariaComponents.Text>
<ariaComponents.Text
weight="bold"
className="table-cell"
tooltip={new Date(nextInvoiceDate).toLocaleString(locale)}
tooltipDisplay="always"
>
{new Date(nextInvoiceDate).toLocaleString(locale, {
dateStyle: 'long',
})}
</ariaComponents.Text>
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
/**
* @file
*
* InvoicesCell component
*/
import * as React from 'react'
import * as aria from '#/components/aria'
/**
*
*/
export interface InvoicesCellProps extends aria.CellProps {}
/**
*
*/
export function InvoicesCell(props: InvoicesCellProps) {
return (
<aria.Cell
className="border-x-2 border-transparent bg-clip-padding px-cell-x py-1 first:rounded-l-full last:rounded-r-full last:border-r-0"
{...props}
/>
)
}

View File

@ -0,0 +1,15 @@
import * as React from 'react'
import * as aria from '#/components/aria'
/**
*
*/
export interface InvoicesColumnProps extends aria.ColumnProps {}
/**
*
*/
export function InvoicesColumn(props: InvoicesColumnProps) {
return <aria.Column {...props} className="px-cell-x" />
}

View File

@ -0,0 +1,15 @@
import * as React from 'react'
import * as aria from '#/components/aria'
/**
*
*/
export interface InvoicesRowProps<T> extends aria.RowProps<T> {}
/**
*
*/
export function InvoicesRow<T extends object>(props: InvoicesRowProps<T>) {
return <aria.Row<T> {...props} className="rounded-rows-child" />
}

View File

@ -0,0 +1,125 @@
/**
* @file
*
* InvoicesTable component
*/
import * as React from 'react'
import OpenInNewIcon from 'enso-assets/open.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as invoicesCell from './InvoicesCell'
import * as invocesColumn from './InvoicesColumn'
import * as invoicesRow from './InvoicesRow'
/**
*
*/
export interface InvoicesTableProps {
readonly titleId: string
readonly items: ReadonlyArray<{
readonly id: string
readonly date: string
readonly amount: number
readonly status: string
readonly description: string
}>
}
/**
*
*/
export function InvoicesTable(props: InvoicesTableProps) {
const { titleId, items } = props
const { locale, getText } = textProvider.useText()
return (
<aria.ResizableTableContainer>
<aria.Table aria-labelledby={titleId} className="-mx-2 text-left rounded-rows">
<aria.TableHeader>
<invocesColumn.InvoicesColumn
id="symbol"
allowsSorting
className="h-full min-w-drive-name-column border-2 border-y border-l-0 border-transparent bg-clip-padding last:w-full last:rounded-r-full last:border-r-0"
>
<ariaComponents.Text weight="semibold" variant="subtitle">
Date
</ariaComponents.Text>
</invocesColumn.InvoicesColumn>
<invocesColumn.InvoicesColumn
id="name"
isRowHeader
allowsSorting
className="h-full min-w-drive-name-column border-2 border-y border-l-0 border-transparent bg-clip-padding rounded-rows-skip-level last:w-full last:rounded-r-full last:border-r-0"
>
<ariaComponents.Text weight="semibold" variant="subtitle">
Amount
</ariaComponents.Text>
</invocesColumn.InvoicesColumn>
<invocesColumn.InvoicesColumn
id="sector"
allowsSorting
defaultWidth="2fr"
className="h-full min-w-drive-name-column border-2 border-y border-l-0 border-transparent bg-clip-padding rounded-rows-skip-level last:w-full last:rounded-r-full last:border-r-0"
>
<ariaComponents.Text weight="semibold" variant="subtitle">
Description
</ariaComponents.Text>
</invocesColumn.InvoicesColumn>
</aria.TableHeader>
<aria.TableBody items={items}>
{item => (
<invoicesRow.InvoicesRow key={item.id}>
<invoicesCell.InvoicesCell>
<div className="flex items-center gap-1">
<ariaComponents.Text>
{new Date(item.date).toLocaleString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</ariaComponents.Text>
<ariaComponents.Button
variant="icon"
icon={OpenInNewIcon}
aria-label={getText('downloadInvoice')}
target="_blank"
size="small"
/>
</div>
</invoicesCell.InvoicesCell>
<invoicesCell.InvoicesCell>
<div className="flex items-center gap-2">
<ariaComponents.Text weight="semibold">
{item.amount.toLocaleString(locale, {
style: 'currency',
currency: 'USD',
})}
</ariaComponents.Text>
<ariaComponents.Text
variant="body"
className="rounded-full border border-share px-2 pb-[1px]"
color="success"
disableLineHeightCompensation
>
{item.status}
</ariaComponents.Text>
</div>
</invoicesCell.InvoicesCell>
<invoicesCell.InvoicesCell>
<ariaComponents.Text truncate="3">{item.description}</ariaComponents.Text>
</invoicesCell.InvoicesCell>
</invoicesRow.InvoicesRow>
)}
</aria.TableBody>
</aria.Table>
</aria.ResizableTableContainer>
)
}

View File

@ -0,0 +1 @@
export * from './InvoicesTable'

View File

@ -0,0 +1,180 @@
/**
* @file
*/
import * as React from 'react'
import * as twMerge from 'tailwind-merge'
import Edit from 'enso-assets/pen.svg'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import AddPaymentMethodModal from '#/modals/AddPaymentMethodModal'
/**
*
*/
export interface PaymentMethodProps {
readonly className?: string
readonly currentPaymentMethod: {
id: string
type: string
last4: string
expMonth: number
expYear: number
}
readonly paymentMethods: {
id: string
type: string
last4: string
expMonth: number
expYear: number
}[]
readonly onAddPaymentMethod: () => void
readonly onRemovePaymentMethod: (id: string) => void
readonly onSetDefaultPaymentMethod: (id: string) => void
readonly onEditPaymentMethod: (id: string) => void
}
/**
* PaymentMethod component
*/
export function PaymentMethod(props: PaymentMethodProps) {
const {
className,
currentPaymentMethod,
paymentMethods,
onAddPaymentMethod,
onSetDefaultPaymentMethod,
} = props
const { getText } = text.useText()
const schema = ariaComponents.Form.useFormSchema(z => z.object({ paymentMethod: z.string() }))
return (
<div
className={twMerge.twMerge(
'-ml-5 -mt-1 table table-auto border-spacing-x-5 border-spacing-y-1',
className
)}
>
<div className="table-row">
<div className="table-cell">
<ariaComponents.Text weight="medium" nowrap>
{getText('billingPageCurrentPaymentMethod')}
</ariaComponents.Text>
</div>
<div className="table-cell">
<div className="flex items-center gap-1.5">
<ariaComponents.Text weight="semibold">
{getText(
'billingPagePaymentMethod',
currentPaymentMethod.type,
currentPaymentMethod.last4
)}
</ariaComponents.Text>
<ariaComponents.DialogTrigger>
<ariaComponents.Button
variant="icon"
size="small"
icon={Edit}
aria-label={getText('billingPageEditPaymentMethod')}
/>
<ariaComponents.Popover size="medium">
<ariaComponents.Form
schema={schema}
defaultValues={{ paymentMethod: currentPaymentMethod.id }}
method="dialog"
gap="medium"
onSubmit={({ paymentMethod }) => {
onSetDefaultPaymentMethod(paymentMethod)
}}
>
{({ form }) => (
<>
<div className="flex flex-col">
<ariaComponents.Text.Heading
level="2"
variant="h1"
className="flex flex-col"
disableLineHeightCompensation
>
{getText('billingPageSelectPaymentMethod')}
</ariaComponents.Text.Heading>
<ariaComponents.Text elementType="p" disableLineHeightCompensation>
{getText('billingPageSelectPaymentMethodDescription')}
</ariaComponents.Text>
</div>
<div className="flex w-full flex-col items-start gap-0.5">
<ariaComponents.Radio.Group
form={form}
name="paymentMethod"
fullWidth
label={getText('paymentMethod')}
>
{paymentMethods.map(paymentMethod => (
<ariaComponents.Radio key={paymentMethod.id} value={paymentMethod.id}>
<span className="flex w-full justify-between">
<span>
{getText(
'billingPagePaymentMethod',
paymentMethod.type,
paymentMethod.last4
)}
</span>
<span>
{getText(
'billingPageExpires',
paymentMethod.expMonth,
paymentMethod.expYear
)}
</span>
</span>
</ariaComponents.Radio>
))}
</ariaComponents.Radio.Group>
<ariaComponents.DialogTrigger>
<ariaComponents.Button variant="link" size="small" className="mt-1">
{getText('billingPageAddPaymentMethod')}
</ariaComponents.Button>
<AddPaymentMethodModal
title={getText('billingPageAddPaymentMethod')}
onSubmit={onAddPaymentMethod}
submitText={getText('billingPageAddPaymentMethod')}
/>
</ariaComponents.DialogTrigger>
</div>
<ariaComponents.Form.FormError />
<ariaComponents.ButtonGroup direction="row" align="start">
<ariaComponents.Form.Submit
size="medium"
fullWidth
onPress={() => {
onSetDefaultPaymentMethod(currentPaymentMethod.id)
}}
>
{getText('update')}
</ariaComponents.Form.Submit>
</ariaComponents.ButtonGroup>
</>
)}
</ariaComponents.Form>
</ariaComponents.Popover>
</ariaComponents.DialogTrigger>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,133 @@
/**
* @file
*
* Plan details.
* Shows summary with plan name, price, and features.
* Also provides a button to upgrade the plan and show features you will get.
*/
import * as React from 'react'
import NotAvailableIcon from 'enso-assets/cross.svg'
import CheckIcon from 'enso-assets/tick.svg'
import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import type * as types from './types'
/**
* Props for {@link PlanDetails}.
*/
export interface PlanDetailsProps {
readonly plan: types.PlanType
readonly nextPlan?: types.PlanType | null
}
/**
* Plan details.
* Shows summary with plan name, price, and features.
* Also provides a button to upgrade the plan and show features you will get.
*/
export function PlanDetails(props: PlanDetailsProps) {
const { plan, nextPlan } = props
const { getText } = textProvider.useText()
return (
<ariaComponents.Alert variant="outline" size="custom" rounded="xlarge" border="small">
<div className="flex items-center gap-2 border-b border-primary/30 px-3 py-1.5">
<ariaComponents.Text variant="subtitle" className="flex flex-col">
{getText(plan)}
<ariaComponents.Text weight="normal">
{getText(`${plan}PlanSubtitle`)}
</ariaComponents.Text>
</ariaComponents.Text>
<ariaComponents.Button
variant="outline"
href="/subscribe"
size="small"
rounded="full"
className="ml-auto"
>
{getText('comparePlans')}
</ariaComponents.Button>
</div>
<div className="flex p-3">
<article className="flex flex-1 flex-col" role="list">
{getText(FEATURES_BY_PLAN[plan])
.split(';')
.map(feature => (
<div
key={feature}
className="flex items-center gap-1.5"
role="listitem"
aria-label={feature}
>
<div className="flex aspect-square w-4 place-items-center rounded-full bg-green/30 text-green">
<SvgMask src={CheckIcon} className="h-full w-full" />
</div>
<ariaComponents.Text>{feature}</ariaComponents.Text>
</div>
))}
</article>
{nextPlan && (
<>
<ariaComponents.Separator orientation="vertical" className="mx-4" />
<article className="flex flex-1 flex-col items-start">
<ariaComponents.Text
elementType="h3"
variant="body"
weight="bold"
className="-mb-0.5 -mt-1"
>
{getText('featuresYouWillGet', getText(nextPlan))}
</ariaComponents.Text>
<div className="flex flex-col" role="list">
{getText(FEATURES_BY_PLAN[nextPlan])
.split(';')
.map(feature => (
<div
key={feature}
className="flex items-center gap-1.5"
role="listitem"
aria-label={feature}
>
<div className="flex aspect-square w-4 place-items-center rounded-full bg-primary/60 text-white/80">
<SvgMask src={NotAvailableIcon} className="h-full w-full rotate-45" />
</div>
<ariaComponents.Text>{feature}</ariaComponents.Text>
</div>
))}
</div>
<ariaComponents.Button
variant="link"
rounded="full"
size="small"
href="/subscribe"
className="mt-1"
>
{getText('upgradeTo', getText(nextPlan))}
</ariaComponents.Button>
</article>
</>
)}
</div>
</ariaComponents.Alert>
)
}
const FEATURES_BY_PLAN: Record<(typeof types.PLANS)[number], text.TextId> = {
solo: 'soloPlanFeatures',
team: 'teamPlanFeatures',
enterprise: 'enterprisePlanFeatures',
}

View File

@ -0,0 +1,42 @@
import * as React from 'react'
import Open from 'enso-assets/open.svg'
import * as appUtils from '#/appUtils'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as backend from '#/services/Backend'
/**
*
*/
export interface YourPlanProps {
readonly plan: backend.Plan
}
/**
* YourPlan component
*/
export function YourPlan(props: YourPlanProps) {
const { plan } = props
const { getText } = text.useText()
return (
<div className="flex flex-col items-start">
<ariaComponents.Text variant="body">{plan}</ariaComponents.Text>
<ariaComponents.Button
variant="link"
size="medium"
icon={Open}
iconPosition="end"
href={appUtils.SUBSCRIBE_PATH}
>
{getText('change')}
</ariaComponents.Button>
</div>
)
}

View File

@ -0,0 +1,9 @@
/**
* This file is used to export all the components in the billing page
*/
export * from './PaymentMethod'
export * from './YourPlan'
export * from './InvoicesTable'
export * from './BillingSummary'
export * from './PlanDetails'

View File

@ -0,0 +1,13 @@
/**
* @file
*
* Shared types for the billing settings tab.
*/
import * as backend from '#/services/Backend'
export const PLANS = backend.PLANS
/**
*
*/
export type PlanType = backend.PlanType

View File

@ -0,0 +1,8 @@
/**
* @file
*
* Barrel file for the Billing settings tab.
*/
import BillingSettingsTab from './BillingSettingsTab'
export default BillingSettingsTab

View File

@ -13,7 +13,6 @@ import * as textProvider from '#/providers/TextProvider'
import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar'
import * as ariaComponents from '#/components/AriaComponents'
import SettingsPage from '#/components/styled/settings/SettingsPage'
import SettingsSection from '#/components/styled/settings/SettingsSection'
import type * as backendModule from '#/services/Backend'
@ -62,7 +61,6 @@ export function MembersSettingsTab() {
: null
return (
<SettingsPage>
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden">
<MembersSettingsTabBar
seatsLeft={seatsLeft}
@ -100,36 +98,35 @@ export function MembersSettingsTab() {
</tr>
))}
{invitations.map(invitation => (
<tr key={invitation.userEmail} className="group h-row rounded-rows-child">
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<span className="block text-sm">{invitation.userEmail}</span>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('pendingInvitation')}
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<ariaComponents.CopyButton
size="custom"
copyText={`enso://auth/registration?organization_id=${invitation.organizationId}`}
aria-label={getText('copyInviteLink')}
copyIcon={false}
>
{getText('copyInviteLink')}
</ariaComponents.CopyButton>
{invitations.map(invitation => (
<tr key={invitation.userEmail} className="group h-row rounded-rows-child">
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<span className="block text-sm">{invitation.userEmail}</span>
</td>
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
<div className="flex flex-col">
{getText('pendingInvitation')}
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
<ariaComponents.CopyButton
size="custom"
copyText={`enso://auth/registration?organization_id=${invitation.organizationId}`}
aria-label={getText('copyInviteLink')}
copyIcon={false}
>
{getText('copyInviteLink')}
</ariaComponents.CopyButton>
<ResendInvitationButton invitation={invitation} backend={backend} />
<ResendInvitationButton invitation={invitation} backend={backend} />
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
</ariaComponents.ButtonGroup>
</div>
</td>
</tr>
))}
</tbody>
</table>
</SettingsSection>
</SettingsPage>
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
</ariaComponents.ButtonGroup>
</div>
</td>
</tr>
))}
</tbody>
</table>
</SettingsSection>
)
}

View File

@ -1,6 +1,7 @@
/** @file A panel to switch between settings tabs. */
import * as React from 'react'
import CreditCard from 'enso-assets/credit_card.svg'
import KeyboardShortcutsIcon from 'enso-assets/keyboard_shortcuts.svg'
import LogIcon from 'enso-assets/log.svg'
import PeopleSettingsIcon from 'enso-assets/people_settings.svg'
@ -43,6 +44,12 @@ const SECTIONS: SettingsSectionData[] = [
{
name: 'Access',
tabs: [
{
name: 'Billing and plans',
settingsTab: SettingsTab.billingAndPlans,
icon: CreditCard,
organizationOnly: true,
},
{
name: 'Members',
settingsTab: SettingsTab.members,

View File

@ -416,6 +416,10 @@ export enum Plan {
enterprise = 'enterprise',
}
/**
* A type representing a plan.
*/
export type PlanType = 'enterprise' | 'solo' | 'team'
export const PLANS = Object.values(Plan)
// This is a function, even though it does not look like one.

View File

@ -677,6 +677,26 @@
"enterprise": "Organization",
"enterprisePlan": "Organization",
"billingSummary": "Billing Summary",
"paymentMethod": "Payment Method",
"nextPayment": "Next Payment",
"nextInvoiceDate": "Next Invoice Date",
"yourPlan": "Your Plan",
"comparePlans": "Compare Plans",
"featuresYouWillGet": "Upgrade to $0 to get:",
"currentPlan": "Current Plan",
"invoiceHistory": "Invoice History",
"billingPageBackButton": "Back to Enso dashboard",
"billingPageCurrentPaymentMethod": "Current payment method",
"billingPageEditPaymentMethod": "Edit payment method",
"billingPageSelectPaymentMethod": "Select payment method",
"billingPageAddPaymentMethod": "Add payment method",
"billingPageSaveDefaultPaymentMethod": "Save as default payment method",
"billingPageSelectPaymentMethodDescription": "Select a payment method to use for your subscription.",
"billingPagePaymentMethod": "$0 •••• $1",
"billingPageExpires": "Expires $0/$1",
"downloadInvoice": "Download Invoice",
"paywallScreenTitle": "Unlock the potential of Enso",
"paywallScreenDescription": "Upgrade to $0 to unlock additional features and get access to priority support.",
"paywallAvailabilityLevel": "Available on $0 plan",

View File

@ -118,6 +118,10 @@ interface PlaceholderOverrides {
readonly inviteFormSeatsLeftError: [exceedBy: number]
readonly inviteFormSeatsLeft: [seatsLeft: number]
readonly seatsLeft: [seatsLeft: number, seatsTotal: number]
readonly billingPageExpires: [number | string, number | string]
readonly billingPagePaymentMethod: [string, string]
readonly featuresYouWillGet: [string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */