diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx index e21fd4b4..b3137e34 100644 --- a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx @@ -45,7 +45,8 @@ import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/ Buy tab — pay step available via drawer CTA; ready to confirm Celo transaction. Buy tab — Celo tx submitted. Buy tab — settling on Base. - Manage tab — balance, stream, history, close/withdraw, API setup. + Manage tab — balance, stream, close/withdraw, API setup. + History tab — filtered, paginated AI credit funding history. G$ below minimum purchase threshold. Transaction or settlement error. Backend unreachable. @@ -55,11 +56,12 @@ import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/ Stepper for buyer key, consent, and pay — tap a step or CTA to open it in a drawer. - Credits balance, stream info, close/withdraw, API setup, and history. + Credits balance, stream info, close/withdraw, and API setup. + Paginated credit history with source, status, and date filters. diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx index edf8a8f1..c562c31b 100644 --- a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx @@ -10,6 +10,7 @@ import { PaymentPendingStory, PaymentConfirmedStory, CreditsManagementStory, + HistoryTabStory, InsufficientGBalanceStory, PaymentFailedStory, BackendUnavailableStory, @@ -65,6 +66,10 @@ export const CreditsManagement: Story = { render: () => , } +export const HistoryTab: Story = { + render: () => , +} + export const InsufficientGBalance: Story = { render: () => , } diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx index c15c1d83..de260e3a 100644 --- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -210,6 +210,24 @@ export function ManageTabStory() { ) } +export function HistoryTabStory() { + return ( + + ) +} + export function CreditsManagementStory() { return } diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx index af0e2042..81c75c2e 100644 --- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -17,6 +17,8 @@ import { updateToast, } from '@goodwidget/ui' import { useAiCreditsAdapter } from './adapter' +import { useAiCreditsHistory } from './useAiCreditsHistory' +import { DEPOSIT_BONUS_PERCENT, STREAM_BONUS_PERCENT } from './quoteMath' import { AiCreditsHero, AiCreditsFlowStepper, @@ -26,7 +28,7 @@ import { CreditsManagementCard, BuyerOperatorCard, SetupSnippet, - UsageLog, + HistoryTab, } from './components' import type { AiCreditsWidgetProps, @@ -216,20 +218,16 @@ function BuyCreditsPanel({ state, actions, isPending, onPay }: BuyPanelProps) { function ManagePanel({ state, actions, - backendUrl, }: { state: AiCreditsWidgetAdapterState actions: AiCreditsWidgetAdapterActions - backendUrl?: string }) { const [refreshing, setRefreshing] = React.useState(false) - const [usageLogRefreshSignal, setUsageLogRefreshSignal] = React.useState(0) const handleRefresh = async () => { setRefreshing(true) try { await actions.refresh() - setUsageLogRefreshSignal((value) => value + 1) } finally { setRefreshing(false) } @@ -251,12 +249,6 @@ function ManagePanel({ - - {state.error && ( @@ -313,6 +305,11 @@ function AiCreditsInner({ const { state, actions } = activeAdapter + const history = useAiCreditsHistory({ + address: state.address, + backendUrl, + }) + const handlePay = useCallback( async (quote: AiCreditsQuote) => { const toastId = createToast({ @@ -373,13 +370,16 @@ function AiCreditsInner({ tabs={[ { id: 'buy', label: 'Buy Credits' }, { id: 'manage', label: 'Manage' }, + { id: 'history', label: 'History' }, ]} activeTab={state.activeTab} onTabChange={handleTabChange} chainId={state.chainId ?? CELO_CHAIN_ID} /> {state.activeTab === 'manage' ? ( - + + ) : state.activeTab === 'history' ? ( + ) : ( buyPanel )} diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 0cbc64d7..0c2283f0 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -110,6 +110,20 @@ const WALLET_LOADING_STATE: Partial = { operatorAddress: null, } +function isInBuyFlowStatus(status: AiCreditsWidgetStatus): boolean { + return ( + status === 'purchase_setup' || + status === 'quote_ready' || + status === 'payment_pending' || + status === 'payment_confirmed' || + status === 'payment_failed' + ) +} + +function isNonBuyTab(tab: AiCreditsWidgetTab): boolean { + return tab === 'manage' || tab === 'history' +} + function resolveActiveTab( prev: AiCreditsWidgetAdapterState, overrides: Partial, @@ -169,7 +183,7 @@ function deriveStatus(params: { if (chainId !== null && chainId !== CELO_CHAIN_ID) return 'unsupported_chain' - if (error && activeTab !== 'manage') return 'payment_failed' + if (error && !isNonBuyTab(activeTab)) return 'payment_failed' if (gBalance === null) return 'purchase_setup' @@ -211,13 +225,15 @@ function withDerivedStatus( } } -function mergeStatePreservingManageTab( +function mergeStatePreservingNonBuyTab( prev: AiCreditsWidgetAdapterState, overrides: Partial, ): AiCreditsWidgetAdapterState { - if (prev.activeTab !== 'manage') { + const nextTab = overrides.activeTab ?? prev.activeTab + if (!isNonBuyTab(prev.activeTab) && !isNonBuyTab(nextTab)) { return withDerivedStatus(prev, overrides, true) } + const activeTab = isNonBuyTab(nextTab) ? nextTab : prev.activeTab const status = deriveStatus({ isConnected: true, chainId: overrides.chainId ?? prev.chainId, @@ -227,13 +243,13 @@ function mergeStatePreservingManageTab( operatorConsented: overrides.operatorConsented ?? prev.operatorConsented, error: overrides.error ?? prev.error, currentStatus: overrides.status ?? prev.status, - activeTab: 'manage', + activeTab, }) return { ...prev, ...overrides, status, - activeTab: 'manage', + activeTab, } } @@ -558,11 +574,11 @@ export function useAiCreditsAdapter({ }) setState((prev) => - mergeStatePreservingManageTab(prev, { + mergeStatePreservingNonBuyTab(prev, { buyerPubKey: account.address, buyerPrvKey: privateKey, error: null, - ...(prev.activeTab !== 'manage' ? { status: 'purchase_setup' } : {}), + ...(!isNonBuyTab(prev.activeTab) ? { status: 'purchase_setup' } : {}), }), ) } catch (err: unknown) { @@ -592,7 +608,7 @@ export function useAiCreditsAdapter({ } const ref: AccountRef = { payer: currentState.address, buyer: currentState.buyerPubKey } - const onManageTab = currentState.activeTab === 'manage' + const onNonBuyTab = isNonBuyTab(currentState.activeTab) try { const operatorStatus = await chainClient.getBuyerOperatorStatus(ref) @@ -604,10 +620,10 @@ export function useAiCreditsAdapter({ if (operatorStatus.operatorAccepted) { patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => - mergeStatePreservingManageTab(prev, { + mergeStatePreservingNonBuyTab(prev, { operatorConsented: true, error: null, - ...(!onManageTab ? { status: 'purchase_setup' } : {}), + ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), }), ) return @@ -632,10 +648,10 @@ export function useAiCreditsAdapter({ patchPayerSession(currentState.address, { operatorConsented: true }) setState((prev) => - mergeStatePreservingManageTab(prev, { + mergeStatePreservingNonBuyTab(prev, { operatorConsented: true, error: null, - ...(!onManageTab ? { status: 'purchase_setup' } : {}), + ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), }), ) } catch (err: unknown) { @@ -658,12 +674,12 @@ export function useAiCreditsAdapter({ if (!operatorStatus.operatorAccepted) return patchPayerSession(currentState.address, { operatorConsented: true }) - const onManageTab = currentState.activeTab === 'manage' + const onNonBuyTab = isNonBuyTab(currentState.activeTab) setState((prev) => - mergeStatePreservingManageTab(prev, { + mergeStatePreservingNonBuyTab(prev, { operatorConsented: true, error: null, - ...(!onManageTab ? { status: 'purchase_setup' } : {}), + ...(!onNonBuyTab ? { status: 'purchase_setup' } : {}), }), ) } catch { @@ -902,7 +918,7 @@ export function useAiCreditsAdapter({ }) } catch { setState((prev) => - mergeStatePreservingManageTab(prev, { + mergeStatePreservingNonBuyTab(prev, { status: 'backend_unavailable', error: 'Could not reach backend — check your connection', }), @@ -1097,7 +1113,7 @@ export function useAiCreditsAdapter({ ) return } - setState((prev) => mergeStatePreservingManageTab(prev, { activeTab: 'manage', error: null })) + setState((prev) => mergeStatePreservingNonBuyTab(prev, { activeTab: tab, error: null })) }, []) const handleStartPurchase = useCallback(() => { diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts index 8bd5c28d..7d7c0871 100644 --- a/packages/ai-credits-widget/src/backendClient.ts +++ b/packages/ai-credits-widget/src/backendClient.ts @@ -163,6 +163,74 @@ function paginateGdCredits( } } +function createDemoHistory(account: string): GdCreditEntry[] { + const normalized = normalizeAddress(account) + return [ + { + id: 'demo-deposit-1', + account: normalized, + rootAccount: normalized, + source: 'deposit', + gdAmountWei: '440000000000000000000', + totalCreditUsd: '6600', + principalUsd: '6000', + bonusUsd: '600', + fundingStatus: 'funded', + txHash: '0xdemo01', + logIndex: 0, + createdAt: '2026-06-25T14:30:00.000Z', + streamUpdateMonth: '2026-06', + buyerAddress: normalized, + }, + { + id: 'demo-stream-update-1', + account: normalized, + rootAccount: normalized, + source: 'streamUpdate', + gdAmountWei: '10000000000000000000', + totalCreditUsd: '0', + principalUsd: '0', + bonusUsd: '0', + fundingStatus: 'funded', + txHash: '0xdemo02', + logIndex: 0, + createdAt: '2026-06-24T12:00:00.000Z', + streamUpdateMonth: '2026-06', + buyerAddress: normalized, + }, + { + id: 'demo-stream-request-1', + account: normalized, + rootAccount: normalized, + source: 'streamRequest', + gdAmountWei: '8000000000000000000', + totalCreditUsd: '1200', + principalUsd: '1000', + bonusUsd: '200', + fundingStatus: 'pending', + txHash: '0xdemo03', + logIndex: 0, + createdAt: '2026-06-23T09:15:00.000Z', + streamUpdateMonth: '2026-06', + buyerAddress: normalized, + }, + { + id: 'demo-stream-cron-1', + account: normalized, + rootAccount: normalized, + source: 'streamCron', + gdAmountWei: '5000000000000000000', + totalCreditUsd: '900', + principalUsd: '750', + bonusUsd: '150', + fundingStatus: 'funded', + createdAt: '2026-06-22T08:00:00.000Z', + streamUpdateMonth: '2026-06', + buyerAddress: normalized, + }, + ] +} + export function resolveBuyerAddress(entries: GdCreditEntry[]): string | null { for (const entry of entries) { if (entry.buyerAddress && isAddress(entry.buyerAddress)) { @@ -234,7 +302,6 @@ export interface AiCreditsBackendClient { payer: string, options?: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string }, ): Promise - getUsageLog(payer: string): Promise notifyPayment(txHash: string): Promise waitForSettlement( ref: AccountRef, @@ -304,7 +371,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient { this.accountStates.set(key, { principalUsd: 0n, bonusUsd: 0n, - transactions: [], + transactions: createDemoHistory(key), rootAccount: key, }) } diff --git a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx new file mode 100644 index 00000000..61b069d9 --- /dev/null +++ b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx @@ -0,0 +1,621 @@ +import React, { useMemo, useState } from 'react' +import { + Button, + ButtonText, + Card, + Heading, + Icon, + Input, + Spinner, + Text, + XStack, + YStack, +} from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' +import type { GdCreditEntry } from '../../backendTypes' +import { usdToCredits, weiToG } from '../../quoteMath' +import { compactButtonProps } from '../shared/styles' +import type { + AiCreditsHistoryActions, + AiCreditsHistoryState, + CreditHistorySource, + CreditHistoryStatusFilter, +} from '../../useAiCreditsHistory' +import { + HISTORY_LOOKBACK_DAYS, + getLast90DaysRange, +} from '../../useAiCreditsHistory' + +const STATUS_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Funded', value: 'funded' }, + { label: 'Pending', value: 'pending' }, + { label: 'Failed', value: 'failed' }, +] + +const SOURCE_PILL_OPTIONS: { id: CreditHistorySource; label: string }[] = [ + { id: 'deposit', label: 'Deposit' }, + { id: 'streamUpdate', label: 'Stream update' }, + { id: 'streamRequest', label: 'Stream credit' }, +] + +export interface HistoryTabProps { + state: AiCreditsHistoryState + actions: AiCreditsHistoryActions +} + +function sourceLabel(source: CreditHistorySource): string { + if (source === 'deposit') return 'G$ Deposit' + if (source === 'streamUpdate') return 'Stream Update' + return 'Stream Credit' +} + +function formatEntryDate(createdAt: string): string { + const date = new Date(createdAt) + if (Number.isNaN(date.getTime())) return createdAt + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatCredits(totalCreditUsd: string): string | null { + const micro = BigInt(totalCreditUsd || '0') + if (micro <= 0n) return null + return `+${usdToCredits(micro.toString())} cr` +} + +function formatGAmount(gdAmountWei: string): string | null { + const amountWei = BigInt(gdAmountWei || '0') + if (amountWei <= 0n) return null + return `${weiToG(amountWei)} G$` +} + +function amountSummary(entry: GdCreditEntry): string | null { + if (entry.source === 'streamUpdate') { + const gAmount = formatGAmount(entry.gdAmountWei) + return gAmount ? `${gAmount}/mo` : null + } + const credits = formatCredits(entry.totalCreditUsd) + if (credits) return credits + if (entry.fundingStatus === 'failed') { + return `${usdToCredits(entry.totalCreditUsd || '0')} cr` + } + return formatGAmount(entry.gdAmountWei) +} + +function entryDetailRows(entry: GdCreditEntry): { label: string; value: string }[] { + const rows: { label: string; value: string }[] = [ + { label: 'Type', value: sourceLabel(entry.source) }, + { label: 'Status', value: statusLabel(entry.fundingStatus) }, + { label: 'Credits', value: `${usdToCredits(entry.totalCreditUsd)} cr` }, + ] + const gAmount = formatGAmount(entry.gdAmountWei) + if (gAmount) rows.push({ label: 'G$', value: gAmount }) + if (entry.source !== 'streamUpdate') { + rows.push( + { label: 'Principal', value: `${usdToCredits(entry.principalUsd)} cr` }, + { label: 'Bonus', value: `${usdToCredits(entry.bonusUsd)} cr` }, + ) + } + if (entry.txHash) rows.push({ label: 'Celo tx', value: entry.txHash }) + if (entry.fundingTxHash) rows.push({ label: 'Funding tx', value: entry.fundingTxHash }) + return rows +} + +function EntryDetailRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ) +} + +function entryAccent(entry: GdCreditEntry): string { + if (entry.fundingStatus === 'failed') return '$error' + if (entry.fundingStatus === 'pending') return '$warning' + return '$success' +} + +function amountAccent(entry: GdCreditEntry): string { + if (entry.fundingStatus === 'failed') return '$error' + if (entry.source === 'streamUpdate') return '$color' + if (entry.fundingStatus === 'pending') return '$warning' + return '$success' +} + +function entryIconName(entry: GdCreditEntry): IconName { + if (entry.fundingStatus === 'failed') return 'alert-circle' + if (entry.source === 'deposit') return 'plus' + if (entry.source === 'streamUpdate') return 'refresh' + return 'arrow-down' +} + +function entryIconColor(entry: GdCreditEntry): 'error' | 'warning' | 'primary' | 'success' { + if (entry.fundingStatus === 'failed') return 'error' + if (entry.fundingStatus === 'pending') return 'warning' + if (entry.source === 'streamUpdate') return 'primary' + return 'success' +} + +function entryIconBackground(entry: GdCreditEntry): string { + if (entry.fundingStatus === 'failed') return '$errorMuted' + if (entry.fundingStatus === 'pending') return '$warningMuted' + if (entry.source === 'streamUpdate') return '$infoMuted' + return '$successMuted' +} + +function statusLabel(status: GdCreditEntry['fundingStatus']): string { + return status.toUpperCase() +} + +function sumFilteredGdWei(entries: GdCreditEntry[]): bigint { + return entries.reduce((sum, entry) => { + if (entry.source === 'streamUpdate') return sum + try { + const amount = BigInt(entry.gdAmountWei || '0') + return amount > 0n ? sum + amount : sum + } catch { + return sum + } + }, 0n) +} + +function formatAccumulatedG(amountWei: bigint): string { + const value = Number(amountWei) / 1e18 + if (!Number.isFinite(value) || value < 0) return '0.00 G$' + return `${new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value)} G$` +} + +function formatShortDate(dateValue: string): string { + const date = new Date(`${dateValue}T00:00:00.000Z`) + if (Number.isNaN(date.getTime())) return dateValue + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) +} + +function escapeCsvValue(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +function exportEntriesCsv(entries: GdCreditEntry[]): void { + if (typeof document === 'undefined') return + const headers = [ + 'id', + 'source', + 'status', + 'credits', + 'gdAmountWei', + 'createdAt', + 'txHash', + 'fundingTxHash', + ] + const rows = entries.map((entry) => + [ + entry.id, + entry.source, + entry.fundingStatus, + usdToCredits(entry.totalCreditUsd), + entry.gdAmountWei, + entry.createdAt, + entry.txHash ?? '', + entry.fundingTxHash ?? '', + ] + .map((value) => escapeCsvValue(String(value))) + .join(','), + ) + const csv = [headers.join(','), ...rows].join('\n') + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = 'ai-credit-history.csv' + anchor.click() + URL.revokeObjectURL(url) +} + +function CreditHistoryEntryRow({ + entry, + expanded, + onToggle, +}: { + entry: GdCreditEntry + expanded: boolean + onToggle: () => void +}) { + const accent = entryAccent(entry) + const amountColor = amountAccent(entry) + const amount = amountSummary(entry) + const titleColor = entry.fundingStatus === 'failed' ? '$error' : '$color' + + return ( + + + + + + + + + {sourceLabel(entry.source)} + + + + + {statusLabel(entry.fundingStatus)} + + + + + + {amount ? ( + + {amount} + + ) : null} + + {formatEntryDate(entry.createdAt)} + + + + + {expanded ? ( + + {entryDetailRows(entry).map((row) => ( + + ))} + + ) : null} + + ) +} + +function SourceFilterChip({ + label, + selected, + onPress, +}: { + label: string + selected: boolean + onPress: () => void +}) { + return ( + + + {label} + + + ) +} + +function StatusFilterSelect({ + value, + onValueChange, +}: { + value: CreditHistoryStatusFilter + onValueChange: (value: CreditHistoryStatusFilter) => void +}) { + const [open, setOpen] = useState(false) + const selected = STATUS_OPTIONS.find((option) => option.value === value) + + return ( + + setOpen((current) => !current)} + > + + Status: {selected?.label ?? 'All'} + + + + + {open ? ( + + {STATUS_OPTIONS.map((option) => ( + { + onValueChange(option.value as CreditHistoryStatusFilter) + setOpen(false) + }} + > + + {option.label} + + + ))} + + ) : null} + + ) +} + +export function HistoryTab({ state, actions }: HistoryTabProps) { + const { + selectedSources, + statusFilter, + fromDate, + toDate, + entries, + hasMore, + loading, + loadingMore, + error, + activeSources, + } = state + const [expandedEntryId, setExpandedEntryId] = useState(null) + const defaultRange = useMemo(() => getLast90DaysRange(), []) + const isDefaultRange = fromDate === defaultRange.from && toDate === defaultRange.to + const rangeSummary = isDefaultRange + ? `Last ${HISTORY_LOOKBACK_DAYS} days activity` + : `${formatShortDate(fromDate)} – ${formatShortDate(toDate)}` + const accumulatedG = useMemo( + () => formatAccumulatedG(sumFilteredGdWei(entries)), + [entries], + ) + + return ( + + + + + AI Credit History + + {rangeSummary} + + + + + + + TOTAL ACCUMULATED + + + {accumulatedG} + + + + + + {SOURCE_PILL_OPTIONS.map((option) => ( + + actions.setSourceChecked(option.id, !selectedSources[option.id]) + } + /> + ))} + + + + + + + + + + + + + + + + + + CREDIT History + + 0 ? 'pointer' : 'default'} + opacity={entries.length > 0 ? 1 : 0.5} + hoverStyle={{ opacity: entries.length > 0 ? 0.85 : 0.5 }} + pressStyle={{ opacity: entries.length > 0 ? 0.7 : 0.5 }} + onPress={() => { + if (entries.length === 0) return + exportEntriesCsv(entries) + }} + > + + Export CSV + + + + + {loading ? ( + + + + + Loading credit history… + + + + ) : error ? ( + + + + {error} + + + + + ) : activeSources.length === 0 ? ( + + + Select at least one source to view history. + + + ) : entries.length === 0 ? ( + + + No credit history matches these filters. + + + ) : ( + + {entries.map((entry) => ( + + setExpandedEntryId((current) => (current === entry.id ? null : entry.id)) + } + /> + ))} + + )} + + {!loading && !error && hasMore && ( + + )} + + ) +} diff --git a/packages/ai-credits-widget/src/components/index.ts b/packages/ai-credits-widget/src/components/index.ts index 1b66eb87..5ece20ed 100644 --- a/packages/ai-credits-widget/src/components/index.ts +++ b/packages/ai-credits-widget/src/components/index.ts @@ -7,7 +7,7 @@ export { BuyCreditsFaq } from './buy/BuyCreditsFaq' export { CreditsManagementCard } from './manage/CreditsManagementCard' export { BuyerOperatorCard } from './manage/BuyerOperatorCard' export { SetupSnippet } from './manage/SetupSnippet' -export { UsageLog } from './manage/UsageLog' +export { HistoryTab } from './history/HistoryTab' export type { AiCreditsFlowStep } from './flow/types' export { getAiCreditsActiveFlowStep } from './flow/purchaseFlowUtils' export { AiCreditsFlowStepper } from './flow/AiCreditsFlowStepper' diff --git a/packages/ai-credits-widget/src/components/manage/UsageLog.tsx b/packages/ai-credits-widget/src/components/manage/UsageLog.tsx deleted file mode 100644 index 8cd8e420..00000000 --- a/packages/ai-credits-widget/src/components/manage/UsageLog.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { Card, Icon, Separator, Spinner, Text, XStack, YStack } from '@goodwidget/ui' -import type { GdCreditEntry } from '../../backendTypes' -import { createBackendClient } from '../../backendClient' -import { formatUsdMicroDisplay, weiToG } from '../../quoteMath' - -const INITIAL_VISIBLE_ENTRIES = 4 - -interface UsageLogProps { - address: string | null - backendUrl?: string - refreshSignal?: number -} - -function sourceLabel(source: GdCreditEntry['source']): string { - if (source === 'deposit') return 'G$ deposit' - return 'G$ stream' -} - -function entryLabel(entry: GdCreditEntry): string { - if (entry.fundingStatus === 'failed') { - return `${sourceLabel(entry.source)} (failed)` - } - return sourceLabel(entry.source) -} - -function formatGAmountWei(gdAmountWei: string): string | null { - const amountWei = BigInt(gdAmountWei || '0') - if (amountWei <= 0n) return null - return `${weiToG(amountWei)} G$` -} - -function formatFundedSummary(entries: GdCreditEntry[]): string { - let usdMicroTotal = 0n - let gdWeiTotal = 0n - - for (const entry of entries) { - if (entry.fundingStatus !== 'funded') continue - usdMicroTotal += BigInt(entry.totalCreditUsd || '0') - gdWeiTotal += BigInt(entry.gdAmountWei || '0') - } - - const parts: string[] = [] - const gTotal = formatGAmountWei(gdWeiTotal.toString()) - if (gTotal) parts.push(gTotal) - parts.push( - usdMicroTotal > 0n ? formatUsdMicroDisplay(usdMicroTotal.toString()) : formatUsdMicroDisplay('0'), - ) - return parts.join(' · ') -} - -function CreditHistoryEntry({ entry }: { entry: GdCreditEntry }) { - const failed = entry.fundingStatus === 'failed' - const amountColor = failed ? '$error' : '$color' - const gAmountDisplay = formatGAmountWei(entry.gdAmountWei) - - return ( - - - - - - {entryLabel(entry)} - - - {new Date(entry.createdAt).toLocaleString()} - - - - {gAmountDisplay && ( - - +{gAmountDisplay} - - )} - - +{formatUsdMicroDisplay(entry.totalCreditUsd)} - - - - - ) -} - -export function UsageLog({ address, backendUrl, refreshSignal = 0 }: UsageLogProps) { - const [expanded, setExpanded] = useState(false) - const [showAll, setShowAll] = useState(false) - const [entries, setEntries] = useState([]) - const [loading, setLoading] = useState(false) - - useEffect(() => { - if (!address) { - setEntries([]) - return - } - - let cancelled = false - const client = createBackendClient(backendUrl) - setLoading(true) - - void client - .getUsageLog(address) - .then((log) => { - if (!cancelled) { - setEntries(log) - setShowAll(false) - } - }) - .catch(() => { - if (!cancelled) { - setEntries([]) - setShowAll(false) - } - }) - .finally(() => { - if (!cancelled) setLoading(false) - }) - - return () => { - cancelled = true - } - }, [address, backendUrl, refreshSignal]) - - const summary = useMemo(() => { - if (loading) return 'Loading…' - if (entries.length === 0) return 'No entries yet' - return formatFundedSummary(entries) - }, [entries, loading]) - - const hiddenCount = Math.max(0, entries.length - INITIAL_VISIBLE_ENTRIES) - const visibleEntries = showAll ? entries : entries.slice(0, INITIAL_VISIBLE_ENTRIES) - - return ( - - { - if (expanded) setShowAll(false) - setExpanded((value) => !value) - }} - cursor="pointer" - > - - Credit History - - - - {summary} - - - - - - {expanded && ( - - {loading ? ( - - ) : entries.length === 0 ? ( - - Purchases and funding activity will appear here. - - ) : ( - - {visibleEntries.map((entry) => ( - - ))} - {hiddenCount > 0 && ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setShowAll((value) => !value) - } - }} - onPress={() => setShowAll((value) => !value)} - > - {showAll ? 'Show less' : `Show ${hiddenCount} more`} - - )} - - )} - - )} - - ) -} diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts new file mode 100644 index 00000000..9a23318a --- /dev/null +++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { GdCreditEntry } from './backendTypes' +import { createBackendClient } from './backendClient' + +export const HISTORY_PAGE_SIZE = 10 +export const HISTORY_LOOKBACK_DAYS = 90 + +export type CreditHistorySource = GdCreditEntry['source'] +export type CreditHistoryStatusFilter = 'all' | GdCreditEntry['fundingStatus'] + +export const HISTORY_SOURCE_OPTIONS: { + id: CreditHistorySource + label: string + defaultChecked: boolean +}[] = [ + { id: 'deposit', label: 'Deposit', defaultChecked: true }, + { id: 'streamUpdate', label: 'Stream update', defaultChecked: true }, + { id: 'streamRequest', label: 'Stream credit', defaultChecked: true }, + { id: 'streamCron', label: 'Stream credit', defaultChecked: true }, +] + +export function formatHistoryDateInput(date: Date): string { + return date.toISOString().slice(0, 10) +} + +export function getLast90DaysRange(now = new Date()): { from: string; to: string } { + const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) + const from = new Date(to) + from.setUTCDate(from.getUTCDate() - HISTORY_LOOKBACK_DAYS) + return { + from: formatHistoryDateInput(from), + to: formatHistoryDateInput(to), + } +} + +function createDefaultSelectedSources(): Record { + return Object.fromEntries( + HISTORY_SOURCE_OPTIONS.map((option) => [option.id, option.defaultChecked]), + ) as Record +} + +function toIsoStartOfDay(dateValue: string): string | undefined { + if (!dateValue) return undefined + const parsed = Date.parse(`${dateValue}T00:00:00.000Z`) + if (Number.isNaN(parsed)) return undefined + return new Date(parsed).toISOString() +} + +function toIsoEndOfDay(dateValue: string): string | undefined { + if (!dateValue) return undefined + const parsed = Date.parse(`${dateValue}T23:59:59.999Z`) + if (Number.isNaN(parsed)) return undefined + return new Date(parsed).toISOString() +} + +export interface AiCreditsHistoryState { + selectedSources: Record + statusFilter: CreditHistoryStatusFilter + fromDate: string + toDate: string + entries: GdCreditEntry[] + offset: number + hasMore: boolean + loading: boolean + loadingMore: boolean + error: string | null + activeSources: CreditHistorySource[] +} + +export interface AiCreditsHistoryActions { + setSourceChecked: (source: CreditHistorySource, checked: boolean) => void + setStatusFilter: (status: CreditHistoryStatusFilter) => void + setFromDate: (value: string) => void + setToDate: (value: string) => void + reload: () => Promise + loadMore: () => Promise +} + +export interface UseAiCreditsHistoryResult { + state: AiCreditsHistoryState + actions: AiCreditsHistoryActions +} + +export function useAiCreditsHistory(options: { + address: string | null + backendUrl?: string +}): UseAiCreditsHistoryResult { + const { address, backendUrl } = options + const defaultRange = useMemo(() => getLast90DaysRange(), []) + + const [selectedSources, setSelectedSources] = useState(createDefaultSelectedSources) + const [statusFilter, setStatusFilter] = useState('all') + const [fromDate, setFromDate] = useState(defaultRange.from) + const [toDate, setToDate] = useState(defaultRange.to) + const [entries, setEntries] = useState([]) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + + const activeSources = useMemo( + () => HISTORY_SOURCE_OPTIONS.map((option) => option.id).filter((id) => selectedSources[id]), + [selectedSources], + ) + + const loadHistory = useCallback( + async (nextOffset: number, append: boolean) => { + if (!address) { + setEntries([]) + setOffset(0) + setHasMore(false) + setError(null) + setLoading(false) + setLoadingMore(false) + return + } + + if (activeSources.length === 0) { + setEntries([]) + setOffset(0) + setHasMore(false) + setError(null) + setLoading(false) + setLoadingMore(false) + return + } + + if (append) setLoadingMore(true) + else setLoading(true) + setError(null) + + const client = createBackendClient(backendUrl) + const apiSource = activeSources.length === 1 ? activeSources[0] : undefined + const fundingStatus = statusFilter === 'all' ? undefined : statusFilter + + try { + const response = await client.getCreditHistory(address, { + limit: HISTORY_PAGE_SIZE, + offset: nextOffset, + source: apiSource, + fundingStatus, + from: toIsoStartOfDay(fromDate), + to: toIsoEndOfDay(toDate), + }) + const pageItems = + activeSources.length === 1 + ? response.items + : response.items.filter((entry) => selectedSources[entry.source]) + + setEntries((prev) => (append ? [...prev, ...pageItems] : pageItems)) + setOffset(nextOffset) + setHasMore(response.hasMore) + } catch (err: unknown) { + if (!append) setEntries([]) + setHasMore(false) + setError(err instanceof Error ? err.message : 'Unable to load credit history') + } finally { + setLoading(false) + setLoadingMore(false) + } + }, + [address, backendUrl, activeSources, statusFilter, fromDate, toDate, selectedSources], + ) + + useEffect(() => { + void loadHistory(0, false) + }, [loadHistory]) + + const setSourceChecked = useCallback((source: CreditHistorySource, checked: boolean) => { + setSelectedSources((prev) => { + if (source === 'streamRequest' || source === 'streamCron') { + return { ...prev, streamRequest: checked, streamCron: checked } + } + return { ...prev, [source]: checked } + }) + }, []) + + const reload = useCallback(async () => { + await loadHistory(0, false) + }, [loadHistory]) + + const loadMore = useCallback(async () => { + await loadHistory(offset + HISTORY_PAGE_SIZE, true) + }, [loadHistory, offset]) + + return { + state: { + selectedSources, + statusFilter, + fromDate, + toDate, + entries, + offset, + hasMore, + loading, + loadingMore, + error, + activeSources, + }, + actions: { + setSourceChecked, + setStatusFilter, + setFromDate, + setToDate, + reload, + loadMore, + }, + } +} diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts index bb1b238f..a3aeebe8 100644 --- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts +++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts @@ -15,7 +15,7 @@ export type AiCreditsWidgetStatus = | 'backend_unavailable' | 'unsupported_chain' -export type AiCreditsWidgetTab = 'buy' | 'manage' +export type AiCreditsWidgetTab = 'buy' | 'manage' | 'history' export interface AiCreditsQuote { depositAmountG: string diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx index 19ecfcbc..6ae1dd6c 100644 --- a/packages/ui/src/components/Icon.tsx +++ b/packages/ui/src/components/Icon.tsx @@ -53,6 +53,9 @@ const SVG_PATHS: Record = { 'arrow-right': 'M5 12h14M12 5l7 7-7 7', 'arrow-down': 'M12 5v14M19 12l-7 7-7-7', 'arrow-up': 'M12 19V5M5 12l7-7 7 7', + plus: 'M12 5v14M5 12h14', + 'arrows-left-right': ['M8 7l-5 5 5 5', 'M3 12h18', 'M16 17l5-5-5-5'], + 'more-horizontal': 'M5 12h.01M12 12h.01M19 12h.01', copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z', wallet: 'M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 14a1 1 0 110-2 1 1 0 010 2zM4 7V5a2 2 0 012-2h12a2 2 0 012 2v2', 'external-link': 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts index e390f2fa..5c6627f0 100644 --- a/tests/widgets/ai-credits-widget/states.spec.ts +++ b/tests/widgets/ai-credits-widget/states.spec.ts @@ -14,6 +14,8 @@ const STORY_IDS = { '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--payment-confirmed&viewMode=story', creditsManagement: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--credits-management&viewMode=story', + historyTab: + '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--history-tab&viewMode=story', insufficientBalance: '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--insufficient-g-balance&viewMode=story', paymentFailed: @@ -104,13 +106,30 @@ test('AiCreditsWidget manage tab', async ({ page }) => { await expect(page.getByTestId('AiCreditsWidget-manage-tab')).toBeVisible() await expect(page.getByText('Buy Credits')).toBeVisible() await expect(page.getByText('Manage')).toBeVisible() + await expect(page.getByText('History')).toBeVisible() await expect(page.getByText('110.00')).toBeVisible() + await expect(page.getByText('Credit History')).not.toBeVisible() await page.screenshot({ path: 'tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png', fullPage: true, }) }) +test('AiCreditsWidget history tab', async ({ page }) => { + await gotoStory(page, STORY_IDS.historyTab) + const root = widget(page, 'AiCreditsWidget-history-tab') + await expect(root).toBeVisible() + await expect(root.getByText('AI Credit History')).toBeVisible() + await expect(root.getByText('Last 90 days activity')).toBeVisible() + await expect(root.getByText('Deposit')).toBeVisible() + await expect(root.getByText('CREDIT History', { exact: true })).toBeVisible() + await expect(root.getByText('G$ Deposit')).toBeVisible({ timeout: 10_000 }) + await page.screenshot({ + path: 'tests/widgets/ai-credits-widget/test-results/acw-13-history-tab.png', + fullPage: true, + }) +}) + test('AiCreditsWidget insufficient_g_balance', async ({ page }) => { await gotoStory(page, STORY_IDS.insufficientBalance) await expect(page.getByTestId('AiCreditsWidget-insufficient-balance')).toBeVisible() diff --git a/tests/widgets/ai-credits-widget/test-results/acw-02-purchase-setup.png b/tests/widgets/ai-credits-widget/test-results/acw-02-purchase-setup.png index d979c3e2..b7b0d1f4 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-02-purchase-setup.png and b/tests/widgets/ai-credits-widget/test-results/acw-02-purchase-setup.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png b/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png index 0785150f..1d4bac60 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png and b/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-04-quote-ready-goodid.png b/tests/widgets/ai-credits-widget/test-results/acw-04-quote-ready-goodid.png index f2a24f2d..9fdaf05a 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-04-quote-ready-goodid.png and b/tests/widgets/ai-credits-widget/test-results/acw-04-quote-ready-goodid.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-05-payment-pending.png b/tests/widgets/ai-credits-widget/test-results/acw-05-payment-pending.png index ce3f7f8b..3f9c4ee5 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-05-payment-pending.png and b/tests/widgets/ai-credits-widget/test-results/acw-05-payment-pending.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-06-payment-confirmed.png b/tests/widgets/ai-credits-widget/test-results/acw-06-payment-confirmed.png index 6ad7d976..bb4dd8f3 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-06-payment-confirmed.png and b/tests/widgets/ai-credits-widget/test-results/acw-06-payment-confirmed.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png b/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png index ae55fa91..daebcef7 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png and b/tests/widgets/ai-credits-widget/test-results/acw-07-credits-management.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-08-insufficient-g-balance.png b/tests/widgets/ai-credits-widget/test-results/acw-08-insufficient-g-balance.png index 1ec4fc33..78418a03 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-08-insufficient-g-balance.png and b/tests/widgets/ai-credits-widget/test-results/acw-08-insufficient-g-balance.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-09-payment-failed.png b/tests/widgets/ai-credits-widget/test-results/acw-09-payment-failed.png index f823651b..36dd7566 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-09-payment-failed.png and b/tests/widgets/ai-credits-widget/test-results/acw-09-payment-failed.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-10-backend-unavailable.png b/tests/widgets/ai-credits-widget/test-results/acw-10-backend-unavailable.png index d70440f3..80f9c132 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-10-backend-unavailable.png and b/tests/widgets/ai-credits-widget/test-results/acw-10-backend-unavailable.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-11-unsupported-chain.png b/tests/widgets/ai-credits-widget/test-results/acw-11-unsupported-chain.png index fa18d0ad..9e1a6cd2 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-11-unsupported-chain.png and b/tests/widgets/ai-credits-widget/test-results/acw-11-unsupported-chain.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-13-history-tab.png b/tests/widgets/ai-credits-widget/test-results/acw-13-history-tab.png new file mode 100644 index 00000000..4e62a35d Binary files /dev/null and b/tests/widgets/ai-credits-widget/test-results/acw-13-history-tab.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-before.png b/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-before.png index 245a1674..784ac2a0 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-before.png and b/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-before.png differ diff --git a/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-modal-open.png b/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-modal-open.png index 245a1674..784ac2a0 100644 Binary files a/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-modal-open.png and b/tests/widgets/ai-credits-widget/test-results/acw-14-appkit-connect-modal-open.png differ