diff --git a/apps/ui/src/app/index.tsx b/apps/ui/src/app/index.tsx index a03a589e99..508e96cdd2 100644 --- a/apps/ui/src/app/index.tsx +++ b/apps/ui/src/app/index.tsx @@ -1,4 +1,5 @@ import { AppProviders } from '@/app/app-providers'; +import { UsageExplorationControls } from '@/components/usage-exploration-controls'; import { ClassicUiApp } from '@/ui-classic/app'; import '@wordpress/components/build-style/style.css'; import '@wordpress/dataviews/build-style/style.css'; @@ -15,6 +16,7 @@ export function App( { connector }: AppProps ) { return ( + ); } diff --git a/apps/ui/src/components/ai-credits-details-dialog/index.tsx b/apps/ui/src/components/ai-credits-details-dialog/index.tsx new file mode 100644 index 0000000000..cab434c9a0 --- /dev/null +++ b/apps/ui/src/components/ai-credits-details-dialog/index.tsx @@ -0,0 +1,45 @@ +import { __ } from '@wordpress/i18n'; +import { Dialog } from '@wordpress/ui'; +import styles from './style.module.css'; + +export function AiCreditsDetailsDialog( { + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: ( open: boolean ) => void; +} ) { + return ( + + + + { __( 'How AI credits work' ) } + + + + + { __( + 'AI credits are Studio’s way of measuring AI-powered work. They are not cash, and they are different from the tokens an AI provider uses to count pieces of text.' + ) } + + + { __( + 'Your monthly allowance resets each month, and unused monthly AI credits do not carry over. Purchased AI credits do not expire and are used after your monthly allowance.' + ) } + + + { __( + 'Different models use AI credits at different rates. More capable models generally use more credits, and longer or more complex tasks can cost more because they require more model work.' + ) } + + + + + + { __( 'Close' ) } + + + + + ); +} diff --git a/apps/ui/src/components/ai-credits-details-dialog/style.module.css b/apps/ui/src/components/ai-credits-details-dialog/style.module.css new file mode 100644 index 0000000000..ad1382dd4d --- /dev/null +++ b/apps/ui/src/components/ai-credits-details-dialog/style.module.css @@ -0,0 +1,7 @@ +.details { + display: flex; + flex-direction: column; + gap: var( --wpds-dimension-padding-sm ); + color: var( --wpds-color-fg-content-neutral ); + line-height: var( --wpds-typography-line-height-sm ); +} diff --git a/apps/ui/src/components/app-message-cards/index.tsx b/apps/ui/src/components/app-message-cards/index.tsx index 48ad895b8d..9eaa0885be 100644 --- a/apps/ui/src/components/app-message-cards/index.tsx +++ b/apps/ui/src/components/app-message-cards/index.tsx @@ -1,48 +1,79 @@ import { __ } from '@wordpress/i18n'; +import { external, Icon } from '@wordpress/icons'; import { Button, Notice } from '@wordpress/ui'; import { clsx } from 'clsx'; +import { useEffect, useState } from 'react'; import toastStyles from '@/components/app-toasts/style.module.css'; +import { PurchaseCreditsDialog } from '@/components/purchase-credits-dialog'; +import { + OPEN_PURCHASE_CREDITS_EVENT, + PURCHASE_CREDITS_PROTOTYPE_URL, +} from '@/components/purchase-credits-dialog/events'; +import { useConnector } from '@/data/core'; import { useActivePersistentMessages } from '@/data/queries/use-app-messages'; +import { useUsageExploration } from '@/data/usage-exploration'; import styles from './style.module.css'; export function AppMessageCards( { className }: { className?: string } ) { const { messages, dismiss } = useActivePersistentMessages(); + const [ purchaseOpen, setPurchaseOpen ] = useState( false ); + const connector = useConnector(); + const { purchaseCreditsFlow } = useUsageExploration(); + const opensExternalCheckout = purchaseCreditsFlow === 'external'; - if ( ! messages.length ) { - return null; - } + useEffect( () => { + const openPurchase = () => { + if ( opensExternalCheckout ) { + void connector.openExternalUrl( PURCHASE_CREDITS_PROTOTYPE_URL ); + return; + } + setPurchaseOpen( true ); + }; + window.addEventListener( OPEN_PURCHASE_CREDITS_EVENT, openPurchase ); + return () => window.removeEventListener( OPEN_PURCHASE_CREDITS_EVENT, openPurchase ); + }, [ connector, opensExternalCheckout ] ); return ( -
- { messages.map( ( message ) => ( -
- - { message.title } - { message.description ? ( - { message.description } - ) : null } - { message.cta ? ( - - - - ) : null } - dismiss( message ) } /> - + <> + { messages.length ? ( +
+ { messages.map( ( message ) => ( +
+ + { message.title } + { message.description ? ( + { message.description } + ) : null } + { message.cta ? ( + + + + ) : null } + dismiss( message ) } /> + +
+ ) ) }
- ) ) } -
+ ) : null } + + ); } diff --git a/apps/ui/src/components/purchase-credits-dialog/events.ts b/apps/ui/src/components/purchase-credits-dialog/events.ts new file mode 100644 index 0000000000..e89a0866d0 --- /dev/null +++ b/apps/ui/src/components/purchase-credits-dialog/events.ts @@ -0,0 +1,7 @@ +export const OPEN_PURCHASE_CREDITS_EVENT = 'studio:open-purchase-credits'; +export const PURCHASE_CREDITS_PROTOTYPE_URL = + 'https://wordpress.com/checkout/studio-ai-credits?prototype=1'; + +export function openPurchaseCreditsDialog() { + window.dispatchEvent( new Event( OPEN_PURCHASE_CREDITS_EVENT ) ); +} diff --git a/apps/ui/src/components/purchase-credits-dialog/index.tsx b/apps/ui/src/components/purchase-credits-dialog/index.tsx new file mode 100644 index 0000000000..83e2044d96 --- /dev/null +++ b/apps/ui/src/components/purchase-credits-dialog/index.tsx @@ -0,0 +1,335 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { privateApis } from '@wordpress/theme'; +import { Button, Dialog, Tooltip } from '@wordpress/ui'; +import { useMemo, useState } from 'react'; +import { toast } from '@/data/app-messages'; +import { + addExplorationCredits, + CREDITS_PER_DOLLAR, + creditsFromDollars, + dollarsFromCredits, + useUsageExploration, +} from '@/data/usage-exploration'; +import { useColorScheme } from '@/hooks/use-color-scheme'; +import { unlock } from '@/lock-unlock'; +import styles from './style.module.css'; +import type { CSSProperties } from 'react'; + +const MIN_CREDIT_AMOUNT = creditsFromDollars( 10 ); +const MAX_CREDIT_AMOUNT = creditsFromDollars( 200 ); +const MAX_TYPED_CREDIT_AMOUNT = creditsFromDollars( 99_999 ); +const DEFAULT_CREDIT_AMOUNT = creditsFromDollars( 50 ); +const CARD_AMOUNTS = [ 10, 20, 50, 100 ].map( creditsFromDollars ); +const PRESET_AMOUNTS = [ 25, 50, 100 ].map( creditsFromDollars ); +const CONFETTI_COLORS = [ + 'var(--wpds-color-fg-interactive-brand)', + 'var(--wpds-color-fg-content-success)', + 'var(--wpds-color-fg-content-warning)', + 'var(--wpds-color-fg-content-error)', +]; +const creditAmountFormatter = new Intl.NumberFormat(); +const priceFormatter = new Intl.NumberFormat( undefined, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 2, +} ); +const { ThemeProvider } = unlock( privateApis ); +function jitter( index: number, salt: number ): number { + const value = Math.sin( index * 127.1 + salt * 311.7 ) * 43758.5453; + return value - Math.floor( value ); +} + +function ConfettiBurst() { + const pieces = useMemo( + () => + Array.from( { length: 22 }, ( _, index ) => ( { + angle: index * ( 360 / 22 ) + ( jitter( index, 1 ) * 24 - 12 ), + distance: 52 + jitter( index, 2 ) * 48, + spin: jitter( index, 3 ) * 540 - 270, + delay: jitter( index, 4 ) * 120, + color: CONFETTI_COLORS[ index % CONFETTI_COLORS.length ], + width: 4 + jitter( index, 5 ) * 3, + height: 6 + jitter( index, 6 ) * 4, + } ) ), + [] + ); + + return ( + + ); +} + +export function PurchaseCreditsDialog( { + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: ( open: boolean ) => void; +} ) { + const [ amount, setAmount ] = useState( String( DEFAULT_CREDIT_AMOUNT ) ); + const [ confettiKey, setConfettiKey ] = useState( 0 ); + const { purchaseCreditsVariant: variant } = useUsageExploration(); + const colorScheme = useColorScheme(); + const dialogBackground = colorScheme === 'dark' ? '#1e1e1e' : '#ffffff'; + const checkoutAmount = Number( amount ); + const checkoutPrice = dollarsFromCredits( checkoutAmount ); + const hasValidAmount = + Number.isInteger( checkoutAmount ) && + checkoutAmount >= MIN_CREDIT_AMOUNT && + checkoutAmount <= MAX_TYPED_CREDIT_AMOUNT; + const sliderAmount = Math.min( + MAX_CREDIT_AMOUNT, + Math.max( MIN_CREDIT_AMOUNT, checkoutAmount || DEFAULT_CREDIT_AMOUNT ) + ); + const isOffScale = checkoutAmount > MAX_CREDIT_AMOUNT; + const formattedAmount = amount ? creditAmountFormatter.format( checkoutAmount ) : ''; + const formattedCreditCount = creditAmountFormatter.format( checkoutAmount ); + const formattedCheckoutPrice = priceFormatter.format( checkoutPrice ); + const hasSelectedPreset = PRESET_AMOUNTS.includes( checkoutAmount ); + const moveCaretToEnd = ( input: HTMLInputElement ) => { + const end = input.value.length; + input.setSelectionRange( end, end ); + }; + const updateTypedAmount = ( value: string ) => { + const digits = value.replace( /\D/g, '' ); + const normalizedAmount = digits.replace( /^0+(?=\d)/, '' ); + const nextAmount = + normalizedAmount.length > String( MAX_TYPED_CREDIT_AMOUNT ).length + ? String( MAX_TYPED_CREDIT_AMOUNT ) + : normalizedAmount; + + if ( + variant === 'slider' && + Number( nextAmount ) > MAX_CREDIT_AMOUNT && + nextAmount !== amount + ) { + setConfettiKey( ( key ) => key + 1 ); + } + + setAmount( nextAmount ); + }; + + const continueToCheckout = () => { + if ( ! hasValidAmount ) { + return; + } + + addExplorationCredits( checkoutPrice ); + onOpenChange( false ); + toast.success( + sprintf( + /* translators: %s: number of AI credits purchased. */ + __( '%s AI credits added' ), + formattedCreditCount + ) + ); + }; + + return ( + + + + + { __( 'Add AI credits' ) } + + + + { __( + 'Choose a one-time AI credit amount to check out securely on WordPress.com. Purchased AI credits do not expire and are used after your monthly allowance.' + ) } + + { variant === 'cards' && ( +
+ { CARD_AMOUNTS.map( ( option ) => ( + + ) ) } +
+ ) } + { variant === 'presets' && ( +
+
+ { PRESET_AMOUNTS.map( ( option ) => ( + + ) ) } +
+ +
+ ) } + { variant === 'slider' && ( +
+ + + { confettiKey > 0 && } + updateTypedAmount( event.target.value ) } + onFocus={ ( event ) => moveCaretToEnd( event.currentTarget ) } + onMouseUp={ ( event ) => { + event.preventDefault(); + moveCaretToEnd( event.currentTarget ); + } } + /> + + + { hasValidAmount + ? formattedCheckoutPrice + : sprintf( + /* translators: %s: minimum number of AI credits. */ + __( '%s or more AI credits' ), + creditAmountFormatter.format( MIN_CREDIT_AMOUNT ) + ) } + + { __( 'one time' ) } + + + setAmount( event.target.value ) } + aria-label={ __( 'AI credit amount slider' ) } + data-overflow={ isOffScale ? '' : undefined } + /> + +
+ ) } +
+ + + { __( 'Cancel' ) } + + + + } + > + { hasValidAmount + ? sprintf( + /* translators: %s: price for the selected AI credits. */ + __( 'Continue for %s' ), + formattedCheckoutPrice + ) + : __( 'Continue' ) } + + }> + { __( 'Checkout on WordPress.com' ) } + + + +
+
+
+ ); +} diff --git a/apps/ui/src/components/purchase-credits-dialog/style.module.css b/apps/ui/src/components/purchase-credits-dialog/style.module.css new file mode 100644 index 0000000000..648701af6c --- /dev/null +++ b/apps/ui/src/components/purchase-credits-dialog/style.module.css @@ -0,0 +1,217 @@ +.amountCards { + display: grid; + gap: var( --wpds-dimension-gap-sm ); + margin-block-start: var( --wpds-dimension-padding-lg ); +} + +.amountCards[data-layout='grid'] { + grid-template-columns: repeat( 2, minmax( 0, 1fr ) ); +} + +.amountCards[data-layout='row'] { + grid-template-columns: repeat( 3, minmax( 0, 1fr ) ); +} + +.amountOption { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: var( --wpds-dimension-gap-xs ); + padding: var( --wpds-dimension-padding-md ); + border: 1px solid var( --wpds-color-stroke-surface-neutral ); + border-radius: 8px; + background: var( --wpds-color-bg-surface-neutral-weak ); + color: var( --wpds-color-fg-content-neutral ); + cursor: pointer; + text-align: start; +} + +.amountOption:hover { + border-color: var( --wpds-color-stroke-interactive-neutral ); +} + +.amountOption[data-selected] { + border-color: transparent; + outline: 2px solid var( --wpds-color-stroke-interactive-brand ); + outline-offset: -1px; +} + +.amountOption:focus-visible { + outline: 2px solid var( --wpds-color-stroke-focus-brand ); + outline-offset: -1px; +} + +.optionValue { + font-size: var( --wpds-typography-font-size-lg ); + font-weight: 600; +} + +.amountCards[data-layout='row'] .optionValue { + font-size: var( --wpds-typography-font-size-sm ); +} + +.optionCreditCount, +.optionFrequency { + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); +} + +.optionFrequency { + font-size: 11px; +} + +.presetPicker, +.amountPicker { + display: flex; + flex-direction: column; +} + +.amountPicker { + margin-block-start: var( --wpds-dimension-padding-lg ); +} + +.amountPicker .amountControl { + position: relative; +} + +.amountPicker .amountDetails { + align-items: flex-end; + text-align: end; +} + +.customAmount { + display: flex; + flex-direction: column; + margin-block-start: var( --wpds-dimension-padding-md ); +} + +.amountInputLabel { + margin-block-end: var( --wpds-dimension-gap-xs ); + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-sm ); + font-weight: 500; +} + +.amountDetails { + display: flex; + align-items: flex-start; + flex-direction: column; + line-height: 1.25; +} + +.amountLabel, +.amountFrequency { + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); +} + +.amountFrequency { + font-size: 11px; +} + +.amountControl { + display: flex; + align-items: center; + gap: var( --wpds-dimension-gap-sm ); + box-sizing: border-box; + min-block-size: 64px; + padding: var( --wpds-dimension-padding-sm ) var( --wpds-dimension-padding-md ); + border: 1px solid var( --wpds-color-stroke-surface-neutral ); + border-radius: 8px; + background: var( --wpds-color-bg-surface-neutral-weak ); +} + +.amountControl:focus-within { + border-color: transparent; + outline: 2px solid var( --wpds-color-stroke-interactive-brand ); + outline-offset: -1px; +} + +.customAmountPrefix { + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-lg ); + font-weight: 600; +} + +.amountInput, +.customAmountInput { + min-inline-size: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-xl ); + font-weight: 600; +} + +.customAmountInput::placeholder { + color: var( --wpds-color-fg-content-neutral-weak ); +} + +.amountRange { + inline-size: 100%; + margin: var( --wpds-dimension-padding-lg ) 0 0; + accent-color: var( --wpds-color-bg-interactive-brand-strong ); + cursor: pointer; +} + +.amountRange[data-overflow] { + filter: drop-shadow( 3px 0 0 var( --wpds-color-fg-interactive-brand ) ); +} + +.amountRange:focus-visible { + outline: 2px solid var( --wpds-color-stroke-focus-brand ); + outline-offset: 3px; +} + +.rangeLabels { + display: flex; + justify-content: space-between; + margin-block-start: var( --wpds-dimension-gap-xs ); + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); +} + +.rangeLabels[data-overflow] span:last-child { + color: var( --wpds-color-fg-interactive-brand ); + font-weight: 600; +} + +.confetti { + position: absolute; + inset: 0; + pointer-events: none; +} + +.confettiPiece { + position: absolute; + top: 50%; + left: 50%; + border-radius: 1px; + opacity: 0; + animation: confetti-burst 900ms cubic-bezier( 0.16, 1, 0.3, 1 ) forwards; +} + +@keyframes confetti-burst { + 0% { + opacity: 1; + transform: rotate( var( --confetti-angle ) ) translateX( 8px ) rotate( 0deg ); + } + + 100% { + opacity: 0; + transform: rotate( var( --confetti-angle ) ) translateX( var( --confetti-distance ) ) + rotate( var( --confetti-spin ) ); + } +} + +@media ( prefers-reduced-motion: reduce ) { + .confettiPiece { + animation: none; + } +} + +.dialogPopup { + overflow: visible; +} diff --git a/apps/ui/src/components/settings-view/style.module.css b/apps/ui/src/components/settings-view/style.module.css index 7344356b45..e060f13878 100644 --- a/apps/ui/src/components/settings-view/style.module.css +++ b/apps/ui/src/components/settings-view/style.module.css @@ -868,9 +868,138 @@ .progressValue { block-size: 100%; border-radius: inherit; - background: var( --wpds-color-fg-interactive-brand ); + background: var( --wpds-color-fg-content-neutral ); } +.progressValueWarning { + background: var( --wpds-color-fg-content-caution-weak ); + filter: saturate( 2 ) brightness( 1.8 ); +} + +.progressValueCritical { + background: var( --wpds-color-fg-content-warning-weak ); + filter: saturate( 1.6 ) brightness( 1.35 ); +} + +.progressValueExhausted { + background: var( --wpds-color-bg-interactive-error-strong ); +} + +.creditMeter { + display: flex; + flex-direction: column; + gap: var( --wpds-dimension-padding-xs ); +} + +.creditMeterHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var( --wpds-dimension-padding-md ); + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-sm ); + font-weight: 500; + line-height: var( --wpds-typography-line-height-sm ); +} + +.creditMeterValue { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.creditMeterCredits { + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); + font-variant-numeric: tabular-nums; + line-height: var( --wpds-typography-line-height-sm ); +} + +.extraCreditRow { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var( --wpds-dimension-padding-md ); +} + +.extraCreditRow .creditMeter, +.extraCreditRow .creditTopUpText { + inline-size: 100%; +} + +.extraCreditSummary { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.extraCreditSummary strong { + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-xs ); + font-weight: 500; + line-height: var( --wpds-typography-line-height-sm ); +} + +.extraCreditSummary span { + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); + font-variant-numeric: tabular-nums; + line-height: var( --wpds-typography-line-height-sm ); +} + +.extraCreditBalanceLine { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var( --wpds-dimension-gap-xs ); +} + +.extraCreditBalanceLine > strong { + font-size: var( --wpds-typography-font-size-sm ); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.creditTopUpText { + min-width: 0; +} + +.creditTopUpText strong { + color: var( --wpds-color-fg-content-neutral ); + font-size: var( --wpds-typography-font-size-xs ); + font-weight: 500; + line-height: var( --wpds-typography-line-height-sm ); +} + +.creditTopUpText p { + margin-block-start: 2px; + font-size: var( --wpds-typography-font-size-xs ); +} + +.creditTopUpButton { + align-self: flex-start; +} + +.creditTopUpAction { + display: flex; + align-items: center; + align-self: flex-start; + gap: var( --wpds-dimension-gap-sm ); +} + +.creditTopUpAction > span { + color: var( --wpds-color-fg-content-neutral-weak ); + font-size: var( --wpds-typography-font-size-xs ); + line-height: var( --wpds-typography-line-height-sm ); +} + +.aiCreditsDetailsButton { + flex: none; + padding-inline: 0; + font-weight: 400; +} + + .previewActionsButton { flex-shrink: 0; } diff --git a/apps/ui/src/components/settings-view/usage-panel.test.tsx b/apps/ui/src/components/settings-view/usage-panel.test.tsx index ccc61abad1..f2c47b9e18 100644 --- a/apps/ui/src/components/settings-view/usage-panel.test.tsx +++ b/apps/ui/src/components/settings-view/usage-panel.test.tsx @@ -10,6 +10,7 @@ import { useSnapshots, } from '@/data/queries/use-snapshots'; import { useUserLocale } from '@/data/queries/use-user-locale'; +import { setUsageExplorationScenario } from '@/data/usage-exploration'; import { useOffline } from '@/hooks/use-offline'; import { UsagePanel } from './usage-panel'; import type { ButtonHTMLAttributes, ReactNode } from 'react'; @@ -69,6 +70,10 @@ vi.mock( '@/data/queries/use-auth-user', () => ( { useLogin: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-assistant-quota', () => ( { + useStudioAssistantQuota: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-snapshots', () => ( { useDeleteAllSnapshots: vi.fn(), useSnapshotUsage: vi.fn(), @@ -79,14 +84,25 @@ vi.mock( '@/hooks/use-offline', () => ( { useOffline: vi.fn(), } ) ); -vi.mock( '@/data/queries/use-assistant-quota', () => ( { - useStudioAssistantQuota: vi.fn(), -} ) ); - vi.mock( '@/data/queries/use-user-locale', () => ( { useUserLocale: vi.fn(), } ) ); +vi.mock( '@/components/purchase-credits-dialog', () => ( { + PurchaseCreditsDialog: () => null, +} ) ); + +vi.mock( '@/components/ai-credits-details-dialog', () => ( { + AiCreditsDetailsDialog: ( { open }: { open: boolean } ) => + open ? ( +
+ Purchased AI credits do not expire and are used after your monthly allowance. Different + models use AI credits at different rates. AI credits measure Studio usage; they are + different from the tokens an AI provider uses to count pieces of text. +
+ ) : null, +} ) ); + // Reached through `useAgenticFeatures`, which reads the agentic-features // preference; this panel has no QueryClientProvider. vi.mock( '@/data/queries/use-user-preferences', () => ( { @@ -95,13 +111,13 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( { } ) ); const useConnectorMock = vi.mocked( useConnector ); +const useStudioAssistantQuotaMock = vi.mocked( useStudioAssistantQuota, { partial: true } ); const useAuthUserMock = vi.mocked( useAuthUser ); const useLoginMock = vi.mocked( useLogin ); const useDeleteAllSnapshotsMock = vi.mocked( useDeleteAllSnapshots ); const useSnapshotUsageMock = vi.mocked( useSnapshotUsage ); const useSnapshotsMock = vi.mocked( useSnapshots ); const useOfflineMock = vi.mocked( useOffline ); -const useStudioAssistantQuotaMock = vi.mocked( useStudioAssistantQuota ); const useUserLocaleMock = vi.mocked( useUserLocale ); describe( 'UsagePanel', () => { @@ -111,6 +127,7 @@ describe( 'UsagePanel', () => { beforeEach( () => { vi.clearAllMocks(); + setUsageExplorationScenario( 'warning' ); confirmDeleteAllPreviewSites.mockResolvedValue( true ); // `agenticRequiresAuth` lets the real useAgenticFeatures derive the @@ -120,10 +137,6 @@ describe( 'UsagePanel', () => { agenticRequiresAuth: true, } as never ); useOfflineMock.mockReturnValue( false ); - useStudioAssistantQuotaMock.mockReturnValue( { - data: undefined, - isLoading: false, - } as never ); useUserLocaleMock.mockReturnValue( 'en' ); useAuthUserMock.mockReturnValue( { data: { id: 1, displayName: 'Ada Lovelace', email: 'ada@example.com' }, @@ -140,73 +153,92 @@ describe( 'UsagePanel', () => { isPending: false, error: null, } as never ); + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 0, + costCap: 100, + costResetDate: '2026-08-01T12:00:00', + studioCodeAiHasAccess: true, + studioCodeAiAccess: 'granted', + }, + isLoading: false, + } as never ); } ); it( 'renders AI credits and preview site usage for the signed-in user', () => { render( ); expect( screen.getByRole( 'heading', { name: 'Usage' } ) ).toBeInTheDocument(); - expect( - screen.getByText( - 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.' - ) - ).toBeInTheDocument(); + expect( screen.getByText( '100,000 available' ) ).toBeInTheDocument(); + expect( screen.getByText( '400,000 of 500,000 AI credits used' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument(); expect( screen.getByText( '2 of 10 active preview sites' ) ).toBeInTheDocument(); expect( useSnapshotsMock ).toHaveBeenCalledWith( 1 ); expect( useSnapshotUsageMock ).toHaveBeenCalledWith( 1 ); expect( useDeleteAllSnapshotsMock ).toHaveBeenCalledWith( 1 ); } ); - it( 'renders AI usage when a quota with a cost cap is available', () => { - useStudioAssistantQuotaMock.mockReturnValue( { - data: { costUsage: 25, costCap: 100, costResetDate: '2026-08-01T12:00:00' }, - isLoading: false, - } as never ); - + it( 'renders the exhausted exploration state', () => { + setUsageExplorationScenario( 'exhausted' ); render( ); + expect( screen.getByText( '0 available' ) ).toBeInTheDocument(); + expect( screen.getByText( '500,000 of 500,000 AI credits used' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Keep chatting with AI credits' ) ).toBeInTheDocument(); expect( - screen.getByText( '25% of monthly limit used (resets on August 1, 2026)' ) - ).toBeInTheDocument(); - expect( - screen.queryByText( - 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.' + screen.getByText( + 'Keep your work moving without waiting for your monthly allowance to reset.' ) - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument(); } ); - it( 'shows an unavailable message when the quota fetch fails', () => { - useStudioAssistantQuotaMock.mockReturnValue( { - data: undefined, - isLoading: false, - isError: true, - } as never ); + it( 'shows purchased credits as a balance without an activity ledger', () => { + setUsageExplorationScenario( 'extra-healthy' ); render( ); - expect( - screen.getByText( 'Studio Code limits are temporarily unavailable.' ) - ).toBeInTheDocument(); - expect( - screen.queryByText( - 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.' - ) - ).not.toBeInTheDocument(); + expect( screen.getByText( '320,000 available' ) ).toBeInTheDocument(); + expect( screen.queryByText( 'Recent activity' ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument(); + expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 ); } ); - it( 'falls back to the Alpha copy when the quota has no cost cap', () => { - useStudioAssistantQuotaMock.mockReturnValue( { - data: { costUsage: 0, costCap: 0 }, - isLoading: false, - } as never ); + it( 'explains how monthly and purchased AI credits work', () => { + render( ); + + fireEvent.click( screen.getByRole( 'button', { name: 'How AI credits work' } ) ); + expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent( + 'Purchased AI credits do not expire and are used after your monthly allowance.' + ); + expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent( + 'Different models use AI credits at different rates.' + ); + expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent( + 'they are different from the tokens an AI provider uses to count pieces of text.' + ); + } ); + + it( 'offers prototype states for extra-credit usage', () => { + setUsageExplorationScenario( 'extra-exhausted' ); render( ); - expect( - screen.getByText( - 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.' - ) - ).toBeInTheDocument(); + expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument(); + expect( screen.getAllByText( '0 available' ) ).toHaveLength( 2 ); + expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 ); + } ); + + it( 'shows extra credits held in reserve before the monthly allowance is used', () => { + setUsageExplorationScenario( 'extra-reserve' ); + render( ); + + expect( screen.getByText( '320,000 available' ) ).toBeInTheDocument(); + expect( screen.getByText( '180,000 of 500,000 AI credits used' ) ).toBeInTheDocument(); + expect( screen.getByText( '500,000 available' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument(); + expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 ); } ); it( 'shows the suspension copy for an explicitly blocked account', () => { @@ -279,7 +311,7 @@ describe( 'UsagePanel', () => { ).toBeInTheDocument(); } ); - it( 'shows normal usage when access is granted through a default-allow policy', () => { + it( 'shows the exploration usage when access is granted through a default-allow policy', () => { useStudioAssistantQuotaMock.mockReturnValue( { data: { costUsage: 25, @@ -293,9 +325,8 @@ describe( 'UsagePanel', () => { render( ); - expect( - screen.getByText( '25% of monthly limit used (resets on August 1, 2026)' ) - ).toBeInTheDocument(); + expect( screen.getByText( '100,000 available' ) ).toBeInTheDocument(); + expect( screen.getByText( '400,000 of 500,000 AI credits used' ) ).toBeInTheDocument(); } ); it( 'confirms through the connector before deleting all preview sites', async () => { @@ -318,8 +349,7 @@ describe( 'UsagePanel', () => { expect( deleteSnapshotsMutate ).not.toHaveBeenCalled(); } ); - it( 'shows a loading row with an empty progress bar in both sections', () => { - useStudioAssistantQuotaMock.mockReturnValue( { data: undefined, isLoading: true } as never ); + it( 'keeps AI usage visible while preview usage loads', () => { // Preview usage is still cached from before the delete, so the bar would // otherwise keep its old fill next to a "Loading…" row. useDeleteAllSnapshotsMock.mockReturnValue( { @@ -330,21 +360,15 @@ describe( 'UsagePanel', () => { render( ); - expect( screen.getAllByText( 'Loading…' ) ).toHaveLength( 2 ); + expect( screen.getAllByText( 'Loading…' ) ).toHaveLength( 1 ); const bars = screen.getAllByTestId( 'usage-progress-bar' ); expect( bars ).toHaveLength( 2 ); - for ( const bar of bars ) { - expect( bar.firstElementChild ).toHaveStyle( { inlineSize: '0%' } ); - } + expect( bars[ 0 ].firstElementChild ).toHaveStyle( { inlineSize: '80%' } ); + expect( bars[ 1 ].firstElementChild ).toHaveStyle( { inlineSize: '0%' } ); } ); it( 'replaces figures and actions with the offline notice while offline', () => { useOfflineMock.mockReturnValue( true ); - useStudioAssistantQuotaMock.mockReturnValue( { - data: { costUsage: 25, costCap: 100, costResetDate: '2026-08-01T12:00:00' }, - isLoading: false, - } as never ); - render( ); expect( screen.getByRole( 'status' ) ).toHaveTextContent( "You're offline" ); diff --git a/apps/ui/src/components/settings-view/usage-panel.tsx b/apps/ui/src/components/settings-view/usage-panel.tsx index beddb59ab3..f181c7d479 100644 --- a/apps/ui/src/components/settings-view/usage-panel.tsx +++ b/apps/ui/src/components/settings-view/usage-panel.tsx @@ -1,17 +1,19 @@ import { clampQuotaFraction, - formatQuotaPercentage, - formatQuotaResetDate, getStudioCodeAiAccessState, } from '@studio/common/lib/studio-assistant-quota'; import { __, _n, sprintf } from '@wordpress/i18n'; -import { moreHorizontal } from '@wordpress/icons'; -import { IconButton } from '@wordpress/ui'; +import { external, Icon, moreHorizontal } from '@wordpress/icons'; +import { Button, IconButton } from '@wordpress/ui'; import { clsx } from 'clsx'; +import { useState } from 'react'; import { SigninNotice } from '@/components/agentic-signin-banner'; import { AiAccessRequiredNotice, AiBlockedNotice } from '@/components/ai-access-required-notice'; +import { AiCreditsDetailsDialog } from '@/components/ai-credits-details-dialog'; import * as Menu from '@/components/menu'; import { OfflineNotice } from '@/components/offline-banner'; +import { PurchaseCreditsDialog } from '@/components/purchase-credits-dialog'; +import { PURCHASE_CREDITS_PROTOTYPE_URL } from '@/components/purchase-credits-dialog/events'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota'; @@ -22,6 +24,7 @@ import { useSnapshots, } from '@/data/queries/use-snapshots'; import { useUserLocale } from '@/data/queries/use-user-locale'; +import { creditsFromDollars, useUsageExploration } from '@/data/usage-exploration'; import styles from './style.module.css'; const DEFAULT_PREVIEW_SITE_LIMIT = 10; @@ -39,79 +42,180 @@ function UnavailableSection( { title }: { title: string } ) { ); } -function UsageProgressBar( { fraction }: { fraction: number } ) { +function UsageProgressBar( { + fraction, + valueClassName, +}: { + fraction: number; + valueClassName?: string; +} ) { return (