mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Billing Page component
This commit is contained in:
parent
38d7fbb94d
commit
eea6f534cf
1
app/ide-desktop/lib/assets/credit_card.svg
Normal file
1
app/ide-desktop/lib/assets/credit_card.svg
Normal 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 |
@ -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}|` +
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)}>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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" />
|
||||
}
|
@ -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" />
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './InvoicesTable'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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'
|
@ -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
|
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for the Billing settings tab.
|
||||
*/
|
||||
import BillingSettingsTab from './BillingSettingsTab'
|
||||
|
||||
export default BillingSettingsTab
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
|
@ -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}. */
|
||||
|
Loading…
Reference in New Issue
Block a user