diff --git a/.github/ISSUE_TEMPLATE/agent-ready-bounty.yml b/.github/ISSUE_TEMPLATE/agent-ready-bounty.yml deleted file mode 100644 index 2b2ebb4..0000000 --- a/.github/ISSUE_TEMPLATE/agent-ready-bounty.yml +++ /dev/null @@ -1,351 +0,0 @@ -name: Agent-ready bounty -description: Create a GoodBounty issue that is readable by humans, executable by Copilot, and reviewable by contributors. -title: '[GoodBounty]: ' -labels: ['enhancement'] -body: - - type: markdown - attributes: - value: | - Use this template for GoodBounties that may be assigned to Copilot or another coding agent before human contributor review. - - The goal is not to write a long prompt. The goal is to create a clear execution contract: - - humans can understand the bounty quickly - - Copilot has enough context to produce a scoped PR - - reviewers can verify the PR against explicit criteria - - - type: input - id: bounty-summary - attributes: - label: Human summary - description: One sentence describing the outcome. Keep this readable for contributors browsing the board. - placeholder: 'Migrate the GoodWalletV2 claim flow into the Citizen Claim Widget using GoodSDKs.' - validations: - required: true - - - type: textarea - id: why-this-matters - attributes: - label: Why this matters - description: Explain the product or architecture reason in 2–4 sentences. - placeholder: | - This moves the widget closer to a real SDK-backed claim flow and gives us a reusable reference for future GoodWidget migrations. - validations: - required: true - - - type: dropdown - id: bounty-tier - attributes: - label: Bounty tier - description: Pick the expected bounty tier. Adjust before assignment if scope changes. - options: - - Basic - - Common - - Rare - - Epic - - Mythic - - Legendary - validations: - required: true - - - type: dropdown - id: bounty-type - attributes: - label: Bounty type - description: Choose the closest category. - options: - - Bug fix - - UI implementation - - Widget feature - - SDK integration - - Architecture cleanup - - Primitive migration - - Documentation - - QA / review - - Research / specification - validations: - required: true - - - type: input - id: target-package - attributes: - label: Target package or area - description: Main package, app, or folder this bounty should touch. - placeholder: 'packages/citizen-claim-widget' - validations: - required: true - - - type: input - id: base-branch - attributes: - label: Required base branch - description: The branch Copilot or the contributor must start from. - placeholder: 'main' - value: 'main' - validations: - required: true - - - type: textarea - id: required-references - attributes: - label: Required references - description: List all files, PRs, branches, docs, or external repositories that must be inspected before implementation. - value: | - The agent and contributor must inspect these before implementation: - - - `ARCHITECTURE.md` - - `AGENTS.md` - - `docs/demo-environment.md` - - Relevant source files / PRs / branches: - - - - If any required reference is unavailable, stop and comment exactly what is missing. - placeholder: | - - GoodWidget PR #... - - GoodSDKs PR #... - - GoodWalletV2/src/... - validations: - required: true - - - type: textarea - id: source-to-target-mapping - attributes: - label: Source-to-target mapping - description: Required for migrations or feature parity work. This prevents shallow “review related files” behavior. - value: | - Before coding, produce or complete this mapping. - - | Source behavior / UI section | Target widget section | Required state / prop / API | Verification | - |---|---|---|---| - | | | | | - | | | | | - | | | | | - - If this bounty is not a migration/parity task, explain why this mapping is not needed. - validations: - required: true - - - type: textarea - id: scope - attributes: - label: Scope - description: Be explicit about what must change and what may change. - value: | - Must change: - - - - May change: - - - - Must not change: - - - validations: - required: true - - - type: textarea - id: non-goals - attributes: - label: Non-goals - description: List anything tempting but out of scope. - value: | - Do not include: - - unrelated refactors - - unrelated design-system changes - - unrelated dependency upgrades - - changes outside the target package unless explicitly listed in scope - validations: - required: true - - - type: checkboxes - id: architecture-constraints - attributes: - label: Architecture constraints - description: Check all constraints that apply. These become review criteria. - options: - - label: Keep SDK/protocol logic out of `packages/ui`. - required: false - - label: Keep widget-specific flow logic inside the widget package. - required: false - - label: Preserve the provider-first runtime path through `GoodWidgetProvider`. - required: false - - label: Preserve the current theming override precedence. - required: false - - label: Do not introduce new public theme targets unless explicitly listed. - required: false - - label: Do not bypass the EIP-1193/provider wrapper model. - required: false - - label: Do not modify demo packages except for explicit Storybook or verification deliverables. - required: false - - label: Do not touch unrelated primitives, packages, or dependencies. - required: false - - - type: textarea - id: acceptance-criteria - attributes: - label: Acceptance criteria - description: These are the main pass/fail checks for the bounty. - value: | - - [ ] - - [ ] - - [ ] - - [ ] - validations: - required: true - - - type: textarea - id: states-and-flows - attributes: - label: Required states, flows, or behaviors - description: Use this especially for UI, wallet, claim, transaction, or async flows. - value: | - | State / flow | Trigger | Expected behavior | Verification | - |---|---|---|---| - | loading | | | | - | | | | | - - If no state machine or flow behavior is involved, write: "No state/flow matrix needed." - validations: - required: true - - - type: textarea - id: sdk-and-version-checks - attributes: - label: SDK, dependency, and cross-repo checks - description: Make version assumptions explicit. This avoids PR #11-style mismatch between unreleased SDK changes and published packages. - value: | - Required SDK/package assumptions: - - - - Cross-repo dependencies: - - - - Required check: - - [ ] Confirm whether referenced SDK/API changes are already released, branch-only, local-only, or pending release. - - [ ] If there is a version/capability gap, report it before implementing a workaround. - validations: - required: false - - - type: textarea - id: storybook-requirements - attributes: - label: Storybook requirements - description: Required for UI/widget work. Use Storybook as the canonical visual/review environment. - value: | - - [ ] Add or update a Storybook story. - - [ ] Include deterministic mock fixtures if wallet/provider state is needed. - - [ ] Add visible `data-testid` values for important states. - - [ ] Cover at least these stories/states: - - - - [ ] Add or update play-function tests if interaction behavior is part of the bounty. - validations: - required: false - - - type: textarea - id: visual-requirements - attributes: - label: Visual and UX requirements - description: Do not say “match the demo” without listing the visible sections or states that must match. - value: | - Required visible sections / UI details: - - - - Required responsive checks: - - Desktop: - - Mobile / narrow viewport: - - Screenshots or recordings required: - - - validations: - required: false - - - type: textarea - id: verification-commands - attributes: - label: Verification commands - description: Commands Copilot/contributor should run and report in the PR body. - value: | - Run and report: - - ```sh - pnpm build - pnpm test:storybook - pnpm test:demo - ``` - - If any command cannot run, explain why and what was run instead. - validations: - required: true - - - type: textarea - id: pr-requirements - attributes: - label: Pull request requirements - description: The PR must mirror the issue and provide review evidence. - value: | - The PR description must include: - - - Summary of changes - - Source-to-target mapping, if applicable - - Acceptance criteria checklist - - Commands run - - Storybook links, screenshots, or recordings for UI changes - - Known risks / follow-ups - - Any intentional deviations from this issue - validations: - required: true - - - type: checkboxes - id: human-reviewer-checklist - attributes: - label: Human reviewer checklist - description: Reviewers should use this before approving or requesting maintainer sign-off. - options: - - label: I reviewed the issue before reviewing the diff. - required: false - - label: I checked whether the PR solves the stated goal, not just a related problem. - required: false - - label: I checked scope boundaries and unrelated file changes. - required: false - - label: I checked architecture constraints. - required: false - - label: I reviewed Storybook or visual evidence where relevant. - required: false - - label: I checked verification command output or reproduced the checks. - required: false - - label: I left concrete review comments or fixed blockers directly. - required: false - - - type: textarea - id: risks-and-followups - attributes: - label: Known risks and follow-ups - description: Capture uncertainty before implementation starts. - placeholder: | - - SDK release dependency may not be published yet. - - Storybook fixture may not cover real wallet behavior. - - Follow-up bounty may be needed for ... - validations: - required: false - - - type: checkboxes - id: spec-readiness-check - attributes: - label: Spec readiness check - description: Complete this before assigning the bounty to Copilot or a contributor. - options: - - label: Required references are listed and accessible. - required: true - - label: Scope and non-goals are clear. - required: true - - label: Source-to-target mapping is complete, or explicitly marked not applicable. - required: true - - label: SDK/version assumptions are checked, or explicitly marked not applicable. - required: true - - label: Acceptance criteria are testable. - required: true - - label: Storybook/Playwright expectations are clear, or explicitly marked not applicable. - required: true - - label: Verification commands are listed. - required: true - - label: This issue is ready for Copilot execution. - required: true diff --git a/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml b/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml new file mode 100644 index 0000000..f02392c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml @@ -0,0 +1,88 @@ +name: GoodWidget Spec Template +description: Define the requirements for a new GoodWidget +body: + - type: textarea + id: short_description + attributes: + label: Short description of the new GoodWidget + description: | + A short description of the new GoodWidget. This should be a concise summary of the widget's purpose and functionality. + value: | + A short description of the new GoodWidget. This should be a concise summary of the widget's purpose and functionality. + validations: + required: true + + - type: textarea + id: repos_and_packages + attributes: + label: Which repos and packages the AI should work with + description: | + List the repositories and packages the AI should work with. + value: | + List the repositories and packages the AI should work with. + validations: + required: true + + - type: textarea + id: ui_implementation_reference + attributes: + label: UI implementation reference + description: | + Any of the following: + + - instruction to copy from another repo + - provide screenshots + value: | + Any of the following: + + - instruction to copy from another repo + - provide screenshots + validations: + required: true + + - type: textarea + id: user_flows_states_behaviours + attributes: + label: User flows, states and behaviours + description: | + If no UI reference is given, elaborate how the UI/UX behaves. + Any other UI/UX instructions to the AI. + value: | + If no UI reference is given, elaborate how the UI/UX behaves. + Any other UI/UX instructions to the AI. + validations: + required: true + + - type: textarea + id: create_the_plan + attributes: + label: Create the plan (keep this section in the issue body) + description: | + This content is persisted in the issue body and should be used by the planner/copilot flow. + value: | + ## Create the plan + + Based on the above description create an execution plan in a **new sub-issue** and preserve the original issue content unchanged. + Do not execute the plan until given instructions to do so. + + Sub-issue requirements: + + - Title format: `[DRAFT][PLAN] ` + - Type: `Task` + - Description must start with: `` + - Link the sub-issue back to this parent issue. + + Instructions when creating the plan: + + - Map relevant files that are too be used as reference from all repos mentioned + - import existing @GoodDollar packages + - Map new components that should be created. Assess when a new component should be created in the new savings widget package or made part of the reusable packages/ui + + Plan specification required sections: + + - Required states, flows, and behaviors + - Execution plan + - acceptance criteria + - human-reviewer checklist + validations: + required: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6add65e..c8c0c7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,14 +1,45 @@ -## Bounty authoring rule +## Working with GoodWidget (General) -When asked to create or refine a GoodBounty issue: +Repository overview: -- Follow `AGENTS.md` for bounty-spec behavior and document routing. +- Follow `AGENTS.md` document routing and basic contribution guidelines. - Use `ARCHITECTURE.md` for package/runtime boundaries. - Use `docs/demo-environment.md` for Storybook and Playwright verification expectations. - Read `docs/architecture/theming-contract.md` only if the spec requires changes to Tamagui config, tokens, themes, presets, primitives, component names, public override targets, or theming behavior. -- Use `.github/ISSUE_TEMPLATE/agent-ready-bounty.yml` as the required structure for bounty specification. - - Produce a complete issue body. - - Identify missing references, assumptions, SDK/version gaps, and verification requirements. - - Stop after the bounty specification is drafted. - - Do not implement, branch, assign yourself, or open a PR unless explicitly asked in a separate follow-up after human approval. +## How to work in the repository + +- New widgets should always have their own package under `packages/` with a clear name and description. +- Follow the existing package structure and conventions for new widget packages. +- For new widgets, new components and UI changes, always include Storybook examples and Playwright smoke tests. organized by widget specific folders. +- For any new component or significant change, include clear comments and documentation. +- Always verify when to modularize large components/hooks and avoid single line helpers. +- Any new widget related components should be added to the widget's own package and not to `packages/ui` unless they are general purpose and reusable across multiple widgets. + +## Define a GoodWidget issue spec + +When asked to create or refine a GoodWidget issue, follow the persisted `Create the plan` section from `.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml` and include all required sections and details. +Always ask for clarification if any of the sections cannot be filled with the available information or if you are not sure about any of the details. +Be as concise as possible while still providing all the necessary information for the implementation and validation of the issue. The more clear and easy to understand the spec is, the easier it will be for the implementer to understand the requirements and for the reviewer to validate the implementation. + +The expected flow a GoodWidget issue planning should follow is: + +1. there is a 'parent' issue/original specification defined by a human operator. +2. there will be an assignment of the original issue to start 'planning' the implementation. +3. this should result in a 'sub-issue' of the original issue, with title `[DRAFT][PLAN] ` (no changes to the original issue) + -- sub-issue should have a type 'Task' + -- sub-issue should start the issue description with: + -- then follow the requested format from the persisted `Create the plan` section of the original issue. +4. Once the planning is done it should be requested to review the plan (no execution of the task or any pull-request should be opened at this stage). +5. Only issues assigned that have `[PLAN]` in their title and don't have `[DRAFT]` anymore can be executed and have pull-requests opened for them. + if you get assigned an issue that has `[DRAFT]` in description or title, dont execute any code changes but ask clarification in the github issue comments. + +## How to work with storybook and playwright + +- storybook examples should demonstrate the expected behavior and states of the widget. + it should include both custodial and non-custodial flows and use the appropriate fixtures for each case. + fixtures to use: `examples/storybook/fixtures/custodialEip1193.ts` and `examples/storybook/fixtures/injectedEip1193.ts`. +- stories should be organized per widget. For base components from packages/ui it should be part of the 'theme' folder, demonstrating the usage of the component with the theming system. +- Playwright smoke tests should cover the main flows and states of the widget, including error and empty states. They should be organized in the same way as the storybook examples, with separate test files for each widget and for the base components in `packages/ui`. +- Playwright smoke tests should always include page.screenshot() calls to capture the different UI states and flows, and these screenshots should be included in the pull-request description and should always sync with the latest screenshots taken. +- Playwright screenshots should be organized as part of the smoke-test per widget directory, and should be named according to the flow and state they represent for easy reference. diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index cebfd38..218e228 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -19,7 +19,7 @@ on: workflow_call: {} jobs: - setup: + copilot-setup-steps: runs-on: ubuntu-latest steps: # 1. Enable corepack so the declared pnpm version (packageManager field) is used diff --git a/.gitignore b/.gitignore index 5136538..659c359 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ dist/ coverage/ .vite/ .codex -test-results/ +/test-results/ playwright-report/ -**/storybook-static/ \ No newline at end of file +**/storybook-static/ +**/.yalc +**/yalc.lock +.bounties/contributor-bounties +local-reference-assets diff --git a/AGENTS.md b/AGENTS.md index 94e484d..4fd5290 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# GoodWidget Agent Operating Guide +# Agent Operating Guide This is the always-read operating contract for coding agents working in this repository. Read this document before writing any code. For deep reference on specific topics, see the @@ -6,20 +6,14 @@ Read this document before writing any code. For deep reference on specific topic --- -## Required Read Order +## Quick Start (Read First) -For any GoodBounty implementation: - -1. Read the GitHub issue body first. -2. Check the issue's "Spec readiness check" section. +1. Read the GitHub issue body. +2. Confirm required spec inputs are present per + [`.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml`](.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml). 3. Read [`ARCHITECTURE.md`](ARCHITECTURE.md). -4. Read [`docs/demo-environment.md`](docs/demo-environment.md) if the task touches UI, - widgets, Storybook, Playwright, screenshots, or demo behavior. -5. Read [`docs/architecture/theming-contract.md`](docs/architecture/theming-contract.md) if - the task touches Tamagui config, tokens, themes, presets, UI primitives, component names, - public override targets, or theming behavior. -6. If required references are missing or inaccessible, stop and report what is missing before - coding. +4. Open the task-specific docs from [Reference routing](#reference-routing). +5. If required references are missing or inaccessible, stop and report what is missing before coding. --- @@ -50,6 +44,15 @@ GoodWidget/ react-web/ # React web demo html/ # plain HTML web-component demo expo/ # Expo / React Native demo + storybook/ + src/ + stories/ + / # one subfolder per widget + *.stories.tsx + design-system/ # stories not tied to a specific widget + tests/ + design-system/ # Playwright tests mapped to design-system stories + widgets// # Playwright tests mapped to widget stories docs/ PACKAGING.md # packaging and distribution guide demo-environment.md # Storybook, Playwright, demo routes, fixtures @@ -68,6 +71,18 @@ GoodWidget/ --- +## Testing And QA (Summary) + +- Story interaction checks: `pnpm test:storybook`. +- Playwright QA/state-flow checks: `pnpm test:demo`. +- Root Playwright runtime artifacts (trace/video/attachments): `/test-results/` (gitignored). +- Committable screenshot evidence: `tests/design-system/test-results/` and + `tests/widgets//test-results/`. +- Detailed workflow, fixture behavior, and QA reporting template live in + [`docs/demo-environment.md`](docs/demo-environment.md) and [`docs/qa-guide.md`](docs/qa-guide.md). + +--- + ## Always / Ask First / Never ### Always @@ -99,17 +114,6 @@ GoodWidget/ --- -## GoodBounty Execution Rules - -- Treat the issue body as the execution contract. -- If scope, non-goals, acceptance criteria, source-to-target mapping, SDK/version assumptions, - or verification commands are missing — **stop and comment on the issue before coding**. -- Do not infer major missing behavior from vague wording. -- Mirror the issue checklist in the PR body. -- The "Spec readiness check" section of the issue body must pass before implementation begins. - ---- - ## PR Requirements - PR title and description must reference the issue number. @@ -127,7 +131,8 @@ Use the right document for each type of task: | Topic | Reference | | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | System overview, package responsibilities, data flow | [`ARCHITECTURE.md`](ARCHITECTURE.md) | -| Storybook, Playwright, demo routes, stories, fixtures, screenshots, test evidence | [`docs/demo-environment.md`](docs/demo-environment.md) | +| Storybook, Playwright, demo routes, stories, fixtures, test evidence, test folder conventions | [`docs/demo-environment.md`](docs/demo-environment.md) | +| Manual QA flow and reporting template | [`docs/qa-guide.md`](docs/qa-guide.md) | | Tamagui config, tokens, themes, presets, UI primitives, component names, public override targets | [`docs/architecture/theming-contract.md`](docs/architecture/theming-contract.md) | | Packaging and distribution | [`docs/PACKAGING.md`](docs/PACKAGING.md) | | Bounty scope, acceptance criteria, verification commands | GitHub issue body | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a9c2f4..70f4bdd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,8 +1,6 @@ # GoodWidget Architecture This document is the authoritative reference for the current GoodWidget codebase. -It is aligned to the `copilot/sub-pr-6` branch and the current Tamagui architecture used in -`packages/ui`, `packages/core`, `packages/embed`, and `packages/claim-widget`. For detailed Tamagui/theming rules, see [`docs/architecture/theming-contract.md`](docs/architecture/theming-contract.md). @@ -43,6 +41,7 @@ GoodWidget/ ui/ # Tamagui tokens, preset, themes, config assembly, manifest, primitives embed/ # Web Component wrapper + CSS custom property bridge claim-widget/ # Example widget package using core + ui + embed + goodreserve-widget/ # Reserve swap widget package using core + ui + embed examples/ react-web/ # React web override and theming demo @@ -71,6 +70,7 @@ GoodWidget/ @goodwidget/embed @goodwidget/claim-widget -> depends on core + ui + embed +@goodwidget/goodreserve-widget -> depends on core + ui + embed ``` `@goodwidget/ui` is the leaf design-system package and must not depend on `@goodwidget/core`. @@ -344,8 +344,6 @@ routes. The `examples/` directory contains additional integration demos: -> > > > > > > 8ce5cec253701479a06652e1f6948993af9c0fa3 - - `examples/react-web/` — React web app demonstrating preset baseline, token overrides, component theme overrides, host `themeOverrides`, and local inline overrides - `examples/html/` — plain HTML page using the claim widget as a web component diff --git a/README.md b/README.md index befab05..ace9479 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ A cross-platform mini app framework for building web3 widgets that run inside wa ## Packages -| Package | Description | -|---------|-------------| -| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context | -| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) | -| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page | -| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component | +| Package | Description | +| -------------------------- | ---------------------------------------------------------------------------- | +| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context | +| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) | +| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page | +| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component | +| `@goodwidget/goodreserve-widget` | Reserve swap widget package (buy/sell flow on Celo/XDC) | ## Quick Start @@ -91,9 +92,9 @@ const config = createGoodWidgetConfig({ ```css good-miniapp { - --gw-color-primary: #FF6B00; - --gw-Card-background: #FFF3E0; - --gw-Button-background: #FF6B00; + --gw-color-primary: #ff6b00; + --gw-Card-background: #fff3e0; + --gw-Button-background: #ff6b00; } ``` @@ -157,6 +158,10 @@ GoodWidget auto-detects and normalizes providers from: See **[docs/PACKAGING.md](docs/PACKAGING.md)** for the full guide on how to build, bundle, and publish your widget as an npm package that works in React, React Native, and plain HTML. +## QA + +See **[docs/qa-guide.md](docs/qa-guide.md)**. + ## Architecture ``` @@ -166,6 +171,7 @@ GoodWidget/ ui/ → @goodwidget/ui (component library, theme system) embed/ → @goodwidget/embed (Web Component wrapper) claim-widget/ → @goodwidget/claim-widget (sample publishable widget) + goodreserve-widget/ → @goodwidget/goodreserve-widget (reserve swap widget) examples/ react-web/ → React demo with style override showcase html/ → Plain HTML consuming a web component widget diff --git a/agent-next-steps/governance-design-md/DESIGN.md b/agent-next-steps/governance-design-md/DESIGN.md new file mode 100644 index 0000000..51ebf47 --- /dev/null +++ b/agent-next-steps/governance-design-md/DESIGN.md @@ -0,0 +1,193 @@ +## GoodDapp Civic (stitch) + +name: GoodDapp Civic +colors: +surface: '#FFFFFF' +surface-dim: '#d8dadc' +surface-bright: '#f8f9fb' +surface-container-lowest: '#ffffff' +surface-container-low: '#f2f4f6' +surface-container: '#eceef0' +surface-container-high: '#e6e8ea' +surface-container-highest: '#e0e3e5' +on-surface: '#191c1e' +on-surface-variant: '#3e4851' +inverse-surface: '#2d3133' +inverse-on-surface: '#eff1f3' +outline: '#6e7882' +outline-variant: '#bdc8d3' +surface-tint: '#006493' +primary: '#006493' +on-primary: '#ffffff' +primary-container: '#00b0ff' +on-primary-container: '#004060' +inverse-primary: '#8dcdff' +secondary: '#006b5f' +on-secondary: '#ffffff' +secondary-container: '#6cf9e4' +on-secondary-container: '#007165' +tertiary: '#585d79' +on-tertiary: '#ffffff' +tertiary-container: '#a0a5c4' +on-tertiary-container: '#363a55' +error: '#F00505' +on-error: '#ffffff' +error-container: '#ffdad6' +on-error-container: '#93000a' +primary-fixed: '#cae6ff' +primary-fixed-dim: '#8dcdff' +on-primary-fixed: '#001e30' +on-primary-fixed-variant: '#004b70' +secondary-fixed: '#6cf9e4' +secondary-fixed-dim: '#49dcc8' +on-secondary-fixed: '#00201c' +on-secondary-fixed-variant: '#005047' +tertiary-fixed: '#dde1ff' +tertiary-fixed-dim: '#c0c5e5' +on-tertiary-fixed: '#151a33' +on-tertiary-fixed-variant: '#404560' +background: '#f8f9fb' +on-background: '#191c1e' +surface-variant: '#e0e3e5' +surface-alt: '#EDF5FC' +text-primary: '#0D182D' +text-secondary: '#4F606F' +text-muted: '#8F9BB3' +border: '#D0D9E4' +success: '#13C636' +warning: '#FFB020' +typography: +headline-xl: +fontFamily: DM Sans +fontSize: 40px +fontWeight: '700' +lineHeight: 48px +letterSpacing: -0.02em +headline-lg: +fontFamily: DM Sans +fontSize: 32px +fontWeight: '700' +lineHeight: 40px +letterSpacing: -0.01em +headline-md: +fontFamily: DM Sans +fontSize: 24px +fontWeight: '700' +lineHeight: 32px +headline-sm: +fontFamily: DM Sans +fontSize: 20px +fontWeight: '700' +lineHeight: 28px +body-lg: +fontFamily: DM Sans +fontSize: 18px +fontWeight: '400' +lineHeight: 28px +body-md: +fontFamily: DM Sans +fontSize: 16px +fontWeight: '400' +lineHeight: 24px +label-md: +fontFamily: DM Sans +fontSize: 14px +fontWeight: '500' +lineHeight: 20px +label-sm: +fontFamily: DM Sans +fontSize: 12px +fontWeight: '600' +lineHeight: 16px +letterSpacing: 0.02em +headline-lg-mobile: +fontFamily: DM Sans +fontSize: 28px +fontWeight: '700' +lineHeight: 36px +rounded: +sm: 0.25rem +DEFAULT: 0.5rem +md: 0.75rem +lg: 1rem +xl: 1.5rem +full: 9999px +spacing: +gap-xs: 4px +gap-sm: 8px +gap-md: 16px +gap-lg: 24px +gap-xl: 32px +margin-page: 40px +max-width-content: 1200px + +--- + +## Brand & Style + +The design system is rooted in a **Modern / Corporate** aesthetic that leans into "Civic Tech" — a blend of operational efficiency and public accessibility. It prioritizes clarity, trust, and inclusivity, ensuring that governance participation feels like a meaningful public service rather than a complex financial transaction. + +The visual language uses expansive white space, a bright and optimistic primary blue, and structured information density to evoke a sense of organized accountability. It avoids the dark, high-contrast, or "neon" tropes of traditional DeFi, opting instead for a "SaaS-lite" feel that is friendly to non-technical community members. + +## Colors + +The palette is anchored by **Vibrant Blue (#00B0FF)**, used exclusively for primary actions, progress indicators, and active navigation states. To ensure a clean, "civic" atmosphere, the system utilizes a tiered background strategy: pure white surfaces for cards and content containers, and a light blue-tinted secondary background for sidebars and informational panels. + +- **Primary:** High-energy blue for "Claim," "Vote," and "Submit." +- **Functional Accents:** Green is reserved for success and positive participation signals; Red is used sparingly for ineligible states or critical errors. +- **Surface Alt:** The specific light blue tint (#EDF5FC) provides subtle depth and distinguishes between the main reading area and supporting operational tools. + +## Typography + +The design system exclusively uses **DM Sans**. This choice provides a geometric, modern, and highly legible foundation that works across both dense data tables and long-form proposal descriptions. + +- **Hierarchy:** Use bold weights (700) for all headlines to maintain a strong "editorial" feel for governance titles. +- **Labels:** Use medium (500) and semi-bold (600) weights for labels and metadata to ensure they remain distinct from body copy. +- **Responsive:** Headlines scale down on mobile to prevent awkward line breaks in long proposal titles while maintaining their relative visual weight. + +## Layout & Spacing + +The layout follows a **Fixed-Fluid Hybrid** model. The main content area is capped at a comfortable reading width, while the background and header extend to the edge of the viewport. + +- **Grid Model:** A 12-column grid is used for desktop. +- **The "Two-Thirds" Rule:** Active governance pages use a primary 8-column main area for proposal content and a 4-column sidebar for "Action & Stats" (voter eligibility, deadline countdown, etc.). +- **Spacing Rhythm:** Use 24px and 32px for major sectional gaps. 16px is the standard for internal card padding and between related UI elements like input fields and their labels. +- **Mobile Reflow:** The sidebar stacks below the main content, with the "Action CTA" pinned to a bottom sticky bar to ensure the voting trigger is always accessible. + +## Elevation & Depth + +This design system uses **Tonal Layering** supplemented by extremely soft, low-contrast shadows. + +- **Base Layer:** The Neutral background (#F6F8FA) serves as the canvas. +- **Secondary Layer:** Sidebars and supporting panels use Surface Alt (#EDF5FC) to create structural zones without the use of heavy borders. +- **Primary Elevation:** Cards and Modals use Surface white (#FFFFFF) with a very diffuse shadow (e.g., `0px 4px 20px rgba(0, 176, 255, 0.05)`). Note the subtle blue tint in the shadow to maintain color harmony. +- **Interactive States:** Buttons and interactive cards may lift slightly on hover, increasing the shadow spread by 4-8px. + +## Shapes + +The shape language is friendly but disciplined. Rounded corners are used to soften the "institutional" feel of governance. + +- **Cards & Containers:** Use a consistent 12px (`rounded-lg`) radius. +- **Primary Buttons:** Use a more pronounced 20px radius to make them stand out as the primary interactive touchpoints. +- **Pills & Tags:** Status chips (e.g., "Active," "Passed") use a full pill radius for immediate visual categorization as non-interactive status markers. +- **Inputs:** Follow the standard 8px-12px radius to match container styles. + +## Components + +### Buttons & Chips + +- **Primary Button:** Solid #00B0FF with white text. 20px roundedness. Bold DM Sans. +- **Secondary Button:** Ghost or outlined using #00B0FF, or a solid tint using #EDF5FC with blue text. +- **Status Chips:** Full pill radius. Use Success, Warning, or Error colors at 10-15% opacity with high-contrast text of the same hue. + +### Governance Specifics + +- **Proposal Hero:** Uses the H1 typography level. Includes a metadata row directly beneath (Date, Author, Status) separated by dot dividers. +- **Voting Steppers:** Clean numeric input fields framed by plus/minus buttons. Use Surface Alt for the background of the stepper unit to distinguish it from the card. +- **Eligibility Banner:** A full-width component at the top of the action sidebar. If ineligible, it uses a Warning background; if eligible, it uses a soft Secondary (#1FC2AF) tint. +- **Results Progress Bars:** Thick, 12px height bars with rounded caps. The primary "Winning" option uses the Primary Blue, while others use Text Muted or Border colors to create clear hierarchy. + +### Inputs & Cards + +- **Input Fields:** 1px border (#D0D9E4), turning Primary Blue on focus. Labels always sit above the field in Label-sm typography. +- **Member Cards:** Minimalist blocks with 12px radius. Prioritize the user's avatar and "Points" count as the primary visual anchors. diff --git a/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md b/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md new file mode 100644 index 0000000..4bd4699 --- /dev/null +++ b/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md @@ -0,0 +1,42 @@ +# Merged Light Preset Gaps + +This note compares `local-reference-assets/governance-ui/DESIGN.md` against the current merged light path in `packages/ui/src/presets.ts`. + +The goal is not to restate everything that is already implemented. The goal is to call out the parts of the governance design direction that are still only partially represented, approximated, or not yet encoded as first-class preset values. + +## What the merged light preset already reflects + +- `background`, `surface`, `surfaceAlt`, `border`, `primary`, `success`, `warning`, and the primary text colors are broadly aligned to the civic palette. +- `DM Sans` is already represented in the governance preset typography direction. +- The general visual language is present: white cards, pale-blue secondary surfaces, soft shadows, and a blue primary action. +- The current `ClaimWidget` screenshot path now renders in a light civic direction rather than the legacy dark wallet look. + +## Likely missing or only partially encoded + +- The full surface ladder from `DESIGN.md` is not yet encoded as distinct preset values. +- `surface-dim`, `surface-bright`, `surface-container-lowest`, `surface-container-low`, `surface-container`, `surface-container-high`, and `surface-container-highest` are still mostly collapsed into a smaller set of equivalent light surfaces. +- The semantic `on-*` palette is not fully represented as theme-level tokens. +- `on-surface`, `on-surface-variant`, `on-primary`, `on-primary-container`, `on-secondary`, `on-secondary-container`, `on-tertiary`, `on-tertiary-container`, `on-error`, and the fixed-color variants are not fully modeled as first-class preset keys. +- `inverse-surface`, `inverse-on-surface`, and `inverse-primary` are not yet explicitly encoded in the merged light branch. +- `secondary-container`, `tertiary`, `tertiary-container`, `error-container`, and the fixed accent colors from the design reference are not mapped as a complete semantic system. +- `text-primary`, `text-secondary`, and `text-muted` exist conceptually in the merged light direction, but the preset still does not expose every naming variant from the design reference as a dedicated semantic alias. +- The merged preset still uses a smaller typography contract than the design document’s full role set. +- The design reference defines `headline-xl`, `headline-lg`, `headline-md`, `headline-sm`, `body-lg`, `body-md`, `label-md`, `label-sm`, and a mobile headline variant, while the merged preset currently exposes only the base body and heading scales. +- The design reference’s spacing language is more explicit than the current preset contract. +- `gap-xs`, `gap-sm`, `gap-md`, `gap-lg`, `gap-xl`, and `margin-page` are not exposed as named design tokens. +- `max-width-content: 1200px` from the design reference is not mirrored as a dedicated governance-specific content width token. +- The shape system is only partially aligned. +- `rounded-sm`, `rounded`, `rounded-md`, `rounded-lg`, `rounded-xl`, and `full` are not modeled as the full governance shape vocabulary from the design reference. +- Component-specific design directions like proposal hero layout, voting steppers, eligibility banners, and results progress bars are still widget-structure guidance rather than preset-level primitives. + +## Interpretation + +- The merged light preset is good enough for current widget rendering and review. +- It is not yet a complete mechanical transcription of `DESIGN.md`. +- In practice, that means the governance preset is still a useful reference target even if the merged preset now covers the main visual path. +- The remaining work is mostly about semantic completeness, not about redoing the current screenshot path. + +## Suggested follow-up + +- Add the missing semantic surface aliases only if current or upcoming widgets actually consume them. +- Keep the design document as the source of truth for brand direction, and use this gap list as the implementation checklist for the preset contract. diff --git a/docs/contributor-bounty-template.md b/docs/contributor-bounty-template.md new file mode 100644 index 0000000..40b6c47 --- /dev/null +++ b/docs/contributor-bounty-template.md @@ -0,0 +1,77 @@ +# Contributor Bounty Template + +Use this when an AI-generated PR already exists and a contributor needs to make it +production-ready before human review. + +Contributor role reference: +https://docs.gooddollar.org/for-developers/contributing/open-source-contributors/contributor-role + +````md +# [GoodBounty] Finalize PR + +## Summary + +Pick up the existing AI-generated PR for ``, run it locally, +fix implementation gaps, update tests/evidence, and prepare it for human review + +## Contributor task + +- Claim the bounty with an ETA. +- Check out the PR branch and run it locally. +- Compare the implementation against the parent issue, plan issue, and repo docs. +- Fix concrete gaps in behavior, tests, code quality, or UI. +- Update screenshots/videos when UI changes. +- Leave a handoff comment with what changed, what was tested, and remaining risks. + +## Scope checks + +_Breakdown and update with specifics towards a feature and execution plan_ + +- [ ] The PR solves the issue requirements. +- [ ] The implementation follows the plan or explains any deviation. +- [ ] Existing repo patterns and package boundaries are respected. +- [ ] Main happy path and important edge/error states work. +- [ ] Storybook covers the expected states. +- [ ] Playwright covers the main flows and includes current screenshots. +- [ ] Desktop and mobile layouts are usable. +- [ ] The PR description links the source issues and includes test evidence. + +## Required commands + +```sh +pnpm install +pnpm build +pnpm lint +pnpm test:demo tests/widgets/ +``` +```` + +If a command cannot be run, document why. + +## Handoff comment + +```md +Contributor: @ +PR: # +Commit: + +Fixed: + +- + +Verified: + +- : + +Evidence: + +- + +Remaining risks: + +- +``` + +``` + +``` diff --git a/docs/demo-environment.md b/docs/demo-environment.md index e24b614..5685f25 100644 --- a/docs/demo-environment.md +++ b/docs/demo-environment.md @@ -4,6 +4,9 @@ This document describes the GoodWidget demo and documentation environment — a Storybook-first setup in `examples/storybook/` that serves as the canonical review environment for GoodWidget UI primitives and widget flows. +For the reviewer workflow, fixture meanings, and a short reporting template, see +[`docs/qa-guide.md`](qa-guide.md). + --- ## Quick start @@ -84,8 +87,11 @@ pnpm storybook & pnpm test:demo ``` -Test artifacts (screenshots, traces, optional video) are written to -`test-results/` (gitignored) and an HTML report to `playwright-report/`. +Test artifacts are split as follows: + +- `test-results/` (gitignored): Playwright traces, videos, and runner attachments. +- `tests/design-system/test-results/`: design-system smoke screenshots. +- `tests/widgets//test-results/`: widget screenshot evidence and debug screenshots. To inspect a trace after a failure: @@ -136,7 +142,7 @@ The mock is passed as the `provider` prop to `ClaimWidget`. 3. Set `title: 'Primitives/MyComponent'` in the meta. 4. Add `tags: ['autodocs']` for automatic docs generation. 5. Add a `data-testid="MyComponent-default"` to the primary rendered element. -6. Add a smoke test case in `tests/demo/smoke.spec.ts`. +6. Add a smoke test case in `tests/design-system/smoke.spec.ts`. Example skeleton: @@ -167,7 +173,7 @@ export const Default: Story = { 2. Set `title: 'Widgets/MyWidget'` in the meta. 3. Import `createMockEip1193Provider` from `../fixtures/mockEip1193` if wallet context is needed. 4. Pass the mock provider to your widget. -5. Add a smoke test case in `tests/demo/smoke.spec.ts`. +5. Add a widget test under `tests/widgets//` (and update `tests/design-system/smoke.spec.ts` only if shared design-system coverage also changed). --- @@ -229,4 +235,3 @@ The Storybook package (`examples/storybook`) deliberately does **not** change It is a consumer of those packages, not a contributor to their design system. See [ARCHITECTURE.md](../ARCHITECTURE.md) for the full GoodWidget architecture. - diff --git a/docs/qa-guide.md b/docs/qa-guide.md new file mode 100644 index 0000000..0037dee --- /dev/null +++ b/docs/qa-guide.md @@ -0,0 +1,111 @@ +# GoodWidget QA Guide + +Use this guide for pre-deployment QA with Storybook being the main review environment. +Follow the [QA-Checklist](#qa-checklist) + +## Setup + +Make sure you are on the branch where you are QA'ing for. +Most of the time this is the branch where a pull-request is created on. +If not, a specific branch will be defined by the QA bounty + +```sh +pnpm install +pnpm build +pnpm storybook +``` + +Open `http://localhost:6006`. + +If you need to refresh the automated evidence: + +```sh +pnpm test:storybook +pnpm test:demo +``` + +pnpm test:demo tests <-- run all tests +pnpm test:demo tests/design-system <-- run all design system, primitives and theme tests +pnpm test:demo tests/widgets <-- run all widgets tests +pnpm test:demo tests/widgets/ <-- only run tests for a specific widget. + +## Storybook Structure + +- `examples/storybook/src/stories/design-system/`: design-system primitives and theme examples. +- `examples/storybook/src/stories//`: widget stories. + +### Fixture / State Meanings + +- `InjectedWallet`: uses the browser wallet (`window.ethereum`). Use this to verify real injected-wallet behavior in the browser, human-testing. +- `CustodialLocalFixture`: uses the local test private key fixture. Use this for deterministic manual QA and Playwright coverage. + +### Design-system stories + +- `Primitives/*`: verify layout, text, spacing, and visible interactions. +- `Theme/ThemePlayground`: verify overrides still apply correctly and do not break readability or hierarchy. + +## Playwright Test Structure Alignment + +- `tests/design-system/`: tests for stories under `stories/design-system/`. +- `tests/widgets//`: tests for stories under `stories//`. +- Screenshot evidence files: +- `tests/design-system/test-results/` +- `tests/widgets//test-results/` + +## QA Checklist + +When applying for a QA bounty, make sure the bounty includes well defined test cases/states and flows. +if any of them are not clear, please discuss and ask questions before starting testing. + +The expected test flow: + +1. Open the PR. +2. Read the issue / acceptance criteria. +3. Confirm the PR includes the latest Playwright screenshots. +4. Confirm the PR includes playwright tests when new widgets are added, or existing widgets are updated with new state and flows. + +Automated screenshots alone are just evidence, not a replacement for manual checking. +Therefor, we expect QA as part of the bounty to: + +5. load locally the branch of the pull-request and run storybook to test the widget/states and flows. +6. once everything is manually confirmed verify if the tests include accurately the expected flows and states +7. QA role is not fixing things, its producing a report on your findings. [QA Report Template](#qa-report-template) + +For manual testing verify sure each changed story/widget: + +- Confirm the story opens without console/runtime errors. +- Compare the rendered state to the PR screenshots. +- Verify the expected CTA, copy, and visible state. +- Check basic layout at a narrow (mobile) width and a normal desktop width. +- If the story is interactive, follow the primary action flow and test the different states and confirm the result is sensible. +- Note anything that is visually broken, misleading, stuck, or inconsistent with the PR evidence or original bounty specification. + +## QA Report Template + +PR: +Tester: +Date: + +### Findings + +- **QA Report**\ + Env: \[Browser / OS / Wallet / Network]\ + Branch: \[name or commit] +- **Tests** + Scenario's to test are provided by the QA bounty instructions. + Any additional flows that you think are missing, or bugs you find outside of the scenarios described. + please follow the [QA bug-report](https://docs.gooddollar.org/for-developers/contributing/open-source1-contributors/qa-role#filing-a-bug-report) + +``` +| Scenario | Expected | Actual | ✅/❌ | +| -------- | -------- | ------ | --- | +| | | | | +``` + +- **Bugs**: \[# / None] +- **Verdict**: Pass / Fail / Minor issues +- **Evidence**: \[links,screenshots,screen-recordings etc] + +``` + +``` diff --git a/examples/expo/app/_layout.tsx b/examples/expo/app/_layout.tsx index 830555a..aa3e7ce 100644 --- a/examples/expo/app/_layout.tsx +++ b/examples/expo/app/_layout.tsx @@ -10,7 +10,7 @@ import { GoodWidgetProvider } from '@goodwidget/core' */ export default function RootLayout() { return ( - + ) diff --git a/examples/react-web/src/App.tsx b/examples/react-web/src/App.tsx index 39d93fa..1e87794 100644 --- a/examples/react-web/src/App.tsx +++ b/examples/react-web/src/App.tsx @@ -506,7 +506,7 @@ function OverrideShowcase() { export function App() { return ( - + ) diff --git a/examples/storybook/.storybook/preview.tsx b/examples/storybook/.storybook/preview.tsx index 5a2d747..e4ea369 100644 --- a/examples/storybook/.storybook/preview.tsx +++ b/examples/storybook/.storybook/preview.tsx @@ -5,8 +5,15 @@ import React from 'react' import type { Preview } from '@storybook/react' import { GoodWidgetProvider } from '@goodwidget/core' +import type { GoodWidgetConfig } from '@goodwidget/ui' import { MiniAppShell } from '@goodwidget/ui' +interface StoryGoodWidgetParameters { + config?: GoodWidgetConfig + defaultTheme?: 'light' | 'dark' + useShell?: boolean +} + const preview: Preview = { parameters: { layout: 'centered', @@ -16,13 +23,22 @@ const preview: Preview = { }, }, decorators: [ - (Story) => ( - - - - - - ), + (Story, context) => { + const params = (context.parameters.goodWidgetProvider ?? {}) as StoryGoodWidgetParameters + const story = + + return ( + + {params.useShell === false ? ( + story + ) : ( + + {story} + + )} + + ) + }, ], } diff --git a/examples/storybook/.storybook/test-runner.ts b/examples/storybook/.storybook/test-runner.ts new file mode 100644 index 0000000..a16e989 --- /dev/null +++ b/examples/storybook/.storybook/test-runner.ts @@ -0,0 +1,11 @@ +import type { TestRunnerConfig } from '@storybook/test-runner' + +const config: TestRunnerConfig = { + setup() { + // Increase Jest timeout from the default 15000ms to 60000ms + // to prevent cold-start compilation timeouts in CI/test runner. + jest.setTimeout(60000) + }, +} + +export default config diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 044f9aa..a3d0d37 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -12,9 +12,12 @@ "@goodwidget/core": "workspace:*", "@goodwidget/ui": "workspace:*", "@goodwidget/claim-widget-theme-demo": "workspace:*", + "@goodwidget/citizen-claim-widget": "workspace:*", + "@goodwidget/goodreserve-widget": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-native-web": "^0.19.13" + "react-native-web": "^0.19.13", + "viem": "^2.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.6.17", diff --git a/examples/storybook/src/fixtures/goodReserveSdkFake.ts b/examples/storybook/src/fixtures/goodReserveSdkFake.ts new file mode 100644 index 0000000..e24be1e --- /dev/null +++ b/examples/storybook/src/fixtures/goodReserveSdkFake.ts @@ -0,0 +1,147 @@ +import type { + GoodReserveSDKConstructor, + GoodReserveSDKLike, + ReserveStats, + ReserveTransactionResult, +} from '@goodwidget/goodreserve-widget' +import type { EIP1193Provider } from '@goodwidget/core' + +// --------------------------------------------------------------------------- +// Deterministic, CI-safe test doubles for the GoodReserve flow. +// +// These exercise the REAL adapter (quote → confirm → buy/sell → success) via +// the SDK injection seam, with no published @goodsdks/good-reserve package and +// no live RPC. The fake encodes the verified PR #35 contract: getBuyQuote/ +// getSellQuote arg order, the buy(tokenIn, amountIn, minReturn, onHash) / +// sell(sellTo, gdAmount, minReturn, onHash) signatures, and the +// { hash, receipt } result shape. +// --------------------------------------------------------------------------- + +const STABLE_TOKEN = '0x765DE816845861e75A25fCA122bb6898B8B1282a' as const +const GD_TOKEN = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' as const + +// erc20 balanceOf(account) selector + a fixed balance, so the adapter's +// balance reads resolve deterministically through eth_call. +const BALANCE_OF_SELECTOR = '0x70a08231' +// 120 * 1e18 (stable, 18 decimals) — comfortably above the 25 test input. +const STABLE_BALANCE_WEI = (120n * 10n ** 18n).toString(16).padStart(64, '0') + +const SUBMITTED_HASH = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as const + +/** Fake GoodReserveSDK implementing the PR #35 public surface deterministically. */ +export const FakeGoodReserveSDK: GoodReserveSDKConstructor = class FakeGoodReserveSDK + implements GoodReserveSDKLike +{ + // Matches the real `new GoodReserveSDK(publicClient, walletClient, env)`. + constructor( + _publicClient: unknown, + _walletClient: unknown, + _env: 'production' | 'development', + ) {} + + getStableTokenAddress(): `0x${string}` { + return STABLE_TOKEN + } + + getGoodDollarAddress(): `0x${string}` { + return GD_TOKEN + } + + async getReserveStats(): Promise { + return { + goodDollarTotalSupply: 0n, + stableTokenDecimals: 18, + goodDollarDecimals: 2, + poolReserveBalance: null, + poolTokenSupply: null, + reserveRatio: 500_000, // PPM → 50% + exitContribution: 5_000, // PPM → 0.50% (verifies the C1 scaling fix) + } + } + + // getBuyQuote(tokenIn, amountIn) → G$ out. ~4.33 G$ per stable for the demo. + async getBuyQuote(_tokenIn: `0x${string}`, amountIn: bigint): Promise { + // amountIn is 18-dec stable; output is 2-dec G$. 25e18 → 10825 (108.25 G$). + return (amountIn * 433n) / 10n ** 18n + } + + // getSellQuote(gdAmount, sellTo) → stable out. + async getSellQuote(gdAmount: bigint, _sellTo: `0x${string}`): Promise { + // gdAmount is 2-dec G$; output is 18-dec stable. + return (gdAmount * 10n ** 18n) / 433n + } + + async buy( + _tokenIn: `0x${string}`, + _amountIn: bigint, + _minReturn: bigint, + onHash?: (hash: `0x${string}`) => void, + ): Promise { + onHash?.(SUBMITTED_HASH) + return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } } + } + + async sell( + _sellTo: `0x${string}`, + _gdAmount: bigint, + _minReturn: bigint, + onHash?: (hash: `0x${string}`) => void, + ): Promise { + onHash?.(SUBMITTED_HASH) + return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } } + } +} + +type EventCallback = (...args: unknown[]) => void + +/** + * Minimal deterministic EIP-1193 provider for the live-adapter story. Reports a + * connected account on Celo and answers eth_call balanceOf with a fixed balance. + * No network access — every method resolves locally. + */ +export function createReserveTestProvider( + account = '0x1111111111111111111111111111111111111111', + chainId = 42220, +): EIP1193Provider { + let activeChainId = chainId + const listeners: Record = {} + + return { + on(event: string, fn: EventCallback) { + ;(listeners[event] ??= []).push(fn) + }, + removeListener(event: string, fn: EventCallback) { + listeners[event] = (listeners[event] ?? []).filter((cb) => cb !== fn) + }, + async request({ method, params }: { method: string; params?: unknown[] }): Promise { + switch (method) { + case 'eth_accounts': + case 'eth_requestAccounts': + return [account] + case 'eth_chainId': + return `0x${activeChainId.toString(16)}` + case 'net_version': + return String(activeChainId) + case 'wallet_switchEthereumChain': { + const requested = (params?.[0] as { chainId?: string } | undefined)?.chainId + if (requested) { + activeChainId = Number.parseInt(requested, 16) + for (const cb of listeners['chainChanged'] ?? []) cb(requested) + } + return null + } + case 'eth_call': { + const call = (params?.[0] as { data?: string } | undefined) ?? {} + // balanceOf(account) → fixed balance; anything else → 0. + if (call.data?.startsWith(BALANCE_OF_SELECTOR)) { + return `0x${STABLE_BALANCE_WEI}` + } + return `0x${''.padStart(64, '0')}` + } + default: + return null + } + }, + } as EIP1193Provider +} diff --git a/examples/storybook/src/fixtures/goodReserveWidgetMock.ts b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts new file mode 100644 index 0000000..b16ade1 --- /dev/null +++ b/examples/storybook/src/fixtures/goodReserveWidgetMock.ts @@ -0,0 +1,168 @@ +import type { ReserveSwapWidgetAdapterState } from '@goodwidget/goodreserve-widget' + +// Deterministic reserve widget state fixtures used by Storybook and CI tests. +export const reserveWidgetMockStates: Record> = { + noProvider: { + status: 'no_provider', + hasProvider: false, + chainId: null, + address: null, + }, + unsupportedChain: { + status: 'unsupported_chain', + hasProvider: true, + chainId: 8453, + address: '0x1111111111111111111111111111111111111111', + }, + sdkInitializing: { + status: 'sdk_initializing', + hasProvider: true, + chainId: 42220, + address: '0x1111111111111111111111111111111111111111', + }, + idleBuy: { + status: 'idle', + chainId: 42220, + address: '0x1111111111111111111111111111111111111111', + hasProvider: true, + tokenInSymbol: 'USDm', + tokenOutSymbol: 'G$', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + inputAmount: '', + direction: 'buy', + }, + amountEditing: { + status: 'amount_editing', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + }, + quoteLoading: { + status: 'quote_loading', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + tokenOutBalance: '10340.22', + }, + quoteReady: { + status: 'quote_ready', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + tokenInBalance: '120.00', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, + quoteError: { + status: 'quote_error', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + error: 'Reserve quote failed. Try again in a moment.', + }, + insufficientBalance: { + status: 'insufficient_balance', + chainId: 42220, + hasProvider: true, + inputAmount: '9999', + tokenInBalance: '120.00', + warning: 'Input exceeds your available token balance.', + }, + slippageSelection: { + status: 'slippage_selection', + chainId: 42220, + hasProvider: true, + slippagePercent: 0.5, + }, + confirmDialog: { + status: 'confirm_dialog', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, + swapPending: { + status: 'swap_pending', + chainId: 42220, + hasProvider: true, + inputAmount: '25', + quote: { + outputAmount: '108.2500', + price: '0.2310', + minimumReceived: '108.1417', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, + swapSuccess: { + status: 'swap_success', + chainId: 42220, + hasProvider: true, + tokenOutSymbol: 'G$', + tokenOutBalance: '12,500', + // Post-swap reality: quote is cleared and the received amount is preserved + // in lastSwapOutput (distinct from the wallet balance). + lastSwapOutput: '10,230', + quote: null, + txHash: '0xabc1230000000000000000000000000000000000000000000000000000000000', + }, + swapError: { + status: 'swap_error', + chainId: 42220, + hasProvider: true, + error: 'Swap reverted due to reserve limits.', + }, + sellQuoteReady: { + status: 'quote_ready', + chainId: 42220, + hasProvider: true, + direction: 'sell', + tokenInSymbol: 'G$', + tokenOutSymbol: 'USDm', + tokenInBalance: '300.00', + tokenOutBalance: '84.00', + inputAmount: '40', + quote: { + outputAmount: '8.9231', + price: '4.4820', + minimumReceived: '8.9142', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, + // Buy-ready state on XDC (chain 50) — exercises the dynamic network label and + // the USDC stable-token symbol used on XDC. + xdcQuoteReady: { + status: 'quote_ready', + chainId: 50, + hasProvider: true, + direction: 'buy', + tokenInSymbol: 'USDC', + tokenOutSymbol: 'G$', + tokenInBalance: '500.00', + tokenOutBalance: '0.00', + inputAmount: '50', + quote: { + outputAmount: '216.5000', + price: '0.2310', + minimumReceived: '216.2835', + priceImpactPercent: 'N/A', + exitContributionPercent: '0%', + }, + }, +} diff --git a/examples/storybook/src/fixtures/mockEip1193.ts b/examples/storybook/src/fixtures/mockEip1193.ts new file mode 100644 index 0000000..4aa9a23 --- /dev/null +++ b/examples/storybook/src/fixtures/mockEip1193.ts @@ -0,0 +1,85 @@ +/** + * Mock EIP-1193 provider for demo and Playwright testing purposes. + * + * This provider simulates a connected wallet with a stable, deterministic + * address and chain ID so demo pages render wallet-aware components in a + * "connected" state without requiring a real browser wallet. + * + * It is ONLY used in the storybook demo (`examples/storybook/src/fixtures/`). + * It does NOT simulate real transaction signing or RPC calls. + * + * Stable demo values: + * address : 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 (well-known demo address) + * chainId : 42220 (Celo mainnet — GoodDollar's primary chain) + */ + +/** The deterministic demo wallet address. */ +export const MOCK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + +/** The deterministic demo chain ID (Celo mainnet). */ +export const MOCK_CHAIN_ID = 42220 + +// Converts a number to a 0x-prefixed hex string as required by the EIP-1193 spec. +function toHex(n: number): string { + return '0x' + n.toString(16) +} + +type EventCallback = (...args: unknown[]) => void + +/** + * Creates a minimal EIP-1193-compatible provider that always reports the + * demo address and chain. Used to pass as the `provider` prop to + * `GoodWidgetProvider` so wallet-aware components render in a connected state. + * + * @example + * ```tsx + * import { createMockEip1193Provider } from '../fixtures/mockEip1193' + * + * + * + * + * ``` + */ +export function createMockEip1193Provider() { + // Internal event-listener registry (mimics EventEmitter interface on providers) + const listeners: Record = {} + + return { + // ------------------------------------------------------------------ events + on(event: string, fn: EventCallback) { + if (!listeners[event]) listeners[event] = [] + listeners[event].push(fn) + }, + + removeListener(event: string, fn: EventCallback) { + if (!listeners[event]) return + listeners[event] = listeners[event].filter((cb) => cb !== fn) + }, + + // ----------------------------------------------------------------- request + /** + * Handles a subset of EIP-1193 JSON-RPC methods needed to put the app + * in a "wallet connected" state. Unsupported methods reject so the + * provider behaves honestly rather than silently swallowing errors. + */ + async request({ method }: { method: string }): Promise { + switch (method) { + // Return the single mock account + case 'eth_accounts': + case 'eth_requestAccounts': + return [MOCK_ADDRESS] + + // Return the chain ID in 0x-hex format (EIP-1193 spec) + case 'eth_chainId': + return toHex(MOCK_CHAIN_ID) + + // net_version returns chain ID as a decimal string + case 'net_version': + return String(MOCK_CHAIN_ID) + + default: + throw new Error(`Mock provider: unsupported method "${method}"`) + } + }, + } +} diff --git a/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidget.stories.tsx b/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidget.stories.tsx new file mode 100644 index 0000000..de60e31 --- /dev/null +++ b/examples/storybook/src/stories/citizen-claim-widget/CitizenClaimWidget.stories.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, WidgetTabs, YStack } from '@goodwidget/ui' +import { CitizenClaimWidget } from '@goodwidget/citizen-claim-widget' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' + +type CitizenClaimTab = 'claim' | 'invite-rewards' | 'news-feed' + +function CitizenClaimWidgetStoryShell({ + provider, + dataTestId, +}: { + provider: unknown + dataTestId: string +}) { + const [activeTab, setActiveTab] = useState('claim') + const [activeChainId, setActiveChainId] = useState(null) + + useEffect(() => { + const eip1193Provider = provider as { + request?: (args: { method: string }) => Promise + on?: (event: string, listener: (value: unknown) => void) => void + removeListener?: (event: string, listener: (value: unknown) => void) => void + } | null + + if (!eip1193Provider?.request) return + + const syncChain = async () => { + const hex = (await eip1193Provider.request?.({ method: 'eth_chainId' })) as string + if (typeof hex === 'string') setActiveChainId(parseInt(hex, 16)) + } + + const onChainChanged = (hex: unknown) => { + if (typeof hex === 'string') setActiveChainId(parseInt(hex, 16)) + } + + void syncChain() + eip1193Provider.on?.('chainChanged', onChainChanged) + return () => eip1193Provider.removeListener?.('chainChanged', onChainChanged) + }, [provider]) + + return ( + + {/* ------------------------------------------------------------------ */} + {/* Header */} + {/* ------------------------------------------------------------------ */} + setActiveTab(tabId as CitizenClaimTab)} + chainId={activeChainId ?? 42220} + /> + + {activeTab === 'claim' ? ( + + ) : ( + + + Widget coming soon + + + )} + + ) +} + +const meta: Meta = { + title: 'Widgets/CitizenClaimWidget', + component: CitizenClaimWidget, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +function InjectedWalletStory() { + const injectedProvider = getInjectedEip1193Provider() + const usableProvider = isInjectedProviderUsable(injectedProvider) + + if (!usableProvider) { + return ( + + No injected wallet found + + Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh + Storybook. + + + ) + } + + return ( + + ) +} + +function CustodialLocalFixtureStory() { + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch (error: unknown) { + return ( + + Custodial fixture not configured + + {error instanceof Error + ? error.message + : 'Set a local private key in custodialEip1193.ts'} + + + ) + } +} + +export const InjectedWallet: Story = { + render: () => , +} + +export const CustodialLocalFixture: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/Card.stories.tsx b/examples/storybook/src/stories/design-system/Card.stories.tsx similarity index 100% rename from examples/storybook/src/stories/Card.stories.tsx rename to examples/storybook/src/stories/design-system/Card.stories.tsx diff --git a/examples/storybook/src/stories/ClaimWidget.stories.tsx b/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx similarity index 70% rename from examples/storybook/src/stories/ClaimWidget.stories.tsx rename to examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx index 9065e50..a9615a3 100644 --- a/examples/storybook/src/stories/ClaimWidget.stories.tsx +++ b/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx @@ -10,9 +10,10 @@ */ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { ClaimWidget } from '@goodwidget/claim-widget' -import { YStack } from '@goodwidget/ui' -import { createMockEip1193Provider } from '../fixtures/mockEip1193' +import { GoodWidgetProvider } from '@goodwidget/core' +import { ClaimWidget } from '@goodwidget/claim-widget-theme-demo' +import { MiniAppShell, YStack } from '@goodwidget/ui' +import { createMockEip1193Provider } from '../../fixtures/mockEip1193' // Stable mock provider — created once at module level to prevent re-render churn. const mockProvider = createMockEip1193Provider() @@ -54,10 +55,13 @@ const tealOverrides = { } const meta: Meta = { - title: 'Widgets/ClaimWidget', + title: 'Theme/ClaimWidgetThemeDemo-Light', component: ClaimWidget, tags: ['autodocs'], parameters: { layout: 'padded' }, + goodWidgetProvider: { + useShell: false, + }, } export default meta type Story = StoryObj @@ -65,26 +69,12 @@ type Story = StoryObj /** Default preset — no overrides, GoodWalletV2 baseline. */ export const Default: Story = { render: () => ( - - - - ), -} - -/** Cobalt brand — token + component theme overrides via themeOverrides. */ -export const CobaltBrand: Story = { - render: () => ( - - - - ), -} - -/** Teal brand — a different brand palette via themeOverrides. */ -export const TealBrand: Story = { - render: () => ( - - - + + + + + + + ), } diff --git a/examples/storybook/src/stories/Drawer.stories.tsx b/examples/storybook/src/stories/design-system/Drawer.stories.tsx similarity index 79% rename from examples/storybook/src/stories/Drawer.stories.tsx rename to examples/storybook/src/stories/design-system/Drawer.stories.tsx index 660bbf8..719be66 100644 --- a/examples/storybook/src/stories/Drawer.stories.tsx +++ b/examples/storybook/src/stories/design-system/Drawer.stories.tsx @@ -6,7 +6,7 @@ */ import React, { useState } from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { expect, within, userEvent } from '@storybook/test' +import { expect, within, userEvent, waitFor } from '@storybook/test' import { Drawer, Card, Heading, Text, Button, ButtonText, YStack } from '@goodwidget/ui' const meta: Meta = { @@ -45,7 +45,14 @@ export const Default: Story = { const canvas = within(canvasElement) const trigger = canvas.getByRole('button', { name: /open drawer/i }) await userEvent.click(trigger) - // After clicking, the Close button should appear inside the Drawer - await expect(canvas.getByRole('button', { name: /close/i })).toBeDefined() + // The Sheet renders into a portal at the document root, so query the whole + // document (not just canvasElement) and wait for the open animation. + const screen = within(canvasElement.ownerDocument.body) + await waitFor( + async () => { + await expect(screen.getByRole('button', { name: /close/i })).toBeVisible() + }, + { timeout: 5000 }, + ) }, } diff --git a/examples/storybook/src/stories/GlowCard.stories.tsx b/examples/storybook/src/stories/design-system/GlowCard.stories.tsx similarity index 100% rename from examples/storybook/src/stories/GlowCard.stories.tsx rename to examples/storybook/src/stories/design-system/GlowCard.stories.tsx diff --git a/examples/storybook/src/stories/ThemePlayground.stories.tsx b/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx similarity index 65% rename from examples/storybook/src/stories/ThemePlayground.stories.tsx rename to examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx index bb9d846..bfea555 100644 --- a/examples/storybook/src/stories/ThemePlayground.stories.tsx +++ b/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx @@ -2,20 +2,19 @@ * ThemePlayground — interactive exploration of GoodWidget's override system. * * This story group covers the four supported override layers in order of precedence: - * 1. Default preset (GoodWalletV2 — no overrides) + * 1. Default preset (GoodWalletV2 dark baseline — no overrides) * 2. Token overrides — broad brand/palette/scale changes * 3. Component sub-theme overrides — targeted named-component skinning * 4. Host themeOverrides — runtime integrator overrides (merged last) - * * Each story mounts a single ClaimWidget to avoid Tamagui theme-key clashing. * * See docs/demo-environment.md for the full override precedence model. */ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { ClaimWidget } from '@goodwidget/claim-widget' +import { ClaimWidget } from '@goodwidget/claim-widget-theme-demo' import { Card, Heading, Text, Alert, YStack } from '@goodwidget/ui' -import { createMockEip1193Provider } from '../fixtures/mockEip1193' +import { createMockEip1193Provider } from '../../fixtures/mockEip1193' const mockProvider = createMockEip1193Provider() @@ -28,7 +27,7 @@ export default meta type Story = StoryObj /** - * Default preset — the GoodWalletV2 base design system, no runtime overrides. + * Default preset — the GoodWalletV2 dark base design system, no runtime overrides. * This is what every widget instance looks like out of the box. */ export const DefaultPreset: Story = { @@ -37,9 +36,9 @@ export const DefaultPreset: Story = { - + ), } @@ -61,8 +60,8 @@ export const TokenOverride: Story = { /> How it works - - {` + {` How it works - - {` + {``} @@ -120,12 +120,13 @@ export const ComponentThemeOverride: Story = { @@ -150,8 +151,8 @@ export const HostOverrideCobalt: Story = { /> How it works - - {` + {` @@ -192,14 +196,17 @@ export const HostOverrideTeal: Story = { /> diff --git a/examples/storybook/src/stories/TokenAmount.stories.tsx b/examples/storybook/src/stories/design-system/TokenAmount.stories.tsx similarity index 100% rename from examples/storybook/src/stories/TokenAmount.stories.tsx rename to examples/storybook/src/stories/design-system/TokenAmount.stories.tsx diff --git a/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx b/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx new file mode 100644 index 0000000..54dd4c7 --- /dev/null +++ b/examples/storybook/src/stories/goodreserve-widget/GoodReserveWidget.stories.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { + GoodReserveWidget, + __setGoodReserveSdkConstructorForTesting, +} from '@goodwidget/goodreserve-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { reserveWidgetMockStates } from '../../fixtures/goodReserveWidgetMock' +import { + FakeGoodReserveSDK, + createReserveTestProvider, +} from '../../fixtures/goodReserveSdkFake' + +const provider = createCustodialEip1193Provider() + +const meta: Meta = { + title: 'Widgets/GoodReserveWidget', + component: GoodReserveWidget, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +// Renders one deterministic reserve state per story for CI-safe widget coverage. +const renderStory = (mockState: Story['args']['mockState'], dataTestId: string) => ( +
+ +
+) + +export const NoProvider: Story = { + render: () => renderStory(reserveWidgetMockStates.noProvider, 'GoodReserveWidget-no-provider'), +} + +export const SdkInitializing: Story = { + render: () => + renderStory(reserveWidgetMockStates.sdkInitializing, 'GoodReserveWidget-sdk-initializing'), +} + +export const UnsupportedChain: Story = { + render: () => + renderStory(reserveWidgetMockStates.unsupportedChain, 'GoodReserveWidget-unsupported-chain'), +} + +export const IdleBuy: Story = { + render: () => renderStory(reserveWidgetMockStates.idleBuy, 'GoodReserveWidget-idle-buy'), +} + +export const AmountEditing: Story = { + render: () => renderStory(reserveWidgetMockStates.amountEditing, 'GoodReserveWidget-amount-editing'), +} + +export const QuoteLoading: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteLoading, 'GoodReserveWidget-quote-loading'), +} + +export const QuoteReadyBuy: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteReady, 'GoodReserveWidget-quote-ready-buy'), +} + +export const QuoteReadySell: Story = { + render: () => + renderStory(reserveWidgetMockStates.sellQuoteReady, 'GoodReserveWidget-quote-ready-sell'), +} + +export const QuoteReadyXdc: Story = { + render: () => + renderStory(reserveWidgetMockStates.xdcQuoteReady, 'GoodReserveWidget-quote-ready-xdc'), +} + +export const QuoteError: Story = { + render: () => renderStory(reserveWidgetMockStates.quoteError, 'GoodReserveWidget-quote-error'), +} + +export const InsufficientBalance: Story = { + render: () => + renderStory(reserveWidgetMockStates.insufficientBalance, 'GoodReserveWidget-insufficient-balance'), +} + +export const SlippageSelection: Story = { + render: () => + renderStory(reserveWidgetMockStates.slippageSelection, 'GoodReserveWidget-slippage-selection'), +} + +export const ConfirmDialog: Story = { + render: () => renderStory(reserveWidgetMockStates.confirmDialog, 'GoodReserveWidget-confirm-dialog'), +} + +export const SwapPending: Story = { + render: () => renderStory(reserveWidgetMockStates.swapPending, 'GoodReserveWidget-swap-pending'), +} + +export const SwapSuccess: Story = { + render: () => renderStory(reserveWidgetMockStates.swapSuccess, 'GoodReserveWidget-swap-success'), +} + +export const SwapError: Story = { + render: () => renderStory(reserveWidgetMockStates.swapError, 'GoodReserveWidget-swap-error'), +} + +// Live adapter (no mockState) so the real amount-input wiring is exercised. +// Used by the Playwright "types into the input" coverage; the SDK is not +// available in CI so this lands in a quote/error state, but the controlled +// input must still accept typed characters. +export const Interactive: Story = { + render: () => ( +
+ +
+ ), +} + +// --------------------------------------------------------------------------- +// LiveFakeSdk — drives the FULL real adapter against a deterministic fake SDK +// (injected via the test seam) and a local EIP-1193 provider. No mockState, no +// published SDK, no live RPC. Playwright uses this to verify the real +// quote → confirm → buy → success transition (including the submitted tx hash +// from the onHash callback and the PPM exit-contribution scaling). +// --------------------------------------------------------------------------- +const liveProvider = createReserveTestProvider() + +// Sets the injected fake synchronously (before the child widget's effects run, +// so bootstrapSdk picks it up) and clears it on unmount, so the fake can never +// leak into other stories rendered later in the same Storybook session. +function LiveFakeSdkHarness() { + const injected = React.useRef(false) + if (!injected.current) { + __setGoodReserveSdkConstructorForTesting(FakeGoodReserveSDK) + injected.current = true + } + React.useEffect(() => { + return () => __setGoodReserveSdkConstructorForTesting(null) + }, []) + return ( +
+ +
+ ) +} + +export const LiveFakeSdk: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png new file mode 100644 index 0000000..01772ec Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/amount-editing.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png new file mode 100644 index 0000000..b51c915 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/confirm-dialog.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png new file mode 100644 index 0000000..2b88e14 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/idle-buy.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png new file mode 100644 index 0000000..4b9721d Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/insufficient-balance.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png new file mode 100644 index 0000000..403bf08 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/no-provider.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png new file mode 100644 index 0000000..1e29545 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-error.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png new file mode 100644 index 0000000..8c77e20 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-loading.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png new file mode 100644 index 0000000..2ff687a Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-buy.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png new file mode 100644 index 0000000..3bace56 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-sell.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-xdc.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-xdc.png new file mode 100644 index 0000000..fec6857 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/quote-ready-xdc.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.png new file mode 100644 index 0000000..16c8876 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/sdk-initializing.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png new file mode 100644 index 0000000..9f89e15 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/slippage-selection.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png new file mode 100644 index 0000000..3088efe Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-error.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png new file mode 100644 index 0000000..d07d0e1 Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-pending.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png new file mode 100644 index 0000000..205f3cb Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/swap-success.png differ diff --git a/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png b/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png new file mode 100644 index 0000000..0c78b0e Binary files /dev/null and b/examples/storybook/src/stories/goodreserve-widget/screenshots/unsupported-chain.png differ diff --git a/package.json b/package.json index 12b7f22..e1fcf3f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "storybook": "pnpm --filter @goodwidget/storybook storybook", "build-storybook": "pnpm --filter @goodwidget/storybook build-storybook", "test:storybook": "pnpm --filter @goodwidget/storybook test:storybook", - "test:demo": "playwright test tests/demo" + "test:demo": "playwright test" }, "devDependencies": { "@playwright/test": "^1.44.0", diff --git a/packages/citizen-claim-widget/package.json b/packages/citizen-claim-widget/package.json index d6cd3db..9a70ca7 100644 --- a/packages/citizen-claim-widget/package.json +++ b/packages/citizen-claim-widget/package.json @@ -1,6 +1,6 @@ { "name": "@goodwidget/citizen-claim-widget", - "version": "0.1.0", + "version": "0.2.0-beta", "description": "GoodWidget migration target for the GoodSDKs citizen claim flow", "type": "module", "main": "./dist/index.cjs", @@ -11,6 +11,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.cjs" } }, "scripts": { @@ -24,14 +34,17 @@ "react-dom": ">=18.0.0" }, "dependencies": { + "@goodsdks/citizen-sdk": "1.2.5", "@goodwidget/core": "workspace:*", - "@goodwidget/ui": "workspace:*" + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.0" }, "devDependencies": { - "react": "^18.3.0", - "react-dom": "^18.3.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", "tsup": "^8.4.0", "typescript": "^5.7.0" } diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx new file mode 100644 index 0000000..76de6de --- /dev/null +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -0,0 +1,559 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { + createComponent, + Card, + Heading, + Text, + Anchor, + Button, + ButtonFrame, + ButtonText, + TokenAmount, + Spinner, + Separator, + ToastContainer, + createToast, + updateToast, + XStack, + YStack, +} from '@goodwidget/ui' +import { SupportedChains } from '@goodsdks/citizen-sdk' +import { useCitizenClaimAdapter } from './adapter' +import type { + CitizenClaimWidgetProps, + CitizenClaimWidgetSuccessDetail, + CitizenClaimWidgetErrorDetail, + CitizenClaimWidgetEnvironment, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Named styled components — these participate in the component sub-theme system. +// Integrators can override light_ClaimCard, dark_ClaimCard etc. in themeOverrides. +// --------------------------------------------------------------------------- + +/** Primary card wrapping the claim amount and action button. */ +const ClaimCard = createComponent(Card, { + name: 'ClaimCard', + extends: 'Card', + borderRadius: '$4', + padding: '$4', +}) + +/** Circular action button that mirrors the GoodWalletV2 claim button design. */ +const ClaimActionButton = createComponent(ButtonFrame, { + name: 'ClaimActionButton', + extends: 'Button', + width: 160, + height: 160, + borderRadius: 9999, + backgroundColor: '$backgroundTransparent', + borderWidth: 0, + shadowOpacity: 0, + overflow: 'visible' as const, + position: 'relative' as const, + paddingHorizontal: 0, + hoverStyle: { backgroundColor: '$backgroundTransparent' }, + pressStyle: { backgroundColor: '$backgroundTransparent', opacity: 0.95 }, + focusStyle: { backgroundColor: '$backgroundTransparent', outlineStyle: 'none' }, +}) + +/** Blurred halo glow layer behind the action ring. */ +const ClaimActionGlow = createComponent(YStack, { + name: 'ClaimActionGlow', + position: 'absolute' as const, + top: -16, + right: -16, + bottom: -16, + left: -16, + borderRadius: 9999, + backgroundColor: '$primary', + opacity: 0.45, +}) + +/** Solid ring forming the outer rim of the action button. */ +const ClaimActionRing = createComponent(YStack, { + name: 'ClaimActionRing', + position: 'absolute' as const, + top: 0, + right: 0, + bottom: 0, + left: 0, + borderRadius: 9999, + backgroundColor: '$primary', +}) + +/** Dark inner circle inside the action ring. */ +const ClaimActionInner = createComponent(YStack, { + name: 'ClaimActionInner', + position: 'absolute' as const, + top: 2, + right: 2, + bottom: 2, + left: 2, + borderRadius: 9999, + backgroundColor: '$backgroundDark', +}) + +/** Vertical container for the per-chain claim breakdown area. */ +const ClaimChainBreakdown = createComponent(YStack, { + name: 'ClaimChainBreakdown', + alignItems: 'center' as const, + gap: '$2', +}) + +/** Row wrapping all chain entries (allows wrapping on smaller widths). */ +const ClaimChainList = createComponent(XStack, { + name: 'ClaimChainList', + flexWrap: 'wrap' as const, + justifyContent: 'center' as const, + alignItems: 'center' as const, + columnGap: '$2', + rowGap: '$1', + paddingHorizontal: '$4', +}) + +/** Single chain entry: amount + chain label (+ separator rendered externally). */ +const ClaimChainItem = createComponent(XStack, { + name: 'ClaimChainItem', + alignItems: 'center' as const, + gap: '$1', +}) + +/** Footer wrapper for daily claim stats block. */ +const ClaimDailyStats = createComponent(YStack, { + name: 'ClaimDailyStats', + alignItems: 'center' as const, + gap: '$1', + paddingTop: '$3', +}) + +/** Single centered stats row (matches GoodWalletV2 footer row behavior). */ +const ClaimDailyStatsRow = createComponent(Text, { + name: 'ClaimDailyStatsRow', + width: '100%' as const, + justifyContent: 'center' as const, + alignItems: 'center' as const, + gap: '$2', + display: 'flex' as const, +}) + +function getChainName(chainId: number): string { + switch (chainId) { + case SupportedChains.FUSE: + return 'Fuse' + case SupportedChains.CELO: + return 'Celo' + case SupportedChains.XDC: + return 'XDC' + default: + return `Chain ${chainId}` + } +} + +function formatCompactNumber(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + useGrouping: true, + notation: 'compact', + }).format(value) +} + +// --------------------------------------------------------------------------- +// Countdown — shows HH:MM:SS until the next claimable period. +// --------------------------------------------------------------------------- +function Countdown({ nextClaim }: { nextClaim: Date }) { + const getTimeLeft = () => Math.max(0, Math.floor((nextClaim.getTime() - Date.now()) / 1000)) + const [timeLeft, setTimeLeft] = useState(getTimeLeft) + + useEffect(() => { + const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000) + return () => clearInterval(id) + // getTimeLeft reads `nextClaim` only; intentionally excluded from deps. + }, [nextClaim]) + + const h = Math.floor(timeLeft / 3600) + .toString() + .padStart(2, '0') + const m = Math.floor((timeLeft % 3600) / 60) + .toString() + .padStart(2, '0') + const s = (timeLeft % 60).toString().padStart(2, '0') + + return <>{`${h}:${m}:${s}`} +} + +// --------------------------------------------------------------------------- +// Inner component — must live inside GoodWidgetProvider so it can use useWallet. +// --------------------------------------------------------------------------- +interface CitizenClaimInnerProps { + environment?: CitizenClaimWidgetEnvironment + walletMode: 'custodial' | 'injected' + onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void + onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void +} + +function CitizenClaimInner({ + environment, + walletMode, + onClaimSuccess, + onClaimError, +}: CitizenClaimInnerProps) { + const { state, actions } = useCitizenClaimAdapter({ environment }) + const { + status, + address, + chainId, + amount, + primaryAction, + primaryLabel, + error, + nextClaimTime, + claimablesByChain, + dailyStats, + } = state + + const isPending = status === 'claiming' || status === 'loading' || status === 'connecting' + const totalClaimableAmount = claimablesByChain.reduce( + (sum, item) => sum + (Number.parseFloat(item.amount) || 0), + 0, + ) + const displayAmount = claimablesByChain.length > 0 ? totalClaimableAmount.toFixed(2) : amount + const chainNameById = useMemo(() => { + const map = new Map() + for (const entry of claimablesByChain) { + map.set(entry.chainId, getChainName(entry.chainId)) + } + return map + }, [claimablesByChain]) + + /** Dispatch the primary action and surface callbacks for claim outcomes. */ + const handlePrimaryAction = useCallback(async () => { + try { + switch (primaryAction) { + case 'connect': + await actions.connect() + break + case 'verify': + await actions.startVerification() + break + case 'claim': { + const claimPlan = [...claimablesByChain] + if (claimPlan.length === 0) { + const singleChainName = chainId ? getChainName(chainId) : 'active chain' + const toastId = createToast({ + message: `Claim initiated on ${singleChainName}`, + status: 'pending', + duration: 0, + }) + + try { + const receipt = await actions.claim() + updateToast(toastId, { + message: `Claim succeeded on ${singleChainName}`, + status: 'success', + duration: 3200, + }) + onClaimSuccess?.({ + address: address!, + chainId: chainId!, + transactionHash: (receipt as { transactionHash?: string } | undefined) + ?.transactionHash, + }) + } catch (singleClaimError: unknown) { + updateToast(toastId, { + message: `Claim failed on ${singleChainName}`, + status: 'error', + duration: 0, + }) + onClaimError?.({ + address: address ?? null, + chainId: chainId ?? null, + message: + singleClaimError instanceof Error ? singleClaimError.message : 'Claim failed', + }) + } + break + } + + for (const claimEntry of claimPlan) { + const entryChainName = + chainNameById.get(claimEntry.chainId) ?? getChainName(claimEntry.chainId) + const toastId = createToast({ + message: `Claim initiated on ${entryChainName}`, + status: 'pending', + duration: 0, + }) + + try { + const receipt = await actions.claimOnChain(claimEntry.chainId) + updateToast(toastId, { + message: `Claim succeeded on ${entryChainName}`, + status: 'success', + duration: 3200, + }) + onClaimSuccess?.({ + address: address!, + chainId: claimEntry.chainId, + transactionHash: (receipt as { transactionHash?: string } | undefined) + ?.transactionHash, + }) + } catch (multiClaimError: unknown) { + updateToast(toastId, { + message: `Claim failed on ${entryChainName}`, + status: 'error', + duration: 0, + }) + onClaimError?.({ + address: address ?? null, + chainId: claimEntry.chainId, + message: + multiClaimError instanceof Error ? multiClaimError.message : 'Claim failed', + }) + } finally { + } + } + + await actions.refresh() + break + } + case 'refresh': + await actions.refresh() + break + case 'switch_chain': + // Default to Celo (42220) as the first preferred supported chain + await actions.switchChain?.(42220) + break + } + } catch (err: unknown) { + if (primaryAction === 'claim') { + onClaimError?.({ + address: address ?? null, + chainId: chainId ?? null, + message: err instanceof Error ? err.message : 'Claim failed', + }) + } + } + }, [ + primaryAction, + actions, + address, + chainId, + chainNameById, + claimablesByChain, + onClaimSuccess, + onClaimError, + ]) + + return ( + + {status === 'not_whitelisted' && ( + + + + Whitelisting Required + + + Face verification is required before you can claim. + + + + We take your privacy seriously. We only store some particularities/relief data in our + database, not the photo of your face itself.{' '} + + Learn more + + + )} + {/* ------------------------------------------------------------------ */} + {/* Main claim card */} + {/* ------------------------------------------------------------------ */} + {status !== 'not_whitelisted' && ( + + + {/* Status content */} + + {status === 'loading' && } + + {status === 'not_connected' && ( + <> + Connect your wallet to claim daily G$ + + )} + + {(status === 'eligible' || status === 'claiming') && ( + <> + Ready to claim + {displayAmount && } + + )} + + {status === 'success' && ( + + Claimed successfully! 🎉 + + )} + + {status === 'already_claimed' && ( + <> + Just a little longer… + More G$ coming soon + + )} + + {status === 'error' && error && ( + + {error} + + )} + + + {status !== 'loading' && claimablesByChain.length > 0 && ( + + + {claimablesByChain.map((entry, index) => ( + + + {getChainName(entry.chainId)} + {index < claimablesByChain.length - 1 && ( + + · + + )} + + ))} + + + )} + + {/* Action button — shown whenever there is a meaningful primary action */} + {primaryAction !== 'none' && ( + + + {/* Blurred glow halo matching GoodWalletV2 claim button */} + + + + + + {isPending ? ( + + {primaryLabel} + + + ) : ( + {primaryLabel} + )} + + + + )} + + + )} + + {/* ------------------------------------------------------------------ */} + {/* Next-claim footer (already_claimed state) */} + {/* ------------------------------------------------------------------ */} + {status === 'already_claimed' && nextClaimTime && ( + + + + Next claim in + + + + + + + Your UBI resets each day. Come back when the timer ends. + + + + )} + + + + Today + + + {formatCompactNumber(dailyStats.dailyNumberOfClaimers)}{' '} + claimers received + + + + + ) +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +/** + * CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow. + * + * Aligned to GoodWalletV2 claim behavior and the claim-widget-theme-demo visual baseline. + * + * Usage as a React component: + * + * + * Also available as a Web Component via the `element` or `register` entry points. + * + * Provider-first runtime path: + * host provider → GoodWidgetProvider → citizen-claim adapter → citizen-sdk + */ +export function CitizenClaimWidget({ + provider, + environment = 'production', + themeOverrides, + config, + defaultTheme = 'dark', + onClaimSuccess, + onClaimError, +}: CitizenClaimWidgetProps) { + const walletMode = + provider && + typeof provider === 'object' && + (provider as { __gwWalletMode?: string }).__gwWalletMode === 'custodial' + ? 'custodial' + : 'injected' + + return ( + + + + + ) +} diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts new file mode 100644 index 0000000..19d6276 --- /dev/null +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -0,0 +1,610 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import { createPublicClient, createWalletClient, custom, formatUnits, http, type Chain } from 'viem' +import { + ClaimSDK, + IdentitySDK, + citizenSdkCapabilities, + checkGenericDailyStats, + checkGenericEntitlement, + isSupportedChain, + SupportedChains, + CHAIN_DECIMALS, +} from '@goodsdks/citizen-sdk' +import type { + CitizenClaimWidgetAdapterActions, + CitizenClaimWidgetAdapterResult, + CitizenClaimWidgetAdapterState, + CitizenClaimWidgetEnvironment, + CitizenClaimWidgetStatus, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Minimal viem chain descriptors for the 3 chains supported by citizen-sdk. +// These are required so walletClient.chain?.id resolves correctly for the SDK. +// --------------------------------------------------------------------------- +const CHAIN_CONFIGS: Record = { + [SupportedChains.FUSE]: { + id: SupportedChains.FUSE, + name: 'Fuse', + nativeCurrency: { name: 'Fuse', symbol: 'FUSE', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.fuse.io'] } }, + } as Chain, + [SupportedChains.CELO]: { + id: SupportedChains.CELO, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { default: { http: ['https://forno.celo.org'] } }, + } as Chain, + [SupportedChains.XDC]: { + id: SupportedChains.XDC, + name: 'XDC Network', + nativeCurrency: { name: 'XDC', symbol: 'XDC', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.ankr.com/xdc'] } }, + } as Chain, +} + +const SUPPORTED_CHAINS = citizenSdkCapabilities.chains +const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments + +// --------------------------------------------------------------------------- +// humanReadableError — converts a raw SDK/viem error into a short, user-friendly +// string. The full technical error is always logged to the console for debugging. +// --------------------------------------------------------------------------- +/** + * Maps a raw error (viem RPC error, network failure, contract revert, etc.) to a + * short, human-readable string suitable for display in the widget UI. + * + * The full error is always logged to `console.error` so it remains available for + * debugging without cluttering the user-facing card. + * + * @param err - The caught error value (may be any type). + * @returns A concise, user-friendly error string. + */ +function humanReadableError(err: unknown): string { + console.error('[CitizenClaimWidget]', err) + + if (!(err instanceof Error)) { + // Log the raw value so non-Error throws are still traceable + console.error('[CitizenClaimWidget] non-Error thrown:', typeof err, err) + return 'Something went wrong. Please try again.' + } + + const msg = err.message + + // Network-level failures (fetch failed, connection refused, etc.) + if ( + msg.includes('Failed to fetch') || + msg.includes('HTTP request failed') || + msg.includes('fetch failed') || + msg.includes('NetworkError') || + msg.includes('net::ERR_') || + msg.includes('ECONNREFUSED') || + msg.includes('ECONNRESET') || + msg.includes('ETIMEDOUT') + ) { + return 'Unable to reach the network. Check your connection and try again.' + } + + // Timeout + if (msg.includes('timeout') || msg.includes('Timeout') || msg.includes('timed out')) { + return 'The request timed out. Please try again.' + } + + // User rejected transaction + if ( + msg.includes('User rejected') || + msg.includes('user rejected') || + msg.includes('4001') || + msg.includes('ACTION_REJECTED') + ) { + return 'Transaction rejected by wallet.' + } + + // Insufficient funds + if (msg.includes('insufficient funds') || msg.includes('InsufficientFunds')) { + return 'Insufficient funds to complete this transaction.' + } + + // Contract revert — try to extract just the revert reason + if (msg.includes('reverted') || msg.includes('revert')) { + const reasonMatch = msg.match(/reason:\s*(.+?)(?:\n|$)/) + if (reasonMatch) { + // Sanitize: strip control characters and cap length to avoid injection/overflow + const reason = reasonMatch[1].replace(/[^\x20-\x7E]/g, '').trim().slice(0, 80) + if (reason) { + return `Transaction failed: ${reason}` + } + } + return 'Transaction was reverted. Please try again.' + } + + // Unsupported chain + if (msg.includes('unsupported chain') || msg.includes('Unsupported chain')) { + return 'This network is not supported. Please switch to a supported chain.' + } + + return 'Something went wrong. Please try again.' +} + +export interface UseCitizenClaimAdapterOptions { + environment?: CitizenClaimWidgetEnvironment + /** + * URL to redirect the user to after face-verification completes. + * Defaults to the current page URL if running in a browser. + */ + rdu?: string +} + +type CitizenEnvironment = 'production' | 'staging' | 'development' + +/** + * Core adapter hook: bridges @goodsdks/citizen-sdk to GoodWidget state/actions. + * + * Runtime path: + * host provider → GoodWidgetProvider → useWallet() → this adapter → citizen-sdk + * + * The adapter: + * 1. Reads wallet state from useWallet() (injected by GoodWidgetProvider) + * 2. Creates viem public/wallet clients from the EIP1193 provider + * 3. Instantiates IdentitySDK + ClaimSDK from those clients + * 4. Manages the CitizenClaimWidgetStatus state machine + * 5. Exposes typed actions: connect, verify, claim, refresh, switchChain + * + * State transitions (mirrors GoodWalletV2 ClaimView.tsx logic): + * not_connected → [connect] → loading + * loading → not_whitelisted | eligible | already_claimed | error + * not_whitelisted → [verify] → (external FV flow) → loading after return + * eligible → [claim] → claiming → success | error + * error → [refresh] → loading + */ +export function useCitizenClaimAdapter( + options: UseCitizenClaimAdapterOptions = {}, +): CitizenClaimWidgetAdapterResult { + const { address, chainId, isConnected, provider, connect } = useWallet() + + // Normalise env string to one of the SDK-declared runtime environments. + const env = ( + options.environment && AVAILABLE_ENVIRONMENTS.includes(options.environment) + ? options.environment + : 'production' + ) as CitizenEnvironment + + // Whether the connected wallet is on a chain supported by citizen-sdk + const onSupportedChain = chainId !== null && isSupportedChain(chainId) + + const [status, setStatus] = useState( + isConnected ? 'loading' : 'not_connected', + ) + const [amount, setAmount] = useState(null) + const [nextClaimTime, setNextClaimTime] = useState(null) + const [error, setError] = useState(null) + const [claimablesByChain, setClaimablesByChain] = useState< + Array<{ chainId: number; amount: string }> + >([]) + const [dailyStats, setDailyStats] = useState({ + dailyNumberOfClaimers: 0, + dailyClaimedAmount: 0, + }) + + // Guard against state updates after the component unmounts + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + // --------------------------------------------------------------------------- + // Client factory — creates viem clients from the EIP1193 provider. + // Returns null when any required wallet state is missing. + // --------------------------------------------------------------------------- + const createClientsForChain = useCallback( + (targetChainId: number) => { + if (!provider || !address) return null + const chain = CHAIN_CONFIGS[targetChainId] + if (!chain) return null + const transport = custom(provider as Parameters[0]) + const publicClient = createPublicClient({ chain, transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport, + }) + return { publicClient, walletClient } + }, + [provider, address], + ) + + const createClients = useCallback(() => { + if (!chainId) return null + if (!provider || !address) return null + const chain = CHAIN_CONFIGS[chainId] + if (!chain) return null + // chain may be undefined for unsupported networks; the SDK will throw clearly. + const transport = custom(provider as Parameters[0]) + const publicClient = createPublicClient({ chain, transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport, + }) + return { publicClient, walletClient } + }, [provider, address, chainId]) + + // --------------------------------------------------------------------------- + // SDK factory — wraps viem clients in IdentitySDK + ClaimSDK instances. + // --------------------------------------------------------------------------- + const createSdkInstances = useCallback( + (clients: ReturnType) => { + if (!clients || !address) return null + const { publicClient, walletClient } = clients + const identitySDK = new IdentitySDK({ publicClient, walletClient, env }) + const claimSDK = new ClaimSDK({ + account: address as `0x${string}`, + publicClient, + walletClient, + identitySDK, + env, + // Return URL used by the GoodID face-verification redirect flow + rdu: options.rdu ?? (typeof window !== 'undefined' ? window.location.href : ''), + }) + return { identitySDK, claimSDK } + }, + [address, env, options.rdu], + ) + + const createSdkInstancesForChain = useCallback( + (targetChainId: number) => { + const clients = createClientsForChain(targetChainId) + if (!clients || !address) return null + const { publicClient, walletClient } = clients + const identitySDK = new IdentitySDK({ publicClient, walletClient, env }) + const claimSDK = new ClaimSDK({ + account: address as `0x${string}`, + publicClient, + walletClient, + identitySDK, + env, + rdu: options.rdu ?? (typeof window !== 'undefined' ? window.location.href : ''), + }) + return { identitySDK, claimSDK } + }, + [createClientsForChain, address, env, options.rdu], + ) + + /** + * Collects claimable UBI amounts for all citizen-sdk supported chains. + * This mirrors GoodWalletV2's claim breakdown model (eligible amounts per chain). + */ + const loadClaimablesByChain = useCallback(async (): Promise => { + const eligible: Array<{ chainId: number; amount: string }> = [] + + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { + const chain = CHAIN_CONFIGS[supportedChainId] + const rpcUrl = chain.rpcUrls.default.http[0] + if (!rpcUrl) return + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }) + const entitlement = await checkGenericEntitlement({ + publicClient, + chainId: supportedChainId, + env, + }) + if (entitlement <= 0n) return + + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(entitlement, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + + if (!mountedRef.current) return + eligible.sort((a, b) => b.chainId - a.chainId) + setClaimablesByChain(eligible) + }, [env]) + + const loadDailyStats = useCallback(async (): Promise => { + let maxClaimers = 0 + let totalClaimed = 0 + + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { + const chain = CHAIN_CONFIGS[supportedChainId] + const rpcUrl = chain.rpcUrls.default.http[0] + if (!rpcUrl) return + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }) + const stats = await checkGenericDailyStats({ + publicClient, + chainId: supportedChainId, + env, + }) + const claimers = Number(stats.claimers) + if (claimers > maxClaimers) maxClaimers = claimers + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + totalClaimed += Number(formatUnits(stats.amount, decimals)) + } catch { + // Best effort aggregation. + } + }), + ) + + if (!mountedRef.current) return + setDailyStats({ + dailyNumberOfClaimers: maxClaimers, + dailyClaimedAmount: totalClaimed, + }) + }, [env]) + + // --------------------------------------------------------------------------- + // loadClaimStatus — primary refresh action. + // Calls getWalletClaimStatus() and maps the SDK result to widget status. + // --------------------------------------------------------------------------- + const loadClaimStatus = useCallback(async () => { + if (!isConnected || !address) { + await loadClaimablesByChain() + await loadDailyStats() + setStatus('not_connected') + return + } + + // Always refresh per-chain claimables for a connected wallet, even if the + // currently active chain is unsupported. This keeps the cross-chain + // breakdown visible while prompting for network switching. + await loadClaimablesByChain() + await loadDailyStats() + + if (!onSupportedChain) { + // Wallet connected but on an unsupported chain — surface switch_chain action + setStatus('not_connected') + return + } + + setStatus('loading') + setError(null) + + const clients = createClients() + if (!clients) { + setStatus('not_connected') + return + } + const sdk = createSdkInstances(clients) + if (!sdk) { + setStatus('not_connected') + return + } + + try { + const walletStatus = await sdk.claimSDK.getWalletClaimStatus() + if (!mountedRef.current) return + + if (walletStatus.status === 'not_whitelisted') { + // User needs face-verification before claiming + setStatus('not_whitelisted') + setAmount(null) + } else if (walletStatus.status === 'can_claim') { + // User is whitelisted and has unclaimed UBI + setStatus('eligible') + const decimals = CHAIN_DECIMALS[chainId as SupportedChains] ?? 18 + setAmount(formatUnits(walletStatus.entitlement, decimals)) + } else { + // User is whitelisted but has already claimed for this period + setStatus('already_claimed') + setNextClaimTime(walletStatus.nextClaimTime ?? null) + setAmount(null) + } + } catch (err: unknown) { + if (!mountedRef.current) return + setStatus('error') + setError(humanReadableError(err)) + } + }, [ + isConnected, + address, + onSupportedChain, + chainId, + createClients, + createSdkInstances, + loadClaimablesByChain, + loadDailyStats, + ]) + + // Auto-refresh claim status whenever wallet connection or chain changes + useEffect(() => { + void loadClaimStatus() + // Re-run only on wallet identity changes; loadClaimStatus is stable per render. + }, [isConnected, address, chainId]) + + // --------------------------------------------------------------------------- + // handleClaim — executes the UBI claim transaction via ClaimSDK. + // Transitions: eligible → claiming → success | error + // --------------------------------------------------------------------------- + const claimOnChain = useCallback( + async (targetChainId: number): Promise => { + if (!provider) throw new Error('No wallet provider available') + if (!address) throw new Error('Wallet not connected') + + if (!isSupportedChain(targetChainId)) { + throw new Error(`Unsupported chain for citizen-sdk: ${targetChainId}`) + } + + setStatus('claiming') + setError(null) + + // Ensure the wallet is on the target chain before signing. + await ( + provider as { + request: (args: { method: string; params: unknown[] }) => Promise + } + ).request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + + const sdk = createSdkInstancesForChain(targetChainId) + if (!sdk) throw new Error('Unable to initialize SDK clients for target chain') + + return sdk.claimSDK.claim() + }, + [provider, address, createSdkInstancesForChain], + ) + + const handleClaim = useCallback(async (): Promise => { + if (!chainId) throw new Error('No active chain selected') + + setStatus('claiming') + setError(null) + + try { + const receipt = await claimOnChain(chainId) + if (!mountedRef.current) return receipt + await loadClaimStatus() + return receipt + } catch (err: unknown) { + if (!mountedRef.current) throw err + setStatus('error') + setError(humanReadableError(err)) + throw err + } + }, [chainId, claimOnChain, loadClaimStatus]) + + // --------------------------------------------------------------------------- + // handleVerify — initiates the GoodID face-verification flow. + // Opens in a new tab; the page reloads/redirects back when complete. + // --------------------------------------------------------------------------- + const handleVerify = useCallback(async (): Promise => { + const clients = createClients() + const sdk = createSdkInstances(clients) + if (!sdk) throw new Error('Wallet not connected or unsupported chain') + + const fvLink = await sdk.identitySDK.generateFVLink( + false, + options.rdu ?? (typeof window !== 'undefined' ? window.location.href : undefined), + chainId ?? undefined, + ) + if (typeof window !== 'undefined') { + window.open(fvLink, '_blank', 'noopener,noreferrer') + } + }, [createClients, createSdkInstances, chainId, options.rdu]) + + const handleConnect = useCallback(async (): Promise => { + setStatus('connecting') + setError(null) + try { + await connect() + await loadClaimStatus() + } catch (err: unknown) { + if (!mountedRef.current) throw err + setStatus('not_connected') + throw err + } + }, [connect, loadClaimStatus]) + + // --------------------------------------------------------------------------- + // handleSwitchChain — requests the wallet to switch to a supported chain. + // Uses the EIP-3326 wallet_switchEthereumChain method. + // --------------------------------------------------------------------------- + const handleSwitchChain = useCallback( + async (targetChainId: number): Promise => { + if (!provider) throw new Error('No wallet provider available') + await ( + provider as { + request: (args: { method: string; params: unknown[] }) => Promise + } + ).request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + }, + [provider], + ) + + // --------------------------------------------------------------------------- + // Derived state: primaryAction and primaryLabel + // --------------------------------------------------------------------------- + const primaryAction: CitizenClaimWidgetAdapterState['primaryAction'] = useMemo(() => { + if (status === 'connecting') return 'connect' + if (status === 'not_connected') { + // Connected but on wrong chain → switch_chain; not connected → connect + return isConnected && !onSupportedChain ? 'switch_chain' : 'connect' + } + if (status === 'not_whitelisted') return 'verify' + // Keep the claim button mounted while a claim is in-flight so UI copy can + // switch to "Claiming..." without hiding the action surface. + if (status === 'claiming') return 'claim' + if (status === 'eligible') return 'claim' + if (status === 'error') return 'refresh' + return 'none' + }, [status, isConnected, onSupportedChain]) + + const primaryLabel: string = useMemo(() => { + switch (primaryAction) { + case 'connect': + if (status === 'connecting') return 'Connecting...' + return 'Connect' + case 'verify': + return 'Verify Identity' + case 'claim': + return 'Claim' + case 'refresh': + return 'Retry' + case 'switch_chain': + return 'Switch Network' + default: + if (status === 'claiming') return 'Claiming...' + if (status === 'success') return 'Claimed!' + if (status === 'already_claimed') return 'Next Claim' + return '' + } + }, [primaryAction, status]) + + const state: CitizenClaimWidgetAdapterState = useMemo( + () => ({ + status, + address: address ?? null, + chainId: chainId ?? null, + amount, + token: 'G$', + primaryAction, + primaryLabel, + error, + nextClaimTime, + claimablesByChain, + dailyStats, + }), + [ + status, + address, + chainId, + amount, + primaryAction, + primaryLabel, + error, + nextClaimTime, + claimablesByChain, + dailyStats, + ], + ) + + const actions: CitizenClaimWidgetAdapterActions = useMemo( + () => ({ + connect: handleConnect, + refresh: loadClaimStatus, + startVerification: handleVerify, + claim: handleClaim, + claimOnChain, + switchChain: handleSwitchChain, + }), + [handleConnect, loadClaimStatus, handleVerify, handleClaim, claimOnChain, handleSwitchChain], + ) + + return { state, actions } +} diff --git a/packages/citizen-claim-widget/src/element.ts b/packages/citizen-claim-widget/src/element.ts new file mode 100644 index 0000000..409c141 --- /dev/null +++ b/packages/citizen-claim-widget/src/element.ts @@ -0,0 +1,26 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import { CitizenClaimWidget } from './CitizenClaimWidget' +import type React from 'react' + +/** + * A Custom Element class wrapping the CitizenClaimWidget React component. + * + * Register it with any tag name: + * customElements.define('gw-citizen-claim', CitizenClaimWidgetElement) + * + * Then use in HTML: + * + * + * Set the wallet provider and theme overrides via JS properties: + * const el = document.querySelector('gw-citizen-claim') + * el.provider = window.ethereum + * el.themeOverrides = { tokens: { color: { primary: '#00AFFE' } } } + */ +export const CitizenClaimWidgetElement = createMiniAppElement( + CitizenClaimWidget as React.ComponentType>, + { + shadow: true, + defaultTheme: 'dark', + events: ['claim-success', 'claim-error'], + }, +) diff --git a/packages/citizen-claim-widget/src/index.ts b/packages/citizen-claim-widget/src/index.ts index 8e01c81..8c22928 100644 --- a/packages/citizen-claim-widget/src/index.ts +++ b/packages/citizen-claim-widget/src/index.ts @@ -1,5 +1,8 @@ +// Integration metadata (links this widget to the citizen-sdk capability manifest) export { citizenClaimIntegration } from './integration' export type { CitizenClaimIntegration } from './integration' + +// Adapter contract types export type { CitizenClaimWidgetAdapterActions, CitizenClaimWidgetAdapterResult, @@ -14,3 +17,10 @@ export type { CitizenClaimWidgetStatus, CitizenClaimWidgetSuccessDetail, } from './widgetRuntimeContract' + +// Adapter hook +export { useCitizenClaimAdapter } from './adapter' +export type { UseCitizenClaimAdapterOptions } from './adapter' + +// Widget component +export { CitizenClaimWidget } from './CitizenClaimWidget' diff --git a/packages/citizen-claim-widget/src/integration.ts b/packages/citizen-claim-widget/src/integration.ts index 61a3323..d2e8f12 100644 --- a/packages/citizen-claim-widget/src/integration.ts +++ b/packages/citizen-claim-widget/src/integration.ts @@ -6,12 +6,15 @@ export const citizenClaimIntegration = { 'whitelistStatus', 'claimStatus', 'claimEntitlement', + 'genericClaimEntitlement', + 'dailyStats', 'startVerification', 'claim', ], chains: [122, 42220, 50], states: [ 'loading', + 'connecting', 'not_connected', 'not_whitelisted', 'eligible', @@ -24,4 +27,3 @@ export const citizenClaimIntegration = { } as const export type CitizenClaimIntegration = typeof citizenClaimIntegration - diff --git a/packages/citizen-claim-widget/src/register.ts b/packages/citizen-claim-widget/src/register.ts new file mode 100644 index 0000000..38a7472 --- /dev/null +++ b/packages/citizen-claim-widget/src/register.ts @@ -0,0 +1,27 @@ +import { CitizenClaimWidgetElement } from './element' + +const DEFAULT_TAG_NAME = 'gw-citizen-claim' + +/** + * Register the custom element. + * + * Call once at the top of your app or in a