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:
padreug 2025-08-13 15:31:18 +02:00
parent 93ffb8bf32
commit ea5a2380f1
43 changed files with 8983 additions and 146 deletions

View file

@ -780,3 +780,330 @@ This architecture makes it easy to add new Nostr functionality:
8. **Metrics and Monitoring**: Add comprehensive metrics for relay performance 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. 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.

View 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.

View file

@ -6,10 +6,10 @@ import Footer from '@/components/layout/Footer.vue'
import LoginDialog from '@/components/auth/LoginDialog.vue' import LoginDialog from '@/components/auth/LoginDialog.vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
import { auth } from '@/composables/useAuth'
import { useMarketPreloader } from '@/composables/useMarketPreloader' import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { nostrChat } from '@/composables/useNostrChat' 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' import { toast } from 'vue-sonner'
const route = useRoute() const route = useRoute()
@ -19,7 +19,7 @@ const showLoginDialog = ref(false)
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
// Initialize relay hub // Initialize relay hub
const relayHub = useRelayHub() const relayHub = relayHubComposable
// Hide navbar on login page // Hide navbar on login page
const showNavbar = computed(() => { const showNavbar = computed(() => {

View 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>

View file

@ -43,15 +43,12 @@
</div> </div>
<div class="actions"> <div class="actions">
<button @click="connect" :disabled="isConnected || connectionStatus === 'connecting'"> <button @click="connect" :disabled="connectionStatus === 'connecting'">
Connect Connect
</button> </button>
<button @click="disconnect" :disabled="!isConnected"> <button @click="disconnect" :disabled="!isConnected">
Disconnect Disconnect
</button> </button>
<button @click="reconnect" :disabled="connectionStatus === 'connecting'">
Reconnect
</button>
</div> </div>
<div class="subscription-info"> <div class="subscription-info">
@ -65,7 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRelayHub } from '@/composables/useRelayHub' import { relayHubComposable } from '@/composables/useRelayHub'
const { const {
isConnected, isConnected,
@ -78,9 +75,8 @@ const {
totalSubscriptionCount, totalSubscriptionCount,
connectionHealth, connectionHealth,
connect, connect,
disconnect, disconnect
reconnect } = relayHubComposable
} = useRelayHub()
</script> </script>
<style scoped> <style scoped>

View file

@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' 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 LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import LoginDialog from '@/components/auth/LoginDialog.vue' import LoginDialog from '@/components/auth/LoginDialog.vue'
import ProfileDialog from '@/components/auth/ProfileDialog.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 { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
import { auth } from '@/composables/useAuth' import { auth } from '@/composables/useAuth'
import { useMarketPreloader } from '@/composables/useMarketPreloader' import { useMarketPreloader } from '@/composables/useMarketPreloader'
import { useMarketStore } from '@/stores/market'
import { nostrChat } from '@/composables/useNostrChat' import { nostrChat } from '@/composables/useNostrChat'
interface NavigationItem { interface NavigationItem {
@ -29,6 +30,7 @@ const showLoginDialog = ref(false)
const showProfileDialog = ref(false) const showProfileDialog = ref(false)
const showLogoutConfirm = ref(false) const showLogoutConfirm = ref(false)
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
const marketStore = useMarketStore()
const navigation = computed<NavigationItem[]>(() => [ const navigation = computed<NavigationItem[]>(() => [
{ name: t('nav.home'), href: '/' }, { name: t('nav.home'), href: '/' },
@ -51,6 +53,13 @@ const totalUnreadMessages = computed(() => {
return nostrChat.totalUnreadCount.value return nostrChat.totalUnreadCount.value
}) })
// Compute cart item count
const cartItemCount = computed(() => {
return marketStore.totalCartItems
})
const toggleMenu = () => { const toggleMenu = () => {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
} }
@ -125,6 +134,17 @@ const handleLogout = async () => {
<!-- <CurrencyDisplay :balance-msat="totalBalance" /> --> <!-- <CurrencyDisplay :balance-msat="totalBalance" /> -->
<!-- </div> --> <!-- </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 --> <!-- Authentication Management -->
<div class="hidden sm:block"> <div class="hidden sm:block">
<DropdownMenu v-if="auth.isAuthenticated.value"> <DropdownMenu v-if="auth.isAuthenticated.value">
@ -151,6 +171,11 @@ const handleLogout = async () => {
<Ticket class="h-4 w-4" /> <Ticket class="h-4 w-4" />
My Tickets My Tickets
</DropdownMenuItem> </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"> <DropdownMenuItem @click="() => router.push('/relay-hub-status')" class="gap-2">
<Activity class="h-4 w-4" /> <Activity class="h-4 w-4" />
Relay Hub Status Relay Hub Status
@ -236,6 +261,12 @@ const handleLogout = async () => {
<Ticket class="h-4 w-4" /> <Ticket class="h-4 w-4" />
My Tickets My Tickets
</Button> </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')" <Button variant="ghost" size="sm" @click="() => router.push('/relay-hub-status')"
class="w-full justify-start gap-2"> class="w-full justify-start gap-2">
<Activity class="h-4 w-4" /> <Activity class="h-4 w-4" />

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -11,7 +11,7 @@
<!-- Add to Cart Button --> <!-- Add to Cart Button -->
<Button <Button
@click="$emit('add-to-cart', product)" @click="addToCart"
:disabled="product.quantity < 1" :disabled="product.quantity < 1"
size="sm" size="sm"
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white" class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
@ -101,6 +101,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useMarketStore } from '@/stores/market'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -111,22 +112,26 @@ interface Props {
product: Product product: Product
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<{ // const emit = defineEmits<{
'add-to-cart': [product: Product] // 'view-details': [product: Product]
'view-details': [product: Product] // 'view-stall': [stallId: string]
'view-stall': [stallId: string] // }>()
}>()
const marketStore = useMarketStore()
const imageError = ref(false) const imageError = ref(false)
const addToCart = () => {
marketStore.addToStallCart(props.product, 1)
}
const handleImageError = () => { const handleImageError = () => {
imageError.value = true imageError.value = true
} }
const formatPrice = (price: number, currency: string) => { const formatPrice = (price: number, currency: string) => {
if (currency === 'sat') { if (currency === 'sat' || currency === 'sats') {
return `${price.toLocaleString('en-US')} sats` return `${price.toLocaleString('en-US')} sats`
} }
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {

View 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>

View file

@ -611,7 +611,7 @@ onUnmounted(() => {
}) })
// Watch for connection state changes // Watch for connection state changes
watch(isConnected, async (connected, prevConnected) => { watch(isConnected, async () => {
// Note: Peer subscriptions are handled by the preloader // Note: Peer subscriptions are handled by the preloader
}) })

View file

@ -7,14 +7,14 @@ import { Button } from '@/components/ui/button'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next' import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { config, configUtils } from '@/lib/config' import { config, configUtils } from '@/lib/config'
import { useRelayHub } from '@/composables/useRelayHub' import { relayHubComposable } from '@/composables/useRelayHub'
const props = defineProps<{ const props = defineProps<{
relays?: string[] relays?: string[]
feedType?: 'all' | 'announcements' | 'events' | 'general' feedType?: 'all' | 'announcements' | 'events' | 'general'
}>() }>()
const relayHub = useRelayHub() const relayHub = relayHubComposable
// Reactive state // Reactive state
const notes = ref<any[]>([]) const notes = ref<any[]>([])
@ -104,10 +104,10 @@ async function loadNotes() {
created_at: event.created_at, created_at: event.created_at,
tags: event.tags || [], tags: event.tags || [],
// Extract mentions from 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 // Check if it's a reply
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'), isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1] replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
})) }))
// Sort by creation time (newest first) // Sort by creation time (newest first)
@ -161,9 +161,9 @@ async function startRealtimeSubscription() {
content: event.content, content: event.content,
created_at: event.created_at, created_at: event.created_at,
tags: event.tags || [], tags: event.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]) || [],
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'), isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1] replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
} }
// Check if note should be included // Check if note should be included

View file

@ -20,7 +20,7 @@ interface Emits {
(e: 'confirm'): void (e: 'confirm'): void
} }
const props = withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
variant: 'destructive', variant: 'destructive',
size: 'default' size: 'default'
}) })

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { LogOut, AlertTriangle } from 'lucide-vue-next' import { LogOut, AlertTriangle } from 'lucide-vue-next'

View file

@ -1,7 +1,7 @@
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue' import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
import { useNostrStore } from '@/stores/nostr' import { useNostrStore } from '@/stores/nostr'
import { useMarketStore } from '@/stores/market' import { useMarketStore } from '@/stores/market'
import { useRelayHub } from '@/composables/useRelayHub' import { relayHubComposable } from './useRelayHub'
import { config } from '@/lib/config' import { config } from '@/lib/config'
// Nostr event kinds for market functionality // Nostr event kinds for market functionality
@ -15,7 +15,7 @@ const MARKET_EVENT_KINDS = {
export function useMarket() { export function useMarket() {
const nostrStore = useNostrStore() const nostrStore = useNostrStore()
const marketStore = useMarketStore() const marketStore = useMarketStore()
const relayHub = useRelayHub() const relayHub = relayHubComposable
// State // State
const isLoading = ref(false) const isLoading = ref(false)
@ -420,23 +420,23 @@ export function useMarket() {
} }
// Handle order events // Handle order events
const handleOrderEvent = (event: any) => { const handleOrderEvent = (_event: any) => {
try { try {
const orderData = JSON.parse(event.content) // const orderData = JSON.parse(event.content)
const order = { // const order = {
id: event.id, // id: event.id,
stall_id: orderData.stall_id || 'unknown', // stall_id: orderData.stall_id || 'unknown',
product_id: orderData.product_id || 'unknown', // product_id: orderData.product_id || 'unknown',
buyer_pubkey: event.pubkey, // buyer_pubkey: event.pubkey,
seller_pubkey: orderData.seller_pubkey || '', // seller_pubkey: orderData.seller_pubkey || '',
quantity: orderData.quantity || 1, // quantity: orderData.quantity || 1,
total_price: orderData.total_price || 0, // total_price: orderData.total_price || 0,
currency: orderData.currency || 'sats', // currency: orderData.currency || 'sats',
status: orderData.status || 'pending', // status: orderData.status || 'pending',
payment_request: orderData.payment_request, // payment_request: orderData.payment_request,
created_at: event.created_at, // created_at: event.created_at,
updated_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 // Note: addOrder method doesn't exist in the store, so we'll just handle it silently
} catch (err) { } catch (err) {

View file

@ -4,7 +4,7 @@ import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
import { hexToBytes } from '@/lib/utils/crypto' import { hexToBytes } from '@/lib/utils/crypto'
import { getAuthToken } from '@/lib/config/lnbits' import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { useRelayHub } from './useRelayHub' import { relayHubComposable } from './useRelayHub'
import { useAuth } from './useAuth' import { useAuth } from './useAuth'
// Types // Types
@ -66,7 +66,7 @@ const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
export function useNostrChat() { export function useNostrChat() {
// Use the centralized relay hub // Use the centralized relay hub
const relayHub = useRelayHub() const relayHub = relayHubComposable
// Use the main authentication system // Use the main authentication system
const auth = useAuth() const auth = useAuth()
@ -82,9 +82,60 @@ export function useNostrChat() {
// Track latest message timestamp for each peer (for sorting) // Track latest message timestamp for each peer (for sorting)
const latestMessageTimestamps = ref<Map<string, number>>(new Map()) const latestMessageTimestamps = ref<Map<string, number>>(new Map())
// Store peers globally // Track peers globally
const peers = ref<any[]>([]) 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 // Computed - use relay hub's connection status and auth system
const isConnected = computed(() => relayHub.isConnected.value) const isConnected = computed(() => relayHub.isConnected.value)
@ -322,6 +373,11 @@ export function useNostrChat() {
await relayHub.connect() 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) { } catch (error) {
console.error('Failed to connect to relays:', error) console.error('Failed to connect to relays:', error)
@ -495,12 +551,44 @@ export function useNostrChat() {
return 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 { try {
// For NIP-04 direct messages, always use peerPubkey as the second argument // For NIP-04 direct messages, always use peerPubkey as the second argument
// This is the public key of the other party in the conversation // This is the public key of the other party in the conversation
const isSentByMe = event.pubkey === currentUser.value.pubkey 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( const decryptedContent = await nip04.decrypt(
currentUser.value.prvkey, currentUser.value.prvkey,
@ -556,7 +644,40 @@ export function useNostrChat() {
onMessageAdded.value(peerPubkey) onMessageAdded.value(peerPubkey)
} }
} catch (error) { } 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, subscribeToAllPeersForNotifications,
currentUser, currentUser,
hasNostrKeys, 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
} }
} }

View 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()

View 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
}
}

View 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()

View file

@ -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 { relayHub, type SubscriptionConfig, type RelayStatus } from '../lib/nostr/relayHub'
import { config } from '../lib/config' import { config } from '../lib/config'
@ -31,19 +31,24 @@ export function useRelayHub() {
error.value = null error.value = null
// Get relay URLs from config // 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) { if (!relayUrls || relayUrls.length === 0) {
throw new Error('No relay URLs configured') throw new Error('No relay URLs configured')
} }
// Initialize the relay hub // Initialize the relay hub
console.log('🔧 RelayHub: Calling relayHub.initialize...')
await relayHub.initialize(relayUrls) await relayHub.initialize(relayUrls)
console.log('🔧 RelayHub: Initialization successful')
// Set up event listeners // Set up event listeners
setupEventListeners() setupEventListeners()
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
isConnected.value = true isConnected.value = true
console.log('🔧 RelayHub: Connection status set to connected')
} catch (err) { } catch (err) {
@ -51,7 +56,7 @@ export function useRelayHub() {
error.value = errorObj error.value = errorObj
connectionStatus.value = 'error' connectionStatus.value = 'error'
isConnected.value = false isConnected.value = false
console.error('Failed to initialize RelayHub:', errorObj) console.error('🔧 RelayHub: Failed to initialize RelayHub:', errorObj)
throw 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 // Get relay status
const getRelayStatus = (url: string): RelayStatus | undefined => { const getRelayStatus = (url: string): RelayStatus | undefined => {
return relayStatuses.value.find(status => status.url === url) 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 // Set up event listeners for relay hub events
const setupEventListeners = (): void => { const setupEventListeners = (): void => {
relayHub.on('connected', (count: number) => { relayHub.on('connected', (count: number) => {
@ -274,18 +255,16 @@ export function useRelayHub() {
return { return {
// State // State
isConnected, isConnected: readonly(isConnected),
connectionStatus, connectionStatus: readonly(connectionStatus),
relayStatuses, relayStatuses: readonly(relayStatuses),
error, error: readonly(error),
activeSubscriptions, activeSubscriptions: readonly(activeSubscriptions),
connectedRelayCount: readonly(connectedRelayCount),
// Computed totalRelayCount: readonly(totalRelayCount),
connectedRelayCount, totalSubscriptionCount: readonly(totalSubscriptionCount),
totalRelayCount, subscriptionDetails: readonly(subscriptionDetails),
totalSubscriptionCount, connectionHealth: readonly(connectionHealth),
subscriptionDetails,
connectionHealth,
// Methods // Methods
initialize, initialize,
@ -294,9 +273,11 @@ export function useRelayHub() {
subscribe, subscribe,
publishEvent, publishEvent,
queryEvents, queryEvents,
reconnect,
getRelayStatus, getRelayStatus,
isRelayConnected, getConnectionHealth: connectionHealth,
cleanup cleanup
} }
} }
// Export singleton instance for global state
export const relayHubComposable = useRelayHub()

View file

@ -28,6 +28,10 @@ interface AppConfig {
api: ApiConfig api: ApiConfig
push: PushConfig push: PushConfig
market: MarketConfig market: MarketConfig
nostrclient: {
url: string
enabled: boolean
}
support: { support: {
npub: string npub: string
} }
@ -72,6 +76,10 @@ export const config: AppConfig = {
lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED), lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED),
defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat' 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: { support: {
npub: import.meta.env.VITE_SUPPORT_NPUB || '' npub: import.meta.env.VITE_SUPPORT_NPUB || ''
} }

View 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'
})

View file

@ -130,6 +130,8 @@ export class RelayHub extends EventEmitter {
return return
} }
console.log('🔧 RelayHub: Initializing with URLs:', relayUrls)
// Convert URLs to relay configs // Convert URLs to relay configs
this.relayConfigs.clear() this.relayConfigs.clear()
relayUrls.forEach((url, index) => { 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 // Start connection management
console.log('🔧 RelayHub: Starting connection...')
await this.connect() await this.connect()
this.startHealthCheck() this.startHealthCheck()
this.isInitialized = true 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.') throw new Error('No relay configurations found. Call initialize() first.')
} }
console.log('🔧 RelayHub: Connecting to', this.relayConfigs.size, 'relays')
try { try {
this._connectionAttempts++ this._connectionAttempts++
console.log('🔧 RelayHub: Connection attempt', this._connectionAttempts)
// Connect to relays in priority order // Connect to relays in priority order
const sortedRelays = Array.from(this.relayConfigs.values()) const sortedRelays = Array.from(this.relayConfigs.values())
.sort((a, b) => (a.priority || 0) - (b.priority || 0)) .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) => { const connectionPromises = sortedRelays.map(async (config) => {
try { try {
console.log('🔧 RelayHub: Connecting to relay:', config.url)
const relay = await this.pool.ensureRelay(config.url) const relay = await this.pool.ensureRelay(config.url)
this.connectedRelays.set(config.url, relay) this.connectedRelays.set(config.url, relay)
console.log('🔧 RelayHub: Successfully connected to:', config.url)
return { url: config.url, success: true } return { url: config.url, success: true }
} catch (error) { } 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 } return { url: config.url, success: false, error }
} }
}) })
@ -182,25 +192,34 @@ export class RelayHub extends EventEmitter {
result => result.status === 'fulfilled' && result.value.success 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) { if (successfulConnections.length > 0) {
this._isConnected = true this._isConnected = true
this._connectionAttempts = 0 this._connectionAttempts = 0
console.log('🔧 RelayHub: Connection successful, connected to', successfulConnections.length, 'relays')
this.emit('connected', successfulConnections.length) this.emit('connected', successfulConnections.length)
} else { } else {
console.error('🔧 RelayHub: Failed to connect to any relay')
throw new Error('Failed to connect to any relay') throw new Error('Failed to connect to any relay')
} }
} catch (error) { } catch (error) {
this._isConnected = false this._isConnected = false
console.error('🔧 RelayHub: Connection failed with error:', error)
this.emit('connectionError', error) this.emit('connectionError', error)
console.error('Connection failed:', error)
// Schedule reconnection if we haven't exceeded max attempts // Schedule reconnection if we haven't exceeded max attempts
if (this._connectionAttempts < this.maxReconnectAttempts) { if (this._connectionAttempts < this.maxReconnectAttempts) {
console.log('🔧 RelayHub: Scheduling reconnection attempt', this._connectionAttempts + 1)
this.scheduleReconnect() this.scheduleReconnect()
} else { } else {
this.emit('maxReconnectAttemptsReached') this.emit('maxReconnectionAttemptsReached')
console.error('Max reconnection attempts reached') console.error('🔧 RelayHub: Max reconnection attempts reached')
} }
} }
} }

18
src/lib/nostr/utils.ts Normal file
View 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
}

View file

@ -1,6 +1,6 @@
// Notification manager for push notifications // Notification manager for push notifications
import { pushService, type NotificationPayload } from './push' // import type { NotificationPayload } from './push'
import { configUtils } from '@/lib/config'
export interface NotificationOptions { export interface NotificationOptions {
enabled: boolean enabled: boolean
@ -69,19 +69,19 @@ export class NotificationManager {
throw new Error('Notifications are disabled') throw new Error('Notifications are disabled')
} }
const payload: NotificationPayload = { // const payload: NotificationPayload = {
title: '🧪 Test Notification', // title: '🧪 Test Notification',
body: 'This is a test notification from Ario', // body: 'This is a test notification from Ario',
tag: 'test', // tag: 'test',
icon: '/apple-touch-icon.png', // icon: '/apple-touch-icon.png',
badge: '/apple-touch-icon.png', // badge: '/apple-touch-icon.png',
data: { // data: {
url: window.location.origin, // url: window.location.origin,
timestamp: Date.now() // timestamp: Date.now()
} // }
} // }
await pushService.sendNotification(payload) // await pushService.sendNotification(payload)
} }
} }

View 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()

View 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()

View 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
View 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
View 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
View 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>

View file

@ -85,10 +85,10 @@
</div> </div>
<!-- Cart Summary --> <!-- 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"> <Button @click="viewCart" class="shadow-lg">
<ShoppingCart class="w-5 h-5 mr-2" /> <ShoppingCart class="w-5 h-5 mr-2" />
Cart ({{ marketStore.cartItemCount }}) Cart ({{ marketStore.totalCartItems }})
</Button> </Button>
</div> </div>
</div> </div>
@ -97,6 +97,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue' import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '@/stores/market' import { useMarketStore } from '@/stores/market'
import { useMarket } from '@/composables/useMarket' import { useMarket } from '@/composables/useMarket'
import { useMarketPreloader } from '@/composables/useMarketPreloader' import { useMarketPreloader } from '@/composables/useMarketPreloader'
@ -108,6 +109,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ShoppingCart } from 'lucide-vue-next' import { ShoppingCart } from 'lucide-vue-next'
import ProductCard from '@/components/market/ProductCard.vue' import ProductCard from '@/components/market/ProductCard.vue'
const router = useRouter()
const marketStore = useMarketStore() const marketStore = useMarketStore()
const market = useMarket() const market = useMarket()
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
@ -158,12 +160,12 @@ const addToCart = (product: any) => {
marketStore.addToCart(product) marketStore.addToCart(product)
} }
const viewProduct = (product: any) => { const viewProduct = (_product: any) => {
// TODO: Navigate to product detail page // TODO: Navigate to product detail page
} }
const viewCart = () => { const viewCart = () => {
// TODO: Navigate to cart page router.push('/cart')
} }
onMounted(() => { onMounted(() => {

View 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
View 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>

View file

@ -184,8 +184,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import RelayHubStatus from '@/components/RelayHubStatus.vue' import RelayHubStatus from '@/components/RelayHubStatus.vue'
import { useRelayHub } from '@/composables/useRelayHub'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { relayHubComposable } from '@/composables/useRelayHub'
const { const {
isConnected, isConnected,
@ -196,7 +196,7 @@ const {
initialize, initialize,
connect, connect,
subscribe subscribe
} = useRelayHub() } = relayHubComposable
// Test state // Test state
const isTesting = ref(false) const isTesting = ref(false)

View file

@ -49,6 +49,36 @@ const router = createRouter({
requiresAuth: true 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', path: '/chat',
name: 'chat', name: 'chat',

View file

@ -1,5 +1,10 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue' 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 // Types
@ -25,7 +30,9 @@ export interface Stall {
description?: string description?: string
logo?: string logo?: string
categories?: string[] categories?: string[]
shipping?: Record<string, any> shipping?: ShippingZone[]
currency: string
nostrEventId?: string // Nostr event ID for nostrmarket integration
} }
export interface Product { export interface Product {
@ -41,23 +48,91 @@ export interface Product {
categories?: string[] categories?: string[]
createdAt: number createdAt: number
updatedAt: number updatedAt: number
nostrEventId?: string // Nostr event ID for nostrmarket integration
} }
// Enhanced Order interface for the new system
export interface Order { export interface Order {
id: string id: string
stall_id: string cartId: string
product_id: string stallId: string
buyer_pubkey: string buyerPubkey: string
seller_pubkey: string sellerPubkey: string
quantity: number status: OrderStatus
total_price: number items: OrderItem[]
contactInfo: ContactInfo
shippingZone: ShippingZone
paymentRequest?: string
paymentMethod: PaymentMethod
subtotal: number
shippingCost: number
total: number
currency: string currency: string
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' createdAt: number
payment_request?: string updatedAt: number
created_at: number // Nostr integration fields
updated_at: number 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 { export interface FilterData {
categories: string[] categories: string[]
merchants: string[] merchants: string[]
@ -65,6 +140,8 @@ export interface FilterData {
currency: string | null currency: string | null
priceFrom: number | null priceFrom: number | null
priceTo: number | null priceTo: number | null
inStock: boolean | null
paymentMethods: PaymentMethod[]
} }
export interface SortOptions { export interface SortOptions {
@ -72,6 +149,22 @@ export interface SortOptions {
order: 'asc' | 'desc' 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', () => { export const useMarketStore = defineStore('market', () => {
// Core market state // Core market state
const markets = ref<Market[]>([]) const markets = ref<Market[]>([])
@ -98,7 +191,9 @@ export const useMarketStore = defineStore('market', () => {
stalls: [], stalls: [],
currency: null, currency: null,
priceFrom: null, priceFrom: null,
priceTo: null priceTo: null,
inStock: null,
paymentMethods: []
}) })
const sortOptions = ref<SortOptions>({ const sortOptions = ref<SortOptions>({
@ -106,9 +201,21 @@ export const useMarketStore = defineStore('market', () => {
order: 'asc' 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 }>>({}) 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 // Computed properties
const filteredProducts = computed(() => { const filteredProducts = computed(() => {
let filtered = products.value 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 // Sort
filtered.sort((a, b) => { filtered.sort((a, b) => {
const aVal = a[sortOptions.value.field as keyof Product] 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(() => { const cartTotal = computed(() => {
return Object.values(shoppingCart.value).reduce((total, item) => { return Object.values(shoppingCart.value).reduce((total, item) => {
return total + (item.product.price * item.quantity) return total + (item.product.price * item.quantity)
@ -291,6 +433,448 @@ export const useMarketStore = defineStore('market', () => {
shoppingCart.value = {} 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>) => { const updateFilterData = (newFilterData: Partial<FilterData>) => {
filterData.value = { ...filterData.value, ...newFilterData } filterData.value = { ...filterData.value, ...newFilterData }
} }
@ -302,7 +886,9 @@ export const useMarketStore = defineStore('market', () => {
stalls: [], stalls: [],
currency: null, currency: null,
priceFrom: null, priceFrom: null,
priceTo: null priceTo: null,
inStock: null,
paymentMethods: []
} }
} }
@ -329,6 +915,9 @@ export const useMarketStore = defineStore('market', () => {
}).format(price) }).format(price)
} }
// Initialize orders from localStorage
loadOrdersFromStorage()
return { return {
// State // State
markets: readonly(markets), markets: readonly(markets),
@ -346,10 +935,20 @@ export const useMarketStore = defineStore('market', () => {
filterData: readonly(filterData), filterData: readonly(filterData),
sortOptions: readonly(sortOptions), sortOptions: readonly(sortOptions),
shoppingCart: readonly(shoppingCart), shoppingCart: readonly(shoppingCart),
stallCarts: readonly(stallCarts),
checkoutCart: readonly(checkoutCart),
checkoutStall: readonly(checkoutStall),
activeOrder: readonly(activeOrder),
paymentRequest: readonly(paymentRequest),
paymentStatus: readonly(paymentStatus),
// Computed // Computed
filteredProducts, filteredProducts,
allCategories, allCategories,
allStallCarts,
totalCartItems,
totalCartValue,
activeStallCart,
cartTotal, cartTotal,
cartItemCount, cartItemCount,
@ -371,6 +970,33 @@ export const useMarketStore = defineStore('market', () => {
clearFilters, clearFilters,
toggleCategoryFilter, toggleCategoryFilter,
updateSortOptions, 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
} }
}) })