diff --git a/apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.tsx b/apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.tsx index 2738ac5..a650af1 100644 --- a/apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.tsx +++ b/apps/blocks/src/blocks/billing/billing-settings-page/billing-settings-page.tsx @@ -3,21 +3,23 @@ /** * Billing settings composition page. * - * Lessons from usage overview / credits card: - * - One identity cluster at the page level (not on every child card) + * SaaS destination layout: + * - One identity cluster at the page level (children use embedded mode) + * - Underline section nav (settings chrome, not a floating pill demo) + * - Section leads + zoned content; container queries for frame/sidebar embed * - Help copy in tooltips, not banners - * - Container-query layout: 390 / tablet / desktop density - * - Child blocks use embedded mode to drop repeated account chrome */ import * as React from 'react'; -import { CircleHelpIcon } from 'lucide-react'; +import { Building2Icon, CircleHelpIcon, UserIcon } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; +import { Badge } from '@constructive-io/ui/badge'; +import { Separator } from '@constructive-io/ui/separator'; import { Tabs, TabsContent, @@ -255,6 +257,15 @@ function pickAsOf(resources: BillingSettingsResources): string | undefined { return selected?.value; } +const tabTriggerClassName = cn( + 'relative min-h-10 shrink-0 rounded-none border-0 border-b-2 border-transparent bg-transparent px-3 py-2.5', + 'text-sm font-medium text-muted-foreground shadow-none', + 'hover:bg-transparent hover:text-foreground', + 'data-[active]:border-foreground data-[active]:bg-transparent data-[active]:text-foreground data-[active]:shadow-none', + 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + 'pointer-coarse:min-h-11' +); + /** * Page identity cluster. Title is optional (`showHeader`); account meta is * always shown so embedded children can drop repeated Avery/Acme chrome. @@ -274,64 +285,130 @@ function SettingsIdentity({ formatOptions: BillingFormatOptions; showTitle: boolean; }) { + const AccountIcon = + account.kind === 'organization' ? Building2Icon : UserIcon; + const accountLabel = account.label ?? account.entityId; + return ( -
- {showTitle ? ( -
-

- {messages.title} -

- - - - - -

{messages.helpTitle}

-

{messages.description}

-
-
- - {messages.description} - -
- ) : null} + {messages.title} + + + + + + +

{messages.helpTitle}

+

{messages.description}

+
+
+ + {messages.description} + + +

+ {messages.description} +

+ + ) : null} -
-
- - {account.label ?? account.entityId} - - - {messages.accountKind[account.kind]} - +
+
+ + + {accountLabel} + + + {messages.accountKind[account.kind]} + +
+ {asOf ? ( +

+ {messages.asOfLabel}{' '} + +

+ ) : null}
- {asOf ? ( -

- {messages.asOfLabel}{' '} - -

- ) : null}
); } -function SectionLead({ children }: { children: React.ReactNode }) { +function SectionIntro({ + title, + lead +}: { + title: string; + lead: string; +}) { + return ( +
+

+ {title} +

+

+ {lead} +

+
+ ); +} + +function ContentZone({ + children, + className, + label +}: { + children: React.ReactNode; + className?: string; + label?: string; +}) { return ( -

+

+ {label ? ( +

+ {label} +

+ ) : null} {children} -

+
); } @@ -406,8 +483,8 @@ export function BillingSettingsPage({ className={cn( // Container is the layout engine — media queries alone aren't enough // when this page is embedded in a sidebar shell or showcase frame. - '@container/billing-settings-page mx-auto flex w-full max-w-7xl min-w-0 flex-col', - 'gap-4 @min-[640px]/billing-settings-page:gap-6', + '@container/billing-settings-page mx-auto flex w-full max-w-6xl min-w-0 flex-col', + 'gap-0', className )} > @@ -421,7 +498,7 @@ export function BillingSettingsPage({ /> {sectionError ? ( - + void handleSectionChange(value)} - className="min-w-0 gap-4 @min-[640px]/billing-settings-page:gap-6" + className={cn( + 'min-w-0 gap-0', + showHeader ? 'mt-1' : 'mt-4' + )} > {/* - Sticky section switcher: stays reachable while scrolling long - overview stacks. Full-width 3-up on narrow containers; inline - pill list when the page is wide enough. + Sticky underline section switcher — SaaS settings chrome. + Full-width 3-up on narrow containers; left-aligned tabs when wide. */}
{messages.overviewTabLabel} {messages.usageTabLabel} {messages.plansTabLabel} @@ -483,21 +562,25 @@ export function BillingSettingsPage({
- +
- {messages.overviewSectionLead} + {/* Two-stage layout so the side rail never paints over entitlements: 1) usage + rail share one row - 2) entitlements is a full-width sibling below (not a grid peer - that sticky content can cover). No sticky rail — tall - subscription+credits stacks must scroll with the page. + 2) entitlements is a full-width sibling below */} -
+
- + + + +
- +
- {messages.usageSectionLead} + {/* Stack on phone/tablet; side-by-side only when the container is @@ -582,11 +674,14 @@ export function BillingSettingsPage({ */}
-
+ -
-
+ + -
+
- +
- {messages.plansSectionLead} +
); } diff --git a/apps/blocks/src/components/billing-showcase/billing-showcase-preview.test.tsx b/apps/blocks/src/components/billing-showcase/billing-showcase-preview.test.tsx index 878c918..d4273db 100644 --- a/apps/blocks/src/components/billing-showcase/billing-showcase-preview.test.tsx +++ b/apps/blocks/src/components/billing-showcase/billing-showcase-preview.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { BillingShowcaseCanvas } from './billing-showcase-canvas'; import { BillingShowcasePreview } from './billing-showcase-preview'; @@ -13,6 +13,43 @@ function selectOption(triggerName: string, optionLabel: string) { fireEvent.click(option as HTMLElement); } +function mockStageWidth(width: number) { + const observers = new Set(); + + class ResizeObserverStub { + constructor(private readonly callback: ResizeObserverCallback) { + observers.add(callback); + } + + observe() { + this.callback([], this as unknown as ResizeObserver); + } + + unobserve() {} + + disconnect() { + observers.delete(this.callback); + } + } + + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + + return { + apply(stage: HTMLElement) { + Object.defineProperty(stage, 'clientWidth', { + configurable: true, + get: () => width + }); + for (const callback of observers) { + callback([], {} as ResizeObserver); + } + }, + cleanup() { + vi.unstubAllGlobals(); + } + }; +} + describe('BillingShowcasePreview', () => { it('switches the canonical subscription fixture with the account control', () => { const { rerender } = render( @@ -173,4 +210,66 @@ describe('BillingShowcasePreview', () => { '768' ); }); + + it('scales the desktop frame to the stage width without changing the logical viewport', () => { + const stageWidth = mockStageWidth(640); + + try { + const { container } = render( + + ); + const stage = container.querySelector( + '[data-slot="billing-preview-stage"]' + ); + expect(stage).not.toBeNull(); + act(() => { + stageWidth.apply(stage as HTMLElement); + }); + + const frame = screen.getByTitle('Pricing table inline live preview'); + const shell = container.querySelector( + '[data-slot="billing-preview-frame"]' + ); + + expect(frame).toHaveAttribute('width', '1280'); + expect(frame).toHaveAttribute('data-preview-viewport', 'desktop'); + expect(shell).toHaveAttribute('data-preview-scale', '0.500'); + expect(shell).toHaveStyle({ width: '640px' }); + } finally { + stageWidth.cleanup(); + } + }); + + it('does not upscale when the stage is wider than the device viewport', async () => { + const user = userEvent.setup(); + const stageWidth = mockStageWidth(900); + + try { + const { container } = render( + + ); + await user.click( + screen.getByRole('button', { name: 'Mobile preview, 390 pixels' }) + ); + + const stage = container.querySelector( + '[data-slot="billing-preview-stage"]' + ); + expect(stage).not.toBeNull(); + act(() => { + stageWidth.apply(stage as HTMLElement); + }); + + const frame = screen.getByTitle('Pricing table inline live preview'); + const shell = container.querySelector( + '[data-slot="billing-preview-frame"]' + ); + + expect(frame).toHaveAttribute('width', '390'); + expect(shell).toHaveAttribute('data-preview-scale', '1.000'); + expect(shell).toHaveStyle({ width: '390px' }); + } finally { + stageWidth.cleanup(); + } + }); }); diff --git a/apps/blocks/src/components/billing-showcase/billing-showcase-preview.tsx b/apps/blocks/src/components/billing-showcase/billing-showcase-preview.tsx index e03446d..0828547 100644 --- a/apps/blocks/src/components/billing-showcase/billing-showcase-preview.tsx +++ b/apps/blocks/src/components/billing-showcase/billing-showcase-preview.tsx @@ -3,8 +3,11 @@ import { useEffect, useId, + useLayoutEffect, useRef, useState, + type CSSProperties, + type ReactNode, type Ref } from 'react'; import { @@ -44,6 +47,7 @@ import { } from '@constructive-io/ui/tooltip'; import { getBillingBlock, type BillingBlockName } from '@/lib/billing-blocks'; +import { cn } from '@/lib/utils'; import { BILLING_SHOWCASE_ACCOUNT_OPTIONS, @@ -87,6 +91,53 @@ function viewportOption(value: BillingPreviewViewport) { return BILLING_PREVIEW_VIEWPORTS.find((option) => option.value === value)!; } +function fitScale(availableWidth: number, viewportWidth: number) { + if (availableWidth <= 0 || viewportWidth <= 0) return 1; + return Math.min(1, availableWidth / viewportWidth); +} + +function formatPreviewScale(scale: number) { + return scale.toFixed(3); +} + +/** Content-box width (clientWidth minus horizontal padding). */ +function contentBoxWidth(element: HTMLElement) { + const styles = window.getComputedStyle(element); + const paddingInline = + (Number.parseFloat(styles.paddingLeft) || 0) + + (Number.parseFloat(styles.paddingRight) || 0); + return Math.max(0, element.clientWidth - paddingInline); +} + +/** + * Measure the stage content box and return a ≤1 scale so a fixed logical + * device width still fits without horizontal clipping. + */ +function usePreviewFitScale( + measureRef: { current: HTMLElement | null }, + viewportWidth: number +) { + const [scale, setScale] = useState(1); + + useLayoutEffect(() => { + const element = measureRef.current; + if (!element) return; + + const update = () => { + setScale(fitScale(contentBoxWidth(element), viewportWidth)); + }; + + update(); + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [measureRef, viewportWidth]); + + return scale; +} + function BillingPreviewViewportControls({ label, onChange, @@ -137,31 +188,75 @@ function BillingPreviewIframe({ height, mode, name, + scale, source, viewport }: { frameRef?: Ref; - height: number | string; + height: number; mode: 'full-screen' | 'inline'; name: BillingBlockName; + scale: number; source: string; viewport: BillingPreviewViewport; }) { const option = viewportOption(viewport); const title = getBillingBlock(name)?.title ?? name; + const layoutWidth = option.width * scale; + const layoutHeight = height * scale; + + const frameStyle: CSSProperties = { + width: option.width, + height, + transform: scale === 1 ? undefined : `scale(${scale})`, + transformOrigin: 'top left' + }; + + return ( +
+