feat: Add market integration roadmap to NOSTR architecture documentation
- Introduce a comprehensive roadmap for integrating nostr-market-app purchasing functionality into the web-app. - Outline key components of the shopping cart system, checkout process, and order management. - Detail phased implementation strategy, including enhanced user experience and advanced features. - Include security, performance, and testing considerations to ensure robust integration. feat: Enhance market store with new order and cart management features - Introduce new interfaces for Order, OrderItem, ContactInfo, and ShippingZone to support enhanced order management. - Update Stall and Product interfaces to include currency and shipping details. - Implement a comprehensive shopping cart system with stall-specific carts, including methods for adding, removing, and updating items. - Add payment-related interfaces and methods for managing payment requests and statuses. - Enhance filter options to include in-stock status and payment methods, improving product filtering capabilities. - Refactor computed properties and methods for better cart management and checkout processes. feat: Implement shopping cart functionality with new components and routing - Add ShoppingCart, CartItem, and CartSummary components to manage cart items and display summaries. - Introduce Cart.vue page to serve as the main shopping cart interface, integrating cart and summary components. - Update Navbar.vue to include a cart icon with item count, enhancing user navigation. - Implement cart management features in the market store, including item addition, quantity updates, and removal. - Establish routing for the cart page, ensuring seamless navigation for users. - Enhance ProductCard.vue to support adding items to the cart directly from the product listing. feat: Update cart and checkout functionality with improved navigation and button labels - Change "Proceed to Checkout" button text to dynamic "Place Order" based on context in CartSummary.vue. - Update "Continue Shopping" button to "Back to Cart" in CartSummary.vue for clearer navigation. - Modify routing for checkout to include stall ID in ShoppingCart.vue, enhancing checkout process. - Simplify Cart.vue by removing CartSummary component and focusing on ShoppingCart display. - Add new route for checkout with stall ID in router configuration for better handling of checkout flows. feat: Enhance cart and checkout components with improved shipping address handling - Update CartSummary.vue to use readonly types for cart items and shipping zones, ensuring immutability. - Modify Checkout.vue to conditionally display the shipping address field based on the selected shipping zone's requirements for physical shipping. - Add a digital delivery note for products that do not require a shipping address. - Introduce a computed property to determine if a shipping address is required, improving validation logic during checkout. - Update market store to include a new property for shipping zones indicating if physical shipping is required. feat: Implement order placement functionality in checkout process - Add a "Place Order" button in Checkout.vue that triggers the order placement process. - Introduce loading state during order placement to enhance user experience. - Implement createAndPlaceOrder method in market store to handle order creation and status updates. - Include error handling for order placement failures, providing user feedback on errors. - Update checkout logic to validate shipping zone and contact information before proceeding. feat: Add Order History page and update Navbar for order tracking - Introduce a new OrderHistory.vue page to display users' past orders with filtering and sorting options. - Update Navbar.vue to include an "Order History" option with a badge showing the count of orders. - Implement computed properties for order count and enhance user navigation experience. feat: Integrate Nostr functionality for order management and user notifications - Add NostrExtensionGuide component to inform users about the required Nostr extension for order transmission. - Implement useNostrOrders composable to manage Nostr connection, event creation, and order sending. - Update Checkout.vue to display Nostr connection status and provide feedback on order transmission. - Enhance OrderHistory.vue to show Nostr transmission status and details for each order. - Modify market store to handle Nostr event details and errors during order placement, ensuring local fallback. - Introduce types for Nostr events to improve type safety and integration with the existing order management system. refactor: Update Nostr relay configuration to use environment variable - Change DEFAULT_RELAYS to dynamically retrieve relay URLs from the VITE_MARKET_RELAYS environment variable. - Add error handling to ensure relays are configured before establishing a connection. - Modify createBlankEvent function to return a more precise type. - Update event signing process to ensure the event ID is generated correctly before signing. refactor: useAuth switch Enhance Nostr order management with authentication checks - Integrate user authentication checks to ensure Nostr features are only accessible to authenticated users. - Replace direct window.nostr calls with auth store methods for retrieving public and private keys. - Implement a helper function for signing events and mock encryption for order content. - Remove obsolete Nostr type definitions to streamline the codebase. feat: Enhance Checkout.vue with Nostr processing feedback and cleanup - Update the checkout button to disable based on order placement state. - Simplify order placement feedback by removing unnecessary Nostr processing checks. - Introduce a new visual indicator for Nostr order processing status. - Refactor computed properties for better clarity and efficiency in shipping zone handling. refactor: Streamline Nostr order handling and integrate buyer public key retrieval - Remove redundant Nostr relay tag from order event creation in useNostrOrders. - Update Checkout.vue to retrieve the buyer's public key from the auth store, enhancing order placement logic. - Modify createAndPlaceOrder method in market store to accept an optional Nostr orders instance for improved flexibility in order processing. refactor: Remove Nostr-related components and streamline order processing - Delete NostrExtensionGuide.vue and associated type definitions to simplify the codebase. - Remove unused useNostr.ts file and related logic from useNostrOrders.ts. - Update order handling in market store to directly integrate Nostr publishing without relying on external components. - Enhance Checkout.vue and Cart.vue to reflect changes in Nostr integration and provide clearer order status feedback. feat: Enhance Nostr chat functionality with malformed message handling - Introduce tracking for malformed message IDs to prevent repeated processing attempts. - Implement functions to mark messages as malformed, clean up old entries, and retrieve statistics on malformed messages. - Add periodic cleanup of malformed messages to manage memory usage. - Enhance message processing logic to skip previously identified malformed messages and provide detailed error handling for decryption failures. - Update the return object to include new functions for managing malformed messages. ZZ feat: Implement Lightning invoice management in market store - Add functionality to create and manage Lightning invoices for orders. - Introduce payment monitoring and status updates for invoices. - Implement payment confirmation messaging via Nostr upon successful payment. - Enhance order interface to include new fields for Lightning invoice details and payment status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. feat: Implement order event handling in useOrderEvents composable - Introduce useOrderEvents composable to manage subscription and processing of order-related events. - Define order event types and interfaces for better type safety and clarity. - Implement methods to handle payment requests, order status updates, and invoice generation. - Enhance OrderHistory.vue to display order event subscription status and last update timestamp. - Update market store to include order update functionality for better integration with order events. FIX: Build errors refactor: Update component styles and improve UI consistency across market pages - Replace various color classes with updated design tokens for better consistency. - Change background colors of components to align with the new design system. - Update text colors to enhance readability and maintain a cohesive look. - Refactor class names in CartItem.vue, CartSummary.vue, DashboardOverview.vue, and other components to use the new color scheme. - Ensure all components reflect the updated design guidelines for a unified user experience. refactor: Remove Order History references from Navbar component - Eliminate order count computation and related UI elements from the Navbar. - Streamline the Navbar by removing the Order History button and badge. - Maintain existing functionality for other menu items, ensuring a cleaner user interface. feat: Implement QR code generation and download functionality in PaymentDisplay component - Add QR code generation for payment requests using the qrcode library. - Enhance UI to display loading states and error messages during QR code generation. - Introduce a download button for users to save the generated QR code. - Implement logic to regenerate QR code when the invoice changes. refactor: Replace useRelayHub with relayHubComposable across components - Update imports in multiple components and composables to use the new relayHubComposable for better consistency and maintainability. - Enhance OrderHistory.vue with debug information for development, displaying key states related to orders, authentication, and relay hub connectivity. - Remove unnecessary reconnect button from RelayHubStatus.vue to streamline user interactions. - Improve logging in useOrderEvents for better debugging and monitoring of order event subscriptions. refactor: Update OrderHistory.vue styles for improved UI consistency - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles for payment status indicators. - Ensure a cohesive look across the component by standardizing class names and styles. refactor: Update component styles for improved UI consistency across checkout pages - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles in CartSummary.vue, PaymentDisplay.vue, Checkout.vue, and OrderHistory.vue. - Standardize class names and styles to ensure a cohesive look across all components. feat: Implement invoice generation and Nostr integration in MerchantStore component - Add functionality to generate Lightning invoices for orders and send them to customers via Nostr. - Introduce a new sendInvoiceToCustomer method to update order details and publish invoice information. - Enhance order event handling in useOrderEvents to update existing orders with new invoice data. - Improve error handling and logging for invoice generation and sending processes. feat: Enhance MerchantStore and PaymentDisplay components for improved invoice handling - Add wallet indicator in MerchantStore to display the selected wallet name during pending orders. - Implement temporary fixes for missing buyer and seller public keys when generating invoices. - Update invoice generation logic to utilize the first available wallet and improve error handling. - Modify PaymentDisplay to use the new bolt11 field for payment requests and enhance date formatting. - Refactor order event handling to ensure accurate updates and invoice management across components. feat: Enhance order event processing in useOrderEvents composable - Refactor processOrderEvent to handle incoming Nostr market order events with improved validation and logging. - Implement logic to update existing orders or create new ones based on event data, ensuring accurate order management. - Add detailed console logging for better debugging and tracking of order events and their statuses. - Ensure compatibility with market order structure and invoice details for seamless integration with payment processing. feat: Enhance order management with localStorage persistence - Update createOrder method to optionally accept an order ID from events, improving order tracking. - Convert items from readonly to mutable for better manipulation. - Implement localStorage persistence for orders, ensuring data is saved and loaded across sessions. - Add methods to save and load orders from localStorage, enhancing user experience and data reliability. feat: Update invoice creation to support additional metadata and nostrmarket compatibility - Modify createInvoice method to accept an optional extra parameter for additional metadata. - Change invoice tag to 'nostrmarket' for improved compatibility with Nostr market. - Include merchant and buyer public keys in the invoice data for better integration. - Update invoice creation in market store to utilize new parameters for enhanced functionality. feat: Enhance order and invoice handling for Nostr market compatibility - Add originalOrderId to order events for tracking Nostr order IDs. - Update invoice creation to utilize original Nostr order ID when generating invoices. - Improve logging for invoice requests to LNBits, providing better visibility into the data being sent. - Ensure compatibility with nostrmarket by adjusting order ID handling in the market store. fix: Refine invoice creation logic for Nostr market compatibility - Adjust order ID handling in invoice creation to prioritize originalOrderId for better compatibility with nostrmarket. - Enhance logging to provide clearer insights into the order ID being used during invoice generation. feat: Integrate nostrmarket service for order publishing and merchant catalog management - Implement functionality to publish orders via the nostrmarket protocol, replacing the previous Nostr integration. - Add methods to publish merchant catalogs, including stalls and products, to nostrmarket with event ID tracking. - Enhance order interface to include nostrEventId for better integration with nostrmarket. - Improve error handling and logging for nostrmarket publishing processes. refactor: Simplify order creation logic in useOrderEvents and update contact structure in nostrmarketService - Streamline order creation by using event.id and defaulting to 'unknown' for stallId. - Update contact structure to include address and message, removing optional email and phone fields for clarity. - Ensure compatibility with new order data structure for improved integration with nostrmarket. feat: Add bech32 to hex conversion utility and integrate into nostrmarketService - Implement a new utility function to convert bech32 keys to hex format, enhancing key handling. - Update nostrmarketService to utilize the new conversion function for user public and private keys. - Modify contact structure to include additional fields for improved order information management. feat: Add nostrclient configuration to AppConfig for enhanced Nostr integration - Introduce a new nostrclient property in AppConfig to manage Nostr client settings. - Include url and enabled fields to configure the Nostr client connection dynamically. - Ensure compatibility with environment variables for flexible deployment configurations. feat: Introduce comprehensive order management and fulfillment documentation - Add ORDER_MANAGEMENT_FULFILLMENT.md to detail the complete order lifecycle, including order states, data models, and merchant/customer interfaces. - Implement test scripts for verifying order and payment request formats in test-nostrmarket-format.js. - Create PaymentRequestDialog.vue for handling payment requests with dynamic options and QR code generation. - Enhance useOrderEvents.ts to process nostrmarket protocol messages for order management. - Update nostrmarketService.ts to handle payment requests and order status updates, ensuring seamless integration with the marketplace. - Integrate payment request dialog in Market.vue and manage its state in the market store. refactor: Remove obsolete test script for nostrmarket order format - Delete test-nostrmarket-format.js as it is no longer needed for verifying order and payment request formats. - Update PaymentRequestDialog.vue to enhance UI components and integrate QR code generation for payment requests. - Refactor payment handling and notification logic to utilize toast notifications instead of Quasar's notify system. feat: Enhance OrderHistory component with payment request handling and QR code generation - Add UI elements to display payment request status and options in OrderHistory.vue. - Implement functions to copy payment requests, open Lightning wallets, and download QR codes. - Update nostrmarketService to generate QR codes for payment requests and manage order statuses effectively. - Remove obsolete PaymentRequestDialog integration from Market.vue for a cleaner UI. feat: Add debug information and toast notifications in OrderHistory component - Introduce debug info display for payment requests and hashes in OrderHistory.vue. - Implement toast notifications for actions like copying payment requests, opening wallets, and downloading QR codes. - Enhance error handling with user feedback for various order-related actions. - Remove obsolete payment request dialog methods from market store for cleaner code. feat: Revamp CartItem and ShoppingCart components for improved layout and functionality - Enhance CartItem.vue with responsive design for desktop and mobile views, including better organization of product details, price, quantity controls, and remove button. - Update ShoppingCart.vue to separate desktop and mobile layouts, improving the user experience with clearer action buttons and cart summary display. - Implement consistent styling and layout adjustments for better visual coherence across different screen sizes.
This commit is contained in:
parent
93ffb8bf32
commit
ea5a2380f1
43 changed files with 8983 additions and 146 deletions
|
|
@ -780,3 +780,330 @@ This architecture makes it easy to add new Nostr functionality:
|
|||
8. **Metrics and Monitoring**: Add comprehensive metrics for relay performance
|
||||
|
||||
This architecture makes the app more maintainable, performant, and user-friendly while providing a solid foundation for future features.
|
||||
|
||||
## Market Integration Roadmap
|
||||
|
||||
### Overview
|
||||
This document outlines the roadmap for integrating the nostr-market-app purchasing functionality into the web-app, creating a seamless e-commerce experience while maintaining the decentralized, Nostr-based architecture.
|
||||
|
||||
### Analysis of nostr-market-app Purchasing Flow
|
||||
|
||||
The nostr-market-app has a sophisticated purchasing system with the following key components:
|
||||
|
||||
#### 1. Shopping Cart System
|
||||
- **Cart Management**: Products are added to stall-specific carts
|
||||
- **Cart State**: Each stall has its own cart with products and quantities
|
||||
- **Cart Persistence**: Cart data is stored locally and synced across sessions
|
||||
|
||||
#### 2. Checkout Process
|
||||
- **Order Confirmation**: Users provide contact information (address, email, message)
|
||||
- **Shipping Selection**: Multiple shipping zones with different costs
|
||||
- **Payment Options**: Lightning Network, BTC Onchain, and Cashu support
|
||||
|
||||
#### 3. Order Placement
|
||||
- **Encrypted Communication**: Orders are encrypted using NIP-04 and sent as direct messages
|
||||
- **Order Structure**: Includes product details, quantities, shipping, and contact info
|
||||
- **Payment Integration**: Lightning invoice generation and QR code display
|
||||
|
||||
#### 4. Key Components
|
||||
- `ShoppingCartCheckout.vue` - Main checkout interface
|
||||
- `useShoppingCart.js` - Cart management logic
|
||||
- `useOrders.js` - Order placement and management
|
||||
- `marketStore.js` - Central state management
|
||||
|
||||
### Implementation Roadmap
|
||||
|
||||
#### Phase 1: Enhanced Shopping Cart System (High Priority)
|
||||
|
||||
**1.1 Extend Market Store**
|
||||
- Add cart management with stall-specific carts
|
||||
- Implement cart persistence and synchronization
|
||||
- Add shipping zone support
|
||||
- Extend existing `useMarketStore` with cart functionality
|
||||
|
||||
**1.2 Create Cart Components**
|
||||
- `ShoppingCart.vue` - Cart overview and management
|
||||
- `CartItem.vue` - Individual cart item with quantity controls
|
||||
- `CartSummary.vue` - Cart totals and checkout button
|
||||
- Integrate cart icon in header with item count
|
||||
|
||||
**1.3 Cart State Management**
|
||||
- Implement stall-specific cart structure
|
||||
- Add cart persistence to local storage
|
||||
- Sync cart state across components
|
||||
- Handle cart updates and real-time synchronization
|
||||
|
||||
#### Phase 2: Checkout System (High Priority)
|
||||
|
||||
**2.1 Checkout Flow**
|
||||
- `CheckoutPage.vue` - Main checkout interface
|
||||
- Contact information form (address, email, message)
|
||||
- Shipping zone selection with cost calculation
|
||||
- Order summary and confirmation
|
||||
|
||||
**2.2 Payment Integration**
|
||||
- Lightning Network invoice generation
|
||||
- QR code display for payments
|
||||
- Payment status tracking
|
||||
- Integration with existing payment infrastructure
|
||||
|
||||
**2.3 Checkout State Management**
|
||||
- Form validation and error handling
|
||||
- Multi-step checkout process
|
||||
- Order confirmation and review
|
||||
|
||||
#### Phase 3: Order Management (Medium Priority)
|
||||
|
||||
**3.1 Order Processing**
|
||||
- Encrypted order creation using NIP-04
|
||||
- Direct message sending to merchants
|
||||
- Order status tracking and updates
|
||||
- Integration with existing Nostr messaging system
|
||||
|
||||
**3.2 Order History**
|
||||
- `OrdersPage.vue` - User's order history
|
||||
- Order status updates and notifications
|
||||
- Communication with merchants
|
||||
- Order filtering and search
|
||||
|
||||
**3.3 Order Communication**
|
||||
- Encrypted messaging between buyers and sellers
|
||||
- Order status notifications
|
||||
- Shipping updates and tracking
|
||||
|
||||
#### Phase 4: Enhanced User Experience (Medium Priority)
|
||||
|
||||
**4.1 Streamlined Navigation**
|
||||
- Integrated cart icon in header
|
||||
- Quick checkout from product cards
|
||||
- Seamless flow between browsing and purchasing
|
||||
- Breadcrumb navigation for checkout process
|
||||
|
||||
**4.2 Real-time Updates**
|
||||
- Live inventory updates
|
||||
- Order status notifications
|
||||
- Chat integration with merchants
|
||||
- WebSocket connections for live updates
|
||||
|
||||
#### Phase 5: Advanced Features (Low Priority)
|
||||
|
||||
**5.1 Multiple Payment Methods**
|
||||
- BTC Onchain payments
|
||||
- Cashu integration
|
||||
- Payment method selection
|
||||
- Payment preference storage
|
||||
|
||||
**5.2 Advanced Filtering and Search**
|
||||
- Enhanced product search
|
||||
- Advanced filtering options
|
||||
- Saved search preferences
|
||||
- Product recommendations
|
||||
|
||||
**5.3 Merchant Tools**
|
||||
- Merchant dashboard
|
||||
- Inventory management
|
||||
- Order fulfillment tools
|
||||
- Analytics and reporting
|
||||
|
||||
### Technical Implementation Details
|
||||
|
||||
#### State Management Architecture
|
||||
|
||||
**Extended Market Store Structure**
|
||||
```typescript
|
||||
interface CartItem {
|
||||
product: Product
|
||||
quantity: number
|
||||
stallId: string
|
||||
}
|
||||
|
||||
interface StallCart {
|
||||
id: string
|
||||
merchant: string
|
||||
products: CartItem[]
|
||||
subtotal: number
|
||||
shippingZone?: ShippingZone
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
cartId: string
|
||||
status: OrderStatus
|
||||
contactInfo: ContactInfo
|
||||
shippingZone: ShippingZone
|
||||
paymentRequest?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
#### Component Architecture
|
||||
|
||||
**New Components to Create**
|
||||
1. `ShoppingCart.vue` - Main cart interface
|
||||
2. `CartItem.vue` - Individual cart item
|
||||
3. `CartSummary.vue` - Cart totals and checkout
|
||||
4. `CheckoutPage.vue` - Complete checkout flow
|
||||
5. `OrderSummary.vue` - Order review and confirmation
|
||||
6. `PaymentModal.vue` - Payment processing interface
|
||||
7. `OrdersPage.vue` - Order history and management
|
||||
|
||||
**Enhanced Existing Components**
|
||||
1. `ProductCard.vue` - Add quick add to cart
|
||||
2. `Market.vue` - Integrate cart functionality
|
||||
3. Header navigation - Add cart icon and count
|
||||
|
||||
#### Data Flow
|
||||
|
||||
**Cart Management Flow**
|
||||
1. User adds product to cart
|
||||
2. Cart state updated in store
|
||||
3. Cart persisted to local storage
|
||||
4. Cart UI components updated
|
||||
5. Real-time sync across components
|
||||
|
||||
**Checkout Flow**
|
||||
1. User initiates checkout from cart
|
||||
2. Contact information collected
|
||||
3. Shipping zone selected
|
||||
4. Order summary displayed
|
||||
5. Payment method selected
|
||||
6. Order encrypted and sent
|
||||
7. Payment processed
|
||||
8. Order confirmed
|
||||
|
||||
#### Integration Points
|
||||
|
||||
**Existing Systems**
|
||||
1. **Authentication**: Integrate with existing auth system
|
||||
2. **Nostr Store**: Extend existing Nostr functionality
|
||||
3. **Relay Hub**: Use existing relay connections
|
||||
4. **Notifications**: Leverage existing notification system
|
||||
5. **Storage**: Extend existing storage mechanisms
|
||||
|
||||
**New Systems**
|
||||
1. **Payment Gateway**: Lightning Network integration
|
||||
2. **Order Management**: Encrypted order processing
|
||||
3. **Cart Persistence**: Local storage with sync
|
||||
4. **Real-time Updates**: WebSocket connections
|
||||
|
||||
### Security Considerations
|
||||
|
||||
#### Data Encryption
|
||||
- All order data encrypted using NIP-04
|
||||
- Private keys never stored in plain text
|
||||
- Secure communication channels
|
||||
- Payment information protection
|
||||
|
||||
#### Privacy Protection
|
||||
- Minimal data collection
|
||||
- User consent for data sharing
|
||||
- Anonymity options for users
|
||||
- Secure storage practices
|
||||
|
||||
#### Payment Security
|
||||
- Lightning Network security
|
||||
- Payment verification
|
||||
- Fraud prevention measures
|
||||
- Secure key management
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
#### Optimization Strategies
|
||||
- Lazy loading of cart components
|
||||
- Efficient state management
|
||||
- Minimal re-renders
|
||||
- Optimized storage operations
|
||||
|
||||
#### Scalability
|
||||
- Modular component architecture
|
||||
- Efficient data structures
|
||||
- Caching strategies
|
||||
- Performance monitoring
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Unit Testing
|
||||
- Component functionality
|
||||
- Store actions and mutations
|
||||
- Utility functions
|
||||
- Integration points
|
||||
|
||||
#### Integration Testing
|
||||
- End-to-end checkout flow
|
||||
- Payment processing
|
||||
- Order management
|
||||
- Real-time updates
|
||||
|
||||
#### User Testing
|
||||
- Usability testing
|
||||
- Performance testing
|
||||
- Security testing
|
||||
- Accessibility testing
|
||||
|
||||
### Deployment and Rollout
|
||||
|
||||
#### Phase 1 Deployment
|
||||
- Enhanced shopping cart
|
||||
- Basic checkout functionality
|
||||
- Order placement system
|
||||
|
||||
#### Phase 2 Deployment
|
||||
- Payment integration
|
||||
- Order tracking
|
||||
- Enhanced user experience
|
||||
|
||||
#### Phase 3 Deployment
|
||||
- Advanced features
|
||||
- Performance optimizations
|
||||
- Full feature set
|
||||
|
||||
### Success Metrics
|
||||
|
||||
#### User Experience Metrics
|
||||
- Cart abandonment rate
|
||||
- Checkout completion rate
|
||||
- Time to complete purchase
|
||||
- User satisfaction scores
|
||||
|
||||
#### Technical Metrics
|
||||
- Page load times
|
||||
- Cart sync performance
|
||||
- Order processing speed
|
||||
- Error rates
|
||||
|
||||
#### Business Metrics
|
||||
- Conversion rates
|
||||
- Average order value
|
||||
- Repeat purchase rate
|
||||
- Customer retention
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
#### Long-term Vision
|
||||
- Multi-currency support
|
||||
- Advanced analytics
|
||||
- AI-powered recommendations
|
||||
- Mobile app development
|
||||
- API for third-party integrations
|
||||
|
||||
#### Scalability Plans
|
||||
- Microservices architecture
|
||||
- Distributed storage
|
||||
- Global relay network
|
||||
- Cross-platform support
|
||||
|
||||
### Conclusion
|
||||
|
||||
This roadmap provides a comprehensive plan for integrating the nostr-market-app purchasing functionality into the web-app. The phased approach ensures core functionality is delivered first while building toward a full-featured e-commerce platform.
|
||||
|
||||
The integration will maintain the decentralized, Nostr-based architecture while providing a professional, user-friendly shopping experience. Each phase builds upon the previous one, ensuring a smooth development process and consistent user experience.
|
||||
|
||||
Key success factors include:
|
||||
- Maintaining the existing architecture and design patterns
|
||||
- Ensuring seamless integration with current systems
|
||||
- Prioritizing user experience and performance
|
||||
- Implementing robust security measures
|
||||
- Creating a scalable and maintainable codebase
|
||||
|
||||
This roadmap serves as a living document that should be updated as development progresses and new requirements emerge.
|
||||
|
|
|
|||
921
ORDER_MANAGEMENT_FULFILLMENT.md
Normal file
921
ORDER_MANAGEMENT_FULFILLMENT.md
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
# Order Management & Fulfillment Workflows
|
||||
|
||||
This document provides comprehensive coverage of the complete order lifecycle, from initial placement through payment processing to final fulfillment and shipping management. It includes detailed analysis of both merchant and customer interfaces, database operations, and automated fulfillment processes.
|
||||
|
||||
## Overview: Order Lifecycle Management
|
||||
|
||||
The marketplace implements a **comprehensive order management system** with dual interfaces for merchants and customers, supporting complete order tracking from placement to fulfillment with automated inventory management and payment processing.
|
||||
|
||||
### Order States and Transitions
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Order Created] --> B[Invoice Generated]
|
||||
B --> C[Payment Request Sent]
|
||||
C --> D{Payment Status}
|
||||
D -->|Paid| E[Payment Confirmed]
|
||||
D -->|Unpaid| F[Invoice Expired/Reissued]
|
||||
F --> C
|
||||
E --> G[Inventory Updated]
|
||||
G --> H{Fulfillment}
|
||||
H -->|Ready| I[Shipped Status]
|
||||
H -->|Issue| J[Error/Refund]
|
||||
I --> K[Order Complete]
|
||||
```
|
||||
|
||||
## Core Order Data Models
|
||||
|
||||
### 1. Order Schema (`models.py:400-485`)
|
||||
|
||||
#### Order Structure Hierarchy
|
||||
```python
|
||||
class OrderItem(BaseModel):
|
||||
product_id: str # Product identifier from order
|
||||
quantity: int # Quantity ordered
|
||||
|
||||
class OrderContact(BaseModel):
|
||||
nostr: Optional[str] = None # Customer's nostr pubkey
|
||||
phone: Optional[str] = None # Customer phone number
|
||||
email: Optional[str] = None # Customer email address
|
||||
|
||||
class OrderExtra(BaseModel):
|
||||
products: List[ProductOverview] # Snapshot of products at time of order
|
||||
currency: str # Pricing currency (USD, EUR, sat, etc.)
|
||||
btc_price: str # Exchange rate at time of order
|
||||
shipping_cost: float = 0 # Shipping cost in currency
|
||||
shipping_cost_sat: float = 0 # Shipping cost in satoshis
|
||||
fail_message: Optional[str] = None # Error message if order failed
|
||||
```
|
||||
|
||||
#### Complete Order Model
|
||||
```python
|
||||
class Order(PartialOrder):
|
||||
stall_id: str # Associated stall identifier
|
||||
invoice_id: str # Lightning invoice payment hash
|
||||
total: float # Total amount in satoshis
|
||||
paid: bool = False # Payment status
|
||||
shipped: bool = False # Shipping/fulfillment status
|
||||
time: Optional[int] = None # Completion timestamp
|
||||
extra: OrderExtra # Additional order metadata
|
||||
```
|
||||
|
||||
### 2. Order Status Models (`models.py:467-485`)
|
||||
|
||||
#### Status Update Structure
|
||||
```python
|
||||
class OrderStatusUpdate(BaseModel):
|
||||
id: str # Order identifier
|
||||
message: Optional[str] = None # Status update message
|
||||
paid: Optional[bool] = False # Payment status
|
||||
shipped: Optional[bool] = None # Shipping status
|
||||
|
||||
class OrderReissue(BaseModel):
|
||||
id: str # Order identifier to reissue
|
||||
shipping_id: Optional[str] = None # Updated shipping zone
|
||||
|
||||
class PaymentRequest(BaseModel):
|
||||
id: str # Order identifier
|
||||
message: Optional[str] = None # Response message
|
||||
payment_options: List[PaymentOption] # Available payment methods
|
||||
```
|
||||
|
||||
### 3. Database Schema (`migrations.py:110-130`)
|
||||
|
||||
#### Order Table Structure
|
||||
```sql
|
||||
CREATE TABLE nostrmarket.orders (
|
||||
merchant_id TEXT NOT NULL, -- Merchant who owns this order
|
||||
id TEXT PRIMARY KEY, -- Unique order identifier (UUID)
|
||||
event_id TEXT, -- Nostr event ID for order placement
|
||||
event_created_at INTEGER NOT NULL, -- Unix timestamp of order creation
|
||||
public_key TEXT NOT NULL, -- Customer's public key
|
||||
merchant_public_key TEXT NOT NULL, -- Merchant's public key
|
||||
contact_data TEXT NOT NULL DEFAULT '{}', -- JSON contact information
|
||||
extra_data TEXT NOT NULL DEFAULT '{}', -- JSON extra metadata
|
||||
order_items TEXT NOT NULL, -- JSON array of ordered items
|
||||
address TEXT, -- Shipping address (deprecated)
|
||||
total REAL NOT NULL, -- Total amount in satoshis
|
||||
shipping_id TEXT NOT NULL, -- Shipping zone identifier
|
||||
stall_id TEXT NOT NULL, -- Associated stall identifier
|
||||
invoice_id TEXT NOT NULL, -- Lightning invoice payment hash
|
||||
paid BOOLEAN NOT NULL DEFAULT false, -- Payment confirmation
|
||||
shipped BOOLEAN NOT NULL DEFAULT false, -- Fulfillment status
|
||||
time INTEGER -- Completion timestamp
|
||||
);
|
||||
```
|
||||
|
||||
## Merchant Order Management Interface
|
||||
|
||||
### 1. Order List Component (`order-list.js`)
|
||||
|
||||
#### Component Structure and Properties
|
||||
```javascript
|
||||
window.app.component('order-list', {
|
||||
name: 'order-list',
|
||||
props: ['stall-id', 'customer-pubkey-filter', 'adminkey', 'inkey'],
|
||||
template: '#order-list',
|
||||
delimiters: ['${', '}'],
|
||||
```
|
||||
|
||||
#### Advanced Search and Filtering (`order-list.js:15-49`)
|
||||
```javascript
|
||||
data: function () {
|
||||
return {
|
||||
orders: [],
|
||||
selectedOrder: null,
|
||||
search: {
|
||||
publicKey: null, // Filter by customer public key
|
||||
isPaid: {label: 'All', id: null}, // Payment status filter
|
||||
isShipped: {label: 'All', id: null}, // Shipping status filter
|
||||
},
|
||||
ternaryOptions: [
|
||||
{label: 'All', id: null}, // Show all orders
|
||||
{label: 'Yes', id: 'true'}, // Filter for paid/shipped = true
|
||||
{label: 'No', id: 'false'} // Filter for paid/shipped = false
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Dynamic Order Fetching (`order-list.js:156-181`)
|
||||
```javascript
|
||||
getOrders: async function () {
|
||||
try {
|
||||
// Support both stall-specific and merchant-wide queries
|
||||
const ordersPath = this.stallId
|
||||
? `stall/order/${this.stallId}` // Orders for specific stall
|
||||
: 'order' // All orders for merchant
|
||||
|
||||
// Build query parameters for filtering
|
||||
const query = []
|
||||
if (this.search.publicKey) {
|
||||
query.push(`pubkey=${this.search.publicKey}`)
|
||||
}
|
||||
if (this.search.isPaid.id) {
|
||||
query.push(`paid=${this.search.isPaid.id}`)
|
||||
}
|
||||
if (this.search.isShipped.id) {
|
||||
query.push(`shipped=${this.search.isShipped.id}`)
|
||||
}
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/nostrmarket/api/v1/${ordersPath}?${query.join('&')}`,
|
||||
this.inkey
|
||||
)
|
||||
this.orders = data.map(s => ({...s, expanded: false}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Order Display and Calculations (`order-list.js:119-155`)
|
||||
|
||||
#### Product Information Retrieval
|
||||
```javascript
|
||||
productName: function (order, productId) {
|
||||
product = order.extra.products.find(p => p.id === productId)
|
||||
if (product) {
|
||||
return product.name
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
productPrice: function (order, productId) {
|
||||
product = order.extra.products.find(p => p.id === productId)
|
||||
if (product) {
|
||||
return `${product.price} ${order.extra.currency}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
orderTotal: function (order) {
|
||||
// Calculate total from individual product costs + shipping
|
||||
const productCost = order.items.reduce((t, item) => {
|
||||
product = order.extra.products.find(p => p.id === item.product_id)
|
||||
return t + item.quantity * product.price
|
||||
}, 0)
|
||||
return productCost + order.extra.shipping_cost
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Shipping Status Management (`order-list.js:259-280`)
|
||||
|
||||
#### Shipping Status Updates
|
||||
```javascript
|
||||
updateOrderShipped: async function () {
|
||||
this.selectedOrder.shipped = !this.selectedOrder.shipped
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PATCH',
|
||||
`/nostrmarket/api/v1/order/${this.selectedOrder.id}`,
|
||||
this.adminkey,
|
||||
{
|
||||
id: this.selectedOrder.id,
|
||||
message: this.shippingMessage, // Custom message to customer
|
||||
shipped: this.selectedOrder.shipped // New shipping status
|
||||
}
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Order updated!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
this.showShipDialog = false
|
||||
}
|
||||
```
|
||||
|
||||
#### Shipping Dialog Interface (`order-list.js:356-365`)
|
||||
```javascript
|
||||
showShipOrderDialog: function (order) {
|
||||
this.selectedOrder = order
|
||||
this.shippingMessage = order.shipped
|
||||
? 'The order has been shipped!'
|
||||
: 'The order has NOT yet been shipped!'
|
||||
|
||||
// Toggle status (will be confirmed on dialog submit)
|
||||
this.selectedOrder.shipped = !order.shipped
|
||||
this.showShipDialog = true
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Order Recovery and Restoration (`order-list.js:194-233`)
|
||||
|
||||
#### Individual Order Restoration
|
||||
```javascript
|
||||
restoreOrder: async function (eventId) {
|
||||
try {
|
||||
this.search.restoring = true
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/nostrmarket/api/v1/order/restore/${eventId}`, // Restore from DM event
|
||||
this.adminkey
|
||||
)
|
||||
await this.getOrders() // Refresh order list
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Order restored!'
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.search.restoring = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Bulk Order Restoration
|
||||
```javascript
|
||||
restoreOrders: async function () {
|
||||
try {
|
||||
this.search.restoring = true
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/nostrmarket/api/v1/orders/restore`, // Restore all from DMs
|
||||
this.adminkey
|
||||
)
|
||||
await this.getOrders()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Orders restored!'
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Invoice Management (`order-list.js:234-258`)
|
||||
|
||||
#### Invoice Reissuance
|
||||
```javascript
|
||||
reissueOrderInvoice: async function (order) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/nostrmarket/api/v1/order/reissue`,
|
||||
this.adminkey,
|
||||
{
|
||||
id: order.id,
|
||||
shipping_id: order.shipping_id // Optional shipping zone update
|
||||
}
|
||||
)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Order invoice reissued!'
|
||||
})
|
||||
|
||||
// Update order in local state
|
||||
data.expanded = order.expanded
|
||||
const i = this.orders.map(o => o.id).indexOf(order.id)
|
||||
if (i !== -1) {
|
||||
this.orders[i] = {...this.orders[i], ...data}
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Customer Order Interface
|
||||
|
||||
### 1. Customer Orders Component (`CustomerOrders.vue`)
|
||||
|
||||
#### Order Display Structure
|
||||
```vue
|
||||
<div v-for="merchant in merchantOrders" :key="merchant.id">
|
||||
<q-card bordered class="q-mb-md">
|
||||
<q-item>
|
||||
<user-profile <!-- Merchant identity -->
|
||||
:pubkey="merchant.pubkey"
|
||||
:profiles="profiles"
|
||||
></user-profile>
|
||||
</q-item>
|
||||
|
||||
<q-list>
|
||||
<div v-for="order in merchant.orders" :key="order.id">
|
||||
<q-expansion-item dense expand-separator>
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong><span v-text="order.stallName"></span></strong>
|
||||
<q-badge <!-- Total amount -->
|
||||
v-if="order.invoice?.human_readable_part?.amount"
|
||||
color="orange"
|
||||
>
|
||||
<span v-text="formatCurrency(order.invoice.human_readable_part.amount / 1000, 'sat')"></span>
|
||||
</q-badge>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<q-badge :color="order.paid ? 'green' : 'grey'"> <!-- Payment status -->
|
||||
<span v-text="order.paid ? 'Paid' : 'Not Paid'"></span>
|
||||
</q-badge>
|
||||
<q-badge :color="order.shipped ? 'green' : 'grey'"> <!-- Shipping status -->
|
||||
<span v-text="order.shipped ? 'Shipped' : 'Not Shipped'"></span>
|
||||
</q-badge>
|
||||
</q-item-section>
|
||||
</template>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Order Data Enrichment (`CustomerOrders.vue:208-220`)
|
||||
|
||||
#### Order Enhancement Pipeline
|
||||
```javascript
|
||||
enrichOrder: function (order) {
|
||||
const stall = this.stallForOrder(order);
|
||||
return {
|
||||
...order,
|
||||
stallName: stall?.name || "Stall", // Stall name for display
|
||||
shippingZone: stall?.shipping?.find( // Shipping zone details
|
||||
(s) => s.id === order.shipping_id
|
||||
) || { id: order.shipping_id, name: order.shipping_id },
|
||||
invoice: this.invoiceForOrder(order), // Parsed Lightning invoice
|
||||
products: this.getProductsForOrder(order), // Product details with quantities
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Stall Association (`CustomerOrders.vue:221-233`)
|
||||
```javascript
|
||||
stallForOrder: function (order) {
|
||||
try {
|
||||
const productId = order.items && order.items[0]?.product_id;
|
||||
if (!productId) return;
|
||||
|
||||
const product = this.products.find((p) => p.id === productId);
|
||||
if (!product) return;
|
||||
|
||||
const stall = this.stalls.find((s) => s.id === product.stall_id);
|
||||
if (!stall) return;
|
||||
|
||||
return stall;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Invoice Processing (`CustomerOrders.vue:234-244`)
|
||||
|
||||
#### Lightning Invoice Decoding
|
||||
```javascript
|
||||
invoiceForOrder: function (order) {
|
||||
try {
|
||||
const lnPaymentOption = order?.payment_options?.find(
|
||||
(p) => p.type === "ln" // Find Lightning payment option
|
||||
);
|
||||
if (!lnPaymentOption?.link) return;
|
||||
|
||||
return decode(lnPaymentOption.link); // Decode BOLT11 invoice
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Product Aggregation (`CustomerOrders.vue:246-259`)
|
||||
|
||||
#### Order Item Processing
|
||||
```javascript
|
||||
getProductsForOrder: function (order) {
|
||||
if (!order?.items?.length) return [];
|
||||
|
||||
return order.items.map((i) => {
|
||||
const product = this.products.find((p) => p.id === i.product_id) || {
|
||||
id: i.product_id,
|
||||
name: i.product_id, // Fallback if product not found
|
||||
};
|
||||
return {
|
||||
...product,
|
||||
orderedQuantity: i.quantity, // Add ordered quantity to product
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Order Operations
|
||||
|
||||
### 1. Order Creation (`services.py:84-133`)
|
||||
|
||||
#### Order Build Pipeline
|
||||
```python
|
||||
async def build_order_with_payment(merchant_id, merchant_public_key, data):
|
||||
# 1. Validate products and calculate costs
|
||||
products = await get_products_by_ids(merchant_id, [p.product_id for p in data.items])
|
||||
data.validate_order_items(products) # Ensure products exist and have stock
|
||||
|
||||
shipping_zone = await get_zone(merchant_id, data.shipping_id)
|
||||
product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
|
||||
products, shipping_zone.id, shipping_zone.cost
|
||||
)
|
||||
|
||||
# 2. Check inventory availability
|
||||
success, _, message = await compute_products_new_quantity(
|
||||
merchant_id, [i.product_id for i in data.items], data.items
|
||||
)
|
||||
if not success:
|
||||
raise ValueError(message) # Insufficient inventory
|
||||
|
||||
# 3. Create Lightning invoice via LNbits
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=round(product_cost_sat + shipping_cost_sat),
|
||||
memo=f"Order '{data.id}' for pubkey '{data.public_key}'",
|
||||
extra={
|
||||
"tag": "nostrmarket", # Tags invoice as marketplace
|
||||
"order_id": data.id,
|
||||
"merchant_pubkey": merchant_public_key,
|
||||
},
|
||||
)
|
||||
|
||||
# 4. Create order record
|
||||
order = Order(
|
||||
**data.dict(),
|
||||
stall_id=products[0].stall_id,
|
||||
invoice_id=payment.payment_hash,
|
||||
total=product_cost_sat + shipping_cost_sat,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return order, payment.bolt11, receipt
|
||||
```
|
||||
|
||||
### 2. Order Retrieval API (`views_api.py:540-577`)
|
||||
|
||||
#### Multi-filter Order Queries
|
||||
```python
|
||||
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
|
||||
async def api_get_orders_for_stall(
|
||||
stall_id: str,
|
||||
paid: Optional[bool] = None, # Filter by payment status
|
||||
shipped: Optional[bool] = None, # Filter by shipping status
|
||||
pubkey: Optional[str] = None, # Filter by customer pubkey
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> List[Order]:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
||||
orders = await get_orders_for_stall(
|
||||
merchant.id, stall_id, paid=paid, shipped=shipped, public_key=pubkey
|
||||
)
|
||||
return orders
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=str(ex)
|
||||
) from ex
|
||||
```
|
||||
|
||||
### 3. Order Status Updates (`views_api.py:625-641`)
|
||||
|
||||
#### Shipping Status API
|
||||
```python
|
||||
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
|
||||
async def api_update_order_status(
|
||||
data: OrderStatusUpdate,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Order:
|
||||
try:
|
||||
assert data.shipped is not None, "Shipped value is required for order"
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found for order {data.id}"
|
||||
|
||||
# Update shipping status in database
|
||||
order = await update_order_shipped_status(merchant.id, data.id, data.shipped)
|
||||
assert order, "Cannot find updated order"
|
||||
|
||||
# Send status update to customer via DM
|
||||
data.paid = order.paid # Include current payment status
|
||||
dm_content = json.dumps(
|
||||
{"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **data.dict()},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await reply_to_structured_dm(
|
||||
merchant, order.public_key, DirectMessageType.ORDER_PAID_OR_SHIPPED.value, dm_content
|
||||
)
|
||||
|
||||
return order
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=str(ex)
|
||||
) from ex
|
||||
```
|
||||
|
||||
### 4. Invoice Reissuance (`views_api.py:710-740`)
|
||||
|
||||
#### Payment Request Regeneration
|
||||
```python
|
||||
@nostrmarket_ext.put("/api/v1/order/reissue")
|
||||
async def api_reissue_order_invoice(
|
||||
reissue_data: OrderReissue,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Order:
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
||||
# Get existing order
|
||||
data = await get_order(merchant.id, reissue_data.id)
|
||||
assert data, "Order cannot be found"
|
||||
|
||||
# Update shipping zone if provided
|
||||
if reissue_data.shipping_id:
|
||||
data.shipping_id = reissue_data.shipping_id
|
||||
|
||||
# Generate new payment request
|
||||
payment_req, order = await build_order_with_payment(
|
||||
merchant.id, merchant.public_key, data
|
||||
)
|
||||
|
||||
# Update order with new invoice details
|
||||
order_update = {
|
||||
"total": payment_req.total,
|
||||
"invoice_id": order.invoice_id, # New payment hash
|
||||
"extra_data": json.dumps(order.extra.dict()),
|
||||
}
|
||||
|
||||
await update_order(
|
||||
merchant.id,
|
||||
order.id,
|
||||
**order_update,
|
||||
)
|
||||
|
||||
# Send new payment request to customer
|
||||
payment_req = PaymentRequest(
|
||||
id=order.id,
|
||||
message="Updated payment request",
|
||||
payment_options=[PaymentOption(type="ln", link=order.bolt11)],
|
||||
)
|
||||
|
||||
dm_content = json.dumps(
|
||||
{"type": DirectMessageType.PAYMENT_REQUEST.value, **payment_req.dict()},
|
||||
)
|
||||
await reply_to_structured_dm(
|
||||
merchant, order.public_key, DirectMessageType.PAYMENT_REQUEST.value, dm_content
|
||||
)
|
||||
|
||||
return await get_order(merchant.id, reissue_data.id)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Cannot reissue order invoice",
|
||||
) from ex
|
||||
```
|
||||
|
||||
## Order Restoration System
|
||||
|
||||
### 1. Order Recovery from Direct Messages (`services.py:645-690`)
|
||||
|
||||
#### DM-based Order Restoration
|
||||
```python
|
||||
async def create_or_update_order_from_dm(
|
||||
merchant_id: str, merchant_pubkey: str, dm: DirectMessage
|
||||
):
|
||||
type_, json_data = PartialDirectMessage.parse_message(dm.message)
|
||||
if not json_data or "id" not in json_data:
|
||||
return
|
||||
|
||||
if type_ == DirectMessageType.CUSTOMER_ORDER:
|
||||
# Restore customer order from DM
|
||||
order, _ = await extract_customer_order_from_dm(
|
||||
merchant_id, merchant_pubkey, dm, json_data
|
||||
)
|
||||
new_order = await create_order(merchant_id, order)
|
||||
|
||||
# Handle stall association updates
|
||||
if new_order.stall_id == "None" and order.stall_id != "None":
|
||||
await update_order(
|
||||
merchant_id,
|
||||
order.id,
|
||||
**{
|
||||
"stall_id": order.stall_id,
|
||||
"extra_data": json.dumps(order.extra.dict()),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if type_ == DirectMessageType.PAYMENT_REQUEST:
|
||||
# Update order with payment request details
|
||||
payment_request = PaymentRequest(**json_data)
|
||||
pr = payment_request.payment_options[0].link
|
||||
invoice = decode(pr)
|
||||
total = invoice.amount_msat / 1000 if invoice.amount_msat else 0
|
||||
await update_order(
|
||||
merchant_id,
|
||||
payment_request.id,
|
||||
**{"total": total, "invoice_id": invoice.payment_hash},
|
||||
)
|
||||
return
|
||||
|
||||
if type_ == DirectMessageType.ORDER_PAID_OR_SHIPPED:
|
||||
# Update order status from status messages
|
||||
order_update = OrderStatusUpdate(**json_data)
|
||||
if order_update.paid:
|
||||
await update_order_paid_status(order_update.id, True)
|
||||
if order_update.shipped:
|
||||
await update_order_shipped_status(merchant_id, order_update.id, True)
|
||||
```
|
||||
|
||||
### 2. Bulk Restoration API (`views_api.py:580-595`)
|
||||
|
||||
#### Complete Order Recovery
|
||||
```python
|
||||
@nostrmarket_ext.put("/api/v1/orders/restore")
|
||||
async def api_restore_orders_from_dms(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
try:
|
||||
merchant = await get_merchant_for_user(wallet.wallet.user)
|
||||
assert merchant, "Merchant cannot be found"
|
||||
|
||||
# Get all order-related direct messages
|
||||
dms = await get_orders_from_direct_messages(merchant.id)
|
||||
for dm in dms:
|
||||
try:
|
||||
# Attempt to restore/update each order from DM history
|
||||
await create_or_update_order_from_dm(
|
||||
merchant.id, merchant.public_key, dm
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Failed to restore order from event '{dm.event_id}': '{e!s}'."
|
||||
)
|
||||
continue
|
||||
|
||||
return {"status": "Orders restoration completed!"}
|
||||
except AssertionError as ex:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=str(ex)
|
||||
) from ex
|
||||
```
|
||||
|
||||
## Real-time Order Updates
|
||||
|
||||
### 1. WebSocket Order Notifications (`order-list.js:281-296`)
|
||||
|
||||
#### Live Order Addition
|
||||
```javascript
|
||||
addOrder: async function (data) {
|
||||
if (
|
||||
!this.search.publicKey ||
|
||||
this.search.publicKey === data.customerPubkey // Filter matches current view
|
||||
) {
|
||||
const orderData = JSON.parse(data.dm.message)
|
||||
const i = this.orders.map(o => o.id).indexOf(orderData.id)
|
||||
if (i === -1) { // Prevent duplicates
|
||||
const order = await this.getOrder(orderData.id) // Fetch complete order data
|
||||
this.orders.unshift(order) // Add to top of list
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Payment Status Updates (`order-list.js:391-396`)
|
||||
|
||||
#### Real-time Payment Confirmation
|
||||
```javascript
|
||||
orderPaid: function (orderId) {
|
||||
const order = this.orders.find(o => o.id === orderId)
|
||||
if (order) {
|
||||
order.paid = true // Update payment status immediately
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Order Management Features
|
||||
|
||||
### 1. Order Selection and Deep Linking (`order-list.js:294-315`)
|
||||
|
||||
#### Order Detail Navigation
|
||||
```javascript
|
||||
orderSelected: async function (orderId, eventId) {
|
||||
const order = await this.getOrder(orderId)
|
||||
if (!order) {
|
||||
// Order missing - offer restoration from DM
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Order could not be found. Do you want to restore it from this direct message?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
const restoredOrder = await this.restoreOrder(eventId)
|
||||
if (restoredOrder) {
|
||||
restoredOrder.expanded = true
|
||||
restoredOrder.isNew = false
|
||||
this.orders = [restoredOrder] // Show only restored order
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Show order details
|
||||
order.expanded = true
|
||||
order.isNew = false
|
||||
this.orders = [order] // Focus on single order
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Customer Association and Filtering
|
||||
|
||||
#### Customer Management Integration
|
||||
```javascript
|
||||
computed: {
|
||||
customerOptions: function () {
|
||||
const options = this.customers.map(c => ({
|
||||
label: this.buildCustomerLabel(c), // Include unread message counts
|
||||
value: c.public_key
|
||||
}))
|
||||
options.unshift({label: 'All', value: null, id: null}) // All customers option
|
||||
return options
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Shipping Zone Integration (`order-list.js:348-355`)
|
||||
|
||||
#### Dynamic Shipping Options
|
||||
```javascript
|
||||
getStallZones: function (stallId) {
|
||||
const stall = this.stalls.find(s => s.id === stallId)
|
||||
if (!stall) return []
|
||||
|
||||
return this.zoneOptions.filter(z =>
|
||||
stall.shipping_zones.find(s => s.id === z.id) // Only zones supported by stall
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Database Operations
|
||||
|
||||
### 1. Order CRUD Operations (`crud.py`)
|
||||
|
||||
#### Order Creation
|
||||
```python
|
||||
async def create_order(merchant_id: str, o: Order) -> Order:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO nostrmarket.orders (
|
||||
merchant_id, id, event_id, event_created_at, public_key,
|
||||
merchant_public_key, contact_data, extra_data, order_items,
|
||||
address, total, shipping_id, stall_id, invoice_id, paid, shipped
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
merchant_id, o.id, o.event_id, o.event_created_at, o.public_key,
|
||||
o.merchant_public_key, json.dumps(o.contact.dict()),
|
||||
json.dumps(o.extra.dict()), json.dumps([i.dict() for i in o.items]),
|
||||
o.address, o.total, o.shipping_id, o.stall_id, o.invoice_id,
|
||||
o.paid, o.shipped,
|
||||
),
|
||||
)
|
||||
return o
|
||||
```
|
||||
|
||||
#### Flexible Order Queries
|
||||
```python
|
||||
async def get_orders(merchant_id: str, **kwargs) -> List[Order]:
|
||||
# Build dynamic WHERE clause from keyword arguments
|
||||
q = " AND ".join(
|
||||
[
|
||||
f"{field[0]} = :{field[0]}"
|
||||
for field in kwargs.items()
|
||||
if field[1] is not None
|
||||
]
|
||||
)
|
||||
|
||||
rows: list[dict] = await db.fetchall(
|
||||
f"SELECT * FROM nostrmarket.orders WHERE merchant_id = :merchant_id "
|
||||
f"{' AND ' + q if q else ''} ORDER BY event_created_at DESC",
|
||||
{"merchant_id": merchant_id, **kwargs},
|
||||
)
|
||||
return [Order.from_row(row) for row in rows]
|
||||
```
|
||||
|
||||
### 2. Status Update Operations
|
||||
|
||||
#### Payment Status Updates
|
||||
```python
|
||||
async def update_order_paid_status(order_id: str, paid: bool) -> Optional[Order]:
|
||||
await db.execute(
|
||||
"UPDATE nostrmarket.orders SET paid = :paid WHERE id = :id",
|
||||
{"paid": paid, "id": order_id},
|
||||
)
|
||||
row: dict = await db.fetchone(
|
||||
"SELECT * FROM nostrmarket.orders WHERE id = :id", {"id": order_id}
|
||||
)
|
||||
return Order.from_row(row) if row else None
|
||||
```
|
||||
|
||||
#### Shipping Status Updates
|
||||
```python
|
||||
async def update_order_shipped_status(
|
||||
merchant_id: str, order_id: str, shipped: bool
|
||||
) -> Optional[Order]:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE nostrmarket.orders
|
||||
SET shipped = :shipped
|
||||
WHERE merchant_id = :merchant_id AND id = :id
|
||||
""",
|
||||
{"shipped": shipped, "merchant_id": merchant_id, "id": order_id},
|
||||
)
|
||||
row: dict = await db.fetchone(
|
||||
"SELECT * FROM nostrmarket.orders WHERE merchant_id = :merchant_id AND id = :id",
|
||||
{"merchant_id": merchant_id, "id": order_id},
|
||||
)
|
||||
return Order.from_row(row) if row else None
|
||||
```
|
||||
|
||||
## Error Handling and Edge Cases
|
||||
|
||||
### 1. Order Restoration Failures
|
||||
- **Missing Products**: Orders reference products that no longer exist
|
||||
- **Invalid Stall Association**: Product moved between stalls after order creation
|
||||
- **Corrupted DM Data**: JSON parsing errors in message restoration
|
||||
- **Payment Hash Conflicts**: Duplicate invoice IDs from reissuance
|
||||
|
||||
### 2. Payment Processing Issues
|
||||
- **Invoice Expiration**: Lightning invoices expire after timeout
|
||||
- **Partial Payments**: Underpayment or overpayment scenarios
|
||||
- **Payment Verification**: Webhook delays or failures
|
||||
- **Double Payment**: Multiple payments for same order
|
||||
|
||||
### 3. Inventory Synchronization
|
||||
- **Race Conditions**: Multiple orders for limited stock
|
||||
- **Negative Inventory**: Orders processed despite insufficient stock
|
||||
- **Product Updates**: Price or availability changes after order placement
|
||||
- **Stall Deactivation**: Orders for disabled stalls or products
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. Payment System Integration
|
||||
- **LNbits Invoice Creation**: Automatic Lightning invoice generation
|
||||
- **Payment Monitoring**: Real-time payment confirmation via webhooks
|
||||
- **Refund Processing**: Automated refunds for failed orders
|
||||
- **Multi-currency Support**: Fiat pricing with BTC conversion
|
||||
|
||||
### 2. Inventory Management Integration
|
||||
- **Stock Validation**: Pre-order inventory checking
|
||||
- **Automatic Deduction**: Post-payment inventory updates
|
||||
- **Backorder Handling**: Out-of-stock order management
|
||||
- **Restock Notifications**: Customer alerts for inventory replenishment
|
||||
|
||||
### 3. Communication System Integration
|
||||
- **Status Updates**: Automated customer notifications
|
||||
- **Order Confirmations**: Receipt and tracking information
|
||||
- **Shipping Notifications**: Fulfillment status updates
|
||||
- **Support Integration**: Customer service ticket creation
|
||||
|
||||
This comprehensive order management system provides complete lifecycle tracking from initial order placement through final fulfillment, with robust error handling, real-time updates, and flexible merchant tools for efficient order processing and customer communication.
|
||||
|
|
@ -6,10 +6,10 @@ import Footer from '@/components/layout/Footer.vue'
|
|||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import 'vue-sonner/style.css'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { nostrChat } from '@/composables/useNostrChat'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -19,7 +19,7 @@ const showLoginDialog = ref(false)
|
|||
const marketPreloader = useMarketPreloader()
|
||||
|
||||
// Initialize relay hub
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Hide navbar on login page
|
||||
const showNavbar = computed(() => {
|
||||
|
|
|
|||
301
src/components/NostrmarketPublisher.vue
Normal file
301
src/components/NostrmarketPublisher.vue
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
<template>
|
||||
<div class="nostrmarket-publisher">
|
||||
<div class="publisher-header">
|
||||
<h3>Nostrmarket Integration</h3>
|
||||
<p>Publish your stalls and products to the nostrmarket network</p>
|
||||
</div>
|
||||
|
||||
<div class="publisher-status">
|
||||
<div class="status-item">
|
||||
<span class="label">Stalls:</span>
|
||||
<span class="value">{{ stallCount }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Products:</span>
|
||||
<span class="value">{{ productCount }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Published:</span>
|
||||
<span class="value">{{ publishedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="publisher-actions">
|
||||
<button
|
||||
@click="publishCatalog"
|
||||
:disabled="isPublishing || !canPublish"
|
||||
class="publish-btn"
|
||||
:class="{ 'publishing': isPublishing }"
|
||||
>
|
||||
<span v-if="isPublishing">Publishing...</span>
|
||||
<span v-else>Publish to Nostrmarket</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="refreshStatus"
|
||||
:disabled="isRefreshing"
|
||||
class="refresh-btn"
|
||||
>
|
||||
<span v-if="isRefreshing">Refreshing...</span>
|
||||
<span v-else>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="publish-result">
|
||||
<h4>Last Publication Result:</h4>
|
||||
<div class="result-details">
|
||||
<div class="result-section">
|
||||
<h5>Stalls Published:</h5>
|
||||
<ul>
|
||||
<li v-for="(eventId, stallId) in lastResult.stalls" :key="stallId">
|
||||
{{ getStallName(stallId) }}: {{ eventId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="result-section">
|
||||
<h5>Products Published:</h5>
|
||||
<ul>
|
||||
<li v-for="(eventId, productId) in lastResult.products" :key="productId">
|
||||
{{ getProductName(productId) }}: {{ eventId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<p>Error: {{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrmarketService } from '@/lib/services/nostrmarketService'
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// State
|
||||
const isPublishing = ref(false)
|
||||
const isRefreshing = ref(false)
|
||||
const lastResult = ref<any>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const stallCount = computed(() => marketStore.stalls.length)
|
||||
const productCount = computed(() => marketStore.products.length)
|
||||
const publishedCount = computed(() => {
|
||||
const publishedStalls = marketStore.stalls.filter(s => s.nostrEventId).length
|
||||
const publishedProducts = marketStore.products.filter(p => p.nostrEventId).length
|
||||
return publishedStalls + publishedProducts
|
||||
})
|
||||
|
||||
const canPublish = computed(() => {
|
||||
return stallCount.value > 0 && productCount.value > 0
|
||||
})
|
||||
|
||||
// Methods
|
||||
const publishCatalog = async () => {
|
||||
if (!canPublish.value) return
|
||||
|
||||
isPublishing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await marketStore.publishToNostrmarket()
|
||||
lastResult.value = result
|
||||
console.log('Catalog published successfully:', result)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
console.error('Failed to publish catalog:', err)
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
isRefreshing.value = true
|
||||
|
||||
try {
|
||||
// Force a refresh of the store data
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh status:', err)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || stallId
|
||||
}
|
||||
|
||||
const getProductName = (productId: string) => {
|
||||
const product = marketStore.products.find(p => p.id === productId)
|
||||
return product?.name || productId
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nostrmarket-publisher {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.publisher-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.publisher-header p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.publisher-status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.publisher-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.publish-btn, .refresh-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.publish-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.publish-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.publish-btn.publishing {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
background: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.publish-result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.publish-result h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #166534;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-section h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #166534;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.result-section li {
|
||||
margin-bottom: 0.25rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.375rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -43,15 +43,12 @@
|
|||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="connect" :disabled="isConnected || connectionStatus === 'connecting'">
|
||||
<button @click="connect" :disabled="connectionStatus === 'connecting'">
|
||||
Connect
|
||||
</button>
|
||||
<button @click="disconnect" :disabled="!isConnected">
|
||||
Disconnect
|
||||
</button>
|
||||
<button @click="reconnect" :disabled="connectionStatus === 'connecting'">
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="subscription-info">
|
||||
|
|
@ -65,7 +62,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
|
|
@ -78,9 +75,8 @@ const {
|
|||
totalSubscriptionCount,
|
||||
connectionHealth,
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect
|
||||
} = useRelayHub()
|
||||
disconnect
|
||||
} = relayHubComposable
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity } from 'lucide-vue-next'
|
||||
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity, ShoppingCart, BarChart3 } from 'lucide-vue-next'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
|
||||
|
|
@ -14,6 +14,7 @@ import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue'
|
|||
import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrChat } from '@/composables/useNostrChat'
|
||||
|
||||
interface NavigationItem {
|
||||
|
|
@ -29,6 +30,7 @@ const showLoginDialog = ref(false)
|
|||
const showProfileDialog = ref(false)
|
||||
const showLogoutConfirm = ref(false)
|
||||
const marketPreloader = useMarketPreloader()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const navigation = computed<NavigationItem[]>(() => [
|
||||
{ name: t('nav.home'), href: '/' },
|
||||
|
|
@ -51,6 +53,13 @@ const totalUnreadMessages = computed(() => {
|
|||
return nostrChat.totalUnreadCount.value
|
||||
})
|
||||
|
||||
// Compute cart item count
|
||||
const cartItemCount = computed(() => {
|
||||
return marketStore.totalCartItems
|
||||
})
|
||||
|
||||
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
|
@ -125,6 +134,17 @@ const handleLogout = async () => {
|
|||
<!-- <CurrencyDisplay :balance-msat="totalBalance" /> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<!-- Cart Icon with Item Count -->
|
||||
<router-link v-if="auth.isAuthenticated.value" to="/cart"
|
||||
class="hidden sm:flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-lg border hover:bg-muted/70 transition-colors relative">
|
||||
<ShoppingCart class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Cart</span>
|
||||
<Badge v-if="cartItemCount > 0"
|
||||
class="absolute -top-2 -right-2 h-5 w-5 text-xs bg-blue-500 text-white border-0 p-0 flex items-center justify-center rounded-full">
|
||||
{{ cartItemCount > 99 ? '99+' : cartItemCount }}
|
||||
</Badge>
|
||||
</router-link>
|
||||
|
||||
<!-- Authentication Management -->
|
||||
<div class="hidden sm:block">
|
||||
<DropdownMenu v-if="auth.isAuthenticated.value">
|
||||
|
|
@ -151,6 +171,11 @@ const handleLogout = async () => {
|
|||
<Ticket class="h-4 w-4" />
|
||||
My Tickets
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem @click="() => router.push('/market-dashboard')" class="gap-2">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
Market Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="() => router.push('/relay-hub-status')" class="gap-2">
|
||||
<Activity class="h-4 w-4" />
|
||||
Relay Hub Status
|
||||
|
|
@ -236,6 +261,12 @@ const handleLogout = async () => {
|
|||
<Ticket class="h-4 w-4" />
|
||||
My Tickets
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" @click="() => router.push('/market-dashboard')"
|
||||
class="w-full justify-start gap-2">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
Market Dashboard
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="() => router.push('/relay-hub-status')"
|
||||
class="w-full justify-start gap-2">
|
||||
<Activity class="h-4 w-4" />
|
||||
|
|
|
|||
250
src/components/market/CartItem.vue
Normal file
250
src/components/market/CartItem.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<!-- Desktop Layout (horizontal) -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-foreground truncate">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right min-w-[80px]">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout (stacked) -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<!-- Product Image and Details Row -->
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-foreground">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10 flex-shrink-0"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Price and Quantity Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Price per item -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { CartItem as CartItemType } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
item: CartItemType
|
||||
stallId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update-quantity': [stallId: string, productId: string, quantity: number]
|
||||
'remove-item': [stallId: string, productId: string]
|
||||
}>()
|
||||
|
||||
// Methods
|
||||
const increaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity + 1
|
||||
if (newQuantity <= props.item.product.quantity) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity - 1
|
||||
if (newQuantity > 0) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = () => {
|
||||
emit('remove-item', props.stallId, props.item.product.id)
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.src = '/placeholder-product.png'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
232
src/components/market/CartSummary.vue
Normal file
232
src/components/market/CartSummary.vue
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<div class="bg-card border rounded-lg p-6 shadow-sm">
|
||||
<!-- Cart Summary Header -->
|
||||
<div class="border-b border-border pb-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Order Summary</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ itemCount }} item{{ itemCount !== 1 ? 's' : '' }} in cart
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items Summary -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<div
|
||||
v-for="item in cartItems"
|
||||
:key="item.product.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-8 h-8 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
||||
<p class="text-muted-foreground">Qty: {{ item.quantity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="border-t border-border pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-foreground">Shipping Zone</label>
|
||||
<Button
|
||||
v-if="availableShippingZones.length > 1"
|
||||
@click="showShippingSelector = !showShippingSelector"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedShippingZone ? 'Change' : 'Select' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex items-center justify-between p-3 bg-muted rounded">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ selectedShippingZone.name }}</p>
|
||||
<p v-if="selectedShippingZone.description" class="text-sm text-muted-foreground">
|
||||
{{ selectedShippingZone.description }}
|
||||
</p>
|
||||
<p v-if="selectedShippingZone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ selectedShippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selector -->
|
||||
<div v-if="showShippingSelector && availableShippingZones.length > 1" class="mt-2">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!selectedShippingZone" class="text-sm text-red-600">
|
||||
Please select a shipping zone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="border-t border-border pt-4 mb-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Subtotal</span>
|
||||
<span class="text-foreground">{{ formatPrice(subtotal, currency) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Shipping</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-2 flex justify-between font-semibold text-lg">
|
||||
<span class="text-foreground">Total</span>
|
||||
<span class="text-green-600">{{ formatPrice(total, currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Actions -->
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="continueShopping"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
>
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Security Note -->
|
||||
<div class="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<Shield class="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Secure Checkout</p>
|
||||
<p>Your order will be encrypted and sent securely to the merchant using Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
import type { ShippingZone } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
stallId: string
|
||||
cartItems: readonly {
|
||||
readonly product: {
|
||||
readonly id: string
|
||||
readonly stall_id: string
|
||||
readonly stallName: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly price: number
|
||||
readonly currency: string
|
||||
readonly quantity: number
|
||||
readonly images?: readonly string[]
|
||||
readonly categories?: readonly string[]
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
}
|
||||
readonly quantity: number
|
||||
readonly stallId: string
|
||||
}[]
|
||||
subtotal: number
|
||||
currency: string
|
||||
availableShippingZones: readonly {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}[]
|
||||
selectedShippingZone?: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'shipping-zone-selected': [shippingZone: ShippingZone]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const showShippingSelector = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const itemCount = computed(() =>
|
||||
props.cartItems.reduce((total, item) => total + item.quantity, 0)
|
||||
)
|
||||
|
||||
const total = computed(() => {
|
||||
const shippingCost = props.selectedShippingZone?.cost || 0
|
||||
return props.subtotal + shippingCost
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
emit('shipping-zone-selected', shippingZone)
|
||||
showShippingSelector.value = false
|
||||
}
|
||||
|
||||
const continueShopping = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
310
src/components/market/DashboardOverview.vue
Normal file
310
src/components/market/DashboardOverview.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.total }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.pending }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ orderStats.paid }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Payments -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Pending Payments</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.pendingPayments }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Total: {{ formatPrice(orderStats.pendingAmount, 'sat') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sales (Merchant) -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Recent Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.recentSales }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 7 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Market Activity</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.active }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Customer Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<ShoppingCart class="w-5 h-5 text-primary" />
|
||||
Customer Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToMarket"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToCart"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||
Shopping Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Store class="w-5 h-5 text-green-500" />
|
||||
Merchant Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToStore"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Store
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToProducts"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
||||
<div v-if="recentActivity.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<component :is="getActivityIcon(activity.type)" class="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ activity.title }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatDate(activity.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="getActivityVariant(activity.type)">
|
||||
{{ activity.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No recent activity</p>
|
||||
<p class="text-sm">Start shopping or selling to see activity here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Status -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Market Status</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Order Events: {{ orderEvents.isSubscribed ? 'Connected' : 'Connecting...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="isConnected ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Market: {{ isConnected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="auth.isAuthenticated ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Auth: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
Clock
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const auth = useAuth()
|
||||
const { isConnected } = useMarket()
|
||||
|
||||
// Computed properties
|
||||
const orderStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
total: orders.length,
|
||||
pending: orders.filter(o => o.status === 'pending').length,
|
||||
paid: orders.filter(o => o.status === 'paid').length,
|
||||
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
|
||||
pendingAmount: orders
|
||||
.filter(o => o.paymentStatus === 'pending')
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
recentSales: orders.filter(o =>
|
||||
o.status === 'paid' && o.createdAt > sevenDaysAgo
|
||||
).length,
|
||||
active: orders.filter(o =>
|
||||
['pending', 'paid', 'processing'].includes(o.status)
|
||||
).length,
|
||||
connected: false
|
||||
}
|
||||
})
|
||||
|
||||
const recentActivity = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const recentOrders = orders
|
||||
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, 5)
|
||||
|
||||
return recentOrders.map(order => ({
|
||||
id: order.id,
|
||||
type: 'order',
|
||||
title: `Order ${order.id.slice(-8)} - ${order.status}`,
|
||||
status: order.status,
|
||||
timestamp: order.updatedAt
|
||||
}))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return `${price} ${currency}`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return Package
|
||||
default: return Clock
|
||||
}
|
||||
}
|
||||
|
||||
const getActivityVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return 'secondary'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const navigateToCart = () => router.push('/cart')
|
||||
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
||||
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
||||
</script>
|
||||
|
||||
331
src/components/market/MarketSettings.vue
Normal file
331
src/components/market/MarketSettings.vue
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Market Settings</h2>
|
||||
<p class="text-muted-foreground mt-1">Configure your store and market preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tabs -->
|
||||
<div class="border-b border-border">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.id"
|
||||
@click="activeSettingsTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeSettingsTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Settings Content -->
|
||||
<div class="min-h-[500px]">
|
||||
<!-- Store Settings Tab -->
|
||||
<div v-if="activeSettingsTab === 'store'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Name</label>
|
||||
<Input v-model="storeSettings.name" placeholder="Enter store name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Description</label>
|
||||
<Input v-model="storeSettings.description" placeholder="Enter store description" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Contact Email</label>
|
||||
<Input v-model="storeSettings.contactEmail" type="email" placeholder="Enter contact email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Category</label>
|
||||
<select v-model="storeSettings.category" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">Select category</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
<option value="books">Books</option>
|
||||
<option value="food">Food & Beverages</option>
|
||||
<option value="services">Services</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveStoreSettings" variant="default">
|
||||
Save Store Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'payment'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Default Currency</label>
|
||||
<select v-model="paymentSettings.defaultCurrency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
<option value="eur">Euro (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Invoice Expiry (minutes)</label>
|
||||
<Input v-model="paymentSettings.invoiceExpiry" type="number" min="5" max="1440" placeholder="60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Auto-generate Invoices</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="paymentSettings.autoGenerateInvoices"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-input rounded"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Automatically generate Lightning invoices for new orders
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="savePaymentSettings" variant="default">
|
||||
Save Payment Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'nostr'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Nostr Network Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Relay Connections</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="relay in nostrSettings.relays" :key="relay" class="flex items-center gap-2">
|
||||
<Input :value="relay" readonly class="flex-1" />
|
||||
<Button @click="removeRelay(relay)" variant="outline" size="sm">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="newRelay" placeholder="wss://relay.example.com" class="flex-1" />
|
||||
<Button @click="addRelay" variant="outline">
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Nostr Public Key</label>
|
||||
<Input :value="nostrSettings.pubkey" readonly class="font-mono text-sm" />
|
||||
<p class="text-xs text-muted-foreground mt-1">Your Nostr public key for receiving orders</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Connection Status</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Connected to Nostr network' : 'Connecting to Nostr network...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveNostrSettings" variant="default">
|
||||
Save Nostr Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'shipping'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Zones</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="zone in shippingSettings.zones" :key="zone.id" class="border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Zone Name</label>
|
||||
<Input v-model="zone.name" placeholder="Zone name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Cost</label>
|
||||
<Input v-model="zone.cost" type="number" min="0" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Currency</label>
|
||||
<select v-model="zone.currency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Button @click="removeShippingZone(zone.id)" variant="outline" size="sm">
|
||||
Remove Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addShippingZone" variant="outline">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Shipping Zone
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveShippingSettings" variant="default">
|
||||
Save Shipping Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
// import { useMarketStore } from '@/stores/market'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeSettingsTab = ref('store')
|
||||
const newRelay = ref('')
|
||||
|
||||
// Settings data
|
||||
const storeSettings = ref({
|
||||
name: 'My Store',
|
||||
description: 'A great place to shop',
|
||||
contactEmail: 'store@example.com',
|
||||
category: 'other'
|
||||
})
|
||||
|
||||
const paymentSettings = ref({
|
||||
defaultCurrency: 'sat',
|
||||
invoiceExpiry: 60,
|
||||
autoGenerateInvoices: true
|
||||
})
|
||||
|
||||
const nostrSettings = ref({
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
],
|
||||
pubkey: 'npub1...' // TODO: Get from auth
|
||||
})
|
||||
|
||||
const shippingSettings = ref({
|
||||
zones: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Local',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '1-2 days'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Domestic',
|
||||
cost: 1000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'International',
|
||||
cost: 5000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '7-14 days'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Settings tabs
|
||||
const settingsTabs = [
|
||||
{ id: 'store', name: 'Store Settings' },
|
||||
{ id: 'payment', name: 'Payment Settings' },
|
||||
{ id: 'nostr', name: 'Nostr Network' },
|
||||
{ id: 'shipping', name: 'Shipping Zones' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const saveStoreSettings = () => {
|
||||
// TODO: Save store settings
|
||||
console.log('Saving store settings:', storeSettings.value)
|
||||
}
|
||||
|
||||
const savePaymentSettings = () => {
|
||||
// TODO: Save payment settings
|
||||
console.log('Saving payment settings:', paymentSettings.value)
|
||||
}
|
||||
|
||||
const saveNostrSettings = () => {
|
||||
// TODO: Save Nostr settings
|
||||
console.log('Saving Nostr settings:', nostrSettings.value)
|
||||
}
|
||||
|
||||
const saveShippingSettings = () => {
|
||||
// TODO: Save shipping settings
|
||||
console.log('Saving shipping settings:', shippingSettings.value)
|
||||
}
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.value && !nostrSettings.value.relays.includes(newRelay.value)) {
|
||||
nostrSettings.value.relays.push(newRelay.value)
|
||||
newRelay.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeRelay = (relay: string) => {
|
||||
const index = nostrSettings.value.relays.indexOf(relay)
|
||||
if (index > -1) {
|
||||
nostrSettings.value.relays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addShippingZone = () => {
|
||||
const newZone = {
|
||||
id: Date.now().toString(),
|
||||
name: 'New Zone',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
}
|
||||
shippingSettings.value.zones.push(newZone)
|
||||
}
|
||||
|
||||
const removeShippingZone = (zoneId: string) => {
|
||||
const index = shippingSettings.value.zones.findIndex(z => z.id === zoneId)
|
||||
if (index > -1) {
|
||||
shippingSettings.value.zones.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Settings component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
550
src/components/market/MerchantStore.vue
Normal file
550
src/components/market/MerchantStore.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Store</h2>
|
||||
<p class="text-muted-foreground mt-1">Manage incoming orders and your products</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button @click="addProduct" variant="default">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Incoming Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Incoming Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.incomingOrders }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.pendingOrders }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ storeStats.paidOrders }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Sales -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ formatPrice(storeStats.totalSales, 'sat') }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 30 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Products</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.totalProducts }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.activeProducts }} active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Satisfaction -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Satisfaction</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<Star class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.totalReviews }} reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Orders Section -->
|
||||
<div class="bg-card rounded-lg border shadow-sm">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">Incoming Orders</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Orders waiting for your attention</p>
|
||||
</div>
|
||||
|
||||
<div v-if="incomingOrders.length > 0" class="divide-y divide-border">
|
||||
<div
|
||||
v-for="order in incomingOrders"
|
||||
:key="order.id"
|
||||
class="p-6 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }} • {{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="order.paymentStatus === 'pending'" variant="secondary">
|
||||
Payment Pending
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Wallet Indicator -->
|
||||
<div v-if="order.status === 'pending' && !order.lightningInvoice" class="text-xs text-muted-foreground mr-2">
|
||||
<span>Wallet: {{ getFirstWalletName() }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="order.status === 'pending' && !order.lightningInvoice"
|
||||
@click="generateInvoice(order.id)"
|
||||
:disabled="isGeneratingInvoice === order.id"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<div v-if="isGeneratingInvoice === order.id" class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-2">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Generate Invoice</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
@click="viewOrderDetails(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Eye class="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
@click="processOrder(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Check class="w-4 h-4 mr-2" />
|
||||
Process
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Items</h5>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.productId"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Customer Info</h5>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="order.contactInfo.email">
|
||||
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.message">
|
||||
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.address">
|
||||
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div v-if="order.lightningInvoice" class="p-4 bg-green-500/10 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
<span class="text-sm font-medium text-green-900">Lightning Invoice Generated</span>
|
||||
</div>
|
||||
<div class="text-sm text-green-700">
|
||||
Amount: {{ formatPrice(order.total, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 bg-yellow-500/10 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="w-5 h-5 text-yellow-600" />
|
||||
<span class="text-sm font-medium text-yellow-900">Invoice Required</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-6 text-center text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No incoming orders</p>
|
||||
<p class="text-sm">Orders from customers will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Order Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Order Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="viewAllOrders"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="generateBulkInvoices"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
Generate Bulk Invoices
|
||||
</Button>
|
||||
<Button
|
||||
@click="exportOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
Export Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="manageProducts"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="storeSettings"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Settings class="w-4 h-4 mr-2" />
|
||||
Store Settings
|
||||
</Button>
|
||||
<Button
|
||||
@click="analytics"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<BarChart3 class="w-4 h-4 mr-2" />
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
DollarSign,
|
||||
Star,
|
||||
Plus,
|
||||
Zap,
|
||||
Eye,
|
||||
Check,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Settings,
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const nostrOrdersComposable = nostrOrders
|
||||
|
||||
// Local state
|
||||
const isGeneratingInvoice = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const incomingOrders = computed(() => {
|
||||
// For now, show all orders as "incoming" since we don't have merchant filtering yet
|
||||
// In a real implementation, this would filter orders where the current user is the seller
|
||||
return Object.values(marketStore.orders)
|
||||
.filter(order => order.status === 'pending')
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
|
||||
const storeStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
incomingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
pendingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
paidOrders: orders.filter(o => o.status === 'paid').length,
|
||||
totalSales: orders
|
||||
.filter(o => o.status === 'paid' && o.createdAt > thirtyDaysAgo)
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
totalProducts: 0, // TODO: Implement product management
|
||||
activeProducts: 0, // TODO: Implement product management
|
||||
satisfaction: 95, // TODO: Implement review system
|
||||
totalReviews: 0 // TODO: Implement review system
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const generateInvoice = async (orderId: string) => {
|
||||
console.log('Generating invoice for order:', orderId)
|
||||
isGeneratingInvoice.value = orderId
|
||||
|
||||
try {
|
||||
// Get the order from the store
|
||||
const order = marketStore.orders[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Temporary fix: If buyerPubkey is missing, try to get it from auth
|
||||
if (!order.buyerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing buyerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { buyerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Temporary fix: If sellerPubkey is missing, use current user's pubkey
|
||||
if (!order.sellerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing sellerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { sellerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Get the updated order
|
||||
const updatedOrder = marketStore.orders[orderId]
|
||||
|
||||
console.log('Order details for invoice generation:', {
|
||||
orderId: updatedOrder.id,
|
||||
orderFields: Object.keys(updatedOrder),
|
||||
buyerPubkey: updatedOrder.buyerPubkey,
|
||||
sellerPubkey: updatedOrder.sellerPubkey,
|
||||
status: updatedOrder.status,
|
||||
total: updatedOrder.total
|
||||
})
|
||||
|
||||
// Get the user's wallet list
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
console.log('Available wallets:', userWallets)
|
||||
|
||||
if (userWallets.length === 0) {
|
||||
throw new Error('No wallet available to generate invoice. Please ensure you have at least one wallet configured.')
|
||||
}
|
||||
|
||||
// Use the first available wallet for invoice generation
|
||||
const walletId = userWallets[0].id
|
||||
const walletName = userWallets[0].name
|
||||
const adminKey = userWallets[0].adminkey
|
||||
console.log('Using wallet for invoice generation:', { walletId, walletName, balance: userWallets[0].balance_msat })
|
||||
|
||||
const invoice = await marketStore.createLightningInvoice(orderId, adminKey)
|
||||
|
||||
if (invoice) {
|
||||
console.log('Lightning invoice created:', invoice)
|
||||
|
||||
// Send the invoice to the customer via Nostr
|
||||
await sendInvoiceToCustomer(updatedOrder, invoice)
|
||||
|
||||
console.log('Invoice sent to customer successfully')
|
||||
|
||||
// Show success message (you could add a toast notification here)
|
||||
alert(`Invoice generated successfully using wallet: ${walletName}`)
|
||||
} else {
|
||||
throw new Error('Failed to create Lightning invoice')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate invoice:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
|
||||
// Show error message to user
|
||||
alert(`Failed to generate invoice: ${errorMessage}`)
|
||||
} finally {
|
||||
isGeneratingInvoice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const sendInvoiceToCustomer = async (order: any, invoice: any) => {
|
||||
try {
|
||||
console.log('Sending invoice to customer for order:', {
|
||||
orderId: order.id,
|
||||
buyerPubkey: order.buyerPubkey,
|
||||
sellerPubkey: order.sellerPubkey,
|
||||
invoiceFields: Object.keys(invoice)
|
||||
})
|
||||
|
||||
// Check if we have the buyer's public key
|
||||
if (!order.buyerPubkey) {
|
||||
console.error('Missing buyerPubkey in order:', order)
|
||||
throw new Error('Cannot send invoice: buyer public key not found')
|
||||
}
|
||||
|
||||
// Update the order with the invoice details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
lightningInvoice: invoice,
|
||||
paymentHash: invoice.payment_hash,
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: invoice.bolt11, // Use bolt11 field from LNBits response
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
// Send the updated order to the customer via Nostr
|
||||
// This will include the invoice information
|
||||
await nostrOrdersComposable.publishOrderEvent(updatedOrder, order.buyerPubkey)
|
||||
|
||||
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invoice to customer:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const viewOrderDetails = (orderId: string) => {
|
||||
// TODO: Navigate to detailed order view
|
||||
console.log('Viewing order details:', orderId)
|
||||
}
|
||||
|
||||
const processOrder = (orderId: string) => {
|
||||
// TODO: Implement order processing
|
||||
console.log('Processing order:', orderId)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
// TODO: Navigate to add product form
|
||||
console.log('Adding new product')
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const viewAllOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const generateBulkInvoices = () => console.log('Generate bulk invoices')
|
||||
const exportOrders = () => console.log('Export orders')
|
||||
const manageProducts = () => console.log('Manage products')
|
||||
const storeSettings = () => router.push('/market-dashboard?tab=settings')
|
||||
const analytics = () => console.log('View analytics')
|
||||
|
||||
const getFirstWalletName = () => {
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
if (userWallets.length > 0) {
|
||||
return userWallets[0].name
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Merchant Store component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
599
src/components/market/OrderHistory.vue
Normal file
599
src/components/market/OrderHistory.vue
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Orders</h2>
|
||||
<p class="text-muted-foreground mt-1">Track all your market orders and payments</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Order Events Status -->
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span>{{ orderEvents.isSubscribed ? 'Live updates' : 'Connecting...' }}</span>
|
||||
</div>
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Stats -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Order Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Total:</span>
|
||||
<Badge variant="secondary">{{ totalOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Pending:</span>
|
||||
<Badge variant="outline" class="text-yellow-600">{{ pendingOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Paid:</span>
|
||||
<Badge variant="outline" class="text-green-600">{{ paidOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Payment Due:</span>
|
||||
<Badge variant="outline" class="text-red-600">{{ pendingPayments }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="sortBy" class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="createdAt">Date Created</option>
|
||||
<option value="total">Order Total</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div v-if="filteredOrders.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="order in sortedOrders"
|
||||
:key="order.id"
|
||||
class="bg-card border rounded-lg p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="flex items-center gap-2 mt-1">
|
||||
<div v-if="order.sentViaNostr" class="flex items-center gap-1 text-xs text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
Sent via Nostr
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-xs text-red-600">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
Nostr failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<!-- Payment Status Indicator -->
|
||||
<div v-if="order.lightningInvoice" class="flex items-center gap-2">
|
||||
<Badge
|
||||
:variant="order.paymentStatus === 'paid' ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="order.paymentStatus === 'paid'" class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<div v-else class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
{{ order.paymentStatus === 'paid' ? 'Paid' : 'Payment Pending' }}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">{{ order.currency.toUpperCase() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<!-- Items -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Items</h4>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="item in order.items.slice(0, 3)"
|
||||
:key="item.productId"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
<div v-if="order.items.length > 3" class="text-sm text-muted-foreground">
|
||||
+{{ order.items.length - 3 }} more items
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Payment</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-1 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
<span class="font-medium">Status:</span> {{ order.paymentStatus === 'paid' ? 'Paid' : 'Pending' }}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
<span class="font-medium">Invoice:</span> {{ order.lightningInvoice.payment_hash.slice(0, 8) }}...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
Waiting for merchant invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Shipping</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-medium">Zone:</span> {{ order.shippingZone?.name || 'N/A' }}
|
||||
</p>
|
||||
<p v-if="order.shippingZone?.estimatedDays">
|
||||
<span class="font-medium">Est. Delivery:</span> {{ order.shippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status & Actions -->
|
||||
<div v-if="order.status === 'pending'" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-foreground mb-2">Payment Required</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Lightning invoice received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="font-medium text-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="order.paymentRequest" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Payment request received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="text-sm text-muted-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Payment Options -->
|
||||
<div class="mt-4 space-y-3">
|
||||
<h5 class="text-sm font-medium text-foreground">Payment Options:</h5>
|
||||
|
||||
<!-- Lightning Payment -->
|
||||
<div v-if="order.paymentRequest" class="bg-card border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="w-4 h-4 text-yellow-500" />
|
||||
<span class="font-medium text-sm">Lightning Payment</span>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">Recommended</Badge>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="text-center mb-3">
|
||||
<div class="w-32 h-32 mx-auto mb-2">
|
||||
<div v-if="order.qrCodeDataUrl && !order.qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="order.qrCodeDataUrl"
|
||||
:alt="`QR Code for ${formatPrice(order.total, order.currency)} payment`"
|
||||
class="w-full h-full border border-border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="order.qrCodeLoading" class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-2xl mb-1 animate-pulse">⚡</div>
|
||||
<div class="text-xs">Generating...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-2xl mb-1">⚡</div>
|
||||
<div class="text-xs">No QR</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Request Link -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-foreground">
|
||||
Payment Request
|
||||
</label>
|
||||
<!-- Debug info -->
|
||||
<div class="text-xs text-red-500 mb-2">
|
||||
Debug: paymentRequest = "{{ order.paymentRequest }}" (length: {{ order.paymentRequest?.length || 0 }})<br>
|
||||
Debug: paymentHash = "{{ order.paymentHash }}" (length: {{ order.paymentHash?.length || 0 }})<br>
|
||||
Debug: Order keys: {{ Object.keys(order).join(', ') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="order.paymentRequest || ''"
|
||||
readonly
|
||||
class="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest(order.paymentRequest)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!order.paymentRequest"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Actions -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<Button
|
||||
@click="openLightningWallet(order.paymentRequest)"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Zap class="w-3 h-3 mr-1" />
|
||||
Pay with Lightning
|
||||
</Button>
|
||||
<Button
|
||||
@click="downloadQRCode(order)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Download class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-amber-600">⏳</span> Waiting for merchant to generate payment invoice
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The merchant will send you a Lightning invoice via Nostr once they process your order
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="order.status === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="cancelOrder(order.id)"
|
||||
>
|
||||
Cancel Order
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
variant="default"
|
||||
size="sm"
|
||||
@click="togglePaymentDisplay(order.id)"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
{{ expandedPayments.has(order.id) ? 'Hide' : 'Show' }} Payment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyOrderId(order.id)"
|
||||
>
|
||||
Copy Order ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Display (Expandable) -->
|
||||
<div v-if="expandedPayments.has(order.id) && order.lightningInvoice" class="mt-4 pt-4 border-t border-border">
|
||||
<PaymentDisplay :order-id="order.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Information (Development Only) -->
|
||||
<div v-if="isDevelopment" class="mt-8 p-4 bg-gray-100 rounded-lg">
|
||||
<h4 class="font-medium mb-2">Debug Information</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Total Orders in Store: {{ Object.keys(marketStore.orders).length }}</div>
|
||||
<div>Filtered Orders: {{ filteredOrders.length }}</div>
|
||||
<div>Order Events Subscribed: {{ orderEvents.isSubscribed ? 'Yes' : 'No' }}</div>
|
||||
<div>Relay Hub Connected: {{ relayHub.isConnected ? 'Yes' : 'No' }}</div>
|
||||
<div>Auth Status: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</div>
|
||||
<div>Current User: {{ auth.currentUser?.value?.pubkey ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No orders yet</h3>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Start shopping in the market to see your order history here
|
||||
</p>
|
||||
<Button @click="navigateToMarket" variant="default">
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Package, Store, Wallet, Zap, Copy, Download } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import PaymentDisplay from './PaymentDisplay.vue'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Local state
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref('createdAt')
|
||||
const expandedPayments = ref(new Set<string>())
|
||||
|
||||
// Computed properties
|
||||
const allOrders = computed(() => Object.values(marketStore.orders))
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return allOrders.value
|
||||
return allOrders.value.filter(order => order.status === statusFilter.value)
|
||||
})
|
||||
|
||||
const sortedOrders = computed(() => {
|
||||
const orders = [...filteredOrders.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'total':
|
||||
return orders.sort((a, b) => b.total - a.total)
|
||||
case 'status':
|
||||
return orders.sort((a, b) => a.status.localeCompare(b.status))
|
||||
case 'createdAt':
|
||||
default:
|
||||
return orders.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
const totalOrders = computed(() => allOrders.value.length)
|
||||
const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'pending').length)
|
||||
const paidOrders = computed(() => allOrders.value.filter(o => o.status === 'paid').length)
|
||||
const pendingPayments = computed(() => allOrders.value.filter(o => o.paymentStatus === 'pending').length)
|
||||
|
||||
const isDevelopment = computed(() => import.meta.env.DEV)
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId: string) => {
|
||||
// TODO: Implement order cancellation
|
||||
console.log('Cancelling order:', orderId)
|
||||
}
|
||||
|
||||
const togglePaymentDisplay = (orderId: string) => {
|
||||
if (expandedPayments.value.has(orderId)) {
|
||||
expandedPayments.value.delete(orderId)
|
||||
} else {
|
||||
expandedPayments.value.add(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
const copyOrderId = async (orderId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(orderId)
|
||||
toast.success('Order ID copied to clipboard')
|
||||
console.log('Order ID copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy order ID:', err)
|
||||
toast.error('Failed to copy order ID')
|
||||
}
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async (paymentRequest: string) => {
|
||||
console.log('Copying payment request:', {
|
||||
paymentRequest: paymentRequest?.substring(0, 50) + '...',
|
||||
hasValue: !!paymentRequest,
|
||||
length: paymentRequest?.length
|
||||
})
|
||||
|
||||
if (!paymentRequest) {
|
||||
toast.error('No payment request available', {
|
||||
description: 'Please wait for the merchant to send the payment request'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentRequest)
|
||||
toast.success('Payment request copied to clipboard', {
|
||||
description: 'You can now paste it into your Lightning wallet'
|
||||
})
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy payment request:', err)
|
||||
toast.error('Failed to copy payment request', {
|
||||
description: 'Please try again or copy manually'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openLightningWallet = (paymentRequest: string) => {
|
||||
// Try to open with common Lightning wallet protocols
|
||||
const protocols = [
|
||||
`lightning:${paymentRequest}`,
|
||||
`bitcoin:${paymentRequest}`,
|
||||
paymentRequest
|
||||
]
|
||||
|
||||
// Try each protocol
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
window.open(protocol, '_blank')
|
||||
toast.success('Opening Lightning wallet', {
|
||||
description: 'If your wallet doesn\'t open, copy the payment request manually'
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('Failed to open protocol:', protocol, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: copy to clipboard
|
||||
copyPaymentRequest(paymentRequest)
|
||||
}
|
||||
|
||||
const downloadQRCode = async (order: any) => {
|
||||
if (!order.qrCodeDataUrl) {
|
||||
toast.error('QR code not available', {
|
||||
description: 'Please wait for the QR code to generate'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = order.qrCodeDataUrl
|
||||
link.download = `payment-qr-${order.id}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
toast.success('QR code downloaded', {
|
||||
description: 'You can now scan it with your mobile wallet'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to download QR code:', err)
|
||||
toast.error('Failed to download QR code', {
|
||||
description: 'Please try again'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
|
||||
// Load orders on mount
|
||||
onMounted(() => {
|
||||
// Orders are already loaded in the market store
|
||||
console.log('Order History component loaded with', allOrders.value.length, 'orders')
|
||||
console.log('Market store orders:', marketStore.orders)
|
||||
|
||||
// Debug: Log order details for orders with payment requests
|
||||
allOrders.value.forEach(order => {
|
||||
if (order.paymentRequest) {
|
||||
console.log('Order with payment request:', {
|
||||
id: order.id,
|
||||
paymentRequest: order.paymentRequest.substring(0, 50) + '...',
|
||||
hasPaymentRequest: !!order.paymentRequest,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Order events status:', orderEvents.isSubscribed.value)
|
||||
console.log('Relay hub connected:', relayHub.isConnected.value)
|
||||
console.log('Auth status:', auth.isAuthenticated)
|
||||
console.log('Current user:', auth.currentUser?.value?.pubkey)
|
||||
|
||||
// Start listening for order events if not already listening
|
||||
if (!orderEvents.isSubscribed.value) {
|
||||
console.log('Starting order events listener...')
|
||||
orderEvents.startListening()
|
||||
} else {
|
||||
console.log('Order events already listening')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for authentication and relay hub readiness
|
||||
watch(
|
||||
[() => auth.isAuthenticated, () => relayHub.isConnected.value],
|
||||
([isAuth, isConnected]) => {
|
||||
if (isAuth && isConnected && !orderEvents.isSubscribed.value) {
|
||||
console.log('Auth and relay hub ready, starting order events listener...')
|
||||
orderEvents.startListening()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
344
src/components/market/PaymentDisplay.vue
Normal file
344
src/components/market/PaymentDisplay.vue
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<template>
|
||||
<div class="bg-white border rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Payment</h3>
|
||||
<Badge :variant="getPaymentStatusVariant(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Information -->
|
||||
<div v-if="invoice" class="space-y-4">
|
||||
<!-- Amount and Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Amount</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ invoice.amount }} {{ currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">Status</p>
|
||||
<p class="text-lg font-semibold" :class="getStatusColor(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-gray-900 mb-2">Lightning Invoice</h4>
|
||||
<p class="text-sm text-gray-600">Scan with your Lightning wallet to pay</p>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="w-48 h-48 mx-auto mb-4">
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="qrCodeDataUrl"
|
||||
:alt="`QR Code for ${invoice.amount} ${currency} payment`"
|
||||
class="w-full h-full border border-gray-200 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="qrCodeLoading" class="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
||||
<div class="text-sm">Generating QR...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="qrCodeError" class="w-full h-full bg-red-50 border border-red-200 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-red-500">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-sm">{{ qrCodeError }}</div>
|
||||
<Button
|
||||
@click="retryQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<div class="text-sm">No invoice</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Actions -->
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="mb-4">
|
||||
<Button
|
||||
@click="downloadQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
Download QR Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Request -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Payment Request
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="invoice.bolt11"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Wallet Button -->
|
||||
<Button
|
||||
@click="openInWallet"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="font-medium text-gray-900 mb-3">Payment Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Payment Hash:</span>
|
||||
<span class="font-mono text-gray-900">{{ formatHash(invoice.payment_hash) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Created:</span>
|
||||
<span class="text-gray-900">{{ formatDate(invoice.created_at ? new Date(invoice.created_at).getTime() : Date.now()) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Expires:</span>
|
||||
<span class="text-gray-900">{{ formatDate(invoice.expiry ? new Date(invoice.expiry).getTime() : Date.now()) }}</span>
|
||||
</div>
|
||||
<div v-if="paidAt" class="flex justify-between">
|
||||
<span class="text-gray-600">Paid At:</span>
|
||||
<span class="text-gray-900">{{ formatDate(paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Invoice State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Wallet class="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-2">No Payment Invoice</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
A Lightning invoice will be sent by the merchant once they process your order.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
You'll receive the invoice via Nostr when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div v-if="paymentStatus === 'pending'" class="mt-6 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-5 h-5 bg-muted rounded-full flex items-center justify-center mt-0.5">
|
||||
<Info class="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<h5 class="font-medium mb-1 text-foreground">Payment Instructions</h5>
|
||||
<ul class="space-y-1">
|
||||
<li>• Use a Lightning-compatible wallet (e.g., Phoenix, Breez, Alby)</li>
|
||||
<li>• Scan the QR code or copy the payment request</li>
|
||||
<li>• Confirm the payment amount and send</li>
|
||||
<li>• Your order will be processed once payment is confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Success -->
|
||||
<div v-if="paymentStatus === 'paid'" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<div class="text-sm text-green-800">
|
||||
<h5 class="font-medium">Payment Confirmed!</h5>
|
||||
<p>Your order is being processed. You'll receive updates via Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Copy,
|
||||
Wallet,
|
||||
|
||||
Info,
|
||||
CheckCircle,
|
||||
Download
|
||||
} from 'lucide-vue-next'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const order = computed(() => marketStore.orders[props.orderId])
|
||||
const invoice = computed(() => order.value?.lightningInvoice)
|
||||
const paymentStatus = computed(() => order.value?.paymentStatus || 'pending')
|
||||
const currency = computed(() => order.value?.currency || 'sat')
|
||||
const paidAt = computed(() => order.value?.paidAt)
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 192, // 48 * 4 for high DPI displays
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getPaymentStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'default'
|
||||
case 'pending': return 'secondary'
|
||||
case 'expired': return 'destructive'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const formatPaymentStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Paid'
|
||||
case 'pending': return 'Pending'
|
||||
case 'expired': return 'Expired'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'text-green-600'
|
||||
case 'pending': return 'text-yellow-600'
|
||||
case 'expired': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
if (!hash) return 'N/A'
|
||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return 'N/A'
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice.value.bolt11)
|
||||
// TODO: Show toast notification
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openInWallet = () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
// Open in Lightning wallet
|
||||
const walletUrl = `lightning:${invoice.value.bolt11}`
|
||||
window.open(walletUrl, '_blank')
|
||||
}
|
||||
|
||||
const downloadQRCode = () => {
|
||||
if (!qrCodeDataUrl.value) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = qrCodeDataUrl.value
|
||||
link.download = `qr-code-${invoice.value?.amount}-${currency.value}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const retryQRCode = () => {
|
||||
if (invoice.value?.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Set up payment monitoring if invoice exists
|
||||
if (invoice.value && props.orderId) {
|
||||
// Payment monitoring is handled by the market store
|
||||
console.log('Payment display mounted for order:', props.orderId)
|
||||
|
||||
// Generate QR code for the invoice
|
||||
if (invoice.value.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for invoice changes to regenerate QR code
|
||||
watch(() => invoice.value?.bolt11, (newPaymentRequest) => {
|
||||
if (newPaymentRequest) {
|
||||
generateQRCode(newPaymentRequest)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
287
src/components/market/PaymentRequestDialog.vue
Normal file
287
src/components/market/PaymentRequestDialog.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<Dialog :open="modelValue" @update:open="updateOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Payment Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete your payment to finalize your order
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="paymentRequest" class="space-y-4">
|
||||
<!-- Order Details -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Order Details</h4>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Order ID: {{ paymentRequest.id }}
|
||||
</div>
|
||||
<div v-if="paymentRequest.message" class="text-sm">
|
||||
{{ paymentRequest.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Options -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Payment Options</h4>
|
||||
<div v-for="option in paymentRequest.payment_options" :key="option.type" class="space-y-2">
|
||||
<Card>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ getPaymentTypeLabel(option.type) }}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">{{ option.link }}</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button
|
||||
:variant="option.type === 'ln' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="handlePayment(option)"
|
||||
>
|
||||
<component :is="getPaymentTypeIcon(option.type)" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div v-if="lightningInvoice" class="space-y-4">
|
||||
<h4 class="text-sm font-medium">Lightning Invoice</h4>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white p-4 rounded-lg border">
|
||||
<div v-if="qrCodeLoading" class="w-48 h-48 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="qrCodeDataUrl"
|
||||
:src="qrCodeDataUrl"
|
||||
alt="Lightning payment QR code"
|
||||
class="w-48 h-48"
|
||||
/>
|
||||
<div v-else-if="qrCodeError" class="w-48 h-48 flex items-center justify-center text-red-500 text-sm">
|
||||
{{ qrCodeError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="copyInvoice"
|
||||
>
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<Button
|
||||
@click="payWithWallet"
|
||||
>
|
||||
Pay with Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-muted-foreground py-8">
|
||||
No payment request available
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="updateOpen(false)">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import QRCode from 'qrcode'
|
||||
import type { NostrmarketPaymentRequest } from '@/lib/services/nostrmarketService'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Zap, Bitcoin, Link, QrCode, CreditCard } from 'lucide-vue-next'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
paymentRequest?: NostrmarketPaymentRequest
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'payment-completed': [orderId: string]
|
||||
}>()
|
||||
|
||||
// Computed
|
||||
const lightningInvoice = computed(() => {
|
||||
if (!props.paymentRequest) return null
|
||||
|
||||
const lightningOption = props.paymentRequest.payment_options.find(opt => opt.type === 'ln')
|
||||
return lightningOption?.link || null
|
||||
})
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for lightning invoice changes and generate QR code
|
||||
watch(lightningInvoice, (newInvoice) => {
|
||||
if (newInvoice) {
|
||||
generateQRCode(newInvoice)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Methods
|
||||
const updateOpen = (open: boolean) => {
|
||||
emit('update:modelValue', open)
|
||||
}
|
||||
|
||||
const getPaymentTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return 'Lightning Network'
|
||||
case 'btc':
|
||||
return 'Bitcoin On-Chain'
|
||||
case 'url':
|
||||
return 'Payment URL'
|
||||
case 'lnurl':
|
||||
return 'LNURL-Pay'
|
||||
default:
|
||||
return type.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
const getPaymentTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return Zap
|
||||
case 'btc':
|
||||
return Bitcoin
|
||||
case 'url':
|
||||
return Link
|
||||
case 'lnurl':
|
||||
return QrCode
|
||||
default:
|
||||
return CreditCard
|
||||
}
|
||||
}
|
||||
|
||||
const handlePayment = async (option: { type: string; link: string }) => {
|
||||
try {
|
||||
switch (option.type) {
|
||||
case 'ln':
|
||||
// For Lightning invoices, we can either show QR code or pay directly
|
||||
if (lightningInvoice.value) {
|
||||
await payWithWallet()
|
||||
}
|
||||
break
|
||||
case 'url':
|
||||
// Open payment URL in new tab
|
||||
window.open(option.link, '_blank')
|
||||
break
|
||||
case 'btc':
|
||||
// Copy Bitcoin address
|
||||
await navigator.clipboard.writeText(option.link)
|
||||
toast.success('Bitcoin address copied to clipboard')
|
||||
break
|
||||
default:
|
||||
console.warn('Unknown payment type:', option.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment handling error:', error)
|
||||
toast.error('Failed to process payment')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInvoice = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lightningInvoice.value)
|
||||
toast.success('Lightning invoice copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy invoice:', error)
|
||||
toast.error('Failed to copy invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const payWithWallet = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
// Import the payment API
|
||||
const { payInvoiceWithWallet } = await import('@/lib/api/events')
|
||||
|
||||
// Get the current user's wallet info
|
||||
const { useAuth } = await import('@/composables/useAuth')
|
||||
const auth = useAuth()
|
||||
|
||||
if (!auth.currentUser.value?.walletId || !auth.currentUser.value?.adminKey) {
|
||||
toast.error('Please connect your wallet to pay')
|
||||
return
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
const result = await payInvoiceWithWallet(
|
||||
lightningInvoice.value,
|
||||
auth.currentUser.value.walletId,
|
||||
auth.currentUser.value.adminKey
|
||||
)
|
||||
|
||||
console.log('Payment result:', result)
|
||||
|
||||
toast.success('Payment successful!')
|
||||
|
||||
// Emit payment completed event
|
||||
if (props.paymentRequest) {
|
||||
emit('payment-completed', props.paymentRequest.id)
|
||||
}
|
||||
|
||||
// Close the dialog
|
||||
updateOpen(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment failed:', error)
|
||||
toast.error('Payment failed: ' + (error instanceof Error ? error.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -10,14 +10,14 @@
|
|||
/>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<Button
|
||||
@click="$emit('add-to-cart', product)"
|
||||
:disabled="product.quantity < 1"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="addToCart"
|
||||
:disabled="product.quantity < 1"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Out of Stock Badge -->
|
||||
<Badge
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -111,22 +112,26 @@ interface Props {
|
|||
product: Product
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'add-to-cart': [product: Product]
|
||||
'view-details': [product: Product]
|
||||
'view-stall': [stallId: string]
|
||||
}>()
|
||||
// const emit = defineEmits<{
|
||||
// 'view-details': [product: Product]
|
||||
// 'view-stall': [stallId: string]
|
||||
// }>()
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
const imageError = ref(false)
|
||||
|
||||
const addToCart = () => {
|
||||
marketStore.addToStallCart(props.product, 1)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat') {
|
||||
if (currency === 'sat' || currency === 'sats') {
|
||||
return `${price.toLocaleString('en-US')} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
|
|
|
|||
250
src/components/market/ShoppingCart.vue
Normal file
250
src/components/market/ShoppingCart.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Cart Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Shopping Cart</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ totalCartItems }} items across {{ allStallCarts.length }} stalls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Total Value</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ formatPrice(totalCartValue, 'sats') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="allStallCarts.length > 0"
|
||||
@click="clearAllCarts"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Cart State -->
|
||||
<div v-if="allStallCarts.length === 0" class="text-center py-12">
|
||||
<ShoppingCart class="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
|
||||
<p class="text-muted-foreground mb-6">Start shopping to add items to your cart</p>
|
||||
<Button @click="$router.push('/market')" variant="default">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stall Carts -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="cart in allStallCarts"
|
||||
:key="cart.id"
|
||||
class="border border-border rounded-lg p-6 bg-card shadow-sm"
|
||||
>
|
||||
<!-- Stall Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{{ getStallName(cart.id) }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ cart.products.length }} item{{ cart.products.length !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Stall Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<CartItem
|
||||
v-for="item in cart.products"
|
||||
:key="item.product.id"
|
||||
:item="item"
|
||||
:stall-id="cart.id"
|
||||
@update-quantity="updateQuantity"
|
||||
@remove-item="removeItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stall Cart Actions -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<!-- Desktop Layout -->
|
||||
<div class="hidden md:flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-right mr-4">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout -->
|
||||
<div class="md:hidden space-y-4">
|
||||
<!-- Action Buttons Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total and Checkout Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
class="flex items-center"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Shopping Button -->
|
||||
<div v-if="allStallCarts.length > 0" class="text-center mt-8">
|
||||
<Button @click="$router.push('/market')" variant="outline" size="lg">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Store,
|
||||
ArrowRight
|
||||
} from 'lucide-vue-next'
|
||||
import CartItem from './CartItem.vue'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const allStallCarts = computed(() => marketStore.allStallCarts)
|
||||
const totalCartItems = computed(() => marketStore.totalCartItems)
|
||||
const totalCartValue = computed(() => marketStore.totalCartValue)
|
||||
|
||||
// Methods
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || 'Unknown Stall'
|
||||
}
|
||||
|
||||
const updateQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
marketStore.updateStallCartQuantity(stallId, productId, quantity)
|
||||
}
|
||||
|
||||
const removeItem = (stallId: string, productId: string) => {
|
||||
marketStore.removeFromStallCart(stallId, productId)
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
marketStore.clearStallCart(stallId)
|
||||
}
|
||||
|
||||
const clearAllCarts = () => {
|
||||
marketStore.clearAllStallCarts()
|
||||
}
|
||||
|
||||
const viewStall = (stallId: string) => {
|
||||
// TODO: Navigate to stall page
|
||||
console.log('View stall:', stallId)
|
||||
}
|
||||
|
||||
const proceedToCheckout = (stallId: string) => {
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
router.push(`/checkout/${stallId}`)
|
||||
}
|
||||
|
||||
const canProceedToCheckout = (stallId: string) => {
|
||||
const cart = marketStore.stallCarts[stallId]
|
||||
return cart && cart.products.length > 0
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -611,7 +611,7 @@ onUnmounted(() => {
|
|||
})
|
||||
|
||||
// Watch for connection state changes
|
||||
watch(isConnected, async (connected, prevConnected) => {
|
||||
watch(isConnected, async () => {
|
||||
// Note: Peer subscriptions are handled by the preloader
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { Button } from '@/components/ui/button'
|
|||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||
import { config, configUtils } from '@/lib/config'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const props = defineProps<{
|
||||
relays?: string[]
|
||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||
}>()
|
||||
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Reactive state
|
||||
const notes = ref<any[]>([])
|
||||
|
|
@ -104,10 +104,10 @@ async function loadNotes() {
|
|||
created_at: event.created_at,
|
||||
tags: event.tags || [],
|
||||
// Extract mentions from tags
|
||||
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||
// Check if it's a reply
|
||||
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
}))
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
|
|
@ -161,9 +161,9 @@ async function startRealtimeSubscription() {
|
|||
content: event.content,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags || [],
|
||||
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
}
|
||||
|
||||
// Check if note should be included
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface Emits {
|
|||
(e: 'confirm'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'destructive',
|
||||
size: 'default'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogOut, AlertTriangle } from 'lucide-vue-next'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
// Nostr event kinds for market functionality
|
||||
|
|
@ -15,7 +15,7 @@ const MARKET_EVENT_KINDS = {
|
|||
export function useMarket() {
|
||||
const nostrStore = useNostrStore()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
|
|
@ -420,23 +420,23 @@ export function useMarket() {
|
|||
}
|
||||
|
||||
// Handle order events
|
||||
const handleOrderEvent = (event: any) => {
|
||||
const handleOrderEvent = (_event: any) => {
|
||||
try {
|
||||
const orderData = JSON.parse(event.content)
|
||||
const order = {
|
||||
id: event.id,
|
||||
stall_id: orderData.stall_id || 'unknown',
|
||||
product_id: orderData.product_id || 'unknown',
|
||||
buyer_pubkey: event.pubkey,
|
||||
seller_pubkey: orderData.seller_pubkey || '',
|
||||
quantity: orderData.quantity || 1,
|
||||
total_price: orderData.total_price || 0,
|
||||
currency: orderData.currency || 'sats',
|
||||
status: orderData.status || 'pending',
|
||||
payment_request: orderData.payment_request,
|
||||
created_at: event.created_at,
|
||||
updated_at: event.created_at
|
||||
}
|
||||
// const orderData = JSON.parse(event.content)
|
||||
// const order = {
|
||||
// id: event.id,
|
||||
// stall_id: orderData.stall_id || 'unknown',
|
||||
// product_id: orderData.product_id || 'unknown',
|
||||
// buyer_pubkey: event.pubkey,
|
||||
// seller_pubkey: orderData.seller_pubkey || '',
|
||||
// quantity: orderData.quantity || 1,
|
||||
// total_price: orderData.total_price || 0,
|
||||
// currency: orderData.currency || 'sats',
|
||||
// status: orderData.status || 'pending',
|
||||
// payment_request: orderData.payment_request,
|
||||
// created_at: event.created_at,
|
||||
// updated_at: event.created_at
|
||||
// }
|
||||
|
||||
// Note: addOrder method doesn't exist in the store, so we'll just handle it silently
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|||
import { hexToBytes } from '@/lib/utils/crypto'
|
||||
import { getAuthToken } from '@/lib/config/lnbits'
|
||||
import { config } from '@/lib/config'
|
||||
import { useRelayHub } from './useRelayHub'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
// Types
|
||||
|
|
@ -66,7 +66,7 @@ const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
|
|||
|
||||
export function useNostrChat() {
|
||||
// Use the centralized relay hub
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Use the main authentication system
|
||||
const auth = useAuth()
|
||||
|
|
@ -82,9 +82,60 @@ export function useNostrChat() {
|
|||
// Track latest message timestamp for each peer (for sorting)
|
||||
const latestMessageTimestamps = ref<Map<string, number>>(new Map())
|
||||
|
||||
// Store peers globally
|
||||
// Track peers globally
|
||||
const peers = ref<any[]>([])
|
||||
|
||||
// Track malformed message IDs to prevent repeated processing attempts
|
||||
const malformedMessageIds = ref(new Set<string>())
|
||||
|
||||
// Mark a message as malformed to prevent future processing attempts
|
||||
const markMessageAsMalformed = (eventId: string) => {
|
||||
malformedMessageIds.value.add(eventId)
|
||||
// Also mark as processed to prevent retries
|
||||
processedMessageIds.value.add(eventId)
|
||||
}
|
||||
|
||||
// Clean up old malformed messages (call this periodically)
|
||||
const cleanupMalformedMessages = () => {
|
||||
// const now = Math.floor(Date.now() / 1000)
|
||||
// const maxAge = 24 * 60 * 60 // 24 hours
|
||||
|
||||
// Clear old malformed message IDs to free memory
|
||||
// This is a simple cleanup - in production you might want more sophisticated tracking
|
||||
if (malformedMessageIds.value.size > 1000) {
|
||||
console.log('Cleaning up malformed message tracking (clearing all)')
|
||||
malformedMessageIds.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up periodic cleanup
|
||||
let cleanupInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// Clean up resources
|
||||
const cleanup = () => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval)
|
||||
cleanupInterval = null
|
||||
console.log('Cleaned up malformed message tracking interval')
|
||||
}
|
||||
}
|
||||
|
||||
// Manually clear all malformed message tracking
|
||||
const clearAllMalformedMessages = () => {
|
||||
const count = malformedMessageIds.value.size
|
||||
malformedMessageIds.value.clear()
|
||||
console.log(`Cleared ${count} malformed message IDs from tracking`)
|
||||
}
|
||||
|
||||
// Get statistics about malformed messages
|
||||
const getMalformedMessageStats = () => {
|
||||
return {
|
||||
totalMalformed: malformedMessageIds.value.size,
|
||||
totalProcessed: processedMessageIds.value.size,
|
||||
malformedIds: Array.from(malformedMessageIds.value).slice(0, 10) // First 10 for debugging
|
||||
}
|
||||
}
|
||||
|
||||
// Computed - use relay hub's connection status and auth system
|
||||
const isConnected = computed(() => relayHub.isConnected.value)
|
||||
|
||||
|
|
@ -322,6 +373,11 @@ export function useNostrChat() {
|
|||
await relayHub.connect()
|
||||
}
|
||||
|
||||
// Set up periodic cleanup of malformed messages
|
||||
if (!cleanupInterval) {
|
||||
cleanupInterval = setInterval(cleanupMalformedMessages, 5 * 60 * 1000) // Every 5 minutes
|
||||
console.log('Set up periodic cleanup of malformed messages')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to relays:', error)
|
||||
|
|
@ -495,13 +551,45 @@ export function useNostrChat() {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if this message was previously identified as malformed
|
||||
if (malformedMessageIds.value.has(event.id)) {
|
||||
console.log('Skipping previously identified malformed message:', event.id)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// For NIP-04 direct messages, always use peerPubkey as the second argument
|
||||
// This is the public key of the other party in the conversation
|
||||
const isSentByMe = event.pubkey === currentUser.value.pubkey
|
||||
|
||||
// Check for malformed messages before attempting decryption
|
||||
if (typeof event.content !== 'string' || event.content.length === 0) {
|
||||
console.warn('Skipping message with invalid content format:', {
|
||||
eventId: event.id,
|
||||
contentType: typeof event.content,
|
||||
contentLength: event.content?.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for our old placeholder encryption format
|
||||
if (event.content.includes('[ENCRYPTED]') && event.content.includes('[ENCRYPTED]')) {
|
||||
console.warn('Skipping message with old placeholder encryption format:', {
|
||||
eventId: event.id,
|
||||
content: event.content.substring(0, 100) + '...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for other common malformed patterns
|
||||
if (event.content.startsWith('[') || event.content.includes('ENCRYPTED')) {
|
||||
console.warn('Skipping message with suspicious encryption format:', {
|
||||
eventId: event.id,
|
||||
content: event.content.substring(0, 100) + '...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const decryptedContent = await nip04.decrypt(
|
||||
currentUser.value.prvkey,
|
||||
peerPubkey, // Always use peerPubkey for shared secret derivation
|
||||
|
|
@ -556,7 +644,40 @@ export function useNostrChat() {
|
|||
onMessageAdded.value(peerPubkey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error)
|
||||
// Provide more specific error handling for different types of failures
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
// Check for specific error patterns that indicate malformed messages
|
||||
if (errorMessage.includes('join.decode') || errorMessage.includes('input should be string')) {
|
||||
console.warn('Skipping malformed message (invalid NIP-04 format):', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage,
|
||||
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
||||
})
|
||||
markMessageAsMalformed(event.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Invalid byte sequence') || errorMessage.includes('hex string')) {
|
||||
console.warn('Skipping message with invalid hex encoding:', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage
|
||||
})
|
||||
markMessageAsMalformed(event.id)
|
||||
return
|
||||
}
|
||||
|
||||
// For other decryption errors, log with more context
|
||||
console.error('Failed to decrypt message:', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage,
|
||||
contentType: typeof event.content,
|
||||
contentLength: event.content?.length,
|
||||
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +915,12 @@ export function useNostrChat() {
|
|||
subscribeToAllPeersForNotifications,
|
||||
currentUser,
|
||||
hasNostrKeys,
|
||||
getNostrKeyStatus
|
||||
getNostrKeyStatus,
|
||||
markMessageAsMalformed,
|
||||
cleanupMalformedMessages,
|
||||
clearAllMalformedMessages, // Add the new function to the return object
|
||||
cleanup, // Add the cleanup function to the return object
|
||||
getMalformedMessageStats // Add the new function to the return object
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
248
src/composables/useNostrOrders.ts
Normal file
248
src/composables/useNostrOrders.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { ref, computed, readonly } from 'vue'
|
||||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { hexToBytes } from '@/lib/utils/crypto'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export function useNostrOrders() {
|
||||
// State
|
||||
const isPublishing = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const publishedEvents = ref<Record<string, string>>({}) // orderId -> eventId
|
||||
|
||||
// Computed
|
||||
const isReady = computed(() => {
|
||||
return auth.isAuthenticated.value &&
|
||||
!!auth.currentUser.value?.pubkey &&
|
||||
!!auth.currentUser.value?.prvkey
|
||||
})
|
||||
|
||||
const currentUserPubkey = computed(() => auth.currentUser.value?.pubkey || '')
|
||||
const currentUserPrvkey = computed(() => auth.currentUser.value?.prvkey || '')
|
||||
|
||||
// Methods
|
||||
const validateAuth = (): { valid: boolean; error?: string } => {
|
||||
if (!auth.isAuthenticated.value) {
|
||||
return { valid: false, error: 'User not authenticated' }
|
||||
}
|
||||
|
||||
if (!currentUserPubkey.value) {
|
||||
return { valid: false, error: 'User public key not available' }
|
||||
}
|
||||
|
||||
if (!currentUserPrvkey.value) {
|
||||
return { valid: false, error: 'User private key not available' }
|
||||
}
|
||||
|
||||
// Validate key formats
|
||||
if (currentUserPubkey.value.length !== 64) {
|
||||
return { valid: false, error: 'Invalid public key format' }
|
||||
}
|
||||
|
||||
if (currentUserPrvkey.value.length !== 64) {
|
||||
return { valid: false, error: 'Invalid private key format' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const createEventTemplate = (recipientPubkey: string, content: string): EventTemplate => {
|
||||
return {
|
||||
kind: 4, // Encrypted Direct Message
|
||||
tags: [['p', recipientPubkey]], // Recipient tag
|
||||
content: content,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const encryptOrderContent = async (order: Order, recipientPubkey: string): Promise<string> => {
|
||||
try {
|
||||
console.log('Encrypting order content:', {
|
||||
orderId: order.id,
|
||||
recipientPubkey,
|
||||
hasPrivateKey: !!currentUserPrvkey.value,
|
||||
privateKeyLength: currentUserPrvkey.value?.length
|
||||
})
|
||||
|
||||
// Validate keys
|
||||
if (!currentUserPrvkey.value || !recipientPubkey) {
|
||||
throw new Error('Missing private key or recipient public key')
|
||||
}
|
||||
|
||||
if (currentUserPrvkey.value.length !== 64) {
|
||||
throw new Error(`Invalid private key length: ${currentUserPrvkey.value.length} (expected 64)`)
|
||||
}
|
||||
|
||||
if (recipientPubkey.length !== 64) {
|
||||
throw new Error(`Invalid recipient public key length: ${recipientPubkey.length} (expected 64)`)
|
||||
}
|
||||
|
||||
// Create the order payload
|
||||
const orderPayload = {
|
||||
type: 'market_order',
|
||||
orderId: order.id,
|
||||
items: order.items,
|
||||
contactInfo: order.contactInfo,
|
||||
shippingZone: order.shippingZone,
|
||||
paymentMethod: order.paymentMethod,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
createdAt: order.createdAt,
|
||||
buyerPubkey: order.buyerPubkey
|
||||
}
|
||||
|
||||
// Convert to JSON string
|
||||
const orderJson = JSON.stringify(orderPayload)
|
||||
console.log('Order payload created:', orderPayload)
|
||||
|
||||
// Encrypt the order content using NIP-04
|
||||
const encryptedContent = await nip04.encrypt(
|
||||
hexToBytes(currentUserPrvkey.value),
|
||||
recipientPubkey,
|
||||
orderJson
|
||||
)
|
||||
|
||||
console.log('Order content encrypted successfully:', {
|
||||
originalLength: orderJson.length,
|
||||
encryptedLength: encryptedContent.length,
|
||||
encryptedPreview: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return encryptedContent
|
||||
} catch (error) {
|
||||
console.error('Failed to encrypt order content:', error)
|
||||
throw new Error('Failed to encrypt order content')
|
||||
}
|
||||
}
|
||||
|
||||
const publishOrderEvent = async (order: Order, recipientPubkey: string): Promise<{ id: string; sig: string }> => {
|
||||
try {
|
||||
// Validate authentication
|
||||
const authValidation = validateAuth()
|
||||
if (!authValidation.valid) {
|
||||
throw new Error(authValidation.error)
|
||||
}
|
||||
|
||||
// Set publishing state
|
||||
isPublishing.value = true
|
||||
lastError.value = null
|
||||
|
||||
// Encrypt the order content
|
||||
const encryptedContent = await encryptOrderContent(order, recipientPubkey)
|
||||
|
||||
// Create event template
|
||||
const eventTemplate = createEventTemplate(recipientPubkey, encryptedContent)
|
||||
|
||||
// Finalize the event (sign and generate ID)
|
||||
const event = finalizeEvent(eventTemplate, hexToBytes(currentUserPrvkey.value))
|
||||
|
||||
// Publish via relay hub
|
||||
await relayHub.publishEvent(event)
|
||||
|
||||
// Store the published event
|
||||
publishedEvents.value[order.id] = event.id
|
||||
|
||||
console.log('Order event published successfully:', {
|
||||
orderId: order.id,
|
||||
eventId: event.id,
|
||||
recipient: recipientPubkey,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
return { id: event.id, sig: event.sig }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
lastError.value = errorMessage
|
||||
console.error('Failed to publish order event:', error)
|
||||
throw new Error(`Failed to publish order: ${errorMessage}`)
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPublishedEventId = (orderId: string): string | undefined => {
|
||||
return publishedEvents.value[orderId]
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
isPublishing.value = false
|
||||
lastError.value = null
|
||||
publishedEvents.value = {}
|
||||
}
|
||||
|
||||
const testEncryption = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!isReady.value) {
|
||||
console.log('Nostr not ready for testing')
|
||||
return false
|
||||
}
|
||||
|
||||
const testMessage = 'Hello, this is a test message for NIP-04 encryption!'
|
||||
const testRecipient = currentUserPubkey.value // Encrypt to ourselves for testing
|
||||
|
||||
console.log('Testing NIP-04 encryption with:', {
|
||||
message: testMessage,
|
||||
recipient: testRecipient,
|
||||
sender: currentUserPubkey.value
|
||||
})
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await nip04.encrypt(
|
||||
hexToBytes(currentUserPrvkey.value),
|
||||
testRecipient,
|
||||
testMessage
|
||||
)
|
||||
|
||||
console.log('Test message encrypted:', encrypted)
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await nip04.decrypt(
|
||||
currentUserPrvkey.value,
|
||||
currentUserPubkey.value,
|
||||
encrypted
|
||||
)
|
||||
|
||||
console.log('Test message decrypted:', decrypted)
|
||||
|
||||
const success = decrypted === testMessage
|
||||
console.log('NIP-04 test result:', success ? 'PASSED' : 'FAILED')
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error('NIP-04 test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isPublishing: readonly(isPublishing),
|
||||
lastError: readonly(lastError),
|
||||
publishedEvents: readonly(publishedEvents),
|
||||
|
||||
// Computed
|
||||
isReady,
|
||||
currentUserPubkey,
|
||||
currentUserPrvkey,
|
||||
|
||||
// Methods
|
||||
validateAuth,
|
||||
createEventTemplate,
|
||||
encryptOrderContent,
|
||||
publishOrderEvent,
|
||||
getPublishedEventId,
|
||||
clearError,
|
||||
reset,
|
||||
testEncryption
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrOrders = useNostrOrders()
|
||||
198
src/composables/useNostrclientHub.ts
Normal file
198
src/composables/useNostrclientHub.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { nostrclientHub, type SubscriptionConfig } from '../lib/nostr/nostrclientHub'
|
||||
|
||||
export function useNostrclientHub() {
|
||||
// Reactive state
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
|
||||
const error = ref<Error | null>(null)
|
||||
const activeSubscriptions = ref<Set<string>>(new Set())
|
||||
|
||||
// Reactive counts
|
||||
const totalSubscriptionCount = ref(0)
|
||||
const subscriptionDetails = ref<Array<{ id: string; filters: any[] }>>([])
|
||||
|
||||
// Computed properties
|
||||
const connectionHealth = computed(() => {
|
||||
return isConnected.value ? 100 : 0
|
||||
})
|
||||
|
||||
// Initialize nostrclient hub
|
||||
const initialize = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
console.log('🔧 NostrclientHub: Initializing...')
|
||||
await nostrclientHub.initialize()
|
||||
console.log('🔧 NostrclientHub: Initialization successful')
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
console.log('🔧 NostrclientHub: Connection status set to connected')
|
||||
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to initialize NostrclientHub')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
console.error('🔧 NostrclientHub: Failed to initialize:', errorObj)
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to nostrclient
|
||||
const connect = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
await nostrclientHub.connect()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to connect')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from nostrclient
|
||||
const disconnect = (): void => {
|
||||
nostrclientHub.disconnect()
|
||||
connectionStatus.value = 'disconnected'
|
||||
isConnected.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
const subscribe = (config: SubscriptionConfig): (() => void) => {
|
||||
try {
|
||||
const unsubscribe = nostrclientHub.subscribe(config)
|
||||
activeSubscriptions.value.add(config.id)
|
||||
|
||||
// Update reactive state
|
||||
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
|
||||
subscriptionDetails.value = nostrclientHub.subscriptionDetails
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
activeSubscriptions.value.delete(config.id)
|
||||
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
|
||||
subscriptionDetails.value = nostrclientHub.subscriptionDetails
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to subscribe:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Publish an event
|
||||
const publishEvent = async (event: any): Promise<void> => {
|
||||
try {
|
||||
await nostrclientHub.publishEvent(event)
|
||||
} catch (err) {
|
||||
console.error('Failed to publish event:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Query events
|
||||
const queryEvents = async (filters: any[]): Promise<any[]> => {
|
||||
try {
|
||||
return await nostrclientHub.queryEvents(filters)
|
||||
} catch (err) {
|
||||
console.error('Failed to query events:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
const setupEventListeners = () => {
|
||||
nostrclientHub.on('connected', () => {
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
connectionStatus.value = 'connected'
|
||||
error.value = null
|
||||
})
|
||||
|
||||
nostrclientHub.on('disconnected', () => {
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
})
|
||||
|
||||
nostrclientHub.on('error', (err) => {
|
||||
error.value = err
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('connectionError', (err) => {
|
||||
error.value = err
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('maxReconnectionAttemptsReached', () => {
|
||||
error.value = new Error('Max reconnection attempts reached')
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('event', ({ subscriptionId, event }) => {
|
||||
console.log('Received event for subscription:', subscriptionId, event.id)
|
||||
})
|
||||
|
||||
nostrclientHub.on('eose', ({ subscriptionId }) => {
|
||||
console.log('EOSE received for subscription:', subscriptionId)
|
||||
})
|
||||
|
||||
nostrclientHub.on('notice', ({ message }) => {
|
||||
console.log('Notice from nostrclient:', message)
|
||||
})
|
||||
|
||||
nostrclientHub.on('eventPublished', ({ eventId }) => {
|
||||
console.log('Event published successfully:', eventId)
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
const cleanup = () => {
|
||||
nostrclientHub.removeAllListeners()
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected: readonly(isConnected),
|
||||
isConnecting: readonly(isConnecting),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
error: readonly(error),
|
||||
activeSubscriptions: readonly(activeSubscriptions),
|
||||
totalSubscriptionCount: readonly(totalSubscriptionCount),
|
||||
subscriptionDetails: readonly(subscriptionDetails),
|
||||
|
||||
// Computed
|
||||
connectionHealth: readonly(connectionHealth),
|
||||
|
||||
// Methods
|
||||
initialize,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
publishEvent,
|
||||
queryEvents,
|
||||
|
||||
// Internal
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
585
src/composables/useOrderEvents.ts
Normal file
585
src/composables/useOrderEvents.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
import { nip04 } from 'nostr-tools'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { config } from '@/lib/config'
|
||||
import type { Order, OrderStatus } from '@/stores/market'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
// Order event types based on NIP-69 and nostrmarket patterns
|
||||
export enum OrderEventType {
|
||||
CUSTOMER_ORDER = 'customer_order',
|
||||
PAYMENT_REQUEST = 'payment_request',
|
||||
ORDER_PAID = 'order_paid',
|
||||
ORDER_SHIPPED = 'order_shipped',
|
||||
ORDER_DELIVERED = 'order_delivered',
|
||||
ORDER_CANCELLED = 'order_cancelled',
|
||||
INVOICE_GENERATED = 'invoice_generated'
|
||||
}
|
||||
|
||||
export interface OrderEvent {
|
||||
type: OrderEventType
|
||||
orderId: string
|
||||
data: any
|
||||
timestamp: number
|
||||
senderPubkey: string
|
||||
}
|
||||
|
||||
export interface PaymentRequestEvent {
|
||||
type: OrderEventType.PAYMENT_REQUEST
|
||||
orderId: string
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
memo: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface OrderStatusEvent {
|
||||
type: OrderEventType.ORDER_PAID | OrderEventType.ORDER_SHIPPED | OrderEventType.ORDER_DELIVERED
|
||||
orderId: string
|
||||
status: OrderStatus
|
||||
timestamp: number
|
||||
additionalData?: any
|
||||
}
|
||||
|
||||
export function useOrderEvents() {
|
||||
const relayHub = relayHubComposable
|
||||
const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// State
|
||||
const isSubscribed = ref(false)
|
||||
const lastEventTimestamp = ref(0)
|
||||
const processedEventIds = ref(new Set<string>())
|
||||
const subscriptionId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const currentUserPubkey = computed(() => auth.currentUser?.value?.pubkey)
|
||||
const isReady = computed(() => {
|
||||
const isAuth = auth.isAuthenticated
|
||||
const isConnected = relayHub.isConnected.value
|
||||
const hasPubkey = !!currentUserPubkey.value
|
||||
|
||||
console.log('OrderEvents isReady check:', { isAuth, isConnected, hasPubkey })
|
||||
return isAuth && isConnected && hasPubkey
|
||||
})
|
||||
|
||||
// Subscribe to order events
|
||||
const subscribeToOrderEvents = async () => {
|
||||
console.log('subscribeToOrderEvents called with:', {
|
||||
isReady: isReady.value,
|
||||
isSubscribed: isSubscribed.value,
|
||||
currentUserPubkey: currentUserPubkey.value,
|
||||
relayHubConnected: relayHub.isConnected.value,
|
||||
authStatus: auth.isAuthenticated
|
||||
})
|
||||
|
||||
if (!isReady.value || isSubscribed.value) {
|
||||
console.warn('Cannot subscribe to order events: not ready or already subscribed', {
|
||||
isReady: isReady.value,
|
||||
isSubscribed: isSubscribed.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Subscribing to order events for user:', currentUserPubkey.value)
|
||||
|
||||
// Subscribe to direct messages (kind 4) that contain order information
|
||||
const filters = [
|
||||
{
|
||||
kinds: [4], // NIP-04 encrypted direct messages
|
||||
'#p': [currentUserPubkey.value].filter(Boolean) as string[], // Messages to us, filter out undefined
|
||||
since: lastEventTimestamp.value
|
||||
}
|
||||
]
|
||||
|
||||
console.log('Using filters:', filters)
|
||||
|
||||
const unsubscribe = relayHub.subscribe({
|
||||
id: `order-events-${currentUserPubkey.value}-${Date.now()}`,
|
||||
filters,
|
||||
relays: config.market.supportedRelays,
|
||||
onEvent: (event: any) => {
|
||||
console.log('Received event in order subscription:', event.id)
|
||||
handleOrderEvent(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log('Order events subscription EOSE')
|
||||
}
|
||||
})
|
||||
|
||||
subscriptionId.value = `order-events-${currentUserPubkey.value}-${Date.now()}`
|
||||
isSubscribed.value = true
|
||||
|
||||
console.log('Successfully subscribed to order events with ID:', subscriptionId.value)
|
||||
return unsubscribe
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to order events:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming order events
|
||||
const handleOrderEvent = async (event: any) => {
|
||||
if (!auth.currentUser?.value?.prvkey) {
|
||||
console.warn('Cannot decrypt order event: no private key available')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we've already processed this event
|
||||
if (processedEventIds.value.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt the message content
|
||||
const decryptedContent = await nip04.decrypt(
|
||||
auth.currentUser.value.prvkey,
|
||||
event.pubkey, // Sender's pubkey
|
||||
event.content
|
||||
)
|
||||
|
||||
// Parse the decrypted content
|
||||
const orderEvent = JSON.parse(decryptedContent)
|
||||
|
||||
console.log('Received order event:', {
|
||||
eventId: event.id,
|
||||
type: orderEvent.type,
|
||||
orderId: orderEvent.orderId,
|
||||
sender: event.pubkey
|
||||
})
|
||||
|
||||
// Handle nostrmarket protocol messages
|
||||
if (orderEvent.type === 0 || orderEvent.type === 1 || orderEvent.type === 2) {
|
||||
await handleNostrmarketMessage(orderEvent, event.pubkey)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the order event based on type
|
||||
await processOrderEvent(orderEvent, event.pubkey)
|
||||
|
||||
// Mark as processed
|
||||
processedEventIds.value.add(event.id)
|
||||
lastEventTimestamp.value = Math.max(lastEventTimestamp.value, event.created_at)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to process order event:', {
|
||||
eventId: event.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nostrmarket protocol messages (type 0, 1, 2)
|
||||
const handleNostrmarketMessage = async (message: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Processing nostrmarket message:', {
|
||||
type: message.type,
|
||||
orderId: message.id,
|
||||
sender: senderPubkey
|
||||
})
|
||||
|
||||
// Import nostrmarket service
|
||||
const { nostrmarketService } = await import('@/lib/services/nostrmarketService')
|
||||
|
||||
switch (message.type) {
|
||||
case 0:
|
||||
// Customer order - this should be handled by the merchant side
|
||||
console.log('Received customer order (type 0) - this should be handled by merchant')
|
||||
break
|
||||
|
||||
case 1:
|
||||
// Payment request from merchant
|
||||
console.log('Received payment request from merchant')
|
||||
await nostrmarketService.handlePaymentRequest(message)
|
||||
break
|
||||
|
||||
case 2:
|
||||
// Order status update from merchant
|
||||
console.log('Received order status update from merchant')
|
||||
await nostrmarketService.handleOrderStatusUpdate(message)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('Unknown nostrmarket message type:', message.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle nostrmarket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Process incoming Nostr events
|
||||
const processOrderEvent = async (event: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Received order event:', {
|
||||
eventId: event.id || 'unknown',
|
||||
type: event.type,
|
||||
orderId: event.orderId,
|
||||
sender: senderPubkey
|
||||
})
|
||||
|
||||
// Only process events that have the required market order structure
|
||||
if (!event.type || event.type !== 'market_order') {
|
||||
console.log('Skipping non-market order event:', event.type)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that this is actually a market order event
|
||||
if (!event.orderId || !event.items || !Array.isArray(event.items)) {
|
||||
console.log('Skipping invalid market order event - missing required fields')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Processing market order:', event)
|
||||
|
||||
// Check if this order already exists - use the orderId as the primary key
|
||||
const existingOrder = Object.values(marketStore.orders).find(
|
||||
order => order.id === event.orderId
|
||||
)
|
||||
|
||||
if (existingOrder) {
|
||||
console.log('Order already exists, updating with new information:', existingOrder.id)
|
||||
|
||||
// Update the existing order with any new information
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// If there's invoice information, update it
|
||||
if (event.lightningInvoice) {
|
||||
updatedOrder.lightningInvoice = event.lightningInvoice
|
||||
updatedOrder.paymentHash = event.paymentHash
|
||||
updatedOrder.paymentStatus = event.paymentStatus || 'pending'
|
||||
updatedOrder.paymentRequest = event.paymentRequest
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(existingOrder.id, updatedOrder)
|
||||
|
||||
console.log('Updated existing order:', {
|
||||
orderId: existingOrder.id,
|
||||
hasInvoice: !!updatedOrder.lightningInvoice,
|
||||
paymentStatus: updatedOrder.paymentStatus
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create a basic order object from the event data
|
||||
const orderData: Partial<Order> = {
|
||||
id: event.orderId,
|
||||
nostrEventId: event.id || 'unknown',
|
||||
buyerPubkey: senderPubkey,
|
||||
sellerPubkey: event.sellerPubkey || '',
|
||||
items: event.items || [],
|
||||
total: event.total || 0,
|
||||
currency: event.currency || 'sat',
|
||||
status: 'pending' as OrderStatus,
|
||||
createdAt: event.createdAt || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
// Add invoice details if present
|
||||
...(event.lightningInvoice && {
|
||||
lightningInvoice: {
|
||||
checking_id: event.lightningInvoice.checking_id || event.lightningInvoice.payment_hash || '',
|
||||
payment_hash: event.lightningInvoice.payment_hash || '',
|
||||
wallet_id: event.lightningInvoice.wallet_id || '',
|
||||
amount: event.lightningInvoice.amount || 0,
|
||||
fee: event.lightningInvoice.fee || 0,
|
||||
bolt11: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
status: 'pending',
|
||||
memo: event.lightningInvoice.memo || '',
|
||||
expiry: event.lightningInvoice.expiry || '',
|
||||
preimage: event.lightningInvoice.preimage || '',
|
||||
extra: event.lightningInvoice.extra || {},
|
||||
created_at: event.lightningInvoice.created_at || '',
|
||||
updated_at: event.lightningInvoice.updated_at || ''
|
||||
},
|
||||
paymentHash: event.lightningInvoice.payment_hash || '',
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Create the order using the store method
|
||||
const order = marketStore.createOrder({
|
||||
id: event.id,
|
||||
cartId: event.id,
|
||||
stallId: 'unknown', // We'll need to determine this from the items
|
||||
buyerPubkey: senderPubkey,
|
||||
sellerPubkey: '', // Will be set when we know the merchant
|
||||
status: 'pending',
|
||||
items: Array.from(orderData.items || []), // Convert readonly to mutable
|
||||
contactInfo: orderData.contactInfo || {},
|
||||
shippingZone: orderData.shippingZone || {
|
||||
id: 'online',
|
||||
name: 'Online',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
description: 'Online delivery'
|
||||
},
|
||||
paymentMethod: 'lightning',
|
||||
subtotal: 0,
|
||||
shippingCost: 0,
|
||||
total: 0,
|
||||
currency: 'sat',
|
||||
originalOrderId: event.id
|
||||
})
|
||||
|
||||
console.log('Created order from market event:', {
|
||||
orderId: order.id,
|
||||
total: order.total,
|
||||
status: order.status
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle market order:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment request events
|
||||
const handlePaymentRequest = async (event: PaymentRequestEvent, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Payment request received for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order with payment request (excluding readonly items)
|
||||
const { items, ...orderWithoutItems } = order
|
||||
const updatedOrder = {
|
||||
...orderWithoutItems,
|
||||
paymentRequest: event.paymentRequest,
|
||||
paymentStatus: 'pending' as const,
|
||||
updatedAt: Date.now() / 1000
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(event.orderId, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: event.orderId,
|
||||
amount: event.amount,
|
||||
currency: event.currency
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order status updates
|
||||
const handleOrderStatusUpdate = async (event: OrderStatusEvent, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Status update received for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status
|
||||
marketStore.updateOrderStatus(event.orderId, event.status)
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: event.orderId,
|
||||
newStatus: event.status,
|
||||
timestamp: event.timestamp
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle order status update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle invoice generation events
|
||||
const handleInvoiceGenerated = async (event: any, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Invoice generated for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order with invoice details (excluding readonly items)
|
||||
const { items, ...orderWithoutItems } = order
|
||||
const updatedOrder = {
|
||||
...orderWithoutItems,
|
||||
lightningInvoice: {
|
||||
payment_hash: event.paymentHash,
|
||||
payment_request: event.paymentRequest,
|
||||
amount: event.amount,
|
||||
memo: event.memo,
|
||||
expiry: event.expiresAt,
|
||||
created_at: event.timestamp,
|
||||
status: 'pending' as const
|
||||
},
|
||||
paymentHash: event.paymentHash,
|
||||
paymentStatus: 'pending' as const,
|
||||
updatedAt: Date.now() / 1000
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(event.orderId, updatedOrder)
|
||||
|
||||
console.log('Order updated with invoice details:', {
|
||||
orderId: event.orderId,
|
||||
paymentHash: event.paymentHash,
|
||||
amount: event.amount
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle invoice generation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle market order events (new orders)
|
||||
const handleMarketOrder = async (event: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Processing market order:', event)
|
||||
|
||||
// Check if this order already exists
|
||||
const existingOrder = Object.values(marketStore.orders).find(
|
||||
order => order.id === event.orderId || order.nostrEventId === event.id
|
||||
)
|
||||
|
||||
if (existingOrder) {
|
||||
console.log('Order already exists, updating with new information:', existingOrder.id)
|
||||
|
||||
// Update the existing order with any new information
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
...event,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// If there's invoice information, update it
|
||||
if (event.lightningInvoice) {
|
||||
updatedOrder.lightningInvoice = event.lightningInvoice
|
||||
updatedOrder.paymentHash = event.paymentHash
|
||||
updatedOrder.paymentStatus = event.paymentStatus || 'pending'
|
||||
updatedOrder.paymentRequest = event.paymentRequest
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(existingOrder.id, updatedOrder)
|
||||
|
||||
console.log('Updated existing order:', {
|
||||
orderId: existingOrder.id,
|
||||
hasInvoice: !!updatedOrder.lightningInvoice,
|
||||
paymentStatus: updatedOrder.paymentStatus
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create a basic order object from the event data
|
||||
const orderData: Partial<Order> = {
|
||||
id: event.orderId,
|
||||
nostrEventId: event.id,
|
||||
buyerPubkey: event.pubkey || '',
|
||||
sellerPubkey: event.sellerPubkey || '',
|
||||
items: event.items || [],
|
||||
total: event.total || 0,
|
||||
currency: event.currency || 'sat',
|
||||
status: 'pending' as OrderStatus,
|
||||
createdAt: event.createdAt || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
// Add invoice details if present
|
||||
...(event.lightningInvoice && {
|
||||
lightningInvoice: {
|
||||
checking_id: event.lightningInvoice.checking_id || event.lightningInvoice.payment_hash || '',
|
||||
payment_hash: event.lightningInvoice.payment_hash || '',
|
||||
wallet_id: event.lightningInvoice.wallet_id || '',
|
||||
amount: event.lightningInvoice.amount || 0,
|
||||
fee: event.lightningInvoice.fee || 0,
|
||||
bolt11: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
status: 'pending',
|
||||
memo: event.lightningInvoice.memo || '',
|
||||
expiry: event.lightningInvoice.expiry || '',
|
||||
preimage: event.lightningInvoice.preimage || '',
|
||||
extra: event.lightningInvoice.extra || {},
|
||||
created_at: event.lightningInvoice.created_at || '',
|
||||
updated_at: event.lightningInvoice.updated_at || ''
|
||||
},
|
||||
paymentHash: event.lightningInvoice.payment_hash || '',
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Create the order using the store method
|
||||
const order = marketStore.createOrder(orderData)
|
||||
|
||||
console.log('Created order from market event:', {
|
||||
orderId: order.id,
|
||||
total: order.total,
|
||||
status: order.status
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle market order:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start listening for order events
|
||||
const startListening = async () => {
|
||||
if (!isReady.value) {
|
||||
console.warn('Cannot start listening: not ready')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await subscribeToOrderEvents()
|
||||
console.log('Started listening for order events')
|
||||
} catch (error) {
|
||||
console.error('Failed to start listening for order events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop listening for order events
|
||||
const stopListening = () => {
|
||||
if (subscriptionId.value) {
|
||||
// Use the cleanup method from relayHub
|
||||
relayHub.cleanup()
|
||||
subscriptionId.value = null
|
||||
}
|
||||
isSubscribed.value = false
|
||||
console.log('Stopped listening for order events')
|
||||
}
|
||||
|
||||
// Clean up old processed events
|
||||
const cleanupProcessedEvents = () => {
|
||||
// const now = Date.now()
|
||||
// const cutoff = now - (24 * 60 * 60 * 1000) // 24 hours ago
|
||||
|
||||
// Remove old event IDs (this is a simple cleanup, could be more sophisticated)
|
||||
if (processedEventIds.value.size > 1000) {
|
||||
processedEventIds.value.clear()
|
||||
console.log('Cleaned up processed event IDs')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isSubscribed,
|
||||
lastEventTimestamp,
|
||||
|
||||
// Methods
|
||||
startListening,
|
||||
stopListening,
|
||||
subscribeToOrderEvents,
|
||||
cleanupProcessedEvents
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const orderEvents = useOrderEvents()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { relayHub, type SubscriptionConfig, type RelayStatus } from '../lib/nostr/relayHub'
|
||||
import { config } from '../lib/config'
|
||||
|
||||
|
|
@ -31,19 +31,24 @@ export function useRelayHub() {
|
|||
error.value = null
|
||||
|
||||
// Get relay URLs from config
|
||||
const relayUrls = config.nostr.relays
|
||||
const relayUrls = config.market.supportedRelays
|
||||
console.log('🔧 RelayHub: Initializing with relay URLs:', relayUrls)
|
||||
|
||||
if (!relayUrls || relayUrls.length === 0) {
|
||||
throw new Error('No relay URLs configured')
|
||||
}
|
||||
|
||||
// Initialize the relay hub
|
||||
console.log('🔧 RelayHub: Calling relayHub.initialize...')
|
||||
await relayHub.initialize(relayUrls)
|
||||
console.log('🔧 RelayHub: Initialization successful')
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
console.log('🔧 RelayHub: Connection status set to connected')
|
||||
|
||||
|
||||
} catch (err) {
|
||||
|
|
@ -51,7 +56,7 @@ export function useRelayHub() {
|
|||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
console.error('Failed to initialize RelayHub:', errorObj)
|
||||
console.error('🔧 RelayHub: Failed to initialize RelayHub:', errorObj)
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
|
@ -128,35 +133,11 @@ export function useRelayHub() {
|
|||
}
|
||||
}
|
||||
|
||||
// Force reconnection
|
||||
const reconnect = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
await relayHub.reconnect()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to reconnect')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Get relay status
|
||||
const getRelayStatus = (url: string): RelayStatus | undefined => {
|
||||
return relayStatuses.value.find(status => status.url === url)
|
||||
}
|
||||
|
||||
// Check if a specific relay is connected
|
||||
const isRelayConnected = (url: string): boolean => {
|
||||
return relayHub.isRelayConnected(url)
|
||||
}
|
||||
|
||||
// Set up event listeners for relay hub events
|
||||
const setupEventListeners = (): void => {
|
||||
relayHub.on('connected', (count: number) => {
|
||||
|
|
@ -274,18 +255,16 @@ export function useRelayHub() {
|
|||
|
||||
return {
|
||||
// State
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
relayStatuses,
|
||||
error,
|
||||
activeSubscriptions,
|
||||
|
||||
// Computed
|
||||
connectedRelayCount,
|
||||
totalRelayCount,
|
||||
totalSubscriptionCount,
|
||||
subscriptionDetails,
|
||||
connectionHealth,
|
||||
isConnected: readonly(isConnected),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
relayStatuses: readonly(relayStatuses),
|
||||
error: readonly(error),
|
||||
activeSubscriptions: readonly(activeSubscriptions),
|
||||
connectedRelayCount: readonly(connectedRelayCount),
|
||||
totalRelayCount: readonly(totalRelayCount),
|
||||
totalSubscriptionCount: readonly(totalSubscriptionCount),
|
||||
subscriptionDetails: readonly(subscriptionDetails),
|
||||
connectionHealth: readonly(connectionHealth),
|
||||
|
||||
// Methods
|
||||
initialize,
|
||||
|
|
@ -294,9 +273,11 @@ export function useRelayHub() {
|
|||
subscribe,
|
||||
publishEvent,
|
||||
queryEvents,
|
||||
reconnect,
|
||||
getRelayStatus,
|
||||
isRelayConnected,
|
||||
getConnectionHealth: connectionHealth,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for global state
|
||||
export const relayHubComposable = useRelayHub()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ interface AppConfig {
|
|||
api: ApiConfig
|
||||
push: PushConfig
|
||||
market: MarketConfig
|
||||
nostrclient: {
|
||||
url: string
|
||||
enabled: boolean
|
||||
}
|
||||
support: {
|
||||
npub: string
|
||||
}
|
||||
|
|
@ -72,6 +76,10 @@ export const config: AppConfig = {
|
|||
lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED),
|
||||
defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat'
|
||||
},
|
||||
nostrclient: {
|
||||
url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1',
|
||||
enabled: Boolean(import.meta.env.VITE_NOSTRCLIENT_ENABLED)
|
||||
},
|
||||
support: {
|
||||
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
|
||||
}
|
||||
|
|
|
|||
334
src/lib/nostr/nostrclientHub.ts
Normal file
334
src/lib/nostr/nostrclientHub.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import type { Filter, Event } from 'nostr-tools'
|
||||
|
||||
export interface NostrclientConfig {
|
||||
url: string
|
||||
privateKey?: string // For private WebSocket endpoint
|
||||
}
|
||||
|
||||
export interface SubscriptionConfig {
|
||||
id: string
|
||||
filters: Filter[]
|
||||
onEvent?: (event: Event) => void
|
||||
onEose?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export interface RelayStatus {
|
||||
url: string
|
||||
connected: boolean
|
||||
lastSeen: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class NostrclientHub extends EventEmitter {
|
||||
private ws: WebSocket | null = null
|
||||
private config: NostrclientConfig
|
||||
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
||||
private reconnectInterval?: NodeJS.Timeout
|
||||
private reconnectAttempts = 0
|
||||
private readonly maxReconnectAttempts = 5
|
||||
private readonly reconnectDelay = 5000
|
||||
|
||||
// Connection state
|
||||
private _isConnected = false
|
||||
private _isConnecting = false
|
||||
|
||||
constructor(config: NostrclientConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._isConnected
|
||||
}
|
||||
|
||||
get isConnecting(): boolean {
|
||||
return this._isConnecting
|
||||
}
|
||||
|
||||
get totalSubscriptionCount(): number {
|
||||
return this.subscriptions.size
|
||||
}
|
||||
|
||||
get subscriptionDetails(): Array<{ id: string; filters: Filter[] }> {
|
||||
return Array.from(this.subscriptions.values()).map(sub => ({
|
||||
id: sub.id,
|
||||
filters: sub.filters
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect to nostrclient WebSocket
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the nostrclient WebSocket
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this._isConnecting || this._isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
this._isConnecting = true
|
||||
this.reconnectAttempts++
|
||||
|
||||
try {
|
||||
console.log('🔧 NostrclientHub: Connecting to nostrclient WebSocket')
|
||||
|
||||
// Determine WebSocket endpoint
|
||||
const wsUrl = this.config.privateKey
|
||||
? `${this.config.url}/${this.config.privateKey}` // Private endpoint
|
||||
: `${this.config.url}/relay` // Public endpoint
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('🔧 NostrclientHub: WebSocket connected')
|
||||
this._isConnected = true
|
||||
this._isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connected')
|
||||
|
||||
// Resubscribe to existing subscriptions
|
||||
this.resubscribeAll()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('🔧 NostrclientHub: WebSocket closed:', event.code, event.reason)
|
||||
this._isConnected = false
|
||||
this._isConnecting = false
|
||||
this.emit('disconnected', event)
|
||||
|
||||
// Schedule reconnection
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.emit('maxReconnectionAttemptsReached')
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('🔧 NostrclientHub: WebSocket error:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this._isConnecting = false
|
||||
console.error('🔧 NostrclientHub: Connection failed:', error)
|
||||
this.emit('connectionError', error)
|
||||
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the WebSocket
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectInterval) {
|
||||
clearTimeout(this.reconnectInterval)
|
||||
this.reconnectInterval = undefined
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
this._isConnected = false
|
||||
this._isConnecting = false
|
||||
this.subscriptions.clear()
|
||||
this.emit('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
subscribe(config: SubscriptionConfig): () => void {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Not connected to nostrclient')
|
||||
}
|
||||
|
||||
// Store subscription
|
||||
this.subscriptions.set(config.id, config)
|
||||
|
||||
// Send REQ message
|
||||
const reqMessage = JSON.stringify([
|
||||
'REQ',
|
||||
config.id,
|
||||
...config.filters
|
||||
])
|
||||
|
||||
this.ws?.send(reqMessage)
|
||||
console.log('🔧 NostrclientHub: Subscribed to', config.id)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.unsubscribe(config.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from events
|
||||
*/
|
||||
unsubscribe(subscriptionId: string): void {
|
||||
if (!this._isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send CLOSE message
|
||||
const closeMessage = JSON.stringify(['CLOSE', subscriptionId])
|
||||
this.ws?.send(closeMessage)
|
||||
|
||||
// Remove from subscriptions
|
||||
this.subscriptions.delete(subscriptionId)
|
||||
console.log('🔧 NostrclientHub: Unsubscribed from', subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event
|
||||
*/
|
||||
async publishEvent(event: Event): Promise<void> {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Not connected to nostrclient')
|
||||
}
|
||||
|
||||
const eventMessage = JSON.stringify(['EVENT', event])
|
||||
this.ws?.send(eventMessage)
|
||||
|
||||
console.log('🔧 NostrclientHub: Published event', event.id)
|
||||
this.emit('eventPublished', { eventId: event.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Query events (one-time fetch)
|
||||
*/
|
||||
async queryEvents(filters: Filter[]): Promise<Event[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._isConnected) {
|
||||
reject(new Error('Not connected to nostrclient'))
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = `query-${Date.now()}`
|
||||
const events: Event[] = []
|
||||
let eoseReceived = false
|
||||
|
||||
// Create temporary subscription for query
|
||||
const tempSubscription = this.subscribe({
|
||||
id: queryId,
|
||||
filters,
|
||||
onEvent: (event) => {
|
||||
events.push(event)
|
||||
},
|
||||
onEose: () => {
|
||||
eoseReceived = true
|
||||
this.unsubscribe(queryId)
|
||||
resolve(events)
|
||||
},
|
||||
onClose: () => {
|
||||
if (!eoseReceived) {
|
||||
reject(new Error('Query subscription closed unexpectedly'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (!eoseReceived) {
|
||||
tempSubscription()
|
||||
reject(new Error('Query timeout'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
private handleMessage(data: string): void {
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
|
||||
if (Array.isArray(message) && message.length >= 2) {
|
||||
const [type, subscriptionId, ...rest] = message
|
||||
|
||||
switch (type) {
|
||||
case 'EVENT':
|
||||
const event = rest[0] as Event
|
||||
const subscription = this.subscriptions.get(subscriptionId)
|
||||
if (subscription?.onEvent) {
|
||||
subscription.onEvent(event)
|
||||
}
|
||||
this.emit('event', { subscriptionId, event })
|
||||
break
|
||||
|
||||
case 'EOSE':
|
||||
const eoseSubscription = this.subscriptions.get(subscriptionId)
|
||||
if (eoseSubscription?.onEose) {
|
||||
eoseSubscription.onEose()
|
||||
}
|
||||
this.emit('eose', { subscriptionId })
|
||||
break
|
||||
|
||||
case 'NOTICE':
|
||||
console.log('🔧 NostrclientHub: Notice from relay:', rest[0])
|
||||
this.emit('notice', { message: rest[0] })
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('🔧 NostrclientHub: Unknown message type:', type)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔧 NostrclientHub: Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resubscribe to all existing subscriptions after reconnection
|
||||
*/
|
||||
private resubscribeAll(): void {
|
||||
for (const [id, config] of this.subscriptions) {
|
||||
const reqMessage = JSON.stringify([
|
||||
'REQ',
|
||||
id,
|
||||
...config.filters
|
||||
])
|
||||
this.ws?.send(reqMessage)
|
||||
}
|
||||
console.log('🔧 NostrclientHub: Resubscribed to', this.subscriptions.size, 'subscriptions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic reconnection
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectInterval) {
|
||||
clearTimeout(this.reconnectInterval)
|
||||
}
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
console.log(`🔧 NostrclientHub: Scheduling reconnection in ${delay}ms`)
|
||||
|
||||
this.reconnectInterval = setTimeout(async () => {
|
||||
await this.connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrclientHub = new NostrclientHub({
|
||||
url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1'
|
||||
})
|
||||
|
|
@ -130,6 +130,8 @@ export class RelayHub extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
console.log('🔧 RelayHub: Initializing with URLs:', relayUrls)
|
||||
|
||||
// Convert URLs to relay configs
|
||||
this.relayConfigs.clear()
|
||||
relayUrls.forEach((url, index) => {
|
||||
|
|
@ -141,12 +143,14 @@ export class RelayHub extends EventEmitter {
|
|||
})
|
||||
})
|
||||
|
||||
console.log('🔧 RelayHub: Relay configs created:', Array.from(this.relayConfigs.values()))
|
||||
|
||||
// Start connection management
|
||||
console.log('🔧 RelayHub: Starting connection...')
|
||||
await this.connect()
|
||||
this.startHealthCheck()
|
||||
this.isInitialized = true
|
||||
|
||||
|
||||
console.log('🔧 RelayHub: Initialization complete')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,22 +161,28 @@ export class RelayHub extends EventEmitter {
|
|||
throw new Error('No relay configurations found. Call initialize() first.')
|
||||
}
|
||||
|
||||
console.log('🔧 RelayHub: Connecting to', this.relayConfigs.size, 'relays')
|
||||
|
||||
try {
|
||||
this._connectionAttempts++
|
||||
|
||||
console.log('🔧 RelayHub: Connection attempt', this._connectionAttempts)
|
||||
|
||||
// Connect to relays in priority order
|
||||
const sortedRelays = Array.from(this.relayConfigs.values())
|
||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
||||
|
||||
console.log('🔧 RelayHub: Attempting connections to:', sortedRelays.map(r => r.url))
|
||||
|
||||
const connectionPromises = sortedRelays.map(async (config) => {
|
||||
try {
|
||||
console.log('🔧 RelayHub: Connecting to relay:', config.url)
|
||||
const relay = await this.pool.ensureRelay(config.url)
|
||||
this.connectedRelays.set(config.url, relay)
|
||||
console.log('🔧 RelayHub: Successfully connected to:', config.url)
|
||||
|
||||
return { url: config.url, success: true }
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to relay ${config.url}:`, error)
|
||||
console.error(`🔧 RelayHub: Failed to connect to relay ${config.url}:`, error)
|
||||
return { url: config.url, success: false, error }
|
||||
}
|
||||
})
|
||||
|
|
@ -182,25 +192,34 @@ export class RelayHub extends EventEmitter {
|
|||
result => result.status === 'fulfilled' && result.value.success
|
||||
)
|
||||
|
||||
console.log('🔧 RelayHub: Connection results:', {
|
||||
total: results.length,
|
||||
successful: successfulConnections.length,
|
||||
failed: results.length - successfulConnections.length
|
||||
})
|
||||
|
||||
if (successfulConnections.length > 0) {
|
||||
this._isConnected = true
|
||||
this._connectionAttempts = 0
|
||||
console.log('🔧 RelayHub: Connection successful, connected to', successfulConnections.length, 'relays')
|
||||
this.emit('connected', successfulConnections.length)
|
||||
|
||||
} else {
|
||||
console.error('🔧 RelayHub: Failed to connect to any relay')
|
||||
throw new Error('Failed to connect to any relay')
|
||||
}
|
||||
} catch (error) {
|
||||
this._isConnected = false
|
||||
console.error('🔧 RelayHub: Connection failed with error:', error)
|
||||
this.emit('connectionError', error)
|
||||
console.error('Connection failed:', error)
|
||||
|
||||
// Schedule reconnection if we haven't exceeded max attempts
|
||||
if (this._connectionAttempts < this.maxReconnectAttempts) {
|
||||
console.log('🔧 RelayHub: Scheduling reconnection attempt', this._connectionAttempts + 1)
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.emit('maxReconnectAttemptsReached')
|
||||
console.error('Max reconnection attempts reached')
|
||||
this.emit('maxReconnectionAttemptsReached')
|
||||
console.error('🔧 RelayHub: Max reconnection attempts reached')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/lib/nostr/utils.ts
Normal file
18
src/lib/nostr/utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Helper function to convert bech32 to hex
|
||||
export function bech32ToHex(bech32Key: string): string {
|
||||
if (bech32Key.startsWith('npub1') || bech32Key.startsWith('nsec1')) {
|
||||
// Import bech32 conversion dynamically to avoid bundling issues
|
||||
const { bech32Decode, convertbits } = require('bech32')
|
||||
const [, data] = bech32Decode(bech32Key)
|
||||
if (!data) {
|
||||
throw new Error(`Invalid bech32 key: ${bech32Key}`)
|
||||
}
|
||||
const converted = convertbits(data, 5, 8, false)
|
||||
if (!converted) {
|
||||
throw new Error(`Failed to convert bech32 key: ${bech32Key}`)
|
||||
}
|
||||
return Buffer.from(converted).toString('hex')
|
||||
}
|
||||
// Already hex format
|
||||
return bech32Key
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Notification manager for push notifications
|
||||
import { pushService, type NotificationPayload } from './push'
|
||||
import { configUtils } from '@/lib/config'
|
||||
// import type { NotificationPayload } from './push'
|
||||
|
||||
|
||||
export interface NotificationOptions {
|
||||
enabled: boolean
|
||||
|
|
@ -69,19 +69,19 @@ export class NotificationManager {
|
|||
throw new Error('Notifications are disabled')
|
||||
}
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
title: '🧪 Test Notification',
|
||||
body: 'This is a test notification from Ario',
|
||||
tag: 'test',
|
||||
icon: '/apple-touch-icon.png',
|
||||
badge: '/apple-touch-icon.png',
|
||||
data: {
|
||||
url: window.location.origin,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
// const payload: NotificationPayload = {
|
||||
// title: '🧪 Test Notification',
|
||||
// body: 'This is a test notification from Ario',
|
||||
// tag: 'test',
|
||||
// icon: '/apple-touch-icon.png',
|
||||
// badge: '/apple-touch-icon.png',
|
||||
// data: {
|
||||
// url: window.location.origin,
|
||||
// timestamp: Date.now()
|
||||
// }
|
||||
// }
|
||||
|
||||
await pushService.sendNotification(payload)
|
||||
// await pushService.sendNotification(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
189
src/lib/services/invoiceService.ts
Normal file
189
src/lib/services/invoiceService.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { getApiUrl } from '@/lib/config/lnbits'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export interface LightningInvoice {
|
||||
checking_id: string
|
||||
payment_hash: string
|
||||
wallet_id: string
|
||||
amount: number
|
||||
fee: number
|
||||
bolt11: string // This is the payment request/invoice
|
||||
status: string
|
||||
memo?: string
|
||||
expiry?: string
|
||||
preimage?: string
|
||||
extra?: Record<string, any>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
amount: number
|
||||
memo: string
|
||||
unit?: 'sat' | 'btc'
|
||||
expiry?: number
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amount_paid: number
|
||||
paid_at?: number
|
||||
payment_hash: string
|
||||
}
|
||||
|
||||
class InvoiceService {
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
// Use the payments endpoint for invoice creation
|
||||
this.baseUrl = getApiUrl('/payments')
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
adminKey: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Construct the URL - for payments, we just append the endpoint
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
console.log('Invoice Service Request:', { url, endpoint })
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': adminKey, // Use the wallet's admin key
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Invoice Service Error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorText,
|
||||
url
|
||||
})
|
||||
throw new Error(`Invoice request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice for an order
|
||||
*/
|
||||
async createInvoice(order: Order, adminKey: string, extra?: Record<string, any>): Promise<LightningInvoice> {
|
||||
const invoiceData: CreateInvoiceRequest = {
|
||||
amount: order.total,
|
||||
unit: 'sat',
|
||||
memo: `Order ${order.id} - ${order.items.length} items`,
|
||||
expiry: 3600, // 1 hour
|
||||
extra: {
|
||||
tag: 'nostrmarket', // Use nostrmarket tag for compatibility
|
||||
order_id: extra?.order_id || order.id, // Use passed order_id or fallback to order.id
|
||||
merchant_pubkey: extra?.merchant_pubkey || order.sellerPubkey, // Use passed merchant_pubkey or fallback
|
||||
...extra // Allow additional metadata to be passed in
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Log the exact data being sent to LNBits
|
||||
const requestBody = {
|
||||
out: false, // Incoming payment
|
||||
...invoiceData
|
||||
}
|
||||
|
||||
console.log('Sending invoice request to LNBits:', {
|
||||
url: `${this.baseUrl}`,
|
||||
body: requestBody,
|
||||
extra: requestBody.extra
|
||||
})
|
||||
|
||||
// Use the correct LNBits payments endpoint
|
||||
const response = await this.request<LightningInvoice>('', adminKey, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
console.log('Full LNBits response:', response)
|
||||
console.log('Response type:', typeof response)
|
||||
console.log('Response keys:', Object.keys(response))
|
||||
|
||||
// Check if we have the expected fields
|
||||
if (!response.bolt11) {
|
||||
console.error('Missing bolt11 in response:', response)
|
||||
throw new Error('Invalid invoice response: missing bolt11')
|
||||
}
|
||||
|
||||
console.log('Lightning invoice created with nostrmarket tag:', {
|
||||
orderId: order.id,
|
||||
paymentHash: response.payment_hash,
|
||||
amount: response.amount,
|
||||
paymentRequest: response.bolt11.substring(0, 50) + '...',
|
||||
extra: invoiceData.extra
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status of an invoice
|
||||
*/
|
||||
async checkPaymentStatus(paymentHash: string, adminKey: string): Promise<PaymentStatus> {
|
||||
try {
|
||||
const response = await this.request<PaymentStatus>(`/${paymentHash}`, adminKey, {})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to check payment status:', error)
|
||||
throw new Error('Failed to check payment status')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all payments for a wallet
|
||||
*/
|
||||
async getWalletPayments(adminKey: string, limit: number = 100): Promise<PaymentStatus[]> {
|
||||
try {
|
||||
const response = await this.request<PaymentStatus[]>(`?limit=${limit}`, adminKey, {})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to get wallet payments:', error)
|
||||
throw new Error('Failed to get wallet payments')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Lightning payment request
|
||||
*/
|
||||
validatePaymentRequest(paymentRequest: string): boolean {
|
||||
// Basic validation - should start with 'lnbc' and be a valid length
|
||||
return paymentRequest.startsWith('lnbc') && paymentRequest.length > 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract payment hash from a payment request
|
||||
*/
|
||||
extractPaymentHash(paymentRequest: string): string | null {
|
||||
try {
|
||||
// This is a simplified extraction - in production you'd use a proper BOLT11 decoder
|
||||
const match = paymentRequest.match(/lnbc[0-9]+[a-z0-9]+/i)
|
||||
return match ? match[0] : null
|
||||
} catch (error) {
|
||||
console.error('Failed to extract payment hash:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const invoiceService = new InvoiceService()
|
||||
|
||||
396
src/lib/services/nostrmarketService.ts
Normal file
396
src/lib/services/nostrmarketService.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import type { Stall, Product, Order } from '@/stores/market'
|
||||
import { bech32ToHex } from '@/lib/utils/bech32'
|
||||
|
||||
export interface NostrmarketStall {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
currency: string
|
||||
shipping: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketProduct {
|
||||
id: string
|
||||
stall_id: string
|
||||
name: string
|
||||
description?: string
|
||||
images: string[]
|
||||
categories: string[]
|
||||
price: number
|
||||
quantity: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface NostrmarketOrder {
|
||||
id: string
|
||||
items: Array<{
|
||||
product_id: string
|
||||
quantity: number
|
||||
}>
|
||||
contact: {
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
address?: {
|
||||
street: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
postal_code: string
|
||||
}
|
||||
shipping_id: string
|
||||
}
|
||||
|
||||
export interface NostrmarketPaymentRequest {
|
||||
type: 1
|
||||
id: string
|
||||
message?: string
|
||||
payment_options: Array<{
|
||||
type: string
|
||||
link: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketOrderStatus {
|
||||
type: 2
|
||||
id: string
|
||||
message?: string
|
||||
paid?: boolean
|
||||
shipped?: boolean
|
||||
}
|
||||
|
||||
export class NostrmarketService {
|
||||
private getAuth() {
|
||||
if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) {
|
||||
throw new Error('User not authenticated or private key not available')
|
||||
}
|
||||
|
||||
// Convert bech32 keys to hex format if needed
|
||||
const originalPubkey = auth.currentUser.value.pubkey
|
||||
const originalPrvkey = auth.currentUser.value.prvkey
|
||||
const pubkey = bech32ToHex(originalPubkey)
|
||||
const prvkey = bech32ToHex(originalPrvkey)
|
||||
|
||||
console.log('🔑 Key conversion debug:', {
|
||||
originalPubkey: originalPubkey?.substring(0, 10) + '...',
|
||||
originalPrvkey: originalPrvkey?.substring(0, 10) + '...',
|
||||
convertedPubkey: pubkey.substring(0, 10) + '...',
|
||||
convertedPrvkey: prvkey.substring(0, 10) + '...',
|
||||
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
||||
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey)
|
||||
})
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
prvkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a stall event (kind 30017) to Nostr
|
||||
*/
|
||||
async publishStall(stall: Stall): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
const stallData: NostrmarketStall = {
|
||||
id: stall.id,
|
||||
name: stall.name,
|
||||
description: stall.description,
|
||||
currency: stall.currency,
|
||||
shipping: (stall.shipping || []).map(zone => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
countries: []
|
||||
}))
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30017,
|
||||
tags: [
|
||||
['t', 'stall'],
|
||||
['t', 'nostrmarket']
|
||||
],
|
||||
content: JSON.stringify(stallData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Stall published to nostrmarket:', {
|
||||
stallId: stall.id,
|
||||
eventId: eventId,
|
||||
content: stallData
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a product event (kind 30018) to Nostr
|
||||
*/
|
||||
async publishProduct(product: Product): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
const productData: NostrmarketProduct = {
|
||||
id: product.id,
|
||||
stall_id: product.stall_id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
images: product.images || [],
|
||||
categories: product.categories || [],
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
currency: product.currency
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30018,
|
||||
tags: [
|
||||
['t', 'product'],
|
||||
['t', 'nostrmarket'],
|
||||
['t', 'stall', product.stall_id],
|
||||
...(product.categories || []).map(cat => ['t', cat])
|
||||
],
|
||||
content: JSON.stringify(productData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Product published to nostrmarket:', {
|
||||
productId: product.id,
|
||||
eventId: eventId,
|
||||
content: productData
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||
*/
|
||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
// Convert order to nostrmarket format - exactly matching the specification
|
||||
const orderData = {
|
||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
||||
id: order.id,
|
||||
items: order.items.map(item => ({
|
||||
product_id: item.productId,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
contact: {
|
||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
||||
email: order.contactInfo?.email || ''
|
||||
// Remove phone field - not in nostrmarket specification
|
||||
},
|
||||
// Only include address if it's a physical good and address is provided
|
||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
||||
address: order.contactInfo.address
|
||||
} : {}),
|
||||
shipping_id: order.shippingZone?.id || 'online'
|
||||
}
|
||||
|
||||
// Encrypt the message using NIP-04
|
||||
const encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 4, // Encrypted DM
|
||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
||||
content: encryptedContent, // Use encrypted content
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Order published to nostrmarket:', {
|
||||
orderId: order.id,
|
||||
eventId: eventId,
|
||||
merchantPubkey,
|
||||
content: orderData,
|
||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming payment request from merchant (type 1)
|
||||
*/
|
||||
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
||||
console.log('Received payment request from merchant:', {
|
||||
orderId: paymentRequest.id,
|
||||
message: paymentRequest.message,
|
||||
paymentOptions: paymentRequest.payment_options
|
||||
})
|
||||
|
||||
// Find the Lightning payment option
|
||||
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
||||
if (!lightningOption) {
|
||||
console.error('No Lightning payment option found in payment request')
|
||||
return
|
||||
}
|
||||
|
||||
// Update the order in the store with payment request
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order with payment request details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentRequest: lightningOption.link,
|
||||
paymentStatus: 'pending' as const,
|
||||
status: 'pending' as const, // Ensure status is pending for payment
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
|
||||
// Generate QR code for the payment request
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
||||
updatedOrder.qrCodeLoading = false
|
||||
updatedOrder.qrCodeError = null
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
||||
updatedOrder.qrCodeLoading = false
|
||||
}
|
||||
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: paymentRequest.id,
|
||||
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
||||
status: updatedOrder.status,
|
||||
paymentStatus: updatedOrder.paymentStatus,
|
||||
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
||||
})
|
||||
} else {
|
||||
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming order status update from merchant (type 2)
|
||||
*/
|
||||
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
||||
console.log('Received order status update from merchant:', {
|
||||
orderId: statusUpdate.id,
|
||||
message: statusUpdate.message,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped
|
||||
})
|
||||
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order status
|
||||
if (statusUpdate.paid !== undefined) {
|
||||
const newStatus = statusUpdate.paid ? 'paid' : 'pending'
|
||||
marketStore.updateOrderStatus(order.id, newStatus)
|
||||
|
||||
// Also update payment status
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired',
|
||||
paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
if (statusUpdate.shipped !== undefined) {
|
||||
// Update shipping status if you have that field
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
shipped: statusUpdate.shipped,
|
||||
status: statusUpdate.shipped ? 'shipped' : order.status,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: statusUpdate.id,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped,
|
||||
newStatus: statusUpdate.paid ? 'paid' : 'pending'
|
||||
})
|
||||
} else {
|
||||
console.warn('Status update received for unknown order:', statusUpdate.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish all stalls and products for a merchant
|
||||
*/
|
||||
async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{
|
||||
stalls: Record<string, string>, // stallId -> eventId
|
||||
products: Record<string, string> // productId -> eventId
|
||||
}> {
|
||||
const results = {
|
||||
stalls: {} as Record<string, string>,
|
||||
products: {} as Record<string, string>
|
||||
}
|
||||
|
||||
// Publish stalls first
|
||||
for (const stall of stalls) {
|
||||
try {
|
||||
const eventId = await this.publishStall(stall)
|
||||
results.stalls[stall.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish stall ${stall.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish products
|
||||
for (const product of products) {
|
||||
try {
|
||||
const eventId = await this.publishProduct(product)
|
||||
results.products[product.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish product ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrmarketService = new NostrmarketService()
|
||||
275
src/lib/services/paymentMonitor.ts
Normal file
275
src/lib/services/paymentMonitor.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { ref } from 'vue'
|
||||
import type { PaymentStatus, LightningInvoice } from './invoiceService'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export interface PaymentMonitorState {
|
||||
isMonitoring: boolean
|
||||
activeInvoices: Map<string, LightningInvoice>
|
||||
paymentStatuses: Map<string, PaymentStatus>
|
||||
lastUpdate: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface PaymentUpdate {
|
||||
orderId: string
|
||||
paymentHash: string
|
||||
status: 'pending' | 'paid' | 'expired'
|
||||
amount: number
|
||||
paidAt?: number
|
||||
}
|
||||
|
||||
class PaymentMonitorService {
|
||||
private state = ref<PaymentMonitorState>({
|
||||
isMonitoring: false,
|
||||
activeInvoices: new Map(),
|
||||
paymentStatuses: new Map(),
|
||||
lastUpdate: 0,
|
||||
error: null
|
||||
})
|
||||
|
||||
private monitoringInterval: NodeJS.Timeout | null = null
|
||||
private updateCallbacks: Map<string, (update: PaymentUpdate) => void> = new Map()
|
||||
|
||||
// Computed properties
|
||||
get isMonitoring() { return this.state.value.isMonitoring }
|
||||
get activeInvoices() { return this.state.value.activeInvoices }
|
||||
get paymentStatuses() { return this.state.value.paymentStatuses }
|
||||
get lastUpdate() { return this.state.value.lastUpdate }
|
||||
get error() { return this.state.value.error }
|
||||
|
||||
/**
|
||||
* Start monitoring payments for a specific order
|
||||
*/
|
||||
async startMonitoring(order: Order, invoice: LightningInvoice): Promise<void> {
|
||||
try {
|
||||
// Add invoice to active monitoring
|
||||
this.state.value.activeInvoices.set(order.id, invoice)
|
||||
this.state.value.paymentStatuses.set(invoice.payment_hash, {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: invoice.payment_hash
|
||||
})
|
||||
|
||||
// Start monitoring if not already running
|
||||
if (!this.state.value.isMonitoring) {
|
||||
await this.startMonitoringLoop()
|
||||
}
|
||||
|
||||
console.log('Started monitoring payment for order:', {
|
||||
orderId: order.id,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to start payment monitoring:', error)
|
||||
this.state.value.error = 'Failed to start payment monitoring'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a specific order
|
||||
*/
|
||||
stopMonitoring(orderId: string): void {
|
||||
const invoice = this.state.value.activeInvoices.get(orderId)
|
||||
if (invoice) {
|
||||
this.state.value.activeInvoices.delete(orderId)
|
||||
this.state.value.paymentStatuses.delete(invoice.payment_hash)
|
||||
console.log('Stopped monitoring payment for order:', orderId)
|
||||
}
|
||||
|
||||
// Stop monitoring loop if no more active invoices
|
||||
if (this.state.value.activeInvoices.size === 0) {
|
||||
this.stopMonitoringLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the monitoring loop
|
||||
*/
|
||||
private async startMonitoringLoop(): Promise<void> {
|
||||
if (this.state.value.isMonitoring) return
|
||||
|
||||
this.state.value.isMonitoring = true
|
||||
console.log('Starting payment monitoring loop')
|
||||
|
||||
// Check immediately
|
||||
await this.checkAllPayments()
|
||||
|
||||
// Set up interval for periodic checks
|
||||
this.monitoringInterval = setInterval(async () => {
|
||||
await this.checkAllPayments()
|
||||
}, 30000) // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the monitoring loop
|
||||
*/
|
||||
private stopMonitoringLoop(): void {
|
||||
if (this.monitoringInterval) {
|
||||
clearInterval(this.monitoringInterval)
|
||||
this.monitoringInterval = null
|
||||
}
|
||||
this.state.value.isMonitoring = false
|
||||
console.log('Stopped payment monitoring loop')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status for all active invoices
|
||||
*/
|
||||
private async checkAllPayments(): Promise<void> {
|
||||
try {
|
||||
this.state.value.error = null
|
||||
this.state.value.lastUpdate = Date.now()
|
||||
|
||||
const promises = Array.from(this.state.value.activeInvoices.entries()).map(
|
||||
async ([orderId, invoice]) => {
|
||||
try {
|
||||
// Get payment status from LNBits
|
||||
const status = await this.getPaymentStatus(invoice.payment_hash)
|
||||
|
||||
// Update local status
|
||||
this.state.value.paymentStatuses.set(invoice.payment_hash, status)
|
||||
|
||||
// Check if status changed
|
||||
const previousStatus = this.state.value.paymentStatuses.get(invoice.payment_hash)
|
||||
if (previousStatus && previousStatus.paid !== status.paid) {
|
||||
await this.handlePaymentStatusChange(orderId, invoice, status)
|
||||
}
|
||||
|
||||
return { orderId, status }
|
||||
} catch (error) {
|
||||
console.error(`Failed to check payment for order ${orderId}:`, error)
|
||||
return { orderId, error }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
} catch (error) {
|
||||
console.error('Payment monitoring error:', error)
|
||||
this.state.value.error = 'Payment monitoring failed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment status from LNBits
|
||||
*/
|
||||
private async getPaymentStatus(paymentHash: string): Promise<PaymentStatus> {
|
||||
try {
|
||||
// For now, we'll simulate payment status checking since we don't have wallet context here
|
||||
// In production, this would integrate with LNBits webhooks or polling
|
||||
// TODO: Pass wallet information from the order context
|
||||
console.log('Payment status check requested for:', paymentHash)
|
||||
|
||||
// Return default pending status for now
|
||||
return {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: paymentHash
|
||||
}
|
||||
|
||||
// Uncomment when wallet context is available:
|
||||
// const status = await invoiceService.checkPaymentStatus(paymentHash, walletId, adminKey)
|
||||
// return status
|
||||
} catch (error) {
|
||||
console.error('Failed to get payment status:', error)
|
||||
// Return default pending status
|
||||
return {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: paymentHash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payment status changes
|
||||
*/
|
||||
private async handlePaymentStatusChange(
|
||||
orderId: string,
|
||||
invoice: LightningInvoice,
|
||||
status: PaymentStatus
|
||||
): Promise<void> {
|
||||
const update: PaymentUpdate = {
|
||||
orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
status: status.paid ? 'paid' : 'pending',
|
||||
amount: invoice.amount,
|
||||
paidAt: status.paid_at
|
||||
}
|
||||
|
||||
console.log('Payment status changed:', update)
|
||||
|
||||
// Notify callbacks
|
||||
const callback = this.updateCallbacks.get(orderId)
|
||||
if (callback) {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Payment update callback error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If payment is complete, stop monitoring this order
|
||||
if (status.paid) {
|
||||
this.stopMonitoring(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for payment updates
|
||||
*/
|
||||
onPaymentUpdate(orderId: string, callback: (update: PaymentUpdate) => void): void {
|
||||
this.updateCallbacks.set(orderId, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a payment update callback
|
||||
*/
|
||||
offPaymentUpdate(orderId: string): void {
|
||||
this.updateCallbacks.delete(orderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current payment status for an order
|
||||
*/
|
||||
getOrderPaymentStatus(orderId: string): PaymentStatus | null {
|
||||
const invoice = this.state.value.activeInvoices.get(orderId)
|
||||
if (!invoice) return null
|
||||
|
||||
return this.state.value.paymentStatuses.get(invoice.payment_hash) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an order payment is complete
|
||||
*/
|
||||
isOrderPaid(orderId: string): boolean {
|
||||
const status = this.getOrderPaymentStatus(orderId)
|
||||
return status?.paid || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending payments
|
||||
*/
|
||||
getPendingPayments(): Array<{ orderId: string; invoice: LightningInvoice }> {
|
||||
return Array.from(this.state.value.activeInvoices.entries())
|
||||
.filter(([orderId]) => !this.isOrderPaid(orderId))
|
||||
.map(([orderId, invoice]) => ({ orderId, invoice }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.stopMonitoringLoop()
|
||||
this.state.value.activeInvoices.clear()
|
||||
this.state.value.paymentStatuses.clear()
|
||||
this.updateCallbacks.clear()
|
||||
this.state.value.error = null
|
||||
console.log('Payment monitor cleaned up')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const paymentMonitor = new PaymentMonitorService()
|
||||
|
||||
11
src/lib/utils/bech32.ts
Normal file
11
src/lib/utils/bech32.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
// Helper function to convert bech32 to hex using nostr-tools
|
||||
export function bech32ToHex(bech32Key: string): string {
|
||||
if (bech32Key.startsWith('npub1') || bech32Key.startsWith('nsec1')) {
|
||||
const { type, data } = nip19.decode(bech32Key)
|
||||
return data as string
|
||||
}
|
||||
// Already hex format
|
||||
return bech32Key
|
||||
}
|
||||
68
src/pages/Cart.vue
Normal file
68
src/pages/Cart.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Success Message -->
|
||||
<div v-if="orderSuccess" class="mb-8">
|
||||
<div class="bg-green-500/10 border border-green-200 rounded-lg p-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-900">Order Placed Successfully!</h3>
|
||||
<p class="text-green-700">
|
||||
Your order has been placed and sent to the merchant.
|
||||
<span v-if="orderId" class="font-medium">Order ID: {{ orderId }}</span>
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="orderId && marketStore.orders[orderId]" class="mt-2">
|
||||
<div v-if="marketStore.orders[orderId].sentViaNostr" class="flex items-center gap-2 text-sm text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>✓ Sent via Nostr network</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-sm text-yellow-600">
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||
<span>⚠ Stored locally only</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Shopping Cart</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Review your items and proceed to checkout for each stall
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Content -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<ShoppingCart />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { CheckCircle } from 'lucide-vue-next'
|
||||
import ShoppingCart from '@/components/market/ShoppingCart.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Check for order success from query params
|
||||
const orderSuccess = computed(() => route.query.orderSuccess === 'true')
|
||||
const orderId = computed(() => route.query.orderId as string)
|
||||
|
||||
// Set the first cart as active if none is selected (for navigation purposes)
|
||||
onMounted(() => {
|
||||
if (marketStore.allStallCarts.length > 0 && !marketStore.activeStallCart) {
|
||||
const firstCart = marketStore.allStallCarts[0]
|
||||
marketStore.setCheckoutCart(firstCart.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
438
src/pages/Checkout.vue
Normal file
438
src/pages/Checkout.vue
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="!isReady" class="flex justify-center items-center min-h-64">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<p class="text-muted-foreground">Loading checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Checkout Error</h2>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="$router.push('/cart')" variant="outline">
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Content -->
|
||||
<div v-else-if="checkoutCart && checkoutStall" class="max-w-4xl mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Checkout</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Complete your purchase from {{ checkoutStall.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="$router.push('/cart')" variant="outline">
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Checkout Form -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Stall Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ checkoutStall.name }}</h3>
|
||||
<p v-if="checkoutStall.description" class="text-muted-foreground">
|
||||
{{ checkoutStall.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Contact Information</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Email (optional)
|
||||
</label>
|
||||
<Input
|
||||
v-model="contactInfo.email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Message to Merchant (optional)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="contactInfo.message"
|
||||
rows="3"
|
||||
placeholder="Any special requests or notes for the merchant..."
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Information</h3>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Shipping Zone
|
||||
</label>
|
||||
<div v-if="availableShippingZones.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
No shipping zones available for this stall.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address (only show for physical shipping) -->
|
||||
<div v-if="selectedShippingZone && requiresPhysicalShipping" class="mt-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Shipping Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="contactInfo.address"
|
||||
rows="3"
|
||||
placeholder="Enter your shipping address..."
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
||||
required
|
||||
></textarea>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Required for physical product delivery
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Digital Delivery Note -->
|
||||
<div v-if="selectedShippingZone && !requiresPhysicalShipping" class="mt-4 p-3 bg-muted/50 border border-border rounded-md">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-5 h-5 text-muted-foreground">📧</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Digital Delivery</p>
|
||||
<p>This product will be delivered digitally. No shipping address required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Method</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="lightning"
|
||||
v-model="paymentMethod"
|
||||
value="lightning"
|
||||
class="text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="lightning" class="text-sm font-medium text-foreground">
|
||||
⚡ Lightning Network (Recommended)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="btc_onchain"
|
||||
v-model="paymentMethod"
|
||||
value="btc_onchain"
|
||||
class="text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="btc_onchain" class="text-sm font-medium text-foreground">
|
||||
₿ Bitcoin Onchain
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Place Order Button -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<!-- Nostr Status Indicator -->
|
||||
<div class="mb-4 p-3 rounded-lg border" :class="{
|
||||
'bg-green-500/10 border-green-200': nostrOrders.isReady.value,
|
||||
'bg-yellow-500/10 border-yellow-200': !nostrOrders.isReady.value
|
||||
}">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div v-if="nostrOrders.isReady.value" class="flex items-center gap-2 text-green-700">
|
||||
<Wifi class="w-4 h-4" />
|
||||
<span>Connected to Nostr network</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-yellow-700">
|
||||
<WifiOff class="w-4 h-4" />
|
||||
<span>Nostr network unavailable</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!nostrOrders.isReady.value" class="text-xs text-yellow-600 mt-1">
|
||||
Orders will be stored locally only. Please log in to send orders to merchants.
|
||||
</p>
|
||||
|
||||
<!-- Test Encryption Button -->
|
||||
<div v-if="nostrOrders.isReady.value" class="mt-3">
|
||||
<Button
|
||||
@click="testEncryption"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isTestingEncryption"
|
||||
class="text-xs"
|
||||
>
|
||||
<div v-if="isTestingEncryption" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
||||
<span>Testing...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<span>Test NIP-04 Encryption</span>
|
||||
</div>
|
||||
</Button>
|
||||
<p v-if="encryptionTestResult" class="text-xs mt-1" :class="{
|
||||
'text-green-600': encryptionTestResult === 'success',
|
||||
'text-red-600': encryptionTestResult === 'error'
|
||||
}">
|
||||
{{ encryptionTestResult === 'success' ? '✓ Encryption test passed' : '✗ Encryption test failed' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="handleCheckout"
|
||||
:disabled="!canProceedToCheckout || isPlacingOrder"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<div v-if="isPlacingOrder" class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Placing Order...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-2">
|
||||
<Lock class="w-4 h-4" />
|
||||
<span>Place Order</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 mt-2 text-center">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary Sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="sticky top-8">
|
||||
<CartSummary
|
||||
:stall-id="checkoutCart.id"
|
||||
:cart-items="checkoutCart.products"
|
||||
:subtotal="checkoutCart.subtotal"
|
||||
:currency="checkoutCart.currency"
|
||||
:available-shipping-zones="availableShippingZones"
|
||||
:selected-shipping-zone="selectedShippingZone || undefined"
|
||||
@shipping-zone-selected="selectShippingZone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Store, Lock, Wifi, WifiOff } from 'lucide-vue-next'
|
||||
import CartSummary from '@/components/market/CartSummary.vue'
|
||||
import type { ShippingZone, ContactInfo } from '@/stores/market'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const auth = useAuth()
|
||||
|
||||
// Route parameters
|
||||
const stallId = route.params.stallId as string
|
||||
|
||||
// Local state
|
||||
const contactInfo = ref<ContactInfo>({
|
||||
email: '',
|
||||
message: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
const paymentMethod = ref<'lightning' | 'btc_onchain'>('lightning')
|
||||
const selectedShippingZone = ref<ShippingZone | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const isPlacingOrder = ref(false)
|
||||
const isTestingEncryption = ref(false)
|
||||
const encryptionTestResult = ref<'success' | 'error' | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const checkoutCart = computed(() => marketStore.checkoutCart)
|
||||
const checkoutStall = computed(() => marketStore.checkoutStall)
|
||||
|
||||
const availableShippingZones = computed(() => {
|
||||
if (!checkoutStall.value) return []
|
||||
return checkoutStall.value.shipping || []
|
||||
})
|
||||
|
||||
const isReady = computed(() => {
|
||||
return checkoutCart.value && checkoutStall.value
|
||||
})
|
||||
|
||||
const requiresPhysicalShipping = computed(() => {
|
||||
return selectedShippingZone.value?.requiresPhysicalShipping || false
|
||||
})
|
||||
|
||||
const canProceedToCheckout = computed(() => {
|
||||
return selectedShippingZone.value && (requiresPhysicalShipping.value ? contactInfo.value.address : true)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
selectedShippingZone.value = shippingZone
|
||||
if (checkoutCart.value) {
|
||||
marketStore.setShippingZone(checkoutCart.value.id, shippingZone)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckout = async () => {
|
||||
// Validate required fields
|
||||
if (!selectedShippingZone.value) {
|
||||
error.value = 'Please select a shipping zone'
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresPhysicalShipping.value && !contactInfo.value.address) {
|
||||
error.value = 'Please provide a shipping address'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear any previous errors
|
||||
error.value = null
|
||||
isPlacingOrder.value = true
|
||||
|
||||
// Create the order
|
||||
const order = await marketStore.createAndPlaceOrder({
|
||||
cartId: checkoutCart.value!.id,
|
||||
stallId: checkoutCart.value!.id,
|
||||
buyerPubkey: auth.currentUser?.value?.pubkey || '', // Get from authenticated user
|
||||
sellerPubkey: checkoutStall.value!.pubkey,
|
||||
status: 'pending',
|
||||
items: checkoutCart.value!.products.map(item => ({
|
||||
productId: item.product.id,
|
||||
productName: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: item.product.price,
|
||||
currency: item.product.currency
|
||||
})),
|
||||
contactInfo: contactInfo.value,
|
||||
shippingZone: selectedShippingZone.value,
|
||||
paymentMethod: paymentMethod.value,
|
||||
subtotal: checkoutCart.value!.subtotal,
|
||||
shippingCost: selectedShippingZone.value.cost,
|
||||
total: checkoutCart.value!.subtotal + selectedShippingZone.value.cost,
|
||||
currency: checkoutCart.value!.currency
|
||||
})
|
||||
|
||||
// Show success message
|
||||
console.log('Order placed successfully:', order)
|
||||
|
||||
// TODO: Navigate to payment page or show payment modal
|
||||
// For now, redirect to cart with success message
|
||||
router.push({
|
||||
path: '/cart',
|
||||
query: { orderSuccess: 'true', orderId: order.id }
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to place order'
|
||||
console.error('Order placement failed:', err)
|
||||
} finally {
|
||||
isPlacingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testEncryption = async () => {
|
||||
try {
|
||||
isTestingEncryption.value = true
|
||||
encryptionTestResult.value = null
|
||||
|
||||
const success = await nostrOrders.testEncryption()
|
||||
encryptionTestResult.value = success ? 'success' : 'error'
|
||||
|
||||
if (success) {
|
||||
console.log('NIP-04 encryption test passed!')
|
||||
} else {
|
||||
console.error('NIP-04 encryption test failed!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Encryption test error:', error)
|
||||
encryptionTestResult.value = 'error'
|
||||
} finally {
|
||||
isTestingEncryption.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize checkout
|
||||
onMounted(() => {
|
||||
if (!stallId) {
|
||||
error.value = 'No stall ID provided'
|
||||
return
|
||||
}
|
||||
|
||||
// Set the checkout cart for this stall
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
|
||||
// Auto-select shipping zone if only one available
|
||||
if (availableShippingZones.value.length === 1) {
|
||||
selectShippingZone(availableShippingZones.value[0])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -85,10 +85,10 @@
|
|||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div v-if="marketStore.cartItemCount > 0" class="fixed bottom-4 right-4">
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.cartItemCount }})
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -97,6 +97,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
|
|
@ -108,6 +109,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProductCard from '@/components/market/ProductCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const market = useMarket()
|
||||
const marketPreloader = useMarketPreloader()
|
||||
|
|
@ -121,7 +123,7 @@ const needsToLoadMarket = computed(() => {
|
|||
marketStore.products.length === 0
|
||||
})
|
||||
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
const isMarketReady = computed(() => {
|
||||
const isLoading = marketStore.isLoading ?? false
|
||||
const ready = marketPreloader.isPreloaded.value ||
|
||||
|
|
@ -158,12 +160,12 @@ const addToCart = (product: any) => {
|
|||
marketStore.addToCart(product)
|
||||
}
|
||||
|
||||
const viewProduct = (product: any) => {
|
||||
const viewProduct = (_product: any) => {
|
||||
// TODO: Navigate to product detail page
|
||||
}
|
||||
|
||||
const viewCart = () => {
|
||||
// TODO: Navigate to cart page
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
126
src/pages/MarketDashboard.vue
Normal file
126
src/pages/MarketDashboard.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Market Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Manage your market activities as both a customer and merchant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex space-x-8 border-b border-border">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ tab.name }}</span>
|
||||
<Badge v-if="tab.badge" variant="secondary" class="text-xs">
|
||||
{{ tab.badge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="min-h-[600px]">
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<DashboardOverview />
|
||||
</div>
|
||||
|
||||
<!-- My Orders Tab (Customer) -->
|
||||
<div v-else-if="activeTab === 'orders'" class="space-y-6">
|
||||
<OrderHistory />
|
||||
</div>
|
||||
|
||||
<!-- My Store Tab (Merchant) -->
|
||||
<div v-else-if="activeTab === 'store'" class="space-y-6">
|
||||
<MerchantStore />
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div v-else-if="activeTab === 'settings'" class="space-y-6">
|
||||
<MarketSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
// import { useAuth } from '@/composables/useAuth'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
Store,
|
||||
Settings,
|
||||
|
||||
} from 'lucide-vue-next'
|
||||
import DashboardOverview from '@/components/market/DashboardOverview.vue'
|
||||
import OrderHistory from '@/components/market/OrderHistory.vue'
|
||||
import MerchantStore from '@/components/market/MerchantStore.vue'
|
||||
import MarketSettings from '@/components/market/MarketSettings.vue'
|
||||
|
||||
// const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// Computed properties for tab badges
|
||||
const orderCount = computed(() => Object.keys(marketStore.orders).length)
|
||||
const pendingOrders = computed(() =>
|
||||
Object.values(marketStore.orders).filter(o => o.status === 'pending').length
|
||||
)
|
||||
// const pendingPayments = computed(() =>
|
||||
// Object.values(marketStore.orders).filter(o => o.paymentStatus === 'pending').length
|
||||
// )
|
||||
|
||||
// Dashboard tabs
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Overview',
|
||||
icon: BarChart3,
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'My Orders',
|
||||
icon: Package,
|
||||
badge: orderCount.value > 0 ? orderCount.value : null
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
name: 'My Store',
|
||||
icon: Store,
|
||||
badge: pendingOrders.value > 0 ? pendingOrders.value : null
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
badge: null
|
||||
}
|
||||
])
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Dashboard mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
425
src/pages/OrderHistory.vue
Normal file
425
src/pages/OrderHistory.vue
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Order History</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
View and track all your market orders
|
||||
</p>
|
||||
<!-- Order Events Status -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Listening for order updates' : 'Connecting to order events...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="orderEvents.lastEventTimestamp.value > 0" class="text-xs text-muted-foreground">
|
||||
Last update: {{ formatDate(orderEvents.lastEventTimestamp.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Stats -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Order Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Total Orders:</span>
|
||||
<Badge variant="secondary">{{ totalOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Pending:</span>
|
||||
<Badge variant="outline" class="text-yellow-600">{{ pendingOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Completed:</span>
|
||||
<Badge variant="outline" class="text-green-600">{{ completedOrders }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" class="w-40 px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="sortBy" class="w-40 px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="createdAt">Date Created</option>
|
||||
<option value="total">Order Total</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div v-if="filteredOrders.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="order in sortedOrders"
|
||||
:key="order.id"
|
||||
class="bg-card border rounded-lg p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="flex items-center gap-2 mt-1">
|
||||
<div v-if="order.sentViaNostr" class="flex items-center gap-1 text-xs text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
Sent via Nostr
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-xs text-red-600">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
Nostr failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<!-- Payment Status Indicator -->
|
||||
<div v-if="order.lightningInvoice" class="flex items-center gap-2">
|
||||
<Badge
|
||||
:variant="order.paymentStatus === 'paid' ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="order.paymentStatus === 'paid'" class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<div v-else class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
{{ order.paymentStatus === 'paid' ? 'Paid' : 'Payment Pending' }}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">{{ order.currency.toUpperCase() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<!-- Items -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Items</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.productId"
|
||||
class="flex justify-between text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{ formatPrice(item.price * item.quantity, item.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Info -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Order Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Subtotal:</span>
|
||||
<span>{{ formatPrice(order.subtotal, order.currency) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Shipping:</span>
|
||||
<span>{{ formatPrice(order.shippingCost, order.currency) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Payment Method:</span>
|
||||
<span class="capitalize">{{ order.paymentMethod.replace('_', ' ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Shipping -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<!-- Contact Info -->
|
||||
<div v-if="order.contactInfo.email || order.contactInfo.message">
|
||||
<h4 class="font-medium text-foreground mb-2">Contact Information</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="order.contactInfo.email">
|
||||
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.message">
|
||||
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Info -->
|
||||
<div v-if="order.shippingZone">
|
||||
<h4 class="font-medium text-foreground mb-2">Shipping</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-medium">Zone:</span> {{ order.shippingZone.name }}
|
||||
</p>
|
||||
<p v-if="order.shippingZone.estimatedDays">
|
||||
<span class="font-medium">Est. Delivery:</span> {{ order.shippingZone.estimatedDays }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.address">
|
||||
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Event Details -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="mb-4 p-4 bg-muted rounded-lg">
|
||||
<h4 class="font-medium text-foreground mb-2">Nostr Network Status</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="order.sentViaNostr" class="space-y-1">
|
||||
<p v-if="order.nostrEventId" class="text-muted-foreground">
|
||||
<span class="font-medium">Event ID:</span>
|
||||
<code class="bg-background px-2 py-1 rounded text-xs">{{ order.nostrEventId.slice(0, 16) }}...</code>
|
||||
</p>
|
||||
<p v-if="order.nostrEventSig" class="text-muted-foreground">
|
||||
<span class="font-medium">Signature:</span>
|
||||
<code class="bg-background px-2 py-1 rounded text-xs">{{ order.nostrEventSig.slice(0, 16) }}...</code>
|
||||
</p>
|
||||
<p class="text-green-600">
|
||||
<span class="font-medium">✓</span> Order successfully transmitted to merchant via Nostr network
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<p v-if="order.nostrError" class="text-red-600">
|
||||
<span class="font-medium">✗</span> Failed to send via Nostr: {{ order.nostrError }}
|
||||
</p>
|
||||
<p class="text-yellow-600">
|
||||
<span class="font-medium">⚠</span> Order stored locally only - merchant may not receive it
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status & Actions -->
|
||||
<div v-if="order.status === 'pending'" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-foreground mb-2">Payment Required</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Lightning invoice received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="font-medium text-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Status: <span class="font-medium text-foreground">{{ order.paymentStatus === 'paid' ? 'Paid' : 'Pending Payment' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-amber-600">⏳</span> Waiting for merchant to generate payment invoice
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The merchant will send you a Lightning invoice via Nostr once they process your order
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="order.status === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="cancelOrder(order.id)"
|
||||
>
|
||||
Cancel Order
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
variant="default"
|
||||
size="sm"
|
||||
@click="togglePaymentDisplay(order.id)"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
{{ expandedPayments.has(order.id) ? 'Hide' : 'Show' }} Payment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyOrderId(order.id)"
|
||||
>
|
||||
Copy Order ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Display (Expandable) -->
|
||||
<div v-if="expandedPayments.has(order.id) && order.lightningInvoice" class="mt-4 pt-4 border-t border-border">
|
||||
<PaymentDisplay
|
||||
:order-id="order.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package class="w-8 h-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No orders yet</h3>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Start shopping in the market to see your order history here
|
||||
</p>
|
||||
<Button @click="router.push('/market')" variant="default">
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Package, Wallet } from 'lucide-vue-next'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
import PaymentDisplay from '@/components/market/PaymentDisplay.vue'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref('createdAt')
|
||||
const expandedPayments = ref(new Set<string>())
|
||||
|
||||
// Computed properties
|
||||
const allOrders = computed(() => Object.values(marketStore.orders))
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return allOrders.value
|
||||
return allOrders.value.filter(order => order.status === statusFilter.value)
|
||||
})
|
||||
|
||||
const sortedOrders = computed(() => {
|
||||
const orders = [...filteredOrders.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'total':
|
||||
return orders.sort((a, b) => b.total - a.total)
|
||||
case 'status':
|
||||
return orders.sort((a, b) => a.status.localeCompare(b.status))
|
||||
case 'createdAt':
|
||||
default:
|
||||
return orders.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
const totalOrders = computed(() => allOrders.value.length)
|
||||
const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'pending').length)
|
||||
const completedOrders = computed(() =>
|
||||
allOrders.value.filter(o => ['delivered', 'shipped'].includes(o.status)).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId: string) => {
|
||||
// TODO: Implement order cancellation
|
||||
console.log('Cancelling order:', orderId)
|
||||
}
|
||||
|
||||
// const viewPayment = (_order: any) => {
|
||||
// // TODO: Implement payment viewing
|
||||
// console.log('Viewing payment for order:', _order.id)
|
||||
// }
|
||||
|
||||
const copyOrderId = async (orderId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(orderId)
|
||||
// TODO: Show toast notification
|
||||
console.log('Order ID copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy order ID:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePaymentDisplay = (orderId: string) => {
|
||||
if (expandedPayments.value.has(orderId)) {
|
||||
expandedPayments.value.delete(orderId)
|
||||
} else {
|
||||
expandedPayments.value.add(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
// Load orders on mount
|
||||
onMounted(() => {
|
||||
// Orders are already loaded in the market store
|
||||
console.log('Order History page loaded with', allOrders.value.length, 'orders')
|
||||
|
||||
// Start listening for order events if not already listening
|
||||
if (!orderEvents.isSubscribed.value) {
|
||||
orderEvents.startListening()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -184,8 +184,8 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import RelayHubStatus from '@/components/RelayHubStatus.vue'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { config } from '@/lib/config'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
|
|
@ -196,7 +196,7 @@ const {
|
|||
initialize,
|
||||
connect,
|
||||
subscribe
|
||||
} = useRelayHub()
|
||||
} = relayHubComposable
|
||||
|
||||
// Test state
|
||||
const isTesting = ref(false)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,36 @@ const router = createRouter({
|
|||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cart',
|
||||
name: 'cart',
|
||||
component: () => import('@/pages/Cart.vue'),
|
||||
meta: {
|
||||
title: 'Shopping Cart',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/checkout/:stallId',
|
||||
name: 'checkout',
|
||||
component: () => import('@/pages/Checkout.vue'),
|
||||
meta: {
|
||||
title: 'Checkout',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/order-history',
|
||||
name: 'OrderHistory',
|
||||
component: () => import('@/pages/OrderHistory.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/market-dashboard',
|
||||
name: 'MarketDashboard',
|
||||
component: () => import('@/pages/MarketDashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { invoiceService } from '@/lib/services/invoiceService'
|
||||
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
||||
import { nostrmarketService } from '@/lib/services/nostrmarketService'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
|
||||
// Types
|
||||
|
|
@ -25,7 +30,9 @@ export interface Stall {
|
|||
description?: string
|
||||
logo?: string
|
||||
categories?: string[]
|
||||
shipping?: Record<string, any>
|
||||
shipping?: ShippingZone[]
|
||||
currency: string
|
||||
nostrEventId?: string // Nostr event ID for nostrmarket integration
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
|
@ -41,23 +48,91 @@ export interface Product {
|
|||
categories?: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string // Nostr event ID for nostrmarket integration
|
||||
}
|
||||
|
||||
// Enhanced Order interface for the new system
|
||||
export interface Order {
|
||||
id: string
|
||||
stall_id: string
|
||||
product_id: string
|
||||
buyer_pubkey: string
|
||||
seller_pubkey: string
|
||||
quantity: number
|
||||
total_price: number
|
||||
cartId: string
|
||||
stallId: string
|
||||
buyerPubkey: string
|
||||
sellerPubkey: string
|
||||
status: OrderStatus
|
||||
items: OrderItem[]
|
||||
contactInfo: ContactInfo
|
||||
shippingZone: ShippingZone
|
||||
paymentRequest?: string
|
||||
paymentMethod: PaymentMethod
|
||||
subtotal: number
|
||||
shippingCost: number
|
||||
total: number
|
||||
currency: string
|
||||
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
|
||||
payment_request?: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Nostr integration fields
|
||||
nostrEventId?: string
|
||||
nostrEventSig?: string
|
||||
sentViaNostr?: boolean
|
||||
nostrError?: string
|
||||
originalOrderId?: string // Original order ID from Nostr event (for nostrmarket compatibility)
|
||||
// Lightning invoice fields
|
||||
lightningInvoice?: LightningInvoice
|
||||
paymentHash?: string
|
||||
paidAt?: number
|
||||
paymentStatus?: 'pending' | 'paid' | 'expired'
|
||||
// QR code fields
|
||||
qrCodeDataUrl?: string
|
||||
qrCodeLoading?: boolean
|
||||
qrCodeError?: string | null
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
price: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
address?: string
|
||||
email?: string
|
||||
message?: string
|
||||
npub?: string
|
||||
}
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
currency: string
|
||||
description?: string
|
||||
estimatedDays?: string
|
||||
requiresPhysicalShipping?: boolean
|
||||
}
|
||||
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||
|
||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||
|
||||
// Cart management interfaces
|
||||
export interface CartItem {
|
||||
product: Product
|
||||
quantity: number
|
||||
stallId: string
|
||||
}
|
||||
|
||||
export interface StallCart {
|
||||
id: string
|
||||
merchant: string
|
||||
products: CartItem[]
|
||||
subtotal: number
|
||||
shippingZone?: ShippingZone
|
||||
currency: string
|
||||
}
|
||||
|
||||
// Enhanced FilterData with more options
|
||||
export interface FilterData {
|
||||
categories: string[]
|
||||
merchants: string[]
|
||||
|
|
@ -65,6 +140,8 @@ export interface FilterData {
|
|||
currency: string | null
|
||||
priceFrom: number | null
|
||||
priceTo: number | null
|
||||
inStock: boolean | null
|
||||
paymentMethods: PaymentMethod[]
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
|
|
@ -72,6 +149,22 @@ export interface SortOptions {
|
|||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// Payment-related interfaces
|
||||
export interface PaymentRequest {
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
expiresAt: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amountPaid: number
|
||||
paidAt?: number
|
||||
transactionId?: string
|
||||
}
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
// Core market state
|
||||
const markets = ref<Market[]>([])
|
||||
|
|
@ -98,7 +191,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
})
|
||||
|
||||
const sortOptions = ref<SortOptions>({
|
||||
|
|
@ -106,9 +201,21 @@ export const useMarketStore = defineStore('market', () => {
|
|||
order: 'asc'
|
||||
})
|
||||
|
||||
// Shopping cart
|
||||
// Enhanced shopping cart with stall-specific carts
|
||||
const stallCarts = ref<Record<string, StallCart>>({})
|
||||
|
||||
// Legacy shopping cart (to be deprecated)
|
||||
const shoppingCart = ref<Record<string, { product: Product; quantity: number }>>({})
|
||||
|
||||
// Checkout state
|
||||
const checkoutCart = ref<StallCart | null>(null)
|
||||
const checkoutStall = ref<Stall | null>(null)
|
||||
const activeOrder = ref<Order | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentRequest = ref<PaymentRequest | null>(null)
|
||||
const paymentStatus = ref<PaymentStatus | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const filteredProducts = computed(() => {
|
||||
let filtered = products.value
|
||||
|
|
@ -164,6 +271,20 @@ export const useMarketStore = defineStore('market', () => {
|
|||
)
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
if (filterData.value.inStock !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.inStock ? product.quantity > 0 : product.quantity === 0
|
||||
)
|
||||
}
|
||||
|
||||
// Payment methods filter
|
||||
if (filterData.value.paymentMethods.length > 0) {
|
||||
// For now, assume all products support Lightning payments
|
||||
// This can be enhanced later with product-specific payment method support
|
||||
filtered = filtered.filter(_product => true)
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
const aVal = a[sortOptions.value.field as keyof Product]
|
||||
|
|
@ -199,6 +320,27 @@ export const useMarketStore = defineStore('market', () => {
|
|||
}))
|
||||
})
|
||||
|
||||
// Enhanced cart computed properties
|
||||
const allStallCarts = computed(() => Object.values(stallCarts.value))
|
||||
|
||||
const totalCartItems = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalCartValue = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.subtotal
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const activeStallCart = computed(() => {
|
||||
if (!checkoutStall.value) return null
|
||||
return stallCarts.value[checkoutStall.value.id] || null
|
||||
})
|
||||
|
||||
// Legacy cart computed properties (to be deprecated)
|
||||
const cartTotal = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
|
|
@ -291,6 +433,448 @@ export const useMarketStore = defineStore('market', () => {
|
|||
shoppingCart.value = {}
|
||||
}
|
||||
|
||||
// Enhanced cart management methods
|
||||
const addToStallCart = (product: Product, quantity: number = 1) => {
|
||||
const stallId = product.stall_id
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (!stall) {
|
||||
console.error('Stall not found for product:', product.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize stall cart if it doesn't exist
|
||||
if (!stallCarts.value[stallId]) {
|
||||
stallCarts.value[stallId] = {
|
||||
id: stallId,
|
||||
merchant: stall.pubkey,
|
||||
products: [],
|
||||
subtotal: 0,
|
||||
currency: stall.currency || 'sats'
|
||||
}
|
||||
}
|
||||
|
||||
const cart = stallCarts.value[stallId]
|
||||
const existingItem = cart.products.find(item => item.product.id === product.id)
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity)
|
||||
} else {
|
||||
cart.products.push({
|
||||
product,
|
||||
quantity: Math.min(quantity, product.quantity),
|
||||
stallId
|
||||
})
|
||||
}
|
||||
|
||||
// Update cart subtotal
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
|
||||
const removeFromStallCart = (stallId: string, productId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.products = cart.products.filter(item => item.product.id !== productId)
|
||||
updateStallCartSubtotal(stallId)
|
||||
|
||||
// Remove empty carts
|
||||
if (cart.products.length === 0) {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
if (quantity <= 0) {
|
||||
removeFromStallCart(stallId, productId)
|
||||
} else {
|
||||
const item = cart.products.find(item => item.product.id === productId)
|
||||
if (item) {
|
||||
item.quantity = Math.min(quantity, item.product.quantity)
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartSubtotal = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.subtotal = cart.products.reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
|
||||
const clearAllStallCarts = () => {
|
||||
stallCarts.value = {}
|
||||
}
|
||||
|
||||
const setCheckoutCart = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (cart && stall) {
|
||||
checkoutCart.value = cart
|
||||
checkoutStall.value = stall
|
||||
}
|
||||
}
|
||||
|
||||
const clearCheckout = () => {
|
||||
checkoutCart.value = null
|
||||
checkoutStall.value = null
|
||||
activeOrder.value = null
|
||||
paymentRequest.value = null
|
||||
paymentStatus.value = null
|
||||
}
|
||||
|
||||
const setShippingZone = (stallId: string, shippingZone: ShippingZone) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.shippingZone = shippingZone
|
||||
}
|
||||
}
|
||||
|
||||
// Order management methods
|
||||
const createOrder = (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => {
|
||||
const order: Order = {
|
||||
...orderData,
|
||||
id: orderData.id || generateOrderId(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
orders.value[order.id] = order
|
||||
activeOrder.value = order
|
||||
|
||||
// Save to localStorage for persistence
|
||||
saveOrdersToStorage()
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
const createAndPlaceOrder = async (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
// Create the order
|
||||
const order = createOrder(orderData)
|
||||
|
||||
// Attempt to publish order via nostrmarket protocol
|
||||
let nostrmarketSuccess = false
|
||||
let nostrmarketError: string | undefined
|
||||
|
||||
try {
|
||||
// Publish the order event to nostrmarket using proper protocol
|
||||
const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey)
|
||||
nostrmarketSuccess = true
|
||||
order.sentViaNostr = true
|
||||
order.nostrEventId = eventId
|
||||
|
||||
console.log('Order published via nostrmarket successfully:', eventId)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error'
|
||||
order.nostrError = errorMessage
|
||||
order.sentViaNostr = false
|
||||
console.error('Failed to publish order via nostrmarket:', errorMessage)
|
||||
}
|
||||
|
||||
// Update order status to 'pending'
|
||||
updateOrderStatus(order.id, 'pending')
|
||||
|
||||
// Clear the checkout cart
|
||||
if (checkoutCart.value) {
|
||||
clearStallCart(checkoutCart.value.id)
|
||||
}
|
||||
|
||||
// Clear checkout state
|
||||
clearCheckout()
|
||||
|
||||
// Show appropriate success/error message
|
||||
if (nostrmarketSuccess) {
|
||||
console.log('Order created and published via nostrmarket successfully')
|
||||
} else {
|
||||
console.warn('Order created but nostrmarket publishing failed:', nostrmarketError)
|
||||
}
|
||||
|
||||
return order
|
||||
} catch (error) {
|
||||
console.error('Failed to create and place order:', error)
|
||||
throw new Error('Failed to place order. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
// nostrmarket integration methods
|
||||
const publishToNostrmarket = async () => {
|
||||
try {
|
||||
console.log('Publishing merchant catalog to nostrmarket...')
|
||||
|
||||
// Get all stalls and products
|
||||
const allStalls = Object.values(stalls.value)
|
||||
const allProducts = Object.values(products.value)
|
||||
|
||||
if (allStalls.length === 0) {
|
||||
console.warn('No stalls to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
if (allProducts.length === 0) {
|
||||
console.warn('No products to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
// Publish to nostrmarket
|
||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
||||
|
||||
console.log('Successfully published to nostrmarket:', result)
|
||||
|
||||
// Update stalls and products with event IDs
|
||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
if (stall) {
|
||||
stall.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
if (product) {
|
||||
product.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to publish to nostrmarket:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice management methods
|
||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||
try {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Create Lightning invoice with admin key and nostrmarket tag
|
||||
// For nostrmarket compatibility, we need to use the original order ID if it exists
|
||||
// If no originalOrderId exists, this order was created in the web-app, so use the current orderId
|
||||
const orderIdForInvoice = order.originalOrderId || orderId
|
||||
console.log('Creating invoice with order ID:', {
|
||||
webAppOrderId: orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
orderIdForInvoice: orderIdForInvoice,
|
||||
hasOriginalOrderId: !!order.originalOrderId
|
||||
})
|
||||
|
||||
const invoice = await invoiceService.createInvoice(order, adminKey, {
|
||||
tag: "nostrmarket",
|
||||
order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility
|
||||
merchant_pubkey: order.sellerPubkey,
|
||||
buyer_pubkey: order.buyerPubkey
|
||||
})
|
||||
|
||||
// Update order with invoice details
|
||||
order.lightningInvoice = invoice
|
||||
order.paymentHash = invoice.payment_hash
|
||||
order.paymentStatus = 'pending'
|
||||
order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response
|
||||
|
||||
// Save to localStorage after invoice creation
|
||||
saveOrdersToStorage()
|
||||
|
||||
// Start monitoring payment
|
||||
await paymentMonitor.startMonitoring(order, invoice)
|
||||
|
||||
// Set up payment update callback
|
||||
paymentMonitor.onPaymentUpdate(orderId, (update) => {
|
||||
handlePaymentUpdate(orderId, update)
|
||||
})
|
||||
|
||||
console.log('Lightning invoice created for order:', {
|
||||
orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
nostrmarketOrderId: order.originalOrderId || orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
|
||||
return invoice
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentUpdate = (orderId: string, update: any) => {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) return
|
||||
|
||||
// Update order payment status
|
||||
order.paymentStatus = update.status
|
||||
if (update.status === 'paid') {
|
||||
order.paidAt = update.paidAt
|
||||
updateOrderStatus(orderId, 'paid')
|
||||
|
||||
// Send payment confirmation via Nostr
|
||||
sendPaymentConfirmation(order)
|
||||
}
|
||||
|
||||
// Save to localStorage after payment update
|
||||
saveOrdersToStorage()
|
||||
|
||||
console.log('Payment status updated for order:', {
|
||||
orderId,
|
||||
status: update.status,
|
||||
amount: update.amount
|
||||
})
|
||||
}
|
||||
|
||||
const sendPaymentConfirmation = async (order: Order) => {
|
||||
try {
|
||||
if (!nostrOrders.isReady.value) {
|
||||
console.warn('Nostr not ready for payment confirmation')
|
||||
return
|
||||
}
|
||||
|
||||
// Create payment confirmation message
|
||||
// const confirmation = {
|
||||
// type: 'payment_confirmation',
|
||||
// orderId: order.id,
|
||||
// paymentHash: order.paymentHash,
|
||||
// amount: order.total,
|
||||
// currency: order.currency,
|
||||
// paidAt: order.paidAt,
|
||||
// message: 'Payment received! Your order is being processed.'
|
||||
// }
|
||||
|
||||
// Send confirmation to customer
|
||||
await nostrOrders.publishOrderEvent(order, order.buyerPubkey)
|
||||
|
||||
console.log('Payment confirmation sent via Nostr')
|
||||
} catch (error) {
|
||||
console.error('Failed to send payment confirmation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getOrderInvoice = (orderId: string): LightningInvoice | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.lightningInvoice || null
|
||||
}
|
||||
|
||||
const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.paymentStatus || null
|
||||
}
|
||||
|
||||
const updateOrderStatus = (orderId: string, status: OrderStatus) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
order.status = status
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const updateOrder = (orderId: string, updatedOrder: Partial<Order>) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
Object.assign(order, updatedOrder)
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const setPaymentRequest = (request: PaymentRequest) => {
|
||||
paymentRequest.value = request
|
||||
}
|
||||
|
||||
const setPaymentStatus = (status: PaymentStatus) => {
|
||||
paymentStatus.value = status
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
const generateOrderId = () => {
|
||||
return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
const saveOrdersToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem('market_orders', JSON.stringify(orders.value))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save orders to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadOrdersFromStorage = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('market_orders')
|
||||
if (stored) {
|
||||
const parsedOrders = JSON.parse(stored)
|
||||
orders.value = parsedOrders
|
||||
console.log('Loaded orders from localStorage:', Object.keys(parsedOrders).length)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load orders from localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Payment utility methods
|
||||
const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => {
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = shippingZone?.cost || 0
|
||||
return subtotal + shippingCost
|
||||
}
|
||||
|
||||
const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const errors: string[] = []
|
||||
|
||||
if (!cart || cart.products.length === 0) {
|
||||
errors.push('Cart is empty')
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
// Check if all products are still in stock
|
||||
for (const item of cart.products) {
|
||||
if (item.quantity > item.product.quantity) {
|
||||
errors.push(`${item.product.name} is out of stock`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cart has shipping zone selected
|
||||
if (!cart.shippingZone) {
|
||||
errors.push('Please select a shipping zone')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const getCartSummary = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (!cart) return null
|
||||
|
||||
const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0)
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = cart.shippingZone?.cost || 0
|
||||
const total = subtotal + shippingCost
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
total,
|
||||
currency: cart.currency
|
||||
}
|
||||
}
|
||||
|
||||
const updateFilterData = (newFilterData: Partial<FilterData>) => {
|
||||
filterData.value = { ...filterData.value, ...newFilterData }
|
||||
}
|
||||
|
|
@ -302,7 +886,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +915,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize orders from localStorage
|
||||
loadOrdersFromStorage()
|
||||
|
||||
return {
|
||||
// State
|
||||
markets: readonly(markets),
|
||||
|
|
@ -346,10 +935,20 @@ export const useMarketStore = defineStore('market', () => {
|
|||
filterData: readonly(filterData),
|
||||
sortOptions: readonly(sortOptions),
|
||||
shoppingCart: readonly(shoppingCart),
|
||||
stallCarts: readonly(stallCarts),
|
||||
checkoutCart: readonly(checkoutCart),
|
||||
checkoutStall: readonly(checkoutStall),
|
||||
activeOrder: readonly(activeOrder),
|
||||
paymentRequest: readonly(paymentRequest),
|
||||
paymentStatus: readonly(paymentStatus),
|
||||
|
||||
// Computed
|
||||
filteredProducts,
|
||||
allCategories,
|
||||
allStallCarts,
|
||||
totalCartItems,
|
||||
totalCartValue,
|
||||
activeStallCart,
|
||||
cartTotal,
|
||||
cartItemCount,
|
||||
|
||||
|
|
@ -371,6 +970,33 @@ export const useMarketStore = defineStore('market', () => {
|
|||
clearFilters,
|
||||
toggleCategoryFilter,
|
||||
updateSortOptions,
|
||||
formatPrice
|
||||
formatPrice,
|
||||
addToStallCart,
|
||||
removeFromStallCart,
|
||||
updateStallCartQuantity,
|
||||
updateStallCartSubtotal,
|
||||
clearStallCart,
|
||||
clearAllStallCarts,
|
||||
setCheckoutCart,
|
||||
clearCheckout,
|
||||
setShippingZone,
|
||||
createOrder,
|
||||
updateOrderStatus,
|
||||
setPaymentRequest,
|
||||
setPaymentStatus,
|
||||
generateOrderId,
|
||||
calculateOrderTotal,
|
||||
validateCartForCheckout,
|
||||
getCartSummary,
|
||||
createAndPlaceOrder,
|
||||
createLightningInvoice,
|
||||
handlePaymentUpdate,
|
||||
sendPaymentConfirmation,
|
||||
getOrderInvoice,
|
||||
getOrderPaymentStatus,
|
||||
updateOrder,
|
||||
saveOrdersToStorage,
|
||||
loadOrdersFromStorage,
|
||||
publishToNostrmarket
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue