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'