Draft of billing page

This commit is contained in:
Sergey Garin 2024-04-30 16:06:54 +03:00
parent c5ed30bc55
commit aec7cb6f38
20 changed files with 653 additions and 10 deletions

View File

@ -66,6 +66,7 @@ import Registration from '#/pages/authentication/Registration'
import ResetPassword from '#/pages/authentication/ResetPassword'
import RestoreAccount from '#/pages/authentication/RestoreAccount'
import SetUsername from '#/pages/authentication/SetUsername'
import * as billing from '#/pages/billing'
import Dashboard from '#/pages/dashboard/Dashboard'
import { Subscribe } from '#/pages/subscribe/Subscribe'
import { SubscribeSuccess } from '#/pages/subscribe/SubscribeSuccess'
@ -386,6 +387,16 @@ function AppRouter(props: AppRouterProps) {
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route
path={appUtils.BILLING_PATH}
element={
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<billing.Billing />
</React.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={

View File

@ -32,11 +32,14 @@ export const ENTER_OFFLINE_MODE_PATH = '/offline'
/** 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'
/** Path to the billing page, which is used to manage the billing information */
export const BILLING_PATH = '/billing'
/** A {@link RegExp} matching all paths. */
export const ALL_PATHS_REGEX = new RegExp(
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${SET_USERNAME_PATH}|${RESTORE_USER_PATH}|` +
`${ENTER_OFFLINE_MODE_PATH}|${SUBSCRIBE_PATH}|${SUBSCRIBE_SUCCESS_PATH})$`
`${ENTER_OFFLINE_MODE_PATH}|${SUBSCRIBE_PATH}|${SUBSCRIBE_SUCCESS_PATH}|${BILLING_PATH})$`
)
// ===========

View File

@ -19,7 +19,7 @@ export interface AlertProps extends React.PropsWithChildren {
/**
* Variants for the Alert component.
*/
export type AlertVariant = 'custom' | 'error' | 'info' | 'success' | 'warning'
export type AlertVariant = 'custom' | 'error' | 'info' | 'neutral' | 'success' | 'warning'
/**
* Sizes for the Alert component.
@ -53,6 +53,7 @@ export const Alert = React.forwardRef(
const VARIANT_CLASSES: Record<AlertVariant, string> = {
custom: '',
neutral: 'bg-gray-200 border-gray-800 text-gray-800',
error: 'bg-red-200 border-red-800 text-red-800',
info: 'bg-blue-200 border-blue-800 text-blue-800',
success: 'bg-green-200 border-green-800 text-green-800',

View File

@ -41,7 +41,7 @@ export interface BaseButtonProps {
/**
* The variant of the button
*/
readonly variant: Variant
readonly variant?: Variant
/**
* The icon to display in the button
*/
@ -90,7 +90,7 @@ export type Variant =
| 'submit'
const DEFAULT_CLASSES =
'flex whitespace-nowrap cursor-pointer border border-transparent transition-[opacity,outline-offset] duration-150 ease-in-out select-none text-center items-center justify-center'
'flex whitespace-nowrap cursor-pointer border border-transparent transition-[opacity,outline-offset] duration-150 ease-in-out select-none text-center items-center justify-center w-fit'
const FOCUS_CLASSES =
'focus-visible:outline-offset-2 focus:outline-none focus-visible:outline focus-visible:outline-primary'
const EXTRA_CLICK_ZONE_CLASSES = 'flex relative before:inset-[-12px] before:absolute before:z-10'
@ -140,7 +140,7 @@ export const Button = React.forwardRef(function Button(
const {
className,
children,
variant,
variant = 'primary',
icon,
loading = false,
isDisabled = loading,
@ -169,18 +169,19 @@ export const Button = React.forwardRef(function Button(
)
const childrenFactory = (): React.ReactNode => {
const isIconOnly = children == null || children === '' || children === ' '
// Icon only button
if (variant === 'icon' && icon != null) {
if (variant === 'icon' && icon != null && isIconOnly) {
return (
<aria.Text className={EXTRA_CLICK_ZONE_CLASSES}>
<SvgMask src={icon} className="flex-none" />
<SvgMask src={icon} className="w-[1.5em] flex-none" />
</aria.Text>
)
} else {
// Default button
return (
<aria.Text className={clsx('flex items-center gap-2', ICON_POSITION[iconPosition])}>
{icon != null && <SvgMask src={icon} className="flex-none" />}
<aria.Text className={clsx('flex items-center gap-[0.3em]', ICON_POSITION[iconPosition])}>
{icon != null && <SvgMask src={icon} className="w-[1.2em] flex-none" />}
<>{children}</>
</aria.Text>
)

View File

@ -0,0 +1,48 @@
/**
* @file
*
* The separator component.
*/
import * as React from 'react'
import * as tw from 'tailwind-merge'
import * as aria from '#/components/aria'
/**
* The props for the separator component.
*/
export interface SeparatorProps extends aria.SeparatorProps {
readonly className?: string
readonly variant?: SeparatorVariant
readonly orientation?: SeparatorOrientation
}
/**
*
*/
export type SeparatorOrientation = 'horizontal' | 'vertical'
/**
*
*/
export type SeparatorVariant = 'primary' | 'secondary'
const VARIANT_MAP: Record<SeparatorVariant, string> = {
primary: 'border-primary',
secondary: 'border-gray-500',
}
/**
* A separator component.
*/
export function Separator(props: SeparatorProps) {
const { orientation = 'horizontal', variant = 'primary', className, ...rest } = props
return (
<aria.Separator
{...rest}
orientation={orientation}
className={tw.twMerge('isolate rounded-full', VARIANT_MAP[variant], className)}
/>
)
}

View File

@ -0,0 +1,194 @@
/**
* @file Text component
*/
import * as React from 'react'
import * as tw from 'tailwind-merge'
import * as aria from '#/components/aria'
import Portal from '#/components/Portal'
import * as mergeRefs from '#/utilities/mergeRefs'
/**
* Props for the Text component
*/
export interface TextProps extends aria.TextProps {
readonly variant?: TextVariant
/**
* Whether the text uses the monospace font.
*/
readonly monospace?: boolean
/**
* Whether the text overflow is ellipsis
*/
readonly ellipsis?: boolean
/**
* The number of lines to clamp the text to
* Requires `ellipsis` to be `true`
* Use `1` for single line truncation
* Using more than 1 line will transform the text to a flex element
* @default 1
*/
readonly lineClamp?: number
/**
* Whether the text is not wrapping
*/
readonly nowrap?: boolean
/**
* Whether the text has italic style
*/
readonly italic?: boolean
readonly weight?: Weight
readonly transform?: TextTransform
}
/**
* Text variants
*/
export type TextVariant =
| 'body'
| 'caption'
| 'custom'
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'subtitle'
/**
* Font weight options
*/
export type Weight = 'bold' | 'custom' | 'extraBold' | 'normal' | 'thin'
/**
* Text transform options
*/
export type TextTransform = 'capitalize' | 'lowercase' | 'none' | 'uppercase'
const BASIC_CLASSES = 'inline-block text-balance'
const TRUNCATE_CLASSES = 'truncate'
const MULTILINE_TRUNCATE_CLASSES = 'line-clamp-1'
const VARIANT_MAP: Record<TextVariant, string> = {
custom: '',
body: 'text-base font-medium leading-[24px]',
caption: 'text-sm font-medium leading-[18px]',
h1: 'text-4xl font-bold leading-[42px]',
h2: 'text-2xl font-semibold leading-[36px]',
h3: 'text-xl font-semibold leading-[30px]',
h4: 'text-lg font-medium leading-[26px]',
h5: 'text-h5 font-medium leading-[22px]',
h6: 'text-h6 font-medium leading-[20px]',
subtitle: 'text-subtitle font-medium leading-[16px]',
}
const WEIGHT_MAP: Record<Weight, string> = {
bold: 'font-bold',
extraBold: 'font-extrabold',
normal: 'font-normal',
thin: 'font-thin',
custom: '',
}
const TRANSFORM_MAP: Record<TextTransform, string> = {
none: '',
capitalize: 'text-capitalize',
lowercase: 'text-lowercase',
uppercase: 'text-uppercase',
}
/**
* Text component
*/
export const Text = React.forwardRef(function Text(
props: TextProps,
ref: React.Ref<HTMLSpanElement>
) {
const {
className,
variant = 'body',
italic = false,
weight = 'normal',
nowrap = false,
ellipsis = false,
monospace = false,
transform = 'none',
lineClamp = 1,
children,
...ariaProps
} = props
const textElementRef = React.useRef<HTMLElement>(null)
const popoverRef = React.useRef<HTMLDivElement>(null)
const { hoverProps } = aria.useHover({
onHoverStart: () => {
if (textElementRef.current && popoverRef.current) {
const isOverflowing =
textElementRef.current.scrollWidth > textElementRef.current.clientWidth ||
textElementRef.current.scrollHeight > textElementRef.current.clientHeight
if (isOverflowing) {
popoverRef.current.showPopover()
updatePosition()
}
}
},
onHoverEnd: () => popoverRef.current?.hidePopover(),
isDisabled: !ellipsis,
})
const { overlayProps, updatePosition } = aria.useOverlayPosition({
overlayRef: popoverRef,
targetRef: textElementRef,
})
const id = React.useId()
const classes = tw.twMerge(
BASIC_CLASSES,
TRANSFORM_MAP[transform],
WEIGHT_MAP[weight],
VARIANT_MAP[variant],
italic && 'italic',
nowrap && 'whitespace-nowrap',
ellipsis && lineClamp === 1 ? TRUNCATE_CLASSES : '',
ellipsis && lineClamp > 1 ? MULTILINE_TRUNCATE_CLASSES : '',
monospace && 'font-mono',
className
)
return (
<>
<aria.Text
ref={mergeRefs.mergeRefs(ref, textElementRef)}
className={classes}
{...aria.mergeProps<TextProps>()(
ariaProps,
hoverProps,
ellipsis ? { popovertarget: id, style: { WebkitLineClamp: `${lineClamp}` } } : {}
)}
>
{children}
</aria.Text>
{ellipsis && (
<div
ref={popoverRef}
className={tw.twMerge(
'inset-[unset] m-[unset] h-auto max-h-12 w-auto max-w-64 rounded-md bg-neutral-800 p-2 text-xs shadow-lg',
'text-white'
)}
id={id}
popover=""
{...overlayProps}
>
{children}
</div>
)}
</>
)
})

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel import file for Text component.
*/
export * from './Text'

View File

@ -6,3 +6,5 @@ export * from './Button/Button'
export * from './Tooltip/Tooltip'
export * from './Dialog'
export * from './Alert'
export * from './Text'
export * from './Separator'

View File

@ -0,0 +1,107 @@
/**
* @file
*
* Billing page
*/
import * as React from 'react'
import GoBack from 'enso-assets/arrow_left.svg'
import * as appUtils from '#/appUtils'
import * as text from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as components from './components'
/**
* Billing page
*/
export function Billing() {
const { getText } = text.useText()
return (
<div className="h-full overflow-auto">
<main className="mx-auto max-w-[1024px] px-4 py-6 pb-16">
<div className="mb-12 py-2">
<ariaComponents.Button
href={appUtils.DASHBOARD_PATH}
icon={GoBack}
iconPosition="start"
variant="icon"
className="-ml-3"
size="small"
>
{getText('billingPageBackButton')}
</ariaComponents.Button>
<ariaComponents.Text variant="h1">{getText('billingPageTitle')}</ariaComponents.Text>
</div>
<div className="flex flex-col gap-12">
<section>
<ariaComponents.Text variant="h3">Payment Method</ariaComponents.Text>
<ariaComponents.Separator variant="primary" className="my-3" />
<components.PaymentMethod
paymentMethods={[
{
id: '1',
type: 'Mastercard',
expMonth: 12,
expYear: 2023,
last4: '1234',
},
]}
/>
</section>
<section>
<ariaComponents.Text variant="h3">Your plan</ariaComponents.Text>
<ariaComponents.Separator variant="primary" className="mb-3 mt-3" />
<components.YourPlan plan="enterprise" />
</section>
<section>
<ariaComponents.Text variant="h3" id="BillingInvoices">
Invoices
</ariaComponents.Text>
<ariaComponents.Separator variant="primary" className="mb-3 mt-3" />
<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>
</main>
</div>
)
}

View File

@ -0,0 +1,21 @@
/**
* @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 {...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} />
}

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} />
}

View File

@ -0,0 +1,86 @@
/**
* @file
*
* InvoicesTable component
*/
import * as React from 'react'
import * as aria from '#/components/aria'
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
return (
<aria.ResizableTableContainer>
<aria.Table aria-labelledby={titleId} className="text-left text-sm font-bold text-black-a50">
<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"
>
Date
</invocesColumn.InvoicesColumn>
<invocesColumn.InvoicesColumn
id="name"
isRowHeader
allowsSorting
defaultWidth="3fr"
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"
>
Amount
</invocesColumn.InvoicesColumn>
<invocesColumn.InvoicesColumn
id="marketCap"
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"
>
Status
</invocesColumn.InvoicesColumn>
<invocesColumn.InvoicesColumn
id="sector"
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"
>
Description
</invocesColumn.InvoicesColumn>
</aria.TableHeader>
<aria.TableBody items={items}>
{item => (
<invoicesRow.InvoicesRow key={item.id} className="rounded-rows-skip-level">
<invoicesCell.InvoicesCell>{item.date}</invoicesCell.InvoicesCell>
<invoicesCell.InvoicesCell className="font-semibold">
{item.amount}
</invoicesCell.InvoicesCell>
<invoicesCell.InvoicesCell>{item.status}</invoicesCell.InvoicesCell>
<invoicesCell.InvoicesCell>{item.description}</invoicesCell.InvoicesCell>
</invoicesRow.InvoicesRow>
)}
</aria.TableBody>
</aria.Table>
</aria.ResizableTableContainer>
)
}

View File

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

View File

@ -0,0 +1,67 @@
import * as React from 'react'
import Edit from 'enso-assets/pen.svg'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
/**
*
*/
export interface PaymentMethodProps {
paymentMethods: {
id: string
type: string
last4: string
expMonth: number
expYear: number
}[]
onAddPaymentMethod: () => void
onRemovePaymentMethod: (id: string) => void
onSetDefaultPaymentMethod: (id: string) => void
onEditPaymentMethod: (id: string) => void
}
/**
*
*/
export function PaymentMethod(props: PaymentMethodProps) {
const {
paymentMethods,
onAddPaymentMethod,
onRemovePaymentMethod,
onSetDefaultPaymentMethod,
onEditPaymentMethod,
} = props
const { getText } = text.useText()
return (
<>
<div>
{paymentMethods.map(paymentMethod => (
<div key={paymentMethod.id} className="flex w-full flex-col py-2">
<ariaComponents.Text>
{getText('billingPagePaymentMethod', paymentMethod.type, paymentMethod.last4)}
</ariaComponents.Text>
<div>
{getText(
'billingPageExpires',
paymentMethod.expMonth.toString(),
paymentMethod.expYear.toString()
)}
</div>
</div>
))}
</div>
<ariaComponents.Button
variant="icon"
size="custom"
iconPosition="end"
className="mt-4 text-sm"
icon={Edit}
>
{getText('billingPageaddPaymentMethod')}
</ariaComponents.Button>
</>
)
}

View File

@ -0,0 +1,43 @@
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">
<ariaComponents.Text variant="caption">{plan}</ariaComponents.Text>
<ariaComponents.Button
variant="icon"
size="custom"
className="mt-4 text-sm"
icon={Open}
iconPosition="end"
href={appUtils.SUBSCRIBE_PATH}
>
{getText('change')}
</ariaComponents.Button>
</div>
)
}

View File

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

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel file for the billing page.
*/
export * from './Billing'

View File

@ -543,5 +543,11 @@
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
"ContactSalesButtonLabel": "Contact Us",
"setOrgNameTitle": "Set your organization name"
"setOrgNameTitle": "Set your organization name",
"billingPageTitle": "Billing",
"billingPageBackButton": "Back to Enso dashboard",
"billingPageaddPaymentMethod": "Add payment method",
"billingPagePaymentMethod": "$0 •••• $1",
"billingPageExpires": "Expires $0/$1"
}

View File

@ -89,6 +89,9 @@ interface PlaceholderOverrides {
readonly getDefaultVersionBackendError: [string]
readonly subscribeSuccessSubtitle: [string]
readonly billingPageExpires: [string, string]
readonly billingPagePaymentMethod: [string, string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */