From 22a8fde1918c34f08c4dc56a1aaf4820016bd266 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 21 Jul 2026 02:56:52 +0700 Subject: [PATCH 1/2] feat(blocks): add billing registry blocks --- apps/blocks/e2e/desktop.visual.spec.ts | 152 ++- apps/blocks/e2e/docs.interaction.spec.ts | 380 +++++- apps/blocks/e2e/mobile.interaction.spec.ts | 139 ++- apps/blocks/e2e/mobile.visual.spec.ts | 101 +- apps/blocks/playwright.config.ts | 1 + apps/blocks/registry.json | 234 ++++ .../src/app/blocks/billing/[name]/page.tsx | 46 + .../blocks/billing/[name]/preview/page.tsx | 36 + apps/blocks/src/app/blocks/billing/page.tsx | 78 ++ apps/blocks/src/app/sitemap.test.ts | 12 +- apps/blocks/src/app/sitemap.ts | 12 +- .../billing-activity-table.test.tsx | 98 ++ .../billing-activity-table.tsx | 983 +++++++++++++++ .../billing-activity-table/messages.ts | 131 ++ .../billing-contracts.test.ts | 72 ++ .../billing-contracts/billing-contracts.ts | 614 ++++++++++ .../billing-credits-card.test.tsx | 74 ++ .../billing-credits-card.tsx | 802 +++++++++++++ .../billing/billing-credits-card/messages.ts | 151 +++ .../billing-entitlements-list.test.tsx | 74 ++ .../billing-entitlements-list.tsx | 662 +++++++++++ .../billing-entitlements-list/messages.ts | 106 ++ .../billing-pricing-table.test.tsx | 96 ++ .../billing-pricing-table.tsx | 757 ++++++++++++ .../billing/billing-pricing-table/messages.ts | 79 ++ .../billing-settings-page.test.tsx | 118 ++ .../billing-settings-page.tsx | 604 ++++++++++ .../billing/billing-settings-page/messages.ts | 59 + .../billing-subscription-card.test.tsx | 95 ++ .../billing-subscription-card.tsx | 589 +++++++++ .../billing-subscription-card/messages.ts | 87 ++ .../blocks/billing/billing-ui/billing-ui.tsx | 264 ++++ .../billing-usage-history.test.tsx | 99 ++ .../billing-usage-history.tsx | 974 +++++++++++++++ .../billing/billing-usage-history/messages.ts | 97 ++ .../billing-usage-overview.test.tsx | 108 ++ .../billing-usage-overview.tsx | 1057 +++++++++++++++++ .../billing-usage-overview/messages.ts | 141 +++ .../billing-block-docs-page.tsx | 360 ++++++ .../billing-showcase-canvas.tsx | 352 ++++++ .../billing-showcase-embed.tsx | 44 + .../billing-showcase-preview.test.tsx | 176 +++ .../billing-showcase-preview.tsx | 451 +++++++ .../billing-showcase-resources.test.ts | 76 ++ .../billing-showcase-resources.ts | 110 ++ .../src/components/site/registry-shell.tsx | 4 + .../src/components/site/site-sidebar.tsx | 49 +- apps/blocks/src/lib/billing-blocks.test.ts | 35 + apps/blocks/src/lib/billing-blocks.ts | 323 +++++ .../src/lib/billing-showcase-fixtures.ts | 625 ++++++++++ apps/registry/REGISTRY.md | 11 +- apps/registry/scripts/build.ts | 2 +- apps/registry/scripts/compiler.test.ts | 6 +- apps/registry/scripts/smoke-install.ts | 56 + scripts/build-pages-artifact.ts | 7 +- scripts/check-registry-contract.ts | 4 +- 56 files changed, 12850 insertions(+), 23 deletions(-) create mode 100644 apps/blocks/src/app/blocks/billing/[name]/page.tsx create mode 100644 apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx create mode 100644 apps/blocks/src/app/blocks/billing/page.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-activity-table/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-contracts/billing-contracts.test.ts create mode 100644 apps/blocks/src/blocks/billing/billing-contracts/billing-contracts.ts create mode 100644 apps/blocks/src/blocks/billing/billing-credits-card/billing-credits-card.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-credits-card/billing-credits-card.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-credits-card/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-entitlements-list/billing-entitlements-list.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-entitlements-list/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-pricing-table/billing-pricing-table.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-pricing-table/billing-pricing-table.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-pricing-table/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-settings-page/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-subscription-card/billing-subscription-card.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-subscription-card/billing-subscription-card.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-subscription-card/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-ui/billing-ui.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-usage-history/billing-usage-history.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-usage-history/billing-usage-history.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-usage-history/messages.ts create mode 100644 apps/blocks/src/blocks/billing/billing-usage-overview/billing-usage-overview.test.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-usage-overview/billing-usage-overview.tsx create mode 100644 apps/blocks/src/blocks/billing/billing-usage-overview/messages.ts create mode 100644 apps/blocks/src/components/billing-showcase/billing-block-docs-page.tsx create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-canvas.tsx create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-embed.tsx create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-preview.test.tsx create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-preview.tsx create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-resources.test.ts create mode 100644 apps/blocks/src/components/billing-showcase/billing-showcase-resources.ts create mode 100644 apps/blocks/src/lib/billing-blocks.test.ts create mode 100644 apps/blocks/src/lib/billing-blocks.ts create mode 100644 apps/blocks/src/lib/billing-showcase-fixtures.ts diff --git a/apps/blocks/e2e/desktop.visual.spec.ts b/apps/blocks/e2e/desktop.visual.spec.ts index c180527..cbb35fc 100644 --- a/apps/blocks/e2e/desktop.visual.spec.ts +++ b/apps/blocks/e2e/desktop.visual.spec.ts @@ -1,12 +1,56 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type FrameLocator, type Page } from '@playwright/test'; const routes = [ - ['/blocks/', 'Blocks that work across your whole product.'], + ['/blocks/', 'Build the product on Constructive.'], ['/blocks/blocks/', 'Install the foundation your way'], ['/blocks/blocks/ui/button/', 'Button'], ['/blocks/blocks/ui/dialog/', 'Dialog'], ] as const; +const billingBlocks = [ + ['billing-pricing-table', 'Pricing table'], + ['billing-subscription-card', 'Subscription card'], + ['billing-entitlements-list', 'Entitlements list'], + ['billing-usage-overview', 'Usage overview'], + ['billing-credits-card', 'Credits card'], + ['billing-usage-history', 'Usage history'], + ['billing-activity-table', 'Activity table'], + ['billing-settings-page', 'Billing'], +] as const; + +const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; + +function billingPreviewFrame(page: Page): FrameLocator { + return page.frameLocator( + '[data-slot="billing-showcase-preview"] iframe[title$="live preview"]', + ); +} + +async function useTheme(page: Page, theme: 'light' | 'dark') { + const html = page.locator('html'); + const themeToggle = page.getByRole('button', { + name: /Switch to (light|dark) theme/, + }); + await expect(themeToggle).toBeEnabled(); + + if (!(await html.evaluate((element, value) => element.classList.contains(value), theme))) { + await page.getByRole('button', { name: `Switch to ${theme} theme` }).click(); + } + + await expect(html).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`)); + const billingFrame = page.locator( + '[data-slot="billing-showcase-preview"] iframe', + ); + if ((await billingFrame.count()) > 0) { + await expect(billingPreviewFrame(page).locator('html')).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + } + await page.evaluate(async () => { + await document.fonts.ready; + }); +} + for (const [route, heading] of routes) { test(`${route} renders the registry documentation surface`, async ({ page }) => { const response = await page.goto(route, { waitUntil: 'networkidle' }); @@ -18,7 +62,7 @@ for (const [route, heading] of routes) { test('home catalogs every base primitive and legacy block routes stay removed', async ({ page }) => { await page.goto('/blocks/', { waitUntil: 'networkidle' }); - await expect(page.locator('section[aria-labelledby="component-catalog"] a[href*="/ui/"]')).toHaveCount(29); + await expect(page.locator('section[aria-labelledby="component-showcase"] a[href*="/ui/"]')).toHaveCount(29); await page.goto('/blocks/blocks/', { waitUntil: 'networkidle' }); await expect(page.locator('section[aria-labelledby="primitive-catalog"] a[href*="/ui/"]')).toHaveCount(29); @@ -26,3 +70,105 @@ test('home catalogs every base primitive and legacy block routes stay removed', const legacyResponse = await page.goto('/blocks/blocks/auth/sign-in-card/', { waitUntil: 'networkidle' }); expect(legacyResponse?.status()).toBe(404); }); + +test('billing catalog discovers all eight live pages and every route returns 200', async ({ page }) => { + const catalogResponse = await page.goto('/blocks/blocks/billing/', { + waitUntil: 'networkidle', + }); + expect(catalogResponse?.status()).toBe(200); + await expect( + page.getByRole('heading', { + level: 1, + name: 'Billing', + }), + ).toBeVisible(); + + const catalog = page.locator('section[aria-labelledby="billing-catalog-heading"]'); + const previewLinks = catalog.getByRole('link'); + await expect(previewLinks).toHaveCount(8); + + const discoveredPaths = await previewLinks.evaluateAll((links) => + links + .map((link) => + new URL((link as HTMLAnchorElement).href).pathname.replace(/\/?$/, '/'), + ) + .sort(), + ); + expect(discoveredPaths).toEqual( + billingBlocks.map(([name]) => billingRoute(name)).sort(), + ); + + for (const [name] of billingBlocks) { + await test.step(name, async () => { + const response = await page.goto(billingRoute(name), { + waitUntil: 'networkidle', + }); + expect(response?.status()).toBe(200); + await expect(page.locator('main')).toBeVisible(); + await expect( + page.locator('[data-slot="billing-showcase-preview"]'), + ).toBeVisible(); + }); + } +}); + +const billingVisualCases = [ + { + name: 'settings', + route: 'billing-settings-page', + slot: 'billing-settings-page', + }, + { + name: 'activity', + route: 'billing-activity-table', + slot: 'billing-activity-table', + }, +] as const; + +for (const visualCase of billingVisualCases) { + for (const theme of ['light', 'dark'] as const) { + test(`billing ${visualCase.name} ready surface renders in ${theme} on desktop`, async ({ + page, + }) => { + await page.goto(billingRoute(visualCase.route), { + waitUntil: 'networkidle', + }); + await useTheme(page, theme); + + const surface = billingPreviewFrame(page).locator( + `[data-slot="${visualCase.slot}"]`, + ); + await expect(surface).toBeVisible(); + if (visualCase.name === 'settings') { + await expect( + surface.getByRole('heading', { name: 'Usage and limits' }), + ).toBeVisible(); + } else { + await expect( + surface.getByRole('table', { + name: 'Billing ledger activity for the selected account and filters.', + }), + ).toBeVisible(); + } + + const box = await surface.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThan(900); + expect(box!.height).toBeGreaterThan( + visualCase.name === 'settings' ? 2_000 : 600, + ); + + const screenshot = await surface.screenshot({ + animations: 'disabled', + caret: 'hide', + }); + expect(screenshot.byteLength).toBeGreaterThan(20_000); + expect( + Math.abs(screenshot.readUInt32BE(16) - box!.width), + ).toBeLessThanOrEqual(1); + expect( + Math.abs(screenshot.readUInt32BE(20) - box!.height), + ).toBeLessThanOrEqual(2); + }); + } +} diff --git a/apps/blocks/e2e/docs.interaction.spec.ts b/apps/blocks/e2e/docs.interaction.spec.ts index f4febfa..ed14a4d 100644 --- a/apps/blocks/e2e/docs.interaction.spec.ts +++ b/apps/blocks/e2e/docs.interaction.spec.ts @@ -1,6 +1,19 @@ -import { expect, test, type Locator, type Page } from '@playwright/test'; +import { + expect, + test, + type FrameLocator, + type Locator, + type Page, +} from '@playwright/test'; const primitiveRoute = (name: string) => `/blocks/blocks/ui/${name}/`; +const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; + +function billingPreviewFrame(page: Page): FrameLocator { + return page.frameLocator( + '[data-slot="billing-showcase-preview"] iframe[title$="live preview"]', + ); +} async function visitPrimitive(page: Page, name: string) { const response = await page.goto(primitiveRoute(name), { waitUntil: 'networkidle' }); @@ -9,6 +22,29 @@ async function visitPrimitive(page: Page, name: string) { await expect(page.getByRole('button', { name: /Switch to (light|dark) theme/ })).toBeEnabled(); } +async function visitBilling(page: Page, name: string) { + const response = await page.goto(billingRoute(name), { + waitUntil: 'networkidle', + }); + expect(response?.status()).toBe(200); + await expect(page.locator('main')).toBeVisible(); + await expect( + page.locator('[data-slot="billing-showcase-preview"]'), + ).toBeVisible(); +} + +async function chooseShowcaseOption( + page: Page, + label: 'Account' | 'Resource state', + option: string, +) { + const preview = page.locator('[data-slot="billing-showcase-preview"]'); + const trigger = preview.getByRole('combobox', { name: label }); + await trigger.click(); + await page.getByRole('option', { name: option, exact: true }).click(); + await expect(trigger).toContainText(option); +} + async function openFromKeyboard(trigger: Locator) { await trigger.focus(); await trigger.press('Enter'); @@ -236,3 +272,345 @@ test('documentation order, anchors, and shared install/source mode remain synchr await expect(sourcePanel).not.toContainText("from '@constructive-io/ui/select'"); expect(pageErrors).toEqual([]); }); + +test('billing preview controls expose both account kinds and every resource state with visible semantics', async ({ + page, +}) => { + await visitBilling(page, 'billing-activity-table'); + const frame = billingPreviewFrame(page); + const activity = frame.locator('[data-slot="billing-activity-table"]'); + + await expect(activity.getByText('Northstar Field Operations')).toBeVisible(); + await expect(activity.getByText('Organization', { exact: true })).toBeVisible(); + + await chooseShowcaseOption(page, 'Account', 'Personal account'); + await expect(activity.getByText('Avery Chen')).toBeVisible(); + await expect(activity.getByText('Personal account', { exact: true })).toBeVisible(); + + await chooseShowcaseOption(page, 'Resource state', 'Loading'); + await expect(activity).toHaveAttribute('aria-busy', 'true'); + await expect(activity.getByRole('status')).toContainText( + 'Loading billing activity', + ); + + await chooseShowcaseOption(page, 'Resource state', 'Empty'); + await expect( + activity.getByRole('heading', { name: 'No billing activity' }), + ).toBeVisible(); + + await chooseShowcaseOption(page, 'Resource state', 'Error'); + await expect( + activity.getByRole('heading', { + name: 'Billing activity could not be loaded', + }), + ).toBeVisible(); + await expect(activity).toContainText('Billing activity is temporarily unavailable.'); + await expect(activity.getByRole('button', { name: 'Try again' })).toBeEnabled(); + + await chooseShowcaseOption(page, 'Resource state', 'Stale'); + await expect(activity.getByText('Stale', { exact: true })).toBeVisible(); + await expect(activity.getByLabel('Data quality: Stale')).toBeVisible(); + + await chooseShowcaseOption(page, 'Resource state', 'Estimated'); + await expect(activity.getByText('Estimated', { exact: true })).toBeVisible(); + await expect(activity.getByLabel('Data quality: Estimated')).toBeVisible(); + + await chooseShowcaseOption(page, 'Resource state', 'Ready'); + for (const semanticLabel of [ + 'Usage recorded', + 'Credits granted', + 'Credits rolled over', + 'Credits expired', + 'Provider pending review', + ]) { + await expect(activity.getByText(semanticLabel, { exact: true })).toBeVisible(); + } + + await activity.getByRole('button', { name: 'Next' }).click(); + const callbackStatus = frame.getByRole('status'); + await expect(callbackStatus).toContainText('Action received.'); + await expect(callbackStatus).toContainText('onPageChange(2)'); + await expect(callbackStatus).toContainText( + 'Its example data remains unchanged.', + ); + await expect(activity).toContainText('Page 1'); +}); + +test('billing breakpoint shortcuts use a real iframe viewport and full screen restores focus', async ({ + page, +}) => { + await visitBilling(page, 'billing-pricing-table'); + const preview = page.locator('[data-slot="billing-showcase-preview"]'); + const inlineFrame = preview.locator( + 'iframe[title="Pricing table inline live preview"]', + ); + + await expect(inlineFrame).toHaveAttribute( + 'src', + /\/blocks\/blocks\/billing\/billing-pricing-table\/preview\/\?account=organization&state=ready$/, + ); + await expect + .poll(() => + inlineFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerWidth, + ), + ) + .toBe(1280); + expect( + await inlineFrame.evaluate((frame) => frame.getBoundingClientRect().height), + ).toBeLessThanOrEqual(960); + + await preview + .getByRole('button', { name: 'Mobile preview, 390 pixels' }) + .click(); + await expect + .poll(() => + inlineFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerWidth, + ), + ) + .toBe(390); + expect( + await billingPreviewFrame(page) + .locator('[data-slot="billing-pricing-table"] > .grid') + .evaluate( + (grid) => getComputedStyle(grid).gridTemplateColumns.split(' ').length, + ), + ).toBe(1); + + const fullscreenTrigger = preview.getByRole('button', { + name: 'Open full-screen preview', + }); + await fullscreenTrigger.click(); + const dialog = page.getByRole('dialog', { name: 'Live source preview' }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Mobile preview, 390 pixels' }), + ).toBeFocused(); + await dialog + .getByRole('button', { name: 'Tablet preview, 768 pixels' }) + .click(); + const fullscreenFrame = dialog.locator('iframe'); + await expect + .poll(() => + fullscreenFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerWidth, + ), + ) + .toBe(768); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(fullscreenTrigger).toBeFocused(); + await expect + .poll(() => + inlineFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerWidth, + ), + ) + .toBe(768); +}); + +test('billing settings keeps narrow leaf layout inside its desktop rail', async ({ + page, +}) => { + await visitBilling(page, 'billing-settings-page'); + const inlineFrame = page.locator( + '[data-slot="billing-showcase-preview"] iframe', + ); + await expect + .poll(() => + inlineFrame.evaluate( + (frame) => (frame as HTMLIFrameElement).contentWindow?.innerWidth, + ), + ) + .toBe(1280); + await expect + .poll(() => + inlineFrame.evaluate((frame) => frame.getBoundingClientRect().height), + ) + .toBe(960); + + const frame = billingPreviewFrame(page); + const primary = frame.locator('[data-slot="billing-settings-usage-primary"]'); + const overviewGrid = primary.locator('xpath=..'); + expect( + await overviewGrid.evaluate( + (grid) => getComputedStyle(grid).gridTemplateColumns.split(' ').length, + ), + ).toBe(12); + + const rail = frame.locator('[data-slot="billing-settings-overview-rail"]'); + expect( + await rail.evaluate((element) => element.getBoundingClientRect().width), + ).toBeLessThan(640); + const subscriptionHeader = rail.locator( + '[data-slot="billing-subscription-card"] [data-slot="card-header"]', + ); + expect( + await subscriptionHeader.evaluate( + (header) => + getComputedStyle(header).gridTemplateColumns.split(' ').length, + ), + ).toBe(1); +}); + +test('billing settings tabs follow keyboard conventions and partial failures remain local', async ({ + page, +}) => { + await visitBilling(page, 'billing-settings-page'); + const settings = billingPreviewFrame(page).locator( + '[data-slot="billing-settings-page"]', + ); + const tabList = settings.getByRole('tablist', { name: 'Billing sections' }); + const overview = tabList.getByRole('tab', { name: 'Overview' }); + const usage = tabList.getByRole('tab', { name: 'Usage' }); + const plans = tabList.getByRole('tab', { name: 'Plans' }); + + await expect(overview).toHaveAttribute('aria-selected', 'true'); + await overview.focus(); + await overview.press('ArrowRight'); + await expect(usage).toBeFocused(); + await expect(usage).toHaveAttribute('aria-selected', 'false'); + await usage.press('Enter'); + await expect(usage).toHaveAttribute('aria-selected', 'true'); + await expect( + settings.locator('[data-slot="billing-usage-history"]'), + ).toBeVisible(); + await expect( + settings.locator('[data-slot="billing-activity-table"]'), + ).toBeVisible(); + + await usage.press('End'); + await expect(plans).toBeFocused(); + await plans.press('Enter'); + await expect(plans).toHaveAttribute('aria-selected', 'true'); + await expect( + settings.locator('[data-slot="billing-pricing-table"]'), + ).toBeVisible(); + + await plans.press('Home'); + await expect(overview).toBeFocused(); + await overview.press('Enter'); + await expect(overview).toHaveAttribute('aria-selected', 'true'); + + await chooseShowcaseOption(page, 'Resource state', 'Partial failure'); + const usageOverview = settings.locator( + '[data-slot="billing-usage-overview"]', + ); + await expect( + usageOverview.getByRole('heading', { name: 'Usage could not be loaded' }), + ).toBeVisible(); + await expect(usageOverview.getByRole('button', { name: 'Try again' })).toBeEnabled(); + + const credits = settings.locator('[data-slot="billing-credits-card"]'); + await expect(credits.getByText('Stale', { exact: true })).toBeVisible(); + await expect(settings.getByText('Scale', { exact: true })).toBeVisible(); + + await usage.click(); + const history = settings.locator('[data-slot="billing-usage-history"]'); + await expect(history.getByText('Data quality: Estimated')).toBeVisible(); + const loadingActivity = settings.locator( + '[data-slot="billing-activity-table"]', + ); + await expect(loadingActivity).toHaveAttribute('aria-busy', 'true'); + await expect(loadingActivity.getByRole('status')).toContainText( + 'Loading billing activity', + ); +}); + +test('billing tables expose captions and scoped column headers', async ({ page }) => { + await visitBilling(page, 'billing-settings-page'); + const settings = billingPreviewFrame(page).locator( + '[data-slot="billing-settings-page"]', + ); + await settings.getByRole('tab', { name: 'Usage' }).click(); + + const historyTable = settings.getByRole('table', { + name: 'Billing usage summaries by period and meter.', + }); + await expect(historyTable).toBeVisible(); + for (const header of [ + 'Period', + 'Meter', + 'Used', + 'Allowance', + 'Credits', + 'Overage', + 'Quality', + ]) { + await expect( + historyTable.getByRole('columnheader', { name: header }), + ).toHaveAttribute('scope', 'col'); + } + + const activityTable = settings.getByRole('table', { + name: 'Billing ledger activity for the selected account and filters.', + }); + await expect(activityTable).toBeVisible(); + for (const header of [ + 'Date', + 'Activity', + 'Meter', + 'Change', + 'Balance after', + 'Details', + ]) { + await expect( + activityTable.getByRole('columnheader', { name: header }), + ).toHaveAttribute('scope', 'col'); + } +}); + +test('billing activity metadata sheet has a name and description, restores focus, and honors reduced motion', async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await visitBilling(page, 'billing-activity-table'); + expect( + await page.evaluate(() => + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ), + ).toBe(true); + + const frame = billingPreviewFrame(page); + const trigger = frame.getByRole('button', { + name: 'View metadata: Provider pending review', + }); + await trigger.click(); + + const sheet = frame.getByRole('dialog', { name: 'Activity details' }); + await expect(sheet).toBeVisible(); + await expect(sheet).toHaveAccessibleDescription( + 'Review the ledger fields and metadata recorded with this activity.', + ); + await expect(sheet.getByRole('heading', { name: 'Metadata' })).toBeVisible(); + await expect(sheet).toContainText('"source": "showcase"'); + + await page.keyboard.press('Escape'); + await expect(sheet).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('billing settings reflow at an equivalent 200 percent zoom viewport', async ({ + page, +}) => { + // A 1440px desktop viewport exposes 720 CSS pixels at 200% browser zoom. + await page.setViewportSize({ width: 720, height: 500 }); + await visitBilling(page, 'billing-settings-page'); + await page + .locator('[data-slot="billing-showcase-preview"]') + .getByRole('button', { name: 'Mobile preview, 390 pixels' }) + .click(); + + await expect( + billingPreviewFrame(page).locator('[data-slot="billing-settings-page"]'), + ).toBeVisible(); + expect( + await page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ).toBe(true); +}); diff --git a/apps/blocks/e2e/mobile.interaction.spec.ts b/apps/blocks/e2e/mobile.interaction.spec.ts index 16bb2fd..0ef7b1c 100644 --- a/apps/blocks/e2e/mobile.interaction.spec.ts +++ b/apps/blocks/e2e/mobile.interaction.spec.ts @@ -1,4 +1,55 @@ -import { expect, test } from '@playwright/test'; +import { + expect, + test, + type FrameLocator, + type Locator, + type Page, +} from '@playwright/test'; + +const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; + +function billingPreviewFrame(page: Page): FrameLocator { + return page.frameLocator( + '[data-slot="billing-showcase-preview"] iframe[title$="live preview"]', + ); +} + +async function expectTouchTargets(targets: Locator) { + const targetCount = await targets.count(); + expect(targetCount).toBeGreaterThan(0); + + for (let index = 0; index < targetCount; index += 1) { + const target = targets.nth(index); + const targetName = + (await target.getAttribute('aria-label')) ?? + (await target.textContent()) ?? + `target ${index + 1}`; + const hitArea = await target.evaluate((element) => { + const box = element.getBoundingClientRect(); + const coarsePointerTarget = getComputedStyle(element, '::after'); + const pseudoMinWidth = Number.parseFloat(coarsePointerTarget.minWidth) || 0; + const pseudoMinHeight = Number.parseFloat(coarsePointerTarget.minHeight) || 0; + return { + width: Math.max(box.width, pseudoMinWidth), + height: Math.max(box.height, pseudoMinHeight), + }; + }); + expect(hitArea.width, `${targetName} width`).toBeGreaterThanOrEqual(44); + expect(hitArea.height, `${targetName} height`).toBeGreaterThanOrEqual(44); + } +} + +async function chooseShowcaseOption( + page: Page, + label: 'Account' | 'Resource state', + option: string, +) { + const preview = page.locator('[data-slot="billing-showcase-preview"]'); + const trigger = preview.getByRole('combobox', { name: label }); + await trigger.click(); + await page.getByRole('option', { name: option, exact: true }).click(); + await expect(trigger).toContainText(option); +} test('navigation changes at the shared 860px breakpoint', async ({ page }) => { await page.setViewportSize({ width: 861, height: 900 }); @@ -30,3 +81,89 @@ test('mobile modal examples open, dismiss, and restore focus without hydration e await expect(trigger).toBeFocused(); expect(pageErrors).toEqual([]); }); + +test('mobile billing tables keep horizontal overflow inside their containers', async ({ + page, +}) => { + const response = await page.goto(billingRoute('billing-settings-page'), { + waitUntil: 'networkidle', + }); + expect(response?.status()).toBe(200); + + const preview = page.locator('[data-slot="billing-showcase-preview"]'); + await preview + .getByRole('button', { name: 'Mobile preview, 390 pixels' }) + .click(); + const frame = billingPreviewFrame(page); + const settings = frame.locator('[data-slot="billing-settings-page"]'); + await settings.getByRole('tab', { name: 'Usage' }).click(); + const tables = settings.getByRole('table'); + await expect(tables).toHaveCount(2); + + let overflowingContainers = 0; + for (let index = 0; index < 2; index += 1) { + const table = tables.nth(index); + const container = table.locator('xpath=..'); + await expect(container).toHaveAttribute('data-slot', 'table-container'); + expect( + await container.evaluate((element) => getComputedStyle(element).overflowX), + ).toBe('auto'); + + expect( + await container.evaluate( + (element) => element.getBoundingClientRect().width <= window.innerWidth, + ), + ).toBe(true); + + if ( + await container.evaluate( + (element) => element.scrollWidth > element.clientWidth, + ) + ) { + overflowingContainers += 1; + } + } + + expect(overflowingContainers).toBeGreaterThan(0); + expect( + await frame.locator('html').evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); + expect( + await page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ).toBe(true); +}); + +test('mobile billing controls expose 44px touch targets and switch account context', async ({ + page, +}) => { + await page.goto(billingRoute('billing-settings-page'), { + waitUntil: 'networkidle', + }); + const preview = page.locator('[data-slot="billing-showcase-preview"]'); + await preview + .getByRole('button', { name: 'Mobile preview, 390 pixels' }) + .click(); + await chooseShowcaseOption(page, 'Account', 'Personal account'); + + const frame = billingPreviewFrame(page); + const settings = frame.locator('[data-slot="billing-settings-page"]'); + await expect(settings.getByText('Avery Chen', { exact: true }).first()).toBeVisible(); + await expect(settings.getByText('Personal account', { exact: true }).first()).toBeVisible(); + + await expectTouchTargets( + preview.locator( + '[data-slot="select-trigger"]:visible, [data-slot="button"]:visible', + ), + ); + await expectTouchTargets( + frame.locator( + '[data-slot="tabs-trigger"]:visible, [data-slot="billing-settings-page"] [data-slot="button"]:visible', + ), + ); +}); diff --git a/apps/blocks/e2e/mobile.visual.spec.ts b/apps/blocks/e2e/mobile.visual.spec.ts index 41cab2f..09b7875 100644 --- a/apps/blocks/e2e/mobile.visual.spec.ts +++ b/apps/blocks/e2e/mobile.visual.spec.ts @@ -1,4 +1,37 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type FrameLocator, type Page } from '@playwright/test'; + +const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; + +function billingPreviewFrame(page: Page): FrameLocator { + return page.frameLocator( + '[data-slot="billing-showcase-preview"] iframe[title$="live preview"]', + ); +} + +async function useTheme(page: Page, theme: 'light' | 'dark') { + const html = page.locator('html'); + const themeToggle = page.getByRole('button', { + name: /Switch to (light|dark) theme/, + }); + await expect(themeToggle).toBeEnabled(); + + if (!(await html.evaluate((element, value) => element.classList.contains(value), theme))) { + await page.getByRole('button', { name: `Switch to ${theme} theme` }).click(); + } + + await expect(html).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`)); + const billingFrame = page.locator( + '[data-slot="billing-showcase-preview"] iframe', + ); + if ((await billingFrame.count()) > 0) { + await expect(billingPreviewFrame(page).locator('html')).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + } + await page.evaluate(async () => { + await document.fonts.ready; + }); +} test('the base primitive catalog remains usable at a mobile viewport', async ({ page }) => { await page.goto('/blocks/blocks/', { waitUntil: 'networkidle' }); @@ -9,3 +42,69 @@ test('the base primitive catalog remains usable at a mobile viewport', async ({ await expect(page).toHaveURL(/\/blocks\/blocks\/ui\/tooltip\/$/); await expect(page.getByRole('heading', { level: 1, name: 'Tooltip' })).toBeVisible(); }); + +const billingVisualCases = [ + { + name: 'settings', + route: 'billing-settings-page', + slot: 'billing-settings-page', + }, + { + name: 'activity', + route: 'billing-activity-table', + slot: 'billing-activity-table', + }, +] as const; + +for (const visualCase of billingVisualCases) { + for (const theme of ['light', 'dark'] as const) { + test(`billing ${visualCase.name} ready surface renders in ${theme} on mobile`, async ({ + page, + }) => { + await page.goto(billingRoute(visualCase.route), { + waitUntil: 'networkidle', + }); + await page + .locator('[data-slot="billing-showcase-preview"]') + .getByRole('button', { name: 'Mobile preview, 390 pixels' }) + .click(); + await useTheme(page, theme); + + const surface = billingPreviewFrame(page).locator( + `[data-slot="${visualCase.slot}"]`, + ); + await expect(surface).toBeVisible(); + if (visualCase.name === 'settings') { + await expect( + surface.getByRole('heading', { name: 'Usage and limits' }), + ).toBeVisible(); + } else { + await expect( + surface.getByRole('table', { + name: 'Billing ledger activity for the selected account and filters.', + }), + ).toBeVisible(); + } + + const box = await surface.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThan(280); + expect(box!.width).toBeLessThanOrEqual(390); + expect(box!.height).toBeGreaterThan( + visualCase.name === 'settings' ? 4_000 : 700, + ); + + const screenshot = await surface.screenshot({ + animations: 'disabled', + caret: 'hide', + }); + expect(screenshot.byteLength).toBeGreaterThan(10_000); + expect( + Math.abs(screenshot.readUInt32BE(16) - box!.width), + ).toBeLessThanOrEqual(1); + expect( + Math.abs(screenshot.readUInt32BE(20) - box!.height), + ).toBeLessThanOrEqual(2); + }); + } +} diff --git a/apps/blocks/playwright.config.ts b/apps/blocks/playwright.config.ts index 649de3c..bf190b8 100644 --- a/apps/blocks/playwright.config.ts +++ b/apps/blocks/playwright.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? 'github' : 'list', + preserveOutput: 'never', use: { baseURL: 'http://127.0.0.1:4173', colorScheme: 'dark', diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index e004b7a..7b2e0d0 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -2488,6 +2488,240 @@ "type": "registry:lib" } ] + }, + { + "name": "billing-contracts", + "type": "registry:lib", + "title": "Billing Contracts", + "description": "Provider-neutral billing DTOs, arbitrary-precision formatters, open-status presentation maps, and explicit resource states shared by every customer billing block.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nInstall this item when building a custom billing composition. It defines the shared resource states, account references, money values, allowances, plans, subscriptions, usage, credits, activity, messages, and formatting options used by the billing blocks.\n\n```ts\nimport type { BillingPlan, BillingResource } from '@/blocks/billing/billing-contracts/billing-contracts';\n\nconst plans: BillingResource = {\n status: 'ready',\n quality: 'authoritative',\n asOf: '2026-07-20T12:00:00.000Z',\n data: [{\n id: 'growth',\n name: 'Growth',\n prices: [{\n kind: 'fixed',\n id: 'growth-monthly',\n interval: 'month',\n money: { amountMinor: '4900', currency: 'USD' }\n }]\n }]\n};\n```\n\nKeep quantities, limits, deltas, and minor-unit money as decimal strings so large values retain their precision. Supply an explicit locale and time zone to the formatters, keep each record's currency, and use the explicit contact-sales, unlimited, uninitialized, and unknown variants when they apply.", + "dependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-contracts/billing-contracts.ts", + "target": "src/blocks/billing/billing-contracts/billing-contracts.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "billing-pricing-table", + "type": "registry:block", + "title": "Billing Pricing Table", + "description": "Responsive plan comparison with controlled interval selection, fixed and contact-sales pricing, current and featured states, and optional actions.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass a plan resource, account context, formatting options, a controlled billing interval, and the actions you want to show.\n\n```tsx\nimport { BillingPricingTable } from '@/blocks/billing/billing-pricing-table/billing-pricing-table';\n\n openPlanSelection({ planId, priceId, account })}\n onContactSales={({ planId, account }) => openSalesRequest({ planId, account })}\n/>\n```\n\nFixed prices use minor-unit decimal strings and the currency on each record. Use `BillingPrice.kind = 'contact_sales'` for custom pricing. Missing callbacks remove their actions, rejected promises appear next to the action, and resolved promises leave the selected plan state under your control.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", + "target": "src/blocks/billing/billing-pricing-table/billing-pricing-table.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-pricing-table/messages.ts", + "target": "src/blocks/billing/billing-pricing-table/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-subscription-card", + "type": "registry:block", + "title": "Billing Subscription Card", + "description": "Current-plan summary with independent subscription, provider, and payment status plus exact lifecycle dates and optional actions.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass the current plan together with any payment and provider states you want to show. Add only the actions that make sense in your billing flow.\n\n```tsx\nimport { BillingSubscriptionCard } from '@/blocks/billing/billing-subscription-card/billing-subscription-card';\n\n openSubscriptionSettings(subscriptionId, account)}\n/>\n```\n\nOnly `renewsAt` is labelled “Renews”; `endsAt` is always labelled “Ends.” Status strings are open, unknown values render neutrally, and missing callbacks hide their controls.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", + "target": "src/blocks/billing/billing-subscription-card/billing-subscription-card.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-subscription-card/messages.ts", + "target": "src/blocks/billing/billing-subscription-card/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-entitlements-list", + "type": "registry:block", + "title": "Billing Entitlements List", + "description": "Structured display of plan feature flags, caps, discrete quotas, meter allowances, and neutral unknown values.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass each entitlement with a semantic kind so features, caps, quotas, and metered allowances keep their distinct meaning.\n\n```tsx\nimport { BillingEntitlementsList } from '@/blocks/billing/billing-entitlements-list/billing-entitlements-list';\n\n\n```\n\nUse explicit `unlimited`, `uninitialized`, and `unknown` variants. Unknown values stay visible with a neutral presentation, and message overrides let you adapt the labels without changing the data model.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", + "target": "src/blocks/billing/billing-entitlements-list/billing-entitlements-list.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-entitlements-list/messages.ts", + "target": "src/blocks/billing/billing-entitlements-list/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-usage-overview", + "type": "registry:block", + "title": "Billing Usage Overview", + "description": "Current-period usage with quota, boolean, unlimited, uninitialized, exhausted, overage, category-pool, and universal-pool states.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass a current-period usage snapshot and nest child meters beneath category or universal pools when credits are shared.\n\n```tsx\nimport { BillingUsageOverview } from '@/blocks/billing/billing-usage-overview/billing-usage-overview';\n\n openUsageHistory(meterSlug)}\n onBuyCredits={(meterSlug) => openCreditPurchase(meterSlug)}\n/>\n```\n\nCategory and universal pools stay nested so their application order remains clear. Progress caps visually at 100 percent while text and accessible values preserve the actual used, allowance, and overage values. Actions disappear when their callbacks are absent.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", + "target": "src/blocks/billing/billing-usage-overview/billing-usage-overview.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-usage-overview/messages.ts", + "target": "src/blocks/billing/billing-usage-overview/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-credits-card", + "type": "registry:block", + "title": "Billing Credits Card", + "description": "Available credits grouped by compatible meter and unit with permanent, period, rollover, expiring, and unknown lot semantics.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass available balances and their credit lots while keeping each meter and unit independent.\n\n```tsx\nimport { BillingCreditsCard } from '@/blocks/billing/billing-credits-card/billing-credits-card';\n\n\n```\n\nKeep incompatible meters and units in separate balance groups. Credit lots show the current grant makeup, while `billing-activity-table` provides a chronological view of grants, usage, adjustments, rollover, and expiration.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-credits-card/billing-credits-card.tsx", + "target": "src/blocks/billing/billing-credits-card/billing-credits-card.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-credits-card/messages.ts", + "target": "src/blocks/billing/billing-credits-card/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-usage-history", + "type": "registry:block", + "title": "Billing Usage History", + "description": "Accessible, table-first period history with controlled meter, period, and pagination filters plus explicit quality labels.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass a page of usage periods and control the meter, period, and pagination values when you provide their callbacks.\n\n```tsx\nimport { BillingUsageHistory } from '@/blocks/billing/billing-usage-history/billing-usage-history';\n\n\n```\n\nCredits and overage columns appear only when their values are marked as confirmed. Quality labels remain visible on each period, and the table keeps pagination and filter changes under your control.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-usage-history/billing-usage-history.tsx", + "target": "src/blocks/billing/billing-usage-history/billing-usage-history.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-usage-history/messages.ts", + "target": "src/blocks/billing/billing-usage-history/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-activity-table", + "type": "registry:block", + "title": "Billing Activity Table", + "description": "Paginated ledger activity with controlled filters, semantic class/type presentation, and a titled metadata detail sheet.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass ledger class and entry type with each activity entry so labels and badge tones describe the event itself.\n\n```tsx\nimport { BillingActivityTable } from '@/blocks/billing/billing-activity-table/billing-activity-table';\n\n\n```\n\nA positive delta can represent consumption and a negative delta can represent release or reset, so presentation derives from `ledgerClass` and `entryType` rather than the sign. Unknown values remain neutral, and JSON-safe metadata opens in a titled detail sheet.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-activity-table/billing-activity-table.tsx", + "target": "src/blocks/billing/billing-activity-table/billing-activity-table.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-activity-table/messages.ts", + "target": "src/blocks/billing/billing-activity-table/messages.ts", + "type": "registry:component" + } + ] + }, + { + "name": "billing-settings-page", + "type": "registry:block", + "title": "Billing Settings Page", + "description": "Router-neutral Overview, Usage, and Plans composition that keeps subscription, usage, credits, entitlements, history, activity, and pricing failures isolated.", + "categories": [ + "blocks", + "billing" + ], + "docs": "## Usage\n\nPass one account together with independent resources and actions. Use `section` and `onSectionChange` to synchronize navigation, or `defaultSection` for local state. The page renders its own heading by default; set `showHeader={false}` when the surrounding document already provides it.\n\n```tsx\nimport { BillingSettingsPage } from '@/blocks/billing/billing-settings-page/billing-settings-page';\n\n\n```\n\nEach resource renders its own loading, empty, error, ready, and quality state, so one unavailable section never replaces the whole page. Controlled filters and actions are forwarded to their matching leaf blocks.", + "dependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "registry/constructive/blocks/billing/billing-settings-page/billing-settings-page.tsx", + "target": "src/blocks/billing/billing-settings-page/billing-settings-page.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/billing/billing-settings-page/messages.ts", + "target": "src/blocks/billing/billing-settings-page/messages.ts", + "type": "registry:component" + } + ] } ] } diff --git a/apps/blocks/src/app/blocks/billing/[name]/page.tsx b/apps/blocks/src/app/blocks/billing/[name]/page.tsx new file mode 100644 index 0000000..4533cfc --- /dev/null +++ b/apps/blocks/src/app/blocks/billing/[name]/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { BillingBlockDocsPage } from '@/components/billing-showcase/billing-block-docs-page'; +import { BILLING_BLOCKS, getBillingBlock } from '@/lib/billing-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +type PageProps = { params: Promise<{ name: string }> }; + +export default async function BillingBlockPage({ params }: PageProps) { + const { name } = await params; + const block = getBillingBlock(name); + if (!block) return notFound(); + + const index = BILLING_BLOCKS.findIndex((item) => item.name === block.name); + const previous = index > 0 ? BILLING_BLOCKS[index - 1] : undefined; + const next = + index < BILLING_BLOCKS.length - 1 ? BILLING_BLOCKS[index + 1] : undefined; + + return ( + + ); +} + +export function generateStaticParams() { + return BILLING_BLOCKS.map(({ name }) => ({ name })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { name } = await params; + const block = getBillingBlock(name); + if (!block) return {}; + + const url = withBase(`/blocks/billing/${block.name}`); + return { + title: block.title, + description: block.description, + alternates: { canonical: url }, + openGraph: { + title: block.title, + description: block.description, + url, + images: [OG_IMAGE] + } + }; +} diff --git a/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx b/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx new file mode 100644 index 0000000..dee8028 --- /dev/null +++ b/apps/blocks/src/app/blocks/billing/[name]/preview/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { BillingShowcaseEmbed } from '@/components/billing-showcase/billing-showcase-embed'; +import { BILLING_BLOCKS, getBillingBlock } from '@/lib/billing-blocks'; + +type PageProps = { params: Promise<{ name: string }> }; + +export const metadata: Metadata = { + robots: { follow: false, index: false } +}; + +export default async function BillingBlockPreviewPage({ params }: PageProps) { + const { name } = await params; + const block = getBillingBlock(name); + if (!block) return notFound(); + + return ( + + } + > + + + ); +} + +export function generateStaticParams() { + return BILLING_BLOCKS.map(({ name }) => ({ name })); +} diff --git a/apps/blocks/src/app/blocks/billing/page.tsx b/apps/blocks/src/app/blocks/billing/page.tsx new file mode 100644 index 0000000..ee0e152 --- /dev/null +++ b/apps/blocks/src/app/blocks/billing/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowUpRightIcon } from 'lucide-react'; + +import { BILLING_BLOCKS } from '@/lib/billing-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; +import { cn } from '@/lib/utils'; + +const TITLE = 'Billing blocks'; +const DESCRIPTION = + 'Polished customer billing surfaces for plans, subscriptions, entitlements, usage, credits, and account activity.'; + +export default function BillingBlocksPage() { + return ( +
+
+

Blocks

+

+ Billing +

+

+ {DESCRIPTION} Start with a focused block or compose them into a + settings destination. +

+

+ {BILLING_BLOCKS.length} blocks +

+
+ +
+

+ Billing blocks +

+ +
    + {BILLING_BLOCKS.map((block) => ( +
  • + + + + {block.title} + + + + {block.description} + + +
  • + ))} +
+
+
+ ); +} + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: withBase('/blocks/billing') }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: withBase('/blocks/billing'), + images: [OG_IMAGE] + } +}; diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index ec061b0..932595d 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -1,18 +1,26 @@ import { describe, expect, it } from 'vitest'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; +import { BILLING_BLOCKS } from '@/lib/billing-blocks'; import sitemap from './sitemap'; describe('sitemap', () => { - it('contains landing, setup, styling, and base primitive pages', () => { + it('contains foundations, 29 primitives, and the complete billing catalog', () => { const entries = sitemap(); - expect(entries).toHaveLength(BASE_PRIMITIVES.length + 3); + expect(BASE_PRIMITIVES).toHaveLength(29); + expect(entries).toHaveLength( + BASE_PRIMITIVES.length + BILLING_BLOCKS.length + 4 + ); expect(entries.map(({ url }) => url)).toEqual([ 'http://localhost:3005/', 'http://localhost:3005/blocks', 'http://localhost:3005/blocks/styling', ...BASE_PRIMITIVES.map(({ name }) => `http://localhost:3005/blocks/ui/${name}`), + 'http://localhost:3005/blocks/billing', + ...BILLING_BLOCKS.map( + ({ name }) => `http://localhost:3005/blocks/billing/${name}` + ), ]); }); }); diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index f52f6ce..74e5d81 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from 'next'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; +import { BILLING_BLOCKS } from '@/lib/billing-blocks'; import { BASE_PATH, SITE_ORIGIN, withBase } from '@/lib/site'; export const dynamic = 'force-static'; @@ -11,12 +12,21 @@ export default function sitemap(): MetadataRoute.Sitemap { '/blocks', '/blocks/styling', ...BASE_PRIMITIVES.map(({ name }) => `/blocks/ui/${name}`), + '/blocks/billing', + ...BILLING_BLOCKS.map(({ name }) => `/blocks/billing/${name}`), ]; const trailingSlash = BASE_PATH ? '/' : ''; return paths.map((path) => ({ url: `${SITE_ORIGIN}${withBase(path)}${trailingSlash}`, changeFrequency: path === '/' ? 'weekly' : 'monthly', - priority: path === '/' ? 1 : path === '/blocks' || path === '/blocks/styling' ? 0.9 : 0.7, + priority: + path === '/' + ? 1 + : path === '/blocks' || + path === '/blocks/styling' || + path === '/blocks/billing' + ? 0.9 + : 0.7, })); } diff --git a/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.test.tsx b/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.test.tsx new file mode 100644 index 0000000..80048b7 --- /dev/null +++ b/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + BillingAccountRef, + BillingActivityEntry, + BillingPage, + BillingResource +} from '@/blocks/billing/billing-contracts/billing-contracts'; + +import { BillingActivityTable } from './billing-activity-table'; + +const account: BillingAccountRef = { + entityId: 'org_1', + kind: 'organization' +}; + +const formatOptions = { locale: 'en-US', timeZone: 'UTC' } as const; + +const page: BillingPage = { + page: 1, + items: [ + { + id: 'evt_1', + occurredAt: '2026-07-10T12:00:00.000Z', + meterSlug: 'api_calls', + ledgerClass: 'usage', + entryType: 'consumption', + delta: '25', + unit: 'requests' + } + ], + hasNextPage: false, + hasPreviousPage: false +}; + +const ready: BillingResource> = { + status: 'ready', + data: page +}; + +describe('BillingActivityTable props contract', () => { + it('mounts required props and surfaces activity rows', () => { + const { container } = render( + + ); + + expect( + container.querySelector('[data-slot="billing-activity-table"]') + ).toHaveClass('@container/billing-activity-table'); + expect(screen.getByText('25')).toBeInTheDocument(); + }); + + it.each([ + { status: 'loading' as const }, + { status: 'empty' as const }, + { status: 'error' as const, error: { message: 'failed' } } + ])('accepts resource.status=$status', (resource) => { + const { container } = render( + + ); + expect( + container.querySelector('[data-slot="billing-activity-table"]') + ).toBeTruthy(); + }); + + it('only shows filters when options and callbacks are both provided', () => { + const { rerender } = render( + + ); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); +}); + diff --git a/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.tsx b/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.tsx new file mode 100644 index 0000000..5e1b830 --- /dev/null +++ b/apps/blocks/src/blocks/billing/billing-activity-table/billing-activity-table.tsx @@ -0,0 +1,983 @@ +'use client'; + +import { useId, useRef, useState } from 'react'; + +import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Badge } from '@constructive-io/ui/badge'; +import { Button } from '@constructive-io/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@constructive-io/ui/card'; +import { Field } from '@constructive-io/ui/field'; +import { + Pagination, + PaginationContent, + PaginationItem +} from '@constructive-io/ui/pagination'; +import { + Select, + SelectContent, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectTrigger, + SelectValue +} from '@constructive-io/ui/select'; +import { Separator } from '@constructive-io/ui/separator'; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@constructive-io/ui/sheet'; +import { Skeleton } from '@constructive-io/ui/skeleton'; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@constructive-io/ui/table'; + +import { + formatBillingDate, + formatBillingQuantity, + normalizeBillingError, + resolveBillingLedgerPresentation, + type BillingAccountRef, + type BillingActivityEntry, + type BillingError, + type BillingFormatOptions, + type BillingMessageEvent, + type BillingPage, + type BillingQuality, + type BillingResource +} from '@/blocks/billing/billing-contracts/billing-contracts'; +import { + BillingQualityBadge, + BillingQuantity, + BillingToolbar, + billingNumericClassName, + billingTableContainerClassName +} from '@/blocks/billing/billing-ui/billing-ui'; +import { cn } from '@/lib/utils'; + +import { + defaultBillingActivityTableMessages, + type BillingActivityTableMessageOverrides, + type BillingActivityTableMessages +} from './messages'; + +export type BillingFilterOption = { + value: string; + label: string; +}; + +export type BillingActivityTableProps = { + resource: BillingResource>; + account: BillingAccountRef; + formatOptions: BillingFormatOptions; + meterOptions?: BillingFilterOption[]; + entryTypeOptions?: BillingFilterOption[]; + meterSlug?: string; + entryType?: string; + onMeterChange?: (value: string) => void | Promise; + onEntryTypeChange?: (value: string) => void | Promise; + onPageChange?: (page: number) => void | Promise; + messages?: BillingActivityTableMessageOverrides; + onError?: (error: BillingError) => void; + onMessage?: (event: BillingMessageEvent) => void; + className?: string; +}; + +type InteractionError = { + scope: 'filters' | 'pagination' | 'retry'; + error: BillingError; +}; + +function mergeMessages( + overrides: BillingActivityTableMessageOverrides | undefined +): BillingActivityTableMessages { + return { + ...defaultBillingActivityTableMessages, + ...overrides, + accountKind: { + ...defaultBillingActivityTableMessages.accountKind, + ...overrides?.accountKind + }, + quality: { + ...defaultBillingActivityTableMessages.quality, + ...overrides?.quality + }, + errors: { + ...defaultBillingActivityTableMessages.errors, + ...overrides?.errors + } + }; +} + +function hasMetadata(entry: BillingActivityEntry) { + return Boolean(entry.metadata && Object.keys(entry.metadata).length > 0); +} + +function metadataJson( + metadata: Record, + fallback: string +): string { + try { + return JSON.stringify(metadata, null, 2) ?? fallback; + } catch { + return fallback; + } +} + +function Quantity({ + value, + unit, + formatOptions +}: { + value: string; + unit?: string; + formatOptions: BillingFormatOptions; +}) { + return ( + + ); +} + +function ActivityHeader({ + titleId, + account, + messages, + quality, + asOf, + formatOptions +}: { + titleId: string; + account: BillingAccountRef; + messages: BillingActivityTableMessages; + quality?: BillingQuality; + asOf?: string; + formatOptions: BillingFormatOptions; +}) { + return ( + +
+
+ + {messages.title} + + {messages.description} +
+ {quality || asOf ? ( +
+ {quality ? ( + + ) : null} + {asOf ? ( + + {messages.asOfLabel}{' '} + + + ) : null} +
+ ) : null} +
+
+ + {account.label ?? account.entityId} + + {messages.accountKind[account.kind]} +
+
+ ); +} + +function LoadingState({ + account, + formatOptions, + messages, + titleId, + className +}: { + account: BillingAccountRef; + formatOptions: BillingFormatOptions; + messages: BillingActivityTableMessages; + titleId: string; + className?: string; +}) { + return ( + + + +

+ {messages.loadingAriaLabel} +

+
+ ); +} + +function InteractionErrorAlert({ + interactionError, + messages +}: { + interactionError: InteractionError | null; + messages: BillingActivityTableMessages; +}) { + if (!interactionError) return null; + + return ( + + + {messages.interactionErrorTitle} + + {interactionError.error.message} + + ); +} + +function FilterControls({ + meterId, + entryTypeId, + meterOptions, + entryTypeOptions, + meterSlug, + entryType, + onMeterChange, + onEntryTypeChange, + pendingControl, + messages +}: { + meterId: string; + entryTypeId: string; + meterOptions?: BillingFilterOption[]; + entryTypeOptions?: BillingFilterOption[]; + meterSlug?: string; + entryType?: string; + onMeterChange?: (value: string) => void; + onEntryTypeChange?: (value: string) => void; + pendingControl: string | null; + messages: BillingActivityTableMessages; +}) { + const showMeter = Boolean(meterOptions?.length && onMeterChange); + const showEntryType = Boolean(entryTypeOptions?.length && onEntryTypeChange); + + if (!showMeter && !showEntryType) return null; + + return ( + + {showMeter && meterOptions && onMeterChange ? ( + + + + ) : null} + + {showEntryType && entryTypeOptions && onEntryTypeChange ? ( + + + + ) : null} + + ); +} + +function PageControls({ + page, + formatOptions, + messages, + pendingControl, + onPageChange +}: { + page: BillingPage; + formatOptions: BillingFormatOptions; + messages: BillingActivityTableMessages; + pendingControl: string | null; + onPageChange: (page: number) => void; +}) { + return ( + + + + + + + + {messages.pageLabel}{' '} + {formatBillingQuantity(String(page.page), formatOptions)} + {page.totalPages !== undefined ? ( + <> + {' '} + {messages.ofLabel}{' '} + {formatBillingQuantity(String(page.totalPages), formatOptions)} + + ) : null} + + + + + + + + ); +} + +function MetadataSheet({ + entry, + open, + onOpenChange, + formatOptions, + messages +}: { + entry: BillingActivityEntry | null; + open: boolean; + onOpenChange: (open: boolean) => void; + formatOptions: BillingFormatOptions; + messages: BillingActivityTableMessages; +}) { + const metadataTitleId = useId(); + const presentation = entry + ? resolveBillingLedgerPresentation(entry.ledgerClass, entry.entryType) + : null; + + return ( + + + + {messages.detailsTitle} + + {messages.detailsDescription} + + + + {entry && presentation ? ( +
+
+ {presentation.label} + + {entry.id} + +
+ + {entry.description ? ( +

{entry.description}

+ ) : null} + +
+
+
{messages.occurredAtLabel}
+
+ +
+
+
+
{messages.meterSlugLabel}
+
{entry.meterSlug}
+
+
+
{messages.ledgerClassLabel}
+
{entry.ledgerClass}
+
+
+
{messages.entryTypeLabel}
+
{entry.entryType}
+
+
+
{messages.deltaLabel}
+
+ +
+
+
+
{messages.balanceAfterLabel}
+
+ {entry.balanceAfter === undefined ? ( + messages.noBalance + ) : ( + + )} +
+
+
+ + + +
+
+

+ {messages.metadataTitle} +

+

+ {messages.metadataDescription} +

+
+
+                
+                  {metadataJson(entry.metadata ?? {}, messages.metadataUnavailable)}
+                
+              
+
+
+ ) : null} + + + + + + +
+
+ ); +} + +export function BillingActivityTable({ + resource, + account, + formatOptions, + meterOptions, + entryTypeOptions, + meterSlug, + entryType, + onMeterChange, + onEntryTypeChange, + onPageChange, + messages: messageOverrides, + onError, + onMessage, + className +}: BillingActivityTableProps) { + const titleId = useId(); + const meterId = useId(); + const entryTypeId = useId(); + const messages = mergeMessages(messageOverrides); + const retryRef = useRef(false); + const pendingControlRef = useRef(null); + const [retryPending, setRetryPending] = useState(false); + const [pendingControl, setPendingControl] = useState(null); + const [interactionError, setInteractionError] = useState(null); + const [selectedEntry, setSelectedEntry] = useState(null); + + function reportInteractionError( + scope: InteractionError['scope'], + key: string, + error: unknown + ) { + const billingError = normalizeBillingError( + error, + messages.errors.UNKNOWN_ERROR + ); + setInteractionError({ scope, error: billingError }); + onError?.(billingError); + onMessage?.({ + kind: 'error', + key, + message: billingError.message + }); + } + + async function runControlledChange( + scope: 'filters' | 'pagination', + key: string, + callback: (value: T) => void | Promise, + value: T + ) { + if (pendingControlRef.current) return; + + pendingControlRef.current = key; + setPendingControl(key); + setInteractionError(null); + try { + await callback(value); + } catch (error) { + reportInteractionError(scope, `${key}.error`, error); + } finally { + pendingControlRef.current = null; + setPendingControl(null); + } + } + + async function handleRetry(retry: () => void) { + if (retryRef.current) return; + + retryRef.current = true; + setRetryPending(true); + setInteractionError(null); + try { + await retry(); + } catch (error) { + reportInteractionError('retry', 'billingActivity.retry.error', error); + } finally { + retryRef.current = false; + setRetryPending(false); + } + } + + if (resource.status === 'loading') { + return ( + + ); + } + + if (resource.status === 'error') { + return ( + + + + + + {messages.errorTitle} + + {resource.error.message} + + + {resource.retry ? ( + + {interactionError?.scope === 'retry' ? ( + + ) : null} + + + ) : null} + + ); + } + + const isReady = resource.status === 'ready'; + const page = isReady ? resource.data : null; + const hasMeterFilter = Boolean(meterOptions?.length && onMeterChange); + const hasEntryTypeFilter = Boolean(entryTypeOptions?.length && onEntryTypeChange); + const hasFilters = hasMeterFilter || hasEntryTypeFilter; + + const filters = ( + + void runControlledChange( + 'filters', + 'billingActivity.meterFilter', + onMeterChange, + value + ) + : undefined + } + onEntryTypeChange={ + onEntryTypeChange + ? (value) => + void runControlledChange( + 'filters', + 'billingActivity.entryTypeFilter', + onEntryTypeChange, + value + ) + : undefined + } + messages={messages} + /> + ); + + if (!page || page.items.length === 0) { + return ( + + + + {hasFilters ? filters : null} + {interactionError?.scope === 'filters' ? ( + + ) : null} + {hasFilters ? ( +