A billing page
Northwind Labs hosts its apps on your platform. Ada, who looks after their money, signs in once a month with four questions: what are we on, who do you bill, which card do you charge, and what did we pay. Today those answers sit in four lists. This guide gives Ada one page with all four, plus the buttons to act on them.
It builds on Your first composed page, so page, Stat, Table and RecordCard are familiar. New here: forms in a dialog, actions that delete records or open links, conditions on a record’s own fields, and a component written in React. The data is a small billing schema: plans, subscriptions (with a plan relation), billingProfiles, paymentMethods and invoices.
1. Your plan
Section titled “1. Your plan”/** @jsxImportSource protobase */import { Action, Field, Grid, Page, Progress, RecordCard, page } from 'protobase/layout'
export const billing = page( 'billing', <Page title="Billing" description="Your plan, how you pay, and what you paid."> <RecordCard title="Your plan" resource="subscriptions" filter="status = 'active'" empty="You have no active plan." actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]} > <Grid columns={3}> <Field name="plan.name" label="Plan" /> <Field name="plan.price" label="Per month" /> <Field name="renewsAt" /> </Grid> <Progress label="Seats" value="seatsUsed" max="plan.seats" /> </RecordCard> </Page>, { icon: 'credit-card' },)The card shows the active subscription. plan.name and plan.price reach through the plan relation, and Progress draws seatsUsed out of plan.seats: a string names a field of the record, a number is used as is. The bar turns amber from 90%.
The two header buttons are named actions of the subscriptions view, each with its own kind of behaviour. The views of the billing schema declare every action this page uses:
import { view } from 'protobase/schema'import type { billingProfiles, invoices, paymentMethods, plans, subscriptions } from './billing-config'
// The views of the billing schema: names, labels, and the named actions the billing page's buttons run.
export const plansView = view<typeof plans>('plans').title((r) => r.name).nav({ hidden: true }).fields((r) => ({ price: r.price.prefix('€').decimals(2) }))
export const subscriptionsView = view<typeof subscriptions>('subscriptions') .nav({ hidden: true }) .fields((r) => ({ renewsAt: r.renewsAt.label('Renews on') })) .actions((a) => [ a.update('cancel', { label: 'Cancel plan', set: { status: 'canceled' }, confirm: 'Your plan stays active until the end of the period.' }), a.link('upgrade', { label: 'Upgrade', href: 'mailto:[email protected]?subject=Upgrade%20from%20plan%20{plan}' }), ])
export const billingProfilesView = view<typeof billingProfiles>('billingProfiles').nav({ hidden: true }).fields((r) => ({ vatNumber: r.vatNumber.label('VAT number') }))
export const paymentMethodsView = view<typeof paymentMethods>('paymentMethods') .nav({ hidden: true }) .actions((a) => [ a.update('makeDefault', { label: 'Make default', set: { isDefault: true } }), a.remove('remove', { label: 'Remove', confirm: 'The card is removed from your account.' }), ])
export const invoicesView = view<typeof invoices>('invoices') .title((r) => r.number) .names({ singular: 'Invoice', plural: 'Invoices' }) .fields((r) => ({ total: r.total.prefix('€').decimals(2), issuedAt: r.issuedAt.label('Date') })) .list((r) => ({ columns: [r.number, r.issuedAt, r.total, r.status] })) .actions((a) => [a.action('download', { label: 'Download PDF' })])a.update('cancel', ...) sets status after its confirm question. a.link opens its href, with {plan} filled in from the record: a path in the app (/plans) stays in the app, anything else (https:, mailto:) leaves it. Cancel the plan in the story and the card says there is no active plan, because its filter no longer matches.
2. Who we bill
Section titled “2. Who we bill”Ada’s company moved offices. The details card gets an edit button:
/** @jsxImportSource protobase */import { Action, Field, Grid, ModalForm, Page, Progress, RecordCard, page } from 'protobase/layout'
export const billing = page( 'billing', <Page title="Billing" description="Your plan, how you pay, and what you paid."> <Grid columns={2}> <RecordCard title="Your plan" resource="subscriptions" filter="status = 'active'" empty="You have no active plan." actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]} > <Grid columns={3}> <Field name="plan.name" label="Plan" /> <Field name="plan.price" label="Per month" /> <Field name="renewsAt" /> </Grid> <Progress label="Seats" value="seatsUsed" max="plan.seats" /> </RecordCard> <RecordCard title="Billing details" resource="billingProfiles" actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vatNumber', 'address', 'email']} />} > <Grid columns={2}> <Field name="company" /> <Field name="vatNumber" /> <Field name="address" /> <Field name="email" /> </Grid> </RecordCard> </Grid> </Page>, { icon: 'credit-card' },)ModalForm mode="edit" in a RecordCard’s actions edits that card’s record: a dialog with the fields listed, the same editors as the record page, the changed fields sent as one update with the record’s ETag. Errors from the server land on their fields. Change the company name in the story and save.
3. How they pay
Section titled “3. How they pay”/** @jsxImportSource protobase */import { Action, CardRow, Field, Grid, ModalForm, Page, Progress, RecordCard, Show, page } from 'protobase/layout'
export const billing = page( 'billing', <Page title="Billing" description="Your plan, how you pay, and what you paid."> <Grid columns={2}> <RecordCard title="Your plan" resource="subscriptions" filter="status = 'active'" empty="You have no active plan." actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]} > <Grid columns={3}> <Field name="plan.name" label="Plan" /> <Field name="plan.price" label="Per month" /> <Field name="renewsAt" /> </Grid> <Progress label="Seats" value="seatsUsed" max="plan.seats" /> </RecordCard> <RecordCard title="Billing details" resource="billingProfiles" actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vatNumber', 'address', 'email']} />} > <Grid columns={2}> <Field name="company" /> <Field name="vatNumber" /> <Field name="address" /> <Field name="email" /> </Grid> </RecordCard> </Grid> <CardRow title="Payment methods" resource="paymentMethods" sort="isDefault desc, id" actions={<ModalForm mode="create" label="Add card" resource="paymentMethods" fields={['brand', 'last4', 'expires']} />} > <Field name="brand" label={false} /> <Field name="last4" label="Number" /> <Field name="expires" /> <Show when="isDefault = true">Default card</Show> <Show when="isDefault = false"> <Action name="makeDefault" /> <Action name="remove" variant="danger" /> </Show> </CardRow> </Page>, { icon: 'credit-card' },)The cards sit side by side in a CardRow, the default card first (sort="isDefault desc, id"). Two Show blocks split the card on its own isDefault field: the default card says so, the others get “Make default” and “Remove”. Those are again view actions, a.update('makeDefault', ...) and a.remove('remove', ...) in the views above.
Making a card the default unsets the others in the database (a trigger or a write hook); the row reloads after every action, so the order follows. “Add card” is a ModalForm mode="create" in the row’s header. In the story, make the Mastercard the default, remove the Visa and add a card.
4. What they paid, and a component of your own
Section titled “4. What they paid, and a component of your own”/** @jsxImportSource protobase */import { Action, Card, CardRow, Field, Grid, Link, ModalForm, Page, Progress, RecordCard, Show, Stat, Table, component, page } from 'protobase/layout'
/** Seats in use over the last months; the app registers the React component under this name. */export const SeatHistory = component<{ months: number }>('SeatHistory')
export const billing = page( 'billing', <Page title="Billing" description="Your plan, how you pay, and what you paid."> <Grid columns={2}> <RecordCard title="Your plan" resource="subscriptions" filter="status = 'active'" empty="You have no active plan." actions={[<Action name="cancel" variant="danger" />, <Action name="upgrade" variant="primary" />]} > <Grid columns={3}> <Field name="plan.name" label="Plan" /> <Field name="plan.price" label="Per month" /> <Field name="renewsAt" /> </Grid> <Progress label="Seats" value="seatsUsed" max="plan.seats" /> <SeatHistory months={6} /> </RecordCard> <RecordCard title="Billing details" resource="billingProfiles" actions={<ModalForm mode="edit" label="Edit" title="Edit billing details" fields={['company', 'vatNumber', 'address', 'email']} />} > <Grid columns={2}> <Field name="company" /> <Field name="vatNumber" /> <Field name="address" /> <Field name="email" /> </Grid> </RecordCard> </Grid> <CardRow title="Payment methods" resource="paymentMethods" sort="isDefault desc, id" actions={<ModalForm mode="create" label="Add card" resource="paymentMethods" fields={['brand', 'last4', 'expires']} />} > <Field name="brand" label={false} /> <Field name="last4" label="Number" /> <Field name="expires" /> <Show when="isDefault = true">Default card</Show> <Show when="isDefault = false"> <Action name="makeDefault" /> <Action name="remove" variant="danger" /> </Show> </CardRow> <Grid columns={3}> <Table title="Invoices" resource="invoices" sort="issuedAt desc" pageSize={5} span={2} /> <Grid columns={1}> <Stat label="Open invoices" resource="invoices" filter="status = 'open'" /> <RecordCard title="Latest invoice" resource="invoices" sort="issuedAt desc" actions={<Action name="download" />}> <Grid columns={2}> <Field name="number" /> <Field name="total" /> </Grid> </RecordCard> <Card title="Questions about a bill?"> </Card> </Grid> </Grid> </Page>, { icon: 'credit-card' },)Two things here are not built-in blocks.
SeatHistory is a custom component. component('SeatHistory') declares it for the layout, with its props typed, and the element stays data: { type: 'SeatHistory', props: { months: 6 }, custom: true }. The React code lives in the app, registered under the same name in protobase.ui.tsx at the project root, which the admin bundles with itself. Inside a RecordCard, useLayoutRecord() gives it the record.
“Download PDF” is a named action without built-in behaviour (a.action('download', ...)), so it runs the handler registered under its name, with the record, the API client and the router. The ERP example registers one of each:
import { defineUi, useSeries, type ActionContext } from 'protobase/ui'
/** Bars for orders per day; the custom component behind `<OrdersPerDay>` in config/overview/page.tsx. */const OrdersPerDay = ({ days }: { days: 7 | 30 | 90 }) => { const series = useSeries('orders', { field: 'createdAt', range: `${days}d`, granularity: 'day' }) if (series.isPending) return <p className="text-[13px] text-muted-foreground">Loading</p> if (series.error) return <p className="text-[13px] text-danger-text">Could not load the orders</p> const points = series.data.points const highest = Math.max(1, ...points.map((point) => point.count)) const total = points.reduce((sum, point) => sum + point.count, 0) return ( <figure> <div className="flex h-24 items-end gap-0.5" role="img" aria-label={`${total} orders in ${days} days`}> {points.map((point) => ( <div key={point.bucket} title={`${point.bucket.slice(0, 10)}: ${point.count}`} className="flex-1 rounded-t-sm bg-primary/70" style={{ height: `${(point.count / highest) * 100}%` }} /> ))} </div> <figcaption className="mt-2 text-xs text-muted-foreground">{total.toLocaleString('en-IE')} orders</figcaption> </figure> )}
/** "Send reminder" on an invoice: opens a mail to the customer, and marks a draft invoice as sent. */const send = async ({ record, recordKey, etag, client }: ActionContext) => { const company = await client.get('companies', String(record!.companyId)) const subject = encodeURIComponent(`Invoice ${String(record!.number)}`) window.location.assign(`mailto:${String(company.record.email ?? '')}?subject=${subject}`) if (record!.status === 'draft') await client.update('invoices', recordKey!, { status: 'sent' }, etag ?? '*')}
export default defineUi({ components: { OrdersPerDay }, actions: { send } })The rest is familiar: a paged Table of invoices spanning two of the three columns, a count of open invoices, and a Link inside the text of a card.
If the app has no component registered under a name, the page says so where the component would be, and an action without a handler is disabled:
Next: Custom components for the details of protobase.ui.tsx, and Layouts for named actions, conditions and access.