diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
index de260e3a..9ae1f66f 100644
--- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
+++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
@@ -7,6 +7,7 @@ import {
type AiCreditsWidgetAdapterState,
type AiCreditsWidgetStatus,
} from '@goodwidget/ai-credits-widget'
+import { MockAiCreditsWidget } from '@goodwidget/ai-credits-widget/mocked'
import {
DefaultAppKitProvider,
useAppKit,
@@ -295,7 +296,7 @@ export function MockBackendStory() {
return (
-
+
)
}
diff --git a/packages/ai-credits-widget/README.md b/packages/ai-credits-widget/README.md
new file mode 100644
index 00000000..a189904a
--- /dev/null
+++ b/packages/ai-credits-widget/README.md
@@ -0,0 +1,25 @@
+# AI Credits Widget
+
+```tsx
+import { AiCreditsWidget } from '@goodwidget/ai-credits-widget'
+
+
+```
+
+For a Custom Element, import `@goodwidget/ai-credits-widget/register`. This import is safe during
+Node.js and Next.js server evaluation; it registers `` only in a browser. Set
+the injected EIP-1193 provider and optional `themeOverrides` as element properties.
+
+Production requires `backendUrl`. The widget keeps its Celo and Base RPC and contract defaults
+internally and fails closed with a backend-unavailable state when the production backend is absent.
+Only the `development` environment may use the built-in mock backend and chain clients.
+
+## EIP-1193 capabilities
+
+Grant only these EIP-1193 methods to the injected provider:
+
+- `eth_accounts` and `eth_chainId` to read the connected wallet.
+- `eth_requestAccounts` when the user selects **Connect Wallet**.
+- `wallet_switchEthereumChain` when the user selects the Celo network switch action.
+- `personal_sign` when the user generates the buyer key.
+- `eth_sendTransaction` when the user confirms a Celo payment.
diff --git a/packages/ai-credits-widget/package.json b/packages/ai-credits-widget/package.json
index 1ba6b91b..86cfd8d1 100644
--- a/packages/ai-credits-widget/package.json
+++ b/packages/ai-credits-widget/package.json
@@ -2,10 +2,18 @@
"name": "@goodwidget/ai-credits-widget",
"version": "0.1.0",
"description": "GoodWidget for buying AI coding credits with G$ on Celo",
+ "files": [
+ "dist",
+ "README.md"
+ ],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
+ "sideEffects": [
+ "./dist/register.js",
+ "./dist/register.cjs"
+ ],
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -21,6 +29,11 @@
"types": "./dist/register.d.ts",
"import": "./dist/register.js",
"require": "./dist/register.cjs"
+ },
+ "./mocked": {
+ "types": "./dist/mocked/index.d.ts",
+ "import": "./dist/mocked/index.js",
+ "require": "./dist/mocked/index.cjs"
}
},
"scripts": {
diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx
index 81c75c2e..7ca16b27 100644
--- a/packages/ai-credits-widget/src/AiCreditsWidget.tsx
+++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx
@@ -18,7 +18,6 @@ import {
} from '@goodwidget/ui'
import { useAiCreditsAdapter } from './adapter'
import { useAiCreditsHistory } from './useAiCreditsHistory'
-import { DEPOSIT_BONUS_PERCENT, STREAM_BONUS_PERCENT } from './quoteMath'
import {
AiCreditsHero,
AiCreditsFlowStepper,
@@ -36,6 +35,7 @@ import type {
AiCreditsPaySuccessDetail,
AiCreditsPayErrorDetail,
AiCreditsWidgetAdapterFactory,
+ AiCreditsWidgetAdapterOptions,
AiCreditsWidgetAdapterActions,
AiCreditsWidgetAdapterState,
AiCreditsWidgetTab,
@@ -54,6 +54,7 @@ interface AiCreditsInnerProps {
vaultAddress?: string
goodIdAddress?: string
adapterFactory?: AiCreditsWidgetAdapterFactory
+ adapterOptions?: AiCreditsWidgetAdapterOptions
onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
onPayError?: (detail: AiCreditsPayErrorDetail) => void
}
@@ -283,6 +284,7 @@ function AiCreditsInner({
vaultAddress,
goodIdAddress,
adapterFactory,
+ adapterOptions,
onPaySuccess,
onPayError,
}: AiCreditsInnerProps) {
@@ -296,6 +298,10 @@ function AiCreditsInner({
goodIdAddress: goodIdAddress as `0x${string}` | undefined,
onPaySuccess,
onPayError,
+ backendClient: adapterOptions?.backendClient,
+ chainClient: adapterOptions?.chainClient,
+ skipVaultPaymentValidation: adapterOptions?.skipVaultPaymentValidation,
+ prepareSettlement: adapterOptions?.prepareSettlement,
})
const activeAdapter = useMemo(
@@ -308,6 +314,8 @@ function AiCreditsInner({
const history = useAiCreditsHistory({
address: state.address,
backendUrl,
+ environment,
+ backendClient: adapterOptions?.backendClient,
})
const handlePay = useCallback(
@@ -403,6 +411,7 @@ export function AiCreditsWidget({
onPaySuccess,
onPayError,
adapterFactory,
+ adapterOptions,
testId,
}: AiCreditsWidgetProps) {
return (
@@ -423,6 +432,7 @@ export function AiCreditsWidget({
vaultAddress={vaultAddress}
goodIdAddress={goodIdAddress}
adapterFactory={adapterFactory}
+ adapterOptions={adapterOptions}
onPaySuccess={onPaySuccess}
onPayError={onPayError}
/>
diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts
index 0c2283f0..2c57934a 100644
--- a/packages/ai-credits-widget/src/adapter.ts
+++ b/packages/ai-credits-widget/src/adapter.ts
@@ -15,7 +15,6 @@ import { privateKeyToAccount } from 'viem/accounts'
import { buildBuyerKeyMessage, deriveBuyerPrivateKeyFromSignature } from './buyerKeyDerivation'
import { normalizeChannelId, signRequestClose, signWithdrawPrincipal } from './buyerSignatures'
import {
- MockAiCreditsBackendClient,
totalCreditUsdFromProfile,
buildAccountView,
createBackendClient,
@@ -110,16 +109,6 @@ 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'
}
@@ -325,6 +314,10 @@ export interface UseAiCreditsAdapterOptions {
goodIdReturnUrl?: string
onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
onPayError?: (detail: AiCreditsPayErrorDetail) => void
+ backendClient?: AiCreditsBackendClient
+ chainClient?: AiCreditsChainClient
+ skipVaultPaymentValidation?: boolean
+ prepareSettlement?: (ref: AccountRef, creditUsd: bigint) => void
}
export function useAiCreditsAdapter({
@@ -338,9 +331,17 @@ export function useAiCreditsAdapter({
goodIdReturnUrl,
onPaySuccess,
onPayError,
+ backendClient: backendClientOverride,
+ chainClient: chainClientOverride,
+ skipVaultPaymentValidation = false,
+ prepareSettlement,
}: UseAiCreditsAdapterOptions): AiCreditsWidgetAdapterResult {
const { address, chainId, isConnected, provider, connect } = useWallet()
const [state, setState] = useState(INITIAL_STATE)
+ const configurationError =
+ backendClientOverride || backendUrl
+ ? null
+ : 'AI Credits backend is not configured'
const providerRef = useRef(null)
providerRef.current = provider as EIP1193Provider | null
@@ -349,20 +350,21 @@ export function useAiCreditsAdapter({
const celoVault = vaultAddress ?? CELO_GD_ANTSEED_VAULT_FALLBACK
const backendClient = useMemo(
- () => createBackendClient(backendUrl),
- [backendUrl],
+ () => backendClientOverride ?? createBackendClient(backendUrl),
+ [backendClientOverride, backendUrl],
)
const chainClient = useMemo(
() =>
- createChainClient(backendUrl, {
+ chainClientOverride ??
+ createChainClient({
baseRpcUrl,
celoRpcUrl,
fundingVaultAddress,
celoVaultAddress: celoVault,
celoGoodIdAddress: goodIdAddress ?? CELO_GOODID_ADDRESS,
}),
- [backendUrl, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
+ [chainClientOverride, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
)
useEffect(() => {
@@ -370,6 +372,16 @@ export function useAiCreditsAdapter({
setState((prev) => (prev.status === 'connecting' ? prev : { ...INITIAL_STATE }))
return
}
+ if (configurationError) {
+ setState((prev) => ({
+ ...prev,
+ address,
+ chainId,
+ status: 'backend_unavailable',
+ error: configurationError,
+ }))
+ return
+ }
let cancelled = false
const sessionPatch = patchPayerSessionFields(address)
@@ -378,8 +390,7 @@ export function useAiCreditsAdapter({
if (
prev.status === 'payment_pending' ||
prev.status === 'payment_confirmed' ||
- prev.status === 'payment_failed' ||
- prev.status === 'backend_unavailable'
+ prev.status === 'payment_failed'
) {
return prev
}
@@ -425,7 +436,7 @@ export function useAiCreditsAdapter({
.catch(() => null)
const minimumsPromise =
- backendClient instanceof MockAiCreditsBackendClient
+ skipVaultPaymentValidation
? Promise.resolve({
minDepositUsd: '1.00',
minStreamUsd: '1.00',
@@ -511,7 +522,7 @@ export function useAiCreditsAdapter({
return () => {
cancelled = true
}
- }, [isConnected, address, chainId, backendClient, chainClient, celoVault])
+ }, [isConnected, address, chainId, backendClient, chainClient, celoVault, configurationError])
const handleConnect = useCallback(async () => {
setState((prev) => withDerivedStatus(prev, { status: 'connecting', error: null }, false))
@@ -735,7 +746,7 @@ export function useAiCreditsAdapter({
throw new Error('Could not build quote — check chain connectivity')
}
- if (!(backendClient instanceof MockAiCreditsBackendClient)) {
+ if (!skipVaultPaymentValidation) {
try {
const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() })
await validateVaultPaymentAmounts({
@@ -787,12 +798,12 @@ export function useAiCreditsAdapter({
buyer: currentState.buyerPubKey,
}
- if (backendClient instanceof MockAiCreditsBackendClient) {
+ if (prepareSettlement) {
const creditUsdMicro = quoteTotalUsdMicro(quote, gdUsdPerToken, currentState.isGoodIdVerified, {
depositBonusPercent: currentState.depositBonusPercent,
streamBonusPercent: currentState.streamBonusPercent,
})
- backendClient.prepareSettlement(accountRef, creditUsdMicro)
+ prepareSettlement(accountRef, creditUsdMicro)
}
const { txHashes } = await executeCeloPayment({
@@ -861,7 +872,16 @@ export function useAiCreditsAdapter({
throw new Error(message)
}
},
- [state, backendClient, chainClient, celoVault, onPaySuccess, onPayError],
+ [
+ state,
+ backendClient,
+ chainClient,
+ celoVault,
+ onPaySuccess,
+ onPayError,
+ prepareSettlement,
+ skipVaultPaymentValidation,
+ ],
)
const handleRefresh = useCallback(
@@ -901,7 +921,9 @@ export function useAiCreditsAdapter({
const statusSeed =
options?.afterGoodIdVerify && prev.status === 'payment_failed'
? 'quote_ready'
- : prev.status
+ : prev.status === 'backend_unavailable'
+ ? 'purchase_setup'
+ : prev.status
return withDerivedStatus(
{ ...prev, status: statusSeed },
{
@@ -1101,10 +1123,16 @@ export function useAiCreditsAdapter({
)
const handleRetry = useCallback(async () => {
- setState((prev) =>
- withDerivedStatus(prev, { activeTab: 'buy', status: 'purchase_setup', error: null }, true),
- )
- }, [])
+ if (configurationError) {
+ setState((prev) => ({
+ ...prev,
+ status: 'backend_unavailable',
+ error: configurationError,
+ }))
+ return
+ }
+ await handleRefresh()
+ }, [configurationError, handleRefresh])
const handleSetActiveTab = useCallback((tab: AiCreditsWidgetTab) => {
if (tab === 'buy') {
diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts
index 7d7c0871..d5f0bd54 100644
--- a/packages/ai-credits-widget/src/backendClient.ts
+++ b/packages/ai-credits-widget/src/backendClient.ts
@@ -13,7 +13,7 @@ import type {
UserCreditProfile,
} from './backendTypes'
import type { BuyerOperatorStatus } from './operatorConsent'
-import { markMockOperatorConsent, type AiCreditsChainClient } from './chainClient'
+import type { AiCreditsChainClient } from './chainClient'
import {
flowRateWeiToMonthlyG,
weiToG,
@@ -126,111 +126,6 @@ function clampHistoryLimit(limit: number | undefined): number {
return Math.min(Math.max(1, Math.floor(limit)), MAX_HISTORY_LIMIT)
}
-function paginateGdCredits(
- entries: GdCreditEntry[],
- options: CreditHistoryQuery = {},
-): CreditHistoryResponse {
- let result = [...entries].sort((a, b) => {
- const byCreated = b.createdAt.localeCompare(a.createdAt)
- if (byCreated !== 0) return byCreated
- return b.id.localeCompare(a.id)
- })
- if (options.source) {
- result = result.filter((entry) => entry.source === options.source)
- }
- if (options.fundingStatus) {
- result = result.filter((entry) => entry.fundingStatus === options.fundingStatus)
- }
- if (options.from) {
- const fromMs = Date.parse(options.from)
- result = result.filter((entry) => Date.parse(entry.createdAt) >= fromMs)
- }
- if (options.to) {
- const toMs = Date.parse(options.to)
- result = result.filter((entry) => Date.parse(entry.createdAt) <= toMs)
- }
- const limit = clampHistoryLimit(options.limit)
- const offset = Math.max(0, Math.floor(options.offset ?? 0))
- const total = result.length
- const items = result.slice(offset, offset + limit)
- return {
- account: '',
- items,
- total,
- limit,
- offset,
- hasMore: offset + limit < total,
- }
-}
-
-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)) {
@@ -321,7 +216,6 @@ export interface AiCreditsBackendClient {
): Promise
}
-const MOCK_DELAY_MS = 600
const BPS_PER_PERCENT = 100
export const DEFAULT_DISCOUNT_CONFIG: DiscountConfig = {
@@ -351,194 +245,6 @@ function discountConfigFromConfigValues(
}
}
-export class MockAiCreditsBackendClient implements AiCreditsBackendClient {
- private activeRef: AccountRef | null = null
- private lastCreditUsd = 0n
-
- private readonly accountStates = new Map<
- string,
- {
- principalUsd: bigint
- bonusUsd: bigint
- transactions: GdCreditEntry[]
- rootAccount: string
- }
- >()
-
- private getState(payer: string) {
- const key = normalizeAddress(payer)
- if (!this.accountStates.has(key)) {
- this.accountStates.set(key, {
- principalUsd: 0n,
- bonusUsd: 0n,
- transactions: createDemoHistory(key),
- rootAccount: key,
- })
- }
- return this.accountStates.get(key)!
- }
-
- async getDiscountConfig(): Promise {
- await sleep(MOCK_DELAY_MS)
- return { ...DEFAULT_DISCOUNT_CONFIG }
- }
-
- private buildProfile(payer: string): UserCreditProfile {
- const state = this.getState(payer)
- const now = new Date().toISOString()
- const outstanding = state.transactions
- .filter((entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed')
- .reduce((sum, entry) => sum + BigInt(entry.totalCreditUsd), 0n)
- return {
- account: normalizeAddress(payer),
- rootAccount: state.rootAccount,
- createdAt: now,
- updatedAt: now,
- totalGdDepositedWei: '0',
- totalPrincipalUsd: state.principalUsd.toString(),
- totalBonusUsd: state.bonusUsd.toString(),
- totalGDStreamedWei: '0',
- totalOutstandingFundingUsd: outstanding.toString(),
- streamFlowRateWeiPerSecond: '0',
- }
- }
-
- async getAccountCredit(payer: string): Promise {
- await sleep(MOCK_DELAY_MS)
- const profile = this.buildProfile(payer)
- return {
- account: profile.account,
- profile,
- }
- }
-
- async getCreditHistory(
- payer: string,
- options: CreditHistoryQuery = {},
- ): Promise {
- await sleep(MOCK_DELAY_MS)
- const history = paginateGdCredits(this.getState(payer).transactions, options)
- return {
- ...history,
- account: normalizeAddress(payer),
- }
- }
-
- async getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }> {
- await sleep(MOCK_DELAY_MS)
- const state = this.getState(payer)
- const pending = state.transactions.filter(
- (entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed',
- )
- const outstanding = pending.reduce(
- (sum, entry) => sum + BigInt(entry.totalCreditUsd || '0'),
- 0n,
- )
- return { outstandingFundingUsd: outstanding.toString(), count: pending.length }
- }
-
- async getTransactions(
- payer: string,
- options: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string } = {},
- ): Promise {
- const history = await this.getCreditHistory(payer, {
- fundingStatus: options.status,
- limit: options.limit,
- offset: options.cursor ? Number(options.cursor) || 0 : 0,
- })
- return { account: history.account, transactions: history.items }
- }
-
- async getUsageLog(payer: string): Promise {
- const history = await this.getCreditHistory(payer, { limit: DEFAULT_HISTORY_LIMIT, offset: 0 })
- return history.items
- }
-
- prepareSettlement(ref: AccountRef, creditUsd: bigint): void {
- this.activeRef = { payer: normalizeAddress(ref.payer), buyer: normalizeAddress(ref.buyer) }
- this.lastCreditUsd = creditUsd
- }
-
- async notifyPayment(txHash: string): Promise {
- await sleep(MOCK_DELAY_MS)
- const ref = this.activeRef
- if (!ref) return { txHash, events: [] }
-
- const totalUsd = this.lastCreditUsd
- const now = new Date().toISOString()
- const entry: GdCreditEntry = {
- id: `${txHash}:0`,
- account: ref.payer,
- rootAccount: ref.payer,
- source: 'deposit',
- gdAmountWei: '0',
- totalCreditUsd: totalUsd.toString(),
- principalUsd: totalUsd.toString(),
- bonusUsd: '0',
- fundingStatus: 'pending',
- txHash,
- logIndex: 0,
- createdAt: now,
- streamUpdateMonth: now.slice(0, 7),
- buyerAddress: ref.buyer,
- }
-
- const state = this.getState(ref.payer)
- if (!state.transactions.find((item) => item.id === entry.id)) {
- state.transactions.unshift(entry)
- }
- return { txHash, events: [entry] }
- }
-
- async waitForSettlement(
- ref: AccountRef,
- _options: { txHashes?: string[]; previousBalance?: string } = {},
- ): Promise {
- await sleep(MOCK_DELAY_MS)
- const state = this.getState(ref.payer)
- for (const entry of state.transactions) {
- if (entry.fundingStatus !== 'pending') continue
- entry.fundingStatus = 'funded'
- state.principalUsd += BigInt(entry.principalUsd)
- state.bonusUsd += BigInt(entry.bonusUsd)
- }
- return { totalCreditUsd: totalCreditUsdFromProfile(this.buildProfile(ref.payer)) }
- }
-
- async closeChannel(
- channelId: string,
- _body: ChannelOperationRequest = {},
- ): Promise {
- await sleep(MOCK_DELAY_MS)
- return { channelId, action: 'close', bridge: { enabled: true, txHash: '0xmock' } }
- }
-
- async withdrawCredits(
- _buyer: string,
- body: WithdrawPrincipalRequest,
- ): Promise {
- await sleep(MOCK_DELAY_MS)
- return {
- account: _buyer,
- amountUsd: body.amount,
- bridge: { enabled: true, txHash: '0xmock' },
- }
- }
-
- async submitOperatorConsent(
- buyer: string,
- _body: OperatorConsentRequest,
- ): Promise {
- await sleep(MOCK_DELAY_MS)
- const normalizedBuyer = normalizeAddress(buyer)
- markMockOperatorConsent(normalizedBuyer)
- return {
- buyer: normalizedBuyer,
- bridge: { enabled: true, txHash: '0xmock' },
- }
- }
-}
-
export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient {
private readonly backendUrl: string
@@ -710,9 +416,57 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient
}
}
-export function createBackendClient(backendUrl: string | undefined): AiCreditsBackendClient {
+export class UnavailableAiCreditsBackendClient implements AiCreditsBackendClient {
+ private unavailable(): never {
+ throw new Error('AI Credits backend is not configured')
+ }
+
+ async getDiscountConfig() {
+ return this.unavailable()
+ }
+
+ async getAccountCredit() {
+ return this.unavailable()
+ }
+
+ async getCreditHistory() {
+ return this.unavailable()
+ }
+
+ async getOutstanding() {
+ return this.unavailable()
+ }
+
+ async getTransactions() {
+ return this.unavailable()
+ }
+
+ async notifyPayment() {
+ return this.unavailable()
+ }
+
+ async waitForSettlement() {
+ return this.unavailable()
+ }
+
+ async closeChannel() {
+ return this.unavailable()
+ }
+
+ async withdrawCredits() {
+ return this.unavailable()
+ }
+
+ async submitOperatorConsent() {
+ return this.unavailable()
+ }
+}
+
+export function createBackendClient(
+ backendUrl: string | undefined,
+): AiCreditsBackendClient {
if (!backendUrl) {
- return new MockAiCreditsBackendClient()
+ return new UnavailableAiCreditsBackendClient()
}
return new ProductionAiCreditsBackendClient(backendUrl)
}
diff --git a/packages/ai-credits-widget/src/chainClient.ts b/packages/ai-credits-widget/src/chainClient.ts
index 02571ebf..8d639777 100644
--- a/packages/ai-credits-widget/src/chainClient.ts
+++ b/packages/ai-credits-widget/src/chainClient.ts
@@ -61,12 +61,6 @@ function normalizeAddress(address: string): string {
return address.toLowerCase()
}
-const mockOperatorAcceptedBuyers = new Set()
-
-export function markMockOperatorConsent(buyer: string): void {
- mockOperatorAcceptedBuyers.add(normalizeAddress(buyer))
-}
-
export type AiCreditsChainClientOptions = {
baseRpcUrl?: string
fundingVaultAddress?: Address
@@ -241,85 +235,9 @@ export class ProductionAiCreditsChainClient implements AiCreditsChainClient {
}
}
-export class MockAiCreditsChainClient implements AiCreditsChainClient {
- private operatorAccepted: boolean
- private readonly gdUsdPerToken: number
- private readonly goodIdVerified: boolean
-
- constructor(
- options: { operatorAccepted?: boolean; gdUsdPerToken?: number; goodIdVerified?: boolean } = {},
- ) {
- this.operatorAccepted = options.operatorAccepted ?? false
- this.gdUsdPerToken = options.gdUsdPerToken ?? 0.0015
- this.goodIdVerified = options.goodIdVerified ?? false
- }
-
- async isGoodIdVerified(_account: string): Promise {
- return this.goodIdVerified
- }
-
- async fetchGdUsdPerToken(): Promise {
- return this.gdUsdPerToken
- }
-
- async buildQuote(depositG: string, streamG: string): Promise {
- return buildQuoteAmounts(depositG, streamG)
- }
-
- async getBuyerOperatorStatus(ref: AccountRef): Promise {
- const payer = normalizeAddress(ref.payer)
- const buyer = normalizeAddress(ref.buyer)
- const operatorAddress = '0x0000000000000000000000000000000000000004'
- const operatorAccepted = this.operatorAccepted || mockOperatorAcceptedBuyers.has(buyer)
- return {
- enabled: true,
- account: payer,
- buyerAddress: buyer,
- operatorAddress,
- currentOperator: operatorAccepted ? operatorAddress : '0x0000000000000000000000000000000000000000',
- operatorAccepted,
- consentNonce: '0',
- }
- }
-
- async buildOperatorConsentPayload(
- ref: AccountRef,
- operatorStatus?: BuyerOperatorStatus,
- ): Promise {
- const status = operatorStatus ?? (await this.getBuyerOperatorStatus(ref))
- if (!status.enabled || !status.operatorAddress) {
- return {
- enabled: false,
- account: normalizeAddress(ref.payer),
- buyerAddress: normalizeAddress(ref.buyer),
- }
- }
- return {
- enabled: true,
- account: normalizeAddress(ref.payer),
- buyerAddress: normalizeAddress(ref.buyer),
- typedData: buildSetOperatorPayload(
- BASE_CHAIN_ID,
- ANTSEED_DEPOSITS_BASE_ADDRESS,
- status.operatorAddress,
- BigInt(status.consentNonce),
- { name: 'AntseedDeposits', version: '1' },
- ),
- }
- }
-
- async getWithdrawableUsd(_buyer: string): Promise {
- return '0'
- }
-}
-
export function createChainClient(
- backendUrl: string | undefined,
options: AiCreditsChainClientOptions = {},
): AiCreditsChainClient {
- if (!backendUrl) {
- return new MockAiCreditsChainClient()
- }
return new ProductionAiCreditsChainClient({
...options,
celoGoodIdAddress: options.celoGoodIdAddress ?? CELO_GOODID_ADDRESS,
diff --git a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx
index b7225d17..27e52b69 100644
--- a/packages/ai-credits-widget/src/components/history/HistoryTab.tsx
+++ b/packages/ai-credits-widget/src/components/history/HistoryTab.tsx
@@ -385,7 +385,7 @@ function StatusFilterSelect({
>,
{
shadow: true,
defaultTheme: 'dark',
+ props: {
+ backendUrl: 'property',
+ },
events: ['pay-success', 'pay-error'],
},
)
+
+export class AiCreditsWidgetElement extends AiCreditsWidgetElementBase {
+ declare backendUrl: string | undefined
+}
diff --git a/packages/ai-credits-widget/src/index.ts b/packages/ai-credits-widget/src/index.ts
index 2eed7f08..451978c7 100644
--- a/packages/ai-credits-widget/src/index.ts
+++ b/packages/ai-credits-widget/src/index.ts
@@ -26,8 +26,8 @@ export type {
GdCreditEntry,
} from './backendClient'
export {
- MockAiCreditsBackendClient,
ProductionAiCreditsBackendClient,
+ UnavailableAiCreditsBackendClient,
createBackendClient,
buildAccountView,
enrichAccountView,
diff --git a/packages/ai-credits-widget/src/mocked/AiCreditsMockWidget.tsx b/packages/ai-credits-widget/src/mocked/AiCreditsMockWidget.tsx
new file mode 100644
index 00000000..33f4f374
--- /dev/null
+++ b/packages/ai-credits-widget/src/mocked/AiCreditsMockWidget.tsx
@@ -0,0 +1,22 @@
+import { useMemo } from 'react'
+import { AiCreditsWidget, type AiCreditsWidgetProps } from '..'
+import { MockAiCreditsBackendClient } from './backendClient'
+import { MockAiCreditsChainClient } from './chainClient'
+
+export function MockAiCreditsWidget(props: Omit) {
+ const backendClient = useMemo(() => new MockAiCreditsBackendClient(), [])
+ const chainClient = useMemo(() => new MockAiCreditsChainClient(), [])
+
+ return (
+ backendClient.prepareSettlement(ref, creditUsd),
+ }}
+ />
+ )
+}
diff --git a/packages/ai-credits-widget/src/mocked/backendClient.ts b/packages/ai-credits-widget/src/mocked/backendClient.ts
new file mode 100644
index 00000000..7be81b93
--- /dev/null
+++ b/packages/ai-credits-widget/src/mocked/backendClient.ts
@@ -0,0 +1,212 @@
+import type {
+ AccountCreditResponse,
+ AccountRef,
+ CeloEventsRecordResponse,
+ ChannelOperationResponse,
+ CreditHistoryQuery,
+ CreditHistoryResponse,
+ DiscountConfig,
+ GdCreditEntry,
+ OperatorConsentResponse,
+ SettlementResult,
+ TransactionsResponse,
+ UserCreditProfile,
+ WithdrawPrincipalRequest,
+ WithdrawPrincipalResponse,
+} from '../backendClient'
+import { DEFAULT_DISCOUNT_CONFIG, totalCreditUsdFromProfile } from '../backendClient'
+import type { AiCreditsBackendClient } from '../backendClient'
+import { markMockOperatorConsent } from './chainClient'
+
+const MOCK_DELAY_MS = 600
+const DEFAULT_HISTORY_LIMIT = 20
+const MAX_HISTORY_LIMIT = 100
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+function normalizeAddress(address: string): string {
+ return address.toLowerCase()
+}
+
+function paginateGdCredits(
+ entries: GdCreditEntry[],
+ options: CreditHistoryQuery = {},
+): CreditHistoryResponse {
+ let result = [...entries].sort((a, b) => {
+ const byCreated = b.createdAt.localeCompare(a.createdAt)
+ return byCreated !== 0 ? byCreated : b.id.localeCompare(a.id)
+ })
+ if (options.source) result = result.filter((entry) => entry.source === options.source)
+ if (options.fundingStatus) result = result.filter((entry) => entry.fundingStatus === options.fundingStatus)
+ if (options.from) {
+ const fromMs = Date.parse(options.from)
+ result = result.filter((entry) => Date.parse(entry.createdAt) >= fromMs)
+ }
+ if (options.to) {
+ const toMs = Date.parse(options.to)
+ result = result.filter((entry) => Date.parse(entry.createdAt) <= toMs)
+ }
+ const limit = options.limit === undefined
+ ? DEFAULT_HISTORY_LIMIT
+ : Math.min(Math.max(1, Math.floor(options.limit)), MAX_HISTORY_LIMIT)
+ const offset = Math.max(0, Math.floor(options.offset ?? 0))
+ return {
+ account: '',
+ items: result.slice(offset, offset + limit),
+ total: result.length,
+ limit,
+ offset,
+ hasMore: offset + limit < result.length,
+ }
+}
+
+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 class MockAiCreditsBackendClient implements AiCreditsBackendClient {
+ private activeRef: AccountRef | null = null
+ private lastCreditUsd = 0n
+ private readonly accountStates = new Map()
+
+ private getState(payer: string) {
+ const key = normalizeAddress(payer)
+ if (!this.accountStates.has(key)) {
+ this.accountStates.set(key, {
+ principalUsd: 0n, bonusUsd: 0n, transactions: createDemoHistory(key), rootAccount: key,
+ })
+ }
+ return this.accountStates.get(key)!
+ }
+
+ async getDiscountConfig(): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return { ...DEFAULT_DISCOUNT_CONFIG }
+ }
+
+ private buildProfile(payer: string): UserCreditProfile {
+ const state = this.getState(payer)
+ const now = new Date().toISOString()
+ const outstanding = state.transactions
+ .filter((entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed')
+ .reduce((sum, entry) => sum + BigInt(entry.totalCreditUsd), 0n)
+ return {
+ account: normalizeAddress(payer), rootAccount: state.rootAccount, createdAt: now, updatedAt: now,
+ totalGdDepositedWei: '0', totalPrincipalUsd: state.principalUsd.toString(),
+ totalBonusUsd: state.bonusUsd.toString(), totalGDStreamedWei: '0',
+ totalOutstandingFundingUsd: outstanding.toString(), streamFlowRateWeiPerSecond: '0',
+ }
+ }
+
+ async getAccountCredit(payer: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const profile = this.buildProfile(payer)
+ return { account: profile.account, profile }
+ }
+
+ async getCreditHistory(payer: string, options: CreditHistoryQuery = {}): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return { ...paginateGdCredits(this.getState(payer).transactions, options), account: normalizeAddress(payer) }
+ }
+
+ async getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }> {
+ await sleep(MOCK_DELAY_MS)
+ const pending = this.getState(payer).transactions.filter(
+ (entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed',
+ )
+ return {
+ outstandingFundingUsd: pending.reduce((sum, entry) => sum + BigInt(entry.totalCreditUsd || '0'), 0n).toString(),
+ count: pending.length,
+ }
+ }
+
+ async getTransactions(payer: string, options: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string } = {}): Promise {
+ const history = await this.getCreditHistory(payer, {
+ fundingStatus: options.status, limit: options.limit, offset: options.cursor ? Number(options.cursor) || 0 : 0,
+ })
+ return { account: history.account, transactions: history.items }
+ }
+
+ prepareSettlement(ref: AccountRef, creditUsd: bigint): void {
+ this.activeRef = { payer: normalizeAddress(ref.payer), buyer: normalizeAddress(ref.buyer) }
+ this.lastCreditUsd = creditUsd
+ }
+
+ async notifyPayment(txHash: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const ref = this.activeRef
+ if (!ref) return { txHash, events: [] }
+ const now = new Date().toISOString()
+ const entry: GdCreditEntry = {
+ id: `${txHash}:0`, account: ref.payer, rootAccount: ref.payer, source: 'deposit',
+ gdAmountWei: '0', totalCreditUsd: this.lastCreditUsd.toString(), principalUsd: this.lastCreditUsd.toString(),
+ bonusUsd: '0', fundingStatus: 'pending', txHash, logIndex: 0, createdAt: now,
+ streamUpdateMonth: now.slice(0, 7), buyerAddress: ref.buyer,
+ }
+ const state = this.getState(ref.payer)
+ if (!state.transactions.find((item) => item.id === entry.id)) state.transactions.unshift(entry)
+ return { txHash, events: [entry] }
+ }
+
+ async waitForSettlement(ref: AccountRef): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const state = this.getState(ref.payer)
+ for (const entry of state.transactions) {
+ if (entry.fundingStatus !== 'pending') continue
+ entry.fundingStatus = 'funded'
+ state.principalUsd += BigInt(entry.principalUsd)
+ state.bonusUsd += BigInt(entry.bonusUsd)
+ }
+ return { totalCreditUsd: totalCreditUsdFromProfile(this.buildProfile(ref.payer)) }
+ }
+
+ async closeChannel(channelId: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return { channelId, action: 'close', bridge: { enabled: true, txHash: '0xmock' } }
+ }
+
+ async withdrawCredits(buyer: string, body: WithdrawPrincipalRequest): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return { account: buyer, amountUsd: body.amount, bridge: { enabled: true, txHash: '0xmock' } }
+ }
+
+ async submitOperatorConsent(buyer: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const normalizedBuyer = normalizeAddress(buyer)
+ markMockOperatorConsent(normalizedBuyer)
+ return { buyer: normalizedBuyer, bridge: { enabled: true, txHash: '0xmock' } }
+ }
+}
diff --git a/packages/ai-credits-widget/src/mocked/chainClient.ts b/packages/ai-credits-widget/src/mocked/chainClient.ts
new file mode 100644
index 00000000..16d14361
--- /dev/null
+++ b/packages/ai-credits-widget/src/mocked/chainClient.ts
@@ -0,0 +1,88 @@
+import type { AccountRef } from '../backendTypes'
+import { BASE_CHAIN_ID, type AiCreditsChainClient } from '../chainClient'
+import { ANTSEED_DEPOSITS_BASE_ADDRESS, buildSetOperatorPayload } from '../operatorConsent'
+import { buildQuoteAmounts } from '../quoteMath'
+import type { BuyerOperatorStatus, OperatorConsentPayloadResponse } from '../operatorConsent'
+import type { AiCreditsQuote } from '../widgetRuntimeContract'
+
+const mockOperatorAcceptedBuyers = new Set()
+
+function normalizeAddress(address: string): string {
+ return address.toLowerCase()
+}
+
+export function markMockOperatorConsent(buyer: string): void {
+ mockOperatorAcceptedBuyers.add(normalizeAddress(buyer))
+}
+
+export class MockAiCreditsChainClient implements AiCreditsChainClient {
+ private operatorAccepted: boolean
+ private readonly gdUsdPerToken: number
+ private readonly goodIdVerified: boolean
+
+ constructor(
+ options: { operatorAccepted?: boolean; gdUsdPerToken?: number; goodIdVerified?: boolean } = {},
+ ) {
+ this.operatorAccepted = options.operatorAccepted ?? false
+ this.gdUsdPerToken = options.gdUsdPerToken ?? 0.0015
+ this.goodIdVerified = options.goodIdVerified ?? false
+ }
+
+ async isGoodIdVerified(): Promise {
+ return this.goodIdVerified
+ }
+
+ async fetchGdUsdPerToken(): Promise {
+ return this.gdUsdPerToken
+ }
+
+ async buildQuote(depositG: string, streamG: string): Promise {
+ return buildQuoteAmounts(depositG, streamG)
+ }
+
+ async getBuyerOperatorStatus(ref: AccountRef): Promise {
+ const payer = normalizeAddress(ref.payer)
+ const buyer = normalizeAddress(ref.buyer)
+ const operatorAddress = '0x0000000000000000000000000000000000000004'
+ const operatorAccepted = this.operatorAccepted || mockOperatorAcceptedBuyers.has(buyer)
+ return {
+ enabled: true,
+ account: payer,
+ buyerAddress: buyer,
+ operatorAddress,
+ currentOperator: operatorAccepted ? operatorAddress : '0x0000000000000000000000000000000000000000',
+ operatorAccepted,
+ consentNonce: '0',
+ }
+ }
+
+ async buildOperatorConsentPayload(
+ ref: AccountRef,
+ operatorStatus?: BuyerOperatorStatus,
+ ): Promise {
+ const status = operatorStatus ?? (await this.getBuyerOperatorStatus(ref))
+ if (!status.enabled || !status.operatorAddress) {
+ return {
+ enabled: false,
+ account: normalizeAddress(ref.payer),
+ buyerAddress: normalizeAddress(ref.buyer),
+ }
+ }
+ return {
+ enabled: true,
+ account: normalizeAddress(ref.payer),
+ buyerAddress: normalizeAddress(ref.buyer),
+ typedData: buildSetOperatorPayload(
+ BASE_CHAIN_ID,
+ ANTSEED_DEPOSITS_BASE_ADDRESS,
+ status.operatorAddress,
+ BigInt(status.consentNonce),
+ { name: 'AntseedDeposits', version: '1' },
+ ),
+ }
+ }
+
+ async getWithdrawableUsd(): Promise {
+ return '0'
+ }
+}
diff --git a/packages/ai-credits-widget/src/mocked/index.ts b/packages/ai-credits-widget/src/mocked/index.ts
new file mode 100644
index 00000000..19d45fc8
--- /dev/null
+++ b/packages/ai-credits-widget/src/mocked/index.ts
@@ -0,0 +1,3 @@
+export { MockAiCreditsWidget } from './AiCreditsMockWidget'
+export { MockAiCreditsBackendClient } from './backendClient'
+export { MockAiCreditsChainClient } from './chainClient'
diff --git a/packages/ai-credits-widget/src/register.ts b/packages/ai-credits-widget/src/register.ts
index e4743b5c..472ee038 100644
--- a/packages/ai-credits-widget/src/register.ts
+++ b/packages/ai-credits-widget/src/register.ts
@@ -1,7 +1,10 @@
-import { AiCreditsWidgetElement } from './element'
-
const DEFAULT_TAG_NAME = 'ai-credits-widget'
+export const goodWidgetMetadata = {
+ packageName: '@goodwidget/ai-credits-widget',
+ packageVersion: '0.1.0',
+} as const
+
/**
* Register the custom element.
*
@@ -11,18 +14,20 @@ const DEFAULT_TAG_NAME = 'ai-credits-widget'
* Then use in HTML:
*
*
- * Returns the tag name so you can use it programmatically:
- * const tag = register() // 'ai-credits-widget'
+ * Resolves to the tag name so you can use it programmatically:
+ * const tag = await register() // 'ai-credits-widget'
*
* Or register under a custom tag:
- * const tag = register('my-ai-credits-widget')
+ * const tag = await register('my-ai-credits-widget')
*/
-export function register(tagName: string = DEFAULT_TAG_NAME): string {
+export async function register(tagName: string = DEFAULT_TAG_NAME): Promise {
+ // Keep browser-only Custom Element dependencies out of server evaluation.
if (typeof customElements === 'undefined') return tagName
if (!customElements.get(tagName)) {
+ const { AiCreditsWidgetElement } = await import('./element')
customElements.define(tagName, AiCreditsWidgetElement)
}
return tagName
}
-register()
+void register().catch(() => undefined)
diff --git a/packages/ai-credits-widget/src/useAiCreditsHistory.ts b/packages/ai-credits-widget/src/useAiCreditsHistory.ts
index 9a23318a..f63dfc28 100644
--- a/packages/ai-credits-widget/src/useAiCreditsHistory.ts
+++ b/packages/ai-credits-widget/src/useAiCreditsHistory.ts
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { GdCreditEntry } from './backendTypes'
import { createBackendClient } from './backendClient'
+import type { AiCreditsBackendClient } from './backendClient'
+import type { AiCreditsWidgetEnvironment } from './widgetRuntimeContract'
export const HISTORY_PAGE_SIZE = 10
export const HISTORY_LOOKBACK_DAYS = 90
@@ -84,8 +86,10 @@ export interface UseAiCreditsHistoryResult {
export function useAiCreditsHistory(options: {
address: string | null
backendUrl?: string
+ environment?: AiCreditsWidgetEnvironment
+ backendClient?: AiCreditsBackendClient
}): UseAiCreditsHistoryResult {
- const { address, backendUrl } = options
+ const { address, backendUrl, environment = 'production', backendClient } = options
const defaultRange = useMemo(() => getLast90DaysRange(), [])
const [selectedSources, setSelectedSources] = useState(createDefaultSelectedSources)
@@ -130,7 +134,7 @@ export function useAiCreditsHistory(options: {
else setLoading(true)
setError(null)
- const client = createBackendClient(backendUrl)
+ const client = backendClient ?? createBackendClient(backendUrl)
const apiSource = activeSources.length === 1 ? activeSources[0] : undefined
const fundingStatus = statusFilter === 'all' ? undefined : statusFilter
@@ -160,7 +164,17 @@ export function useAiCreditsHistory(options: {
setLoadingMore(false)
}
},
- [address, backendUrl, activeSources, statusFilter, fromDate, toDate, selectedSources],
+ [
+ address,
+ backendUrl,
+ environment,
+ backendClient,
+ activeSources,
+ statusFilter,
+ fromDate,
+ toDate,
+ selectedSources,
+ ],
)
useEffect(() => {
diff --git a/packages/ai-credits-widget/src/widgetRuntimeContract.ts b/packages/ai-credits-widget/src/widgetRuntimeContract.ts
index a3aeebe8..65a06de8 100644
--- a/packages/ai-credits-widget/src/widgetRuntimeContract.ts
+++ b/packages/ai-credits-widget/src/widgetRuntimeContract.ts
@@ -1,5 +1,8 @@
import type { Address } from 'viem'
import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui'
+import type { AccountRef } from './backendTypes'
+import type { AiCreditsBackendClient } from './backendClient'
+import type { AiCreditsChainClient } from './chainClient'
export type AiCreditsWidgetEnvironment = 'production' | 'staging' | 'development'
@@ -76,6 +79,13 @@ export type AiCreditsWidgetAdapterFactory = (
input: AiCreditsWidgetAdapterFactoryInput,
) => AiCreditsWidgetAdapterResult
+export interface AiCreditsWidgetAdapterOptions {
+ backendClient?: AiCreditsBackendClient
+ chainClient?: AiCreditsChainClient
+ skipVaultPaymentValidation?: boolean
+ prepareSettlement?: (ref: AccountRef, creditUsd: bigint) => void
+}
+
export interface AiCreditsPaySuccessDetail {
address: string
chainId: number
@@ -106,5 +116,6 @@ export interface AiCreditsWidgetProps {
onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
onPayError?: (detail: AiCreditsPayErrorDetail) => void
adapterFactory?: AiCreditsWidgetAdapterFactory
+ adapterOptions?: AiCreditsWidgetAdapterOptions
testId?: string
}
diff --git a/packages/ai-credits-widget/tsup.config.ts b/packages/ai-credits-widget/tsup.config.ts
index 95c2de0d..ec345df0 100644
--- a/packages/ai-credits-widget/tsup.config.ts
+++ b/packages/ai-credits-widget/tsup.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
index: 'src/index.ts',
element: 'src/element.ts',
register: 'src/register.ts',
+ 'mocked/index': 'src/mocked/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
diff --git a/packages/embed/src/createMiniAppElement.tsx b/packages/embed/src/createMiniAppElement.tsx
index 7357a07c..db3fad0c 100644
--- a/packages/embed/src/createMiniAppElement.tsx
+++ b/packages/embed/src/createMiniAppElement.tsx
@@ -9,6 +9,9 @@ import { readCSSOverrides, observeCSSChanges } from './cssPropertyBridge'
import { normalizePropDefs, toKebabCase, toCamelCase, emitEvent } from './bridge'
import type { PropDefinitions } from './bridge'
+const getBridgedProperty = Symbol('getBridgedProperty')
+const setBridgedProperty = Symbol('setBridgedProperty')
+
export interface MiniAppElementOptions {
shadow?: boolean
props?: PropDefinitions
@@ -17,6 +20,20 @@ export interface MiniAppElementOptions {
defaultConfig?: GoodWidgetConfig
}
+export interface MiniAppElement extends HTMLElement {
+ provider: EIP1193Provider | null
+ themeOverrides: GoodWidgetThemeOverrides | undefined
+ config: GoodWidgetConfig | undefined
+ emitEvent(eventName: string, detail?: unknown): void
+}
+
+export interface MiniAppElementConstructor {
+ new (): MiniAppElement
+ readonly prototype: MiniAppElement
+ readonly observedAttributes: string[]
+ themeManifest: ThemeManifest
+}
+
function deepMergeOverrides(
a?: GoodWidgetThemeOverrides,
b?: GoodWidgetThemeOverrides,
@@ -55,14 +72,11 @@ function deepMergeOverrides(
export function createMiniAppElement(
App: React.ComponentType>,
options: MiniAppElementOptions = {},
-) {
- const HTMLElementBase =
+): MiniAppElementConstructor {
+ const HTMLElementBase: typeof HTMLElement =
typeof globalThis !== 'undefined' && 'HTMLElement' in globalThis
? (globalThis as { HTMLElement: typeof HTMLElement }).HTMLElement
- : undefined
- if (!HTMLElementBase) {
- throw new Error('createMiniAppElement is only supported in DOM environments')
- }
+ : (class {} as typeof HTMLElement)
const {
shadow = true,
@@ -89,9 +103,36 @@ export function createMiniAppElement(
#disconnectStyleSync: (() => void) | null = null
#extraProps: Record = {}
+ constructor() {
+ super()
+ const upgradeProperty = (name: string, apply: (value: unknown) => void) => {
+ if (!Object.prototype.hasOwnProperty.call(this, name)) return
+ const value = (this as unknown as Record)[name]
+ delete (this as unknown as Record)[name]
+ apply(value)
+ }
+
+ upgradeProperty('provider', (value) => {
+ this.provider = (value as EIP1193Provider | null | undefined) ?? null
+ })
+ upgradeProperty('themeOverrides', (value) => {
+ this.themeOverrides = value as GoodWidgetThemeOverrides | undefined
+ })
+ upgradeProperty('config', (value) => {
+ this.config = value as GoodWidgetConfig | undefined
+ })
+
+ for (const [name, definition] of Object.entries(normalizedProps)) {
+ if (definition.type !== 'property') continue
+ upgradeProperty(name, (value) => {
+ this[setBridgedProperty](name, value)
+ })
+ }
+ }
+
static get observedAttributes(): string[] {
return Object.entries(normalizedProps)
- .filter(([_, def]) => def.type === 'attribute')
+ .filter(([, def]) => def.type === 'attribute')
.map(([name]) => toKebabCase(name))
}
@@ -174,6 +215,15 @@ export function createMiniAppElement(
emitEvent(this, eventName, detail)
}
+ [getBridgedProperty](name: string): unknown {
+ return this.#extraProps[name]
+ }
+
+ [setBridgedProperty](name: string, value: unknown): void {
+ this.#extraProps[name] = value
+ this.#render()
+ }
+
#render() {
if (!this.#root) return
@@ -181,6 +231,10 @@ export function createMiniAppElement(
const appProps: Record = {
...this.#extraProps,
+ provider: this.#provider ?? undefined,
+ config: this.#config ?? defaultConfig,
+ themeOverrides: mergedOverrides,
+ defaultTheme,
}
for (const eventName of events) {
@@ -206,5 +260,19 @@ export function createMiniAppElement(
}
}
+ for (const [name, definition] of Object.entries(normalizedProps)) {
+ if (definition.type !== 'property' || name in GoodWidgetElement.prototype) continue
+ Object.defineProperty(GoodWidgetElement.prototype, name, {
+ configurable: true,
+ enumerable: true,
+ get(this: GoodWidgetElement) {
+ return this[getBridgedProperty](name)
+ },
+ set(this: GoodWidgetElement, value: unknown) {
+ this[setBridgedProperty](name, value)
+ },
+ })
+ }
+
return GoodWidgetElement
}
diff --git a/packages/embed/src/index.ts b/packages/embed/src/index.ts
index 57fe388b..1396a556 100644
--- a/packages/embed/src/index.ts
+++ b/packages/embed/src/index.ts
@@ -1,4 +1,9 @@
export { createMiniAppElement } from './createMiniAppElement'
+export type {
+ MiniAppElement,
+ MiniAppElementConstructor,
+ MiniAppElementOptions,
+} from './createMiniAppElement'
export { DefaultAppKitProvider } from './DefaultAppKitProvider'
export { injectStylesIntoShadow, updateShadowStyles, getResetCSS } from './shadowStyles'
export { readCSSOverrides, observeCSSChanges } from './cssPropertyBridge'