diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..77e9744d --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,5 @@ +{ + "setup-worktree": [ + "npm install" + ] +} diff --git a/README.md b/README.md index 8e34af17..dfb706bd 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,59 @@ Key directories: - `utils/` and `lib/`: Utilities and integrations (Cashu, Nostr) - `test/`: Scripts and docs for Lightning/regtest testing +## API Mocking with MSW + +This project uses [Mock Service Worker (MSW)](https://mswjs.io/) for API mocking in development. This allows you to test error scenarios and edge cases without hitting the real backend. + +### Testing the 413 Payload Too Large Error + +To test the 413 error scenario: + +1. **Start the development server:** + ```bash + npm run dev + ``` + +2. **Open the app in your browser** and wait for the service worker to initialize (check Network tab for `mockServiceWorker.js`). + +3. **Enable the 413 mock scenario** in your browser console: + ```javascript + localStorage.setItem('msw:scenario', '413'); + // Optional: add latency delay + localStorage.setItem('msw:latency', '1500'); // milliseconds + ``` + +4. **Refresh the page** and trigger a chat request. The API call to `v1/chat/completions` will return a 413 error with the payload: + ```json + { + "error": { + "message": "Payload Too Large", + "code": "PAYLOAD_TOO_LARGE", + "status": 413 + } + } + ``` + +5. **Disable the mock** when done: + ```javascript + localStorage.removeItem('msw:scenario'); + localStorage.removeItem('msw:latency'); + ``` + +The mock handler is configured in `mocks/handlers.ts` and automatically starts in development mode via `components/ClientProviders.tsx`. + +### Minibits Mint Mock Data + +**⚠️ Warning:** If you encounter unusual mint errors for Minibits in development mode (e.g., keyset errors, unexpected responses), the cached mock data in `mocks/handlers.ts` may be outdated. The mock responses for `/v1/keysets` and `/v1/info` endpoints should be updated periodically to match the current Minibits mint state. + +To update the mock data: +1. Fetch the latest data from the real mint: + ```bash + curl https://mint.minibits.cash/Bitcoin/v1/keysets + curl https://mint.minibits.cash/Bitcoin/v1/info + ``` +2. Update the corresponding handlers in `mocks/handlers.ts` with the fresh responses. + ## License MIT \ No newline at end of file diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 00000000..3ee8b847 --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,632 @@ +# Refactoring Plan: Modular Architecture for Routstr Chat + +## Current Issues + +### 1. **Scattered Wallet Logic** +Currently, wallet functionality is fragmented across: +- **hooks/**: `useCashuWallet`, `useCreateCashuWallet`, `useCashuToken`, `useWalletOperations`, `useNutzaps`, `useCashuHistory` +- **lib/**: `cashu.ts`, `cashuLightning.ts` +- **utils/**: `cashuUtils.ts`, `walletUtils.ts` +- **stores/**: `cashuStore.ts`, `nutzapStore.ts`, `transactionHistoryStore.ts` + +### 2. **Scattered Chat Logic** +Chat functionality is spread across: +- **hooks/**: `useChatActions`, `useConversationState` +- **utils/**: `messageUtils`, `apiUtils`, `conversationUtils` +- **context/**: `ChatProvider` + +### 3. **Scattered Nostr Logic** +Nostr infrastructure is distributed between: +- **hooks/**: `useNostr`, `useAuthor`, `useCurrentUser`, `useLoginActions` +- **lib/**: `nostr.ts`, `nostr-kinds.ts`, `nostrTimestamps.ts` +- **context/**: `NostrContext`, `NostrProvider` +- **utils/**: `nip60Utils` + +### 4. **General Problems** +- ❌ No clear separation between domain logic and UI/infrastructure +- ❌ Hooks contain too much business logic (hard to test) +- ❌ Utils folder is a catch-all with no clear organization +- ❌ Difficult to understand dependencies between modules +- ❌ **Cannot easily extract wallet functionality for reuse in other projects** +- ❌ High cognitive load for new contributors + +--- + +## Proposed Architecture + +### Feature-Based Organization with Domain-Driven Design + +``` +src/ +├── features/ # Feature modules (self-contained domains) +│ ├── wallet/ # 🎯 Wallet feature (easily extractable!) +│ │ ├── core/ # Pure business logic (framework-agnostic) +│ │ │ ├── domain/ # Domain models and types +│ │ │ │ ├── Proof.ts +│ │ │ │ ├── Token.ts +│ │ │ │ ├── Mint.ts +│ │ │ │ └── Transaction.ts +│ │ │ ├── services/ # Business logic services +│ │ │ │ ├── CashuWalletService.ts +│ │ │ │ ├── TokenService.ts +│ │ │ │ ├── MintService.ts +│ │ │ │ ├── LightningService.ts +│ │ │ │ └── NutzapService.ts +│ │ │ └── utils/ # Wallet-specific utilities +│ │ │ ├── balance.ts +│ │ │ ├── fees.ts +│ │ │ └── formatting.ts +│ │ ├── infrastructure/ # External dependencies & adapters +│ │ │ ├── api/ # API clients +│ │ │ │ └── cashu-mint-client.ts +│ │ │ ├── storage/ # Storage adapters +│ │ │ │ ├── ProofStorage.ts +│ │ │ │ └── WalletStorage.ts +│ │ │ └── nostr/ # Nostr integration for wallet +│ │ │ ├── nip60-adapter.ts +│ │ │ └── nutzap-adapter.ts +│ │ ├── state/ # State management +│ │ │ ├── cashuStore.ts +│ │ │ ├── nutzapStore.ts +│ │ │ └── transactionHistoryStore.ts +│ │ ├── hooks/ # React hooks for wallet +│ │ │ ├── useCashuWallet.ts +│ │ │ ├── useCashuToken.ts +│ │ │ ├── useLightning.ts +│ │ │ └── useWalletBalance.ts +│ │ ├── components/ # Wallet UI components +│ │ │ ├── WalletDisplay.tsx +│ │ │ ├── DepositModal.tsx +│ │ │ ├── SendTokenForm.tsx +│ │ │ └── MintSelector.tsx +│ │ ├── index.ts # Public API (what to expose) +│ │ └── README.md # Feature documentation +│ │ +│ ├── chat/ # Chat feature +│ │ ├── core/ +│ │ │ ├── domain/ +│ │ │ │ ├── Message.ts +│ │ │ │ ├── Conversation.ts +│ │ │ │ └── Model.ts +│ │ │ ├── services/ +│ │ │ │ ├── ChatService.ts +│ │ │ │ ├── ConversationService.ts +│ │ │ │ ├── StreamingService.ts +│ │ │ │ └── MessageService.ts +│ │ │ └── utils/ +│ │ │ ├── message-formatting.ts +│ │ │ └── thinking-parser.ts +│ │ ├── infrastructure/ +│ │ │ ├── api/ +│ │ │ │ └── ai-api-client.ts +│ │ │ └── storage/ +│ │ │ └── ConversationStorage.ts +│ │ ├── state/ +│ │ │ └── chatStore.ts +│ │ ├── hooks/ +│ │ │ ├── useChatActions.ts +│ │ │ ├── useConversationState.ts +│ │ │ └── useStreamingResponse.ts +│ │ ├── components/ +│ │ │ ├── ChatContainer.tsx +│ │ │ ├── ChatMessages.tsx +│ │ │ ├── ChatInput.tsx +│ │ │ └── MessageContent.tsx +│ │ ├── index.ts +│ │ └── README.md +│ │ +│ └── nostr/ # Nostr feature (identity & relay management) +│ ├── core/ +│ │ ├── domain/ +│ │ │ ├── Event.ts +│ │ │ ├── User.ts +│ │ │ └── Relay.ts +│ │ ├── services/ +│ │ │ ├── NostrService.ts +│ │ │ ├── RelayService.ts +│ │ │ ├── AuthService.ts +│ │ │ └── EventPublisher.ts +│ │ └── utils/ +│ │ ├── nip-04.ts +│ │ ├── nip-44.ts +│ │ └── key-management.ts +│ ├── infrastructure/ +│ │ ├── relay/ +│ │ │ └── relay-pool.ts +│ │ └── storage/ +│ │ └── NostrStorage.ts +│ ├── state/ +│ │ └── nostrStore.ts +│ ├── hooks/ +│ │ ├── useNostr.ts +│ │ ├── useCurrentUser.ts +│ │ └── useRelays.ts +│ ├── providers/ +│ │ └── NostrProvider.tsx +│ ├── index.ts +│ └── README.md +│ +├── shared/ # Shared utilities and infrastructure +│ ├── types/ # Global TypeScript types +│ │ └── index.ts +│ ├── config/ # App-wide configuration +│ │ ├── constants.ts +│ │ └── env.ts +│ ├── lib/ # Third-party library wrappers +│ │ └── query-client.ts +│ ├── hooks/ # Generic React hooks +│ │ ├── useLocalStorage.ts +│ │ ├── useMediaQuery.ts +│ │ └── useDebounce.ts +│ └── utils/ # Generic utilities +│ ├── storage.ts +│ ├── formatting.ts +│ └── validation.ts +│ +├── components/ # Shared/Layout UI components only +│ ├── ui/ # Shadcn/Radix primitives +│ │ ├── button.tsx +│ │ ├── dialog.tsx +│ │ └── ... +│ └── layout/ +│ ├── AppLayout.tsx +│ └── Header.tsx +│ +└── app/ # Next.js app directory (routes only) + ├── layout.tsx + ├── page.tsx + └── ... +``` + +--- + +## Migration Strategy + +### Phase 1: Extract Wallet Feature (Week 1-2) + +**Goal**: Make wallet functionality a self-contained, reusable module + +#### Step 1.1: Create Domain Models +```typescript +// features/wallet/core/domain/Proof.ts +export interface Proof { + id: string; + amount: number; + secret: string; + C: string; +} + +// features/wallet/core/domain/Token.ts +export interface CashuToken { + mint: string; + proofs: Proof[]; + del?: string[]; +} + +// features/wallet/core/domain/Mint.ts +export interface Mint { + url: string; + info?: MintInfo; + keysets?: MintKeyset[]; + active: boolean; +} +``` + +#### Step 1.2: Create Service Layer +```typescript +// features/wallet/core/services/CashuWalletService.ts +export class CashuWalletService { + constructor( + private storage: IWalletStorage, + private mintClient: ICashuMintClient, + private nostrAdapter?: INip60Adapter + ) {} + + async createWallet(privkey: string, mints: string[]): Promise { + // Pure business logic - no React, no hooks + } + + async getBalance(): Promise { + // ... + } + + async sendToken(amount: number, mintUrl: string): Promise { + // ... + } +} +``` + +#### Step 1.3: Create Infrastructure Adapters +```typescript +// features/wallet/infrastructure/storage/ProofStorage.ts +export interface IProofStorage { + saveProofs(proofs: Proof[]): Promise; + getProofs(mintUrl: string): Promise; + removeProofs(proofs: Proof[]): Promise; +} + +export class LocalStorageProofAdapter implements IProofStorage { + // Implementation using localStorage +} + +export class Nip60ProofAdapter implements IProofStorage { + // Implementation using Nostr NIP-60 +} +``` + +#### Step 1.4: Create React Hooks (Thin wrappers) +```typescript +// features/wallet/hooks/useCashuWallet.ts +export function useCashuWallet() { + const service = useWalletService(); // Dependency injection + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['wallet'], + queryFn: () => service.getWallet() + }); +} +``` + +#### Step 1.5: Public API Export +```typescript +// features/wallet/index.ts +// Services (for framework-agnostic usage) +export { CashuWalletService } from './core/services/CashuWalletService'; +export { TokenService } from './core/services/TokenService'; + +// Hooks (for React usage) +export { useCashuWallet } from './hooks/useCashuWallet'; +export { useCashuToken } from './hooks/useCashuToken'; + +// Types +export type * from './core/domain'; + +// Components (optional) +export { WalletDisplay } from './components/WalletDisplay'; +``` + +**Migration Checklist for Wallet:** +- [ ] Move `lib/cashu.ts` → `features/wallet/core/services/` +- [ ] Move `lib/cashuLightning.ts` → `features/wallet/core/services/LightningService.ts` +- [ ] Move `utils/cashuUtils.ts` → `features/wallet/core/utils/` +- [ ] Move `utils/walletUtils.ts` → `features/wallet/core/utils/` +- [ ] Move `stores/cashuStore.ts` → `features/wallet/state/` +- [ ] Move `stores/nutzapStore.ts` → `features/wallet/state/` +- [ ] Move hooks → `features/wallet/hooks/` +- [ ] Move wallet components → `features/wallet/components/` +- [ ] Create adapters for external dependencies +- [ ] Write `features/wallet/README.md` with usage examples + +--- + +### Phase 2: Extract Chat Feature (Week 3) + +**Migration Checklist:** +- [ ] Create domain models (Message, Conversation, Model) +- [ ] Extract `utils/apiUtils.ts` → `features/chat/infrastructure/api/` +- [ ] Extract `utils/messageUtils.ts` → `features/chat/core/utils/` +- [ ] Extract `utils/conversationUtils.ts` → `features/chat/core/utils/` +- [ ] Move `hooks/useChatActions.ts` → `features/chat/hooks/` +- [ ] Move chat components → `features/chat/components/` +- [ ] Create ChatService for business logic +- [ ] Create `features/chat/index.ts` public API + +--- + +### Phase 3: Extract Nostr Feature (Week 4) + +**Migration Checklist:** +- [ ] Create domain models (Event, User, Relay) +- [ ] Extract `lib/nostr.ts` → `features/nostr/core/services/` +- [ ] Extract NIP utilities → `features/nostr/core/utils/` +- [ ] Move `context/NostrContext.tsx` → `features/nostr/providers/` +- [ ] Move hooks → `features/nostr/hooks/` +- [ ] Create NostrService for relay management +- [ ] Create `features/nostr/index.ts` public API + +--- + +### Phase 4: Cleanup & Reorganize Shared Code (Week 5) + +**Migration Checklist:** +- [ ] Move generic hooks → `shared/hooks/` +- [ ] Move generic utils → `shared/utils/` +- [ ] Create `shared/types/` for global types +- [ ] Keep UI components in root `components/` (they're truly shared) +- [ ] Update all import paths +- [ ] Remove old `utils/`, `lib/`, `hooks/` folders + +--- + +## Benefits of New Structure + +### ✅ For External Contributors + +1. **Clear Entry Points**: + - "Want to work on wallet? Look in `features/wallet/`" + - "Want to fix chat? Look in `features/chat/`" + +2. **Self-Documenting**: + - Each feature has its own README + - Clear separation: domain → services → infrastructure → UI + +3. **Easy Testing**: + - Core business logic has no React dependencies + - Can test services in isolation + - Mock infrastructure easily + +### ✅ For Code Reuse + +**Example: Using wallet in another project** + +```typescript +// In another project (non-React) +import { CashuWalletService } from '@routstr/wallet'; + +const walletService = new CashuWalletService( + new MyCustomStorage(), + new CashuMintClient() +); + +const balance = await walletService.getBalance(); +``` + +```typescript +// In another React project +import { useCashuWallet, WalletDisplay } from '@routstr/wallet'; + +function App() { + const { balance } = useCashuWallet(); + return ; +} +``` + +### ✅ For Maintainability + +- **Dependency Injection**: Services don't hard-code dependencies +- **Testability**: Business logic separated from React +- **Boundaries**: Clear contracts between features +- **Scalability**: Add new features without touching existing ones + +--- + +## Example: Before & After + +### Before (Current) +```typescript +// hooks/useCashuWallet.ts - 482 lines of mixed concerns +- Nostr queries +- State management +- Business logic +- Error handling +- React hooks +- Cashu SDK calls +``` + +### After (Proposed) +```typescript +// features/wallet/core/services/CashuWalletService.ts +class CashuWalletService { + async getWallet(): Promise { + // Pure business logic + } +} + +// features/wallet/infrastructure/nostr/nip60-adapter.ts +class Nip60Adapter implements IWalletStorage { + // Nostr-specific implementation +} + +// features/wallet/hooks/useCashuWallet.ts (30 lines) +export function useCashuWallet() { + const service = useWalletService(); + return useQuery(['wallet'], () => service.getWallet()); +} +``` + +--- + +## Publishing Wallet as Standalone Package + +Once refactored, you can publish the wallet feature: + +```json +// package.json for @routstr/wallet +{ + "name": "@routstr/wallet", + "version": "1.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./react": "./dist/hooks/index.js", + "./components": "./dist/components/index.js" + }, + "peerDependencies": { + "react": ">=18", + "@tanstack/react-query": ">=5" + } +} +``` + +Usage in other projects: +```bash +npm install @routstr/wallet +``` + +--- + +## Step-by-Step Migration Commands + +### 1. Create Feature Directories +```bash +mkdir -p features/wallet/{core/{domain,services,utils},infrastructure/{api,storage,nostr},state,hooks,components} +mkdir -p features/chat/{core/{domain,services,utils},infrastructure/{api,storage},state,hooks,components} +mkdir -p features/nostr/{core/{domain,services,utils},infrastructure/{relay,storage},state,hooks,providers} +mkdir -p shared/{types,config,lib,hooks,utils} +``` + +### 2. Start with Wallet Migration +```bash +# Move domain logic +mv lib/cashu.ts features/wallet/core/services/CashuWalletService.ts +mv lib/cashuLightning.ts features/wallet/core/services/LightningService.ts + +# Move utilities +mv utils/cashuUtils.ts features/wallet/core/utils/ +mv utils/walletUtils.ts features/wallet/core/utils/ + +# Move state +mv stores/cashuStore.ts features/wallet/state/ +mv stores/nutzapStore.ts features/wallet/state/ + +# Move hooks +mv hooks/useCashuWallet.ts features/wallet/hooks/ +mv hooks/useCashuToken.ts features/wallet/hooks/ +mv hooks/useNutzaps.ts features/wallet/hooks/ +``` + +### 3. Fix Imports +After moving files, update all imports: +```typescript +// Old +import { useCashuWallet } from '@/hooks/useCashuWallet'; + +// New +import { useCashuWallet } from '@/features/wallet'; +``` + +--- + +## Testing Strategy + +### Core Services (Pure Functions) +```typescript +// features/wallet/core/services/__tests__/CashuWalletService.test.ts +describe('CashuWalletService', () => { + it('should calculate balance correctly', () => { + const service = new CashuWalletService(mockStorage, mockClient); + const balance = service.calculateBalance(mockProofs); + expect(balance).toBe(1000); + }); +}); +``` + +### Infrastructure Adapters +```typescript +// features/wallet/infrastructure/storage/__tests__/Nip60Adapter.test.ts +describe('Nip60Adapter', () => { + it('should save proofs to Nostr', async () => { + const adapter = new Nip60Adapter(mockNostr); + await adapter.saveProofs(mockProofs); + expect(mockNostr.event).toHaveBeenCalled(); + }); +}); +``` + +### React Hooks +```typescript +// features/wallet/hooks/__tests__/useCashuWallet.test.tsx +import { renderHook } from '@testing-library/react'; + +it('should fetch wallet data', async () => { + const { result } = renderHook(() => useCashuWallet()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); +}); +``` + +--- + +## Documentation for Each Feature + +Each feature should have a README: + +### `features/wallet/README.md` +```markdown +# Wallet Feature + +## Overview +Cashu ecash wallet with NIP-60 integration. + +## Usage + +### In React +\`\`\`tsx +import { useCashuWallet } from '@/features/wallet'; + +function MyComponent() { + const { balance } = useCashuWallet(); + return
Balance: {balance}
; +} +\`\`\` + +### Without React +\`\`\`ts +import { CashuWalletService } from '@/features/wallet'; + +const service = new CashuWalletService(storage, client); +const balance = await service.getBalance(); +\`\`\` + +## Architecture +- Core: Pure business logic +- Infrastructure: External dependencies +- Hooks: React integration +``` + +--- + +## Estimated Timeline + +| Phase | Duration | Tasks | +|-------|----------|-------| +| Phase 1 | 2 weeks | Extract Wallet Feature | +| Phase 2 | 1 week | Extract Chat Feature | +| Phase 3 | 1 week | Extract Nostr Feature | +| Phase 4 | 1 week | Cleanup & Documentation | +| **Total** | **5 weeks** | **Complete Refactor** | + +--- + +## Success Metrics + +✅ **Wallet is 100% extractable** - Can be used in any JS project +✅ **All features have <100 LOC hooks** - Business logic moved to services +✅ **90%+ test coverage** - Core services fully tested +✅ **Documentation complete** - Each feature has README +✅ **New contributor onboarding < 1 hour** - Clear structure + +--- + +## Questions & Considerations + +1. **Do we want to keep using Zustand?** + - Consider moving to services + React Query for server state + - Keep Zustand only for UI state + +2. **Monorepo or Separate Packages?** + - Consider Turborepo/Nx if you want to publish `@routstr/wallet` separately + +3. **TypeScript Strict Mode?** + - Now is a good time to enable `strict: true` + +4. **Testing Framework?** + - Add Vitest for unit tests + - Add Playwright for E2E + +--- + +## Next Steps + +1. **Review this plan** with the team +2. **Start with Phase 1** (Wallet) - it's the highest priority +3. **Create feature branch** `refactor/modular-architecture` +4. **Migrate incrementally** - don't break main +5. **Write tests** as you migrate +6. **Update documentation** continuously + +--- + +**Questions? Start with `features/wallet/` and make it work. Then replicate the pattern for other features.** + diff --git a/app/globals.css b/app/globals.css index 193eca89..3b667a7d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,13 +6,17 @@ :root { --radius: 1rem; --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); + --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); + --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); + --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); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); @@ -125,6 +129,15 @@ body { } } +/* Bitcoin Connect theme variables */ +html { + --bc-color-brand: #196ce7; + --bc-color-brand-dark: #3994ff; + --bc-brand-mix: 100%; + --bc-color-brand-button-text: #ffffff; + --bc-color-brand-button-text-dark: #ffffff; +} + /* Custom container sizing for narrower max-width */ .container { width: 100%; diff --git a/app/layout.tsx b/app/layout.tsx index dd67c4f3..7381a583 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import ClientProviders from "@/components/ClientProviders"; import { Toaster } from "sonner"; +import BitcoinConnectClient from "@/components/bitcoin-connect/BitcoinConnectClient"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -39,6 +40,7 @@ export default function RootLayout({ {children} + diff --git a/app/page.tsx b/app/page.tsx index 9d22f855..bdd1d2fd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,13 +7,14 @@ import { ChatProvider } from '@/context/ChatProvider'; import ChatContainer from '@/components/chat/ChatContainer'; import SettingsModal from '@/components/SettingsModal'; import LoginModal from '@/components/LoginModal'; -import TutorialOverlay from '@/components/TutorialOverlay'; -import DepositModal from '@/components/DepositModal'; +import TopUpPromptModal from '@/components/TopUpPromptModal'; import { QueryTimeoutModal } from '@/components/QueryTimeoutModal'; +import QRCodeModal from '@/components/QRCodeModal'; import { useAuth } from '@/context/AuthProvider'; import { useChat } from '@/context/ChatProvider'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useCashuWallet } from '@/hooks/useCashuWallet'; +import { useCashuWallet } from '@/features/wallet'; +import { hasSeenTopUpPrompt, markTopUpPromptSeen } from '@/utils/storageUtils'; function ChatPageContent() { const router = useRouter(); @@ -26,14 +27,9 @@ function ChatPageContent() { setIsSettingsOpen, isLoginModalOpen, setIsLoginModalOpen, - isTutorialOpen, initialSettingsTab, - handleTutorialComplete, - handleTutorialClose, // API State - mintUrl, - setMintUrl, baseUrl, setBaseUrl, selectedModel, @@ -55,8 +51,6 @@ function ChatPageContent() { // Chat State clearConversations, - usingNip60, - setUsingNip60, isBalanceLoading, conversations, loadConversation, @@ -64,19 +58,38 @@ function ChatPageContent() { conversationsLoaded, } = useChat(); - const [isDepositModalOpen, setIsDepositModalOpen] = useState(false); - const { showQueryTimeoutModal, setShowQueryTimeoutModal, didRelaysTimeout, isLoading: isWalletLoading } = useCashuWallet(); + const [isTopUpPromptOpen, setIsTopUpPromptOpen] = useState(false); + const [topUpPromptDismissed, setTopUpPromptDismissed] = useState(false); + const { showQueryTimeoutModal, setShowQueryTimeoutModal, didRelaysTimeout, setDidRelaysTimeout, isLoading: isWalletLoading } = useCashuWallet(); const pendingUrlSyncRef = useRef(false); const searchParamsString = useMemo(() => searchParams.toString(), [searchParams]); const chatIdFromUrl = useMemo(() => searchParams.get('chatId'), [searchParams]); + // QR Code Modal State + const [qrModalData, setQrModalData] = useState<{ invoice: string; amount: string; unit: string } | null>(null); + useEffect(() => { - if (!isBalanceLoading && balance === 0 && isAuthenticated && !isSettingsOpen && !usingNip60) { - setIsDepositModalOpen(true); + let topUpTimer: NodeJS.Timeout | null = null; + + if (!isBalanceLoading && balance === 0 && isAuthenticated && !isSettingsOpen) { + if (!hasSeenTopUpPrompt() && !topUpPromptDismissed) { + setIsTopUpPromptOpen(false); + topUpTimer = setTimeout(() => { + setIsTopUpPromptOpen(true); + }, 500); + } else { + setIsTopUpPromptOpen(false); + } } else { - setIsDepositModalOpen(false); + setIsTopUpPromptOpen(false); } - }, [balance, isBalanceLoading, isAuthenticated, usingNip60]); + + return () => { + if (topUpTimer) clearTimeout(topUpTimer); + }; + }, [balance, isBalanceLoading, isAuthenticated, isSettingsOpen, topUpPromptDismissed]); + + const handleTopUp = (_amount?: number) => {}; useEffect(() => { if (!activeConversationId) return; @@ -149,7 +162,7 @@ function ChatPageContent() { return (
- + {/* Modals */} {isSettingsOpen && isAuthenticated && ( @@ -157,8 +170,6 @@ function ChatPageContent() { isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} initialActiveTab={initialSettingsTab} - mintUrl={mintUrl} - setMintUrl={setMintUrl} baseUrl={baseUrl} setBaseUrl={setBaseUrl} selectedModel={selectedModel} @@ -176,10 +187,6 @@ function ChatPageContent() { setConfiguredModels={setConfiguredModels} modelProviderMap={modelProviderMap} setModelProviderFor={setModelProviderFor} - usingNip60={usingNip60} - setUsingNip60={(value) => { - setUsingNip60(value); - }} /> )} @@ -189,27 +196,29 @@ function ChatPageContent() { onLogin={() => setIsLoginModalOpen(false)} /> - {false && ()} - - {/* Deposit Modal */} - {isDepositModalOpen && ( - setIsDepositModalOpen(false)} - mintUrl={mintUrl} - balance={balance} - setBalance={setBalance} - usingNip60={usingNip60} + {/* Top-up Prompt */} + {isTopUpPromptOpen && ( + { setIsTopUpPromptOpen(false); setTopUpPromptDismissed(true); }} + onTopUp={handleTopUp} + onDontShowAgain={() => { setTopUpPromptDismissed(true); markTopUpPromptSeen(); }} + setIsLoginModalOpen={setIsLoginModalOpen} /> )} setShowQueryTimeoutModal(false)} + onClose={() => { console.log('rdlogs: closing query timeout modal', showQueryTimeoutModal, didRelaysTimeout, isWalletLoading); setShowQueryTimeoutModal(false); setDidRelaysTimeout(false); }} + /> + + {/* QR Code Modal */} + setQrModalData(null)} + invoice={qrModalData?.invoice || ''} + amount={qrModalData?.amount || ''} + unit={qrModalData?.unit || ''} />
); diff --git a/components/ClientProviders.tsx b/components/ClientProviders.tsx index a734dbfe..afaf3bb9 100644 --- a/components/ClientProviders.tsx +++ b/components/ClientProviders.tsx @@ -1,9 +1,13 @@ 'use client'; -import { ReactNode, useEffect } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; +import { useEffect as useReactEffect } from 'react'; +import { useNostrLogin } from '@nostrify/react/login'; +import { useLoginActions } from '@/hooks/useLoginActions'; +import { generateSecretKey, nip19 } from 'nostr-tools'; import NostrProvider from '@/components/NostrProvider' import dynamic from 'next/dynamic'; -import { migrateStorageItems } from '@/utils/storageUtils'; +import { migrateStorageItems, saveRelays } from '@/utils/storageUtils'; import { InvoiceRecoveryProvider } from '@/components/InvoiceRecoveryProvider'; const DynamicNostrLoginProvider = dynamic( @@ -17,11 +21,12 @@ import { AppProvider } from './AppProvider'; import { AppConfig } from '@/context/AppContext'; const presetRelays = [ - { url: 'wss://relay.chorus.community', name: 'Chorus' }, + { url: 'wss://relay.routstr.com', name: 'Routstr Relay' }, { url: 'wss://relay.damus.io', name: 'Damus' }, { url: 'wss://nos.lol', name: 'nos.lol' }, { url: 'wss://relay.nostr.band', name: 'Nostr.Band' }, { url: 'wss://relay.primal.net', name: 'Primal' }, + { url: 'wss://relay.chorus.community', name: 'Chorus Relay' }, ]; const queryClient = new QueryClient({ @@ -34,13 +39,54 @@ const queryClient = new QueryClient({ }, }); -const defaultConfig: AppConfig = { - relayUrls: [ -...presetRelays.slice(0, 3).map(relay => relay.url), - ] -}; - export default function ClientProviders({ children }: { children: ReactNode }) { + const [relayUrls, setRelayUrls] = useState( + presetRelays.slice(0, 3).map(relay => relay.url) + ); + + // Fetch relay URLs from URL parameters + useEffect(() => { + if (typeof window === 'undefined') return; + + const params = new URLSearchParams(window.location.search); + const relaysParam = params.get('relays'); + + if (relaysParam) { + // Parse comma-separated relay URLs from URL parameter + const urlRelays = relaysParam + .split(',') + .map(url => url.trim()) + .filter(url => url.startsWith('wss://') || url.startsWith('ws://')); + + if (urlRelays.length > 0) { + setRelayUrls(urlRelays); + } + } + }, []); + saveRelays(relayUrls); + + const defaultConfig: AppConfig = { + relayUrls: relayUrls + }; + + function AutoLogin() { + const { logins } = useNostrLogin(); + const loginActions = useLoginActions(); + + useReactEffect(() => { + if (logins.length === 0) { + try { + const sk = generateSecretKey(); + const nsec = nip19.nsecEncode(sk); + loginActions.nsec(nsec); + } catch (err) { + // no-op + } + } + }, [logins.length]); + + return null; + } // Run storage migration on app startup useEffect(() => { migrateStorageItems(); @@ -67,6 +113,7 @@ export default function ClientProviders({ children }: { children: ReactNode }) { + {children} diff --git a/components/InvoiceRecoveryProvider.tsx b/components/InvoiceRecoveryProvider.tsx index 5c18db53..6b49a27a 100644 --- a/components/InvoiceRecoveryProvider.tsx +++ b/components/InvoiceRecoveryProvider.tsx @@ -5,7 +5,7 @@ import { useInvoiceChecker } from '@/hooks/useInvoiceChecker'; import { useInvoiceSync, StoredInvoice } from '@/hooks/useInvoiceSync'; import { toast } from 'sonner'; import { MintQuoteState, MeltQuoteState } from '@cashu/cashu-ts'; -import { formatBalance } from '@/lib/cashu'; +import { formatBalance } from '@/features/wallet'; interface InvoiceRecoveryProviderProps { children: React.ReactNode; diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index ae4b33ac..8ba30e6a 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useEffect } from 'react'; import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import { useLoginActions } from '@/hooks/useLoginActions'; +import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { Shield, Eye, EyeOff, Copy, Check } from 'lucide-react'; interface LoginModalProps { @@ -30,6 +31,7 @@ export default function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps const [activeTab, setActiveTab] = useState<'create' | 'signin'>('signin'); const loginActions = useLoginActions(); + const { setLogin } = useLoggedInAccounts(); // Reset state when modal opens useEffect(() => { @@ -53,7 +55,8 @@ export default function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps if (!('nostr' in window)) { throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); } - await loginActions.extension(); + const login = await loginActions.extension(); + setLogin(login.id); onLogin(); onClose(); } catch (error) { @@ -70,7 +73,8 @@ export default function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps setError(null); try { - loginActions.nsec(nsec); + const login = loginActions.nsec(nsec); + setLogin(login.id); onLogin(); onClose(); } catch (error) { @@ -115,7 +119,8 @@ export default function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps const completeSignup = async () => { if (generatedNsec) { try { - loginActions.nsec(generatedNsec); + const login = loginActions.nsec(generatedNsec); + setLogin(login.id); onLogin(); onClose(); } catch (error) { @@ -333,6 +338,12 @@ export default function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps type="password" value={nsec} onChange={(e) => setNsec(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void handleKeyLogin(); + } + }} placeholder="nsec1..." className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-white/30 transition-colors" /> diff --git a/components/MessageContent.tsx b/components/MessageContent.tsx index c4fc9239..52eeb34f 100644 --- a/components/MessageContent.tsx +++ b/components/MessageContent.tsx @@ -1,32 +1,48 @@ 'use client'; +import { useState } from 'react'; import MarkdownRenderer from './MarkdownRenderer'; import { downloadImageFromSrc } from '../utils/download'; - -interface MessageContent { - type: 'text' | 'image_url'; - text?: string; - image_url?: { - url: string; - }; -} +import { FileText } from 'lucide-react'; +import type { MessageContent as ChatMessageContent } from '@/types/chat'; interface MessageContentProps { - content: string | MessageContent[]; + content: string | ChatMessageContent[]; } export default function MessageContentRenderer({ content }: MessageContentProps) { + type ImageStatus = 'loading' | 'loaded' | 'error'; + const [imageStatusMap, setImageStatusMap] = useState>({}); + + const setImageStatus = (key: string, status: ImageStatus) => { + setImageStatusMap(prev => { + if (prev[key] === status) return prev; + return { ...prev, [key]: status }; + }); + }; + + const isImageLoaded = (key: string) => imageStatusMap[key] === 'loaded'; + const isImageError = (key: string) => imageStatusMap[key] === 'error'; + if (typeof content === 'string') { return ; } - // Count the number of images - const imageCount = content.filter(item => item.type === 'image_url').length; + const getAttachmentLabel = (mimeType?: string): string | null => { + if (!mimeType) return null; + if (mimeType === 'application/pdf') return 'PDF'; + if (mimeType.startsWith('image/')) { + return mimeType.replace('image/', '').toUpperCase(); + } + return mimeType.toUpperCase(); + }; - // Separate text and images - const textContent = content.filter(item => item.type === 'text'); const imageContent = content.filter(item => item.type === 'image_url'); + // Separate text, image, and file content + const textContent = content.filter(item => item.type === 'text' && !item.hidden); + const fileContent = content.filter(item => item.type === 'file'); + return (
{/* Render text content first */} @@ -34,30 +50,83 @@ export default function MessageContentRenderer({ content }: MessageContentProps) ))} + {/* Render file attachments */} + {fileContent.length > 0 && ( +
+ {fileContent.map((item, index) => { + const label = getAttachmentLabel(item.file?.mimeType); + return ( +
+
+ ); + })} +
+ )} + {/* Render images in a flex container */} {imageContent.length > 0 && ( -
- {imageContent.map((item, index) => ( -
- User uploaded image 1 ? 'max-w-[200px] max-h-[200px]' : 'max-w-[300px] max-h-[300px]'} w-auto h-auto object-contain rounded-lg border border-white/10`} - /> - {item.image_url?.url && ( +
+ {imageContent.map((item, index) => { + const imageUrl = item.image_url?.url; + const statusKey = `${index}-${imageUrl ?? 'no-url'}`; + const loaded = isImageLoaded(statusKey); + const errored = isImageError(statusKey); + + return ( +
+
+
+
+
+ {imageUrl && ( + Image setImageStatus(statusKey, 'loaded')} + onError={() => setImageStatus(statusKey, 'error')} + className={`block max-w-[320px] w-full h-full max-h-[360px] object-contain bg-black/40 transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`} + /> + )} + {errored && ( +
+ Image failed to load +
+ )} - )} -
- ))} +
+ ); + })}
)}
); -} \ No newline at end of file +} diff --git a/components/QRCodeModal.tsx b/components/QRCodeModal.tsx new file mode 100644 index 00000000..2cc30a5b --- /dev/null +++ b/components/QRCodeModal.tsx @@ -0,0 +1,71 @@ +'use client'; + +import React from 'react'; +import { X } from 'lucide-react'; +import QRCode from 'react-qr-code'; + +interface QRCodeModalProps { + isOpen: boolean; + onClose: () => void; + invoice: string; + amount: string; + unit: string; +} + +/** + * Centered QR code modal that displays above all other components + */ +const QRCodeModal: React.FC = ({ isOpen, onClose, invoice, amount, unit }) => { + if (!isOpen || !invoice) return null; + + return ( +
+
e.stopPropagation()} + > +
+

Scan QR Code

+ +
+ +
+
+ +
+
+ +
+
{amount} {unit}s
+ +
+
+
+ ); +}; + +export default QRCodeModal; + diff --git a/components/QueryTimeoutModal.tsx b/components/QueryTimeoutModal.tsx index f57f90a0..73ed73bc 100644 --- a/components/QueryTimeoutModal.tsx +++ b/components/QueryTimeoutModal.tsx @@ -12,6 +12,11 @@ export const QueryTimeoutModal: React.FC = ({ isOpen, on window.location.reload(); }; + const handleDismiss = () => { + try { localStorage.setItem('cashu_relays_timeout', 'false'); } catch {} + onClose(); + }; + if (!isOpen) return null; return ( @@ -22,10 +27,16 @@ export const QueryTimeoutModal: React.FC = ({ isOpen, on It looks like there was a problem connecting to the relays. Please add/remove relays and refresh the page to try again.

-
+
+ diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 8758835d..4a88ac29 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -9,16 +9,17 @@ import GeneralTab from './settings/GeneralTab'; import ModelsTab from '@/components/settings/ModelsTab'; import HistoryTab from './settings/HistoryTab'; import ApiKeysTab from './settings/ApiKeysTab'; -import UnifiedWallet from './settings/UnifiedWallet'; +import UnifiedWallet from '@/features/wallet/components/UnifiedWallet'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { Drawer } from 'vaul'; +import { DEFAULT_MINT_URL } from '@/lib/utils'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; initialActiveTab?: 'settings' | 'wallet' | 'history' | 'api-keys' | 'models'; - mintUrl: string; - setMintUrl: (url: string) => void; baseUrl: string; setBaseUrl: (url: string) => void; selectedModel: Model | null; @@ -36,16 +37,13 @@ interface SettingsModalProps { setConfiguredModels?: (models: string[]) => void; modelProviderMap?: Record; setModelProviderFor?: (modelId: string, baseUrl: string) => void; - usingNip60: boolean; - setUsingNip60: (usingNip60: boolean) => void; + isMobile?: boolean; } const SettingsModal = ({ isOpen, onClose, initialActiveTab, - mintUrl, - setMintUrl, baseUrl, setBaseUrl, selectedModel, @@ -63,13 +61,14 @@ const SettingsModal = ({ setConfiguredModels, modelProviderMap, setModelProviderFor, - usingNip60, - setUsingNip60 + isMobile: propIsMobile }: SettingsModalProps) => { const { user } = useCurrentUser(); const {logins} = useNostrLogin(); const [activeTab, setActiveTab] = useState<'settings' | 'wallet' | 'history' | 'api-keys' | 'models'>(initialActiveTab || 'settings'); const [baseUrls, setBaseUrls] = useState([]); // State to hold base URLs + const mediaQueryIsMobile = useMediaQuery('(max-width: 640px)'); + const isMobile = propIsMobile ?? mediaQueryIsMobile; // Derive base URLs from current baseUrl only (no defaults) useEffect(() => { @@ -78,14 +77,121 @@ const SettingsModal = ({ }, [baseUrl]); - // Handle auto-saving mint URL changes - const handleMintUrlChange = useCallback((url: string) => { - setMintUrl(url); - localStorage.setItem('mint_url', url); - }, [setMintUrl]); + if (!isOpen) return null; + const contentBody = ( + <> +
+

Settings

+ +
- if (!isOpen) return null; + {/* Tabs */} +
+ + + + + +
+ +
+ {activeTab === 'settings' ? ( + + ) : activeTab === 'models' ? ( + + ) : activeTab === 'history' ? ( + + ) : activeTab === 'api-keys' ? ( + + ) : activeTab === 'wallet' ? ( + + ) : null} +
+ + ); + + if (isMobile) { + return ( + { if (!open) onClose(); }}> + + + +
+
+ Settings +
+ {contentBody} +
+
+ + + + ); + } return (
@@ -97,101 +203,7 @@ const SettingsModal = ({ paddingBottom: 'env(safe-area-inset-bottom)' }} > -
-

Settings

- -
- - {/* Tabs */} -
- - - - - -
- -
- {activeTab === 'settings' ? ( - - ) : activeTab === 'models' ? ( - - ) : activeTab === 'history' ? ( - - ) : activeTab === 'api-keys' ? ( - - ) : activeTab === 'wallet' ? ( - - ) : null} -
+ {contentBody}
); diff --git a/components/TopUpPromptModal.tsx b/components/TopUpPromptModal.tsx new file mode 100644 index 00000000..b273b6d9 --- /dev/null +++ b/components/TopUpPromptModal.tsx @@ -0,0 +1,717 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { QrCode, ClipboardPaste } from 'lucide-react'; +import QRCode from 'react-qr-code'; +import { Drawer } from 'vaul'; +import { useCashuWallet, useCashuStore, useTransactionHistoryStore, formatBalance, useCashuToken } from '@/features/wallet'; +import { useInvoiceSync } from '@/hooks/useInvoiceSync'; +import { PendingTransaction } from '@/features/wallet/state/transactionHistoryStore'; +import { createLightningInvoice, mintTokensFromPaidInvoice } from '@/lib/cashuLightning'; +import { MintQuoteState, getDecodedToken } from '@cashu/cashu-ts'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; + +interface TopUpPromptModalProps { + isOpen: boolean; + onClose: () => void; + onTopUp: (amount?: number) => void; + onDontShowAgain: () => void; + setIsLoginModalOpen: (open: boolean) => void; +} + +const TopUpPromptModal: React.FC = ({ isOpen, onClose, onDontShowAgain, setIsLoginModalOpen }) => { + const [customAmount, setCustomAmount] = useState(''); + const [invoice, setInvoice] = useState(''); + const [quoteId, setQuoteId] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [pendingTransactionId, setPendingTransactionId] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); + const [pendingAmount, setPendingAmount] = useState(null); + + const { updateProofs } = useCashuWallet(); + const cashuStore = useCashuStore(); + const { addInvoice, updateInvoice } = useInvoiceSync(); + const transactionHistoryStore = useTransactionHistoryStore(); + const { receiveToken } = useCashuToken(); + const isMobile = useMediaQuery('(max-width: 640px)'); + const [bcStatus, setBcStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); + const [bcBalance, setBcBalance] = useState(null); + const [cashuToken, setCashuToken] = useState(''); + const [isReceivingToken, setIsReceivingToken] = useState(false); + const [activeTab, setActiveTab] = useState<'lightning' | 'token' | 'wallet'>('lightning'); + const [nwcCustomAmount, setNwcCustomAmount] = useState(''); + const [isPayingWithNWC, setIsPayingWithNWC] = useState(false); + + useEffect(() => { + let unsubConnect: undefined | (() => void); + let unsubDisconnect: undefined | (() => void); + let unsubConnecting: undefined | (() => void); + + (async () => { + try { + const mod = await import('@getalby/bitcoin-connect-react'); + const fetchBalance = async (provider: any): Promise => { + try { + if (provider && typeof provider.getBalance === 'function') { + const res = await provider.getBalance(); + if (typeof res === 'number') return res; + if (res && typeof res === 'object') { + if ('balance' in res && typeof (res as any).balance === 'number') { + const unit = ((res as any).unit || '').toString().toLowerCase(); + const n = (res as any).balance as number; + return unit.includes('msat') ? Math.floor(n / 1000) : n; + } + if ('balanceMsats' in res && typeof (res as any).balanceMsats === 'number') { + return Math.floor((res as any).balanceMsats / 1000); + } + } + } + } catch {} + return null; + }; + + unsubConnecting = mod.onConnecting?.(() => setBcStatus('connecting')); + unsubConnect = mod.onConnected?.(async (provider: any) => { + setBcStatus('connected'); + const sats = await fetchBalance(provider); + if (sats !== null) setBcBalance(sats); + }); + unsubDisconnect = mod.onDisconnected?.(() => { + setBcStatus('disconnected'); + setBcBalance(null); + }); + + try { + const cfg = mod.getConnectorConfig?.(); + if (cfg) { + setBcStatus('connected'); + try { + const provider = await mod.requestProvider(); + const sats = await fetchBalance(provider); + if (sats !== null) setBcBalance(sats); + } catch {} + } + } catch {} + } catch {} + })(); + + return () => { + try { unsubConnect && unsubConnect(); } catch {} + try { unsubDisconnect && unsubDisconnect(); } catch {} + try { unsubConnecting && unsubConnecting(); } catch {} + }; + }, []); + + // Prevent hydration mismatch by waiting for client-side hydration + useEffect(() => { + setIsHydrated(true); + }, []); + + if (!isOpen || !isHydrated) return null; + + const quickAmounts = [500, 1000, 5000]; + + const copyInvoiceToClipboard = async () => { + if (!invoice) return; + try { + await navigator.clipboard.writeText(invoice); + setSuccessMessage('Invoice copied to clipboard'); + setTimeout(() => setSuccessMessage(null), 2000); + } catch (e) { + setError('Failed to copy invoice'); + setTimeout(() => setError(null), 2000); + } + }; + + const handlePasteToken = async () => { + try { + const text = await navigator.clipboard.readText(); + setCashuToken(text); + } catch (e) { + setError('Failed to read from clipboard'); + setTimeout(() => setError(null), 2000); + } + }; + + const handleReceiveToken = async () => { + if (!cashuToken.trim()) { + setError('Please paste a cashu token'); + setTimeout(() => setError(null), 2000); + return; + } + + try { + setIsReceivingToken(true); + setError(null); + + // Decode token to get original amount and unit for display + const decodedToken = getDecodedToken(cashuToken.trim()); + if (!decodedToken) { + throw new Error('Invalid token format'); + } + + const tokenUnit = decodedToken.unit || 'sat'; + // Calculate total from original token proofs + const originalTotalAmount = decodedToken.proofs.reduce((sum: number, p: { amount: number }) => sum + p.amount, 0); + + // Receive the token + await receiveToken(cashuToken.trim()); + + // Convert msat to sat for display consistency + const displayAmount = tokenUnit === 'msat' ? Math.floor(originalTotalAmount / 1000) : originalTotalAmount; + + setSuccessMessage(`Received ${formatBalance(displayAmount, 'sats')}!`); + setCashuToken(''); + setTimeout(() => { + setSuccessMessage(null); + onClose(); + }, 2000); + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to receive token'; + setError(message); + setTimeout(() => setError(null), 3000); + } finally { + setIsReceivingToken(false); + } + }; + + const handleCreateInvoice = async (amount?: number) => { + if (!cashuStore.activeMintUrl) { + setError('No active mint selected.'); + return; + } + + const amt = amount !== undefined ? amount : parseInt(customAmount); + if (isNaN(amt) || amt <= 0) { + setError('Enter a valid amount'); + return; + } + + try { + setIsProcessing(true); + setError(null); + + const invoiceData = await createLightningInvoice(cashuStore.activeMintUrl, amt); + setInvoice(invoiceData.paymentRequest); + setQuoteId(invoiceData.quoteId); + setPendingAmount(amt); + + await addInvoice({ + type: 'mint', + mintUrl: cashuStore.activeMintUrl, + quoteId: invoiceData.quoteId, + paymentRequest: invoiceData.paymentRequest, + amount: amt, + state: MintQuoteState.UNPAID, + expiresAt: invoiceData.expiresAt + }); + + const pendingId = crypto.randomUUID(); + const pendingTx: PendingTransaction = { + id: pendingId, + direction: 'in', + amount: amt.toString(), + timestamp: Math.floor(Date.now() / 1000), + status: 'pending', + mintUrl: cashuStore.activeMintUrl, + quoteId: invoiceData.quoteId, + paymentRequest: invoiceData.paymentRequest, + }; + transactionHistoryStore.addPendingTransaction(pendingTx); + setPendingTransactionId(pendingId); + + void checkPaymentStatus(cashuStore.activeMintUrl, invoiceData.quoteId, amt, pendingId); + } catch (e) { + console.error('Error creating invoice:', e); + setError('Failed to create invoice'); + } finally { + setIsProcessing(false); + } + }; + + const handlePaid = async (_response: any) => { + if (!cashuStore.activeMintUrl || !quoteId || !pendingAmount) return; + try { + const proofs = await mintTokensFromPaidInvoice(cashuStore.activeMintUrl, quoteId, pendingAmount); + if (proofs.length > 0) { + await updateProofs({ mintUrl: cashuStore.activeMintUrl, proofsToAdd: proofs, proofsToRemove: [] }); + await updateInvoice(quoteId, { state: MintQuoteState.PAID, paidAt: Date.now() }); + if (pendingTransactionId) transactionHistoryStore.removePendingTransaction(pendingTransactionId); + setPendingTransactionId(null); + setSuccessMessage(`Received ${formatBalance(pendingAmount, 'sats')}!`); + setInvoice(''); + setQuoteId(''); + setPendingAmount(null); + } + } catch (_e) { + // Fallback to existing polling which is already in progress + } + }; + + const checkPaymentStatus = async (mintUrl: string, qid: string, amt: number, pendingId: string) => { + try { + const proofs = await mintTokensFromPaidInvoice(mintUrl, qid, amt); + if (proofs.length > 0) { + await updateProofs({ mintUrl, proofsToAdd: proofs, proofsToRemove: [] }); + await updateInvoice(qid, { state: MintQuoteState.PAID, paidAt: Date.now() }); + transactionHistoryStore.removePendingTransaction(pendingId); + setPendingTransactionId(null); + setSuccessMessage(`Received ${formatBalance(amt, 'sats')}!`); + setTimeout(() => setSuccessMessage(null), 4000); + return; + } + setTimeout(() => { + if (quoteId === qid) { + void checkPaymentStatus(mintUrl, qid, amt, pendingId); + } + }, 5000); + } catch (e) { + if (!(e instanceof Error && e.message.includes('not been paid'))) { + console.error('Error checking payment:', e); + setError('Failed to check payment'); + } else { + setTimeout(() => { + if (quoteId === qid) { + void checkPaymentStatus(mintUrl, qid, amt, pendingId); + } + }, 5000); + } + } + }; + + const handlePayWithNWC = async (amount?: number) => { + if (bcStatus !== 'connected') { + setError('Please connect your wallet first'); + return; + } + + if (!cashuStore.activeMintUrl) { + setError('No active mint selected.'); + return; + } + + const amt = amount !== undefined ? amount : parseInt(nwcCustomAmount); + if (isNaN(amt) || amt <= 0) { + setError('Enter a valid amount'); + return; + } + + try { + setIsPayingWithNWC(true); + setError(null); + + // Create invoice + const invoiceData = await createLightningInvoice(cashuStore.activeMintUrl, amt); + const paymentRequest = invoiceData.paymentRequest; + const qid = invoiceData.quoteId; + + await addInvoice({ + type: 'mint', + mintUrl: cashuStore.activeMintUrl, + quoteId: qid, + paymentRequest, + amount: amt, + state: MintQuoteState.UNPAID, + expiresAt: invoiceData.expiresAt + }); + + const pendingId = crypto.randomUUID(); + const pendingTx: PendingTransaction = { + id: pendingId, + direction: 'in', + amount: amt.toString(), + timestamp: Math.floor(Date.now() / 1000), + status: 'pending', + mintUrl: cashuStore.activeMintUrl, + quoteId: qid, + paymentRequest, + }; + transactionHistoryStore.addPendingTransaction(pendingTx); + + // Pay with connected wallet + try { + const mod = await import('@getalby/bitcoin-connect-react'); + const provider = await mod.requestProvider(); + const res = await provider.sendPayment(paymentRequest); + + // Check payment status and update proofs + if (res && (res as any).preimage) { + const proofs = await mintTokensFromPaidInvoice(cashuStore.activeMintUrl, qid, amt); + if (proofs.length > 0) { + await updateProofs({ mintUrl: cashuStore.activeMintUrl, proofsToAdd: proofs, proofsToRemove: [] }); + await updateInvoice(qid, { state: MintQuoteState.PAID, paidAt: Date.now() }); + transactionHistoryStore.removePendingTransaction(pendingId); + setSuccessMessage(`Received ${formatBalance(amt, 'sats')}!`); + setNwcCustomAmount(''); + setTimeout(() => { + setSuccessMessage(null); + onClose(); + }, 2000); + } else { + // Start polling if proofs not immediately available + void checkPaymentStatus(cashuStore.activeMintUrl, qid, amt, pendingId); + } + } else { + // Start polling + void checkPaymentStatus(cashuStore.activeMintUrl, qid, amt, pendingId); + } + } catch (paymentError) { + console.error('Error paying with NWC:', paymentError); + setError('Payment failed. Please try again.'); + void checkPaymentStatus(cashuStore.activeMintUrl, qid, amt, pendingId); + } + } catch (e) { + console.error('Error creating invoice:', e); + setError('Failed to create invoice'); + } finally { + setIsPayingWithNWC(false); + } + }; + + const modalContent = ( +
+

Top Up

+ + {/* Tabs */} +
+ + + +
+ + {/* Tab Content Container */} +
+ {/* Lightning Tab */} + {activeTab === 'lightning' && ( +
+ {/* QR / placeholder */} +
+
+ {invoice ? ( + + ) : ( + + )} +
+
+ + {invoice && ( + + )} + +
+ {quickAmounts.map(a => ( + + ))} +
+ +
+ setCustomAmount(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void handleCreateInvoice(); + } + }} + className="flex-1 bg-white/5 border border-white/20 rounded-lg px-4 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-white/40 focus:outline-none focus:ring-1 focus:ring-white/20 transition-all" + /> + +
+ +
+ )} + + {/* Token Tab */} + {activeTab === 'token' && ( +
+
+
+ +
+