Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 31 additions & 3 deletions .github/workflows/goodwallet-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,7 @@ tsconfig.tsbuildinfo

# Codex
codex-resume
.agents/skills
.agents/skills
local-packages/
.bounties
.env*
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 30 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
// Injected content via Sentry wizard below
import { withSentryConfig } from "@sentry/nextjs"

/** @type {import('next').NextConfig} */
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,
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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) {
Expand All @@ -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"
Expand All @@ -39,11 +47,11 @@ export const WalletConnectDialog = () => {
}
}, [])

if (status !== "pending") return null
if (!mounted || status !== "pending") return null

return (
return createPortal(
<div
className={dialogStyles.dialogOverlay}
className={`${dialogStyles.dialogOverlay} ${styles.aboveWidgets}`}
onClick={(e) => {
updateWalletConnectDialogStatus("rejected")
e.stopPropagation()
Expand Down Expand Up @@ -75,6 +83,7 @@ export const WalletConnectDialog = () => {
) : null}
</div>
</dialog>
</div>
</div>,
document.body,
)
}
24 changes: 3 additions & 21 deletions src/widgets/AuthenticatedWidgetRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -42,32 +37,19 @@ export const AuthenticatedWidgetRoute = ({
chainIds={widget.providerPolicy.chainIds}
requiredMethods={widget.providerPolicy.requiredMethods}
>
<MountedWidget
widget={widget}
themeOverrides={themeOverrides}
config={config}
/>
<MountedWidget widget={widget} />
</WidgetProvider>
)
}

const MountedWidget = ({
widget,
themeOverrides,
config,
}: {
widget: RegisteredWidget
themeOverrides?: WidgetHostProps["themeOverrides"]
config?: WidgetHostProps["config"]
}) => {
const MountedWidget = ({ widget }: { widget: RegisteredWidget }) => {
const provider = useWidgetProvider()

return (
<WidgetRenderer
widget={widget}
provider={provider}
themeOverrides={themeOverrides}
config={config}
elementProps={widget.elementProps}
/>
)
}
14 changes: 14 additions & 0 deletions src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 10 additions & 1 deletion src/widgets/hostProperties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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()
})
})
Loading
Loading