diff --git a/.env.example b/.env.example
index 205936a..8ce7000 100644
--- a/.env.example
+++ b/.env.example
@@ -110,3 +110,9 @@ POSTGRES_TOKENS_DATABASE_URL=pglite://./data/local.db
# Server-to-server API key for the airdrop registration endpoint.
AIRDROP_API_KEY=
+
+# AI Credits widget backend (PUBLIC — inlined into the client bundle).
+NEXT_PUBLIC_AI_CREDITS_BACKEND_URL=
+NEXT_PUBLIC_AI_CREDITS_FUNDING_VAULT_ADDRESS=
+# Hide the dashboard button while keeping /{locale}/ai-credits accessible.
+NEXT_PUBLIC_AI_CREDITS_WIDGET_DASHBOARD_ENABLED=true
diff --git a/.github/workflows/goodwallet-pr.yml b/.github/workflows/goodwallet-pr.yml
index f6144a5..4ddba14 100644
--- a/.github/workflows/goodwallet-pr.yml
+++ b/.github/workflows/goodwallet-pr.yml
@@ -26,17 +26,36 @@ jobs:
with:
node-version: 24
+ - name: Enable Corepack
+ run: corepack enable
+
- name: Run install
- uses: borales/actions-yarn@v5
- with:
- cmd: install
+ run: yarn install --immutable
- name: Run Biome Check
uses: borales/actions-yarn@v5
with:
cmd: biome:verify:all
+ - name: Pull Vercel Preview environment
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+ run: npx --yes vercel@latest pull --yes --environment=preview --token="$VERCEL_TOKEN"
+
+ - name: Build Vercel prebuilt artifact
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ env:
+ NODE_OPTIONS: --max_old_space_size=8192
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+ run: npx --yes vercel@latest build --token="$VERCEL_TOKEN"
+
- name: Run Build
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
env:
NODE_OPTIONS: --max_old_space_size=4096
run: yarn build
@@ -53,6 +72,15 @@ jobs:
uses: borales/actions-yarn@v5
with:
cmd: test:e2e
+
+ - name: Deploy Vercel prebuilt Preview
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+ run: npx --yes vercel@latest deploy --prebuilt --token="$VERCEL_TOKEN"
+
- name: Upload Playwright screenshots and failure artifacts
if: always()
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 4530833..61de0c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,4 +65,7 @@ tsconfig.tsbuildinfo
# Codex
codex-resume
-.agents/skills
\ No newline at end of file
+.agents/skills
+local-packages/
+.bounties
+.env*
diff --git a/README.md b/README.md
index aa96db2..36eb340 100644
--- a/README.md
+++ b/README.md
@@ -92,3 +92,38 @@ yarn dev
Other scripts: `yarn build`, `yarn start`, `yarn test`, `yarn lint`.
Then open http://localhost:3000 in your browser
+
+## Preview deployments
+
+Vercel Git deployments are disabled in `vercel.json`. Pull requests targeting
+`main` are built in GitHub Actions and deployed to Vercel as prebuilt Preview
+artifacts after the checks pass.
+
+The repository needs these GitHub Actions secrets:
+
+- `VERCEL_TOKEN`
+- `VERCEL_ORG_ID`
+- `VERCEL_PROJECT_ID`
+
+## GoodWidget visibility
+
+Widget routes are registered independently from dashboard buttons. For example,
+setting `NEXT_PUBLIC_AI_CREDITS_WIDGET_DASHBOARD_ENABLED=false` hides the AI
+Credits button while keeping the authenticated `/{locale}/ai-credits` path
+available. `NEXT_PUBLIC_*` values are read during the Next.js build, so a Vercel
+environment change requires a new deployment/redeploy.
+
+## GoodWidget local-tarball workflow
+
+Widget packages that are not yet published to npm (e.g. `@goodwidget/ai-credits-widget`)
+must be installed from a local tarball built inside the
+[GoodDollar/GoodWidget](https://github.com/GoodDollar/GoodWidget) monorepo.
+
+1. Clone `GoodDollar/GoodWidget` alongside this repo.
+2. Inside the GoodWidget monorepo, pack the widget:
+ ```sh
+ pnpm --filter @goodwidget/ai-credits-widget pack --pack-destination /path/to/GoodWallet/local-packages
+ ```
+3. The tarball lands at `local-packages/ai-credits-widget.tgz` (already referenced in `package.json`).
+4. Run `yarn install` in this repo to link the tarball.
+5. The path `local-packages/*.tgz` is git-ignored; do **not** commit tarballs.
diff --git a/next.config.mjs b/next.config.mjs
index 38bfdd4..d394936 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,4 +1,3 @@
-// Injected content via Sentry wizard below
import { withSentryConfig } from "@sentry/nextjs"
/** @type {import('next').NextConfig} */
@@ -6,8 +5,38 @@ const nextConfig = {
reactCompiler: true,
reactStrictMode: true,
+ transpilePackages: [
+ "@goodwidget/ai-credits-widget",
+ "@goodwidget/core",
+ "@goodwidget/embed",
+ "@goodwidget/ui",
+ "react-native-web",
+ ],
+
+ turbopack: {
+ resolveAlias: {
+ "react-native": "react-native-web",
+ },
+ },
+
+ webpack: (config) => {
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ "react-native$": "react-native-web",
+ }
+ config.resolve.extensions = [
+ ".web.js",
+ ".web.jsx",
+ ".web.ts",
+ ".web.tsx",
+ ...(config.resolve.extensions ?? []),
+ ]
+ return config
+ },
+
env: {
NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA,
+ TAMAGUI_TARGET: "web",
},
images: {
minimumCacheTTL: 31536000,
diff --git a/package.json b/package.json
index 37268b3..8941fb6 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,10 @@
"@bitcoinerlab/secp256k1": "^1.2.0",
"@electric-sql/pglite": "^0.5.3",
"@gooddollar/goodprotocol": "^2.2.0",
+ "@goodwidget/ai-credits-widget": "0.1.5",
+ "@goodwidget/core": "0.1.4",
+ "@goodwidget/embed": "0.1.4",
+ "@goodwidget/ui": "0.1.4",
"@hcaptcha/react-hcaptcha": "^2.0.2",
"@json-rpc-tools/utils": "^1.7.6",
"@lifi/sdk": "^4.1.0",
@@ -41,6 +45,10 @@
"@solana/webcrypto-ed25519-polyfill": "^6.9.0",
"@toruslabs/customauth": "^20.4.0",
"@walletconnect/utils": "^2.23.10",
+ "@x402/core": "^2.19.0",
+ "@x402/evm": "^2.19.0",
+ "@x402/extensions": "^2.19.0",
+ "@x402/svm": "^2.19.0",
"async-await-mutex-lock": "^1.0.12",
"async-retry": "^1.3.3",
"bitcoinjs-lib": "^7.0.1",
@@ -63,6 +71,7 @@
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-jazzicon": "^1.0.4",
+ "react-native-web": "^0.19.13",
"swr": "^2.4.2",
"valtio": "^2.3.2",
"viem": "^2.54.0",
@@ -77,6 +86,7 @@
"@types/node": "26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+ "@types/react-native-web": "^0",
"@vitest/coverage-v8": "4.1.9",
"autoprefixer": "^10.5.0",
"babel-plugin-react-compiler": "^1.0.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index c2dfae4..1293167 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -10,6 +10,9 @@ export default defineConfig({
snapshotPathTemplate: "{testDir}/screenshots/{projectName}/{arg}{ext}",
fullyParallel: false,
workers: 1,
+ timeout: 90_000,
+ expect: { timeout: 30_000 },
+ retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
diff --git a/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.module.css b/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.module.css
index 1df53d6..a3260bd 100644
--- a/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.module.css
+++ b/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.module.css
@@ -1,13 +1,5 @@
-.dialogOverlay {
- width: 100%;
- height: 100%;
- background-color: var(--token-bg-overlay);
- position: fixed;
- top: 0;
- z-index: 100;
- display: flex;
- align-items: center;
- max-width: 768px;
+.aboveWidgets {
+ z-index: 1100;
}
.dialogContainer {
diff --git a/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.tsx b/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.tsx
index 1519cf0..27dfe72 100644
--- a/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.tsx
+++ b/src/sections/WalletConnect/components/WalletConnectDialog/WalletConnectDialog.tsx
@@ -1,6 +1,7 @@
"use client"
-import { useEffect } from "react"
+import { useEffect, useState } from "react"
+import { createPortal } from "react-dom"
import { Button } from "ui"
import { useSnapshot } from "valtio"
@@ -15,6 +16,7 @@ import { ErrorDialog } from "./ErrorDialog"
import { GenericDialog } from "./GenericDialog"
import { SessionProposalDialog } from "./SessionProposalDialog"
import { SessionRequestDialog } from "./SessionRequestDialog"
+import styles from "./WalletConnectDialog.module.css"
const renderDialog = (dialog: WalletConnectDialogType) => {
switch (dialog.type) {
@@ -31,6 +33,12 @@ const renderDialog = (dialog: WalletConnectDialogType) => {
export const WalletConnectDialog = () => {
const { dialog, status, exiting } = useSnapshot(walletConnectDialogStore)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ return () => setMounted(false)
+ }, [])
useEffect(() => {
document.body.style.overflow = "hidden"
@@ -39,11 +47,11 @@ export const WalletConnectDialog = () => {
}
}, [])
- if (status !== "pending") return null
+ if (!mounted || status !== "pending") return null
- return (
+ return createPortal(
{
updateWalletConnectDialogStatus("rejected")
e.stopPropagation()
@@ -75,6 +83,7 @@ export const WalletConnectDialog = () => {
) : null}
-
+ ,
+ document.body,
)
}
diff --git a/src/widgets/AuthenticatedWidgetRoute.tsx b/src/widgets/AuthenticatedWidgetRoute.tsx
index 583d32e..093c0eb 100644
--- a/src/widgets/AuthenticatedWidgetRoute.tsx
+++ b/src/widgets/AuthenticatedWidgetRoute.tsx
@@ -6,19 +6,14 @@ import { setBottomSheetProps } from "@/components/Snippet/BottomSheet/bottomShee
import { LoadingSpinner } from "@/components/Snippet/LoadingSpinner"
import { useSessionContext } from "@/login/hooks/context"
-import type { WidgetHostProps } from "./hostTypes"
import { useWidgetProvider, WidgetProvider } from "./provider/WidgetProvider"
import { type RegisteredWidget, widgetRegistry } from "./registry"
import { WidgetRenderer } from "./WidgetRenderer"
export const AuthenticatedWidgetRoute = ({
widgetId,
- themeOverrides,
- config,
}: {
widgetId: string
- themeOverrides?: WidgetHostProps["themeOverrides"]
- config?: WidgetHostProps["config"]
}) => {
const { signer, isLoading } = useSessionContext()
const widget = widgetRegistry.get(widgetId)
@@ -42,32 +37,19 @@ export const AuthenticatedWidgetRoute = ({
chainIds={widget.providerPolicy.chainIds}
requiredMethods={widget.providerPolicy.requiredMethods}
>
-
+
)
}
-const MountedWidget = ({
- widget,
- themeOverrides,
- config,
-}: {
- widget: RegisteredWidget
- themeOverrides?: WidgetHostProps["themeOverrides"]
- config?: WidgetHostProps["config"]
-}) => {
+const MountedWidget = ({ widget }: { widget: RegisteredWidget }) => {
const provider = useWidgetProvider()
return (
)
}
diff --git a/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts b/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts
new file mode 100644
index 0000000..0f038e9
--- /dev/null
+++ b/src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts
@@ -0,0 +1,14 @@
+/**
+ * Vitest mock for `@goodwidget/ai-credits-widget/register`.
+ *
+ * The real module performs a Custom Element side-effect registration which
+ * requires a browser DOM. This no-op stub satisfies the import during unit
+ * tests without triggering DOM APIs.
+ */
+export const goodWidgetMetadata = {
+ packageName: "@goodwidget/ai-credits-widget",
+ packageVersion: "0.1.0",
+}
+
+export const register = (tagName?: string): string =>
+ tagName ?? "ai-credits-widget"
diff --git a/src/widgets/hostProperties.test.ts b/src/widgets/hostProperties.test.ts
index c13a3e9..a77d535 100644
--- a/src/widgets/hostProperties.test.ts
+++ b/src/widgets/hostProperties.test.ts
@@ -10,24 +10,33 @@ import type { RestrictedEip1193Provider } from "./provider/RestrictedEip1193Prov
describe("Web Component host properties", () => {
it("hands objects over by identity and clears Wallet references on cleanup", () => {
const provider = {} as RestrictedEip1193Provider
- const element = {} as HostedWidgetElement
+ const element = {} as HostedWidgetElement & Record
const themeOverrides = { color: { primary: "#00AFFE" } }
const config = { environment: "production" }
+ const elementProps = {
+ backendUrl: "https://api.example.com",
+ fundingVaultAddress: "0xfundingvault",
+ }
assignHostedWidgetProperties(element, {
provider,
themeOverrides,
config,
+ elementProps,
})
expect(element.provider).toBe(provider)
expect(element.themeOverrides).toBe(themeOverrides)
expect(element.config).toBe(config)
+ expect(element.backendUrl).toBe(elementProps.backendUrl)
+ expect(element.fundingVaultAddress).toBe(elementProps.fundingVaultAddress)
clearHostedWidgetProperties(element)
expect(element.provider).toBeNull()
expect(element.themeOverrides).toBeUndefined()
expect(element.config).toBeUndefined()
+ expect(element.backendUrl).toBeUndefined()
+ expect(element.fundingVaultAddress).toBeUndefined()
})
})
diff --git a/src/widgets/hostProperties.ts b/src/widgets/hostProperties.ts
index c8542a5..f9d8c38 100644
--- a/src/widgets/hostProperties.ts
+++ b/src/widgets/hostProperties.ts
@@ -1,18 +1,42 @@
import type { HostedWidgetElement, WidgetHostProps } from "./hostTypes"
+const ELEMENT_PROP_KEYS = new WeakMap