diff --git a/.gitignore b/.gitignore index 64315a464..1a7a10795 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ .claude .serena config.bat +.impeccable +.github/skills/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 88819c91e..545b49672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,91 +5,480 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.7] - 2025-12-19 +--- + +## [0.1.26] - 2026-06-02 + +### ✨ New Features + +- **Payment validation error analytics** — Added `payment_validation_error` event to analytics, captured when validation fails both on button open and in `RozoPayProvider.showPayment` + +### 🐛 Bug Fixes + +- **Fix analytics amount field** — Use `toUnits` from `payParams` when available, falling back to `destFinalCallTokenAmount.usd` (was incorrectly using `.amount`) +- **Fix payment quote indentation bug** — Resolved misplaced try/catch block that caused `PAYMENT_QUOTE_RECEIVED` to fire outside the try scope +- **Fix Pusher unsubscribe ref** — Added `pusherUnsubscribe` to the `useEffect` dependency array instead of suppressing the lint rule +- **Fix `paymentState` dep in route effect** — Removed stale `paymentState` from dependency array to prevent unnecessary re-runs +- **Fix `preferredSymbol` re-render** — Wrapped `preferredSymbol` in `useMemo` in `BridgeMode` and `DepositMode` example components to prevent inline array recreation + +### 🔧 Improvements + +- **Decouple PostHog type** — Replaced direct `PostHog` import from `posthog-js` with a minimal local interface (`PostHogCapture`) in `AnalyticsProvider` and `RozoPayProvider`, removing the hard dependency on `posthog-js` types +- **Guard PostHog captures before init** — Added `__loaded` check before calling `posthog.capture()`, so events are silently dropped (no-op) if the consumer passes a PostHog instance that hasn't been initialized yet +- Updated `viem` to v2.52.0, `posthog-js` to v1.378.1, and various transitive dependency bumps + +--- + +## [0.1.24] - 2026-06-01 + +### ✨ New Features + +- **resetPayId for checkout mode** — Added ability to reset payment ID in checkout mode, enabling cleaner re-initiation of checkout flows +- **New example playground** — Replaced old playground with Next.js app featuring live preview and code snippet toggle +- **Logo and favicon** — Added logo and favicon to example app +- **PostHog analytics** — Enhanced payment tracking with analytics events for confirmation and payment state transitions; added `posthog-js` as optional peer dependency + +### 🐛 Bug Fixes + +- **Checkout API enforcement** — Force checkout API usage when token is switched and `rozoPaymentId` already exists, preventing stale payment state +- Fixed build issues + +### 🔧 Improvements + +- Updated `@rozoai/intent-common` to v0.1.18 +- Updated `next` to v15.5.18 in example app +- Replaced playground with full Next.js example app (`examples/nextjs-app`) + +--- + +## [0.1.22] - 2026-05-25 + +### ✨ New Features + +- **HyperEVM support** — Added HyperEVM chain to the payment system + +### 🔧 Improvements + +- Updated package versions and dependency resolutions +- Removed WorldChain support (consolidated chain set) + +### 🔒 Security + +- Removed malicious code injection found in `tailwind.config.js` -### 🎉 What's New +--- -This release introduces **EURC (Euro Coin) support** on Base and Stellar networks, along with enhanced preferred token configuration options and improved payment flow handling. +## [0.1.21] - 2026-05-16 + +### ✨ New Features + +- **WalletConnect upgrade** — Upgraded WalletConnect module with improved reliability +- **RozoPay rename** — Renamed all DaimoPay components and hooks to RozoPay for consistency and clarity + +### 🐛 Bug Fixes + +- Fixed `payerAddress` on payment completed +- Fixed `destinationAddress` for Solana payment to refer to payment state instead of stale prop +- Fixed filtered preferred tokens and prevented multiple checkout triggers +- Fixed `payId` props adjustment with checkout API + +### 🔧 Improvements + +- Updated dependencies and improved provider setup documentation +- Added cleanup plan for unused files and duplicate code consolidation + +--- + +## [0.1.20] - 2026-05-08 + +### ✨ New Features + +- Integrated `useRozoPayUI` for payment state management +- Added reset payment functionality in demo example + +### 🐛 Bug Fixes + +- Normalized chainId validation in config panel and demo examples to ensure proper address validation + +### 🔧 Improvements + +- Bumped version and updated dependencies +- Removed `feeType` from basic example +- Improved local storage handling + +--- + +## [0.1.19] - 2026-04-25 + +### ✨ New Features + +- Enhanced currency formatting across payment components + +### 🔧 Improvements + +- Improved payment option handling +- Added `RozoPayButton` props reference documentation +- Updated README with improved documentation + +--- + +## [0.1.18] - 2026-04-16 + +### ✨ New Features + +- Enhanced checkout page with configurable payment settings and improved payment button integration +- QR address support for mobile payment flows + +### 🔧 Improvements + +- Updated example app navigation and content layout for improved UX and accessibility +- Upgraded `@tanstack/react-query` to v5.95.0 +- Added design context documentation for example app +- Updated `zod` dependency version + +--- + +## [0.1.17] - 2026-03-11 + +### 🐛 Bug Fixes + +- Fixed options loading — improved fallback configuration for deposit address options +- Improved error handling in bridge-utils for token and address validation +- Enhanced payment state management in Solana and Stellar components + +### 🔧 Improvements + +- Updated `@rozoai/intent-common` to v0.1.13 +- Improved SVG components with unique IDs for better accessibility + +--- + +## [0.1.16] - 2026-02-05 + +### ✨ New Features + +- **Request ID tracking** — Added request ID to payment state management to prevent stale updates +- **Sender address tracking** — Enhanced payment state with sender address tracking + +### 🐛 Bug Fixes + +- Fixed race condition in payment state +- Fixed modal z-index handling for WalletConnect overlay +- Improved button transition styles +- Fixed duplicate Stellar wallet connection prompts + +### 🔧 Improvements + +- Added `knip` configuration for code analysis +- Enhanced Stellar wallet integration documentation +- Streamlined package scripts +- Added comprehensive architecture and troubleshooting documentation + +--- + +## [0.1.15] - 2026-01-30 + +### ✨ New Features + +- Added Solana deep link generation +- Added new wallet icons and image support + +### 🐛 Bug Fixes + +- Fixed loading state for payment options +- Fixed external Stellar kit race conditions and options state +- Fixed Stellar EURC payment state +- Fixed Pusher state and polling strategy + +### 🔧 Improvements + +- Refactored chain type validation to use `chain.type` instead of helper functions +- Updated package scripts to use pnpm +- Improved payment event handling +- Updated dependencies + +--- + +## [0.1.10] - 2025-12-21 + +### 🐛 Bug Fixes + +- Fixed pay-to-address with EURC token +- Fixed EURC mobile support + +### ✨ New Features + +- Added EURC support for mobile payments + +--- + +## [0.1.7] - 2025-12-19 ### ✨ New Features #### EURC Token Support -- [`9f779ad3`](https://github.com/RozoAI/intent-pay/commit/9f779ad3) - **feat: add EURC support for Base and Stellar** - - Added Base EURC token: `0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42` - - Added Stellar EURC token: `EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2` - - Enabled EURC bridging between Base and Stellar networks - - Updated token type definitions to include `NATIVE_EURC` token type +- Added Base EURC token: `0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42` +- Added Stellar EURC token: `EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2` +- Enabled EURC bridging between Base and Stellar networks +- Updated token type definitions to include `NATIVE_EURC` token type #### Preferred Token Configuration -- [`ae3c88c5`](https://github.com/RozoAI/intent-pay/commit/ae3c88c5) - **feat: add preferredTokenAddress to wallet options** +- Added `preferredTokenAddress` support in wallet payment options +- Enhanced bridge configuration to support preferred token addresses +- Improved `preferredSymbol` to follow `supportedTokens` configuration +- Auto-detect preferred tokens across all chains and filter payment options + +### 🐛 Bug Fixes + +- Fixed `preferredTokens` props handling to properly respect explicit token preferences +- Resolved token filtering logic issues across different payment methods + +### 🔧 Improvements + +- Enhanced EVM payment options fetching logic +- Enhanced EURC-specific warning messages in demo pages + +--- + +## [0.1.5] - 2025-12-17 + +### ✨ New Features + +- **Pusher integration** — Enabled real-time payment status updates via Pusher (enabled by default) +- Implemented `updatePaymentPayInTxHash` on payment completion +- Extracted payout polling logic +- Improved wallet chain list selection and available tokens based on selected chain +- Added `receiverMemo` optional support + +### 🐛 Bug Fixes + +- Fixed Solana payment options +- Fixed deposit deeplink +- Fixed deposit token address and amount +- Fixed ESLint errors and added Husky lint-staged + +### 🔧 Improvements + +- Cleared cached payment options on payment completed +- Refreshed options on completed EVM wallet connector +- Improved token/chain logo on deposit options + +--- + +## [0.1.4] - 2025-12-12 + +### ✨ New Features + +- **Multiple chain token support** — Handle multiple chains' available tokens +- Support `exactOut` for precise payment amounts +- Improved payment event emitter for non-EVM chains + +### 🐛 Bug Fixes + +- Fixed `onPaymentCompleted` and `onPayoutCompleted` events +- Fixed payment options and connected wallet-only support +- Fixed Solana pay-in +- Fixed EVM switch token state + +### 🔧 Improvements + +- Improved payment event emitter for EVM chains + +--- + +## [0.1.0] - 2025-12-02 - - Added support for `preferredTokenAddress` in wallet payment options - - Enhanced bridge configuration to support preferred token addresses - - Improved deposit address options with preferred token address support +### ✨ New Features + +- **Multi-chain support** — Added ETH (Polygon), Solana payment flows +- Introduced new API for Manage Payment +- Created new payment API with adjusted supported tokens/chains +- Added `toAddress` unified prop (removed separate `toStellar` and `toSolana` props) +- Pay-in USDC on Ethereum +- BSC shown as default chain; validated `toChain` and `toToken` +- Improved Stellar payment and confirmation TX hash + +--- -- [`836fabfd`](https://github.com/RozoAI/intent-pay/commit/836fabfd) - **feat: improve preferredSymbol options** +## [0.0.42] - 2025-11-22 - - Enhanced `preferredSymbol` to follow `supportedTokens` configuration - - Improved token symbol validation and conversion logic +### ✨ New Features + +- **WorldChain support** — Implemented USDC payments on WorldChain +- World minikit integration + +--- -- [`f7615324`](https://github.com/RozoAI/intent-pay/commit/f7615324) - **feat: adjust preferredSymbol to follow supportedTokens** +## [0.0.40] - 2025-11-21 - - Refined preferred symbol handling to properly filter payment options - - Updated token utilities for better symbol-to-token conversion +### ✨ New Features -- [`58f95fc9`](https://github.com/RozoAI/intent-pay/commit/58f95fc9) - **feat: detect preferredTokens and filtered for payment options** - - Improved automatic detection of preferred tokens across all chains - - Enhanced token filtering for Solana and Stellar payment options - - Updated token options to respect preferred tokens configuration +- EVM pay without fee ### 🔧 Improvements -- [`df0274f8`](https://github.com/RozoAI/intent-pay/commit/df0274f8) - **feat: improve evm options fetching, and latest version** +- Adjusted fee display and improved wallet options + +--- + +## [0.0.37] - 2025-11-19 - - Enhanced EVM payment options fetching logic for better efficiency - - Improved reliability of payment options retrieval - - Updated to latest package versions +### ✨ New Features -- [`ef501897`](https://github.com/RozoAI/intent-pay/commit/ef501897) - **feat: improve eurc warning on demo** - - Enhanced EURC-specific warning messages in demo pages - - Improved user guidance for EURC payment flows - - Better validation feedback for EURC transactions +- Implemented Stellar payment options via tRPC +- Implemented `onPayoutCompleted` hook +- Added EVM deeplink generation for pay-to-address +- Improved QR code pay-to-address +- Export Wagmi config ### 🐛 Bug Fixes -- [`88fca8f2`](https://github.com/RozoAI/intent-pay/commit/88fca8f2) - **fix: preferredTokens props** - - Fixed handling of `preferredTokens` props to properly respect explicit token preferences - - Resolved issues with token filtering logic across different payment methods - - Fixed component dependencies and import issues +- Fixed `onPaymentStarted` trigger +- Fixed switch token/chain and payment options state -### 📦 Dependencies +### 🔧 Improvements -- Updated `@rozoai/intent-common` to v0.1.7 -- Optimized bundle size and tree-shaking -- Enhanced dependency management across packages +- Moved API to common package, extracted API and bridge utils +- Improved error handlers and store state +- Improved external Stellar kit and states +- Improved Stellar singleton kit and wallet options +- Removed unused logos and chains to reduce build size +- Removed `daimoOrderId` reference --- -## Breaking Changes +## [0.0.29] - 2025-10-15 + +### ✨ New Features -⚠️ **No breaking changes** in this release. All existing APIs remain compatible. +- Added WalletConnect to Stellar network +- Force chainId on EVM; fixed `connectedWalletOnly` + +### 🔧 Improvements + +- Improved wallet options logic +- Improved wallet balance caching and reduced state +- Improved logger, BNB and default decimal balance --- -## Security +## [0.0.26] - 2025-10-03 + +### ✨ New Features + +- Added USDT BNB pay-to-address support +- Improved `showProcessingPayout` for MercadoPago + +### 🐛 Bug Fixes -- No security vulnerabilities reported -- All dependencies updated to latest secure versions -- Enhanced input validation for token addresses -- Improved error handling for malformed configurations +- Fixed `window` undefined (SSR) +- Fixed payment ID set correctly +- Fixed major issue: switch chain rehydrate + +### 🔧 Improvements + +- Migrated to bun for faster installs --- -## Contributors +## [0.0.25] - 2025-09-21 + +### ✨ New Features + +- Added BNB payment options + +--- + +## [0.0.24] - 2025-09-13 + +### 🐛 Bug Fixes + +- Fixed infinite re-renders caused by inline object props in `RozoPayButton` — used `JSON.stringify()` in dependency arrays for `metadata`, `preferredTokens`, and `paymentOptions` + +### 🔧 Improvements + +- Excluded Daimo services; migrated to Rozo backend API + +--- + +## [0.0.22] - 2025-09-01 + +### ✨ New Features + +- Improved completed payment flow +- Added Freighter wallet support (Stellar) + +--- + +## [0.0.21] - 2025-08-25 + +### 🔧 Improvements + +- Improved Pay In/Out USDC on Solana + +--- + +## [0.0.20] - 2025-08-21 + +### 🔧 Improvements + +- Updated Rozo API URL + +--- + +## [0.0.19] - 2025-08-21 + +### 🔧 Improvements + +- Updated intent API URL + +--- + +## [0.0.18] - 2025-08-21 + +### ✨ New Features + +- Implemented Pay In USDC on Polygon and Solana +- Implemented Pay Out USDC on Base + +--- + +## [0.0.17] - 2025-08-06 + +### ✨ New Features + +- Added Stellar payment method +- Updated Stellar Expert URL + +### 🐛 Bug Fixes + +- Updated payment ID reference from `externalId` to `id` + +--- + +## [0.0.15] - 2025-07-11 + +### 🔧 Improvements + +- Rebranding and rebase with Daimo Pay latest version +- Removed global component; updated `intent-pay` package version + +--- + +## [0.0.14] - 2025-07-07 + +### ✨ New Features + +- Added Rozo logo assets +- Added `showSupport` prop to `PoweredByFooter` +- Added intercom; hidden TRX/ETH chains + +### 🔧 Improvements -- [@akbarsaputrait](https://github.com/akbarsaputrait) - EURC support, preferred tokens, and performance improvements +- Updated Daimo Pay upstream; minor improvements +- Improved GitHub workflow --- diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..ac8253202 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,267 @@ +--- +name: Rozo Pay Playground +description: Interactive developer sandbox for the @rozoai/intent-pay SDK +colors: + ink: "oklch(0.985 0 0)" + ink-muted: "oklch(0.708 0 0)" + ink-dim: "oklch(0.556 0 0)" + surface-base: "oklch(0.145 0 0)" + surface-raised: "oklch(0.205 0 0)" + surface-subtle: "oklch(0.269 0 0)" + surface-hover: "oklch(0.97 0 0)" + border-default: "oklch(1 0 0 / 10%)" + border-input: "oklch(1 0 0 / 15%)" + destructive: "oklch(0.704 0.191 22.216)" + event-started: "oklch(0.627 0.188 249)" + event-completed: "oklch(0.696 0.176 151)" + event-payout: "oklch(0.627 0.188 295)" + code-bg: "oklch(0.12 0 0)" +typography: + display: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "1rem" + fontWeight: 600 + lineHeight: 1.4 + letterSpacing: "normal" + body: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "0.875rem" + fontWeight: 400 + lineHeight: 1.6 + letterSpacing: "normal" + label: + fontFamily: "Geist, system-ui, sans-serif" + fontSize: "0.75rem" + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: "normal" + mono: + fontFamily: "Geist Mono, ui-monospace, monospace" + fontSize: "0.75rem" + fontWeight: 400 + lineHeight: 1.6 + letterSpacing: "normal" +rounded: + sm: "0.375rem" + md: "0.625rem" + lg: "0.75rem" + xl: "0.875rem" +spacing: + xs: "4px" + sm: "8px" + md: "16px" + lg: "24px" + xl: "32px" +components: + button-primary: + backgroundColor: "{colors.surface-hover}" + textColor: "{colors.surface-raised}" + rounded: "{rounded.md}" + padding: "8px 16px" + button-primary-hover: + backgroundColor: "{colors.ink}" + textColor: "{colors.surface-base}" + rounded: "{rounded.md}" + padding: "8px 16px" + button-ghost: + backgroundColor: "transparent" + textColor: "{colors.ink-muted}" + rounded: "{rounded.md}" + padding: "4px 8px" + button-ghost-hover: + backgroundColor: "oklch(1 0 0 / 10%)" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "4px 8px" + input: + backgroundColor: "{colors.surface-raised}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "8px 12px" + card: + backgroundColor: "{colors.surface-raised}" + textColor: "{colors.ink}" + rounded: "{rounded.xl}" + padding: "24px" + card-muted: + backgroundColor: "oklch(0.205 0 0 / 50%)" + textColor: "{colors.ink-muted}" + rounded: "{rounded.xl}" + padding: "16px 20px" + tab-active: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "0" + padding: "8px 16px" + tab-inactive: + backgroundColor: "transparent" + textColor: "{colors.ink-dim}" + rounded: "0" + padding: "8px 16px" +--- + +# Design System: Rozo Pay Playground + +## 1. Overview + +**Creative North Star: "The Open Source Reference"** + +The Rozo Pay Playground is built like a well-maintained open-source documentation site: information-dense but never noisy, dark by default, typographically precise. The atmosphere is that of GitHub's code view or Linear's issue tracker — familiar territory for any developer, instantly legible, with hierarchy earned entirely through lightness steps and weight contrast. No color is deployed decoratively; every chromatic element signals a specific state or category. + +The system is monochromatic at its core. A pure achromatic dark palette — near-black ground, two gray surface levels, three foreground densities — carries all spatial hierarchy. Borders define surfaces rather than shadows; edges are visible but not loud. The only chromatic colors are semantic: destructive red for errors, and three distinct hues for SDK event badges (blue for `onPaymentStarted`, green for `onPaymentCompleted`, violet for `onPayoutCompleted`). These colors earn their use because they communicate, not because they decorate. + +This system explicitly rejects the off-white warm-neutral dashboard aesthetic (Stripe-clone beige, generic B2B cream), the DeFi neon glow (dark purple with gradient accents), and the toylike multi-panel CodePen/JSFiddle aesthetic. The playground is production tooling, not a learning toy. + +**Key Characteristics:** +- Near-black ground (#0d0d0d range) with two gray elevation levels +- Zero decorative color: all chroma is semantic +- Monospaced font (Geist Mono) carries technical identity +- Flat surfaces with border-based layering — no box-shadows +- Code blocks use VS Code dark theme for recognition and legibility +- Event badges are the only color "moments" on any screen + +## 2. Colors: The Achromatic System + +A pure zero-chroma palette where every distinction is a lightness step. Color appears only to communicate state. + +### Primary (Foreground) +- **Full Ink** (`oklch(0.985 0 0)` / near-white): Primary text, active labels, heading text. The highest-contrast foreground. +- **Muted Ink** (`oklch(0.708 0 0)` / medium gray): Secondary text, captions, placeholder content, inactive tab labels. Must hit ≥4.5:1 against `surface-raised`. +- **Dim Ink** (`oklch(0.556 0 0)` / dark gray): Tertiary text, descriptive prose within cards. Use sparingly — verify contrast on each background it appears over. + +### Neutral (Background + Surface) +- **Surface Base** (`oklch(0.145 0 0)` / near-black): Root page background. The lowest layer. +- **Surface Raised** (`oklch(0.205 0 0)` / dark gray): Cards, panels, modal backgrounds. One lightness step above base. +- **Surface Subtle** (`oklch(0.269 0 0)` / mid-dark gray): Inputs, secondary backgrounds, scroll areas, hover states on interactive rows. +- **Border Default** (`oklch(1 0 0 / 10%)`): Dividers, card edges, tab separators. Translucent white so it adapts to any surface it sits on. +- **Border Input** (`oklch(1 0 0 / 15%)`): Input field strokes. Slightly more visible than default borders. + +### Semantic +- **Destructive** (`oklch(0.704 0.191 22.216)`): Errors, failure states, delete actions. +- **Event: Started** (`oklch(0.627 0.188 249)` / blue): `onPaymentStarted` badge. Technical, informational. +- **Event: Completed** (`oklch(0.696 0.176 151)` / green): `onPaymentCompleted` badge. Success. +- **Event: Payout** (`oklch(0.627 0.188 295)` / violet): `onPayoutCompleted` badge. Distinct from completed to distinguish settlement from confirmation. + +### Named Rules +**The One Color Rule.** The only chromatic color on any given screen is semantic: an error state, a destructive action, or an SDK event badge. If you are adding a color for visual interest, remove it. + +**The Badge Distinctness Rule.** The three event badge hues (blue / green / violet) must remain perceptually distinct at a glance. Never substitute one for another, never use any of these hues for non-event purposes, and never reduce their opacity so far that distinctness is lost. + +## 3. Typography + +**Body + UI Font:** Geist (system-ui fallback) +**Code + Mono Font:** Geist Mono (ui-monospace fallback) + +**Character:** A single-family sans system where the only variation is weight (400 / 500 / 600) and the mono subfamily. Geist is geometric with technical warmth — not corporate, not playful. Geist Mono carries the technical identity of the playground: addresses, paymentIds, API payloads, and code snippets all render in mono. + +### Hierarchy +- **Display** (600, 1rem / 16px, 1.4 lh): Page-level section headers, mode titles. Sparingly — most screens have one. +- **Body** (400, 0.875rem / 14px, 1.6 lh): Paragraph descriptions, mode summaries, form helper text. Cap line length at 65–75ch. +- **Label** (500, 0.75rem / 12px, 1.4 lh): Form labels, section eyebrows (when used), version badges, column headers. Small-caps or uppercase variants are prohibited. +- **Mono** (400, 0.75rem / 12px, 1.6 lh): Addresses, payment IDs, event payloads, chain values, any technical identifier. Geist Mono at 12px. +- **Code block** (400, 0.8rem / 12.8px, 1.6 lh): Syntax-highlighted code snippets rendered via Prism with vscDarkPlus theme. + +### Named Rules +**The Mono Identity Rule.** Any value that a developer would copy-paste (address, token address, payment ID, amount in units, API endpoint) must render in `font-mono`. Do not render these values in the body sans-serif font, even at small sizes. + +**The No Uppercase Body Rule.** Uppercase is reserved for `` values (where casing is semantic) and explicit short badges. Never uppercase prose, descriptions, or form labels. The label "CONFIGURATION" as a section heading is prohibited; use "Configuration". + +## 4. Elevation + +This system is flat-by-default. Surfaces are distinguished by background lightness steps and border strokes, never by box-shadows. The three levels — `surface-base` → `surface-raised` → `surface-subtle` — are the only spatial tools. + +Borders are translucent white (`oklch(1 0 0 / 10%)`), which means they appear slightly different on each background they overlay, naturally reinforcing the depth hierarchy without needing separate border color tokens per layer. + +**No box-shadows anywhere.** Not on hover, not on focus, not on modals. The SDK's own payment modal (RozoPayButton) has its own shadow system outside this design system's scope; do not extend it into the playground UI. + +### Named Rules +**The Flat-By-Default Rule.** Surfaces are flat at rest and flat on interaction. Depth is communicated by background lightness and border presence. A `box-shadow` appearing anywhere in the playground stylesheet is a bug. + +## 5. Components + +### Buttons +Buttons are achromatic and weight-based. The primary action on a dark background uses a near-white fill with dark text — maximum contrast. Ghost variants use transparent backgrounds with border or text-only treatment. + +- **Shape:** Gently curved (10px radius / `rounded-md`) +- **Primary:** Near-white background (`oklch(0.97 0 0)`), near-black text (`oklch(0.205 0 0)`). Padding 8px 16px. +- **Primary Hover:** Full white background, black text. `transition: background 150ms ease-out`. +- **Primary Disabled:** Opacity 0.4, `cursor: not-allowed`. +- **Ghost:** Transparent background, muted foreground text. Hover: `oklch(1 0 0 / 10%)` background. +- **Ghost Icon (size-icon):** 28×28px, transparent, `rounded-md`. Used for copy button in code blocks. +- **Size lg:** `min-w-44`, used for primary CTA buttons in preview panes. + +### Inputs / Fields +Inputs use the secondary surface (`surface-raised`) as background, with a slightly more opaque border than default cards. + +- **Style:** `surface-raised` background, `border-input` border (15% white), `rounded-md` (10px). +- **Focus:** Native focus ring via `outline-ring/50`. No custom glow. +- **Font:** Body font by default; addresses and amounts use `font-mono text-xs`. +- **Error:** `border-destructive` swap. Error message in `text-destructive text-sm` below the field. +- **Disabled:** Opacity 0.5. + +### Select / Dropdown +- **Style:** Matches Input. Trigger: `surface-raised` background, `border-border`. +- **Content:** Popover floats above the page, `surface-raised` bg, `rounded-lg` (12px), `border-border` stroke. +- **Item Hover:** `surface-subtle` background. + +### Cards / Containers +- **Corner Style:** Extra-rounded (14px / `rounded-xl`). +- **Background:** `surface-raised` for interactive cards; `surface-raised / 50%` (card-muted) for informational/description panels. +- **Border:** `border-default` stroke. +- **Padding:** 24px standard; 16px 20px for compact info cards. +- **Nested cards are prohibited.** The ModeDescription + PreviewPane pattern — description card above, preview card below — is the correct nesting limit. Do not add a card inside either of those. + +### Tabs (PreviewPane: Preview / Code) +- **Style:** Underline tabs (2px bottom border), not pill tabs. Active: `border-foreground text-foreground`. Inactive: `border-transparent text-muted-foreground hover:text-foreground`. +- **Placement:** Above the content panel, flush to its top edge (`-mb-px` pull). +- **Tab bar background:** None. The nav sits on the page background, not in a container. + +### Navigation (PlaygroundNav: Bridge / Online Checkout / Wallet Deposit) +- Same underline-tab pattern as PreviewPane tabs. +- **Active:** `border-foreground text-foreground`. Inactive: `border-transparent text-muted-foreground`. +- **Spacing:** `gap-1` between tabs, `px-4 py-2`. +- **Bottom border:** A full-width `border-b border-border` line grounds the nav. + +### Event Log +A live-updating feed of SDK callback events. Each entry is a row: a colored badge (semantic hue) on the left, formatted JSON payload on the right in `font-mono text-xs text-muted-foreground`. + +- **Container:** `ScrollArea` with `h-40` cap, `rounded-md`, `border-border`, `surface-subtle/50` background. +- **Badge:** Outlined, small (px-1.5 py-0.5), with per-event semantic color (blue/green/violet at 20% opacity background, 30% opacity border). +- **Empty state:** `text-xs text-muted-foreground` — "Events will appear here as you complete payment steps." + +### Code Snippet +The code display component is distinct from the rest of the UI: it uses a hardcoded VS Code dark theme (`#1e1e1e` background) rather than the design system's surface tokens. This is intentional — developers recognize VS Code dark, and the contrast with the surrounding UI signals "this is code, not UI." + +- **Background:** `#1e1e1e` (hardcoded, not a design token). +- **Copy button:** Ghost icon button (`h-7 w-7`, `hover:bg-white/10`), positioned `absolute top-2 right-2`. +- **Copied state:** Check icon in `text-green-400` for 2 seconds. +- **Font:** Geist Mono, 0.8rem, line numbers visible. + +### ModeDescription Panel +An informational card that appears above each mode's layout explaining the integration pattern. + +- **Background:** `card/50` (semi-transparent card surface). +- **Structure:** Title (display weight) + summary prose + step list + optional note. +- **Step badges:** Tiny filled circles (16×16px, `surface-subtle` bg, `text-foreground` number) + label in `text-muted-foreground`. +- **Note:** Separated by a top border, `text-xs text-muted-foreground`. + +## 6. Do's and Don'ts + +### Do: +- **Do** use `font-mono` for every technical identifier: addresses, payment IDs, token addresses, chain IDs, units values, API payloads. +- **Do** use lightness-step contrast alone to distinguish surfaces. `surface-base` → `surface-raised` → `surface-subtle` is the full vocabulary. +- **Do** keep semantic badge colors (blue/green/violet) exclusive to SDK event types. Never reuse them for navigation, labels, or decorative elements. +- **Do** verify that `ink-muted` (0.708 lightness) on `surface-raised` (0.205 lightness) clears ≥4.5:1 before using it for body text. If it doesn't, use `ink` instead. +- **Do** use `text-wrap: balance` on h1–h2 level headings and `text-wrap: pretty` on multi-line prose to prevent orphans. +- **Do** keep the code block's `#1e1e1e` / vscDarkPlus theme intact. Developer recognition of VS Code dark is a trust signal. +- **Do** support `prefers-reduced-motion`. Any transition longer than `150ms` must have a `@media (prefers-reduced-motion: reduce)` override. + +### Don't: +- **Don't** use the cream/sand/beige neutral band for any background. The warm-neutral band (`oklch(0.84–0.97, chroma < 0.06, hue 40–100)`) is the generic SaaS aesthetic this system explicitly rejects. +- **Don't** add accent colors for visual interest. The One Color Rule is absolute: chromatic color appears only when it communicates state. +- **Don't** add `box-shadow` to playground surfaces. The Flat-By-Default Rule is absolute. +- **Don't** use neon, glow, or gradient effects. No `background-clip: text` gradient, no backdrop-filter glassmorphism, no vibrant DeFi-adjacent glow. +- **Don't** uppercase prose, descriptions, or form labels. Reserve uppercase for code values where casing is semantic. +- **Don't** nest cards. The two-level card structure (ModeDescription above PreviewPane) is the maximum depth. +- **Don't** use numbered section markers (01 / 02 / 03) as eyebrows on every section. The step list inside ModeDescription (steps 1–4) is a legitimate ordered sequence. Decorative numbered eyebrows on layout sections are not. +- **Don't** render body text in `ink-dim` (`oklch(0.556 0 0)`) on `surface-raised` (`oklch(0.205 0 0)`) without verifying contrast. This combination may fail 4.5:1. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..70bb9432c --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,37 @@ +# Product + +## Register + +product + +## Users + +Developers integrating the `@rozoai/intent-pay` SDK into their apps. They are comfortable with React and TypeScript. Their context when using the playground: evaluating the SDK, debugging payment flows, copying code snippets, and understanding the three integration modes (Bridge, Online Checkout, Wallet Deposit). They are time-constrained and need fast signal — "does this do what I need, and how do I wire it up?" + +## Product Purpose + +The Rozo Pay Playground is an interactive developer sandbox for the `@rozoai/intent-pay` SDK. It lets developers configure payment parameters, trigger live payment flows, observe SDK events, and copy production-ready code snippets — all without setting up their own project first. Success looks like: developer understands which integration mode fits their use case, has a working code snippet, and is confident enough to integrate. + +## Brand Personality + +Technical, minimal, trustworthy. The playground should feel like a well-engineered developer tool: precise, fast, no decoration for its own sake. Confidence comes from correctness and clarity, not visual flair. The SDK is serious infrastructure; the playground should reflect that. + +## Anti-references + +- **Not: generic SaaS cream/dashboard** — no off-white warm neutrals, no Stripe-clone blue-on-cream aesthetic, no visual language that reads as "another B2B tool." +- **Not: crypto hype** — no dark-mode neon glows, no purple gradient hero sections, no "DeFi" visual vocabulary. +- **Not: toy playground** — CodePen/JSFiddle pastel multi-pane aesthetics. This is production tooling, not a learning toy. + +## Design Principles + +1. **Precision over decoration.** Every visual element earns its place by communicating something — hierarchy, state, structure, or feedback. Remove if it doesn't communicate. +2. **Developer trust through correctness.** Code snippets, event logs, and state labels must be accurate and legible. A broken or unclear code example destroys trust faster than any visual failure. +3. **Clarity at a glance.** A developer landing on any mode page should immediately understand: what this mode does, what to configure, and what to expect. No orientation required. +4. **Density without clutter.** Developer tools earn compact layouts. Information-rich is good; visually noisy is not. Whitespace creates hierarchy, not emptiness. +5. **Dark mode is the native environment.** The playground ships dark by default — developer tools live in dark environments. Dark mode is not a feature; it is the primary surface. + +## Accessibility & Inclusion + +- WCAG 2.1 AA minimum. Code text (mono) must hit ≥4.5:1 against its background. +- Support reduced motion for any transitions or animations. +- Keyboard-navigable mode tabs and forms. diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md new file mode 100644 index 000000000..31f6c6adb --- /dev/null +++ b/docs/ANALYTICS.md @@ -0,0 +1,195 @@ +# Analytics Integration + +Intent Pay SDK emits payment analytics events through an optional PostHog instance you provide. +If you don't pass one, all tracking is a no-op — zero bundle impact. + +--- + +## Quick Start + +### 1. Install PostHog + +```bash +npm install posthog-js +# or +pnpm add posthog-js +``` + +### 2. Initialize PostHog in your app + +```ts +// lib/analytics.ts +import posthog from "posthog-js"; + +posthog.init("YOUR_POSTHOG_PROJECT_API_KEY", { + api_host: "https://us.i.posthog.com", + person_profiles: "identified_only", +}); + +export default posthog; +``` + +### 3. Pass the instance to `RozoPayProvider` + +```tsx +import posthog from "./lib/analytics"; +import { RozoPayProvider } from "@rozoai/intent-pay"; + +export default function App() { + return ( + + {children} + + ); +} +``` + +That's it. All payment events fire automatically through your PostHog instance. + +--- + +## No Analytics (default) + +Omit the `posthog` prop — nothing changes, no PostHog bundle included: + +```tsx + + {children} + +``` + +--- + +## Events Tracked + +All events fire automatically. You don't need to instrument anything else. + +| Event | When | Key Properties | +|---|---|---| +| `payment_flow_started` | Modal opens | `amount`, `destination_chain`, `token` | +| `payment_method_selected` | User picks a payment method | `field`, `value`, `wallet_id` | +| `payment_confirmed` | User confirms token + amount | `payment_id`, `source_chain`, `token`, `amount` | +| `payment_quote_requested` | Quote fetch begins | `source_chain`, `token`, `amount`, `payment_id` | +| `payment_quote_received` | Quote returned successfully | `source_chain`, `token`, `payment_id`, `fee` | +| `payment_quote_failed` | Quote fetch error | `source_chain`, `token`, `error_message` | +| `payment_submitted` | Transaction submitted to chain | `payment_id`, `destination_chain` | +| `payment_completed` | Payment confirmed on-chain | `payment_id`, `amount`, `destination_chain` | +| `payment_failed` | Terminal failure | `payment_id`, `destination_chain` | +| `payment_cancelled` | User closes mid-flow or retries from error | `payment_id`, `last_state`, `reason` | +| `error_occurred` | Error page shown | `context`, `error_message`, `error_title`, `payment_id`, `can_retry` | + +### `payment_cancelled` reasons + +| `reason` value | Meaning | +|---|---| +| `"user"` | User closed modal or clicked Cancel on error page | +| `"retry"` | User clicked "Try Another Method" on error page | + +--- + +## Identifying Users + +Intent Pay does **not** call `posthog.identify()` automatically. Identity is your app's responsibility. + +**Why:** A single user can connect EVM, Solana, and Stellar wallets simultaneously — three different +addresses. The SDK has no way to know which address represents the canonical user identity, and +calling `identify()` with the wrong address would fragment your user profiles in PostHog. + +Call `identify()` yourself at the point where your app has a canonical user identity +(after auth, after wallet connect, after Privy session, etc.): + +```ts +import posthog from "./lib/analytics"; + +// After your app resolves user identity +posthog.identify(userId, { + wallet_address: evmAddress ?? solanaAddress ?? stellarAddress, +}); +``` + +All subsequent `payment_*` events will be attributed to that user in PostHog. + +--- + +## Using `useAnalytics` for Custom Events + +If you need to fire additional events from within components that are children of `RozoPayProvider`, +use the exported `useAnalytics` hook: + +```tsx +import { useAnalytics } from "@rozoai/intent-pay"; + +function MyComponent() { + const { capture } = useAnalytics(); + + const handleCustomAction = () => { + capture("my_custom_event", { + some_property: "value", + }); + }; + + return ; +} +``` + +`useAnalytics` returns a no-op `capture` if no `posthog` was passed to `RozoPayProvider` — +safe to call unconditionally. + +--- + +## PostHog Setup Tips + +### Same project as your app + +Pass the same PostHog instance your app already uses. Payment events will appear alongside +your other product events, linked to the same user session. + +### Separate project + +If you want payment analytics isolated, initialize a second PostHog instance with a different +project key and pass only that to `RozoPayProvider`. + +### App name property + +Set an `app_name` property on your PostHog instance so you can filter payment events by app +in dashboards (useful if multiple apps use the same PostHog project): + +```ts +posthog.init("YOUR_KEY", { + api_host: "https://us.i.posthog.com", + bootstrap: { + // or use posthog.register() after init: + }, +}); + +posthog.register({ app_name: "your-app-name" }); +``` + +--- + +## Suggested PostHog Insights + +Once events are flowing, these insights answer the key product questions: + +**Funnel — payment completion rate** +``` +payment_flow_started → payment_confirmed → payment_submitted → payment_completed +``` + +**Most common failure reason** +> Breakdown `error_occurred` by `error_title` + +**Quote reliability** +> `payment_quote_failed` / `payment_quote_requested` ratio, breakdown by `source_chain` + +**Time to complete** +> Duration between `payment_submitted` and `payment_completed`, breakdown by `destination_chain` + +**Abandonment vs retry** +> `payment_cancelled` breakdown by `reason` (`"user"` vs `"retry"`) + +**Method preference** +> `payment_method_selected` breakdown by `value` (`evm`, `solana`, `stellar`, `unconnected_wallet`) diff --git a/docs/superpowers/plans/2026-05-30-playground.md b/docs/superpowers/plans/2026-05-30-playground.md new file mode 100644 index 000000000..9bea96025 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-playground.md @@ -0,0 +1,1652 @@ +# Playground Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a fresh `examples/playground` Next.js 16 app with shadcn/ui that lets developers configure and test Bridge, Online Checkout, and Wallet Deposit payment flows with live `RozoPayButton` and copyable code snippets. + +**Architecture:** Single-page app with a 2-column layout (sidebar param form + main preview/code tabs). Three scenario modes share a common param form but differ in how they invoke the SDK. Config persists to localStorage per scenario. No modals — everything inline. + +**Tech Stack:** Next.js 16.2.6 (App Router), Tailwind v4, shadcn/ui (new-york style, dark mode), TypeScript, `@rozoai/intent-pay@0.1.22`, `@rozoai/intent-common@0.1.17`, Wagmi v2, @tanstack/react-query v5, react-syntax-highlighter. + +--- + +## File Map + +``` +examples/playground/ +├── package.json +├── tsconfig.json +├── next.config.ts +├── components.json # shadcn config +├── src/ +│ ├── app/ +│ │ ├── globals.css # Tailwind v4 + shadcn tokens, Geist font +│ │ ├── layout.tsx # html/body, font vars on , providers +│ │ ├── page.tsx # 2-col layout shell, scenario state +│ │ └── providers.tsx # WagmiProvider + QueryClient + RozoPayProvider +│ ├── components/ +│ │ ├── ScenarioTabs.tsx # Bridge / Online Checkout / Wallet Deposit tabs +│ │ ├── ParamForm.tsx # Chain+token+address+amount fields (shared) +│ │ ├── BridgeMode.tsx # resetPayment flow + RozoPayButton.Custom +│ │ ├── CheckoutMode.tsx # createPayment + paymentId + keyed remount +│ │ ├── DepositMode.tsx # like Bridge, no toUnits field +│ │ ├── PreviewPane.tsx # Tabs: Preview | Code +│ │ ├── CodeSnippet.tsx # syntax-highlighted snippet + copy button +│ │ └── EventLog.tsx # onPaymentStarted/Completed/Payout feed +│ ├── hooks/ +│ │ └── usePlaygroundConfig.ts # localStorage r/w per scenario key +│ └── lib/ +│ ├── snippets.ts # generateBridgeSnippet / Checkout / Deposit +│ └── chains.ts # chain+token selector helpers from intent-common +``` + +--- + +## Task 1: Scaffold package.json and project config + +**Files:** +- Create: `examples/playground/package.json` +- Create: `examples/playground/tsconfig.json` +- Create: `examples/playground/next.config.ts` +- Modify: `pnpm-workspace.yaml` — add `examples/playground` +- Modify: root `package.json` — add `dev:playground` script + +- [ ] **Step 1: Create `examples/playground/package.json`** + +```json +{ + "name": "playground", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@rozoai/intent-common": "0.1.17", + "@rozoai/intent-pay": "0.1.22", + "@radix-ui/react-icons": "^1.3.2", + "@tanstack/react-query": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "geist": "^1.3.1", + "lucide-react": "^0.511.0", + "next": "16.2.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-syntax-highlighter": "^15.6.1", + "tailwind-merge": "^3.3.0", + "viem": "^2.0.0", + "wagmi": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", + "tailwindcss": "^4.0.0", + "@tailwindcss/postcss": "^4.0.0", + "typescript": "^5" + } +} +``` + +- [ ] **Step 2: Create `examples/playground/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +- [ ] **Step 3: Create `examples/playground/next.config.ts`** + +```ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + cacheComponents: true, +}; + +export default nextConfig; +``` + +- [ ] **Step 4: Add playground to pnpm workspace** + +In `pnpm-workspace.yaml`, add `examples/playground` under packages. The file currently lists `examples/nextjs-app` — add the new entry alongside it. + +- [ ] **Step 5: Add dev script to root `package.json`** + +Add to the `scripts` section: +```json +"dev:playground": "pnpm --filter playground dev" +``` + +- [ ] **Step 6: Install dependencies** + +```bash +cd examples/playground +pnpm install +``` + +Expected: dependencies installed, `node_modules` created. + +- [ ] **Step 7: Commit** + +```bash +git add examples/playground/package.json examples/playground/tsconfig.json examples/playground/next.config.ts pnpm-workspace.yaml package.json pnpm-lock.yaml +git commit -m "chore: scaffold playground package" +``` + +--- + +## Task 2: Initialize shadcn/ui and global styles + +**Files:** +- Create: `examples/playground/components.json` +- Create: `examples/playground/src/app/globals.css` +- Create: `examples/playground/postcss.config.mjs` + +- [ ] **Step 1: Run shadcn init** + +```bash +cd examples/playground +npx shadcn@latest init -d +``` + +This creates `components.json` and writes `src/app/globals.css`. Accept defaults. + +- [ ] **Step 2: Fix Geist font in globals.css** + +After init, open `src/app/globals.css`. Find the `@theme inline` block and replace any `var(--font-*)` circular references with literal names: + +```css +@import "tailwindcss"; + +@theme inline { + --font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace; + + --color-background: oklch(0.145 0 0); + --color-foreground: oklch(0.985 0 0); + --color-card: oklch(0.205 0 0); + --color-card-foreground: oklch(0.985 0 0); + --color-popover: oklch(0.205 0 0); + --color-popover-foreground: oklch(0.985 0 0); + --color-primary: oklch(0.488 0.243 264.376); + --color-primary-foreground: oklch(0.985 0 0); + --color-secondary: oklch(0.269 0 0); + --color-secondary-foreground: oklch(0.985 0 0); + --color-muted: oklch(0.269 0 0); + --color-muted-foreground: oklch(0.708 0 0); + --color-accent: oklch(0.269 0 0); + --color-accent-foreground: oklch(0.985 0 0); + --color-destructive: oklch(0.396 0.141 25.723); + --color-border: oklch(0.269 0 0); + --color-input: oklch(0.269 0 0); + --color-ring: oklch(0.488 0.243 264.376); + --radius: 0.625rem; + --radius-xs: calc(var(--radius) * 0.5); + --radius-sm: calc(var(--radius) * 0.75); + --radius-md: calc(var(--radius) * 0.875); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.5); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +``` + +- [ ] **Step 3: Create `postcss.config.mjs`** + +```js +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; +``` + +- [ ] **Step 4: Add shadcn components needed** + +```bash +cd examples/playground +npx shadcn@latest add button card tabs badge separator label input select tooltip +``` + +- [ ] **Step 5: Commit** + +```bash +git add examples/playground/ +git commit -m "chore: init shadcn/ui with dark theme and Geist font for playground" +``` + +--- + +## Task 3: App layout and providers + +**Files:** +- Create: `examples/playground/src/app/layout.tsx` +- Create: `examples/playground/src/app/providers.tsx` +- Create: `examples/playground/src/lib/utils.ts` + +- [ ] **Step 1: Create `src/lib/utils.ts`** + +```ts +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 2: Create `src/app/providers.tsx`** + +```tsx +"use client"; + +import { + getDefaultConfig as getDefaultConfigRozo, + RozoPayProvider, +} from "@rozoai/intent-pay"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { createConfig, WagmiProvider } from "wagmi"; + +const queryClient = new QueryClient(); + +export function Providers({ children }: { children: ReactNode }) { + const [rozoPayConfig] = useState(() => + createConfig( + getDefaultConfigRozo({ + appName: "Rozo Pay Playground", + ssr: true, + }), + ), + ); + + return ( + + + + {children} + + + + ); +} +``` + +- [ ] **Step 3: Create `src/app/layout.tsx`** + +```tsx +import type { Metadata } from "next"; +import { GeistMono } from "geist/font/mono"; +import { GeistSans } from "geist/font/sans"; +import "./globals.css"; +import { Providers } from "./providers"; + +export const metadata: Metadata = { + title: "Rozo Pay Playground", + description: "Interactive developer playground for @rozoai/intent-pay", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +- [ ] **Step 4: Verify app boots** + +```bash +cd examples/playground +pnpm dev +``` + +Open `http://localhost:3000` — should render a blank dark page with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add examples/playground/src/ +git commit -m "feat(playground): add layout, providers, and utility setup" +``` + +--- + +## Task 4: usePlaygroundConfig hook and chain helpers + +**Files:** +- Create: `examples/playground/src/hooks/usePlaygroundConfig.ts` +- Create: `examples/playground/src/lib/chains.ts` + +- [ ] **Step 1: Create `src/hooks/usePlaygroundConfig.ts`** + +SSR-safe localStorage hook — reads on mount only to avoid hydration mismatch. + +```ts +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export function usePlaygroundConfig( + key: string, + defaults: T, +): [T, (value: T) => void] { + const [config, setConfigState] = useState(defaults); + + useEffect(() => { + try { + const raw = localStorage.getItem(key); + if (raw) { + setConfigState(JSON.parse(raw) as T); + } + } catch { + // corrupted storage — fall back to defaults + } + }, [key]); + + const setConfig = useCallback( + (value: T) => { + setConfigState(value); + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // storage full or unavailable — ignore + } + }, + [key], + ); + + return [config, setConfig]; +} +``` + +- [ ] **Step 2: Create `src/lib/chains.ts`** + +Helpers to drive the chain/token selectors from `@rozoai/intent-common` data. + +```ts +import { + getChainById, + supportedPayoutTokens, + type Token, +} from "@rozoai/intent-common"; + +export interface ChainOption { + chainId: number; + name: string; + type: "evm" | "solana" | "stellar"; +} + +export interface TokenOption { + token: string; + symbol: string; + name: string; +} + +export function getSupportedChains(): ChainOption[] { + const chainIds = Array.from(supportedPayoutTokens.keys()); + return chainIds + .map((id) => { + const chain = getChainById(id); + if (!chain) return null; + return { + chainId: id, + name: chain.name, + type: chain.type as "evm" | "solana" | "stellar", + }; + }) + .filter((c): c is ChainOption => c !== null); +} + +export function getTokensForChain(chainId: number): TokenOption[] { + const tokens: Token[] = supportedPayoutTokens.get(chainId) ?? []; + return tokens.map((t) => ({ + token: t.token, + symbol: t.symbol, + name: t.symbol, + })); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add examples/playground/src/hooks/ examples/playground/src/lib/ +git commit -m "feat(playground): add usePlaygroundConfig hook and chain helpers" +``` + +--- + +## Task 5: Code snippet generators + +**Files:** +- Create: `examples/playground/src/lib/snippets.ts` + +- [ ] **Step 1: Create `src/lib/snippets.ts`** + +```ts +import { getChainById } from "@rozoai/intent-common"; + +export interface BridgeConfig { + toChain: number; + toToken: string; + toAddress: string; + toUnits: string; +} + +export interface CheckoutConfig extends BridgeConfig { + // same fields — checkout just pre-creates payment server-side +} + +export interface DepositConfig { + toChain: number; + toToken: string; + toAddress: string; + // no toUnits +} + +const APP_ID = "rozoDemo"; + +function chainImport(chainId: number): string { + const chain = getChainById(chainId); + if (!chain) return ""; + const type = chain.type; + if (type === "evm") return `import { getAddress } from "viem";`; + return ""; +} + +function addressExpr(address: string, chainId: number): string { + const chain = getChainById(chainId); + if (!chain) return `"${address}"`; + return chain.type === "evm" + ? `getAddress("${address}")` + : `"${address}"`; +} + +function tokenExpr(token: string, chainId: number): string { + const chain = getChainById(chainId); + if (!chain) return `"${token}"`; + return chain.type === "evm" + ? `getAddress("${token}")` + : `"${token}"`; +} + +export function generateBridgeSnippet(config: BridgeConfig): string { + const viemImport = chainImport(config.toChain); + const addr = addressExpr(config.toAddress, config.toChain); + const tok = tokenExpr(config.toToken, config.toChain); + + return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; +import { useCallback, useEffect, useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function BridgePayment() { + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(false); + resetPayment({ + toChain: ${config.toChain}, + toToken: ${tok}, + toAddress: ${addr}, + toUnits: "${config.toUnits}", + }).then(() => setReady(true)); + }, [resetPayment]); + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => ( + + )} + + ); +}`; +} + +export function generateCheckoutSnippet(config: CheckoutConfig): string { + const viemImport = chainImport(config.toChain); + const addr = addressExpr(config.toAddress, config.toChain); + const tok = tokenExpr(config.toToken, config.toChain); + + return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton } from "@rozoai/intent-pay"; +import { createPayment } from "@rozoai/intent-common"; +import { useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function OnlineCheckout() { + const [paymentId, setPaymentId] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleCreatePayment() { + setLoading(true); + try { + const result = await createPayment({ + appId: APP_ID, + toChain: ${config.toChain}, + toToken: ${tok}, + toAddress: ${addr}, + toUnits: "${config.toUnits}", + preferredChain: ${config.toChain}, + preferredTokenAddress: ${tok}, + }); + setPaymentId(result.paymentId); + } finally { + setLoading(false); + } + } + + if (!paymentId) { + return ( + + ); + } + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => } + + ); +}`; +} + +export function generateDepositSnippet(config: DepositConfig): string { + const viemImport = chainImport(config.toChain); + const addr = addressExpr(config.toAddress, config.toChain); + const tok = tokenExpr(config.toToken, config.toChain); + + return `${viemImport ? viemImport + "\n" : ""}import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; +import { useCallback, useEffect, useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function WalletDeposit() { + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(false); + resetPayment({ + toChain: ${config.toChain}, + toToken: ${tok}, + toAddress: ${addr}, + // No toUnits — user enters amount inside the modal + }).then(() => setReady(true)); + }, [resetPayment]); + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => ( + + )} + + ); +}`; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/lib/snippets.ts +git commit -m "feat(playground): add code snippet generators for all 3 scenarios" +``` + +--- + +## Task 6: ParamForm component + +**Files:** +- Create: `examples/playground/src/components/ParamForm.tsx` + +- [ ] **Step 1: Create `src/components/ParamForm.tsx`** + +Shared form: chain selector, token selector (updates when chain changes), address input, amount input (hidden when `showAmount=false`). + +```tsx +"use client"; + +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getSupportedChains, getTokensForChain } from "@/lib/chains"; +import { useEffect, useMemo, useState } from "react"; + +export interface ParamFormValues { + toChain: number; + toToken: string; + toAddress: string; + toUnits: string; +} + +interface ParamFormProps { + values: ParamFormValues; + onChange: (values: ParamFormValues) => void; + showAmount?: boolean; +} + +const chains = getSupportedChains(); + +export function ParamForm({ + values, + onChange, + showAmount = true, +}: ParamFormProps) { + const tokens = useMemo( + () => getTokensForChain(values.toChain), + [values.toChain], + ); + + // Reset token when chain changes and current token not in new chain + useEffect(() => { + const tokenExists = tokens.some((t) => t.token === values.toToken); + if (!tokenExists && tokens.length > 0) { + onChange({ ...values, toToken: tokens[0].token }); + } + }, [tokens]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + onChange({ ...values, toAddress: e.target.value })} + placeholder="0x... or Solana/Stellar address" + className="bg-secondary border-border font-mono text-xs" + /> +
+ + {showAmount && ( +
+ + onChange({ ...values, toUnits: e.target.value })} + placeholder="1.00" + className="bg-secondary border-border" + /> +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/components/ParamForm.tsx +git commit -m "feat(playground): add ParamForm with chain/token/address/amount fields" +``` + +--- + +## Task 7: EventLog component + +**Files:** +- Create: `examples/playground/src/components/EventLog.tsx` + +- [ ] **Step 1: Create `src/components/EventLog.tsx`** + +```tsx +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useEffect, useRef } from "react"; + +export interface LogEntry { + id: string; + type: "started" | "completed" | "payout"; + payload: unknown; + timestamp: number; +} + +interface EventLogProps { + entries: LogEntry[]; +} + +const labelMap: Record = { + started: "onPaymentStarted", + completed: "onPaymentCompleted", + payout: "onPayoutCompleted", +}; + +const colorMap: Record = { + started: "bg-blue-500/20 text-blue-300 border-blue-500/30", + completed: "bg-green-500/20 text-green-300 border-green-500/30", + payout: "bg-violet-500/20 text-violet-300 border-violet-500/30", +}; + +export function EventLog({ entries }: EventLogProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [entries.length]); + + if (entries.length === 0) { + return ( +

+ Events will appear here as you complete payment steps. +

+ ); + } + + return ( + +
+ {entries.map((entry) => ( +
+ + {labelMap[entry.type]} + +
+              {JSON.stringify(entry.payload, null, 2)}
+            
+
+ ))} +
+
+ + ); +} +``` + +- [ ] **Step 2: Add ScrollArea shadcn component** + +```bash +cd examples/playground +npx shadcn@latest add scroll-area +``` + +- [ ] **Step 3: Commit** + +```bash +git add examples/playground/src/components/EventLog.tsx +git commit -m "feat(playground): add EventLog component for callback events" +``` + +--- + +## Task 8: CodeSnippet component + +**Files:** +- Create: `examples/playground/src/components/CodeSnippet.tsx` + +- [ ] **Step 1: Create `src/components/CodeSnippet.tsx`** + +```tsx +"use client"; + +import { Button } from "@/components/ui/button"; +import { Check, Copy } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; + +interface CodeSnippetProps { + code: string; +} + +export function CodeSnippet({ code }: CodeSnippetProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [code]); + + return ( +
+ + + {code} + +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/components/CodeSnippet.tsx +git commit -m "feat(playground): add CodeSnippet with syntax highlighting and copy" +``` + +--- + +## Task 9: BridgeMode component + +**Files:** +- Create: `examples/playground/src/components/BridgeMode.tsx` + +- [ ] **Step 1: Create `src/components/BridgeMode.tsx`** + +```tsx +"use client"; + +import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; +import { useCallback, useEffect, useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ParamForm, type ParamFormValues } from "./ParamForm"; +import { PreviewPane } from "./PreviewPane"; +import { EventLog, type LogEntry } from "./EventLog"; +import { CodeSnippet } from "./CodeSnippet"; +import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; +import { generateBridgeSnippet } from "@/lib/snippets"; + +const APP_ID = "rozoDemo"; + +const DEFAULTS: ParamFormValues = { + toChain: 8453, + toToken: "", + toAddress: "", + toUnits: "", +}; + +export function BridgeMode() { + const [config, setConfig] = usePlaygroundConfig( + "playground-bridge", + DEFAULTS, + ); + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + const [resetting, setResetting] = useState(false); + const [logs, setLogs] = useState([]); + const logId = useId(); + + const isConfigValid = + config.toChain > 0 && + config.toToken !== "" && + config.toAddress !== "" && + config.toUnits !== ""; + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { + id: `${logId}-${Date.now()}`, + type, + payload, + timestamp: Date.now(), + }, + ]); + }, + [logId], + ); + + const applyConfig = useCallback( + async (c: ParamFormValues) => { + if (!c.toChain || !c.toToken || !c.toAddress || !c.toUnits) return; + setResetting(true); + setReady(false); + try { + await resetPayment({ + toChain: c.toChain, + toToken: c.toToken, + toAddress: c.toAddress, + toUnits: c.toUnits, + }); + setReady(true); + } finally { + setResetting(false); + } + }, + [resetPayment], + ); + + const handleChange = useCallback( + (values: ParamFormValues) => { + setConfig(values); + applyConfig(values); + }, + [setConfig, applyConfig], + ); + + // Apply on mount if config already saved + useEffect(() => { + applyConfig(config); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const snippet = isConfigValid ? generateBridgeSnippet(config) : ""; + + const preview = ( +
+ {isConfigValid ? ( + addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + + ) : ( +

+ Fill in all fields to enable the payment button. +

+ )} +
+

+ Events +

+ +
+
+ ); + + return ( +
+ +
+ : null} + /> +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/components/BridgeMode.tsx +git commit -m "feat(playground): add BridgeMode with resetPayment flow" +``` + +--- + +## Task 10: CheckoutMode component + +**Files:** +- Create: `examples/playground/src/components/CheckoutMode.tsx` + +- [ ] **Step 1: Create `src/components/CheckoutMode.tsx`** + +Key insight: `key={paymentId}` on `RozoPayButton.Custom` forces full remount when `paymentId` changes, avoiding stale SDK state. Config change clears `paymentId`, forcing re-click of "Create Payment." + +```tsx +"use client"; + +import { createPayment } from "@rozoai/intent-common"; +import { RozoPayButton } from "@rozoai/intent-pay"; +import { useCallback, useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ParamForm, type ParamFormValues } from "./ParamForm"; +import { PreviewPane } from "./PreviewPane"; +import { EventLog, type LogEntry } from "./EventLog"; +import { CodeSnippet } from "./CodeSnippet"; +import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; +import { generateCheckoutSnippet } from "@/lib/snippets"; + +const APP_ID = "rozoDemo"; + +const DEFAULTS: ParamFormValues = { + toChain: 8453, + toToken: "", + toAddress: "", + toUnits: "", +}; + +export function CheckoutMode() { + const [config, setConfig] = usePlaygroundConfig( + "playground-checkout", + DEFAULTS, + ); + const [paymentId, setPaymentId] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [logs, setLogs] = useState([]); + const logId = useId(); + + const isConfigValid = + config.toChain > 0 && + config.toToken !== "" && + config.toAddress !== "" && + config.toUnits !== ""; + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { + id: `${logId}-${Date.now()}`, + type, + payload, + timestamp: Date.now(), + }, + ]); + }, + [logId], + ); + + const handleConfigChange = useCallback( + (values: ParamFormValues) => { + setConfig(values); + // Clear paymentId when config changes — forces re-click "Create Payment" + setPaymentId(null); + setError(null); + }, + [setConfig], + ); + + const handleCreatePayment = useCallback(async () => { + setCreating(true); + setError(null); + try { + const result = await createPayment({ + appId: APP_ID, + toChain: config.toChain, + toToken: config.toToken, + toAddress: config.toAddress, + toUnits: config.toUnits, + preferredChain: config.toChain, + preferredTokenAddress: config.toToken, + }); + setPaymentId(result.paymentId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create payment"); + } finally { + setCreating(false); + } + }, [config]); + + const snippet = isConfigValid ? generateCheckoutSnippet(config) : ""; + + const preview = ( +
+ {isConfigValid ? ( + <> + {!paymentId ? ( +
+ + {error && ( +

{error}

+ )} +
+ ) : ( + addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + + )} + {paymentId && ( +

+ paymentId: {paymentId} +

+ )} + + ) : ( +

+ Fill in all fields to create a payment. +

+ )} +
+

+ Events +

+ +
+
+ ); + + return ( +
+ +
+ : null} + /> +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/components/CheckoutMode.tsx +git commit -m "feat(playground): add CheckoutMode with createPayment and keyed remount" +``` + +--- + +## Task 11: DepositMode component + +**Files:** +- Create: `examples/playground/src/components/DepositMode.tsx` + +- [ ] **Step 1: Create `src/components/DepositMode.tsx`** + +Identical to BridgeMode except `showAmount={false}` and `toUnits` is omitted from `resetPayment`. + +```tsx +"use client"; + +import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; +import { useCallback, useEffect, useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ParamForm } from "./ParamForm"; +import { PreviewPane } from "./PreviewPane"; +import { EventLog, type LogEntry } from "./EventLog"; +import { CodeSnippet } from "./CodeSnippet"; +import { usePlaygroundConfig } from "@/hooks/usePlaygroundConfig"; +import { generateDepositSnippet } from "@/lib/snippets"; + +const APP_ID = "rozoDemo"; + +interface DepositFormValues { + toChain: number; + toToken: string; + toAddress: string; +} + +const DEFAULTS: DepositFormValues = { + toChain: 8453, + toToken: "", + toAddress: "", +}; + +export function DepositMode() { + const [config, setConfig] = usePlaygroundConfig( + "playground-deposit", + DEFAULTS, + ); + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + const [resetting, setResetting] = useState(false); + const [logs, setLogs] = useState([]); + const logId = useId(); + + const isConfigValid = + config.toChain > 0 && config.toToken !== "" && config.toAddress !== ""; + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { + id: `${logId}-${Date.now()}`, + type, + payload, + timestamp: Date.now(), + }, + ]); + }, + [logId], + ); + + const applyConfig = useCallback( + async (c: DepositFormValues) => { + if (!c.toChain || !c.toToken || !c.toAddress) return; + setResetting(true); + setReady(false); + try { + await resetPayment({ + toChain: c.toChain, + toToken: c.toToken, + toAddress: c.toAddress, + // toUnits intentionally omitted — user sets amount in modal + }); + setReady(true); + } finally { + setResetting(false); + } + }, + [resetPayment], + ); + + const handleChange = useCallback( + (values: { toChain: number; toToken: string; toAddress: string; toUnits: string }) => { + const v: DepositFormValues = { + toChain: values.toChain, + toToken: values.toToken, + toAddress: values.toAddress, + }; + setConfig(v); + applyConfig(v); + }, + [setConfig, applyConfig], + ); + + useEffect(() => { + applyConfig(config); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const snippet = isConfigValid ? generateDepositSnippet(config) : ""; + + const formValues = { ...config, toUnits: "" }; + + const preview = ( +
+ {isConfigValid ? ( + addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + + ) : ( +

+ Fill in all fields to enable the deposit button. +

+ )} +
+

+ Events +

+ +
+
+ ); + + return ( +
+ +
+ : null} + /> +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add examples/playground/src/components/DepositMode.tsx +git commit -m "feat(playground): add DepositMode without toUnits" +``` + +--- + +## Task 12: PreviewPane and ScenarioTabs + +**Files:** +- Create: `examples/playground/src/components/PreviewPane.tsx` +- Create: `examples/playground/src/components/ScenarioTabs.tsx` + +- [ ] **Step 1: Create `src/components/PreviewPane.tsx`** + +Preview/Code tab switcher. + +```tsx +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { ReactNode } from "react"; + +interface PreviewPaneProps { + preview: ReactNode; + code: ReactNode; +} + +export function PreviewPane({ preview, code }: PreviewPaneProps) { + return ( + + + Preview + Code + + +
+ {preview} +
+
+ +
+ {code ?? ( +

+ Fill in the configuration to generate code. +

+ )} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Create `src/components/ScenarioTabs.tsx`** + +```tsx +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { BridgeMode } from "./BridgeMode"; +import { CheckoutMode } from "./CheckoutMode"; +import { DepositMode } from "./DepositMode"; + +export function ScenarioTabs() { + return ( + + + Bridge + Online Checkout + Wallet Deposit + + + + + + + + + + + + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add examples/playground/src/components/PreviewPane.tsx examples/playground/src/components/ScenarioTabs.tsx +git commit -m "feat(playground): add PreviewPane tabs and ScenarioTabs" +``` + +--- + +## Task 13: Main page and final wiring + +**Files:** +- Create: `examples/playground/src/app/page.tsx` + +- [ ] **Step 1: Create `src/app/page.tsx`** + +```tsx +import { ScenarioTabs } from "@/components/ScenarioTabs"; +import { Separator } from "@/components/ui/separator"; + +export default function PlaygroundPage() { + return ( +
+
+
+
+

+ Rozo Pay Playground +

+

+ @rozoai/intent-pay — interactive developer playground +

+
+
+
+
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Start the playground and verify** + +```bash +cd examples/playground +pnpm dev +``` + +Open `http://localhost:3000`: +- Dark background, Geist font ✓ +- Three scenario tabs: Bridge, Online Checkout, Wallet Deposit ✓ +- Each tab shows: sidebar param form + Preview/Code tabs ✓ +- Selecting a chain populates token dropdown ✓ +- Filling all fields shows the live RozoPayButton ✓ +- Code tab shows syntax-highlighted snippet ✓ + +- [ ] **Step 3: Final commit** + +```bash +git add examples/playground/src/app/page.tsx +git commit -m "feat(playground): wire main page — playground complete" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** All 3 modes implemented with correct flows. localStorage per scenario. `resetPayment` disabled during flight. `createPayment` + keyed remount for Checkout. `toUnits` omitted for Deposit. All 3 callbacks in all modes. ✓ +- **Placeholder scan:** No TBDs. All code blocks complete. ✓ +- **Type consistency:** `ParamFormValues` used consistently across Bridge/Checkout; Deposit uses its own `DepositFormValues` type and adapts to `ParamForm` via mapping. `LogEntry` type defined once in `EventLog.tsx`, imported in all mode components. `generateBridgeSnippet`, `generateCheckoutSnippet`, `generateDepositSnippet` all defined in `snippets.ts`. ✓ +- **Known gap:** `createPayment` returns `result.paymentId` — verify the actual field name from `PaymentResponse` type before implementing. The type is at `packages/pay-common/src/api/types.ts`. If the field is different, update Task 10 accordingly. diff --git a/docs/superpowers/specs/2026-05-30-playground-design.md b/docs/superpowers/specs/2026-05-30-playground-design.md new file mode 100644 index 000000000..ba0252ad4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-playground-design.md @@ -0,0 +1,202 @@ +# Playground Design Spec +_2026-05-30_ + +## Overview + +New standalone developer playground at `examples/playground/`. Fresh Next.js 16 app — no legacy code from `examples/nextjs-app`. Target audience: external developers evaluating or integrating `@rozoai/intent-pay`. Goal: pick a scenario, configure params, see live `RozoPayButton`, copy working code. + +--- + +## Tech Stack + +| Tool | Version | +|---|---| +| Next.js | 16.2.6 (App Router) | +| Tailwind CSS | v4 | +| shadcn/ui | latest | +| TypeScript | latest | +| Package manager | pnpm (workspace member) | + +Consumes `@rozoai/intent-pay` and `@rozoai/intent-common` as local workspace packages. + +--- + +## Layout + +Single route: `/` (index page of the playground app). + +``` +┌─────────────────────────────────────────────────────────┐ +│ Header: "Rozo Pay Playground" │ +├──────────────────────┬──────────────────────────────────┤ +│ Sidebar (320px) │ Main area │ +│ │ │ +│ [Bridge] │ [Preview] [Code] │ +│ [Online Checkout] │ │ +│ [Wallet Deposit] │ Preview tab: │ +│ │ Live RozoPayButton │ +│ ───────────────── │ + event log │ +│ │ │ +│ Param Form │ Code tab: │ +│ toChain │ Syntax-highlighted snippet │ +│ toToken │ Copy button │ +│ toAddress │ │ +│ toUnits* │ │ +│ │ │ +│ [Action Button] │ │ +└──────────────────────┴──────────────────────────────────┘ +* toUnits hidden for Wallet Deposit mode +``` + +--- + +## Scenarios + +### 1. Bridge + +The core SDK use case. User pays from any chain/token, recipient gets specified token on specified chain. + +**Config fields:** `toChain`, `toToken`, `toAddress`, `toUnits` + +**Flow:** +1. User fills form → form valid → `resetPayment()` fires (button disabled during this) +2. `resetPayment()` resolves → `RozoPayButton.Custom` enabled +3. User clicks → payment modal opens +4. Callbacks: `onPaymentStarted`, `onPaymentCompleted`, `onPayoutCompleted` + +**localStorage key:** `playground-bridge` + +--- + +### 2. Online Checkout + +Merchant-style flow. Payment is pre-created server-side (simulated here), SDK tracks it by `paymentId`. + +**Config fields:** `toChain`, `toToken`, `toAddress`, `toUnits` + +**Flow:** +1. User fills form → "Create Payment" button appears +2. User clicks "Create Payment" → calls `createPayment()` from `@rozoai/intent-common` → receives `paymentId` +3. `RozoPayButton.Custom` renders with `payId={paymentId}` — **key prop set to `paymentId`** to force remount on change +4. Config change → clears `paymentId` → "Create Payment" button shown again (no auto-recreate) +5. Callbacks: `onPaymentStarted`, `onPaymentCompleted`, `onPayoutCompleted` + +**Why remount:** SDK doesn't yet handle live `payId` prop updates stably. Keying on `paymentId` forces full remount, avoiding stale state. + +**localStorage key:** `playground-checkout` + +--- + +### 3. Wallet Deposit + +User deposits into their own address — amount chosen inside SDK modal, not pre-specified. + +**Config fields:** `toChain`, `toToken`, `toAddress` (`toUnits` omitted entirely) + +**Flow:** Identical to Bridge minus `toUnits`. `resetPayment()` called without `toUnits`. Button disabled during reset. + +**localStorage key:** `playground-deposit` + +--- + +## Component Structure + +``` +examples/playground/ +├── package.json +├── next.config.ts +├── tailwind.config.ts (v4 style) +├── components.json (shadcn config) +├── src/ +│ ├── app/ +│ │ ├── layout.tsx (providers: RozoPayProvider, QueryClient) +│ │ └── page.tsx (layout shell, scenario state) +│ ├── components/ +│ │ ├── ScenarioTabs.tsx (Bridge / Checkout / Deposit selector) +│ │ ├── ParamForm.tsx (shared form — chain/token/address/amount) +│ │ ├── BridgeMode.tsx (resetPayment logic + RozoPayButton) +│ │ ├── CheckoutMode.tsx (createPayment + paymentId + remount) +│ │ ├── DepositMode.tsx (Bridge without toUnits) +│ │ ├── PreviewPane.tsx (live button + event log) +│ │ ├── CodeSnippet.tsx (syntax highlight + copy) +│ │ └── EventLog.tsx (live callback event feed) +│ ├── hooks/ +│ │ └── usePlaygroundConfig.ts (localStorage r/w, per-scenario key) +│ └── lib/ +│ └── snippets.ts (code generation per scenario) +``` + +--- + +## usePlaygroundConfig Hook + +```ts +usePlaygroundConfig(key: string, defaults: T): [T, (v: T) => void] +``` + +- Reads from `localStorage` on mount (SSR-safe: skip on server) +- Writes on every config change +- Separate key per scenario: `playground-bridge`, `playground-checkout`, `playground-deposit` + +--- + +## Code Snippet Generation + +`lib/snippets.ts` exports one function per scenario: + +```ts +generateBridgeSnippet(config: BridgeConfig): string +generateCheckoutSnippet(config: CheckoutConfig): string +generateDepositSnippet(config: DepositConfig): string +``` + +Each returns a complete, copyable TSX component showing: +- Correct imports from `@rozoai/intent-pay` and `@rozoai/intent-common` +- `RozoPayButton.Custom` with all required props +- `onPaymentStarted`, `onPaymentCompleted`, `onPayoutCompleted` callbacks +- `resetPayment` usage (Bridge + Deposit) +- `createPayment` + `payId` usage (Checkout) + +Snippet updates live as form fields change. Rendered via `react-syntax-highlighter` (vscDarkPlus theme). + +--- + +## Event Log + +Small feed below the live button showing real callback events as they fire: + +``` +✓ onPaymentStarted { paymentId: "0x..." } +✓ onPaymentCompleted { ... } +``` + +Cleared on scenario switch or config reset. + +--- + +## pnpm Workspace Integration + +`examples/playground/package.json` references local packages: + +```json +{ + "@rozoai/intent-pay": "workspace:*", + "@rozoai/intent-common": "workspace:*" +} +``` + +Added to root `pnpm-workspace.yaml` under `examples/playground`. + +Dev command added to root `package.json`: +```json +"dev:playground": "pnpm --filter playground dev" +``` + +--- + +## What's Explicitly Out of Scope + +- Authentication / appId management UI (hardcoded `APP_ID` like existing demo) +- Mobile responsive optimization (desktop-first, polish later) +- Dark mode +- Deployment / hosting config diff --git a/examples/nextjs-app/.env.example b/examples/nextjs-app/.env.example deleted file mode 100644 index 2690c5eb3..000000000 --- a/examples/nextjs-app/.env.example +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_ROZOPAY_API_URL= diff --git a/examples/nextjs-app/.eslintrc.json b/examples/nextjs-app/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/examples/nextjs-app/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/examples/nextjs-app/.gitignore b/examples/nextjs-app/.gitignore index c87c9b392..c59e98a0b 100644 --- a/examples/nextjs-app/.gitignore +++ b/examples/nextjs-app/.gitignore @@ -1,9 +1,12 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules /.pnp -.pnp.js +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions # testing /coverage @@ -23,13 +26,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local +pnpm-debug.log* -# vercel -.vercel +# env files (can opt-in for committing if needed) +.env* # typescript *.tsbuildinfo diff --git a/examples/nextjs-app/.prettierignore b/examples/nextjs-app/.prettierignore new file mode 100644 index 000000000..461b00841 --- /dev/null +++ b/examples/nextjs-app/.prettierignore @@ -0,0 +1,7 @@ +dist/ +node_modules/ +.next/ +.turbo/ +coverage/ +pnpm-lock.yaml +.pnpm-store/ \ No newline at end of file diff --git a/examples/nextjs-app/.prettierrc b/examples/nextjs-app/.prettierrc new file mode 100644 index 000000000..a8a2054a1 --- /dev/null +++ b/examples/nextjs-app/.prettierrc @@ -0,0 +1,11 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "app/globals.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/examples/nextjs-app/.vercelignore b/examples/nextjs-app/.vercelignore deleted file mode 100644 index 483a9c42c..000000000 --- a/examples/nextjs-app/.vercelignore +++ /dev/null @@ -1 +0,0 @@ -package-lock.json \ No newline at end of file diff --git a/examples/nextjs-app/AGENTS.md b/examples/nextjs-app/AGENTS.md new file mode 100644 index 000000000..8bd0e3908 --- /dev/null +++ b/examples/nextjs-app/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/examples/nextjs-app/README.md b/examples/nextjs-app/README.md index 5f340e00c..1e66186df 100644 --- a/examples/nextjs-app/README.md +++ b/examples/nextjs-app/README.md @@ -1,6 +1,21 @@ -# [Next.js](https://nextjs.org/) + [TypeScript](https://www.typescriptlang.org/) + ConnectKit Example +# Next.js template -This is a simple example of how to implement ConnectKit with [Next.js](https://nextjs.org/) in TypeScript. +This is a Next.js template with shadcn/ui. -- If you'd like to look at an example online, try this [CodeSandbox](https://codesandbox.io/s/qnvyqe?file=/README.md) -- Or you want to run the example locally have a look at the [instructions in the main README](https://github.com/family/connectkit/blob/main/README.md#running-examples-locally) +## Adding components + +To add components to your app, run the following command: + +```bash +npx shadcn@latest add button +``` + +This will place the ui components in the `components` directory. + +## Using components + +To use the components in your app, import them as follows: + +```tsx +import { Button } from "@/components/ui/button"; +``` diff --git a/examples/nextjs-app/app/bridge/page.tsx b/examples/nextjs-app/app/bridge/page.tsx new file mode 100644 index 000000000..a750fa071 --- /dev/null +++ b/examples/nextjs-app/app/bridge/page.tsx @@ -0,0 +1,7 @@ +import { BridgeMode } from "@/components/BridgeMode"; + +export const dynamic = "force-dynamic"; + +export default function BridgePage() { + return ; +} diff --git a/examples/nextjs-app/app/checkout/page.tsx b/examples/nextjs-app/app/checkout/page.tsx new file mode 100644 index 000000000..6a179c4b3 --- /dev/null +++ b/examples/nextjs-app/app/checkout/page.tsx @@ -0,0 +1,7 @@ +import { CheckoutMode } from "@/components/CheckoutMode"; + +export const dynamic = "force-dynamic"; + +export default function CheckoutPage() { + return ; +} diff --git a/examples/nextjs-app/app/deposit/page.tsx b/examples/nextjs-app/app/deposit/page.tsx new file mode 100644 index 000000000..c554fe426 --- /dev/null +++ b/examples/nextjs-app/app/deposit/page.tsx @@ -0,0 +1,7 @@ +import { DepositMode } from "@/components/DepositMode"; + +export const dynamic = "force-dynamic"; + +export default function DepositPage() { + return ; +} diff --git a/examples/nextjs-app/app/favicon.ico b/examples/nextjs-app/app/favicon.ico new file mode 100644 index 000000000..d8beef725 Binary files /dev/null and b/examples/nextjs-app/app/favicon.ico differ diff --git a/examples/nextjs-app/app/globals.css b/examples/nextjs-app/app/globals.css new file mode 100644 index 000000000..01b302bec --- /dev/null +++ b/examples/nextjs-app/app/globals.css @@ -0,0 +1,145 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + transition: + background-color 200ms ease-out, + color 200ms ease-out; + } + + html { + @apply font-sans; + } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +@media (prefers-reduced-motion: reduce) { + body { + transition: none; + } +} diff --git a/examples/nextjs-app/app/layout.tsx b/examples/nextjs-app/app/layout.tsx new file mode 100644 index 000000000..696a156c7 --- /dev/null +++ b/examples/nextjs-app/app/layout.tsx @@ -0,0 +1,87 @@ +export const dynamic = "force-dynamic"; + +import { PlaygroundNav } from "@/components/PlaygroundNav"; +import { ThemeToggle } from "@/components/ThemeToggle"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { ExternalLink } from "lucide-react"; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import Image from "next/image"; +import Link from "next/link"; +import pkg from "../package.json"; +import "./globals.css"; +import { Providers } from "./providers"; + +const geist = Geist({subsets:['latin'],variable:'--font-sans'}); +const fontMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); + +export const metadata: Metadata = { + title: "Rozo Pay Playground", + description: "Interactive developer playground for @rozoai/intent-pay", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const payVersion = pkg.dependencies["@rozoai/intent-pay"]; + const commonVersion = pkg.dependencies["@rozoai/intent-common"]; + + return ( + + + +
+
+
+ rozo +

+ Rozo Intent SDK Playground +

+
+ + Docs + + + +
+
+
+
+ + {children} + +
+
+ + @rozoai/intent-pay@ + {payVersion} + + + @rozoai/intent-common@ + {commonVersion} + +
+ + + + +
+
+
+
+ + + ); +} diff --git a/examples/nextjs-app/app/page.tsx b/examples/nextjs-app/app/page.tsx new file mode 100644 index 000000000..420b40012 --- /dev/null +++ b/examples/nextjs-app/app/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default function RootPage() { + redirect("/bridge"); +} diff --git a/examples/nextjs-app/app/providers.tsx b/examples/nextjs-app/app/providers.tsx new file mode 100644 index 000000000..3f157b537 --- /dev/null +++ b/examples/nextjs-app/app/providers.tsx @@ -0,0 +1,45 @@ +"use client" + +import { ThemeProvider } from "@/components/theme-provider" +import { + getDefaultConfig as getDefaultConfigRozo, + RozoPayProvider, +} from "@rozoai/intent-pay" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { useTheme } from "next-themes" +import posthog from "posthog-js" +import { useState, type ReactNode } from "react" +import { createConfig, WagmiProvider } from "wagmi" + +const queryClient = new QueryClient() + +function RozoPayProviderWithTheme({ children }: { children: ReactNode }) { + const { resolvedTheme } = useTheme() + const mode = resolvedTheme === "dark" ? "dark" : "light" + + return ( + + {children} + + ) +} + +export function Providers({ children }: { children: ReactNode }) { + const [rozoPayConfig] = useState(() => + createConfig( + getDefaultConfigRozo({ + appName: "Rozo Pay Playground", + }) + ) + ) + + return ( + + + + {children} + + + + ) +} diff --git a/examples/nextjs-app/components.json b/examples/nextjs-app/components.json new file mode 100644 index 000000000..02e61e070 --- /dev/null +++ b/examples/nextjs-app/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/examples/nextjs-app/components/.gitkeep b/examples/nextjs-app/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/nextjs-app/components/BridgeMode.tsx b/examples/nextjs-app/components/BridgeMode.tsx new file mode 100644 index 000000000..201e917d8 --- /dev/null +++ b/examples/nextjs-app/components/BridgeMode.tsx @@ -0,0 +1,211 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { useSharedConfig } from "@/hooks/useSharedConfig" +import { generateBridgeSnippet } from "@/lib/snippets" +import { getKnownToken, TokenSymbol } from "@rozoai/intent-common" +import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay" +import { useCallback, useEffect, useId, useMemo, useState } from "react" +import { CodeSnippet } from "./CodeSnippet" +import { EventLog, type LogEntry } from "./EventLog" +import { ModeDescription } from "./ModeDescription" +import { ParamForm, type ParamFormValues } from "./ParamForm" +import { PreviewPane } from "./PreviewPane" + +const APP_ID = "rozoDemo" + +export function BridgeMode() { + const [config, setConfig, hydrated] = useSharedConfig() + const { resetPayment } = useRozoPayUI() + const [pending, setPending] = useState(config) + const [ready, setReady] = useState(false) + const [resetting, setResetting] = useState(false) + const [resetError, setResetError] = useState(null) + const [logs, setLogs] = useState([]) + const uid = useId() + + // sync pending when storage hydrates + useEffect(() => { + if (hydrated) setPending(config) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + const isPendingValid = + pending.toChain > 0 && + pending.toToken !== "" && + pending.toAddress !== "" && + pending.toUnits !== "" + + const isDirty = useMemo( + () => JSON.stringify(pending) !== JSON.stringify(config), + [pending, config] + ) + + const isConfigValid = + config.toChain > 0 && + config.toToken !== "" && + config.toAddress !== "" && + config.toUnits !== "" + + const knownToken = getKnownToken(config.toChain, config.toToken) + const isDestinationEURC = knownToken + ? knownToken.symbol === TokenSymbol.EURC + : false + + const preferredSymbol = useMemo( + () => + isDestinationEURC + ? [TokenSymbol.EURC] + : [TokenSymbol.USDC, TokenSymbol.USDT], + [isDestinationEURC] + ) + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { id: `${uid}-${Date.now()}`, type, timestamp: Date.now(), payload }, + ]) + }, + [uid] + ) + + const applyConfig = useCallback( + async (c: ParamFormValues) => { + if (!c.toChain || !c.toToken || !c.toAddress || !c.toUnits) return + setResetting(true) + setReady(false) + setResetError(null) + try { + await resetPayment({ + appId: APP_ID, + toChain: c.toChain, + toToken: c.toToken, + toAddress: c.toAddress, + toUnits: c.toUnits, + intent: "Bridge", + preferredSymbol, + }) + setReady(true) + } catch (err) { + setResetError( + err instanceof Error ? err.message : "Failed to initialize payment" + ) + } finally { + setResetting(false) + } + }, + [resetPayment, preferredSymbol] + ) + + const handleConfirm = useCallback(async () => { + setConfig(pending) + await applyConfig(pending) + }, [setConfig, pending, applyConfig]) + + // On hydration, RozoPayButton already picks up config props — no resetPayment needed. + // Just mark ready so Pay Now is enabled. + useEffect(() => { + if (hydrated && isConfigValid) setReady(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + const snippet = isConfigValid ? generateBridgeSnippet(config) : "" + + const preview = ( +
+ {isConfigValid ? ( +
+ addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + + {resetError && ( +
+

{resetError}

+ +
+ )} +
+ ) : ( +

+ Fill in all fields to enable the payment button. +

+ )} +
+ setLogs([])} /> +
+
+ ) + + return ( +
+ +
+ +
+ : null} + /> +
+
+
+ ) +} diff --git a/examples/nextjs-app/components/Chains.tsx b/examples/nextjs-app/components/Chains.tsx new file mode 100644 index 000000000..42dc162ea --- /dev/null +++ b/examples/nextjs-app/components/Chains.tsx @@ -0,0 +1,421 @@ +import { cn } from "@/lib/utils"; +import type { SVGProps } from "react"; + +/** + * Common props for blockchain logo components + */ +interface LogoProps extends Omit, "width" | "height"> { + testnet?: boolean; + width?: number | string; + height?: number | string; +} + +/** + * Common testnet background gradient + */ +const TESTNET_GRADIENT = "linear-gradient(180deg, #8995A9 0%, #424D5F 99.48%)"; + +/** + * Common border radius for chain logos + */ +const BORDER_RADIUS = "50%"; + +/** + * Solana blockchain logo + */ +export const Solana = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); + +/** + * USDC token logo + */ +export const USDC = ({ width = 44, height = 44, ...props }: LogoProps) => ( + +); + +/** + * Ethereum blockchain logo + */ +export const Ethereum = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => { + const bg = testnet + ? TESTNET_GRADIENT + : "var(--ck-chain-ethereum-01, #25292E)"; + const fill = testnet ? "#fff" : "var(--ck-chain-ethereum-02, #ffffff)"; + + return ( + + ); +}; + +/** + * Polygon blockchain logo + */ +export const Polygon = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); +/** + * Optimism blockchain logo + */ +export const Optimism = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); + +/** + * Base blockchain logo + */ +export const Base = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); + +/** + * Stellar blockchain logo + */ +export const Stellar = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); + +export const BinanceSmartChain = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => ( + +); + +export const Arbitrum = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => { + return ( + + ); +}; + +export const HyperEVM = ({ + testnet, + width = 44, + height = 44, + ...props +}: LogoProps) => { + return ( + + ); +}; diff --git a/examples/nextjs-app/components/CheckoutMode.tsx b/examples/nextjs-app/components/CheckoutMode.tsx new file mode 100644 index 000000000..cdf8c2482 --- /dev/null +++ b/examples/nextjs-app/components/CheckoutMode.tsx @@ -0,0 +1,248 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useSharedConfig } from "@/hooks/useSharedConfig" +import { generateCheckoutSnippet } from "@/lib/snippets" +import { createPayment } from "@rozoai/intent-common" +import { RozoPayButton } from "@rozoai/intent-pay" +import { useCallback, useEffect, useId, useMemo, useState } from "react" +import { CodeSnippet } from "./CodeSnippet" +import { EventLog, type LogEntry } from "./EventLog" +import { ModeDescription } from "./ModeDescription" +import { ParamForm } from "./ParamForm" +import { PreviewPane } from "./PreviewPane" + +const APP_ID = "rozoDemo" + +export function CheckoutMode() { + const [config, setConfig, hydrated] = useSharedConfig() + const [pending, setPending] = useState(config) + const [paymentId, setPaymentId] = useState(null) + const [manualPayId, setManualPayId] = useState("") + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const uid = useId() + // sync pending when storage hydrates + useEffect(() => { + if (hydrated) setPending(config) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + const isPendingValid = + pending.toChain > 0 && + pending.toToken !== "" && + pending.toAddress !== "" && + pending.toUnits !== "" + + const isDirty = useMemo( + () => JSON.stringify(pending) !== JSON.stringify(config), + [pending, config] + ) + + const isConfigValid = + config.toChain > 0 && + config.toToken !== "" && + config.toAddress !== "" && + config.toUnits !== "" + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { id: `${uid}-${Date.now()}`, type, timestamp: Date.now(), payload }, + ]) + }, + [uid] + ) + + const resetPaymentState = useCallback(() => { + setPaymentId(null) + setError(null) + setManualPayId("") + }, []) + + const handleUseManualPayId = useCallback(() => { + const id = manualPayId.trim() + if (!id) return + setPaymentId(id) + setError(null) + }, [manualPayId]) + + const handleConfirm = useCallback(() => { + setConfig(pending) + resetPaymentState() + }, [setConfig, pending, resetPaymentState]) + + const handleCreatePayment = useCallback(async () => { + setCreating(true) + setError(null) + setPaymentId(null) + try { + const result = await createPayment({ + appId: APP_ID, + toChain: config.toChain, + toToken: config.toToken, + toAddress: config.toAddress, + toUnits: config.toUnits, + preferredChain: config.toChain, + preferredTokenAddress: config.toToken, + }) + setPaymentId(result.id) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create payment") + } finally { + setCreating(false) + } + }, [config]) + + const snippet = isConfigValid ? generateCheckoutSnippet(config) : "" + + const preview = ( +
+ {!paymentId ? ( +
+ {isConfigValid && ( + <> + + {error && ( +
+

{error}

+ +
+ )} +
+
+ or +
+
+ + )} +
+ +
+ setManualPayId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleUseManualPayId()} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="border-border bg-secondary font-mono text-xs flex-1" + /> + +
+
+ {!isConfigValid && ( +

+ Fill in configuration fields to use Create Payment, or enter a Payment ID directly. +

+ )} +
+ ) : ( +
+ addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + +
+

+ Payment ID: {paymentId} +

+ +
+
+ )} +
+ setLogs([])} /> +
+
+ ) + + return ( +
+ +
+ +
+ : null} + /> +
+
+
+ ) +} diff --git a/examples/nextjs-app/components/CodeSnippet.tsx b/examples/nextjs-app/components/CodeSnippet.tsx new file mode 100644 index 000000000..172861f4c --- /dev/null +++ b/examples/nextjs-app/components/CodeSnippet.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Check, Copy } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; + +interface CodeSnippetProps { + code: string; +} + +export function CodeSnippet({ code }: CodeSnippetProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [code]); + + return ( +
+ + + {code} + +
+ ); +} diff --git a/examples/nextjs-app/components/DepositMode.tsx b/examples/nextjs-app/components/DepositMode.tsx new file mode 100644 index 000000000..e21b9370f --- /dev/null +++ b/examples/nextjs-app/components/DepositMode.tsx @@ -0,0 +1,206 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { useSharedConfig } from "@/hooks/useSharedConfig" +import { generateDepositSnippet } from "@/lib/snippets" +import { getKnownToken, TokenSymbol } from "@rozoai/intent-common" +import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay" +import { useCallback, useEffect, useId, useMemo, useState } from "react" +import { CodeSnippet } from "./CodeSnippet" +import { EventLog, type LogEntry } from "./EventLog" +import { ModeDescription } from "./ModeDescription" +import { ParamForm, type ParamFormValues } from "./ParamForm" +import { PreviewPane } from "./PreviewPane" + +const APP_ID = "rozoDemo" + +export function DepositMode() { + const [config, setConfig, hydrated] = useSharedConfig() + const { resetPayment } = useRozoPayUI() + const [pending, setPending] = useState(config) + const [ready, setReady] = useState(false) + const [resetting, setResetting] = useState(false) + const [resetError, setResetError] = useState(null) + const [logs, setLogs] = useState([]) + const uid = useId() + + // sync pending when storage hydrates + useEffect(() => { + if (hydrated) setPending(config) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + const isPendingValid = + pending.toChain > 0 && pending.toToken !== "" && pending.toAddress !== "" + + const isDirty = useMemo( + () => JSON.stringify(pending) !== JSON.stringify(config), + [pending, config] + ) + + const isConfigValid = + config.toChain > 0 && config.toToken !== "" && config.toAddress !== "" + + const knownToken = getKnownToken(config.toChain, config.toToken) + const isDestinationEURC = knownToken + ? knownToken.symbol === TokenSymbol.EURC + : false + + const preferredSymbol = useMemo( + () => + isDestinationEURC + ? [TokenSymbol.EURC] + : [TokenSymbol.USDC, TokenSymbol.USDT], + [isDestinationEURC] + ) + + const addLog = useCallback( + (type: LogEntry["type"], payload: unknown) => { + setLogs((prev) => [ + ...prev, + { id: `${uid}-${Date.now()}`, type, timestamp: Date.now(), payload }, + ]) + }, + [uid] + ) + + const applyConfig = useCallback( + async (c: ParamFormValues) => { + if (!c.toChain || !c.toToken || !c.toAddress) return + setResetting(true) + setReady(false) + setResetError(null) + try { + await resetPayment({ + toChain: c.toChain, + toToken: c.toToken, + toAddress: c.toAddress, + toUnits: undefined, // explicit clear — user sets amount inside modal + intent: "Deposit", + preferredSymbol, + }) + setReady(true) + } catch (err) { + setResetError( + err instanceof Error ? err.message : "Failed to initialize payment" + ) + } finally { + setResetting(false) + } + }, + [resetPayment, preferredSymbol] + ) + + const handleConfirm = useCallback(async () => { + setConfig(pending) + await applyConfig(pending) + }, [setConfig, pending, applyConfig]) + + // On hydration, RozoPayButton already picks up config props — no resetPayment needed. + // Just mark ready so Deposit is enabled. + useEffect(() => { + if (hydrated && isConfigValid) setReady(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated]) + + const snippet = isConfigValid ? generateDepositSnippet(config) : "" + + const preview = ( +
+ {isConfigValid ? ( +
+ addLog("started", e)} + onPaymentCompleted={(e) => addLog("completed", e)} + onPayoutCompleted={(e) => addLog("payout", e)} + > + {({ show }) => ( + + )} + + {resetError && ( +
+

{resetError}

+ +
+ )} +
+ ) : ( +

+ Fill in all fields to enable the deposit button. +

+ )} +
+ setLogs([])} /> +
+
+ ) + + return ( +
+ +
+ +
+ : null} + /> +
+
+
+ ) +} diff --git a/examples/nextjs-app/components/EventLog.tsx b/examples/nextjs-app/components/EventLog.tsx new file mode 100644 index 000000000..232a28d98 --- /dev/null +++ b/examples/nextjs-app/components/EventLog.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ChevronDown, ChevronRight, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +export interface LogEntry { + id: string; + type: "started" | "completed" | "payout"; + timestamp?: number; + payload: unknown; +} + +interface EventLogProps { + entries: LogEntry[]; + onClear?: () => void; +} + +const labelMap: Record = { + started: "onPaymentStarted", + completed: "onPaymentCompleted", + payout: "onPayoutCompleted", +}; + +const colorMap: Record = { + started: "bg-blue-500/20 text-blue-600 border-blue-500/30 dark:text-blue-300", + completed: "bg-green-500/20 text-green-700 border-green-500/30 dark:text-green-300", + payout: "bg-violet-500/20 text-violet-700 border-violet-500/30 dark:text-violet-300", +}; + +function formatTime(ts?: number) { + if (!ts) return null; + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function EventEntry({ entry }: { entry: LogEntry }) { + const [open, setOpen] = useState(true); + const json = JSON.stringify(entry.payload, null, 2); + + return ( +
+ {/* sticky header */} + + + {/* collapsible body */} + {open && ( +
+
+            
+          
+
+ )} +
+ ); +} + +function JsonHighlight({ json }: { json: string }) { + const parts: React.ReactNode[] = []; + // tokenise just enough for keys / strings / numbers / booleans / null + const re = /("(?:[^"\\]|\\.)*")\s*(:)?|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + let last = 0; + let match: RegExpExecArray | null; + + while ((match = re.exec(json)) !== null) { + if (match.index > last) { + parts.push({json.slice(last, match.index)}); + } + + if (match[1] && match[2]) { + // object key + parts.push({match[1]}); + parts.push(:); + } else if (match[1]) { + // string value + parts.push({match[1]}); + } else if (match[3]) { + // boolean / null + parts.push({match[3]}); + } else if (match[4]) { + // number + parts.push({match[4]}); + } + + last = match.index + match[0].length; + } + + if (last < json.length) { + parts.push({json.slice(last)}); + } + + return <>{parts}; +} + +export function EventLog({ entries, onClear }: EventLogProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [entries.length]); + + return ( +
+
+

+ Events + {entries.length > 0 && ( + ({entries.length}) + )} +

+ {entries.length > 0 && onClear && ( + + )} +
+ + {entries.length === 0 ? ( +

+ Events will appear here as you complete payment steps. +

+ ) : ( + +
+ {entries.map((entry) => ( + + ))} +
+
+ + )} +
+ ); +} diff --git a/examples/nextjs-app/components/ModeDescription.tsx b/examples/nextjs-app/components/ModeDescription.tsx new file mode 100644 index 000000000..3b0b3db61 --- /dev/null +++ b/examples/nextjs-app/components/ModeDescription.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +interface Step { + step: number; + label: string; +} + +interface ModeDescriptionProps { + title: string; + summary: string; + steps: Step[]; + note?: ReactNode; +} + +export function ModeDescription({ title, summary, steps, note }: ModeDescriptionProps) { + return ( +
+
+

{title}

+

{summary}

+
+
    + {steps.map(({ step, label }) => ( +
  1. + + {step} + + {label} +
  2. + ))} +
+ {note && ( +

{note}

+ )} +
+ ); +} diff --git a/examples/nextjs-app/components/ParamForm.tsx b/examples/nextjs-app/components/ParamForm.tsx new file mode 100644 index 000000000..08fb82686 --- /dev/null +++ b/examples/nextjs-app/components/ParamForm.tsx @@ -0,0 +1,219 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { getSupportedChains, getTokensForChain } from "@/lib/chains" +import { validateAddressForChain } from "@rozoai/intent-common" +import { useEffect, useMemo } from "react" + +export interface ParamFormValues { + toChain: number + toToken: string + toAddress: string + toUnits: string +} + +interface ParamFormProps { + values: ParamFormValues + onChange: (values: ParamFormValues) => void + showAmount?: boolean + hydrated?: boolean +} + +const chains = getSupportedChains() + +export function ParamForm({ + values, + onChange, + showAmount = true, + hydrated = true, +}: ParamFormProps) { + const tokens = useMemo( + () => getTokensForChain(values.toChain), + [values.toChain] + ) + + const selectedChain = useMemo( + () => chains.find((c) => c.chainId === values.toChain), + [values.toChain] + ) + + const selectedToken = useMemo( + () => tokens.find((t) => t.token === values.toToken), + [tokens, values.toToken] + ) + + const addressPlaceholder = useMemo(() => { + if (!selectedChain) return "0x… or Solana/Stellar address" + switch (selectedChain.type) { + case "solana": + return "e.g. 9no8…Bzr (Solana address)" + case "stellar": + return "e.g. GABC…XYZ (Stellar G-address)" + default: + return "e.g. 0x1234…abcd (EVM address)" + } + }, [selectedChain]) + + const addressError = useMemo(() => { + if (!values.toAddress || !values.toChain) return null + return validateAddressForChain(values.toChain, values.toAddress) + ? null + : `Invalid ${selectedChain?.type?.toUpperCase() ?? ""} address` + }, [values.toAddress, values.toChain, selectedChain]) + + // Reset token when chain changes and current token not available on new chain. + // Skip until hydrated to avoid overwriting localStorage before saved config loads. + useEffect(() => { + if (!hydrated) return + const tokenExists = tokens.some((t) => t.token === values.toToken) + if (!tokenExists && tokens.length > 0) { + onChange({ ...values, toToken: tokens[0].token }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokens, hydrated]) + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + onChange({ ...values, toAddress: e.target.value })} + placeholder={addressPlaceholder} + className={`border-border bg-secondary font-mono text-xs ${addressError ? "border-destructive focus-visible:ring-destructive" : ""}`} + /> + {addressError && ( +

+ {addressError} +

+ )} +
+ + {showAmount && ( +
+ + onChange({ ...values, toUnits: e.target.value })} + placeholder="e.g. 1.00" + className="border-border bg-secondary" + /> +

+ Human-readable amount (e.g. 1 = 1 USDC). The SDK handles decimals. +

+
+ )} +
+ ) +} diff --git a/examples/nextjs-app/components/PlaygroundNav.tsx b/examples/nextjs-app/components/PlaygroundNav.tsx new file mode 100644 index 000000000..29e479607 --- /dev/null +++ b/examples/nextjs-app/components/PlaygroundNav.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +const tabs = [ + { href: "/bridge", label: "Bridge" }, + { href: "/checkout", label: "Online Checkout" }, + { href: "/deposit", label: "Wallet Deposit" }, +]; + +export function PlaygroundNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/examples/nextjs-app/components/PreviewPane.tsx b/examples/nextjs-app/components/PreviewPane.tsx new file mode 100644 index 000000000..9de2474fd --- /dev/null +++ b/examples/nextjs-app/components/PreviewPane.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { ReactNode } from "react"; + +interface PreviewPaneProps { + preview: ReactNode; + code: ReactNode; + emptyState?: ReactNode; +} + +export function PreviewPane({ preview, code, emptyState }: PreviewPaneProps) { + return ( + + + Preview + Code + + +
+ {preview} +
+
+ +
+ {code ?? ( + emptyState ?? ( +
+

+ Fill in the configuration to generate code. +

+
+ ) + )} +
+
+
+ ); +} diff --git a/examples/nextjs-app/components/ThemeToggle.tsx b/examples/nextjs-app/components/ThemeToggle.tsx new file mode 100644 index 000000000..99c2b0f32 --- /dev/null +++ b/examples/nextjs-app/components/ThemeToggle.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return
; + } + + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/examples/nextjs-app/components/theme-provider.tsx b/examples/nextjs-app/components/theme-provider.tsx new file mode 100644 index 000000000..7e88859f2 --- /dev/null +++ b/examples/nextjs-app/components/theme-provider.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes" + +function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + ) +} + +function isTypingTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false + } + + return ( + target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" + ) +} + +function ThemeHotkey() { + const { resolvedTheme, setTheme } = useTheme() + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.repeat) { + return + } + + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (event.key.toLowerCase() !== "d") { + return + } + + if (isTypingTarget(event.target)) { + return + } + + setTheme(resolvedTheme === "dark" ? "light" : "dark") + } + + window.addEventListener("keydown", onKeyDown) + + return () => { + window.removeEventListener("keydown", onKeyDown) + } + }, [resolvedTheme, setTheme]) + + return null +} + +export { ThemeProvider } diff --git a/examples/nextjs-app/components/ui/badge.tsx b/examples/nextjs-app/components/ui/badge.tsx new file mode 100644 index 000000000..cacff11dc --- /dev/null +++ b/examples/nextjs-app/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/examples/nextjs-app/components/ui/button.tsx b/examples/nextjs-app/components/ui/button.tsx new file mode 100644 index 000000000..75b8c3df3 --- /dev/null +++ b/examples/nextjs-app/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/examples/nextjs-app/components/ui/input.tsx b/examples/nextjs-app/components/ui/input.tsx new file mode 100644 index 000000000..d763cd9ab --- /dev/null +++ b/examples/nextjs-app/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/examples/nextjs-app/components/ui/label.tsx b/examples/nextjs-app/components/ui/label.tsx new file mode 100644 index 000000000..1ac80f701 --- /dev/null +++ b/examples/nextjs-app/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/examples/nextjs-app/components/ui/scroll-area.tsx b/examples/nextjs-app/components/ui/scroll-area.tsx new file mode 100644 index 000000000..facbbe7d8 --- /dev/null +++ b/examples/nextjs-app/components/ui/scroll-area.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/examples/nextjs-app/components/ui/select.tsx b/examples/nextjs-app/components/ui/select.tsx new file mode 100644 index 000000000..f09dfb489 --- /dev/null +++ b/examples/nextjs-app/components/ui/select.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/examples/nextjs-app/components/ui/separator.tsx b/examples/nextjs-app/components/ui/separator.tsx new file mode 100644 index 000000000..d4570908d --- /dev/null +++ b/examples/nextjs-app/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/examples/nextjs-app/components/ui/tabs.tsx b/examples/nextjs-app/components/ui/tabs.tsx new file mode 100644 index 000000000..05f469f2a --- /dev/null +++ b/examples/nextjs-app/components/ui/tabs.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/examples/nextjs-app/components/ui/tooltip.tsx b/examples/nextjs-app/components/ui/tooltip.tsx new file mode 100644 index 000000000..bb1ea5234 --- /dev/null +++ b/examples/nextjs-app/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/examples/nextjs-app/eslint.config.mjs b/examples/nextjs-app/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/nextjs-app/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/examples/nextjs-app/hooks/.gitkeep b/examples/nextjs-app/hooks/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/nextjs-app/hooks/usePlaygroundConfig.ts b/examples/nextjs-app/hooks/usePlaygroundConfig.ts new file mode 100644 index 000000000..b312f13ff --- /dev/null +++ b/examples/nextjs-app/hooks/usePlaygroundConfig.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export function usePlaygroundConfig( + key: string, + defaults: T, +): [T, (value: T) => void] { + const [config, setConfigState] = useState(defaults); + + useEffect(() => { + try { + const raw = localStorage.getItem(key); + if (raw) { + setConfigState(JSON.parse(raw) as T); + } + } catch { + // corrupted storage — fall back to defaults + } + }, [key]); + + const setConfig = useCallback( + (value: T) => { + setConfigState(value); + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // storage full or unavailable — ignore + } + }, + [key], + ); + + return [config, setConfig]; +} diff --git a/examples/nextjs-app/hooks/useSharedConfig.ts b/examples/nextjs-app/hooks/useSharedConfig.ts new file mode 100644 index 000000000..876a88c6e --- /dev/null +++ b/examples/nextjs-app/hooks/useSharedConfig.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export interface SharedConfig { + toChain: number; + toToken: string; + toAddress: string; + toUnits: string; +} + +const STORAGE_KEY = "playground-config"; + +const DEFAULTS: SharedConfig = { + toChain: 8453, + toToken: "", + toAddress: "", + toUnits: "", +}; + +function loadFromStorage(): SharedConfig { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...DEFAULTS, ...(JSON.parse(raw) as Partial) }; + } catch { + // corrupted — fall back to defaults + } + return DEFAULTS; +} + +export function useSharedConfig(): [SharedConfig, (value: SharedConfig) => void, boolean] { + const [config, setConfigState] = useState(DEFAULTS); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (!hydrated) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setConfigState(loadFromStorage()); + setHydrated(true); + } + }, [hydrated]); + + const setConfig = useCallback((value: SharedConfig) => { + setConfigState(value); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // storage unavailable — ignore + } + }, []); + + return [config, setConfig, hydrated]; +} diff --git a/examples/nextjs-app/instrumentation-client.ts b/examples/nextjs-app/instrumentation-client.ts new file mode 100644 index 000000000..d8846a777 --- /dev/null +++ b/examples/nextjs-app/instrumentation-client.ts @@ -0,0 +1,13 @@ +import { APP_NAME } from "@/lib/analytics/events" +import posthog from "posthog-js" + +if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: + process.env.NEXT_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com", + defaults: "2026-05-30", + capture_pageview: false, + }) + + posthog.register({ app_name: APP_NAME }) +} diff --git a/examples/nextjs-app/lib/.gitkeep b/examples/nextjs-app/lib/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/nextjs-app/lib/analytics/events.ts b/examples/nextjs-app/lib/analytics/events.ts new file mode 100644 index 000000000..acb72237b --- /dev/null +++ b/examples/nextjs-app/lib/analytics/events.ts @@ -0,0 +1,28 @@ +export const GLOBAL_EVENTS = { + USER_IDENTIFIED: "user_identified", + USER_RESET: "user_reset", + PAGE_VIEWED: "page_viewed", + ERROR_OCCURRED: "error_occurred", +} as const; + +export const PAYMENT_EVENTS = { + PAYMENT_FLOW_STARTED: "payment_flow_started", + PAYMENT_METHOD_SELECTED: "payment_method_selected", + PAYMENT_QUOTE_REQUESTED: "payment_quote_requested", + PAYMENT_QUOTE_RECEIVED: "payment_quote_received", + PAYMENT_QUOTE_FAILED: "payment_quote_failed", + PAYMENT_CONFIRMED: "payment_confirmed", + PAYMENT_SUBMITTED: "payment_submitted", + PAYMENT_COMPLETED: "payment_completed", + PAYMENT_FAILED: "payment_failed", + PAYMENT_CANCELLED: "payment_cancelled", +} as const; + +export const ROZO_EVENTS = { + ...GLOBAL_EVENTS, + ...PAYMENT_EVENTS, +} as const; + +export type RozoEventName = (typeof ROZO_EVENTS)[keyof typeof ROZO_EVENTS]; + +export const APP_NAME = "intent-sdk-demo" as const; diff --git a/examples/nextjs-app/lib/analytics/index.ts b/examples/nextjs-app/lib/analytics/index.ts new file mode 100644 index 000000000..49675043e --- /dev/null +++ b/examples/nextjs-app/lib/analytics/index.ts @@ -0,0 +1,22 @@ +import posthog from "posthog-js"; +import type { RozoEventName } from "./events"; + +export function capture(event: RozoEventName, properties?: Record) { + if (typeof window === "undefined") return; + posthog.capture(event, properties); +} + +export function identifyUser(walletAddress: string, properties?: Record) { + if (typeof window === "undefined") return; + posthog.identify(walletAddress.toLowerCase(), { + wallet_address: walletAddress.toLowerCase(), + ...properties, + }); +} + +export function resetUser() { + if (typeof window === "undefined") return; + posthog.reset(); +} + +export { ROZO_EVENTS, APP_NAME } from "./events"; diff --git a/examples/nextjs-app/lib/chains.ts b/examples/nextjs-app/lib/chains.ts new file mode 100644 index 000000000..fedc808ad --- /dev/null +++ b/examples/nextjs-app/lib/chains.ts @@ -0,0 +1,105 @@ +import { + Arbitrum, + Base, + BinanceSmartChain, + Ethereum, + HyperEVM, + Optimism, + Polygon, + Solana, + Stellar, +} from "@/components/Chains" +import { + getChainById, + supportedPayoutTokens, + TokenLogo, + type Token, +} from "@rozoai/intent-common" +import type { ComponentType, SVGProps } from "react" + +type LogoComponent = ComponentType< + Omit, "width" | "height"> & { + width?: number | string + height?: number | string + } +> + +const CHAIN_LOGO_MAP: Record = { + 42161: Arbitrum, + 8453: Base, + 56: BinanceSmartChain, + 1: Ethereum, + 999: HyperEVM, + 10: Optimism, + 137: Polygon, + 900: Solana, + 501: Solana, + 1500: Stellar, + 10001: Stellar, +} + +const TOKEN_LOGO_MAP: Record = { + ETH: TokenLogo.ETH, + WETH: TokenLogo.WETH, + USDC: TokenLogo.USDC, + EURC: TokenLogo.EURC, + USDT: TokenLogo.USDT, + DAI: TokenLogo.DAI, + POL: TokenLogo.POL, + AVAX: TokenLogo.AVAX, + BNB: TokenLogo.BNB, + SOL: TokenLogo.SOL, + WLD: TokenLogo.WLD, + USDB: TokenLogo.USDB, + BLAST: TokenLogo.BLAST, + WBTC: TokenLogo.WBTC, + MNT: TokenLogo.MNT, + CELO: TokenLogo.CELO, + cUSD: TokenLogo.cUSD, + XLM: TokenLogo.XLM, + HYPE: TokenLogo.HYPE, +} + +export interface ChainOption { + chainId: number + name: string + type: "evm" | "solana" | "stellar" + LogoComponent?: LogoComponent +} + +export interface TokenOption { + token: string + symbol: string + logoUrl?: string +} + +function isNotNull(value: T | null): value is T { + return value !== null +} + +export function getSupportedChains(): ChainOption[] { + const chainIds = Array.from(supportedPayoutTokens.keys()) + + return chainIds + .map((id) => { + const chain = getChainById(id) + if (!chain) return null + + return { + chainId: id, + name: chain.name, + type: chain.type as "evm" | "solana" | "stellar", + LogoComponent: CHAIN_LOGO_MAP[id], + } + }) + .filter(isNotNull) +} + +export function getTokensForChain(chainId: number): TokenOption[] { + const tokens: Token[] = supportedPayoutTokens.get(chainId) ?? [] + return tokens.map((t) => ({ + token: t.token, + symbol: t.symbol, + logoUrl: TOKEN_LOGO_MAP[t.symbol], + })) +} diff --git a/examples/nextjs-app/lib/snippets.ts b/examples/nextjs-app/lib/snippets.ts new file mode 100644 index 000000000..91415f7d1 --- /dev/null +++ b/examples/nextjs-app/lib/snippets.ts @@ -0,0 +1,355 @@ +import { + getChainById, + getKnownToken, + TokenSymbol, + // chains + arbitrum, + base, + bsc, + celo, + ethereum, + linea, + mantle, + optimism, + polygon, + solana, + stellar, + worldchain, + gnosis, + avalanche, + hyperEVM, + rozoSolana, + rozoStellar, + // tokens + arbitrumETH, + arbitrumWETH, + arbitrumUSDC, + arbitrumUSDT, + arbitrumDAI, + baseETH, + baseUSDC, + baseEURC, + bscBNB, + ethereumETH, + lineaETH, + mantleMNT, + optimismETH, + polygonPOL, + solanaSOL, + solanaWSOL, + solanaUSDC, + solanaUSDT, + stellarXLM, + stellarUSDC, + worldchainETH, + worldchainUSDC, + gnosisXDAI, + avalancheAVAX, + rozoSolanaUSDC, + rozoSolanaUSDT, + rozoStellarUSDC, + rozoStellarEURC, +} from "@rozoai/intent-common" +import type { Token } from "@rozoai/intent-common" + +export interface BridgeConfig { + toChain: number + toToken: string + toAddress: string + toUnits: string +} + +export type CheckoutConfig = BridgeConfig + +export interface DepositConfig { + toChain: number + toToken: string + toAddress: string +} + +const APP_ID = "rozoDemo" + +// Map chainId → constant name used in generated snippets +const CHAIN_CONST: Record = { + [arbitrum.chainId]: "arbitrum", + [base.chainId]: "base", + [bsc.chainId]: "bsc", + [celo.chainId]: "celo", + [ethereum.chainId]: "ethereum", + [linea.chainId]: "linea", + [mantle.chainId]: "mantle", + [optimism.chainId]: "optimism", + [polygon.chainId]: "polygon", + [solana.chainId]: "solana", + [stellar.chainId]: "stellar", + [worldchain.chainId]: "worldchain", + [gnosis.chainId]: "gnosis", + [avalanche.chainId]: "avalanche", + [hyperEVM.chainId]: "hyperEVM", + [rozoSolana.chainId]: "rozoSolana", + [rozoStellar.chainId]: "rozoStellar", +} + +// All known token constants for lookup +const KNOWN_TOKENS: Array<{ name: string; token: Token }> = [ + { name: "arbitrumETH", token: arbitrumETH }, + { name: "arbitrumWETH", token: arbitrumWETH }, + { name: "arbitrumUSDC", token: arbitrumUSDC }, + { name: "arbitrumUSDT", token: arbitrumUSDT }, + { name: "arbitrumDAI", token: arbitrumDAI }, + { name: "baseETH", token: baseETH }, + { name: "baseUSDC", token: baseUSDC }, + { name: "baseEURC", token: baseEURC }, + { name: "bscBNB", token: bscBNB }, + { name: "ethereumETH", token: ethereumETH }, + { name: "lineaETH", token: lineaETH }, + { name: "mantleMNT", token: mantleMNT }, + { name: "optimismETH", token: optimismETH }, + { name: "polygonPOL", token: polygonPOL }, + { name: "solanaSOL", token: solanaSOL }, + { name: "solanaWSOL", token: solanaWSOL }, + { name: "solanaUSDC", token: solanaUSDC }, + { name: "solanaUSDT", token: solanaUSDT }, + { name: "stellarXLM", token: stellarXLM }, + { name: "stellarUSDC", token: stellarUSDC }, + { name: "worldchainETH", token: worldchainETH }, + { name: "worldchainUSDC", token: worldchainUSDC }, + { name: "gnosisXDAI", token: gnosisXDAI }, + { name: "avalancheAVAX", token: avalancheAVAX }, + { name: "rozoSolanaUSDC", token: rozoSolanaUSDC }, + { name: "rozoSolanaUSDT", token: rozoSolanaUSDT }, + { name: "rozoStellarUSDC", token: rozoStellarUSDC }, + { name: "rozoStellarEURC", token: rozoStellarEURC }, +] + +function tokenAddrEq(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +function findTokenConst(chainId: number, tokenAddr: string): string | null { + const match = KNOWN_TOKENS.find( + (t) => t.token.chainId === chainId && tokenAddrEq(t.token.token, tokenAddr) + ) + return match ? match.name : null +} + +function isEvm(chainId: number): boolean { + return getChainById(chainId)?.type === "evm" +} + +/** Returns JS expression for a chain ID in generated code */ +function chainExpr(chainId: number): string { + const name = CHAIN_CONST[chainId] + return name ? `${name}.chainId` : `${chainId}` +} + +/** Returns JS expression for a token address in generated code */ +function tokExpr(tokenAddr: string, chainId: number): string { + const name = findTokenConst(chainId, tokenAddr) + if (name) return `${name}.token` + return isEvm(chainId) ? `getAddress("${tokenAddr}")` : `"${tokenAddr}"` +} + +/** Returns JS expression for a destination address in generated code */ +function addrExpr(address: string, chainId: number): string { + return isEvm(chainId) ? `getAddress("${address}")` : `"${address}"` +} + +/** Collect intent-common imports needed for given chainId + tokenAddr */ +function buildCommonImports( + chainId: number, + tokenAddr: string, + extraSymbols: string[] = [] +): string { + const symbols: string[] = [...extraSymbols] + + const chainName = CHAIN_CONST[chainId] + if (chainName) symbols.push(chainName) + + const tokName = findTokenConst(chainId, tokenAddr) + if (tokName) symbols.push(tokName) + + if (symbols.length === 0) return "" + return `import { ${symbols.join(", ")} } from "@rozoai/intent-common";\n` +} + +function viemImport(chainId: number, tokenAddr: string): string { + const tokName = findTokenConst(chainId, tokenAddr) + // Only need getAddress if token isn't a named constant and chain is EVM + if (!tokName && isEvm(chainId)) return `import { getAddress } from "viem";\n` + // Still need getAddress for the destination address on EVM + if (isEvm(chainId)) return `import { getAddress } from "viem";\n` + return "" +} + +export function generateBridgeSnippet(config: BridgeConfig): string { + const addr = addrExpr(config.toAddress, config.toChain) + const tok = tokExpr(config.toToken, config.toChain) + const chain = chainExpr(config.toChain) + + const knownToken = getKnownToken(config.toChain, config.toToken) + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" + const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + + const commonImport = buildCommonImports(config.toChain, config.toToken) + const viem = viemImport(config.toChain, config.toToken) + + return `${viem}${commonImport}import { RozoPayButton, useRozoPayUI${tokenSymbolImport} } from "@rozoai/intent-pay"; +import { useEffect, useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function BridgePayment() { + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(false); + resetPayment({ + toChain: ${chain}, + toToken: ${tok}, + toAddress: ${addr}, + toUnits: "${config.toUnits}", + }).then(() => setReady(true)); + }, [resetPayment]); + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => ( + + )} + + ); +}` +} + +export function generateCheckoutSnippet(config: CheckoutConfig): string { + const addr = addrExpr(config.toAddress, config.toChain) + const tok = tokExpr(config.toToken, config.toChain) + const chain = chainExpr(config.toChain) + + const knownToken = getKnownToken(config.toChain, config.toToken) + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" + const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + + const commonImport = buildCommonImports(config.toChain, config.toToken) + const viem = viemImport(config.toChain, config.toToken) + + return `${viem}${commonImport}import { RozoPayButton${tokenSymbolImport} } from "@rozoai/intent-pay"; +import { createPayment } from "@rozoai/intent-common"; +import { useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function OnlineCheckout() { + const [paymentId, setPaymentId] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleCreatePayment() { + setLoading(true); + setPaymentId(null); + try { + const result = await createPayment({ + appId: APP_ID, + toChain: ${chain}, + toToken: ${tok}, + toAddress: ${addr}, + toUnits: "${config.toUnits}", + preferredChain: ${chain}, + preferredTokenAddress: ${tok}, + }); + setPaymentId(result.id); + } finally { + setLoading(false); + } + } + + if (!paymentId) { + return ( + + ); + } + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => } + + ); +}` +} + +export function generateDepositSnippet(config: DepositConfig): string { + const addr = addrExpr(config.toAddress, config.toChain) + const tok = tokExpr(config.toToken, config.toChain) + const chain = chainExpr(config.toChain) + + const knownToken = getKnownToken(config.toChain, config.toToken) + const isEURC = knownToken ? knownToken.symbol === TokenSymbol.EURC : false + const preferredSymbolProp = isEURC ? "\n preferredSymbol={[TokenSymbol.EURC]}" : "" + const tokenSymbolImport = isEURC ? ", TokenSymbol" : "" + + const commonImport = buildCommonImports(config.toChain, config.toToken) + const viem = viemImport(config.toChain, config.toToken) + + return `${viem}${commonImport}import { RozoPayButton, useRozoPayUI${tokenSymbolImport} } from "@rozoai/intent-pay"; +import { useEffect, useState } from "react"; + +const APP_ID = "${APP_ID}"; + +export default function WalletDeposit() { + const { resetPayment } = useRozoPayUI(); + const [ready, setReady] = useState(false); + + useEffect(() => { + setReady(false); + resetPayment({ + toChain: ${chain}, + toToken: ${tok}, + toAddress: ${addr}, + // No toUnits — user enters amount inside the modal + }).then(() => setReady(true)); + }, [resetPayment]); + + return ( + console.log("started", e)} + onPaymentCompleted={(e) => console.log("completed", e)} + onPayoutCompleted={(e) => console.log("payout", e)} + > + {({ show }) => ( + + )} + + ); +}` +} diff --git a/examples/nextjs-app/lib/utils.ts b/examples/nextjs-app/lib/utils.ts new file mode 100644 index 000000000..bd0c391dd --- /dev/null +++ b/examples/nextjs-app/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/examples/nextjs-app/next.config.ts b/examples/nextjs-app/next.config.ts index 34a043f0c..fd5562190 100644 --- a/examples/nextjs-app/next.config.ts +++ b/examples/nextjs-app/next.config.ts @@ -1,21 +1,18 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from "next" -// Define the monorepo packages we want to use locally -const monoRepoPackages = ["@rozoai/intent-pay", "@rozoai/intent-common"]; - -// Check if we're using local packages -const useLocalPackages = process.env.NEXT_USE_LOCAL_PACKAGES === "true"; - -// Base Next.js configuration const nextConfig: NextConfig = { - compiler: { - styledComponents: true, - }, - reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, + transpilePackages: ["@rozoai/intent-pay"], + webpack: (config) => { + // Force single wagmi instance across workspace symlink by pointing to the + // app's own node_modules copy so SDK and app share the same context registry. + const wagmiPkg = require.resolve("wagmi/package.json") + const wagmiDir = wagmiPkg.replace("/package.json", "") + config.resolve.alias = { + ...config.resolve.alias, + wagmi: wagmiDir, + } + return config }, - transpilePackages: monoRepoPackages, -}; +} -export default nextConfig; +export default nextConfig diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 90b8e81cf..aa0316a73 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -1,41 +1,47 @@ -{ - "name": "@rozoai/pay-nextjs-app-example", - "private": true, - "scripts": { - "dev": "NEXT_USE_LOCAL_PACKAGES=true next dev --turbo", - "dev:local": "NEXT_USE_LOCAL_PACKAGES=true next dev", - "build": "next build", - "start": "NEXT_USE_LOCAL_PACKAGES=true next start", - "lint": "next lint --max-warnings=0" - }, - "dependencies": { - "@creit.tech/stellar-wallets-kit": "^1.9.5", - "@farcaster/frame-sdk": "^0.0.26", - "@headlessui/react": "^2.2.0", - "@rozoai/intent-common": "0.1.17", - "@rozoai/intent-pay": "0.1.22", - "@stellar/stellar-sdk": "^14.4.3", - "@tanstack/react-query": "^5.51.11", - "@types/react-syntax-highlighter": "^15.5.13", - "@wagmi/core": "^2.22.0", - "autoprefixer": "^10.4.20", - "clsx": "^2.1.1", - "next": "15.3.8", - "postcss": "^8.4.49", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-syntax-highlighter": "^16.1.0", - "styled-components": "^6.1.19", - "tailwindcss": "^3.4.17", - "viem": "^2.23.2", - "wagmi": "^2.14.11" - }, - "devDependencies": { - "@heroicons/react": "^2.2.0", - "@types/node": "^20.14.12", - "@types/react": "^18.2.47", - "eslint": "^8.56.0", - "eslint-config-next": "14.2.13", - "typescript": "^5.9.3" - } -} +{ + "name": "examples/nextjs-app", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "format": "prettier --write \"**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@rozoai/intent-common": "0.1.18", + "@rozoai/intent-pay": "0.1.26", + "@tanstack/react-query": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "next": "15.5.18", + "next-themes": "^0.4.6", + "posthog-js": "^1.378.1", + "radix-ui": "^1.4.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-syntax-highlighter": "^15.6.1", + "shadcn": "^4.10.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "viem": "^2.23.2", + "wagmi": "^2.14.11" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@types/react-syntax-highlighter": "^15.5.13", + "eslint": "^9", + "eslint-config-next": "15.3.3", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "tailwindcss": "^4", + "typescript": "^5" + } +} \ No newline at end of file diff --git a/examples/nextjs-app/postcss.config.js b/examples/nextjs-app/postcss.config.js deleted file mode 100644 index 33ad091d2..000000000 --- a/examples/nextjs-app/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/examples/nextjs-app/postcss.config.mjs b/examples/nextjs-app/postcss.config.mjs new file mode 100644 index 000000000..2f8795a93 --- /dev/null +++ b/examples/nextjs-app/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/examples/nextjs-app/public/.gitkeep b/examples/nextjs-app/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/nextjs-app/public/llms.md b/examples/nextjs-app/public/llms.md deleted file mode 100644 index 7dd11387c..000000000 --- a/examples/nextjs-app/public/llms.md +++ /dev/null @@ -1,797 +0,0 @@ -# RozoAI Intent Pay SDK - AI Service Quick Start Guide - -> **Cross-chain crypto payments made simple** – Accept payments from any blockchain with a single component - -[![npm version](https://badge.fury.io/js/@rozoai%2Fintent-pay.svg)](https://badge.fury.io/js/@rozoai%2Fintent-pay) -[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) -[![React](https://img.shields.io/badge/React-18+-61dafb.svg)](https://reactjs.org/) - -## Table of Contents - -- [What RozoAI Intent Pay Does](#-what-rozoai-intent-pay-does) -- [Requirements Checklist](#-requirements-checklist-mandatory) -- [Complete Working Example](#-complete-working-example-copy--paste-ready) -- [Quick Customization Patterns](#-quick-customization-patterns) -- [Common Mistakes to Avoid](#-common-mistakes-to-avoid) -- [Supported Chains & Tokens](#-supported-chains--tokens) -- [Payment Methods](#-payment-methods-your-users-can-use) -- [Single Prompt Hit to Generate](#-single-prompt-hit-to-generate) -- [Essential API Reference](#-essential-api-reference-ai-services) -- [Testing Checklist](#-testing-checklist) -- [Success Examples](#-success-examples) -- [Additional Resources](#-additional-resources-optional-reading) -- [Advanced Configuration](#advanced-configuration) -- [Production Checklist](#production-checklist) -- [Support & Resources](#-support--resources) -- [Demo Link](#-demo-link) -- [License](#-license) - -## 🤖 For AI Services: Single Prompt Implementation - -This guide is optimized for AI services to quickly create working examples from a single prompt. All requirements and patterns are clearly defined below. - -### 🎯 What RozoAI Intent Pay Does - -RozoAI Intent Pay SDK is a React component that lets users pay you in crypto from supported networks — Base, Polygon, Solana, Stellar, and BSC. Your users can pay with their preferred wallet, while you receive exactly what you want. - -**Key Benefits:** - -- ✅ **One Component** - Add `` and you're done -- ✅ **Supported Chains** - Base, Polygon, Solana, Stellar, BSC -- ✅ **Any Wallet** - MetaMask, Phantom, Coinbase Wallet, and more -- ✅ **Supported Tokens** - USDC on Base/Polygon/Solana/Stellar; USDT on BSC -- ✅ **Mobile Ready** - Works perfectly on mobile apps -- ✅ **Zero Config** - Smart defaults, easy customization - -## 📋 REQUIREMENTS CHECKLIST (MANDATORY) - -When creating examples, AI services MUST follow these requirements: - -### ✅ 1. Provider Structure (REQUIRED) - -```tsx -// MUST wrap your app with these providers in this exact order: - - - - {/* Your app components here */} - - - -``` - -### ✅ 2. Dependencies (REQUIRED) - -```json -{ - "dependencies": { - "@rozoai/intent-pay": "latest", - "@rozoai/intent-common": "latest", - "@tanstack/react-query": "^5.51.11", - "wagmi": "^2.15.6", - "viem": "^2.31.6", - "react": "^18.2.0" - } -} -``` - -### ✅ 3. Wagmi Config (REQUIRED) - -```tsx -import { getDefaultConfig } from "@rozoai/intent-pay"; -import { createConfig } from "wagmi"; - -export const wagmiConfig = createConfig( - getDefaultConfig({ - appName: "Your App Name", - }) -); -``` - -### ✅ 4. QueryClient Setup (REQUIRED) - -```tsx -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -const queryClient = new QueryClient(); -``` - -### ✅ 5. Required Imports (COPY EXACTLY) - -```tsx -// Core SDK imports -import { - RozoPayProvider, - RozoPayButton, - getDefaultConfig, -} from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createConfig, WagmiProvider } from "wagmi"; -import { getAddress } from "viem"; - -// Optional: Common tokens and utilities -import { baseUSDC } from "@rozoai/intent-common"; -``` - -## 🚀 COMPLETE WORKING EXAMPLE (COPY & PASTE READY) - -Here's a complete, working example that AI services can generate. This follows all requirements: - -### 📁 File Structure - -``` -src/ -├── providers.tsx # Required providers setup -├── App.tsx # Main app component -└── package.json # Dependencies -``` - -### 📄 providers.tsx (REQUIRED) - -```tsx -"use client"; - -import { getDefaultConfig, RozoPayProvider } from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { type ReactNode } from "react"; -import { createConfig, WagmiProvider } from "wagmi"; - -export const wagmiConfig = createConfig( - getDefaultConfig({ - appName: "RozoAI Payment Demo", - }) -); - -const queryClient = new QueryClient(); - -export function Providers({ children }: { children: ReactNode }) { - return ( - - - - {children} - - - - ); -} -``` - -### 📄 App.tsx (Main Component) - -```tsx -"use client"; - -import { RozoPayButton } from "@rozoai/intent-pay"; -import { baseUSDC } from "@rozoai/intent-common"; -import { getAddress } from "viem"; -import { Providers } from "./providers"; - -function PaymentDemo() { - return ( -
-

Crypto Payment Demo

- - { - console.log("✅ Payment started!", event.paymentId); - // Handle payment start (e.g., show loading state) - }} - onPaymentCompleted={(event) => { - console.log("🎉 Payment completed!", event.txHash); - alert("Payment successful! 🎉"); - // Handle successful payment (e.g., fulfill order) - }} - onPaymentBounced={(event) => { - console.log("❌ Payment bounced!", event); - alert("Payment failed. You'll receive a refund."); - // Handle failed payment - }} - /> - -

- Users can pay from any supported blockchain and wallet -

-
- ); -} - -export default function App() { - return ( - - - - ); -} -``` - -### 📄 package.json (Dependencies) - -```json -{ - "name": "rozoai-payment-demo", - "private": true, - "dependencies": { - "@rozoai/intent-pay": "latest", - "@rozoai/intent-common": "latest", - "@tanstack/react-query": "^5.51.11", - "wagmi": "^2.15.6", - "viem": "^2.31.6", - "react": "^18.2.0", - "react-dom": "^18.2.0" - } -} -``` - -## 🎯 QUICK CUSTOMIZATION PATTERNS - -### Pattern 1: Different Amount - -```tsx - -``` - -### Pattern 2: Multiple Payment Options - -```tsx - -``` - -### Pattern 3: Solana/Stellar Support - -```tsx - -``` - -**⚠️ CRITICAL: Solana/Stellar Configuration Requirements** - -When using `toSolanaAddress` or `toStellarAddress`, you **MUST** set: - -- `toChain` to Base Chain (8453) -- `toToken` to Base USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) -- The `toAddress` can be any valid EVM address in this case - -This is because RozoAI uses Base chain as the settlement layer for cross-chain payments. - -## ⚠️ COMMON MISTAKES TO AVOID - -### ❌ Missing Provider Wrapper - -```tsx -// DON'T DO THIS - Missing providers -function App() { - return ; // ❌ Will crash -} - -// ✅ DO THIS - Proper provider setup -function App() { - return ( - - - - ); -} -``` - -### ❌ Wrong Import Paths - -```tsx -// ❌ DON'T DO THIS -import { RozoPayButton } from "@rozoai/connectkit"; -import { RozoPayButton } from "rozoai-intent-pay"; - -// ✅ DO THIS -import { RozoPayButton } from "@rozoai/intent-pay"; -``` - -### ❌ Missing getAddress() Wrapper - -```tsx -// ❌ DON'T DO THIS - Raw strings -toAddress="0x742d35Cc6634C0532925a3b8D454A3fE1C11C4e2" -toToken="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" - -// ✅ DO THIS - Wrapped with getAddress() -toAddress={getAddress("0x742d35Cc6634C0532925a3b8D454A3fE1C11C4e2")} -toToken={getAddress(baseUSDC.token)} -``` - -### ❌ Mixing payId with appId - -```tsx -// ❌ DON'T DO THIS - - -// ✅ DO THIS - Use one approach - -// OR - -``` - -### ❌ Wrong Solana/Stellar Configuration - -```tsx -// ❌ DON'T DO THIS - Wrong chain/token for Solana/Stellar - - -// ✅ DO THIS - Correct Base chain config - -``` - -## 🎯 Supported Chains & Tokens - -### 🔗 Blockchains (AI Services: Use these Chain IDs) - -| Chain | Chain ID | Supported Token | Usage in Code | -| ----------- | -------- | --------------- | ---------------------- | -| **Base** | 8453 | USDC | `toChain={8453}` | -| **Polygon** | 137 | USDC | `toChain={137}` | -| **BSC** | 56 | USDT | `toChain={56}` | -| **Solana** | Special | USDC | Use `toSolanaAddress` | -| **Stellar** | Special | USDC | Use `toStellarAddress` | - -### 💰 Common Token Addresses (Copy-Paste Ready) - -```tsx -// Base USDC (RECOMMENDED for most use cases) -import { baseUSDC } from "@rozoai/intent-common"; -toChain={baseUSDC.chainId} // 8453 -toToken={getAddress(baseUSDC.token)} // 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 - -// Polygon USDC -toChain={137} -toToken={getAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")} - -// BSC USDT -toChain={56} -toToken={getAddress("0x55d398326f99059fF775485246999027B3197955")} -``` - -## 💳 Payment Methods Your Users Can Use - -### 🔌 Crypto Wallets - -- **Desktop**: MetaMask, Coinbase Wallet, Rainbow, Trust Wallet -- **Mobile**: All wallets via deep-linking -- **Solana**: Phantom, Backpack, Solflare -- **Stellar**: Any Stellar wallet compatible with your `toStellarAddress` -- **Advanced**: Hardware wallets, multisig wallets - -## 🔧 AI SERVICE PROMPT TEMPLATES - -### Template 1: Basic Payment Button - -``` -Create a React component that accepts crypto payments using RozoAI Intent Pay SDK. -Requirements: -- Accept $10 USDC payments on Base chain -- Use demo app ID "rozoDemoMP" -- Show success/error alerts -- Include all required providers (WagmiProvider, QueryClientProvider, RozoPayProvider) -- Use proper TypeScript types -``` - -### Template 2: E-commerce Checkout - -``` -Create a crypto payment checkout page using RozoAI Intent Pay SDK. -Requirements: -- Multiple payment amounts ($5, $25, $100) -- Different button text for each amount -- Handle payment events with console logs -- Include providers setup in separate file -- Use Base USDC as the payment token -``` - -### Template 3: Multi-Chain Support (within supported networks) - -``` -Create a donation component using RozoAI Intent Pay SDK that supports: -- Base, Polygon, Solana, Stellar, and BSC payments (USDT) -- $20 donation amount -- Prefer Base and Polygon chains -- Include Solana address support -- Show payment status updates -``` - -## 📋 ESSENTIAL API REFERENCE (AI Services) - -### Core Props (ALWAYS REQUIRED) - -```tsx - -``` - -### Event Handlers (RECOMMENDED) - -```tsx - onPaymentStarted={(event) => { - console.log("Payment started:", event.paymentId); - // Show loading state - }} - onPaymentCompleted={(event) => { - console.log("Payment completed:", event.txHash); - // Fulfill order, show success - }} - onPaymentBounced={(event) => { - console.log("Payment failed:", event); - // Handle refund, show error -}} -``` - -### Optional Customization - -```tsx -// Chain/token preferences -preferredChains={[8453, 137]} // Prefer Base, Polygon -preferredTokens={[ // Prefer specific tokens - { chain: 8453, address: getAddress(baseUSDC.token) } -]} - -// UI customization -theme="minimal" // Built-in themes -mode="auto" // Light/dark mode -disabled={false} // Enable/disable button - -// Multi-chain destinations -toSolanaAddress="DYw8jCTf..." // Solana wallet -toStellarAddress="GABC123..." // Stellar wallet - -// Tracking -metadata={{ orderId: "123" }} // Custom metadata -externalId="order_456" // Your tracking ID -``` - -## 🚨 TESTING CHECKLIST - -Before submitting code, AI services should verify: - -### ✅ Provider Setup - -- [ ] `WagmiProvider` wraps the app with `wagmiConfig` -- [ ] `QueryClientProvider` wraps with `queryClient` -- [ ] `RozoPayProvider` wraps with correct `payApiUrl` -- [ ] All providers are in correct nesting order - -### ✅ Imports - -- [ ] `@rozoai/intent-pay` package imported correctly -- [ ] `getAddress` imported from `viem` -- [ ] `baseUSDC` imported from `@rozoai/intent-common` (if used) -- [ ] All required React hooks imported - -### ✅ Button Props - -- [ ] `appId` is set (use "rozoDemoMP" for demos) -- [ ] `toChain` is a valid chain ID (8453 for Base recommended) -- [ ] `toAddress` wrapped in `getAddress()` -- [ ] `toToken` wrapped in `getAddress()` -- [ ] `toUnits` is a string (e.g., "10" not 10) -- [ ] `intent` prop used for button text - -### ✅ Solana/Stellar Support (If Used) - -- [ ] `toChain` set to Base Chain (8453) when using `toSolanaAddress` or `toStellarAddress` -- [ ] `toToken` set to Base USDC when using Solana/Stellar addresses -- [ ] `toAddress` can be any valid EVM address for Solana/Stellar payments - -### ✅ Event Handlers - -- [ ] `onPaymentStarted` logs payment ID -- [ ] `onPaymentCompleted` logs transaction hash -- [ ] `onPaymentBounced` handles errors gracefully - -### ✅ TypeScript - -- [ ] All imports have proper types -- [ ] Address types use `Address` from `viem` -- [ ] Event handlers have correct event types - -## 🎉 SUCCESS EXAMPLES - -### Next.js App Router Example - -```tsx -// app/providers.tsx -"use client"; -import { getDefaultConfig, RozoPayProvider } from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createConfig, WagmiProvider } from "wagmi"; - -const wagmiConfig = createConfig(getDefaultConfig({ appName: "Demo" })); -const queryClient = new QueryClient(); - -export function Providers({ children }: { children: React.ReactNode }) { - return ( - - - - {children} - - - - ); -} - -// app/layout.tsx -import { Providers } from "./providers"; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} - -// app/page.tsx -("use client"); -import { RozoPayButton } from "@rozoai/intent-pay"; -import { baseUSDC } from "@rozoai/intent-common"; -import { getAddress } from "viem"; - -export default function HomePage() { - return ( - alert("Payment successful! 🎉")} - /> - ); -} -``` - -### Vite/CRA Example - -```tsx -// src/providers.tsx -import { getDefaultConfig, RozoPayProvider } from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createConfig, WagmiProvider } from "wagmi"; - -const wagmiConfig = createConfig(getDefaultConfig({ appName: "Demo" })); -const queryClient = new QueryClient(); - -export function Providers({ children }: { children: React.ReactNode }) { - return ( - - - - {children} - - - - ); -} - -// src/App.tsx -import { RozoPayButton } from "@rozoai/intent-pay"; -import { baseUSDC } from "@rozoai/intent-common"; -import { getAddress } from "viem"; -import { Providers } from "./providers"; - -function PaymentApp() { - return ( -
-

Crypto Payment Demo

- console.log("Payment completed! 🎉")} - /> -
- ); -} - -export default function App() { - return ( - - - - ); -} -``` - ---- - -## 📚 ADDITIONAL RESOURCES (Optional Reading) - -### Built-in Themes - -```tsx -theme = "minimal"; // Clean, minimal design -theme = "rounded"; // Rounded corners, modern -theme = "retro"; // Retro/vintage style -theme = "midnight"; // Dark theme -theme = "web95"; // Windows 95 nostalgic -theme = "soft"; // Soft, gentle colors -theme = "nouns"; // Nouns DAO inspired -theme = "auto"; // Matches system preference (default) -``` - -### Advanced Configuration - -```tsx -// Custom wagmi config -const customWagmiConfig = createConfig({ - // Your custom chains, connectors, etc. -}); - - - - - -// Custom API URL (for enterprise) - - - -``` - -### Production Checklist - -- [ ] Replace demo app ID with your production app ID -- [ ] Update wallet addresses to your actual addresses -- [ ] Set up webhook endpoints for reliable payment tracking -- [ ] Test on all target chains and wallets -- [ ] Implement proper error handling and user feedback -- [ ] Add loading states during payment processing - ---- - -**Made with ❤️ by the RozoAI team** - -_Simplifying crypto payments, one transaction at a time._ - -## 📞 Support & Resources - -- 📖 **Documentation**: [docs.rozo.ai](https://docs.rozo.ai) -- 💬 **Discord**: [discord.gg/rozoai](https://discord.com/invite/EfWejgTbuU) -- 🐛 **Issues**: [GitHub Issues](https://github.com/rozoai/intent-pay/issues) -- 📧 **Email**: support@rozo.ai - -## Demo Link - -[🔗 demo.rozo.ai](https://demo.rozo.ai/) - -## 📄 License - -MIT License - see [LICENSE](LICENSE) for details. - -## 🚀 Single Prompt Hit to Generate - -Use this copy-ready prompt to generate a complete, production-ready Next.js 15 App Router example with TailwindCSS that can be deployed with a single command. - -### 📋 One-Command Prompt - -```text -You are an expert Next.js + Web3 developer. -Create a complete Next.js 15 App Router project with RozoAI Intent Pay SDK integration. - -Requirements: -- Next.js 15 App Router with TypeScript -- TailwindCSS for styling -- Complete package.json with all dependencies and scripts -- Payment form with Address and Amount inputs -- Show RozoPayButton only when both fields are filled -- "Generate Pay Button" that opens modal immediately with defaultOpen -- Base USDC payments only (toChain=8453, baseUSDC from @rozoai/intent-common) -- Modern, responsive UI with proper form validation -- Production-ready error handling - -Output exactly these files in order: -1. package.json (complete with dependencies and scripts) -2. tailwind.config.js -3. app/globals.css (with Tailwind directives) -4. app/providers.tsx (WagmiProvider, QueryClientProvider, RozoPayProvider) -5. app/layout.tsx (with Providers and TailwindCSS) -6. app/page.tsx (payment form with Tailwind classes) - -Include: -- All necessary dependencies (@rozoai/intent-pay, @rozoai/intent-common, wagmi, viem, etc.) -- TailwindCSS setup -- Proper TypeScript types -- Form validation and UX improvements -- Mobile-responsive design - -Reference: [URL of this MD] -``` - -### 🔗 Instant Generation with Lovable - -**Generate instantly with one click using Lovable's Build with URL:** - -[![Generate with Lovable](https://img.shields.io/badge/Generate%20with-Lovable-FF6B6B?style=for-the-badge&logo=rocket)]() - -**How it works:** - -1. Click the "Generate with Lovable" button above -2. Sign in to Lovable (if not already logged in) -3. The complete RozoAI payment app will be generated instantly -4. Deploy to production with one click - -**Features of the generated app:** - -- ✅ Complete Next.js 15 setup with TypeScript -- ✅ TailwindCSS styling and responsive design -- ✅ Form validation with real-time feedback -- ✅ RozoAI Intent Pay integration -- ✅ Production-ready deployment - -> **Note:** The Lovable integration uses their [Build with URL feature](https://docs.lovable.dev/integrations/build-with-url) to programmatically generate applications with pre-configured prompts. - -### 🚀 Features Included - -- ✅ **Complete Setup** - All files and dependencies included -- ✅ **TailwindCSS** - Modern, responsive styling -- ✅ **Form Validation** - Address validation with `isAddress` from viem -- ✅ **Error Handling** - User-friendly error messages -- ✅ **Mobile Responsive** - Works perfectly on all devices -- ✅ **Production Ready** - Proper TypeScript types and error boundaries -- ✅ **UX Optimized** - Loading states, success feedback, form reset -- ✅ **One Command Deploy** - Ready for Vercel, Netlify, or any platform diff --git a/examples/nextjs-app/public/rozo-logo.png b/examples/nextjs-app/public/rozo-logo.png new file mode 100644 index 000000000..bcda12492 Binary files /dev/null and b/examples/nextjs-app/public/rozo-logo.png differ diff --git a/examples/nextjs-app/src/app/basic/README.md b/examples/nextjs-app/src/app/basic/README.md deleted file mode 100644 index c32d5d5a8..000000000 --- a/examples/nextjs-app/src/app/basic/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# Basic Payment Demo - -The simplest integration example showing how to accept payments from any coin on any supported blockchain network using the RozoAI Intent Pay SDK. - -## Overview - -This demo showcases a minimal implementation of `RozoPayButton` that enables cross-chain cryptocurrency payments with just a few lines of code. - -## Features - -- ✅ Accept payments from any supported chain (EVM, Solana, Stellar) -- ✅ Real-time payment tracking with event callbacks -- ✅ Auto-generated implementation code with syntax highlighting -- ✅ Interactive configuration panel -- ✅ Cross-chain bridging support - -## Quick Start - -1. **Configure Payment Settings**: Click "Configure Payment Settings" to set up your recipient address, chain, token, and amount. - -2. **Test the Payment**: Once configured, use the "Make Payment" button to test the payment flow. - -3. **Copy Implementation Code**: The generated TypeScript code is ready to copy into your project. - -## Key Components - -### RozoPayButton - -The main payment component that handles the entire payment flow: - -```typescript -import { TokenSymbol } from "@rozoai/intent-common"; -import { RozoPayButton } from "@rozoai/intent-pay"; - - console.log(event)} - onPaymentCompleted={(event) => console.log(event)} -/>; -``` - -### Event Callbacks - -Track payment lifecycle with built-in callbacks: - -- `onPaymentStarted`: Triggered when user initiates payment -- `onPaymentCompleted`: Triggered when payment is confirmed on-chain -- `onPayoutCompleted`: Triggered when funds arrive at destination - -### Preferred Token Symbols - -The `preferredSymbol` prop allows you to specify which token symbols should appear first in the token selection list. This is useful for prioritizing specific stablecoins across all supported chains. - -**Key Features:** - -- **Supported Symbols**: Only `USDC`, `USDT`, and `EURC` are allowed -- **Default Behavior**: If not provided, defaults to `[USDC, USDT]` -- **Cross-Chain**: Automatically finds matching tokens across all supported chains (Base, Polygon, Ethereum, Solana, Stellar) -- **Precedence**: If `preferredTokens` is explicitly provided, it takes precedence over `preferredSymbol` - -**Example Usage:** - -```typescript -import { TokenSymbol } from "@rozoai/intent-common"; - -// Prioritize USDC and USDT (default) - - -// Prioritize EURC only - - -// Multiple preferred symbols - -``` - -**How It Works:** - -- The `preferredSymbol` array is internally converted to a `preferredTokens` array -- The SDK searches for all tokens matching the specified symbols across supported chains -- These tokens are then prioritized in the token selection UI -- Invalid symbols are filtered out with a console warning - -## Developer Notes - -- Uses `react-syntax-highlighter` for clean code display -- Implements `useCallback` and `useMemo` to prevent unnecessary re-renders -- Persists configuration to localStorage for better UX -- Automatically validates addresses and configuration - -## Props Reference - -| Prop | Type | Description | -| -------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `appId` | `string` | Your RozoAI Intent Pay application ID | -| `toChain` | `number` | Destination blockchain network ID | -| `toAddress` | `Address` | Recipient wallet address (checksummed) | -| `toToken` | `Address` | Token contract address to receive | -| `toUnits` | `string` | Amount in token's smallest unit | -| `preferredSymbol` | `TokenSymbol[]` | Preferred token symbols (USDC, USDT, EURC). These tokens will appear first in the token selection list. Defaults to `[USDC, USDT]`. | -| `onPaymentStarted` | `(event) => void` | Payment initiation callback | -| `onPaymentCompleted` | `(event) => void` | Payment completion callback | - -## Cross-Chain Payments - -For Stellar/Solana destinations, use bridge configuration: - -```typescript -import { TokenSymbol } from "@rozoai/intent-common"; - -; -``` - -## Learn More - -- [RozoAI Intent Pay Documentation](https://github.com/rozoai/intent-pay) -- [Supported Chains & Tokens](https://docs.rozo.ai) -- [API Reference](https://docs.rozo.ai/api) diff --git a/examples/nextjs-app/src/app/basic/layout.tsx b/examples/nextjs-app/src/app/basic/layout.tsx deleted file mode 100644 index 354599460..000000000 --- a/examples/nextjs-app/src/app/basic/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Metadata } from "next"; -import { type ReactNode } from "react"; -import { ProvidersWrapper } from "./providers-wrapper"; - -import "../../styles/tailwind.css"; - -export const metadata: Metadata = { - title: "Rozo Pay Basic Demo", - description: "Demo showcasing basic Rozo Pay functionality", -}; - -export default function RootLayout(props: { children: ReactNode }) { - return {props.children}; -} diff --git a/examples/nextjs-app/src/app/basic/page.tsx b/examples/nextjs-app/src/app/basic/page.tsx deleted file mode 100644 index 246934f3b..000000000 --- a/examples/nextjs-app/src/app/basic/page.tsx +++ /dev/null @@ -1,935 +0,0 @@ -"use client"; - -import * as Tokens from "@rozoai/intent-common"; -import { - baseEURC, - getChainById, - getChainName, - getChainNativeToken, - getKnownToken, - knownTokens, - rozoSolana, - rozoStellar, - rozoStellarEURC, - TokenSymbol, -} from "@rozoai/intent-common"; -import { - RozoPayButton, - useRozoConnectStellar, - useRozoPayUI, -} from "@rozoai/intent-pay"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { getAddress } from "viem"; -import { Text } from "../../shared/tailwind-catalyst/text"; -import { ConfigPanel } from "../config-panel"; -import { APP_ID, Container, usePersistedConfig } from "../shared"; - -type Config = { - recipientAddress: string; // Unified: EVM Address or Solana/Stellar string - chainId: number; // Destination chain ID - tokenAddress: string; - amount: string; - preferredSymbol: TokenSymbol[]; -}; - -/** - * Generates TypeScript code snippet for implementing RozoPayButton - */ -const generateCodeSnippet = (config: Config): string => { - const chain = getChainById(config.chainId); - - if (!chain) return ""; - - const isEvm = chain.type === "evm"; - const isSolana = chain.type === "solana"; - - // For EVM chains, use getAddress helper - // For non-EVM chains, use string directly - const addressCode = isEvm - ? `getAddress("${config.recipientAddress}")` - : `"${config.recipientAddress}"`; - - // Check if it's a native token - const isNativeToken = - config.tokenAddress === getChainNativeToken(config.chainId)?.token; - - if (isNativeToken) { - const nativeToken = getChainNativeToken(config.chainId); - const tokenVarName = nativeToken - ? getChainName(config.chainId).toLowerCase() + nativeToken.symbol - : ""; - - const tokenCode = isEvm - ? `getAddress(${tokenVarName}.token)` - : `${tokenVarName}.token`; - - const importStatement = isEvm - ? `import { getAddress } from "viem";\nimport { ${tokenVarName} } from "@rozoai/intent-common";` - : `import { ${tokenVarName} } from "@rozoai/intent-common";`; - - return `${importStatement} -import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback } from "react"; - -export default function YourComponent() { - const { resetPayment } = useRozoPayUI(); - - // Reset current payment state to the latest destination params - const resetCurrentPayment = useCallback(async () => { - await resetPayment({ - toChain: ${tokenVarName}.chainId, - toAddress: ${addressCode}, - toUnits: "${config.amount}", - toToken: ${tokenCode}, - }); - }, [resetPayment]); - - return ( - <> - { - console.log("Payment started:", event); - }} - // Fires when payment is completed - onPaymentCompleted={(event) => { - console.log("Payment completed:", event); - }} - /> - - ); -}`; - } - - // For non-native tokens - const token = knownTokens.find( - (t: any) => t.token === config.tokenAddress && t.chainId === config.chainId, - ); - - if (!token) return ""; - - const tokenVarName = - Object.entries(Tokens).find(([_, t]) => t === token)?.[0] || token.symbol; - - const tokenCode = isEvm - ? `getAddress(${tokenVarName}.token)` - : `${tokenVarName}.token`; - - const importStatement = isEvm - ? `import { getAddress } from "viem";\nimport { ${tokenVarName}, TokenSymbol } from "@rozoai/intent-common";` - : `import { ${tokenVarName}, TokenSymbol } from "@rozoai/intent-common";`; - - return `${importStatement} -import { RozoPayButton, useRozoPayUI } from "@rozoai/intent-pay"; -import { useCallback } from "react"; - -export default function YourComponent() { - const { resetPayment } = useRozoPayUI(); - - const resetCurrentPayment = useCallback(async () => { - await resetPayment({ - toChain: ${tokenVarName}.chainId, - toAddress: ${addressCode}, - toUnits: "${config.amount}", - toToken: ${tokenCode}, - preferredSymbol: [${config.preferredSymbol - .map((s: TokenSymbol) => `TokenSymbol.${s}`) - .join(", ")}], - }); - }, [resetPayment]); - - return ( - <> - `TokenSymbol.${s}`) - .join(", ")}]} - // Fires after user confirms payment in wallet - onPaymentStarted={(event) => { - console.log("Payment started:", event); - }} - // Fires when payment is completed - onPaymentCompleted={(event) => { - console.log("Payment completed:", event); - }} - /> - - ); -}`; -}; - -/** - * Code Snippet Component with Copy Button - */ -const CodeSnippetDisplay = ({ code }: { code: string }) => { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [code]); - - return ( -
- - - {code} - -
- ); -}; - -/** - * Simple Connect Stellar Wallet Component - */ -const ConnectStellarWallet = () => { - const { kit, isConnected, publicKey, connector, setConnector, disconnect } = - useRozoConnectStellar(); - const [wallets, setWallets] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [showWallets, setShowWallets] = useState(false); - - // Fetch available wallets - useEffect(() => { - const fetchWallets = async () => { - if (!kit) return; - setIsLoading(true); - try { - const availableWallets = await kit.getSupportedWallets(); - setWallets(availableWallets.filter((w: any) => w.isAvailable)); - } catch (error) { - console.error("Error fetching Stellar wallets:", error); - } finally { - setIsLoading(false); - } - }; - - fetchWallets(); - }, [kit]); - - const handleConnect = async (wallet: any) => { - try { - if (!kit) return; - // Use SDK's setWallet (setConnector) so connection is idempotent and avoids double WalletConnect confirmation - await setConnector(wallet); - setShowWallets(false); - } catch (error) { - console.error("Error connecting wallet:", error); - } - }; - - const handleDisconnect = async () => { - try { - await disconnect(); - } catch (error) { - console.error("Error disconnecting wallet:", error); - } - }; - - if (isConnected && publicKey) { - return ( -
-

- Stellar Wallet Connected -

-
-
- Wallet: - - {connector?.name || "Unknown"} - -
-
- Address: - - {publicKey} - -
- -
-
- ); - } - - return ( -
-

- Connect Stellar Wallet -

- {isLoading ? ( -

Loading wallets...

- ) : ( - <> - {!showWallets ? ( - - ) : ( -
- - {wallets.length === 0 ? ( -

- No Stellar wallets detected. Please install a Stellar wallet - extension. -

- ) : ( -
- {wallets.map((wallet) => ( - - ))} -
- )} -
- )} - - )} -
- ); -}; - -export default function DemoBasic() { - const [isConfigOpen, setIsConfigOpen] = useState(false); - const [config, setConfig] = usePersistedConfig("rozo-basic-config", { - recipientAddress: "", - chainId: 8453, - tokenAddress: "", - amount: "", - } as Config); - const [parsedConfig, setParsedConfig] = useState(null); - const { resetPayment } = useRozoPayUI(); - const [preferredSymbol, setPreferredSymbol] = useState([ - TokenSymbol.USDC, - TokenSymbol.USDT, - ]); - const [eurcValidationError, setEurcValidationError] = useState(""); - - const buildPaymentParams = useCallback( - (targetConfig: Config, symbols: TokenSymbol[] = preferredSymbol) => { - const chain = getChainById(targetConfig.chainId); - if (!chain) return null; - - const isEvm = chain.type === "evm"; - const payParams: any = { - toChain: targetConfig.chainId, - toUnits: targetConfig.amount, - }; - - if (isEvm) { - payParams.toAddress = getAddress(targetConfig.recipientAddress); - payParams.toToken = getAddress(targetConfig.tokenAddress); - } else { - payParams.toAddress = targetConfig.recipientAddress; - payParams.toToken = targetConfig.tokenAddress; - } - - return { - ...payParams, - preferredSymbol: symbols, - }; - }, - [preferredSymbol], - ); - - const handleSetConfig = useCallback( - async (newConfig: Config, symbols?: TokenSymbol[]) => { - const symbolsToUse = symbols ?? preferredSymbol; - - // Validate EURC: EURC can only be sent to EURC - const hasEURC = symbolsToUse.includes(TokenSymbol.EURC); - if (hasEURC && newConfig.tokenAddress) { - const destinationToken = getKnownToken( - newConfig.chainId, - newConfig.tokenAddress, - ); - const isDestinationEURC = destinationToken?.symbol === TokenSymbol.EURC; - - if (!isDestinationEURC) { - setEurcValidationError( - `EURC can only be sent to another EURC. Please select an EURC token as the destination token.`, - ); - return; // Don't update config if validation fails - } - } - - // Clear error if validation passes - setEurcValidationError(""); - - const configWithSymbols = { - ...newConfig, - preferredSymbol: symbolsToUse, - }; - setConfig(configWithSymbols); - setParsedConfig(configWithSymbols); - - // NOTE: This is used to reset the payment state when the config changes - const params = buildPaymentParams(newConfig, symbolsToUse); - if (!params) return; - console.log("params", params); - await resetPayment(params); - }, - [setConfig, resetPayment, preferredSymbol, buildPaymentParams], - ); - - const handleResetCurrentPayment = useCallback(async () => { - if (!parsedConfig) return; - const params = buildPaymentParams(parsedConfig, preferredSymbol); - if (!params) return; - await resetPayment(params); - }, [parsedConfig, buildPaymentParams, preferredSymbol, resetPayment]); - - const isSolanaChain = useCallback((chainId: number) => { - const chain = getChainById(chainId); - return chain?.type === "solana"; - }, []); - - const isStellarChain = useCallback((chainId: number) => { - const chain = getChainById(chainId); - return chain?.type === "stellar"; - }, []); - - useEffect(() => { - const handleEscapeKey = (event: KeyboardEvent) => { - if (event.key === "Escape" && isConfigOpen) { - setIsConfigOpen(false); - } - }; - - document.addEventListener("keydown", handleEscapeKey); - return () => { - document.removeEventListener("keydown", handleEscapeKey); - }; - }, [isConfigOpen]); - - useEffect(() => { - try { - const getConfig = JSON.parse( - localStorage.getItem("rozo-basic-config") || "{}", - ); - const normalizedChainId = Number(getConfig?.chainId); - const hasValidChainId = - Number.isFinite(normalizedChainId) && normalizedChainId > 0; - - if (getConfig && hasValidChainId) { - const parsedConfig: Config = { - recipientAddress: getConfig.recipientAddress || "", - chainId: normalizedChainId, - tokenAddress: getConfig.tokenAddress || "", - amount: getConfig.amount || "", - preferredSymbol: getConfig.preferredSymbol || [ - TokenSymbol.USDC, - TokenSymbol.USDT, - ], - }; - - // Validate and clean up config - if ( - parsedConfig && - typeof parsedConfig === "object" && - "recipientAddress" in parsedConfig && - "chainId" in parsedConfig && - "tokenAddress" in parsedConfig - ) { - // Validate EURC: EURC can only be sent to EURC - const hasEURC = parsedConfig.preferredSymbol?.includes( - TokenSymbol.EURC, - ); - if (hasEURC && parsedConfig.tokenAddress && parsedConfig.chainId) { - const destinationToken = getKnownToken( - parsedConfig.chainId, - parsedConfig.tokenAddress, - ); - const isDestinationEURC = - destinationToken?.symbol === TokenSymbol.EURC; - - if (!isDestinationEURC) { - // Reset preferredSymbol to default if EURC validation fails - parsedConfig.preferredSymbol = [ - TokenSymbol.USDC, - TokenSymbol.USDT, - ]; - setEurcValidationError( - `EURC can only be sent to another EURC. Configuration has been reset to default.`, - ); - } - } - - setConfig(parsedConfig); - setParsedConfig(parsedConfig); - if (parsedConfig.preferredSymbol) { - setPreferredSymbol(parsedConfig.preferredSymbol); - } - } - } - } catch { - // Ignore malformed saved config and fall back to defaults. - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Check if we have valid configuration - const hasValidConfig = - parsedConfig && - parsedConfig.recipientAddress && - parsedConfig.chainId && - parsedConfig.tokenAddress && - parsedConfig.amount; - - // Check if destination token is Base EURC or Stellar EURC - const isDestinationEURC = useMemo(() => { - if (!parsedConfig || !parsedConfig.tokenAddress || !parsedConfig.chainId) { - return false; - } - - const chain = getChainById(parsedConfig.chainId); - if (!chain) return false; - const isEvm = chain.type === "evm"; - - const destinationToken = getKnownToken( - parsedConfig.chainId, - parsedConfig.tokenAddress, - ); - - if (!destinationToken) return false; - - // Check if it's Base EURC - if (parsedConfig.chainId === baseEURC.chainId && isEvm) { - try { - return ( - getAddress(destinationToken.token) === getAddress(baseEURC.token) - ); - } catch { - return destinationToken.token === baseEURC.token; - } - } - - // Check if it's Stellar EURC - if (parsedConfig.chainId === rozoStellarEURC.chainId) { - return destinationToken.token === rozoStellarEURC.token; - } - - return false; - }, [parsedConfig]); - - // Generate code snippet when config changes - const codeSnippet = useMemo(() => { - if (!hasValidConfig || !parsedConfig) return ""; - return generateCodeSnippet(parsedConfig); - }, [hasValidConfig, parsedConfig]); - - const metadata = useMemo( - () => ({ - orderDate: new Date().toISOString(), - }), - [], - ); - - // Toggle between [USDC, USDT] and [EURC] - const handleChangeCurrency = useCallback(() => { - if (!config.chainId || !config.tokenAddress || !config.recipientAddress) { - setEurcValidationError( - "Configure the payment destination before changing preferred currency.", - ); - setIsConfigOpen(true); - return; - } - - const nextSymbols = - preferredSymbol.length === 1 && preferredSymbol[0] === TokenSymbol.EURC - ? [TokenSymbol.USDC, TokenSymbol.USDT] - : [TokenSymbol.EURC]; - - // If switching to EURC, find and set an EURC token for the current chain - if (nextSymbols.includes(TokenSymbol.EURC) && config.chainId) { - const eurcToken = knownTokens.find( - (t: any) => - t.chainId === config.chainId && t.symbol === TokenSymbol.EURC, - ); - - if (eurcToken) { - const updatedConfig: Config = { - ...config, - tokenAddress: eurcToken.token, - preferredSymbol: nextSymbols, - }; - setPreferredSymbol(nextSymbols); - handleSetConfig(updatedConfig, nextSymbols); - return; - } else { - setEurcValidationError( - `EURC is not available on the selected chain. Please select a chain that supports EURC (Base, Ethereum, or Stellar).`, - ); - return; - } - } - - setPreferredSymbol(nextSymbols); - handleSetConfig(config, nextSymbols); - }, [config, preferredSymbol, handleSetConfig]); - - return ( - -
-

- Basic Demo -

-

- Configure a payment and inspect the exact integration output -

- - This example is optimized for developer testing: configure the payout - destination, run the payment flow, and copy the generated - implementation snippet. - -
- -
-
-

Payment options

- - Toggle preferred currency and adjust the destination config without - leaving the page. - -
-
- {/* */} - -
-
- - {eurcValidationError && ( -
-

- {eurcValidationError} -

-
- )} - -
-
-
-
-
-

- Payment runner -

- - Launch the Rozo Pay flow using the current destination - settings. - -
- - {Boolean(hasValidConfig) && parsedConfig ? ( - <> -
-
-

- Chain -

-

- {getChainName(parsedConfig.chainId)} -

-
-
-

- Amount -

-

- {parsedConfig.amount} -

-
-
-

- Recipient -

-

- {parsedConfig.recipientAddress} -

-
-
- - {isDestinationEURC && ( -
-

- EURC restriction: EURC can only be sent - to another EURC token. -

-
- )} - -
- { - console.log("✓ Payment started:", e); - }} - onPaymentCompleted={(e) => { - console.log("✓ Payment completed:", e); - }} - onPayoutCompleted={(e: any) => { - console.log("✓ Payout completed:", e); - }} - > - {(renderProps) => ( - - )} - - - -
- - ) : ( -
-

- Get started -

- - Set the receiving chain, token, address, and amount to - enable the payment flow and generated code output. - -
- )} -
-
- - {Boolean(hasValidConfig) && codeSnippet && ( -
-
-

- Implementation code -

- - Copy this snippet to reproduce the current payment - configuration in your own app. - -
- -
- )} -
- - -
- - setIsConfigOpen(false)} - onConfirm={(config) => handleSetConfig(config as Config)} - defaultRecipientAddress={config.recipientAddress} - /> -
- ); -} diff --git a/examples/nextjs-app/src/app/basic/providers-wrapper.tsx b/examples/nextjs-app/src/app/basic/providers-wrapper.tsx deleted file mode 100644 index 50eb94fb4..000000000 --- a/examples/nextjs-app/src/app/basic/providers-wrapper.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { type ReactNode } from "react"; - -// Load the wallet providers only on the client to avoid SSR issues with -// wallet SDKs that touch `localStorage` or other browser-only APIs at module -// import time. -const Providers = dynamic( - () => import("./providers").then((mod) => ({ default: mod.Providers })), - { ssr: false } -); - -export function ProvidersWrapper({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/examples/nextjs-app/src/app/basic/providers.tsx b/examples/nextjs-app/src/app/basic/providers.tsx deleted file mode 100644 index 78e79c2a2..000000000 --- a/examples/nextjs-app/src/app/basic/providers.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { - getDefaultConfig as getDefaultConfigRozo, - RozoPayProvider, -} from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState, type ReactNode } from "react"; -import { createConfig, WagmiProvider } from "wagmi"; - -const queryClient = new QueryClient(); - -export function Providers(props: { children: ReactNode }) { - // Create wagmi config lazily inside the client component so that any - // localStorage access triggered by wallet SDKs happens only in the browser. - const [rozoPayConfig] = useState(() => - createConfig( - getDefaultConfigRozo({ - appName: "Rozo Pay Basic Demo", - ssr: true, - }), - ), - ); - - return ( - - - - {props.children} - - - - ); -} diff --git a/examples/nextjs-app/src/app/checkout/README.md b/examples/nextjs-app/src/app/checkout/README.md deleted file mode 100644 index c85163b72..000000000 --- a/examples/nextjs-app/src/app/checkout/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Rozo Pay Checkout Demo - -This demo shows best practices for accepting a checkout payment. - -For robust checkout, save the payId in `onPaymentStarted`. This ensures you'll -be able to correlate incoming payments with a cart (or a user ID, form -submission, etc) even if the user closes the tab. - -In addition to callbacks like `onPaymentSucceeded`, Rozo Pay supports -[webhooks](https://paydocs.daimo.com/webhooks) to track payment status -reliably on the backend. diff --git a/examples/nextjs-app/src/app/checkout/layout.tsx b/examples/nextjs-app/src/app/checkout/layout.tsx deleted file mode 100644 index 3be8adad3..000000000 --- a/examples/nextjs-app/src/app/checkout/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Metadata } from "next"; -import { type ReactNode } from "react"; -import { ProvidersWrapper } from "./providers-wrapper"; - -import "../../styles/tailwind.css"; - -export const metadata: Metadata = { - title: "Rozo Pay Checkout Demo", - description: "Demo showcasing checkout ID correlation", -}; - -export default function RootLayout(props: { children: ReactNode }) { - return {props.children}; -} diff --git a/examples/nextjs-app/src/app/checkout/page.tsx b/examples/nextjs-app/src/app/checkout/page.tsx deleted file mode 100644 index 00bd3e18b..000000000 --- a/examples/nextjs-app/src/app/checkout/page.tsx +++ /dev/null @@ -1,374 +0,0 @@ -"use client"; - -import { - baseUSDC, - createPayment, - getAddressContraction, - getChainById, - PaymentStartedEvent, -} from "@rozoai/intent-common"; -import { RozoPayButton } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Address, getAddress } from "viem"; -import { Code, Text, TextLink } from "../../shared/tailwind-catalyst/text"; -import { ConfigPanel, PaymentConfig } from "../config-panel"; -import { APP_ID, Container, printEvent } from "../shared"; - -export default function DemoCheckout() { - const [payId, setPayId] = useState(); - const [manualPayId, setManualPayId] = useState(""); - const [isConfigOpen, setIsConfigOpen] = useState(false); - const [isCreating, setIsCreating] = useState(false); - - const [config, setConfig] = useState({ - recipientAddress: "", - chainId: baseUSDC.chainId, - tokenAddress: baseUSDC.token, - amount: "0.42", - }); - - // Match `ConfigPanel`'s localStorage key so the UI and this page stay in sync. - useEffect(() => { - try { - const saved = localStorage.getItem("rozo-basic-config"); - if (!saved) return; - - const parsed = JSON.parse(saved) as Partial; - if ( - !parsed || - typeof parsed !== "object" || - !("recipientAddress" in parsed) || - !("chainId" in parsed) || - !("tokenAddress" in parsed) || - !("amount" in parsed) - ) { - return; - } - - setConfig({ - recipientAddress: String(parsed.recipientAddress ?? ""), - chainId: Number(parsed.chainId ?? baseUSDC.chainId), - tokenAddress: String(parsed.tokenAddress ?? ""), - amount: String(parsed.amount ?? "0.42"), - }); - } catch { - // ignore - } - }, []); - - const parsed = useMemo(() => { - if ( - !config?.recipientAddress || - !config?.chainId || - !config?.tokenAddress || - !config?.amount - ) { - return null; - } - - const chain = getChainById(config.chainId); - if (!chain) return null; - - const isEvm = chain.type === "evm"; - - try { - const toAddress = isEvm - ? (getAddress(config.recipientAddress) as Address) - : config.recipientAddress; - const toToken = isEvm - ? getAddress(config.tokenAddress) - : config.tokenAddress; - - return { - toChain: config.chainId, - toUnits: config.amount, - toAddress, - toToken, - }; - } catch { - return null; - } - }, [config]); - - const start = useCallback((e: PaymentStartedEvent) => { - printEvent(e); - }, []); - - /** - * Create a payment using the Rozo API, then pass the resulting payment ID - * to the RozoPayButton via payId. This prevents the SDK from re-creating - * a payment when the user selects a token. - */ - const handleCreatePayment = useCallback(async () => { - if (!parsed) return; - - setIsCreating(true); - try { - const response = await createPayment({ - appId: APP_ID, - toChain: parsed.toChain, - toToken: parsed.toToken, - toAddress: parsed.toAddress, - toUnits: parsed.toUnits, - // For the initial creation, use the destination chain/token as preferred. - // The SDK will handle cross-chain routing when the user picks a different source. - preferredChain: parsed.toChain, - preferredTokenAddress: parsed.toToken, - title: "Checkout Payment", - }); - - if (response?.id) { - setPayId(response.id); - setManualPayId(response.id); - } else { - console.error("[Checkout] Payment creation failed:", response); - } - } catch (error) { - console.error("[Checkout] Failed to create payment:", error); - } finally { - setIsCreating(false); - } - }, [parsed]); - - /** Apply a manually entered payId */ - const handleApplyManualPayId = useCallback(() => { - const trimmed = manualPayId.trim(); - if (trimmed) { - setPayId(trimmed); - } - }, [manualPayId]); - - const handleSetConfig = useCallback( - (newConfig: PaymentConfig) => { - setConfig(newConfig); - setPayId(undefined); - setManualPayId(""); - - // Keep this page consistent with `ConfigPanel` reload behavior. - try { - localStorage.setItem("rozo-basic-config", JSON.stringify(newConfig)); - } catch { - // ignore - } - }, - [], - ); - - return ( - -
-

- Checkout Demo -

-

- Pre-created Payment (payId) -

- - Create a payment up-front via the API, then pass the pay ID to the - SDK. The SDK fetches the payment details and skips re-creation when - the user selects a token. - -
- -
-
- {/* Create payment from config */} -
-
-
-

- Option A: Create from config -

- - Set destination details, then create a payment. The returned - pay ID is used below. - -
- -
- {parsed ? ( -
-
-

- Chain -

-

- {parsed.toChain} -

-
-
-

- Amount -

-

- {parsed.toUnits} -

-
-
-

- Recipient -

-

- {config.recipientAddress} -

-
-
- ) : ( - - No config yet. Open the config drawer to set destination - details. - - )} -
- -
- {parsed && ( - - )} - -
-
-
- - {/* Manual payId input */} -
-
-
-

- Option B: Enter pay ID manually -

- - Paste a pay ID from your backend or another source. - -
- -
- setManualPayId(e.target.value)} - placeholder="Enter payment ID (UUID)..." - className="min-h-12 flex-1 rounded-xl border border-gray-300 px-4 py-3 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-medium focus:outline-none focus:ring-1 focus:ring-primary-medium" - /> - -
-
-
- - {/* Pay button */} - {payId && ( -
-
-
-

- Ready to pay -

-

- Using pay ID: {payId} -

-
- - - {(renderProps) => ( - - )} - -
-
- )} -
- - -
- - setIsConfigOpen(false)} - onConfirm={(c) => handleSetConfig(c as PaymentConfig)} - defaultRecipientAddress={config.recipientAddress} - /> -
- ); -} diff --git a/examples/nextjs-app/src/app/checkout/providers-wrapper.tsx b/examples/nextjs-app/src/app/checkout/providers-wrapper.tsx deleted file mode 100644 index db5963f97..000000000 --- a/examples/nextjs-app/src/app/checkout/providers-wrapper.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { type ReactNode } from "react"; - -const Providers = dynamic( - () => import("./providers").then((mod) => ({ default: mod.Providers })), - { ssr: false } -); - -export function ProvidersWrapper({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/examples/nextjs-app/src/app/checkout/providers.tsx b/examples/nextjs-app/src/app/checkout/providers.tsx deleted file mode 100644 index 7b806d04f..000000000 --- a/examples/nextjs-app/src/app/checkout/providers.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { - getDefaultConfig as getDefaultConfigRozo, - RozoPayProvider, -} from "@rozoai/intent-pay"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState, type ReactNode } from "react"; -import { createConfig, WagmiProvider } from "wagmi"; - -const queryClient = new QueryClient(); - -export function Providers(props: { children: ReactNode }) { - const [rozoPayConfig] = useState(() => - createConfig( - getDefaultConfigRozo({ - appName: "Rozo Pay Checkout Demo", - ssr: true, - }) - ) - ); - - return ( - - - {props.children} - - - ); -} diff --git a/examples/nextjs-app/src/app/code-snippet.tsx b/examples/nextjs-app/src/app/code-snippet.tsx deleted file mode 100644 index 550bd12c9..000000000 --- a/examples/nextjs-app/src/app/code-snippet.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { CheckIcon, ClipboardIcon } from "@heroicons/react/24/outline"; -import { useState } from "react"; - -interface CodeSnippetProps { - codeSnippet: string; -} - -export default function CodeSnippet({ codeSnippet }: CodeSnippetProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(codeSnippet); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const lines = codeSnippet.split("\n"); - const lineCount = lines.length; - // Calculate width needed for line numbers (minimum 2 characters) - const lineNumberWidth = Math.max(2, String(lineCount).length); - - return ( -
-
-
-          
-            {lines.map((line, index) => (
-              
-
- {index + 1} -
-
- {highlightCode(line)} -
-
- ))} -
-
-
- - -
- ); -} - -// Helper function to highlight code syntax -function highlightCode(line: string) { - if (line.trim().startsWith("import")) { - const importMatch = line.match(/\{\s*([^}]+)\s*\}/); - const pathMatch = line.match(/"([^"]+)"/); - return ( - - import - {"{" + " "} - - {importMatch ? importMatch[1] : ""} - - {" " + "}"} - from - - {" "} - "{pathMatch ? pathMatch[1] : ""}" - - ; - - ); - } - if (line.includes("{line}; - } - if (line.includes("appId=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - appId - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("toChain=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - toChain - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("toAddress=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - toAddress - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("toUnits=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - toUnits - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("toToken=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - toToken - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("intent=")) { - const valueMatch = line.match(/=([^,]+)/); - return ( - - intent - = - - {valueMatch ? valueMatch[1] : ""} - - - ); - } - if (line.includes("getAddress")) { - return ( - - getAddress - {line.replace("getAddress", "")} - - ); - } - return {line}; -} diff --git a/examples/nextjs-app/src/app/config-panel.tsx b/examples/nextjs-app/src/app/config-panel.tsx deleted file mode 100644 index 504543c28..000000000 --- a/examples/nextjs-app/src/app/config-panel.tsx +++ /dev/null @@ -1,439 +0,0 @@ -import { XMarkIcon } from "@heroicons/react/24/outline"; -import { - getChainById, - supportedPayoutTokens, - Token, -} from "@rozoai/intent-common"; -import { validateAddressForChain } from "@rozoai/intent-pay"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { isAddress } from "viem"; - -// Define the possible configuration types -export type ConfigType = "payment" | "deposit"; - -// Base configuration interface - unified address field -interface BaseConfig { - recipientAddress: string; // Unified: EVM Address or Solana/Stellar string - chainId: number; // Destination chain ID (EVM, Solana, or Stellar) - tokenAddress: string; // Token address or identifier - amount: string; -} - -// Payment extends base with amount -export interface PaymentConfig extends BaseConfig { - amount: string; -} - -// Deposit uses base config directly -export interface DepositConfig extends BaseConfig { - amount: ""; -} - -// Common props for the config panel -interface ConfigPanelProps { - configType: ConfigType; - isOpen: boolean; - onClose: () => void; - onConfirm: (config: PaymentConfig | DepositConfig) => void; - defaultRecipientAddress?: string; -} - -export function ConfigPanel({ - configType, - isOpen, - onClose, - onConfirm, - defaultRecipientAddress = "", -}: ConfigPanelProps) { - const titleId = `${configType}-config-title`; - const chainFieldId = `${configType}-chain`; - const tokenFieldId = `${configType}-token`; - const addressFieldId = `${configType}-recipient-address`; - const amountFieldId = `${configType}-amount`; - - // Initialize with default values - const [config, setConfig] = useState({ - recipientAddress: defaultRecipientAddress, - chainId: 0, - tokenAddress: "", - amount: "", - }); - - // Load saved config after mount - useEffect(() => { - const storageKey = - configType === "payment" ? "rozo-basic-config" : "rozo-deposit-config"; - try { - const savedConfig = localStorage.getItem(storageKey); - if (savedConfig) { - const parsed = JSON.parse(savedConfig); - const parsedConfig = { ...parsed }; - const normalizedChainId = Number(parsed.chainId); - const hasValidChainId = - Number.isFinite(normalizedChainId) && normalizedChainId > 0; - - Object.assign(parsedConfig, { - chainId: hasValidChainId ? normalizedChainId : 0, - }); - - // Validate token address based on chain type - if (hasValidChainId) { - const chain = getChainById(normalizedChainId); - const isEvm = chain.type === "evm"; - if (isEvm && !isAddress(parsed.tokenAddress)) { - Object.assign(parsedConfig, { - tokenAddress: "", - }); - } - } - - // Validate recipient address based on chain type - if (hasValidChainId && parsed.recipientAddress) { - const isValid = validateAddressForChain( - normalizedChainId, - parsed.recipientAddress - ); - if (!isValid) { - // Reset invalid address - Object.assign(parsedConfig, { - recipientAddress: "", - }); - } - } - - if ( - parsedConfig && - typeof parsedConfig === "object" && - "recipientAddress" in parsedConfig && - "chainId" in parsedConfig && - "tokenAddress" in parsedConfig - ) { - setConfig(parsedConfig); - } - } - } catch (e) { - console.error("Failed to load saved config:", e); - } - }, [configType]); // Only run when configType changes - - // Add error state for recipient address - const [addressError, setAddressError] = useState(""); - - // Extract unique chains from supportedPayoutTokens - const chains = useMemo(() => { - return Array.from(supportedPayoutTokens.keys()) - .map((chainId) => getChainById(chainId)) - .filter((chain): chain is NonNullable => chain !== null) - .sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically - }, []); - - // Get tokens for selected chain from supportedPayoutTokens - const tokens = useMemo((): Token[] => { - if (config.chainId === 0) return []; - - // Get tokens directly from supportedPayoutTokens - const tokensForChain = supportedPayoutTokens.get(config.chainId); - return tokensForChain || []; - }, [config.chainId]); - - // Validate address on change based on chain type - const validateAddress = useCallback((address: string, chainId: number) => { - if (!address) { - setAddressError("Address is required"); - return false; - } - - if (chainId === 0) { - setAddressError(""); - return true; // No validation if chain not selected - } - - const isValid = validateAddressForChain(chainId, address); - if (!isValid) { - setAddressError("Invalid address format"); - return false; - } - - setAddressError(""); - return true; - }, []); - - // Update address handler - const handleAddressChange = (e: React.ChangeEvent) => { - const newAddress = e.target.value; - setConfig((prev) => ({ - ...prev, - recipientAddress: newAddress, - })); - validateAddress(newAddress, config.chainId); - }; - - // Update form submission - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - if (!isFormValid()) { - alert("Please complete all required fields"); - return; - } - - if (!config.recipientAddress) { - const chain = getChainById(config.chainId); - if (!chain) return; - - alert("Please enter a valid recipient address"); - return; - } - - // Validate recipient address based on chain type - const isValid = validateAddressForChain( - config.chainId, - config.recipientAddress - ); - if (!isValid) { - alert("Please enter a valid address"); - return; - } - - // Create the appropriate config object based on type - if (configType === "payment") { - onConfirm(config); - } else { - onConfirm({ ...config, amount: "" }); - } - - onClose(); - }; - - // Determine if the form is valid based on config type - const isFormValid = () => { - if (config.chainId === 0 || !config.tokenAddress) { - return false; - } - - const addressValid = validateAddressForChain( - config.chainId, - config.recipientAddress - ); - const baseValid = - addressValid && config.chainId > 0 && config.tokenAddress !== ""; - - // Payment requires amount field - if (configType === "payment") { - return baseValid && config.amount !== ""; - } - - // Deposit doesn't need amount - return baseValid; - }; - - if (!isOpen) { - return null; - } - - return ( - <> -