feat: Add market integration roadmap to NOSTR architecture documentation
- Introduce a comprehensive roadmap for integrating nostr-market-app purchasing functionality into the web-app. - Outline key components of the shopping cart system, checkout process, and order management. - Detail phased implementation strategy, including enhanced user experience and advanced features. - Include security, performance, and testing considerations to ensure robust integration. feat: Enhance market store with new order and cart management features - Introduce new interfaces for Order, OrderItem, ContactInfo, and ShippingZone to support enhanced order management. - Update Stall and Product interfaces to include currency and shipping details. - Implement a comprehensive shopping cart system with stall-specific carts, including methods for adding, removing, and updating items. - Add payment-related interfaces and methods for managing payment requests and statuses. - Enhance filter options to include in-stock status and payment methods, improving product filtering capabilities. - Refactor computed properties and methods for better cart management and checkout processes. feat: Implement shopping cart functionality with new components and routing - Add ShoppingCart, CartItem, and CartSummary components to manage cart items and display summaries. - Introduce Cart.vue page to serve as the main shopping cart interface, integrating cart and summary components. - Update Navbar.vue to include a cart icon with item count, enhancing user navigation. - Implement cart management features in the market store, including item addition, quantity updates, and removal. - Establish routing for the cart page, ensuring seamless navigation for users. - Enhance ProductCard.vue to support adding items to the cart directly from the product listing. feat: Update cart and checkout functionality with improved navigation and button labels - Change "Proceed to Checkout" button text to dynamic "Place Order" based on context in CartSummary.vue. - Update "Continue Shopping" button to "Back to Cart" in CartSummary.vue for clearer navigation. - Modify routing for checkout to include stall ID in ShoppingCart.vue, enhancing checkout process. - Simplify Cart.vue by removing CartSummary component and focusing on ShoppingCart display. - Add new route for checkout with stall ID in router configuration for better handling of checkout flows. feat: Enhance cart and checkout components with improved shipping address handling - Update CartSummary.vue to use readonly types for cart items and shipping zones, ensuring immutability. - Modify Checkout.vue to conditionally display the shipping address field based on the selected shipping zone's requirements for physical shipping. - Add a digital delivery note for products that do not require a shipping address. - Introduce a computed property to determine if a shipping address is required, improving validation logic during checkout. - Update market store to include a new property for shipping zones indicating if physical shipping is required. feat: Implement order placement functionality in checkout process - Add a "Place Order" button in Checkout.vue that triggers the order placement process. - Introduce loading state during order placement to enhance user experience. - Implement createAndPlaceOrder method in market store to handle order creation and status updates. - Include error handling for order placement failures, providing user feedback on errors. - Update checkout logic to validate shipping zone and contact information before proceeding. feat: Add Order History page and update Navbar for order tracking - Introduce a new OrderHistory.vue page to display users' past orders with filtering and sorting options. - Update Navbar.vue to include an "Order History" option with a badge showing the count of orders. - Implement computed properties for order count and enhance user navigation experience. feat: Integrate Nostr functionality for order management and user notifications - Add NostrExtensionGuide component to inform users about the required Nostr extension for order transmission. - Implement useNostrOrders composable to manage Nostr connection, event creation, and order sending. - Update Checkout.vue to display Nostr connection status and provide feedback on order transmission. - Enhance OrderHistory.vue to show Nostr transmission status and details for each order. - Modify market store to handle Nostr event details and errors during order placement, ensuring local fallback. - Introduce types for Nostr events to improve type safety and integration with the existing order management system. refactor: Update Nostr relay configuration to use environment variable - Change DEFAULT_RELAYS to dynamically retrieve relay URLs from the VITE_MARKET_RELAYS environment variable. - Add error handling to ensure relays are configured before establishing a connection. - Modify createBlankEvent function to return a more precise type. - Update event signing process to ensure the event ID is generated correctly before signing. refactor: useAuth switch Enhance Nostr order management with authentication checks - Integrate user authentication checks to ensure Nostr features are only accessible to authenticated users. - Replace direct window.nostr calls with auth store methods for retrieving public and private keys. - Implement a helper function for signing events and mock encryption for order content. - Remove obsolete Nostr type definitions to streamline the codebase. feat: Enhance Checkout.vue with Nostr processing feedback and cleanup - Update the checkout button to disable based on order placement state. - Simplify order placement feedback by removing unnecessary Nostr processing checks. - Introduce a new visual indicator for Nostr order processing status. - Refactor computed properties for better clarity and efficiency in shipping zone handling. refactor: Streamline Nostr order handling and integrate buyer public key retrieval - Remove redundant Nostr relay tag from order event creation in useNostrOrders. - Update Checkout.vue to retrieve the buyer's public key from the auth store, enhancing order placement logic. - Modify createAndPlaceOrder method in market store to accept an optional Nostr orders instance for improved flexibility in order processing. refactor: Remove Nostr-related components and streamline order processing - Delete NostrExtensionGuide.vue and associated type definitions to simplify the codebase. - Remove unused useNostr.ts file and related logic from useNostrOrders.ts. - Update order handling in market store to directly integrate Nostr publishing without relying on external components. - Enhance Checkout.vue and Cart.vue to reflect changes in Nostr integration and provide clearer order status feedback. feat: Enhance Nostr chat functionality with malformed message handling - Introduce tracking for malformed message IDs to prevent repeated processing attempts. - Implement functions to mark messages as malformed, clean up old entries, and retrieve statistics on malformed messages. - Add periodic cleanup of malformed messages to manage memory usage. - Enhance message processing logic to skip previously identified malformed messages and provide detailed error handling for decryption failures. - Update the return object to include new functions for managing malformed messages. ZZ feat: Implement Lightning invoice management in market store - Add functionality to create and manage Lightning invoices for orders. - Introduce payment monitoring and status updates for invoices. - Implement payment confirmation messaging via Nostr upon successful payment. - Enhance order interface to include new fields for Lightning invoice details and payment status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. ZZ feat: Enhance OrderHistory.vue with payment status indicators and invoice management - Add visual indicators for payment status, including 'Paid' and 'Payment Pending' badges. - Implement expandable payment display for orders with Lightning invoices. - Introduce functionality to toggle payment display and generate Lightning invoices. - Update order status messaging to reflect payment requirements and invoice generation status. feat: Implement order event handling in useOrderEvents composable - Introduce useOrderEvents composable to manage subscription and processing of order-related events. - Define order event types and interfaces for better type safety and clarity. - Implement methods to handle payment requests, order status updates, and invoice generation. - Enhance OrderHistory.vue to display order event subscription status and last update timestamp. - Update market store to include order update functionality for better integration with order events. FIX: Build errors refactor: Update component styles and improve UI consistency across market pages - Replace various color classes with updated design tokens for better consistency. - Change background colors of components to align with the new design system. - Update text colors to enhance readability and maintain a cohesive look. - Refactor class names in CartItem.vue, CartSummary.vue, DashboardOverview.vue, and other components to use the new color scheme. - Ensure all components reflect the updated design guidelines for a unified user experience. refactor: Remove Order History references from Navbar component - Eliminate order count computation and related UI elements from the Navbar. - Streamline the Navbar by removing the Order History button and badge. - Maintain existing functionality for other menu items, ensuring a cleaner user interface. feat: Implement QR code generation and download functionality in PaymentDisplay component - Add QR code generation for payment requests using the qrcode library. - Enhance UI to display loading states and error messages during QR code generation. - Introduce a download button for users to save the generated QR code. - Implement logic to regenerate QR code when the invoice changes. refactor: Replace useRelayHub with relayHubComposable across components - Update imports in multiple components and composables to use the new relayHubComposable for better consistency and maintainability. - Enhance OrderHistory.vue with debug information for development, displaying key states related to orders, authentication, and relay hub connectivity. - Remove unnecessary reconnect button from RelayHubStatus.vue to streamline user interactions. - Improve logging in useOrderEvents for better debugging and monitoring of order event subscriptions. refactor: Update OrderHistory.vue styles for improved UI consistency - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles for payment status indicators. - Ensure a cohesive look across the component by standardizing class names and styles. refactor: Update component styles for improved UI consistency across checkout pages - Replace color classes with updated design tokens for better alignment with the new design system. - Enhance readability by adjusting text colors and background styles in CartSummary.vue, PaymentDisplay.vue, Checkout.vue, and OrderHistory.vue. - Standardize class names and styles to ensure a cohesive look across all components. feat: Implement invoice generation and Nostr integration in MerchantStore component - Add functionality to generate Lightning invoices for orders and send them to customers via Nostr. - Introduce a new sendInvoiceToCustomer method to update order details and publish invoice information. - Enhance order event handling in useOrderEvents to update existing orders with new invoice data. - Improve error handling and logging for invoice generation and sending processes. feat: Enhance MerchantStore and PaymentDisplay components for improved invoice handling - Add wallet indicator in MerchantStore to display the selected wallet name during pending orders. - Implement temporary fixes for missing buyer and seller public keys when generating invoices. - Update invoice generation logic to utilize the first available wallet and improve error handling. - Modify PaymentDisplay to use the new bolt11 field for payment requests and enhance date formatting. - Refactor order event handling to ensure accurate updates and invoice management across components. feat: Enhance order event processing in useOrderEvents composable - Refactor processOrderEvent to handle incoming Nostr market order events with improved validation and logging. - Implement logic to update existing orders or create new ones based on event data, ensuring accurate order management. - Add detailed console logging for better debugging and tracking of order events and their statuses. - Ensure compatibility with market order structure and invoice details for seamless integration with payment processing. feat: Enhance order management with localStorage persistence - Update createOrder method to optionally accept an order ID from events, improving order tracking. - Convert items from readonly to mutable for better manipulation. - Implement localStorage persistence for orders, ensuring data is saved and loaded across sessions. - Add methods to save and load orders from localStorage, enhancing user experience and data reliability. feat: Update invoice creation to support additional metadata and nostrmarket compatibility - Modify createInvoice method to accept an optional extra parameter for additional metadata. - Change invoice tag to 'nostrmarket' for improved compatibility with Nostr market. - Include merchant and buyer public keys in the invoice data for better integration. - Update invoice creation in market store to utilize new parameters for enhanced functionality. feat: Enhance order and invoice handling for Nostr market compatibility - Add originalOrderId to order events for tracking Nostr order IDs. - Update invoice creation to utilize original Nostr order ID when generating invoices. - Improve logging for invoice requests to LNBits, providing better visibility into the data being sent. - Ensure compatibility with nostrmarket by adjusting order ID handling in the market store. fix: Refine invoice creation logic for Nostr market compatibility - Adjust order ID handling in invoice creation to prioritize originalOrderId for better compatibility with nostrmarket. - Enhance logging to provide clearer insights into the order ID being used during invoice generation. feat: Integrate nostrmarket service for order publishing and merchant catalog management - Implement functionality to publish orders via the nostrmarket protocol, replacing the previous Nostr integration. - Add methods to publish merchant catalogs, including stalls and products, to nostrmarket with event ID tracking. - Enhance order interface to include nostrEventId for better integration with nostrmarket. - Improve error handling and logging for nostrmarket publishing processes. refactor: Simplify order creation logic in useOrderEvents and update contact structure in nostrmarketService - Streamline order creation by using event.id and defaulting to 'unknown' for stallId. - Update contact structure to include address and message, removing optional email and phone fields for clarity. - Ensure compatibility with new order data structure for improved integration with nostrmarket. feat: Add bech32 to hex conversion utility and integrate into nostrmarketService - Implement a new utility function to convert bech32 keys to hex format, enhancing key handling. - Update nostrmarketService to utilize the new conversion function for user public and private keys. - Modify contact structure to include additional fields for improved order information management. feat: Add nostrclient configuration to AppConfig for enhanced Nostr integration - Introduce a new nostrclient property in AppConfig to manage Nostr client settings. - Include url and enabled fields to configure the Nostr client connection dynamically. - Ensure compatibility with environment variables for flexible deployment configurations. feat: Introduce comprehensive order management and fulfillment documentation - Add ORDER_MANAGEMENT_FULFILLMENT.md to detail the complete order lifecycle, including order states, data models, and merchant/customer interfaces. - Implement test scripts for verifying order and payment request formats in test-nostrmarket-format.js. - Create PaymentRequestDialog.vue for handling payment requests with dynamic options and QR code generation. - Enhance useOrderEvents.ts to process nostrmarket protocol messages for order management. - Update nostrmarketService.ts to handle payment requests and order status updates, ensuring seamless integration with the marketplace. - Integrate payment request dialog in Market.vue and manage its state in the market store. refactor: Remove obsolete test script for nostrmarket order format - Delete test-nostrmarket-format.js as it is no longer needed for verifying order and payment request formats. - Update PaymentRequestDialog.vue to enhance UI components and integrate QR code generation for payment requests. - Refactor payment handling and notification logic to utilize toast notifications instead of Quasar's notify system. feat: Enhance OrderHistory component with payment request handling and QR code generation - Add UI elements to display payment request status and options in OrderHistory.vue. - Implement functions to copy payment requests, open Lightning wallets, and download QR codes. - Update nostrmarketService to generate QR codes for payment requests and manage order statuses effectively. - Remove obsolete PaymentRequestDialog integration from Market.vue for a cleaner UI. feat: Add debug information and toast notifications in OrderHistory component - Introduce debug info display for payment requests and hashes in OrderHistory.vue. - Implement toast notifications for actions like copying payment requests, opening wallets, and downloading QR codes. - Enhance error handling with user feedback for various order-related actions. - Remove obsolete payment request dialog methods from market store for cleaner code. feat: Revamp CartItem and ShoppingCart components for improved layout and functionality - Enhance CartItem.vue with responsive design for desktop and mobile views, including better organization of product details, price, quantity controls, and remove button. - Update ShoppingCart.vue to separate desktop and mobile layouts, improving the user experience with clearer action buttons and cart summary display. - Implement consistent styling and layout adjustments for better visual coherence across different screen sizes.
This commit is contained in:
parent
93ffb8bf32
commit
ea5a2380f1
43 changed files with 8983 additions and 146 deletions
|
|
@ -6,10 +6,10 @@ import Footer from '@/components/layout/Footer.vue'
|
|||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import 'vue-sonner/style.css'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { nostrChat } from '@/composables/useNostrChat'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -19,7 +19,7 @@ const showLoginDialog = ref(false)
|
|||
const marketPreloader = useMarketPreloader()
|
||||
|
||||
// Initialize relay hub
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Hide navbar on login page
|
||||
const showNavbar = computed(() => {
|
||||
|
|
|
|||
301
src/components/NostrmarketPublisher.vue
Normal file
301
src/components/NostrmarketPublisher.vue
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
<template>
|
||||
<div class="nostrmarket-publisher">
|
||||
<div class="publisher-header">
|
||||
<h3>Nostrmarket Integration</h3>
|
||||
<p>Publish your stalls and products to the nostrmarket network</p>
|
||||
</div>
|
||||
|
||||
<div class="publisher-status">
|
||||
<div class="status-item">
|
||||
<span class="label">Stalls:</span>
|
||||
<span class="value">{{ stallCount }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Products:</span>
|
||||
<span class="value">{{ productCount }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Published:</span>
|
||||
<span class="value">{{ publishedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="publisher-actions">
|
||||
<button
|
||||
@click="publishCatalog"
|
||||
:disabled="isPublishing || !canPublish"
|
||||
class="publish-btn"
|
||||
:class="{ 'publishing': isPublishing }"
|
||||
>
|
||||
<span v-if="isPublishing">Publishing...</span>
|
||||
<span v-else>Publish to Nostrmarket</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="refreshStatus"
|
||||
:disabled="isRefreshing"
|
||||
class="refresh-btn"
|
||||
>
|
||||
<span v-if="isRefreshing">Refreshing...</span>
|
||||
<span v-else>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="publish-result">
|
||||
<h4>Last Publication Result:</h4>
|
||||
<div class="result-details">
|
||||
<div class="result-section">
|
||||
<h5>Stalls Published:</h5>
|
||||
<ul>
|
||||
<li v-for="(eventId, stallId) in lastResult.stalls" :key="stallId">
|
||||
{{ getStallName(stallId) }}: {{ eventId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="result-section">
|
||||
<h5>Products Published:</h5>
|
||||
<ul>
|
||||
<li v-for="(eventId, productId) in lastResult.products" :key="productId">
|
||||
{{ getProductName(productId) }}: {{ eventId }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<p>Error: {{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrmarketService } from '@/lib/services/nostrmarketService'
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// State
|
||||
const isPublishing = ref(false)
|
||||
const isRefreshing = ref(false)
|
||||
const lastResult = ref<any>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const stallCount = computed(() => marketStore.stalls.length)
|
||||
const productCount = computed(() => marketStore.products.length)
|
||||
const publishedCount = computed(() => {
|
||||
const publishedStalls = marketStore.stalls.filter(s => s.nostrEventId).length
|
||||
const publishedProducts = marketStore.products.filter(p => p.nostrEventId).length
|
||||
return publishedStalls + publishedProducts
|
||||
})
|
||||
|
||||
const canPublish = computed(() => {
|
||||
return stallCount.value > 0 && productCount.value > 0
|
||||
})
|
||||
|
||||
// Methods
|
||||
const publishCatalog = async () => {
|
||||
if (!canPublish.value) return
|
||||
|
||||
isPublishing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await marketStore.publishToNostrmarket()
|
||||
lastResult.value = result
|
||||
console.log('Catalog published successfully:', result)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
console.error('Failed to publish catalog:', err)
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
isRefreshing.value = true
|
||||
|
||||
try {
|
||||
// Force a refresh of the store data
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh status:', err)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || stallId
|
||||
}
|
||||
|
||||
const getProductName = (productId: string) => {
|
||||
const product = marketStore.products.find(p => p.id === productId)
|
||||
return product?.name || productId
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nostrmarket-publisher {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.publisher-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.publisher-header p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.publisher-status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.publisher-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.publish-btn, .refresh-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.publish-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.publish-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.publish-btn.publishing {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
background: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.publish-result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.publish-result h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #166534;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-section h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #166534;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.result-section li {
|
||||
margin-bottom: 0.25rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.375rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -43,15 +43,12 @@
|
|||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="connect" :disabled="isConnected || connectionStatus === 'connecting'">
|
||||
<button @click="connect" :disabled="connectionStatus === 'connecting'">
|
||||
Connect
|
||||
</button>
|
||||
<button @click="disconnect" :disabled="!isConnected">
|
||||
Disconnect
|
||||
</button>
|
||||
<button @click="reconnect" :disabled="connectionStatus === 'connecting'">
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="subscription-info">
|
||||
|
|
@ -65,7 +62,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
|
|
@ -78,9 +75,8 @@ const {
|
|||
totalSubscriptionCount,
|
||||
connectionHealth,
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect
|
||||
} = useRelayHub()
|
||||
disconnect
|
||||
} = relayHubComposable
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity } from 'lucide-vue-next'
|
||||
import { Sun, Moon, Menu, X, User, LogOut, Ticket, Wallet, MessageSquare, Activity, ShoppingCart, BarChart3 } from 'lucide-vue-next'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
|
||||
|
|
@ -14,6 +14,7 @@ import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue'
|
|||
import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrChat } from '@/composables/useNostrChat'
|
||||
|
||||
interface NavigationItem {
|
||||
|
|
@ -29,6 +30,7 @@ const showLoginDialog = ref(false)
|
|||
const showProfileDialog = ref(false)
|
||||
const showLogoutConfirm = ref(false)
|
||||
const marketPreloader = useMarketPreloader()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const navigation = computed<NavigationItem[]>(() => [
|
||||
{ name: t('nav.home'), href: '/' },
|
||||
|
|
@ -51,6 +53,13 @@ const totalUnreadMessages = computed(() => {
|
|||
return nostrChat.totalUnreadCount.value
|
||||
})
|
||||
|
||||
// Compute cart item count
|
||||
const cartItemCount = computed(() => {
|
||||
return marketStore.totalCartItems
|
||||
})
|
||||
|
||||
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
|
@ -125,6 +134,17 @@ const handleLogout = async () => {
|
|||
<!-- <CurrencyDisplay :balance-msat="totalBalance" /> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<!-- Cart Icon with Item Count -->
|
||||
<router-link v-if="auth.isAuthenticated.value" to="/cart"
|
||||
class="hidden sm:flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-lg border hover:bg-muted/70 transition-colors relative">
|
||||
<ShoppingCart class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Cart</span>
|
||||
<Badge v-if="cartItemCount > 0"
|
||||
class="absolute -top-2 -right-2 h-5 w-5 text-xs bg-blue-500 text-white border-0 p-0 flex items-center justify-center rounded-full">
|
||||
{{ cartItemCount > 99 ? '99+' : cartItemCount }}
|
||||
</Badge>
|
||||
</router-link>
|
||||
|
||||
<!-- Authentication Management -->
|
||||
<div class="hidden sm:block">
|
||||
<DropdownMenu v-if="auth.isAuthenticated.value">
|
||||
|
|
@ -151,6 +171,11 @@ const handleLogout = async () => {
|
|||
<Ticket class="h-4 w-4" />
|
||||
My Tickets
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem @click="() => router.push('/market-dashboard')" class="gap-2">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
Market Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="() => router.push('/relay-hub-status')" class="gap-2">
|
||||
<Activity class="h-4 w-4" />
|
||||
Relay Hub Status
|
||||
|
|
@ -236,6 +261,12 @@ const handleLogout = async () => {
|
|||
<Ticket class="h-4 w-4" />
|
||||
My Tickets
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" @click="() => router.push('/market-dashboard')"
|
||||
class="w-full justify-start gap-2">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
Market Dashboard
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="() => router.push('/relay-hub-status')"
|
||||
class="w-full justify-start gap-2">
|
||||
<Activity class="h-4 w-4" />
|
||||
|
|
|
|||
250
src/components/market/CartItem.vue
Normal file
250
src/components/market/CartItem.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<!-- Desktop Layout (horizontal) -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-foreground truncate">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right min-w-[80px]">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout (stacked) -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<!-- Product Image and Details Row -->
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-foreground">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10 flex-shrink-0"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Price and Quantity Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Price per item -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { CartItem as CartItemType } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
item: CartItemType
|
||||
stallId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update-quantity': [stallId: string, productId: string, quantity: number]
|
||||
'remove-item': [stallId: string, productId: string]
|
||||
}>()
|
||||
|
||||
// Methods
|
||||
const increaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity + 1
|
||||
if (newQuantity <= props.item.product.quantity) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity - 1
|
||||
if (newQuantity > 0) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = () => {
|
||||
emit('remove-item', props.stallId, props.item.product.id)
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.src = '/placeholder-product.png'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
232
src/components/market/CartSummary.vue
Normal file
232
src/components/market/CartSummary.vue
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<div class="bg-card border rounded-lg p-6 shadow-sm">
|
||||
<!-- Cart Summary Header -->
|
||||
<div class="border-b border-border pb-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Order Summary</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ itemCount }} item{{ itemCount !== 1 ? 's' : '' }} in cart
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items Summary -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<div
|
||||
v-for="item in cartItems"
|
||||
:key="item.product.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-8 h-8 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
||||
<p class="text-muted-foreground">Qty: {{ item.quantity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="border-t border-border pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-foreground">Shipping Zone</label>
|
||||
<Button
|
||||
v-if="availableShippingZones.length > 1"
|
||||
@click="showShippingSelector = !showShippingSelector"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedShippingZone ? 'Change' : 'Select' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex items-center justify-between p-3 bg-muted rounded">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ selectedShippingZone.name }}</p>
|
||||
<p v-if="selectedShippingZone.description" class="text-sm text-muted-foreground">
|
||||
{{ selectedShippingZone.description }}
|
||||
</p>
|
||||
<p v-if="selectedShippingZone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ selectedShippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selector -->
|
||||
<div v-if="showShippingSelector && availableShippingZones.length > 1" class="mt-2">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!selectedShippingZone" class="text-sm text-red-600">
|
||||
Please select a shipping zone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="border-t border-border pt-4 mb-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Subtotal</span>
|
||||
<span class="text-foreground">{{ formatPrice(subtotal, currency) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Shipping</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-2 flex justify-between font-semibold text-lg">
|
||||
<span class="text-foreground">Total</span>
|
||||
<span class="text-green-600">{{ formatPrice(total, currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Actions -->
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="continueShopping"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
>
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Security Note -->
|
||||
<div class="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<Shield class="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Secure Checkout</p>
|
||||
<p>Your order will be encrypted and sent securely to the merchant using Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
import type { ShippingZone } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
stallId: string
|
||||
cartItems: readonly {
|
||||
readonly product: {
|
||||
readonly id: string
|
||||
readonly stall_id: string
|
||||
readonly stallName: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly price: number
|
||||
readonly currency: string
|
||||
readonly quantity: number
|
||||
readonly images?: readonly string[]
|
||||
readonly categories?: readonly string[]
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
}
|
||||
readonly quantity: number
|
||||
readonly stallId: string
|
||||
}[]
|
||||
subtotal: number
|
||||
currency: string
|
||||
availableShippingZones: readonly {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}[]
|
||||
selectedShippingZone?: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'shipping-zone-selected': [shippingZone: ShippingZone]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const showShippingSelector = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const itemCount = computed(() =>
|
||||
props.cartItems.reduce((total, item) => total + item.quantity, 0)
|
||||
)
|
||||
|
||||
const total = computed(() => {
|
||||
const shippingCost = props.selectedShippingZone?.cost || 0
|
||||
return props.subtotal + shippingCost
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
emit('shipping-zone-selected', shippingZone)
|
||||
showShippingSelector.value = false
|
||||
}
|
||||
|
||||
const continueShopping = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
310
src/components/market/DashboardOverview.vue
Normal file
310
src/components/market/DashboardOverview.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.total }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.pending }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ orderStats.paid }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Payments -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Pending Payments</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.pendingPayments }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Total: {{ formatPrice(orderStats.pendingAmount, 'sat') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sales (Merchant) -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Recent Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.recentSales }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 7 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Market Activity</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.active }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Customer Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<ShoppingCart class="w-5 h-5 text-primary" />
|
||||
Customer Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToMarket"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToCart"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||
Shopping Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Store class="w-5 h-5 text-green-500" />
|
||||
Merchant Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToStore"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Store
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToProducts"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
||||
<div v-if="recentActivity.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<component :is="getActivityIcon(activity.type)" class="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ activity.title }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatDate(activity.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="getActivityVariant(activity.type)">
|
||||
{{ activity.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No recent activity</p>
|
||||
<p class="text-sm">Start shopping or selling to see activity here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Status -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Market Status</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Order Events: {{ orderEvents.isSubscribed ? 'Connected' : 'Connecting...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="isConnected ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Market: {{ isConnected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="auth.isAuthenticated ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Auth: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
Clock
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const auth = useAuth()
|
||||
const { isConnected } = useMarket()
|
||||
|
||||
// Computed properties
|
||||
const orderStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
total: orders.length,
|
||||
pending: orders.filter(o => o.status === 'pending').length,
|
||||
paid: orders.filter(o => o.status === 'paid').length,
|
||||
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
|
||||
pendingAmount: orders
|
||||
.filter(o => o.paymentStatus === 'pending')
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
recentSales: orders.filter(o =>
|
||||
o.status === 'paid' && o.createdAt > sevenDaysAgo
|
||||
).length,
|
||||
active: orders.filter(o =>
|
||||
['pending', 'paid', 'processing'].includes(o.status)
|
||||
).length,
|
||||
connected: false
|
||||
}
|
||||
})
|
||||
|
||||
const recentActivity = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const recentOrders = orders
|
||||
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, 5)
|
||||
|
||||
return recentOrders.map(order => ({
|
||||
id: order.id,
|
||||
type: 'order',
|
||||
title: `Order ${order.id.slice(-8)} - ${order.status}`,
|
||||
status: order.status,
|
||||
timestamp: order.updatedAt
|
||||
}))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return `${price} ${currency}`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return Package
|
||||
default: return Clock
|
||||
}
|
||||
}
|
||||
|
||||
const getActivityVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return 'secondary'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const navigateToCart = () => router.push('/cart')
|
||||
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
||||
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
||||
</script>
|
||||
|
||||
331
src/components/market/MarketSettings.vue
Normal file
331
src/components/market/MarketSettings.vue
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Market Settings</h2>
|
||||
<p class="text-muted-foreground mt-1">Configure your store and market preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tabs -->
|
||||
<div class="border-b border-border">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.id"
|
||||
@click="activeSettingsTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeSettingsTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Settings Content -->
|
||||
<div class="min-h-[500px]">
|
||||
<!-- Store Settings Tab -->
|
||||
<div v-if="activeSettingsTab === 'store'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Name</label>
|
||||
<Input v-model="storeSettings.name" placeholder="Enter store name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Description</label>
|
||||
<Input v-model="storeSettings.description" placeholder="Enter store description" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Contact Email</label>
|
||||
<Input v-model="storeSettings.contactEmail" type="email" placeholder="Enter contact email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Category</label>
|
||||
<select v-model="storeSettings.category" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">Select category</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
<option value="books">Books</option>
|
||||
<option value="food">Food & Beverages</option>
|
||||
<option value="services">Services</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveStoreSettings" variant="default">
|
||||
Save Store Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'payment'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Default Currency</label>
|
||||
<select v-model="paymentSettings.defaultCurrency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
<option value="eur">Euro (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Invoice Expiry (minutes)</label>
|
||||
<Input v-model="paymentSettings.invoiceExpiry" type="number" min="5" max="1440" placeholder="60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Auto-generate Invoices</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="paymentSettings.autoGenerateInvoices"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-input rounded"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Automatically generate Lightning invoices for new orders
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="savePaymentSettings" variant="default">
|
||||
Save Payment Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'nostr'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Nostr Network Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Relay Connections</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="relay in nostrSettings.relays" :key="relay" class="flex items-center gap-2">
|
||||
<Input :value="relay" readonly class="flex-1" />
|
||||
<Button @click="removeRelay(relay)" variant="outline" size="sm">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="newRelay" placeholder="wss://relay.example.com" class="flex-1" />
|
||||
<Button @click="addRelay" variant="outline">
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Nostr Public Key</label>
|
||||
<Input :value="nostrSettings.pubkey" readonly class="font-mono text-sm" />
|
||||
<p class="text-xs text-muted-foreground mt-1">Your Nostr public key for receiving orders</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Connection Status</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Connected to Nostr network' : 'Connecting to Nostr network...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveNostrSettings" variant="default">
|
||||
Save Nostr Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'shipping'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Zones</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="zone in shippingSettings.zones" :key="zone.id" class="border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Zone Name</label>
|
||||
<Input v-model="zone.name" placeholder="Zone name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Cost</label>
|
||||
<Input v-model="zone.cost" type="number" min="0" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Currency</label>
|
||||
<select v-model="zone.currency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Button @click="removeShippingZone(zone.id)" variant="outline" size="sm">
|
||||
Remove Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addShippingZone" variant="outline">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Shipping Zone
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveShippingSettings" variant="default">
|
||||
Save Shipping Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
// import { useMarketStore } from '@/stores/market'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeSettingsTab = ref('store')
|
||||
const newRelay = ref('')
|
||||
|
||||
// Settings data
|
||||
const storeSettings = ref({
|
||||
name: 'My Store',
|
||||
description: 'A great place to shop',
|
||||
contactEmail: 'store@example.com',
|
||||
category: 'other'
|
||||
})
|
||||
|
||||
const paymentSettings = ref({
|
||||
defaultCurrency: 'sat',
|
||||
invoiceExpiry: 60,
|
||||
autoGenerateInvoices: true
|
||||
})
|
||||
|
||||
const nostrSettings = ref({
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
],
|
||||
pubkey: 'npub1...' // TODO: Get from auth
|
||||
})
|
||||
|
||||
const shippingSettings = ref({
|
||||
zones: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Local',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '1-2 days'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Domestic',
|
||||
cost: 1000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'International',
|
||||
cost: 5000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '7-14 days'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Settings tabs
|
||||
const settingsTabs = [
|
||||
{ id: 'store', name: 'Store Settings' },
|
||||
{ id: 'payment', name: 'Payment Settings' },
|
||||
{ id: 'nostr', name: 'Nostr Network' },
|
||||
{ id: 'shipping', name: 'Shipping Zones' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const saveStoreSettings = () => {
|
||||
// TODO: Save store settings
|
||||
console.log('Saving store settings:', storeSettings.value)
|
||||
}
|
||||
|
||||
const savePaymentSettings = () => {
|
||||
// TODO: Save payment settings
|
||||
console.log('Saving payment settings:', paymentSettings.value)
|
||||
}
|
||||
|
||||
const saveNostrSettings = () => {
|
||||
// TODO: Save Nostr settings
|
||||
console.log('Saving Nostr settings:', nostrSettings.value)
|
||||
}
|
||||
|
||||
const saveShippingSettings = () => {
|
||||
// TODO: Save shipping settings
|
||||
console.log('Saving shipping settings:', shippingSettings.value)
|
||||
}
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.value && !nostrSettings.value.relays.includes(newRelay.value)) {
|
||||
nostrSettings.value.relays.push(newRelay.value)
|
||||
newRelay.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeRelay = (relay: string) => {
|
||||
const index = nostrSettings.value.relays.indexOf(relay)
|
||||
if (index > -1) {
|
||||
nostrSettings.value.relays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addShippingZone = () => {
|
||||
const newZone = {
|
||||
id: Date.now().toString(),
|
||||
name: 'New Zone',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
}
|
||||
shippingSettings.value.zones.push(newZone)
|
||||
}
|
||||
|
||||
const removeShippingZone = (zoneId: string) => {
|
||||
const index = shippingSettings.value.zones.findIndex(z => z.id === zoneId)
|
||||
if (index > -1) {
|
||||
shippingSettings.value.zones.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Settings component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
550
src/components/market/MerchantStore.vue
Normal file
550
src/components/market/MerchantStore.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Store</h2>
|
||||
<p class="text-muted-foreground mt-1">Manage incoming orders and your products</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button @click="addProduct" variant="default">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Incoming Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Incoming Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.incomingOrders }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.pendingOrders }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ storeStats.paidOrders }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Sales -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ formatPrice(storeStats.totalSales, 'sat') }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 30 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Products</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.totalProducts }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.activeProducts }} active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Satisfaction -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Satisfaction</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<Star class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.totalReviews }} reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Orders Section -->
|
||||
<div class="bg-card rounded-lg border shadow-sm">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">Incoming Orders</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Orders waiting for your attention</p>
|
||||
</div>
|
||||
|
||||
<div v-if="incomingOrders.length > 0" class="divide-y divide-border">
|
||||
<div
|
||||
v-for="order in incomingOrders"
|
||||
:key="order.id"
|
||||
class="p-6 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }} • {{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="order.paymentStatus === 'pending'" variant="secondary">
|
||||
Payment Pending
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Wallet Indicator -->
|
||||
<div v-if="order.status === 'pending' && !order.lightningInvoice" class="text-xs text-muted-foreground mr-2">
|
||||
<span>Wallet: {{ getFirstWalletName() }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="order.status === 'pending' && !order.lightningInvoice"
|
||||
@click="generateInvoice(order.id)"
|
||||
:disabled="isGeneratingInvoice === order.id"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<div v-if="isGeneratingInvoice === order.id" class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-2">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Generate Invoice</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
@click="viewOrderDetails(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Eye class="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
@click="processOrder(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Check class="w-4 h-4 mr-2" />
|
||||
Process
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Items</h5>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.productId"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Customer Info</h5>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="order.contactInfo.email">
|
||||
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.message">
|
||||
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.address">
|
||||
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div v-if="order.lightningInvoice" class="p-4 bg-green-500/10 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
<span class="text-sm font-medium text-green-900">Lightning Invoice Generated</span>
|
||||
</div>
|
||||
<div class="text-sm text-green-700">
|
||||
Amount: {{ formatPrice(order.total, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 bg-yellow-500/10 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="w-5 h-5 text-yellow-600" />
|
||||
<span class="text-sm font-medium text-yellow-900">Invoice Required</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-6 text-center text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No incoming orders</p>
|
||||
<p class="text-sm">Orders from customers will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Order Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Order Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="viewAllOrders"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="generateBulkInvoices"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
Generate Bulk Invoices
|
||||
</Button>
|
||||
<Button
|
||||
@click="exportOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
Export Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="manageProducts"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="storeSettings"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Settings class="w-4 h-4 mr-2" />
|
||||
Store Settings
|
||||
</Button>
|
||||
<Button
|
||||
@click="analytics"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<BarChart3 class="w-4 h-4 mr-2" />
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
DollarSign,
|
||||
Star,
|
||||
Plus,
|
||||
Zap,
|
||||
Eye,
|
||||
Check,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Settings,
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const nostrOrdersComposable = nostrOrders
|
||||
|
||||
// Local state
|
||||
const isGeneratingInvoice = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const incomingOrders = computed(() => {
|
||||
// For now, show all orders as "incoming" since we don't have merchant filtering yet
|
||||
// In a real implementation, this would filter orders where the current user is the seller
|
||||
return Object.values(marketStore.orders)
|
||||
.filter(order => order.status === 'pending')
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
|
||||
const storeStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
incomingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
pendingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
paidOrders: orders.filter(o => o.status === 'paid').length,
|
||||
totalSales: orders
|
||||
.filter(o => o.status === 'paid' && o.createdAt > thirtyDaysAgo)
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
totalProducts: 0, // TODO: Implement product management
|
||||
activeProducts: 0, // TODO: Implement product management
|
||||
satisfaction: 95, // TODO: Implement review system
|
||||
totalReviews: 0 // TODO: Implement review system
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const generateInvoice = async (orderId: string) => {
|
||||
console.log('Generating invoice for order:', orderId)
|
||||
isGeneratingInvoice.value = orderId
|
||||
|
||||
try {
|
||||
// Get the order from the store
|
||||
const order = marketStore.orders[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Temporary fix: If buyerPubkey is missing, try to get it from auth
|
||||
if (!order.buyerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing buyerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { buyerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Temporary fix: If sellerPubkey is missing, use current user's pubkey
|
||||
if (!order.sellerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing sellerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { sellerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Get the updated order
|
||||
const updatedOrder = marketStore.orders[orderId]
|
||||
|
||||
console.log('Order details for invoice generation:', {
|
||||
orderId: updatedOrder.id,
|
||||
orderFields: Object.keys(updatedOrder),
|
||||
buyerPubkey: updatedOrder.buyerPubkey,
|
||||
sellerPubkey: updatedOrder.sellerPubkey,
|
||||
status: updatedOrder.status,
|
||||
total: updatedOrder.total
|
||||
})
|
||||
|
||||
// Get the user's wallet list
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
console.log('Available wallets:', userWallets)
|
||||
|
||||
if (userWallets.length === 0) {
|
||||
throw new Error('No wallet available to generate invoice. Please ensure you have at least one wallet configured.')
|
||||
}
|
||||
|
||||
// Use the first available wallet for invoice generation
|
||||
const walletId = userWallets[0].id
|
||||
const walletName = userWallets[0].name
|
||||
const adminKey = userWallets[0].adminkey
|
||||
console.log('Using wallet for invoice generation:', { walletId, walletName, balance: userWallets[0].balance_msat })
|
||||
|
||||
const invoice = await marketStore.createLightningInvoice(orderId, adminKey)
|
||||
|
||||
if (invoice) {
|
||||
console.log('Lightning invoice created:', invoice)
|
||||
|
||||
// Send the invoice to the customer via Nostr
|
||||
await sendInvoiceToCustomer(updatedOrder, invoice)
|
||||
|
||||
console.log('Invoice sent to customer successfully')
|
||||
|
||||
// Show success message (you could add a toast notification here)
|
||||
alert(`Invoice generated successfully using wallet: ${walletName}`)
|
||||
} else {
|
||||
throw new Error('Failed to create Lightning invoice')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate invoice:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
|
||||
// Show error message to user
|
||||
alert(`Failed to generate invoice: ${errorMessage}`)
|
||||
} finally {
|
||||
isGeneratingInvoice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const sendInvoiceToCustomer = async (order: any, invoice: any) => {
|
||||
try {
|
||||
console.log('Sending invoice to customer for order:', {
|
||||
orderId: order.id,
|
||||
buyerPubkey: order.buyerPubkey,
|
||||
sellerPubkey: order.sellerPubkey,
|
||||
invoiceFields: Object.keys(invoice)
|
||||
})
|
||||
|
||||
// Check if we have the buyer's public key
|
||||
if (!order.buyerPubkey) {
|
||||
console.error('Missing buyerPubkey in order:', order)
|
||||
throw new Error('Cannot send invoice: buyer public key not found')
|
||||
}
|
||||
|
||||
// Update the order with the invoice details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
lightningInvoice: invoice,
|
||||
paymentHash: invoice.payment_hash,
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: invoice.bolt11, // Use bolt11 field from LNBits response
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
// Send the updated order to the customer via Nostr
|
||||
// This will include the invoice information
|
||||
await nostrOrdersComposable.publishOrderEvent(updatedOrder, order.buyerPubkey)
|
||||
|
||||
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invoice to customer:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const viewOrderDetails = (orderId: string) => {
|
||||
// TODO: Navigate to detailed order view
|
||||
console.log('Viewing order details:', orderId)
|
||||
}
|
||||
|
||||
const processOrder = (orderId: string) => {
|
||||
// TODO: Implement order processing
|
||||
console.log('Processing order:', orderId)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
// TODO: Navigate to add product form
|
||||
console.log('Adding new product')
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const viewAllOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const generateBulkInvoices = () => console.log('Generate bulk invoices')
|
||||
const exportOrders = () => console.log('Export orders')
|
||||
const manageProducts = () => console.log('Manage products')
|
||||
const storeSettings = () => router.push('/market-dashboard?tab=settings')
|
||||
const analytics = () => console.log('View analytics')
|
||||
|
||||
const getFirstWalletName = () => {
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
if (userWallets.length > 0) {
|
||||
return userWallets[0].name
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Merchant Store component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
599
src/components/market/OrderHistory.vue
Normal file
599
src/components/market/OrderHistory.vue
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Orders</h2>
|
||||
<p class="text-muted-foreground mt-1">Track all your market orders and payments</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Order Events Status -->
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span>{{ orderEvents.isSubscribed ? 'Live updates' : 'Connecting...' }}</span>
|
||||
</div>
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Stats -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Order Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Total:</span>
|
||||
<Badge variant="secondary">{{ totalOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Pending:</span>
|
||||
<Badge variant="outline" class="text-yellow-600">{{ pendingOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Paid:</span>
|
||||
<Badge variant="outline" class="text-green-600">{{ paidOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Payment Due:</span>
|
||||
<Badge variant="outline" class="text-red-600">{{ pendingPayments }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="sortBy" class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="createdAt">Date Created</option>
|
||||
<option value="total">Order Total</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div v-if="filteredOrders.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="order in sortedOrders"
|
||||
:key="order.id"
|
||||
class="bg-card border rounded-lg p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="flex items-center gap-2 mt-1">
|
||||
<div v-if="order.sentViaNostr" class="flex items-center gap-1 text-xs text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
Sent via Nostr
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-xs text-red-600">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
Nostr failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<!-- Payment Status Indicator -->
|
||||
<div v-if="order.lightningInvoice" class="flex items-center gap-2">
|
||||
<Badge
|
||||
:variant="order.paymentStatus === 'paid' ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="order.paymentStatus === 'paid'" class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<div v-else class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
{{ order.paymentStatus === 'paid' ? 'Paid' : 'Payment Pending' }}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">{{ order.currency.toUpperCase() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<!-- Items -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Items</h4>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="item in order.items.slice(0, 3)"
|
||||
:key="item.productId"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
<div v-if="order.items.length > 3" class="text-sm text-muted-foreground">
|
||||
+{{ order.items.length - 3 }} more items
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Payment</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-1 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
<span class="font-medium">Status:</span> {{ order.paymentStatus === 'paid' ? 'Paid' : 'Pending' }}
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
<span class="font-medium">Invoice:</span> {{ order.lightningInvoice.payment_hash.slice(0, 8) }}...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
Waiting for merchant invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Shipping</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-medium">Zone:</span> {{ order.shippingZone?.name || 'N/A' }}
|
||||
</p>
|
||||
<p v-if="order.shippingZone?.estimatedDays">
|
||||
<span class="font-medium">Est. Delivery:</span> {{ order.shippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status & Actions -->
|
||||
<div v-if="order.status === 'pending'" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-foreground mb-2">Payment Required</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Lightning invoice received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="font-medium text-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="order.paymentRequest" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Payment request received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="text-sm text-muted-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Payment Options -->
|
||||
<div class="mt-4 space-y-3">
|
||||
<h5 class="text-sm font-medium text-foreground">Payment Options:</h5>
|
||||
|
||||
<!-- Lightning Payment -->
|
||||
<div v-if="order.paymentRequest" class="bg-card border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="w-4 h-4 text-yellow-500" />
|
||||
<span class="font-medium text-sm">Lightning Payment</span>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">Recommended</Badge>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="text-center mb-3">
|
||||
<div class="w-32 h-32 mx-auto mb-2">
|
||||
<div v-if="order.qrCodeDataUrl && !order.qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="order.qrCodeDataUrl"
|
||||
:alt="`QR Code for ${formatPrice(order.total, order.currency)} payment`"
|
||||
class="w-full h-full border border-border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="order.qrCodeLoading" class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-2xl mb-1 animate-pulse">⚡</div>
|
||||
<div class="text-xs">Generating...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-2xl mb-1">⚡</div>
|
||||
<div class="text-xs">No QR</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Request Link -->
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-medium text-muted-foreground">
|
||||
Payment Request
|
||||
</label>
|
||||
<!-- Debug info -->
|
||||
<div class="text-xs text-red-500 mb-2">
|
||||
Debug: paymentRequest = "{{ order.paymentRequest }}" (length: {{ order.paymentRequest?.length || 0 }})<br>
|
||||
Debug: paymentHash = "{{ order.paymentHash }}" (length: {{ order.paymentHash?.length || 0 }})<br>
|
||||
Debug: Order keys: {{ Object.keys(order).join(', ') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="order.paymentRequest || ''"
|
||||
readonly
|
||||
class="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest(order.paymentRequest)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!order.paymentRequest"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Actions -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<Button
|
||||
@click="openLightningWallet(order.paymentRequest)"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Zap class="w-3 h-3 mr-1" />
|
||||
Pay with Lightning
|
||||
</Button>
|
||||
<Button
|
||||
@click="downloadQRCode(order)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Download class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-amber-600">⏳</span> Waiting for merchant to generate payment invoice
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The merchant will send you a Lightning invoice via Nostr once they process your order
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="order.status === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="cancelOrder(order.id)"
|
||||
>
|
||||
Cancel Order
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
variant="default"
|
||||
size="sm"
|
||||
@click="togglePaymentDisplay(order.id)"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
{{ expandedPayments.has(order.id) ? 'Hide' : 'Show' }} Payment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyOrderId(order.id)"
|
||||
>
|
||||
Copy Order ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Display (Expandable) -->
|
||||
<div v-if="expandedPayments.has(order.id) && order.lightningInvoice" class="mt-4 pt-4 border-t border-border">
|
||||
<PaymentDisplay :order-id="order.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Information (Development Only) -->
|
||||
<div v-if="isDevelopment" class="mt-8 p-4 bg-gray-100 rounded-lg">
|
||||
<h4 class="font-medium mb-2">Debug Information</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Total Orders in Store: {{ Object.keys(marketStore.orders).length }}</div>
|
||||
<div>Filtered Orders: {{ filteredOrders.length }}</div>
|
||||
<div>Order Events Subscribed: {{ orderEvents.isSubscribed ? 'Yes' : 'No' }}</div>
|
||||
<div>Relay Hub Connected: {{ relayHub.isConnected ? 'Yes' : 'No' }}</div>
|
||||
<div>Auth Status: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</div>
|
||||
<div>Current User: {{ auth.currentUser?.value?.pubkey ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No orders yet</h3>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Start shopping in the market to see your order history here
|
||||
</p>
|
||||
<Button @click="navigateToMarket" variant="default">
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Package, Store, Wallet, Zap, Copy, Download } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import PaymentDisplay from './PaymentDisplay.vue'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Local state
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref('createdAt')
|
||||
const expandedPayments = ref(new Set<string>())
|
||||
|
||||
// Computed properties
|
||||
const allOrders = computed(() => Object.values(marketStore.orders))
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return allOrders.value
|
||||
return allOrders.value.filter(order => order.status === statusFilter.value)
|
||||
})
|
||||
|
||||
const sortedOrders = computed(() => {
|
||||
const orders = [...filteredOrders.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'total':
|
||||
return orders.sort((a, b) => b.total - a.total)
|
||||
case 'status':
|
||||
return orders.sort((a, b) => a.status.localeCompare(b.status))
|
||||
case 'createdAt':
|
||||
default:
|
||||
return orders.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
const totalOrders = computed(() => allOrders.value.length)
|
||||
const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'pending').length)
|
||||
const paidOrders = computed(() => allOrders.value.filter(o => o.status === 'paid').length)
|
||||
const pendingPayments = computed(() => allOrders.value.filter(o => o.paymentStatus === 'pending').length)
|
||||
|
||||
const isDevelopment = computed(() => import.meta.env.DEV)
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId: string) => {
|
||||
// TODO: Implement order cancellation
|
||||
console.log('Cancelling order:', orderId)
|
||||
}
|
||||
|
||||
const togglePaymentDisplay = (orderId: string) => {
|
||||
if (expandedPayments.value.has(orderId)) {
|
||||
expandedPayments.value.delete(orderId)
|
||||
} else {
|
||||
expandedPayments.value.add(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
const copyOrderId = async (orderId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(orderId)
|
||||
toast.success('Order ID copied to clipboard')
|
||||
console.log('Order ID copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy order ID:', err)
|
||||
toast.error('Failed to copy order ID')
|
||||
}
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async (paymentRequest: string) => {
|
||||
console.log('Copying payment request:', {
|
||||
paymentRequest: paymentRequest?.substring(0, 50) + '...',
|
||||
hasValue: !!paymentRequest,
|
||||
length: paymentRequest?.length
|
||||
})
|
||||
|
||||
if (!paymentRequest) {
|
||||
toast.error('No payment request available', {
|
||||
description: 'Please wait for the merchant to send the payment request'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentRequest)
|
||||
toast.success('Payment request copied to clipboard', {
|
||||
description: 'You can now paste it into your Lightning wallet'
|
||||
})
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy payment request:', err)
|
||||
toast.error('Failed to copy payment request', {
|
||||
description: 'Please try again or copy manually'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openLightningWallet = (paymentRequest: string) => {
|
||||
// Try to open with common Lightning wallet protocols
|
||||
const protocols = [
|
||||
`lightning:${paymentRequest}`,
|
||||
`bitcoin:${paymentRequest}`,
|
||||
paymentRequest
|
||||
]
|
||||
|
||||
// Try each protocol
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
window.open(protocol, '_blank')
|
||||
toast.success('Opening Lightning wallet', {
|
||||
description: 'If your wallet doesn\'t open, copy the payment request manually'
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('Failed to open protocol:', protocol, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: copy to clipboard
|
||||
copyPaymentRequest(paymentRequest)
|
||||
}
|
||||
|
||||
const downloadQRCode = async (order: any) => {
|
||||
if (!order.qrCodeDataUrl) {
|
||||
toast.error('QR code not available', {
|
||||
description: 'Please wait for the QR code to generate'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = order.qrCodeDataUrl
|
||||
link.download = `payment-qr-${order.id}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
toast.success('QR code downloaded', {
|
||||
description: 'You can now scan it with your mobile wallet'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to download QR code:', err)
|
||||
toast.error('Failed to download QR code', {
|
||||
description: 'Please try again'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
|
||||
// Load orders on mount
|
||||
onMounted(() => {
|
||||
// Orders are already loaded in the market store
|
||||
console.log('Order History component loaded with', allOrders.value.length, 'orders')
|
||||
console.log('Market store orders:', marketStore.orders)
|
||||
|
||||
// Debug: Log order details for orders with payment requests
|
||||
allOrders.value.forEach(order => {
|
||||
if (order.paymentRequest) {
|
||||
console.log('Order with payment request:', {
|
||||
id: order.id,
|
||||
paymentRequest: order.paymentRequest.substring(0, 50) + '...',
|
||||
hasPaymentRequest: !!order.paymentRequest,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Order events status:', orderEvents.isSubscribed.value)
|
||||
console.log('Relay hub connected:', relayHub.isConnected.value)
|
||||
console.log('Auth status:', auth.isAuthenticated)
|
||||
console.log('Current user:', auth.currentUser?.value?.pubkey)
|
||||
|
||||
// Start listening for order events if not already listening
|
||||
if (!orderEvents.isSubscribed.value) {
|
||||
console.log('Starting order events listener...')
|
||||
orderEvents.startListening()
|
||||
} else {
|
||||
console.log('Order events already listening')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for authentication and relay hub readiness
|
||||
watch(
|
||||
[() => auth.isAuthenticated, () => relayHub.isConnected.value],
|
||||
([isAuth, isConnected]) => {
|
||||
if (isAuth && isConnected && !orderEvents.isSubscribed.value) {
|
||||
console.log('Auth and relay hub ready, starting order events listener...')
|
||||
orderEvents.startListening()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
344
src/components/market/PaymentDisplay.vue
Normal file
344
src/components/market/PaymentDisplay.vue
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<template>
|
||||
<div class="bg-white border rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Payment</h3>
|
||||
<Badge :variant="getPaymentStatusVariant(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Information -->
|
||||
<div v-if="invoice" class="space-y-4">
|
||||
<!-- Amount and Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Amount</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ invoice.amount }} {{ currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-600">Status</p>
|
||||
<p class="text-lg font-semibold" :class="getStatusColor(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-gray-900 mb-2">Lightning Invoice</h4>
|
||||
<p class="text-sm text-gray-600">Scan with your Lightning wallet to pay</p>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="w-48 h-48 mx-auto mb-4">
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="qrCodeDataUrl"
|
||||
:alt="`QR Code for ${invoice.amount} ${currency} payment`"
|
||||
class="w-full h-full border border-gray-200 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="qrCodeLoading" class="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
||||
<div class="text-sm">Generating QR...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="qrCodeError" class="w-full h-full bg-red-50 border border-red-200 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-red-500">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-sm">{{ qrCodeError }}</div>
|
||||
<Button
|
||||
@click="retryQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-gray-500">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<div class="text-sm">No invoice</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Actions -->
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="mb-4">
|
||||
<Button
|
||||
@click="downloadQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
Download QR Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Request -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Payment Request
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="invoice.bolt11"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Wallet Button -->
|
||||
<Button
|
||||
@click="openInWallet"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="font-medium text-gray-900 mb-3">Payment Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Payment Hash:</span>
|
||||
<span class="font-mono text-gray-900">{{ formatHash(invoice.payment_hash) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Created:</span>
|
||||
<span class="text-gray-900">{{ formatDate(invoice.created_at ? new Date(invoice.created_at).getTime() : Date.now()) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Expires:</span>
|
||||
<span class="text-gray-900">{{ formatDate(invoice.expiry ? new Date(invoice.expiry).getTime() : Date.now()) }}</span>
|
||||
</div>
|
||||
<div v-if="paidAt" class="flex justify-between">
|
||||
<span class="text-gray-600">Paid At:</span>
|
||||
<span class="text-gray-900">{{ formatDate(paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Invoice State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Wallet class="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-2">No Payment Invoice</h4>
|
||||
<p class="text-gray-600 mb-4">
|
||||
A Lightning invoice will be sent by the merchant once they process your order.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
You'll receive the invoice via Nostr when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div v-if="paymentStatus === 'pending'" class="mt-6 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-5 h-5 bg-muted rounded-full flex items-center justify-center mt-0.5">
|
||||
<Info class="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<h5 class="font-medium mb-1 text-foreground">Payment Instructions</h5>
|
||||
<ul class="space-y-1">
|
||||
<li>• Use a Lightning-compatible wallet (e.g., Phoenix, Breez, Alby)</li>
|
||||
<li>• Scan the QR code or copy the payment request</li>
|
||||
<li>• Confirm the payment amount and send</li>
|
||||
<li>• Your order will be processed once payment is confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Success -->
|
||||
<div v-if="paymentStatus === 'paid'" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<div class="text-sm text-green-800">
|
||||
<h5 class="font-medium">Payment Confirmed!</h5>
|
||||
<p>Your order is being processed. You'll receive updates via Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Copy,
|
||||
Wallet,
|
||||
|
||||
Info,
|
||||
CheckCircle,
|
||||
Download
|
||||
} from 'lucide-vue-next'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const order = computed(() => marketStore.orders[props.orderId])
|
||||
const invoice = computed(() => order.value?.lightningInvoice)
|
||||
const paymentStatus = computed(() => order.value?.paymentStatus || 'pending')
|
||||
const currency = computed(() => order.value?.currency || 'sat')
|
||||
const paidAt = computed(() => order.value?.paidAt)
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 192, // 48 * 4 for high DPI displays
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getPaymentStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'default'
|
||||
case 'pending': return 'secondary'
|
||||
case 'expired': return 'destructive'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const formatPaymentStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Paid'
|
||||
case 'pending': return 'Pending'
|
||||
case 'expired': return 'Expired'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'text-green-600'
|
||||
case 'pending': return 'text-yellow-600'
|
||||
case 'expired': return 'text-red-600'
|
||||
default: return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
if (!hash) return 'N/A'
|
||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return 'N/A'
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice.value.bolt11)
|
||||
// TODO: Show toast notification
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openInWallet = () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
// Open in Lightning wallet
|
||||
const walletUrl = `lightning:${invoice.value.bolt11}`
|
||||
window.open(walletUrl, '_blank')
|
||||
}
|
||||
|
||||
const downloadQRCode = () => {
|
||||
if (!qrCodeDataUrl.value) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = qrCodeDataUrl.value
|
||||
link.download = `qr-code-${invoice.value?.amount}-${currency.value}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const retryQRCode = () => {
|
||||
if (invoice.value?.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Set up payment monitoring if invoice exists
|
||||
if (invoice.value && props.orderId) {
|
||||
// Payment monitoring is handled by the market store
|
||||
console.log('Payment display mounted for order:', props.orderId)
|
||||
|
||||
// Generate QR code for the invoice
|
||||
if (invoice.value.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for invoice changes to regenerate QR code
|
||||
watch(() => invoice.value?.bolt11, (newPaymentRequest) => {
|
||||
if (newPaymentRequest) {
|
||||
generateQRCode(newPaymentRequest)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
287
src/components/market/PaymentRequestDialog.vue
Normal file
287
src/components/market/PaymentRequestDialog.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<Dialog :open="modelValue" @update:open="updateOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Payment Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete your payment to finalize your order
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="paymentRequest" class="space-y-4">
|
||||
<!-- Order Details -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Order Details</h4>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Order ID: {{ paymentRequest.id }}
|
||||
</div>
|
||||
<div v-if="paymentRequest.message" class="text-sm">
|
||||
{{ paymentRequest.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Options -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Payment Options</h4>
|
||||
<div v-for="option in paymentRequest.payment_options" :key="option.type" class="space-y-2">
|
||||
<Card>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ getPaymentTypeLabel(option.type) }}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">{{ option.link }}</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button
|
||||
:variant="option.type === 'ln' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="handlePayment(option)"
|
||||
>
|
||||
<component :is="getPaymentTypeIcon(option.type)" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div v-if="lightningInvoice" class="space-y-4">
|
||||
<h4 class="text-sm font-medium">Lightning Invoice</h4>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white p-4 rounded-lg border">
|
||||
<div v-if="qrCodeLoading" class="w-48 h-48 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="qrCodeDataUrl"
|
||||
:src="qrCodeDataUrl"
|
||||
alt="Lightning payment QR code"
|
||||
class="w-48 h-48"
|
||||
/>
|
||||
<div v-else-if="qrCodeError" class="w-48 h-48 flex items-center justify-center text-red-500 text-sm">
|
||||
{{ qrCodeError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="copyInvoice"
|
||||
>
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<Button
|
||||
@click="payWithWallet"
|
||||
>
|
||||
Pay with Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-muted-foreground py-8">
|
||||
No payment request available
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="updateOpen(false)">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import QRCode from 'qrcode'
|
||||
import type { NostrmarketPaymentRequest } from '@/lib/services/nostrmarketService'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Zap, Bitcoin, Link, QrCode, CreditCard } from 'lucide-vue-next'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
paymentRequest?: NostrmarketPaymentRequest
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'payment-completed': [orderId: string]
|
||||
}>()
|
||||
|
||||
// Computed
|
||||
const lightningInvoice = computed(() => {
|
||||
if (!props.paymentRequest) return null
|
||||
|
||||
const lightningOption = props.paymentRequest.payment_options.find(opt => opt.type === 'ln')
|
||||
return lightningOption?.link || null
|
||||
})
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for lightning invoice changes and generate QR code
|
||||
watch(lightningInvoice, (newInvoice) => {
|
||||
if (newInvoice) {
|
||||
generateQRCode(newInvoice)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Methods
|
||||
const updateOpen = (open: boolean) => {
|
||||
emit('update:modelValue', open)
|
||||
}
|
||||
|
||||
const getPaymentTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return 'Lightning Network'
|
||||
case 'btc':
|
||||
return 'Bitcoin On-Chain'
|
||||
case 'url':
|
||||
return 'Payment URL'
|
||||
case 'lnurl':
|
||||
return 'LNURL-Pay'
|
||||
default:
|
||||
return type.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
const getPaymentTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return Zap
|
||||
case 'btc':
|
||||
return Bitcoin
|
||||
case 'url':
|
||||
return Link
|
||||
case 'lnurl':
|
||||
return QrCode
|
||||
default:
|
||||
return CreditCard
|
||||
}
|
||||
}
|
||||
|
||||
const handlePayment = async (option: { type: string; link: string }) => {
|
||||
try {
|
||||
switch (option.type) {
|
||||
case 'ln':
|
||||
// For Lightning invoices, we can either show QR code or pay directly
|
||||
if (lightningInvoice.value) {
|
||||
await payWithWallet()
|
||||
}
|
||||
break
|
||||
case 'url':
|
||||
// Open payment URL in new tab
|
||||
window.open(option.link, '_blank')
|
||||
break
|
||||
case 'btc':
|
||||
// Copy Bitcoin address
|
||||
await navigator.clipboard.writeText(option.link)
|
||||
toast.success('Bitcoin address copied to clipboard')
|
||||
break
|
||||
default:
|
||||
console.warn('Unknown payment type:', option.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment handling error:', error)
|
||||
toast.error('Failed to process payment')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInvoice = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lightningInvoice.value)
|
||||
toast.success('Lightning invoice copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy invoice:', error)
|
||||
toast.error('Failed to copy invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const payWithWallet = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
// Import the payment API
|
||||
const { payInvoiceWithWallet } = await import('@/lib/api/events')
|
||||
|
||||
// Get the current user's wallet info
|
||||
const { useAuth } = await import('@/composables/useAuth')
|
||||
const auth = useAuth()
|
||||
|
||||
if (!auth.currentUser.value?.walletId || !auth.currentUser.value?.adminKey) {
|
||||
toast.error('Please connect your wallet to pay')
|
||||
return
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
const result = await payInvoiceWithWallet(
|
||||
lightningInvoice.value,
|
||||
auth.currentUser.value.walletId,
|
||||
auth.currentUser.value.adminKey
|
||||
)
|
||||
|
||||
console.log('Payment result:', result)
|
||||
|
||||
toast.success('Payment successful!')
|
||||
|
||||
// Emit payment completed event
|
||||
if (props.paymentRequest) {
|
||||
emit('payment-completed', props.paymentRequest.id)
|
||||
}
|
||||
|
||||
// Close the dialog
|
||||
updateOpen(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment failed:', error)
|
||||
toast.error('Payment failed: ' + (error instanceof Error ? error.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -10,14 +10,14 @@
|
|||
/>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<Button
|
||||
@click="$emit('add-to-cart', product)"
|
||||
:disabled="product.quantity < 1"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="addToCart"
|
||||
:disabled="product.quantity < 1"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Out of Stock Badge -->
|
||||
<Badge
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -111,22 +112,26 @@ interface Props {
|
|||
product: Product
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'add-to-cart': [product: Product]
|
||||
'view-details': [product: Product]
|
||||
'view-stall': [stallId: string]
|
||||
}>()
|
||||
// const emit = defineEmits<{
|
||||
// 'view-details': [product: Product]
|
||||
// 'view-stall': [stallId: string]
|
||||
// }>()
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
const imageError = ref(false)
|
||||
|
||||
const addToCart = () => {
|
||||
marketStore.addToStallCart(props.product, 1)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat') {
|
||||
if (currency === 'sat' || currency === 'sats') {
|
||||
return `${price.toLocaleString('en-US')} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
|
|
|
|||
250
src/components/market/ShoppingCart.vue
Normal file
250
src/components/market/ShoppingCart.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Cart Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Shopping Cart</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ totalCartItems }} items across {{ allStallCarts.length }} stalls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Total Value</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ formatPrice(totalCartValue, 'sats') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="allStallCarts.length > 0"
|
||||
@click="clearAllCarts"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Cart State -->
|
||||
<div v-if="allStallCarts.length === 0" class="text-center py-12">
|
||||
<ShoppingCart class="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
|
||||
<p class="text-muted-foreground mb-6">Start shopping to add items to your cart</p>
|
||||
<Button @click="$router.push('/market')" variant="default">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stall Carts -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="cart in allStallCarts"
|
||||
:key="cart.id"
|
||||
class="border border-border rounded-lg p-6 bg-card shadow-sm"
|
||||
>
|
||||
<!-- Stall Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{{ getStallName(cart.id) }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ cart.products.length }} item{{ cart.products.length !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Stall Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<CartItem
|
||||
v-for="item in cart.products"
|
||||
:key="item.product.id"
|
||||
:item="item"
|
||||
:stall-id="cart.id"
|
||||
@update-quantity="updateQuantity"
|
||||
@remove-item="removeItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stall Cart Actions -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<!-- Desktop Layout -->
|
||||
<div class="hidden md:flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-right mr-4">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout -->
|
||||
<div class="md:hidden space-y-4">
|
||||
<!-- Action Buttons Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total and Checkout Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
class="flex items-center"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Shopping Button -->
|
||||
<div v-if="allStallCarts.length > 0" class="text-center mt-8">
|
||||
<Button @click="$router.push('/market')" variant="outline" size="lg">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Store,
|
||||
ArrowRight
|
||||
} from 'lucide-vue-next'
|
||||
import CartItem from './CartItem.vue'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const allStallCarts = computed(() => marketStore.allStallCarts)
|
||||
const totalCartItems = computed(() => marketStore.totalCartItems)
|
||||
const totalCartValue = computed(() => marketStore.totalCartValue)
|
||||
|
||||
// Methods
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || 'Unknown Stall'
|
||||
}
|
||||
|
||||
const updateQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
marketStore.updateStallCartQuantity(stallId, productId, quantity)
|
||||
}
|
||||
|
||||
const removeItem = (stallId: string, productId: string) => {
|
||||
marketStore.removeFromStallCart(stallId, productId)
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
marketStore.clearStallCart(stallId)
|
||||
}
|
||||
|
||||
const clearAllCarts = () => {
|
||||
marketStore.clearAllStallCarts()
|
||||
}
|
||||
|
||||
const viewStall = (stallId: string) => {
|
||||
// TODO: Navigate to stall page
|
||||
console.log('View stall:', stallId)
|
||||
}
|
||||
|
||||
const proceedToCheckout = (stallId: string) => {
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
router.push(`/checkout/${stallId}`)
|
||||
}
|
||||
|
||||
const canProceedToCheckout = (stallId: string) => {
|
||||
const cart = marketStore.stallCarts[stallId]
|
||||
return cart && cart.products.length > 0
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -611,7 +611,7 @@ onUnmounted(() => {
|
|||
})
|
||||
|
||||
// Watch for connection state changes
|
||||
watch(isConnected, async (connected, prevConnected) => {
|
||||
watch(isConnected, async () => {
|
||||
// Note: Peer subscriptions are handled by the preloader
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { Button } from '@/components/ui/button'
|
|||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||
import { config, configUtils } from '@/lib/config'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const props = defineProps<{
|
||||
relays?: string[]
|
||||
feedType?: 'all' | 'announcements' | 'events' | 'general'
|
||||
}>()
|
||||
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Reactive state
|
||||
const notes = ref<any[]>([])
|
||||
|
|
@ -104,10 +104,10 @@ async function loadNotes() {
|
|||
created_at: event.created_at,
|
||||
tags: event.tags || [],
|
||||
// Extract mentions from tags
|
||||
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||
// Check if it's a reply
|
||||
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
}))
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
|
|
@ -161,9 +161,9 @@ async function startRealtimeSubscription() {
|
|||
content: event.content,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags || [],
|
||||
mentions: event.tags?.filter(tag => tag[0] === 'p').map(tag => tag[1]) || [],
|
||||
isReply: event.tags?.some(tag => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find(tag => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
mentions: event.tags?.filter((tag: any[]) => tag[0] === 'p').map((tag: any[]) => tag[1]) || [],
|
||||
isReply: event.tags?.some((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply'),
|
||||
replyTo: event.tags?.find((tag: any[]) => tag[0] === 'e' && tag[3] === 'reply')?.[1]
|
||||
}
|
||||
|
||||
// Check if note should be included
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface Emits {
|
|||
(e: 'confirm'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'destructive',
|
||||
size: 'default'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogOut, AlertTriangle } from 'lucide-vue-next'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
// Nostr event kinds for market functionality
|
||||
|
|
@ -15,7 +15,7 @@ const MARKET_EVENT_KINDS = {
|
|||
export function useMarket() {
|
||||
const nostrStore = useNostrStore()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
|
|
@ -420,23 +420,23 @@ export function useMarket() {
|
|||
}
|
||||
|
||||
// Handle order events
|
||||
const handleOrderEvent = (event: any) => {
|
||||
const handleOrderEvent = (_event: any) => {
|
||||
try {
|
||||
const orderData = JSON.parse(event.content)
|
||||
const order = {
|
||||
id: event.id,
|
||||
stall_id: orderData.stall_id || 'unknown',
|
||||
product_id: orderData.product_id || 'unknown',
|
||||
buyer_pubkey: event.pubkey,
|
||||
seller_pubkey: orderData.seller_pubkey || '',
|
||||
quantity: orderData.quantity || 1,
|
||||
total_price: orderData.total_price || 0,
|
||||
currency: orderData.currency || 'sats',
|
||||
status: orderData.status || 'pending',
|
||||
payment_request: orderData.payment_request,
|
||||
created_at: event.created_at,
|
||||
updated_at: event.created_at
|
||||
}
|
||||
// const orderData = JSON.parse(event.content)
|
||||
// const order = {
|
||||
// id: event.id,
|
||||
// stall_id: orderData.stall_id || 'unknown',
|
||||
// product_id: orderData.product_id || 'unknown',
|
||||
// buyer_pubkey: event.pubkey,
|
||||
// seller_pubkey: orderData.seller_pubkey || '',
|
||||
// quantity: orderData.quantity || 1,
|
||||
// total_price: orderData.total_price || 0,
|
||||
// currency: orderData.currency || 'sats',
|
||||
// status: orderData.status || 'pending',
|
||||
// payment_request: orderData.payment_request,
|
||||
// created_at: event.created_at,
|
||||
// updated_at: event.created_at
|
||||
// }
|
||||
|
||||
// Note: addOrder method doesn't exist in the store, so we'll just handle it silently
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { nip04, finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|||
import { hexToBytes } from '@/lib/utils/crypto'
|
||||
import { getAuthToken } from '@/lib/config/lnbits'
|
||||
import { config } from '@/lib/config'
|
||||
import { useRelayHub } from './useRelayHub'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
// Types
|
||||
|
|
@ -66,7 +66,7 @@ const saveUnreadData = (peerPubkey: string, data: UnreadMessageData): void => {
|
|||
|
||||
export function useNostrChat() {
|
||||
// Use the centralized relay hub
|
||||
const relayHub = useRelayHub()
|
||||
const relayHub = relayHubComposable
|
||||
|
||||
// Use the main authentication system
|
||||
const auth = useAuth()
|
||||
|
|
@ -82,9 +82,60 @@ export function useNostrChat() {
|
|||
// Track latest message timestamp for each peer (for sorting)
|
||||
const latestMessageTimestamps = ref<Map<string, number>>(new Map())
|
||||
|
||||
// Store peers globally
|
||||
// Track peers globally
|
||||
const peers = ref<any[]>([])
|
||||
|
||||
// Track malformed message IDs to prevent repeated processing attempts
|
||||
const malformedMessageIds = ref(new Set<string>())
|
||||
|
||||
// Mark a message as malformed to prevent future processing attempts
|
||||
const markMessageAsMalformed = (eventId: string) => {
|
||||
malformedMessageIds.value.add(eventId)
|
||||
// Also mark as processed to prevent retries
|
||||
processedMessageIds.value.add(eventId)
|
||||
}
|
||||
|
||||
// Clean up old malformed messages (call this periodically)
|
||||
const cleanupMalformedMessages = () => {
|
||||
// const now = Math.floor(Date.now() / 1000)
|
||||
// const maxAge = 24 * 60 * 60 // 24 hours
|
||||
|
||||
// Clear old malformed message IDs to free memory
|
||||
// This is a simple cleanup - in production you might want more sophisticated tracking
|
||||
if (malformedMessageIds.value.size > 1000) {
|
||||
console.log('Cleaning up malformed message tracking (clearing all)')
|
||||
malformedMessageIds.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up periodic cleanup
|
||||
let cleanupInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// Clean up resources
|
||||
const cleanup = () => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval)
|
||||
cleanupInterval = null
|
||||
console.log('Cleaned up malformed message tracking interval')
|
||||
}
|
||||
}
|
||||
|
||||
// Manually clear all malformed message tracking
|
||||
const clearAllMalformedMessages = () => {
|
||||
const count = malformedMessageIds.value.size
|
||||
malformedMessageIds.value.clear()
|
||||
console.log(`Cleared ${count} malformed message IDs from tracking`)
|
||||
}
|
||||
|
||||
// Get statistics about malformed messages
|
||||
const getMalformedMessageStats = () => {
|
||||
return {
|
||||
totalMalformed: malformedMessageIds.value.size,
|
||||
totalProcessed: processedMessageIds.value.size,
|
||||
malformedIds: Array.from(malformedMessageIds.value).slice(0, 10) // First 10 for debugging
|
||||
}
|
||||
}
|
||||
|
||||
// Computed - use relay hub's connection status and auth system
|
||||
const isConnected = computed(() => relayHub.isConnected.value)
|
||||
|
||||
|
|
@ -322,6 +373,11 @@ export function useNostrChat() {
|
|||
await relayHub.connect()
|
||||
}
|
||||
|
||||
// Set up periodic cleanup of malformed messages
|
||||
if (!cleanupInterval) {
|
||||
cleanupInterval = setInterval(cleanupMalformedMessages, 5 * 60 * 1000) // Every 5 minutes
|
||||
console.log('Set up periodic cleanup of malformed messages')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to relays:', error)
|
||||
|
|
@ -495,13 +551,45 @@ export function useNostrChat() {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if this message was previously identified as malformed
|
||||
if (malformedMessageIds.value.has(event.id)) {
|
||||
console.log('Skipping previously identified malformed message:', event.id)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// For NIP-04 direct messages, always use peerPubkey as the second argument
|
||||
// This is the public key of the other party in the conversation
|
||||
const isSentByMe = event.pubkey === currentUser.value.pubkey
|
||||
|
||||
// Check for malformed messages before attempting decryption
|
||||
if (typeof event.content !== 'string' || event.content.length === 0) {
|
||||
console.warn('Skipping message with invalid content format:', {
|
||||
eventId: event.id,
|
||||
contentType: typeof event.content,
|
||||
contentLength: event.content?.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for our old placeholder encryption format
|
||||
if (event.content.includes('[ENCRYPTED]') && event.content.includes('[ENCRYPTED]')) {
|
||||
console.warn('Skipping message with old placeholder encryption format:', {
|
||||
eventId: event.id,
|
||||
content: event.content.substring(0, 100) + '...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for other common malformed patterns
|
||||
if (event.content.startsWith('[') || event.content.includes('ENCRYPTED')) {
|
||||
console.warn('Skipping message with suspicious encryption format:', {
|
||||
eventId: event.id,
|
||||
content: event.content.substring(0, 100) + '...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const decryptedContent = await nip04.decrypt(
|
||||
currentUser.value.prvkey,
|
||||
peerPubkey, // Always use peerPubkey for shared secret derivation
|
||||
|
|
@ -556,7 +644,40 @@ export function useNostrChat() {
|
|||
onMessageAdded.value(peerPubkey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error)
|
||||
// Provide more specific error handling for different types of failures
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
// Check for specific error patterns that indicate malformed messages
|
||||
if (errorMessage.includes('join.decode') || errorMessage.includes('input should be string')) {
|
||||
console.warn('Skipping malformed message (invalid NIP-04 format):', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage,
|
||||
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
||||
})
|
||||
markMessageAsMalformed(event.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (errorMessage.includes('Invalid byte sequence') || errorMessage.includes('hex string')) {
|
||||
console.warn('Skipping message with invalid hex encoding:', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage
|
||||
})
|
||||
markMessageAsMalformed(event.id)
|
||||
return
|
||||
}
|
||||
|
||||
// For other decryption errors, log with more context
|
||||
console.error('Failed to decrypt message:', {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
error: errorMessage,
|
||||
contentType: typeof event.content,
|
||||
contentLength: event.content?.length,
|
||||
contentPreview: typeof event.content === 'string' ? event.content.substring(0, 100) + '...' : 'Invalid content type'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,7 +915,12 @@ export function useNostrChat() {
|
|||
subscribeToAllPeersForNotifications,
|
||||
currentUser,
|
||||
hasNostrKeys,
|
||||
getNostrKeyStatus
|
||||
getNostrKeyStatus,
|
||||
markMessageAsMalformed,
|
||||
cleanupMalformedMessages,
|
||||
clearAllMalformedMessages, // Add the new function to the return object
|
||||
cleanup, // Add the cleanup function to the return object
|
||||
getMalformedMessageStats // Add the new function to the return object
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
248
src/composables/useNostrOrders.ts
Normal file
248
src/composables/useNostrOrders.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { ref, computed, readonly } from 'vue'
|
||||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { hexToBytes } from '@/lib/utils/crypto'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export function useNostrOrders() {
|
||||
// State
|
||||
const isPublishing = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const publishedEvents = ref<Record<string, string>>({}) // orderId -> eventId
|
||||
|
||||
// Computed
|
||||
const isReady = computed(() => {
|
||||
return auth.isAuthenticated.value &&
|
||||
!!auth.currentUser.value?.pubkey &&
|
||||
!!auth.currentUser.value?.prvkey
|
||||
})
|
||||
|
||||
const currentUserPubkey = computed(() => auth.currentUser.value?.pubkey || '')
|
||||
const currentUserPrvkey = computed(() => auth.currentUser.value?.prvkey || '')
|
||||
|
||||
// Methods
|
||||
const validateAuth = (): { valid: boolean; error?: string } => {
|
||||
if (!auth.isAuthenticated.value) {
|
||||
return { valid: false, error: 'User not authenticated' }
|
||||
}
|
||||
|
||||
if (!currentUserPubkey.value) {
|
||||
return { valid: false, error: 'User public key not available' }
|
||||
}
|
||||
|
||||
if (!currentUserPrvkey.value) {
|
||||
return { valid: false, error: 'User private key not available' }
|
||||
}
|
||||
|
||||
// Validate key formats
|
||||
if (currentUserPubkey.value.length !== 64) {
|
||||
return { valid: false, error: 'Invalid public key format' }
|
||||
}
|
||||
|
||||
if (currentUserPrvkey.value.length !== 64) {
|
||||
return { valid: false, error: 'Invalid private key format' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const createEventTemplate = (recipientPubkey: string, content: string): EventTemplate => {
|
||||
return {
|
||||
kind: 4, // Encrypted Direct Message
|
||||
tags: [['p', recipientPubkey]], // Recipient tag
|
||||
content: content,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const encryptOrderContent = async (order: Order, recipientPubkey: string): Promise<string> => {
|
||||
try {
|
||||
console.log('Encrypting order content:', {
|
||||
orderId: order.id,
|
||||
recipientPubkey,
|
||||
hasPrivateKey: !!currentUserPrvkey.value,
|
||||
privateKeyLength: currentUserPrvkey.value?.length
|
||||
})
|
||||
|
||||
// Validate keys
|
||||
if (!currentUserPrvkey.value || !recipientPubkey) {
|
||||
throw new Error('Missing private key or recipient public key')
|
||||
}
|
||||
|
||||
if (currentUserPrvkey.value.length !== 64) {
|
||||
throw new Error(`Invalid private key length: ${currentUserPrvkey.value.length} (expected 64)`)
|
||||
}
|
||||
|
||||
if (recipientPubkey.length !== 64) {
|
||||
throw new Error(`Invalid recipient public key length: ${recipientPubkey.length} (expected 64)`)
|
||||
}
|
||||
|
||||
// Create the order payload
|
||||
const orderPayload = {
|
||||
type: 'market_order',
|
||||
orderId: order.id,
|
||||
items: order.items,
|
||||
contactInfo: order.contactInfo,
|
||||
shippingZone: order.shippingZone,
|
||||
paymentMethod: order.paymentMethod,
|
||||
subtotal: order.subtotal,
|
||||
shippingCost: order.shippingCost,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
createdAt: order.createdAt,
|
||||
buyerPubkey: order.buyerPubkey
|
||||
}
|
||||
|
||||
// Convert to JSON string
|
||||
const orderJson = JSON.stringify(orderPayload)
|
||||
console.log('Order payload created:', orderPayload)
|
||||
|
||||
// Encrypt the order content using NIP-04
|
||||
const encryptedContent = await nip04.encrypt(
|
||||
hexToBytes(currentUserPrvkey.value),
|
||||
recipientPubkey,
|
||||
orderJson
|
||||
)
|
||||
|
||||
console.log('Order content encrypted successfully:', {
|
||||
originalLength: orderJson.length,
|
||||
encryptedLength: encryptedContent.length,
|
||||
encryptedPreview: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return encryptedContent
|
||||
} catch (error) {
|
||||
console.error('Failed to encrypt order content:', error)
|
||||
throw new Error('Failed to encrypt order content')
|
||||
}
|
||||
}
|
||||
|
||||
const publishOrderEvent = async (order: Order, recipientPubkey: string): Promise<{ id: string; sig: string }> => {
|
||||
try {
|
||||
// Validate authentication
|
||||
const authValidation = validateAuth()
|
||||
if (!authValidation.valid) {
|
||||
throw new Error(authValidation.error)
|
||||
}
|
||||
|
||||
// Set publishing state
|
||||
isPublishing.value = true
|
||||
lastError.value = null
|
||||
|
||||
// Encrypt the order content
|
||||
const encryptedContent = await encryptOrderContent(order, recipientPubkey)
|
||||
|
||||
// Create event template
|
||||
const eventTemplate = createEventTemplate(recipientPubkey, encryptedContent)
|
||||
|
||||
// Finalize the event (sign and generate ID)
|
||||
const event = finalizeEvent(eventTemplate, hexToBytes(currentUserPrvkey.value))
|
||||
|
||||
// Publish via relay hub
|
||||
await relayHub.publishEvent(event)
|
||||
|
||||
// Store the published event
|
||||
publishedEvents.value[order.id] = event.id
|
||||
|
||||
console.log('Order event published successfully:', {
|
||||
orderId: order.id,
|
||||
eventId: event.id,
|
||||
recipient: recipientPubkey,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
return { id: event.id, sig: event.sig }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
lastError.value = errorMessage
|
||||
console.error('Failed to publish order event:', error)
|
||||
throw new Error(`Failed to publish order: ${errorMessage}`)
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPublishedEventId = (orderId: string): string | undefined => {
|
||||
return publishedEvents.value[orderId]
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
isPublishing.value = false
|
||||
lastError.value = null
|
||||
publishedEvents.value = {}
|
||||
}
|
||||
|
||||
const testEncryption = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!isReady.value) {
|
||||
console.log('Nostr not ready for testing')
|
||||
return false
|
||||
}
|
||||
|
||||
const testMessage = 'Hello, this is a test message for NIP-04 encryption!'
|
||||
const testRecipient = currentUserPubkey.value // Encrypt to ourselves for testing
|
||||
|
||||
console.log('Testing NIP-04 encryption with:', {
|
||||
message: testMessage,
|
||||
recipient: testRecipient,
|
||||
sender: currentUserPubkey.value
|
||||
})
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await nip04.encrypt(
|
||||
hexToBytes(currentUserPrvkey.value),
|
||||
testRecipient,
|
||||
testMessage
|
||||
)
|
||||
|
||||
console.log('Test message encrypted:', encrypted)
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await nip04.decrypt(
|
||||
currentUserPrvkey.value,
|
||||
currentUserPubkey.value,
|
||||
encrypted
|
||||
)
|
||||
|
||||
console.log('Test message decrypted:', decrypted)
|
||||
|
||||
const success = decrypted === testMessage
|
||||
console.log('NIP-04 test result:', success ? 'PASSED' : 'FAILED')
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error('NIP-04 test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isPublishing: readonly(isPublishing),
|
||||
lastError: readonly(lastError),
|
||||
publishedEvents: readonly(publishedEvents),
|
||||
|
||||
// Computed
|
||||
isReady,
|
||||
currentUserPubkey,
|
||||
currentUserPrvkey,
|
||||
|
||||
// Methods
|
||||
validateAuth,
|
||||
createEventTemplate,
|
||||
encryptOrderContent,
|
||||
publishOrderEvent,
|
||||
getPublishedEventId,
|
||||
clearError,
|
||||
reset,
|
||||
testEncryption
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrOrders = useNostrOrders()
|
||||
198
src/composables/useNostrclientHub.ts
Normal file
198
src/composables/useNostrclientHub.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { nostrclientHub, type SubscriptionConfig } from '../lib/nostr/nostrclientHub'
|
||||
|
||||
export function useNostrclientHub() {
|
||||
// Reactive state
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const connectionStatus = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
|
||||
const error = ref<Error | null>(null)
|
||||
const activeSubscriptions = ref<Set<string>>(new Set())
|
||||
|
||||
// Reactive counts
|
||||
const totalSubscriptionCount = ref(0)
|
||||
const subscriptionDetails = ref<Array<{ id: string; filters: any[] }>>([])
|
||||
|
||||
// Computed properties
|
||||
const connectionHealth = computed(() => {
|
||||
return isConnected.value ? 100 : 0
|
||||
})
|
||||
|
||||
// Initialize nostrclient hub
|
||||
const initialize = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
console.log('🔧 NostrclientHub: Initializing...')
|
||||
await nostrclientHub.initialize()
|
||||
console.log('🔧 NostrclientHub: Initialization successful')
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
console.log('🔧 NostrclientHub: Connection status set to connected')
|
||||
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to initialize NostrclientHub')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
console.error('🔧 NostrclientHub: Failed to initialize:', errorObj)
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to nostrclient
|
||||
const connect = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
await nostrclientHub.connect()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to connect')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from nostrclient
|
||||
const disconnect = (): void => {
|
||||
nostrclientHub.disconnect()
|
||||
connectionStatus.value = 'disconnected'
|
||||
isConnected.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
const subscribe = (config: SubscriptionConfig): (() => void) => {
|
||||
try {
|
||||
const unsubscribe = nostrclientHub.subscribe(config)
|
||||
activeSubscriptions.value.add(config.id)
|
||||
|
||||
// Update reactive state
|
||||
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
|
||||
subscriptionDetails.value = nostrclientHub.subscriptionDetails
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
activeSubscriptions.value.delete(config.id)
|
||||
totalSubscriptionCount.value = nostrclientHub.totalSubscriptionCount
|
||||
subscriptionDetails.value = nostrclientHub.subscriptionDetails
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to subscribe:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Publish an event
|
||||
const publishEvent = async (event: any): Promise<void> => {
|
||||
try {
|
||||
await nostrclientHub.publishEvent(event)
|
||||
} catch (err) {
|
||||
console.error('Failed to publish event:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Query events
|
||||
const queryEvents = async (filters: any[]): Promise<any[]> => {
|
||||
try {
|
||||
return await nostrclientHub.queryEvents(filters)
|
||||
} catch (err) {
|
||||
console.error('Failed to query events:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
const setupEventListeners = () => {
|
||||
nostrclientHub.on('connected', () => {
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
connectionStatus.value = 'connected'
|
||||
error.value = null
|
||||
})
|
||||
|
||||
nostrclientHub.on('disconnected', () => {
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
})
|
||||
|
||||
nostrclientHub.on('error', (err) => {
|
||||
error.value = err
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('connectionError', (err) => {
|
||||
error.value = err
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('maxReconnectionAttemptsReached', () => {
|
||||
error.value = new Error('Max reconnection attempts reached')
|
||||
connectionStatus.value = 'error'
|
||||
})
|
||||
|
||||
nostrclientHub.on('event', ({ subscriptionId, event }) => {
|
||||
console.log('Received event for subscription:', subscriptionId, event.id)
|
||||
})
|
||||
|
||||
nostrclientHub.on('eose', ({ subscriptionId }) => {
|
||||
console.log('EOSE received for subscription:', subscriptionId)
|
||||
})
|
||||
|
||||
nostrclientHub.on('notice', ({ message }) => {
|
||||
console.log('Notice from nostrclient:', message)
|
||||
})
|
||||
|
||||
nostrclientHub.on('eventPublished', ({ eventId }) => {
|
||||
console.log('Event published successfully:', eventId)
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
const cleanup = () => {
|
||||
nostrclientHub.removeAllListeners()
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isConnected: readonly(isConnected),
|
||||
isConnecting: readonly(isConnecting),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
error: readonly(error),
|
||||
activeSubscriptions: readonly(activeSubscriptions),
|
||||
totalSubscriptionCount: readonly(totalSubscriptionCount),
|
||||
subscriptionDetails: readonly(subscriptionDetails),
|
||||
|
||||
// Computed
|
||||
connectionHealth: readonly(connectionHealth),
|
||||
|
||||
// Methods
|
||||
initialize,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
publishEvent,
|
||||
queryEvents,
|
||||
|
||||
// Internal
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
585
src/composables/useOrderEvents.ts
Normal file
585
src/composables/useOrderEvents.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
import { nip04 } from 'nostr-tools'
|
||||
import { relayHubComposable } from './useRelayHub'
|
||||
import { useAuth } from './useAuth'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { config } from '@/lib/config'
|
||||
import type { Order, OrderStatus } from '@/stores/market'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
// Order event types based on NIP-69 and nostrmarket patterns
|
||||
export enum OrderEventType {
|
||||
CUSTOMER_ORDER = 'customer_order',
|
||||
PAYMENT_REQUEST = 'payment_request',
|
||||
ORDER_PAID = 'order_paid',
|
||||
ORDER_SHIPPED = 'order_shipped',
|
||||
ORDER_DELIVERED = 'order_delivered',
|
||||
ORDER_CANCELLED = 'order_cancelled',
|
||||
INVOICE_GENERATED = 'invoice_generated'
|
||||
}
|
||||
|
||||
export interface OrderEvent {
|
||||
type: OrderEventType
|
||||
orderId: string
|
||||
data: any
|
||||
timestamp: number
|
||||
senderPubkey: string
|
||||
}
|
||||
|
||||
export interface PaymentRequestEvent {
|
||||
type: OrderEventType.PAYMENT_REQUEST
|
||||
orderId: string
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
memo: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface OrderStatusEvent {
|
||||
type: OrderEventType.ORDER_PAID | OrderEventType.ORDER_SHIPPED | OrderEventType.ORDER_DELIVERED
|
||||
orderId: string
|
||||
status: OrderStatus
|
||||
timestamp: number
|
||||
additionalData?: any
|
||||
}
|
||||
|
||||
export function useOrderEvents() {
|
||||
const relayHub = relayHubComposable
|
||||
const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// State
|
||||
const isSubscribed = ref(false)
|
||||
const lastEventTimestamp = ref(0)
|
||||
const processedEventIds = ref(new Set<string>())
|
||||
const subscriptionId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const currentUserPubkey = computed(() => auth.currentUser?.value?.pubkey)
|
||||
const isReady = computed(() => {
|
||||
const isAuth = auth.isAuthenticated
|
||||
const isConnected = relayHub.isConnected.value
|
||||
const hasPubkey = !!currentUserPubkey.value
|
||||
|
||||
console.log('OrderEvents isReady check:', { isAuth, isConnected, hasPubkey })
|
||||
return isAuth && isConnected && hasPubkey
|
||||
})
|
||||
|
||||
// Subscribe to order events
|
||||
const subscribeToOrderEvents = async () => {
|
||||
console.log('subscribeToOrderEvents called with:', {
|
||||
isReady: isReady.value,
|
||||
isSubscribed: isSubscribed.value,
|
||||
currentUserPubkey: currentUserPubkey.value,
|
||||
relayHubConnected: relayHub.isConnected.value,
|
||||
authStatus: auth.isAuthenticated
|
||||
})
|
||||
|
||||
if (!isReady.value || isSubscribed.value) {
|
||||
console.warn('Cannot subscribe to order events: not ready or already subscribed', {
|
||||
isReady: isReady.value,
|
||||
isSubscribed: isSubscribed.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Subscribing to order events for user:', currentUserPubkey.value)
|
||||
|
||||
// Subscribe to direct messages (kind 4) that contain order information
|
||||
const filters = [
|
||||
{
|
||||
kinds: [4], // NIP-04 encrypted direct messages
|
||||
'#p': [currentUserPubkey.value].filter(Boolean) as string[], // Messages to us, filter out undefined
|
||||
since: lastEventTimestamp.value
|
||||
}
|
||||
]
|
||||
|
||||
console.log('Using filters:', filters)
|
||||
|
||||
const unsubscribe = relayHub.subscribe({
|
||||
id: `order-events-${currentUserPubkey.value}-${Date.now()}`,
|
||||
filters,
|
||||
relays: config.market.supportedRelays,
|
||||
onEvent: (event: any) => {
|
||||
console.log('Received event in order subscription:', event.id)
|
||||
handleOrderEvent(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log('Order events subscription EOSE')
|
||||
}
|
||||
})
|
||||
|
||||
subscriptionId.value = `order-events-${currentUserPubkey.value}-${Date.now()}`
|
||||
isSubscribed.value = true
|
||||
|
||||
console.log('Successfully subscribed to order events with ID:', subscriptionId.value)
|
||||
return unsubscribe
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to order events:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming order events
|
||||
const handleOrderEvent = async (event: any) => {
|
||||
if (!auth.currentUser?.value?.prvkey) {
|
||||
console.warn('Cannot decrypt order event: no private key available')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we've already processed this event
|
||||
if (processedEventIds.value.has(event.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt the message content
|
||||
const decryptedContent = await nip04.decrypt(
|
||||
auth.currentUser.value.prvkey,
|
||||
event.pubkey, // Sender's pubkey
|
||||
event.content
|
||||
)
|
||||
|
||||
// Parse the decrypted content
|
||||
const orderEvent = JSON.parse(decryptedContent)
|
||||
|
||||
console.log('Received order event:', {
|
||||
eventId: event.id,
|
||||
type: orderEvent.type,
|
||||
orderId: orderEvent.orderId,
|
||||
sender: event.pubkey
|
||||
})
|
||||
|
||||
// Handle nostrmarket protocol messages
|
||||
if (orderEvent.type === 0 || orderEvent.type === 1 || orderEvent.type === 2) {
|
||||
await handleNostrmarketMessage(orderEvent, event.pubkey)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the order event based on type
|
||||
await processOrderEvent(orderEvent, event.pubkey)
|
||||
|
||||
// Mark as processed
|
||||
processedEventIds.value.add(event.id)
|
||||
lastEventTimestamp.value = Math.max(lastEventTimestamp.value, event.created_at)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to process order event:', {
|
||||
eventId: event.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nostrmarket protocol messages (type 0, 1, 2)
|
||||
const handleNostrmarketMessage = async (message: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Processing nostrmarket message:', {
|
||||
type: message.type,
|
||||
orderId: message.id,
|
||||
sender: senderPubkey
|
||||
})
|
||||
|
||||
// Import nostrmarket service
|
||||
const { nostrmarketService } = await import('@/lib/services/nostrmarketService')
|
||||
|
||||
switch (message.type) {
|
||||
case 0:
|
||||
// Customer order - this should be handled by the merchant side
|
||||
console.log('Received customer order (type 0) - this should be handled by merchant')
|
||||
break
|
||||
|
||||
case 1:
|
||||
// Payment request from merchant
|
||||
console.log('Received payment request from merchant')
|
||||
await nostrmarketService.handlePaymentRequest(message)
|
||||
break
|
||||
|
||||
case 2:
|
||||
// Order status update from merchant
|
||||
console.log('Received order status update from merchant')
|
||||
await nostrmarketService.handleOrderStatusUpdate(message)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('Unknown nostrmarket message type:', message.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle nostrmarket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Process incoming Nostr events
|
||||
const processOrderEvent = async (event: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Received order event:', {
|
||||
eventId: event.id || 'unknown',
|
||||
type: event.type,
|
||||
orderId: event.orderId,
|
||||
sender: senderPubkey
|
||||
})
|
||||
|
||||
// Only process events that have the required market order structure
|
||||
if (!event.type || event.type !== 'market_order') {
|
||||
console.log('Skipping non-market order event:', event.type)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that this is actually a market order event
|
||||
if (!event.orderId || !event.items || !Array.isArray(event.items)) {
|
||||
console.log('Skipping invalid market order event - missing required fields')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Processing market order:', event)
|
||||
|
||||
// Check if this order already exists - use the orderId as the primary key
|
||||
const existingOrder = Object.values(marketStore.orders).find(
|
||||
order => order.id === event.orderId
|
||||
)
|
||||
|
||||
if (existingOrder) {
|
||||
console.log('Order already exists, updating with new information:', existingOrder.id)
|
||||
|
||||
// Update the existing order with any new information
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// If there's invoice information, update it
|
||||
if (event.lightningInvoice) {
|
||||
updatedOrder.lightningInvoice = event.lightningInvoice
|
||||
updatedOrder.paymentHash = event.paymentHash
|
||||
updatedOrder.paymentStatus = event.paymentStatus || 'pending'
|
||||
updatedOrder.paymentRequest = event.paymentRequest
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(existingOrder.id, updatedOrder)
|
||||
|
||||
console.log('Updated existing order:', {
|
||||
orderId: existingOrder.id,
|
||||
hasInvoice: !!updatedOrder.lightningInvoice,
|
||||
paymentStatus: updatedOrder.paymentStatus
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create a basic order object from the event data
|
||||
const orderData: Partial<Order> = {
|
||||
id: event.orderId,
|
||||
nostrEventId: event.id || 'unknown',
|
||||
buyerPubkey: senderPubkey,
|
||||
sellerPubkey: event.sellerPubkey || '',
|
||||
items: event.items || [],
|
||||
total: event.total || 0,
|
||||
currency: event.currency || 'sat',
|
||||
status: 'pending' as OrderStatus,
|
||||
createdAt: event.createdAt || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
// Add invoice details if present
|
||||
...(event.lightningInvoice && {
|
||||
lightningInvoice: {
|
||||
checking_id: event.lightningInvoice.checking_id || event.lightningInvoice.payment_hash || '',
|
||||
payment_hash: event.lightningInvoice.payment_hash || '',
|
||||
wallet_id: event.lightningInvoice.wallet_id || '',
|
||||
amount: event.lightningInvoice.amount || 0,
|
||||
fee: event.lightningInvoice.fee || 0,
|
||||
bolt11: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
status: 'pending',
|
||||
memo: event.lightningInvoice.memo || '',
|
||||
expiry: event.lightningInvoice.expiry || '',
|
||||
preimage: event.lightningInvoice.preimage || '',
|
||||
extra: event.lightningInvoice.extra || {},
|
||||
created_at: event.lightningInvoice.created_at || '',
|
||||
updated_at: event.lightningInvoice.updated_at || ''
|
||||
},
|
||||
paymentHash: event.lightningInvoice.payment_hash || '',
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Create the order using the store method
|
||||
const order = marketStore.createOrder({
|
||||
id: event.id,
|
||||
cartId: event.id,
|
||||
stallId: 'unknown', // We'll need to determine this from the items
|
||||
buyerPubkey: senderPubkey,
|
||||
sellerPubkey: '', // Will be set when we know the merchant
|
||||
status: 'pending',
|
||||
items: Array.from(orderData.items || []), // Convert readonly to mutable
|
||||
contactInfo: orderData.contactInfo || {},
|
||||
shippingZone: orderData.shippingZone || {
|
||||
id: 'online',
|
||||
name: 'Online',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
description: 'Online delivery'
|
||||
},
|
||||
paymentMethod: 'lightning',
|
||||
subtotal: 0,
|
||||
shippingCost: 0,
|
||||
total: 0,
|
||||
currency: 'sat',
|
||||
originalOrderId: event.id
|
||||
})
|
||||
|
||||
console.log('Created order from market event:', {
|
||||
orderId: order.id,
|
||||
total: order.total,
|
||||
status: order.status
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle market order:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle payment request events
|
||||
const handlePaymentRequest = async (event: PaymentRequestEvent, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Payment request received for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order with payment request (excluding readonly items)
|
||||
const { items, ...orderWithoutItems } = order
|
||||
const updatedOrder = {
|
||||
...orderWithoutItems,
|
||||
paymentRequest: event.paymentRequest,
|
||||
paymentStatus: 'pending' as const,
|
||||
updatedAt: Date.now() / 1000
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(event.orderId, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: event.orderId,
|
||||
amount: event.amount,
|
||||
currency: event.currency
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order status updates
|
||||
const handleOrderStatusUpdate = async (event: OrderStatusEvent, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Status update received for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status
|
||||
marketStore.updateOrderStatus(event.orderId, event.status)
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: event.orderId,
|
||||
newStatus: event.status,
|
||||
timestamp: event.timestamp
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle order status update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle invoice generation events
|
||||
const handleInvoiceGenerated = async (event: any, _senderPubkey: string) => {
|
||||
try {
|
||||
// Find the order in our store
|
||||
const order = marketStore.orders[event.orderId]
|
||||
if (!order) {
|
||||
console.warn('Invoice generated for unknown order:', event.orderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order with invoice details (excluding readonly items)
|
||||
const { items, ...orderWithoutItems } = order
|
||||
const updatedOrder = {
|
||||
...orderWithoutItems,
|
||||
lightningInvoice: {
|
||||
payment_hash: event.paymentHash,
|
||||
payment_request: event.paymentRequest,
|
||||
amount: event.amount,
|
||||
memo: event.memo,
|
||||
expiry: event.expiresAt,
|
||||
created_at: event.timestamp,
|
||||
status: 'pending' as const
|
||||
},
|
||||
paymentHash: event.paymentHash,
|
||||
paymentStatus: 'pending' as const,
|
||||
updatedAt: Date.now() / 1000
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(event.orderId, updatedOrder)
|
||||
|
||||
console.log('Order updated with invoice details:', {
|
||||
orderId: event.orderId,
|
||||
paymentHash: event.paymentHash,
|
||||
amount: event.amount
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle invoice generation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle market order events (new orders)
|
||||
const handleMarketOrder = async (event: any, senderPubkey: string) => {
|
||||
try {
|
||||
console.log('Processing market order:', event)
|
||||
|
||||
// Check if this order already exists
|
||||
const existingOrder = Object.values(marketStore.orders).find(
|
||||
order => order.id === event.orderId || order.nostrEventId === event.id
|
||||
)
|
||||
|
||||
if (existingOrder) {
|
||||
console.log('Order already exists, updating with new information:', existingOrder.id)
|
||||
|
||||
// Update the existing order with any new information
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
...event,
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// If there's invoice information, update it
|
||||
if (event.lightningInvoice) {
|
||||
updatedOrder.lightningInvoice = event.lightningInvoice
|
||||
updatedOrder.paymentHash = event.paymentHash
|
||||
updatedOrder.paymentStatus = event.paymentStatus || 'pending'
|
||||
updatedOrder.paymentRequest = event.paymentRequest
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(existingOrder.id, updatedOrder)
|
||||
|
||||
console.log('Updated existing order:', {
|
||||
orderId: existingOrder.id,
|
||||
hasInvoice: !!updatedOrder.lightningInvoice,
|
||||
paymentStatus: updatedOrder.paymentStatus
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create a basic order object from the event data
|
||||
const orderData: Partial<Order> = {
|
||||
id: event.orderId,
|
||||
nostrEventId: event.id,
|
||||
buyerPubkey: event.pubkey || '',
|
||||
sellerPubkey: event.sellerPubkey || '',
|
||||
items: event.items || [],
|
||||
total: event.total || 0,
|
||||
currency: event.currency || 'sat',
|
||||
status: 'pending' as OrderStatus,
|
||||
createdAt: event.createdAt || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
// Add invoice details if present
|
||||
...(event.lightningInvoice && {
|
||||
lightningInvoice: {
|
||||
checking_id: event.lightningInvoice.checking_id || event.lightningInvoice.payment_hash || '',
|
||||
payment_hash: event.lightningInvoice.payment_hash || '',
|
||||
wallet_id: event.lightningInvoice.wallet_id || '',
|
||||
amount: event.lightningInvoice.amount || 0,
|
||||
fee: event.lightningInvoice.fee || 0,
|
||||
bolt11: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
status: 'pending',
|
||||
memo: event.lightningInvoice.memo || '',
|
||||
expiry: event.lightningInvoice.expiry || '',
|
||||
preimage: event.lightningInvoice.preimage || '',
|
||||
extra: event.lightningInvoice.extra || {},
|
||||
created_at: event.lightningInvoice.created_at || '',
|
||||
updated_at: event.lightningInvoice.updated_at || ''
|
||||
},
|
||||
paymentHash: event.lightningInvoice.payment_hash || '',
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: event.lightningInvoice.bolt11 || event.lightningInvoice.payment_request || '',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// Create the order using the store method
|
||||
const order = marketStore.createOrder(orderData)
|
||||
|
||||
console.log('Created order from market event:', {
|
||||
orderId: order.id,
|
||||
total: order.total,
|
||||
status: order.status
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle market order:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Start listening for order events
|
||||
const startListening = async () => {
|
||||
if (!isReady.value) {
|
||||
console.warn('Cannot start listening: not ready')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await subscribeToOrderEvents()
|
||||
console.log('Started listening for order events')
|
||||
} catch (error) {
|
||||
console.error('Failed to start listening for order events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop listening for order events
|
||||
const stopListening = () => {
|
||||
if (subscriptionId.value) {
|
||||
// Use the cleanup method from relayHub
|
||||
relayHub.cleanup()
|
||||
subscriptionId.value = null
|
||||
}
|
||||
isSubscribed.value = false
|
||||
console.log('Stopped listening for order events')
|
||||
}
|
||||
|
||||
// Clean up old processed events
|
||||
const cleanupProcessedEvents = () => {
|
||||
// const now = Date.now()
|
||||
// const cutoff = now - (24 * 60 * 60 * 1000) // 24 hours ago
|
||||
|
||||
// Remove old event IDs (this is a simple cleanup, could be more sophisticated)
|
||||
if (processedEventIds.value.size > 1000) {
|
||||
processedEventIds.value.clear()
|
||||
console.log('Cleaned up processed event IDs')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isSubscribed,
|
||||
lastEventTimestamp,
|
||||
|
||||
// Methods
|
||||
startListening,
|
||||
stopListening,
|
||||
subscribeToOrderEvents,
|
||||
cleanupProcessedEvents
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const orderEvents = useOrderEvents()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { relayHub, type SubscriptionConfig, type RelayStatus } from '../lib/nostr/relayHub'
|
||||
import { config } from '../lib/config'
|
||||
|
||||
|
|
@ -31,19 +31,24 @@ export function useRelayHub() {
|
|||
error.value = null
|
||||
|
||||
// Get relay URLs from config
|
||||
const relayUrls = config.nostr.relays
|
||||
const relayUrls = config.market.supportedRelays
|
||||
console.log('🔧 RelayHub: Initializing with relay URLs:', relayUrls)
|
||||
|
||||
if (!relayUrls || relayUrls.length === 0) {
|
||||
throw new Error('No relay URLs configured')
|
||||
}
|
||||
|
||||
// Initialize the relay hub
|
||||
console.log('🔧 RelayHub: Calling relayHub.initialize...')
|
||||
await relayHub.initialize(relayUrls)
|
||||
console.log('🔧 RelayHub: Initialization successful')
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
console.log('🔧 RelayHub: Connection status set to connected')
|
||||
|
||||
|
||||
} catch (err) {
|
||||
|
|
@ -51,7 +56,7 @@ export function useRelayHub() {
|
|||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
console.error('Failed to initialize RelayHub:', errorObj)
|
||||
console.error('🔧 RelayHub: Failed to initialize RelayHub:', errorObj)
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
|
@ -128,35 +133,11 @@ export function useRelayHub() {
|
|||
}
|
||||
}
|
||||
|
||||
// Force reconnection
|
||||
const reconnect = async (): Promise<void> => {
|
||||
try {
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = null
|
||||
|
||||
await relayHub.reconnect()
|
||||
|
||||
connectionStatus.value = 'connected'
|
||||
isConnected.value = true
|
||||
} catch (err) {
|
||||
const errorObj = err instanceof Error ? err : new Error('Failed to reconnect')
|
||||
error.value = errorObj
|
||||
connectionStatus.value = 'error'
|
||||
isConnected.value = false
|
||||
throw errorObj
|
||||
}
|
||||
}
|
||||
|
||||
// Get relay status
|
||||
const getRelayStatus = (url: string): RelayStatus | undefined => {
|
||||
return relayStatuses.value.find(status => status.url === url)
|
||||
}
|
||||
|
||||
// Check if a specific relay is connected
|
||||
const isRelayConnected = (url: string): boolean => {
|
||||
return relayHub.isRelayConnected(url)
|
||||
}
|
||||
|
||||
// Set up event listeners for relay hub events
|
||||
const setupEventListeners = (): void => {
|
||||
relayHub.on('connected', (count: number) => {
|
||||
|
|
@ -274,18 +255,16 @@ export function useRelayHub() {
|
|||
|
||||
return {
|
||||
// State
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
relayStatuses,
|
||||
error,
|
||||
activeSubscriptions,
|
||||
|
||||
// Computed
|
||||
connectedRelayCount,
|
||||
totalRelayCount,
|
||||
totalSubscriptionCount,
|
||||
subscriptionDetails,
|
||||
connectionHealth,
|
||||
isConnected: readonly(isConnected),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
relayStatuses: readonly(relayStatuses),
|
||||
error: readonly(error),
|
||||
activeSubscriptions: readonly(activeSubscriptions),
|
||||
connectedRelayCount: readonly(connectedRelayCount),
|
||||
totalRelayCount: readonly(totalRelayCount),
|
||||
totalSubscriptionCount: readonly(totalSubscriptionCount),
|
||||
subscriptionDetails: readonly(subscriptionDetails),
|
||||
connectionHealth: readonly(connectionHealth),
|
||||
|
||||
// Methods
|
||||
initialize,
|
||||
|
|
@ -294,9 +273,11 @@ export function useRelayHub() {
|
|||
subscribe,
|
||||
publishEvent,
|
||||
queryEvents,
|
||||
reconnect,
|
||||
getRelayStatus,
|
||||
isRelayConnected,
|
||||
getConnectionHealth: connectionHealth,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for global state
|
||||
export const relayHubComposable = useRelayHub()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ interface AppConfig {
|
|||
api: ApiConfig
|
||||
push: PushConfig
|
||||
market: MarketConfig
|
||||
nostrclient: {
|
||||
url: string
|
||||
enabled: boolean
|
||||
}
|
||||
support: {
|
||||
npub: string
|
||||
}
|
||||
|
|
@ -72,6 +76,10 @@ export const config: AppConfig = {
|
|||
lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED),
|
||||
defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat'
|
||||
},
|
||||
nostrclient: {
|
||||
url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1',
|
||||
enabled: Boolean(import.meta.env.VITE_NOSTRCLIENT_ENABLED)
|
||||
},
|
||||
support: {
|
||||
npub: import.meta.env.VITE_SUPPORT_NPUB || ''
|
||||
}
|
||||
|
|
|
|||
334
src/lib/nostr/nostrclientHub.ts
Normal file
334
src/lib/nostr/nostrclientHub.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import type { Filter, Event } from 'nostr-tools'
|
||||
|
||||
export interface NostrclientConfig {
|
||||
url: string
|
||||
privateKey?: string // For private WebSocket endpoint
|
||||
}
|
||||
|
||||
export interface SubscriptionConfig {
|
||||
id: string
|
||||
filters: Filter[]
|
||||
onEvent?: (event: Event) => void
|
||||
onEose?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export interface RelayStatus {
|
||||
url: string
|
||||
connected: boolean
|
||||
lastSeen: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class NostrclientHub extends EventEmitter {
|
||||
private ws: WebSocket | null = null
|
||||
private config: NostrclientConfig
|
||||
private subscriptions: Map<string, SubscriptionConfig> = new Map()
|
||||
private reconnectInterval?: NodeJS.Timeout
|
||||
private reconnectAttempts = 0
|
||||
private readonly maxReconnectAttempts = 5
|
||||
private readonly reconnectDelay = 5000
|
||||
|
||||
// Connection state
|
||||
private _isConnected = false
|
||||
private _isConnecting = false
|
||||
|
||||
constructor(config: NostrclientConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._isConnected
|
||||
}
|
||||
|
||||
get isConnecting(): boolean {
|
||||
return this._isConnecting
|
||||
}
|
||||
|
||||
get totalSubscriptionCount(): number {
|
||||
return this.subscriptions.size
|
||||
}
|
||||
|
||||
get subscriptionDetails(): Array<{ id: string; filters: Filter[] }> {
|
||||
return Array.from(this.subscriptions.values()).map(sub => ({
|
||||
id: sub.id,
|
||||
filters: sub.filters
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect to nostrclient WebSocket
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log('🔧 NostrclientHub: Initializing connection to', this.config.url)
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the nostrclient WebSocket
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this._isConnecting || this._isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
this._isConnecting = true
|
||||
this.reconnectAttempts++
|
||||
|
||||
try {
|
||||
console.log('🔧 NostrclientHub: Connecting to nostrclient WebSocket')
|
||||
|
||||
// Determine WebSocket endpoint
|
||||
const wsUrl = this.config.privateKey
|
||||
? `${this.config.url}/${this.config.privateKey}` // Private endpoint
|
||||
: `${this.config.url}/relay` // Public endpoint
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('🔧 NostrclientHub: WebSocket connected')
|
||||
this._isConnected = true
|
||||
this._isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connected')
|
||||
|
||||
// Resubscribe to existing subscriptions
|
||||
this.resubscribeAll()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('🔧 NostrclientHub: WebSocket closed:', event.code, event.reason)
|
||||
this._isConnected = false
|
||||
this._isConnecting = false
|
||||
this.emit('disconnected', event)
|
||||
|
||||
// Schedule reconnection
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.emit('maxReconnectionAttemptsReached')
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('🔧 NostrclientHub: WebSocket error:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this._isConnecting = false
|
||||
console.error('🔧 NostrclientHub: Connection failed:', error)
|
||||
this.emit('connectionError', error)
|
||||
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the WebSocket
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectInterval) {
|
||||
clearTimeout(this.reconnectInterval)
|
||||
this.reconnectInterval = undefined
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
this._isConnected = false
|
||||
this._isConnecting = false
|
||||
this.subscriptions.clear()
|
||||
this.emit('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
subscribe(config: SubscriptionConfig): () => void {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Not connected to nostrclient')
|
||||
}
|
||||
|
||||
// Store subscription
|
||||
this.subscriptions.set(config.id, config)
|
||||
|
||||
// Send REQ message
|
||||
const reqMessage = JSON.stringify([
|
||||
'REQ',
|
||||
config.id,
|
||||
...config.filters
|
||||
])
|
||||
|
||||
this.ws?.send(reqMessage)
|
||||
console.log('🔧 NostrclientHub: Subscribed to', config.id)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.unsubscribe(config.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from events
|
||||
*/
|
||||
unsubscribe(subscriptionId: string): void {
|
||||
if (!this._isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send CLOSE message
|
||||
const closeMessage = JSON.stringify(['CLOSE', subscriptionId])
|
||||
this.ws?.send(closeMessage)
|
||||
|
||||
// Remove from subscriptions
|
||||
this.subscriptions.delete(subscriptionId)
|
||||
console.log('🔧 NostrclientHub: Unsubscribed from', subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event
|
||||
*/
|
||||
async publishEvent(event: Event): Promise<void> {
|
||||
if (!this._isConnected) {
|
||||
throw new Error('Not connected to nostrclient')
|
||||
}
|
||||
|
||||
const eventMessage = JSON.stringify(['EVENT', event])
|
||||
this.ws?.send(eventMessage)
|
||||
|
||||
console.log('🔧 NostrclientHub: Published event', event.id)
|
||||
this.emit('eventPublished', { eventId: event.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Query events (one-time fetch)
|
||||
*/
|
||||
async queryEvents(filters: Filter[]): Promise<Event[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._isConnected) {
|
||||
reject(new Error('Not connected to nostrclient'))
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = `query-${Date.now()}`
|
||||
const events: Event[] = []
|
||||
let eoseReceived = false
|
||||
|
||||
// Create temporary subscription for query
|
||||
const tempSubscription = this.subscribe({
|
||||
id: queryId,
|
||||
filters,
|
||||
onEvent: (event) => {
|
||||
events.push(event)
|
||||
},
|
||||
onEose: () => {
|
||||
eoseReceived = true
|
||||
this.unsubscribe(queryId)
|
||||
resolve(events)
|
||||
},
|
||||
onClose: () => {
|
||||
if (!eoseReceived) {
|
||||
reject(new Error('Query subscription closed unexpectedly'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (!eoseReceived) {
|
||||
tempSubscription()
|
||||
reject(new Error('Query timeout'))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
private handleMessage(data: string): void {
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
|
||||
if (Array.isArray(message) && message.length >= 2) {
|
||||
const [type, subscriptionId, ...rest] = message
|
||||
|
||||
switch (type) {
|
||||
case 'EVENT':
|
||||
const event = rest[0] as Event
|
||||
const subscription = this.subscriptions.get(subscriptionId)
|
||||
if (subscription?.onEvent) {
|
||||
subscription.onEvent(event)
|
||||
}
|
||||
this.emit('event', { subscriptionId, event })
|
||||
break
|
||||
|
||||
case 'EOSE':
|
||||
const eoseSubscription = this.subscriptions.get(subscriptionId)
|
||||
if (eoseSubscription?.onEose) {
|
||||
eoseSubscription.onEose()
|
||||
}
|
||||
this.emit('eose', { subscriptionId })
|
||||
break
|
||||
|
||||
case 'NOTICE':
|
||||
console.log('🔧 NostrclientHub: Notice from relay:', rest[0])
|
||||
this.emit('notice', { message: rest[0] })
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('🔧 NostrclientHub: Unknown message type:', type)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔧 NostrclientHub: Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resubscribe to all existing subscriptions after reconnection
|
||||
*/
|
||||
private resubscribeAll(): void {
|
||||
for (const [id, config] of this.subscriptions) {
|
||||
const reqMessage = JSON.stringify([
|
||||
'REQ',
|
||||
id,
|
||||
...config.filters
|
||||
])
|
||||
this.ws?.send(reqMessage)
|
||||
}
|
||||
console.log('🔧 NostrclientHub: Resubscribed to', this.subscriptions.size, 'subscriptions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic reconnection
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectInterval) {
|
||||
clearTimeout(this.reconnectInterval)
|
||||
}
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
console.log(`🔧 NostrclientHub: Scheduling reconnection in ${delay}ms`)
|
||||
|
||||
this.reconnectInterval = setTimeout(async () => {
|
||||
await this.connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrclientHub = new NostrclientHub({
|
||||
url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1'
|
||||
})
|
||||
|
|
@ -130,6 +130,8 @@ export class RelayHub extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
console.log('🔧 RelayHub: Initializing with URLs:', relayUrls)
|
||||
|
||||
// Convert URLs to relay configs
|
||||
this.relayConfigs.clear()
|
||||
relayUrls.forEach((url, index) => {
|
||||
|
|
@ -141,12 +143,14 @@ export class RelayHub extends EventEmitter {
|
|||
})
|
||||
})
|
||||
|
||||
console.log('🔧 RelayHub: Relay configs created:', Array.from(this.relayConfigs.values()))
|
||||
|
||||
// Start connection management
|
||||
console.log('🔧 RelayHub: Starting connection...')
|
||||
await this.connect()
|
||||
this.startHealthCheck()
|
||||
this.isInitialized = true
|
||||
|
||||
|
||||
console.log('🔧 RelayHub: Initialization complete')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -157,22 +161,28 @@ export class RelayHub extends EventEmitter {
|
|||
throw new Error('No relay configurations found. Call initialize() first.')
|
||||
}
|
||||
|
||||
console.log('🔧 RelayHub: Connecting to', this.relayConfigs.size, 'relays')
|
||||
|
||||
try {
|
||||
this._connectionAttempts++
|
||||
|
||||
console.log('🔧 RelayHub: Connection attempt', this._connectionAttempts)
|
||||
|
||||
// Connect to relays in priority order
|
||||
const sortedRelays = Array.from(this.relayConfigs.values())
|
||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
||||
|
||||
console.log('🔧 RelayHub: Attempting connections to:', sortedRelays.map(r => r.url))
|
||||
|
||||
const connectionPromises = sortedRelays.map(async (config) => {
|
||||
try {
|
||||
console.log('🔧 RelayHub: Connecting to relay:', config.url)
|
||||
const relay = await this.pool.ensureRelay(config.url)
|
||||
this.connectedRelays.set(config.url, relay)
|
||||
console.log('🔧 RelayHub: Successfully connected to:', config.url)
|
||||
|
||||
return { url: config.url, success: true }
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to relay ${config.url}:`, error)
|
||||
console.error(`🔧 RelayHub: Failed to connect to relay ${config.url}:`, error)
|
||||
return { url: config.url, success: false, error }
|
||||
}
|
||||
})
|
||||
|
|
@ -182,25 +192,34 @@ export class RelayHub extends EventEmitter {
|
|||
result => result.status === 'fulfilled' && result.value.success
|
||||
)
|
||||
|
||||
console.log('🔧 RelayHub: Connection results:', {
|
||||
total: results.length,
|
||||
successful: successfulConnections.length,
|
||||
failed: results.length - successfulConnections.length
|
||||
})
|
||||
|
||||
if (successfulConnections.length > 0) {
|
||||
this._isConnected = true
|
||||
this._connectionAttempts = 0
|
||||
console.log('🔧 RelayHub: Connection successful, connected to', successfulConnections.length, 'relays')
|
||||
this.emit('connected', successfulConnections.length)
|
||||
|
||||
} else {
|
||||
console.error('🔧 RelayHub: Failed to connect to any relay')
|
||||
throw new Error('Failed to connect to any relay')
|
||||
}
|
||||
} catch (error) {
|
||||
this._isConnected = false
|
||||
console.error('🔧 RelayHub: Connection failed with error:', error)
|
||||
this.emit('connectionError', error)
|
||||
console.error('Connection failed:', error)
|
||||
|
||||
// Schedule reconnection if we haven't exceeded max attempts
|
||||
if (this._connectionAttempts < this.maxReconnectAttempts) {
|
||||
console.log('🔧 RelayHub: Scheduling reconnection attempt', this._connectionAttempts + 1)
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.emit('maxReconnectAttemptsReached')
|
||||
console.error('Max reconnection attempts reached')
|
||||
this.emit('maxReconnectionAttemptsReached')
|
||||
console.error('🔧 RelayHub: Max reconnection attempts reached')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/lib/nostr/utils.ts
Normal file
18
src/lib/nostr/utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Helper function to convert bech32 to hex
|
||||
export function bech32ToHex(bech32Key: string): string {
|
||||
if (bech32Key.startsWith('npub1') || bech32Key.startsWith('nsec1')) {
|
||||
// Import bech32 conversion dynamically to avoid bundling issues
|
||||
const { bech32Decode, convertbits } = require('bech32')
|
||||
const [, data] = bech32Decode(bech32Key)
|
||||
if (!data) {
|
||||
throw new Error(`Invalid bech32 key: ${bech32Key}`)
|
||||
}
|
||||
const converted = convertbits(data, 5, 8, false)
|
||||
if (!converted) {
|
||||
throw new Error(`Failed to convert bech32 key: ${bech32Key}`)
|
||||
}
|
||||
return Buffer.from(converted).toString('hex')
|
||||
}
|
||||
// Already hex format
|
||||
return bech32Key
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Notification manager for push notifications
|
||||
import { pushService, type NotificationPayload } from './push'
|
||||
import { configUtils } from '@/lib/config'
|
||||
// import type { NotificationPayload } from './push'
|
||||
|
||||
|
||||
export interface NotificationOptions {
|
||||
enabled: boolean
|
||||
|
|
@ -69,19 +69,19 @@ export class NotificationManager {
|
|||
throw new Error('Notifications are disabled')
|
||||
}
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
title: '🧪 Test Notification',
|
||||
body: 'This is a test notification from Ario',
|
||||
tag: 'test',
|
||||
icon: '/apple-touch-icon.png',
|
||||
badge: '/apple-touch-icon.png',
|
||||
data: {
|
||||
url: window.location.origin,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
// const payload: NotificationPayload = {
|
||||
// title: '🧪 Test Notification',
|
||||
// body: 'This is a test notification from Ario',
|
||||
// tag: 'test',
|
||||
// icon: '/apple-touch-icon.png',
|
||||
// badge: '/apple-touch-icon.png',
|
||||
// data: {
|
||||
// url: window.location.origin,
|
||||
// timestamp: Date.now()
|
||||
// }
|
||||
// }
|
||||
|
||||
await pushService.sendNotification(payload)
|
||||
// await pushService.sendNotification(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
189
src/lib/services/invoiceService.ts
Normal file
189
src/lib/services/invoiceService.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { getApiUrl } from '@/lib/config/lnbits'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export interface LightningInvoice {
|
||||
checking_id: string
|
||||
payment_hash: string
|
||||
wallet_id: string
|
||||
amount: number
|
||||
fee: number
|
||||
bolt11: string // This is the payment request/invoice
|
||||
status: string
|
||||
memo?: string
|
||||
expiry?: string
|
||||
preimage?: string
|
||||
extra?: Record<string, any>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
amount: number
|
||||
memo: string
|
||||
unit?: 'sat' | 'btc'
|
||||
expiry?: number
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amount_paid: number
|
||||
paid_at?: number
|
||||
payment_hash: string
|
||||
}
|
||||
|
||||
class InvoiceService {
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
// Use the payments endpoint for invoice creation
|
||||
this.baseUrl = getApiUrl('/payments')
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
adminKey: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// Construct the URL - for payments, we just append the endpoint
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
console.log('Invoice Service Request:', { url, endpoint })
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': adminKey, // Use the wallet's admin key
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Invoice Service Error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorText,
|
||||
url
|
||||
})
|
||||
throw new Error(`Invoice request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice for an order
|
||||
*/
|
||||
async createInvoice(order: Order, adminKey: string, extra?: Record<string, any>): Promise<LightningInvoice> {
|
||||
const invoiceData: CreateInvoiceRequest = {
|
||||
amount: order.total,
|
||||
unit: 'sat',
|
||||
memo: `Order ${order.id} - ${order.items.length} items`,
|
||||
expiry: 3600, // 1 hour
|
||||
extra: {
|
||||
tag: 'nostrmarket', // Use nostrmarket tag for compatibility
|
||||
order_id: extra?.order_id || order.id, // Use passed order_id or fallback to order.id
|
||||
merchant_pubkey: extra?.merchant_pubkey || order.sellerPubkey, // Use passed merchant_pubkey or fallback
|
||||
...extra // Allow additional metadata to be passed in
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Log the exact data being sent to LNBits
|
||||
const requestBody = {
|
||||
out: false, // Incoming payment
|
||||
...invoiceData
|
||||
}
|
||||
|
||||
console.log('Sending invoice request to LNBits:', {
|
||||
url: `${this.baseUrl}`,
|
||||
body: requestBody,
|
||||
extra: requestBody.extra
|
||||
})
|
||||
|
||||
// Use the correct LNBits payments endpoint
|
||||
const response = await this.request<LightningInvoice>('', adminKey, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
console.log('Full LNBits response:', response)
|
||||
console.log('Response type:', typeof response)
|
||||
console.log('Response keys:', Object.keys(response))
|
||||
|
||||
// Check if we have the expected fields
|
||||
if (!response.bolt11) {
|
||||
console.error('Missing bolt11 in response:', response)
|
||||
throw new Error('Invalid invoice response: missing bolt11')
|
||||
}
|
||||
|
||||
console.log('Lightning invoice created with nostrmarket tag:', {
|
||||
orderId: order.id,
|
||||
paymentHash: response.payment_hash,
|
||||
amount: response.amount,
|
||||
paymentRequest: response.bolt11.substring(0, 50) + '...',
|
||||
extra: invoiceData.extra
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status of an invoice
|
||||
*/
|
||||
async checkPaymentStatus(paymentHash: string, adminKey: string): Promise<PaymentStatus> {
|
||||
try {
|
||||
const response = await this.request<PaymentStatus>(`/${paymentHash}`, adminKey, {})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to check payment status:', error)
|
||||
throw new Error('Failed to check payment status')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all payments for a wallet
|
||||
*/
|
||||
async getWalletPayments(adminKey: string, limit: number = 100): Promise<PaymentStatus[]> {
|
||||
try {
|
||||
const response = await this.request<PaymentStatus[]>(`?limit=${limit}`, adminKey, {})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to get wallet payments:', error)
|
||||
throw new Error('Failed to get wallet payments')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Lightning payment request
|
||||
*/
|
||||
validatePaymentRequest(paymentRequest: string): boolean {
|
||||
// Basic validation - should start with 'lnbc' and be a valid length
|
||||
return paymentRequest.startsWith('lnbc') && paymentRequest.length > 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract payment hash from a payment request
|
||||
*/
|
||||
extractPaymentHash(paymentRequest: string): string | null {
|
||||
try {
|
||||
// This is a simplified extraction - in production you'd use a proper BOLT11 decoder
|
||||
const match = paymentRequest.match(/lnbc[0-9]+[a-z0-9]+/i)
|
||||
return match ? match[0] : null
|
||||
} catch (error) {
|
||||
console.error('Failed to extract payment hash:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const invoiceService = new InvoiceService()
|
||||
|
||||
396
src/lib/services/nostrmarketService.ts
Normal file
396
src/lib/services/nostrmarketService.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import type { Stall, Product, Order } from '@/stores/market'
|
||||
import { bech32ToHex } from '@/lib/utils/bech32'
|
||||
|
||||
export interface NostrmarketStall {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
currency: string
|
||||
shipping: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketProduct {
|
||||
id: string
|
||||
stall_id: string
|
||||
name: string
|
||||
description?: string
|
||||
images: string[]
|
||||
categories: string[]
|
||||
price: number
|
||||
quantity: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface NostrmarketOrder {
|
||||
id: string
|
||||
items: Array<{
|
||||
product_id: string
|
||||
quantity: number
|
||||
}>
|
||||
contact: {
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
address?: {
|
||||
street: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
postal_code: string
|
||||
}
|
||||
shipping_id: string
|
||||
}
|
||||
|
||||
export interface NostrmarketPaymentRequest {
|
||||
type: 1
|
||||
id: string
|
||||
message?: string
|
||||
payment_options: Array<{
|
||||
type: string
|
||||
link: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketOrderStatus {
|
||||
type: 2
|
||||
id: string
|
||||
message?: string
|
||||
paid?: boolean
|
||||
shipped?: boolean
|
||||
}
|
||||
|
||||
export class NostrmarketService {
|
||||
private getAuth() {
|
||||
if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) {
|
||||
throw new Error('User not authenticated or private key not available')
|
||||
}
|
||||
|
||||
// Convert bech32 keys to hex format if needed
|
||||
const originalPubkey = auth.currentUser.value.pubkey
|
||||
const originalPrvkey = auth.currentUser.value.prvkey
|
||||
const pubkey = bech32ToHex(originalPubkey)
|
||||
const prvkey = bech32ToHex(originalPrvkey)
|
||||
|
||||
console.log('🔑 Key conversion debug:', {
|
||||
originalPubkey: originalPubkey?.substring(0, 10) + '...',
|
||||
originalPrvkey: originalPrvkey?.substring(0, 10) + '...',
|
||||
convertedPubkey: pubkey.substring(0, 10) + '...',
|
||||
convertedPrvkey: prvkey.substring(0, 10) + '...',
|
||||
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
||||
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey)
|
||||
})
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
prvkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a stall event (kind 30017) to Nostr
|
||||
*/
|
||||
async publishStall(stall: Stall): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
const stallData: NostrmarketStall = {
|
||||
id: stall.id,
|
||||
name: stall.name,
|
||||
description: stall.description,
|
||||
currency: stall.currency,
|
||||
shipping: (stall.shipping || []).map(zone => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
countries: []
|
||||
}))
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30017,
|
||||
tags: [
|
||||
['t', 'stall'],
|
||||
['t', 'nostrmarket']
|
||||
],
|
||||
content: JSON.stringify(stallData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Stall published to nostrmarket:', {
|
||||
stallId: stall.id,
|
||||
eventId: eventId,
|
||||
content: stallData
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a product event (kind 30018) to Nostr
|
||||
*/
|
||||
async publishProduct(product: Product): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
const productData: NostrmarketProduct = {
|
||||
id: product.id,
|
||||
stall_id: product.stall_id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
images: product.images || [],
|
||||
categories: product.categories || [],
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
currency: product.currency
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30018,
|
||||
tags: [
|
||||
['t', 'product'],
|
||||
['t', 'nostrmarket'],
|
||||
['t', 'stall', product.stall_id],
|
||||
...(product.categories || []).map(cat => ['t', cat])
|
||||
],
|
||||
content: JSON.stringify(productData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Product published to nostrmarket:', {
|
||||
productId: product.id,
|
||||
eventId: eventId,
|
||||
content: productData
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||
*/
|
||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
||||
const { pubkey, prvkey } = this.getAuth()
|
||||
|
||||
// Convert order to nostrmarket format - exactly matching the specification
|
||||
const orderData = {
|
||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
||||
id: order.id,
|
||||
items: order.items.map(item => ({
|
||||
product_id: item.productId,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
contact: {
|
||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
||||
email: order.contactInfo?.email || ''
|
||||
// Remove phone field - not in nostrmarket specification
|
||||
},
|
||||
// Only include address if it's a physical good and address is provided
|
||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
||||
address: order.contactInfo.address
|
||||
} : {}),
|
||||
shipping_id: order.shippingZone?.id || 'online'
|
||||
}
|
||||
|
||||
// Encrypt the message using NIP-04
|
||||
const encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 4, // Encrypted DM
|
||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
||||
content: encryptedContent, // Use encrypted content
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkey)
|
||||
const eventId = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Order published to nostrmarket:', {
|
||||
orderId: order.id,
|
||||
eventId: eventId,
|
||||
merchantPubkey,
|
||||
content: orderData,
|
||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming payment request from merchant (type 1)
|
||||
*/
|
||||
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
||||
console.log('Received payment request from merchant:', {
|
||||
orderId: paymentRequest.id,
|
||||
message: paymentRequest.message,
|
||||
paymentOptions: paymentRequest.payment_options
|
||||
})
|
||||
|
||||
// Find the Lightning payment option
|
||||
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
||||
if (!lightningOption) {
|
||||
console.error('No Lightning payment option found in payment request')
|
||||
return
|
||||
}
|
||||
|
||||
// Update the order in the store with payment request
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order with payment request details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentRequest: lightningOption.link,
|
||||
paymentStatus: 'pending' as const,
|
||||
status: 'pending' as const, // Ensure status is pending for payment
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
|
||||
// Generate QR code for the payment request
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
||||
updatedOrder.qrCodeLoading = false
|
||||
updatedOrder.qrCodeError = null
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
||||
updatedOrder.qrCodeLoading = false
|
||||
}
|
||||
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: paymentRequest.id,
|
||||
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
||||
status: updatedOrder.status,
|
||||
paymentStatus: updatedOrder.paymentStatus,
|
||||
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
||||
})
|
||||
} else {
|
||||
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming order status update from merchant (type 2)
|
||||
*/
|
||||
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
||||
console.log('Received order status update from merchant:', {
|
||||
orderId: statusUpdate.id,
|
||||
message: statusUpdate.message,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped
|
||||
})
|
||||
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order status
|
||||
if (statusUpdate.paid !== undefined) {
|
||||
const newStatus = statusUpdate.paid ? 'paid' : 'pending'
|
||||
marketStore.updateOrderStatus(order.id, newStatus)
|
||||
|
||||
// Also update payment status
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired',
|
||||
paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
if (statusUpdate.shipped !== undefined) {
|
||||
// Update shipping status if you have that field
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
shipped: statusUpdate.shipped,
|
||||
status: statusUpdate.shipped ? 'shipped' : order.status,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: statusUpdate.id,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped,
|
||||
newStatus: statusUpdate.paid ? 'paid' : 'pending'
|
||||
})
|
||||
} else {
|
||||
console.warn('Status update received for unknown order:', statusUpdate.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish all stalls and products for a merchant
|
||||
*/
|
||||
async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{
|
||||
stalls: Record<string, string>, // stallId -> eventId
|
||||
products: Record<string, string> // productId -> eventId
|
||||
}> {
|
||||
const results = {
|
||||
stalls: {} as Record<string, string>,
|
||||
products: {} as Record<string, string>
|
||||
}
|
||||
|
||||
// Publish stalls first
|
||||
for (const stall of stalls) {
|
||||
try {
|
||||
const eventId = await this.publishStall(stall)
|
||||
results.stalls[stall.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish stall ${stall.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish products
|
||||
for (const product of products) {
|
||||
try {
|
||||
const eventId = await this.publishProduct(product)
|
||||
results.products[product.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish product ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrmarketService = new NostrmarketService()
|
||||
275
src/lib/services/paymentMonitor.ts
Normal file
275
src/lib/services/paymentMonitor.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { ref } from 'vue'
|
||||
import type { PaymentStatus, LightningInvoice } from './invoiceService'
|
||||
import type { Order } from '@/stores/market'
|
||||
|
||||
export interface PaymentMonitorState {
|
||||
isMonitoring: boolean
|
||||
activeInvoices: Map<string, LightningInvoice>
|
||||
paymentStatuses: Map<string, PaymentStatus>
|
||||
lastUpdate: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface PaymentUpdate {
|
||||
orderId: string
|
||||
paymentHash: string
|
||||
status: 'pending' | 'paid' | 'expired'
|
||||
amount: number
|
||||
paidAt?: number
|
||||
}
|
||||
|
||||
class PaymentMonitorService {
|
||||
private state = ref<PaymentMonitorState>({
|
||||
isMonitoring: false,
|
||||
activeInvoices: new Map(),
|
||||
paymentStatuses: new Map(),
|
||||
lastUpdate: 0,
|
||||
error: null
|
||||
})
|
||||
|
||||
private monitoringInterval: NodeJS.Timeout | null = null
|
||||
private updateCallbacks: Map<string, (update: PaymentUpdate) => void> = new Map()
|
||||
|
||||
// Computed properties
|
||||
get isMonitoring() { return this.state.value.isMonitoring }
|
||||
get activeInvoices() { return this.state.value.activeInvoices }
|
||||
get paymentStatuses() { return this.state.value.paymentStatuses }
|
||||
get lastUpdate() { return this.state.value.lastUpdate }
|
||||
get error() { return this.state.value.error }
|
||||
|
||||
/**
|
||||
* Start monitoring payments for a specific order
|
||||
*/
|
||||
async startMonitoring(order: Order, invoice: LightningInvoice): Promise<void> {
|
||||
try {
|
||||
// Add invoice to active monitoring
|
||||
this.state.value.activeInvoices.set(order.id, invoice)
|
||||
this.state.value.paymentStatuses.set(invoice.payment_hash, {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: invoice.payment_hash
|
||||
})
|
||||
|
||||
// Start monitoring if not already running
|
||||
if (!this.state.value.isMonitoring) {
|
||||
await this.startMonitoringLoop()
|
||||
}
|
||||
|
||||
console.log('Started monitoring payment for order:', {
|
||||
orderId: order.id,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to start payment monitoring:', error)
|
||||
this.state.value.error = 'Failed to start payment monitoring'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a specific order
|
||||
*/
|
||||
stopMonitoring(orderId: string): void {
|
||||
const invoice = this.state.value.activeInvoices.get(orderId)
|
||||
if (invoice) {
|
||||
this.state.value.activeInvoices.delete(orderId)
|
||||
this.state.value.paymentStatuses.delete(invoice.payment_hash)
|
||||
console.log('Stopped monitoring payment for order:', orderId)
|
||||
}
|
||||
|
||||
// Stop monitoring loop if no more active invoices
|
||||
if (this.state.value.activeInvoices.size === 0) {
|
||||
this.stopMonitoringLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the monitoring loop
|
||||
*/
|
||||
private async startMonitoringLoop(): Promise<void> {
|
||||
if (this.state.value.isMonitoring) return
|
||||
|
||||
this.state.value.isMonitoring = true
|
||||
console.log('Starting payment monitoring loop')
|
||||
|
||||
// Check immediately
|
||||
await this.checkAllPayments()
|
||||
|
||||
// Set up interval for periodic checks
|
||||
this.monitoringInterval = setInterval(async () => {
|
||||
await this.checkAllPayments()
|
||||
}, 30000) // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the monitoring loop
|
||||
*/
|
||||
private stopMonitoringLoop(): void {
|
||||
if (this.monitoringInterval) {
|
||||
clearInterval(this.monitoringInterval)
|
||||
this.monitoringInterval = null
|
||||
}
|
||||
this.state.value.isMonitoring = false
|
||||
console.log('Stopped payment monitoring loop')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status for all active invoices
|
||||
*/
|
||||
private async checkAllPayments(): Promise<void> {
|
||||
try {
|
||||
this.state.value.error = null
|
||||
this.state.value.lastUpdate = Date.now()
|
||||
|
||||
const promises = Array.from(this.state.value.activeInvoices.entries()).map(
|
||||
async ([orderId, invoice]) => {
|
||||
try {
|
||||
// Get payment status from LNBits
|
||||
const status = await this.getPaymentStatus(invoice.payment_hash)
|
||||
|
||||
// Update local status
|
||||
this.state.value.paymentStatuses.set(invoice.payment_hash, status)
|
||||
|
||||
// Check if status changed
|
||||
const previousStatus = this.state.value.paymentStatuses.get(invoice.payment_hash)
|
||||
if (previousStatus && previousStatus.paid !== status.paid) {
|
||||
await this.handlePaymentStatusChange(orderId, invoice, status)
|
||||
}
|
||||
|
||||
return { orderId, status }
|
||||
} catch (error) {
|
||||
console.error(`Failed to check payment for order ${orderId}:`, error)
|
||||
return { orderId, error }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
} catch (error) {
|
||||
console.error('Payment monitoring error:', error)
|
||||
this.state.value.error = 'Payment monitoring failed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment status from LNBits
|
||||
*/
|
||||
private async getPaymentStatus(paymentHash: string): Promise<PaymentStatus> {
|
||||
try {
|
||||
// For now, we'll simulate payment status checking since we don't have wallet context here
|
||||
// In production, this would integrate with LNBits webhooks or polling
|
||||
// TODO: Pass wallet information from the order context
|
||||
console.log('Payment status check requested for:', paymentHash)
|
||||
|
||||
// Return default pending status for now
|
||||
return {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: paymentHash
|
||||
}
|
||||
|
||||
// Uncomment when wallet context is available:
|
||||
// const status = await invoiceService.checkPaymentStatus(paymentHash, walletId, adminKey)
|
||||
// return status
|
||||
} catch (error) {
|
||||
console.error('Failed to get payment status:', error)
|
||||
// Return default pending status
|
||||
return {
|
||||
paid: false,
|
||||
amount_paid: 0,
|
||||
payment_hash: paymentHash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle payment status changes
|
||||
*/
|
||||
private async handlePaymentStatusChange(
|
||||
orderId: string,
|
||||
invoice: LightningInvoice,
|
||||
status: PaymentStatus
|
||||
): Promise<void> {
|
||||
const update: PaymentUpdate = {
|
||||
orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
status: status.paid ? 'paid' : 'pending',
|
||||
amount: invoice.amount,
|
||||
paidAt: status.paid_at
|
||||
}
|
||||
|
||||
console.log('Payment status changed:', update)
|
||||
|
||||
// Notify callbacks
|
||||
const callback = this.updateCallbacks.get(orderId)
|
||||
if (callback) {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Payment update callback error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If payment is complete, stop monitoring this order
|
||||
if (status.paid) {
|
||||
this.stopMonitoring(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for payment updates
|
||||
*/
|
||||
onPaymentUpdate(orderId: string, callback: (update: PaymentUpdate) => void): void {
|
||||
this.updateCallbacks.set(orderId, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a payment update callback
|
||||
*/
|
||||
offPaymentUpdate(orderId: string): void {
|
||||
this.updateCallbacks.delete(orderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current payment status for an order
|
||||
*/
|
||||
getOrderPaymentStatus(orderId: string): PaymentStatus | null {
|
||||
const invoice = this.state.value.activeInvoices.get(orderId)
|
||||
if (!invoice) return null
|
||||
|
||||
return this.state.value.paymentStatuses.get(invoice.payment_hash) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an order payment is complete
|
||||
*/
|
||||
isOrderPaid(orderId: string): boolean {
|
||||
const status = this.getOrderPaymentStatus(orderId)
|
||||
return status?.paid || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending payments
|
||||
*/
|
||||
getPendingPayments(): Array<{ orderId: string; invoice: LightningInvoice }> {
|
||||
return Array.from(this.state.value.activeInvoices.entries())
|
||||
.filter(([orderId]) => !this.isOrderPaid(orderId))
|
||||
.map(([orderId, invoice]) => ({ orderId, invoice }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.stopMonitoringLoop()
|
||||
this.state.value.activeInvoices.clear()
|
||||
this.state.value.paymentStatuses.clear()
|
||||
this.updateCallbacks.clear()
|
||||
this.state.value.error = null
|
||||
console.log('Payment monitor cleaned up')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const paymentMonitor = new PaymentMonitorService()
|
||||
|
||||
11
src/lib/utils/bech32.ts
Normal file
11
src/lib/utils/bech32.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
// Helper function to convert bech32 to hex using nostr-tools
|
||||
export function bech32ToHex(bech32Key: string): string {
|
||||
if (bech32Key.startsWith('npub1') || bech32Key.startsWith('nsec1')) {
|
||||
const { type, data } = nip19.decode(bech32Key)
|
||||
return data as string
|
||||
}
|
||||
// Already hex format
|
||||
return bech32Key
|
||||
}
|
||||
68
src/pages/Cart.vue
Normal file
68
src/pages/Cart.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Success Message -->
|
||||
<div v-if="orderSuccess" class="mb-8">
|
||||
<div class="bg-green-500/10 border border-green-200 rounded-lg p-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-900">Order Placed Successfully!</h3>
|
||||
<p class="text-green-700">
|
||||
Your order has been placed and sent to the merchant.
|
||||
<span v-if="orderId" class="font-medium">Order ID: {{ orderId }}</span>
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="orderId && marketStore.orders[orderId]" class="mt-2">
|
||||
<div v-if="marketStore.orders[orderId].sentViaNostr" class="flex items-center gap-2 text-sm text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>✓ Sent via Nostr network</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-sm text-yellow-600">
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||
<span>⚠ Stored locally only</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Shopping Cart</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Review your items and proceed to checkout for each stall
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Content -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<ShoppingCart />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { CheckCircle } from 'lucide-vue-next'
|
||||
import ShoppingCart from '@/components/market/ShoppingCart.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Check for order success from query params
|
||||
const orderSuccess = computed(() => route.query.orderSuccess === 'true')
|
||||
const orderId = computed(() => route.query.orderId as string)
|
||||
|
||||
// Set the first cart as active if none is selected (for navigation purposes)
|
||||
onMounted(() => {
|
||||
if (marketStore.allStallCarts.length > 0 && !marketStore.activeStallCart) {
|
||||
const firstCart = marketStore.allStallCarts[0]
|
||||
marketStore.setCheckoutCart(firstCart.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
438
src/pages/Checkout.vue
Normal file
438
src/pages/Checkout.vue
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="!isReady" class="flex justify-center items-center min-h-64">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<p class="text-muted-foreground">Loading checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Checkout Error</h2>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="$router.push('/cart')" variant="outline">
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Content -->
|
||||
<div v-else-if="checkoutCart && checkoutStall" class="max-w-4xl mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Checkout</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Complete your purchase from {{ checkoutStall.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="$router.push('/cart')" variant="outline">
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Main Checkout Form -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Stall Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ checkoutStall.name }}</h3>
|
||||
<p v-if="checkoutStall.description" class="text-muted-foreground">
|
||||
{{ checkoutStall.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Contact Information</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Email (optional)
|
||||
</label>
|
||||
<Input
|
||||
v-model="contactInfo.email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Message to Merchant (optional)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="contactInfo.message"
|
||||
rows="3"
|
||||
placeholder="Any special requests or notes for the merchant..."
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Information -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Information</h3>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Shipping Zone
|
||||
</label>
|
||||
<div v-if="availableShippingZones.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
No shipping zones available for this stall.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address (only show for physical shipping) -->
|
||||
<div v-if="selectedShippingZone && requiresPhysicalShipping" class="mt-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Shipping Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="contactInfo.address"
|
||||
rows="3"
|
||||
placeholder="Enter your shipping address..."
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
||||
required
|
||||
></textarea>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Required for physical product delivery
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Digital Delivery Note -->
|
||||
<div v-if="selectedShippingZone && !requiresPhysicalShipping" class="mt-4 p-3 bg-muted/50 border border-border rounded-md">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-5 h-5 text-muted-foreground">📧</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Digital Delivery</p>
|
||||
<p>This product will be delivered digitally. No shipping address required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Method</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="lightning"
|
||||
v-model="paymentMethod"
|
||||
value="lightning"
|
||||
class="text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="lightning" class="text-sm font-medium text-foreground">
|
||||
⚡ Lightning Network (Recommended)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="btc_onchain"
|
||||
v-model="paymentMethod"
|
||||
value="btc_onchain"
|
||||
class="text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="btc_onchain" class="text-sm font-medium text-foreground">
|
||||
₿ Bitcoin Onchain
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Place Order Button -->
|
||||
<div class="bg-card border rounded-lg p-6">
|
||||
<!-- Nostr Status Indicator -->
|
||||
<div class="mb-4 p-3 rounded-lg border" :class="{
|
||||
'bg-green-500/10 border-green-200': nostrOrders.isReady.value,
|
||||
'bg-yellow-500/10 border-yellow-200': !nostrOrders.isReady.value
|
||||
}">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div v-if="nostrOrders.isReady.value" class="flex items-center gap-2 text-green-700">
|
||||
<Wifi class="w-4 h-4" />
|
||||
<span>Connected to Nostr network</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 text-yellow-700">
|
||||
<WifiOff class="w-4 h-4" />
|
||||
<span>Nostr network unavailable</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!nostrOrders.isReady.value" class="text-xs text-yellow-600 mt-1">
|
||||
Orders will be stored locally only. Please log in to send orders to merchants.
|
||||
</p>
|
||||
|
||||
<!-- Test Encryption Button -->
|
||||
<div v-if="nostrOrders.isReady.value" class="mt-3">
|
||||
<Button
|
||||
@click="testEncryption"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isTestingEncryption"
|
||||
class="text-xs"
|
||||
>
|
||||
<div v-if="isTestingEncryption" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
||||
<span>Testing...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<span>Test NIP-04 Encryption</span>
|
||||
</div>
|
||||
</Button>
|
||||
<p v-if="encryptionTestResult" class="text-xs mt-1" :class="{
|
||||
'text-green-600': encryptionTestResult === 'success',
|
||||
'text-red-600': encryptionTestResult === 'error'
|
||||
}">
|
||||
{{ encryptionTestResult === 'success' ? '✓ Encryption test passed' : '✗ Encryption test failed' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="handleCheckout"
|
||||
:disabled="!canProceedToCheckout || isPlacingOrder"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<div v-if="isPlacingOrder" class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Placing Order...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-2">
|
||||
<Lock class="w-4 h-4" />
|
||||
<span>Place Order</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 mt-2 text-center">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary Sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="sticky top-8">
|
||||
<CartSummary
|
||||
:stall-id="checkoutCart.id"
|
||||
:cart-items="checkoutCart.products"
|
||||
:subtotal="checkoutCart.subtotal"
|
||||
:currency="checkoutCart.currency"
|
||||
:available-shipping-zones="availableShippingZones"
|
||||
:selected-shipping-zone="selectedShippingZone || undefined"
|
||||
@shipping-zone-selected="selectShippingZone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Store, Lock, Wifi, WifiOff } from 'lucide-vue-next'
|
||||
import CartSummary from '@/components/market/CartSummary.vue'
|
||||
import type { ShippingZone, ContactInfo } from '@/stores/market'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const auth = useAuth()
|
||||
|
||||
// Route parameters
|
||||
const stallId = route.params.stallId as string
|
||||
|
||||
// Local state
|
||||
const contactInfo = ref<ContactInfo>({
|
||||
email: '',
|
||||
message: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
const paymentMethod = ref<'lightning' | 'btc_onchain'>('lightning')
|
||||
const selectedShippingZone = ref<ShippingZone | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const isPlacingOrder = ref(false)
|
||||
const isTestingEncryption = ref(false)
|
||||
const encryptionTestResult = ref<'success' | 'error' | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const checkoutCart = computed(() => marketStore.checkoutCart)
|
||||
const checkoutStall = computed(() => marketStore.checkoutStall)
|
||||
|
||||
const availableShippingZones = computed(() => {
|
||||
if (!checkoutStall.value) return []
|
||||
return checkoutStall.value.shipping || []
|
||||
})
|
||||
|
||||
const isReady = computed(() => {
|
||||
return checkoutCart.value && checkoutStall.value
|
||||
})
|
||||
|
||||
const requiresPhysicalShipping = computed(() => {
|
||||
return selectedShippingZone.value?.requiresPhysicalShipping || false
|
||||
})
|
||||
|
||||
const canProceedToCheckout = computed(() => {
|
||||
return selectedShippingZone.value && (requiresPhysicalShipping.value ? contactInfo.value.address : true)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
selectedShippingZone.value = shippingZone
|
||||
if (checkoutCart.value) {
|
||||
marketStore.setShippingZone(checkoutCart.value.id, shippingZone)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckout = async () => {
|
||||
// Validate required fields
|
||||
if (!selectedShippingZone.value) {
|
||||
error.value = 'Please select a shipping zone'
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresPhysicalShipping.value && !contactInfo.value.address) {
|
||||
error.value = 'Please provide a shipping address'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear any previous errors
|
||||
error.value = null
|
||||
isPlacingOrder.value = true
|
||||
|
||||
// Create the order
|
||||
const order = await marketStore.createAndPlaceOrder({
|
||||
cartId: checkoutCart.value!.id,
|
||||
stallId: checkoutCart.value!.id,
|
||||
buyerPubkey: auth.currentUser?.value?.pubkey || '', // Get from authenticated user
|
||||
sellerPubkey: checkoutStall.value!.pubkey,
|
||||
status: 'pending',
|
||||
items: checkoutCart.value!.products.map(item => ({
|
||||
productId: item.product.id,
|
||||
productName: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: item.product.price,
|
||||
currency: item.product.currency
|
||||
})),
|
||||
contactInfo: contactInfo.value,
|
||||
shippingZone: selectedShippingZone.value,
|
||||
paymentMethod: paymentMethod.value,
|
||||
subtotal: checkoutCart.value!.subtotal,
|
||||
shippingCost: selectedShippingZone.value.cost,
|
||||
total: checkoutCart.value!.subtotal + selectedShippingZone.value.cost,
|
||||
currency: checkoutCart.value!.currency
|
||||
})
|
||||
|
||||
// Show success message
|
||||
console.log('Order placed successfully:', order)
|
||||
|
||||
// TODO: Navigate to payment page or show payment modal
|
||||
// For now, redirect to cart with success message
|
||||
router.push({
|
||||
path: '/cart',
|
||||
query: { orderSuccess: 'true', orderId: order.id }
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to place order'
|
||||
console.error('Order placement failed:', err)
|
||||
} finally {
|
||||
isPlacingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testEncryption = async () => {
|
||||
try {
|
||||
isTestingEncryption.value = true
|
||||
encryptionTestResult.value = null
|
||||
|
||||
const success = await nostrOrders.testEncryption()
|
||||
encryptionTestResult.value = success ? 'success' : 'error'
|
||||
|
||||
if (success) {
|
||||
console.log('NIP-04 encryption test passed!')
|
||||
} else {
|
||||
console.error('NIP-04 encryption test failed!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Encryption test error:', error)
|
||||
encryptionTestResult.value = 'error'
|
||||
} finally {
|
||||
isTestingEncryption.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize checkout
|
||||
onMounted(() => {
|
||||
if (!stallId) {
|
||||
error.value = 'No stall ID provided'
|
||||
return
|
||||
}
|
||||
|
||||
// Set the checkout cart for this stall
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
|
||||
// Auto-select shipping zone if only one available
|
||||
if (availableShippingZones.value.length === 1) {
|
||||
selectShippingZone(availableShippingZones.value[0])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -85,10 +85,10 @@
|
|||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div v-if="marketStore.cartItemCount > 0" class="fixed bottom-4 right-4">
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.cartItemCount }})
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -97,6 +97,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
|
|
@ -108,6 +109,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProductCard from '@/components/market/ProductCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const market = useMarket()
|
||||
const marketPreloader = useMarketPreloader()
|
||||
|
|
@ -121,7 +123,7 @@ const needsToLoadMarket = computed(() => {
|
|||
marketStore.products.length === 0
|
||||
})
|
||||
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
const isMarketReady = computed(() => {
|
||||
const isLoading = marketStore.isLoading ?? false
|
||||
const ready = marketPreloader.isPreloaded.value ||
|
||||
|
|
@ -158,12 +160,12 @@ const addToCart = (product: any) => {
|
|||
marketStore.addToCart(product)
|
||||
}
|
||||
|
||||
const viewProduct = (product: any) => {
|
||||
const viewProduct = (_product: any) => {
|
||||
// TODO: Navigate to product detail page
|
||||
}
|
||||
|
||||
const viewCart = () => {
|
||||
// TODO: Navigate to cart page
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
126
src/pages/MarketDashboard.vue
Normal file
126
src/pages/MarketDashboard.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Market Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Manage your market activities as both a customer and merchant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex space-x-8 border-b border-border">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ tab.name }}</span>
|
||||
<Badge v-if="tab.badge" variant="secondary" class="text-xs">
|
||||
{{ tab.badge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="min-h-[600px]">
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<DashboardOverview />
|
||||
</div>
|
||||
|
||||
<!-- My Orders Tab (Customer) -->
|
||||
<div v-else-if="activeTab === 'orders'" class="space-y-6">
|
||||
<OrderHistory />
|
||||
</div>
|
||||
|
||||
<!-- My Store Tab (Merchant) -->
|
||||
<div v-else-if="activeTab === 'store'" class="space-y-6">
|
||||
<MerchantStore />
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div v-else-if="activeTab === 'settings'" class="space-y-6">
|
||||
<MarketSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
// import { useAuth } from '@/composables/useAuth'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
Store,
|
||||
Settings,
|
||||
|
||||
} from 'lucide-vue-next'
|
||||
import DashboardOverview from '@/components/market/DashboardOverview.vue'
|
||||
import OrderHistory from '@/components/market/OrderHistory.vue'
|
||||
import MerchantStore from '@/components/market/MerchantStore.vue'
|
||||
import MarketSettings from '@/components/market/MarketSettings.vue'
|
||||
|
||||
// const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// Computed properties for tab badges
|
||||
const orderCount = computed(() => Object.keys(marketStore.orders).length)
|
||||
const pendingOrders = computed(() =>
|
||||
Object.values(marketStore.orders).filter(o => o.status === 'pending').length
|
||||
)
|
||||
// const pendingPayments = computed(() =>
|
||||
// Object.values(marketStore.orders).filter(o => o.paymentStatus === 'pending').length
|
||||
// )
|
||||
|
||||
// Dashboard tabs
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Overview',
|
||||
icon: BarChart3,
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'My Orders',
|
||||
icon: Package,
|
||||
badge: orderCount.value > 0 ? orderCount.value : null
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
name: 'My Store',
|
||||
icon: Store,
|
||||
badge: pendingOrders.value > 0 ? pendingOrders.value : null
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
badge: null
|
||||
}
|
||||
])
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Dashboard mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
425
src/pages/OrderHistory.vue
Normal file
425
src/pages/OrderHistory.vue
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Order History</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
View and track all your market orders
|
||||
</p>
|
||||
<!-- Order Events Status -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Listening for order updates' : 'Connecting to order events...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="orderEvents.lastEventTimestamp.value > 0" class="text-xs text-muted-foreground">
|
||||
Last update: {{ formatDate(orderEvents.lastEventTimestamp.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Stats -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Order Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Total Orders:</span>
|
||||
<Badge variant="secondary">{{ totalOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Pending:</span>
|
||||
<Badge variant="outline" class="text-yellow-600">{{ pendingOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Completed:</span>
|
||||
<Badge variant="outline" class="text-green-600">{{ completedOrders }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter" class="w-40 px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="sortBy" class="w-40 px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="createdAt">Date Created</option>
|
||||
<option value="total">Order Total</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div v-if="filteredOrders.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="order in sortedOrders"
|
||||
:key="order.id"
|
||||
class="bg-card border rounded-lg p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
<!-- Nostr Status -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="flex items-center gap-2 mt-1">
|
||||
<div v-if="order.sentViaNostr" class="flex items-center gap-1 text-xs text-green-600">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
Sent via Nostr
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-xs text-red-600">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
Nostr failed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<!-- Payment Status Indicator -->
|
||||
<div v-if="order.lightningInvoice" class="flex items-center gap-2">
|
||||
<Badge
|
||||
:variant="order.paymentStatus === 'paid' ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="order.paymentStatus === 'paid'" class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<div v-else class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
{{ order.paymentStatus === 'paid' ? 'Paid' : 'Payment Pending' }}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">{{ order.currency.toUpperCase() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<!-- Items -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Items</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.productId"
|
||||
class="flex justify-between text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{ formatPrice(item.price * item.quantity, item.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Info -->
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-2">Order Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Subtotal:</span>
|
||||
<span>{{ formatPrice(order.subtotal, order.currency) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Shipping:</span>
|
||||
<span>{{ formatPrice(order.shippingCost, order.currency) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Payment Method:</span>
|
||||
<span class="capitalize">{{ order.paymentMethod.replace('_', ' ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Shipping -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||
<!-- Contact Info -->
|
||||
<div v-if="order.contactInfo.email || order.contactInfo.message">
|
||||
<h4 class="font-medium text-foreground mb-2">Contact Information</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="order.contactInfo.email">
|
||||
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.message">
|
||||
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Info -->
|
||||
<div v-if="order.shippingZone">
|
||||
<h4 class="font-medium text-foreground mb-2">Shipping</h4>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-medium">Zone:</span> {{ order.shippingZone.name }}
|
||||
</p>
|
||||
<p v-if="order.shippingZone.estimatedDays">
|
||||
<span class="font-medium">Est. Delivery:</span> {{ order.shippingZone.estimatedDays }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.address">
|
||||
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Event Details -->
|
||||
<div v-if="order.sentViaNostr !== undefined" class="mb-4 p-4 bg-muted rounded-lg">
|
||||
<h4 class="font-medium text-foreground mb-2">Nostr Network Status</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="order.sentViaNostr" class="space-y-1">
|
||||
<p v-if="order.nostrEventId" class="text-muted-foreground">
|
||||
<span class="font-medium">Event ID:</span>
|
||||
<code class="bg-background px-2 py-1 rounded text-xs">{{ order.nostrEventId.slice(0, 16) }}...</code>
|
||||
</p>
|
||||
<p v-if="order.nostrEventSig" class="text-muted-foreground">
|
||||
<span class="font-medium">Signature:</span>
|
||||
<code class="bg-background px-2 py-1 rounded text-xs">{{ order.nostrEventSig.slice(0, 16) }}...</code>
|
||||
</p>
|
||||
<p class="text-green-600">
|
||||
<span class="font-medium">✓</span> Order successfully transmitted to merchant via Nostr network
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<p v-if="order.nostrError" class="text-red-600">
|
||||
<span class="font-medium">✗</span> Failed to send via Nostr: {{ order.nostrError }}
|
||||
</p>
|
||||
<p class="text-yellow-600">
|
||||
<span class="font-medium">⚠</span> Order stored locally only - merchant may not receive it
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status & Actions -->
|
||||
<div v-if="order.status === 'pending'" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-foreground mb-2">Payment Required</h4>
|
||||
<div v-if="order.lightningInvoice" class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-green-600">✓</span> Lightning invoice received from merchant
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Amount: <span class="font-medium text-foreground">{{ formatPrice(order.total, order.currency) }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Status: <span class="font-medium text-foreground">{{ order.paymentStatus === 'paid' ? 'Paid' : 'Pending Payment' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-amber-600">⏳</span> Waiting for merchant to generate payment invoice
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The merchant will send you a Lightning invoice via Nostr once they process your order
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="order.status === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="cancelOrder(order.id)"
|
||||
>
|
||||
Cancel Order
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
variant="default"
|
||||
size="sm"
|
||||
@click="togglePaymentDisplay(order.id)"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
{{ expandedPayments.has(order.id) ? 'Hide' : 'Show' }} Payment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyOrderId(order.id)"
|
||||
>
|
||||
Copy Order ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Display (Expandable) -->
|
||||
<div v-if="expandedPayments.has(order.id) && order.lightningInvoice" class="mt-4 pt-4 border-t border-border">
|
||||
<PaymentDisplay
|
||||
:order-id="order.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package class="w-8 h-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No orders yet</h3>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Start shopping in the market to see your order history here
|
||||
</p>
|
||||
<Button @click="router.push('/market')" variant="default">
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Package, Wallet } from 'lucide-vue-next'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
import PaymentDisplay from '@/components/market/PaymentDisplay.vue'
|
||||
import { orderEvents } from '@/composables/useOrderEvents'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref('createdAt')
|
||||
const expandedPayments = ref(new Set<string>())
|
||||
|
||||
// Computed properties
|
||||
const allOrders = computed(() => Object.values(marketStore.orders))
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return allOrders.value
|
||||
return allOrders.value.filter(order => order.status === statusFilter.value)
|
||||
})
|
||||
|
||||
const sortedOrders = computed(() => {
|
||||
const orders = [...filteredOrders.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'total':
|
||||
return orders.sort((a, b) => b.total - a.total)
|
||||
case 'status':
|
||||
return orders.sort((a, b) => a.status.localeCompare(b.status))
|
||||
case 'createdAt':
|
||||
default:
|
||||
return orders.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
const totalOrders = computed(() => allOrders.value.length)
|
||||
const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'pending').length)
|
||||
const completedOrders = computed(() =>
|
||||
allOrders.value.filter(o => ['delivered', 'shipped'].includes(o.status)).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId: string) => {
|
||||
// TODO: Implement order cancellation
|
||||
console.log('Cancelling order:', orderId)
|
||||
}
|
||||
|
||||
// const viewPayment = (_order: any) => {
|
||||
// // TODO: Implement payment viewing
|
||||
// console.log('Viewing payment for order:', _order.id)
|
||||
// }
|
||||
|
||||
const copyOrderId = async (orderId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(orderId)
|
||||
// TODO: Show toast notification
|
||||
console.log('Order ID copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy order ID:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePaymentDisplay = (orderId: string) => {
|
||||
if (expandedPayments.value.has(orderId)) {
|
||||
expandedPayments.value.delete(orderId)
|
||||
} else {
|
||||
expandedPayments.value.add(orderId)
|
||||
}
|
||||
}
|
||||
|
||||
// Load orders on mount
|
||||
onMounted(() => {
|
||||
// Orders are already loaded in the market store
|
||||
console.log('Order History page loaded with', allOrders.value.length, 'orders')
|
||||
|
||||
// Start listening for order events if not already listening
|
||||
if (!orderEvents.isSubscribed.value) {
|
||||
orderEvents.startListening()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -184,8 +184,8 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import RelayHubStatus from '@/components/RelayHubStatus.vue'
|
||||
import { useRelayHub } from '@/composables/useRelayHub'
|
||||
import { config } from '@/lib/config'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
|
|
@ -196,7 +196,7 @@ const {
|
|||
initialize,
|
||||
connect,
|
||||
subscribe
|
||||
} = useRelayHub()
|
||||
} = relayHubComposable
|
||||
|
||||
// Test state
|
||||
const isTesting = ref(false)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,36 @@ const router = createRouter({
|
|||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cart',
|
||||
name: 'cart',
|
||||
component: () => import('@/pages/Cart.vue'),
|
||||
meta: {
|
||||
title: 'Shopping Cart',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/checkout/:stallId',
|
||||
name: 'checkout',
|
||||
component: () => import('@/pages/Checkout.vue'),
|
||||
meta: {
|
||||
title: 'Checkout',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/order-history',
|
||||
name: 'OrderHistory',
|
||||
component: () => import('@/pages/OrderHistory.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/market-dashboard',
|
||||
name: 'MarketDashboard',
|
||||
component: () => import('@/pages/MarketDashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { invoiceService } from '@/lib/services/invoiceService'
|
||||
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
||||
import { nostrmarketService } from '@/lib/services/nostrmarketService'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
|
||||
// Types
|
||||
|
|
@ -25,7 +30,9 @@ export interface Stall {
|
|||
description?: string
|
||||
logo?: string
|
||||
categories?: string[]
|
||||
shipping?: Record<string, any>
|
||||
shipping?: ShippingZone[]
|
||||
currency: string
|
||||
nostrEventId?: string // Nostr event ID for nostrmarket integration
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
|
@ -41,23 +48,91 @@ export interface Product {
|
|||
categories?: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string // Nostr event ID for nostrmarket integration
|
||||
}
|
||||
|
||||
// Enhanced Order interface for the new system
|
||||
export interface Order {
|
||||
id: string
|
||||
stall_id: string
|
||||
product_id: string
|
||||
buyer_pubkey: string
|
||||
seller_pubkey: string
|
||||
quantity: number
|
||||
total_price: number
|
||||
cartId: string
|
||||
stallId: string
|
||||
buyerPubkey: string
|
||||
sellerPubkey: string
|
||||
status: OrderStatus
|
||||
items: OrderItem[]
|
||||
contactInfo: ContactInfo
|
||||
shippingZone: ShippingZone
|
||||
paymentRequest?: string
|
||||
paymentMethod: PaymentMethod
|
||||
subtotal: number
|
||||
shippingCost: number
|
||||
total: number
|
||||
currency: string
|
||||
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
|
||||
payment_request?: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Nostr integration fields
|
||||
nostrEventId?: string
|
||||
nostrEventSig?: string
|
||||
sentViaNostr?: boolean
|
||||
nostrError?: string
|
||||
originalOrderId?: string // Original order ID from Nostr event (for nostrmarket compatibility)
|
||||
// Lightning invoice fields
|
||||
lightningInvoice?: LightningInvoice
|
||||
paymentHash?: string
|
||||
paidAt?: number
|
||||
paymentStatus?: 'pending' | 'paid' | 'expired'
|
||||
// QR code fields
|
||||
qrCodeDataUrl?: string
|
||||
qrCodeLoading?: boolean
|
||||
qrCodeError?: string | null
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
price: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
address?: string
|
||||
email?: string
|
||||
message?: string
|
||||
npub?: string
|
||||
}
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
currency: string
|
||||
description?: string
|
||||
estimatedDays?: string
|
||||
requiresPhysicalShipping?: boolean
|
||||
}
|
||||
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||
|
||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||
|
||||
// Cart management interfaces
|
||||
export interface CartItem {
|
||||
product: Product
|
||||
quantity: number
|
||||
stallId: string
|
||||
}
|
||||
|
||||
export interface StallCart {
|
||||
id: string
|
||||
merchant: string
|
||||
products: CartItem[]
|
||||
subtotal: number
|
||||
shippingZone?: ShippingZone
|
||||
currency: string
|
||||
}
|
||||
|
||||
// Enhanced FilterData with more options
|
||||
export interface FilterData {
|
||||
categories: string[]
|
||||
merchants: string[]
|
||||
|
|
@ -65,6 +140,8 @@ export interface FilterData {
|
|||
currency: string | null
|
||||
priceFrom: number | null
|
||||
priceTo: number | null
|
||||
inStock: boolean | null
|
||||
paymentMethods: PaymentMethod[]
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
|
|
@ -72,6 +149,22 @@ export interface SortOptions {
|
|||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// Payment-related interfaces
|
||||
export interface PaymentRequest {
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
expiresAt: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amountPaid: number
|
||||
paidAt?: number
|
||||
transactionId?: string
|
||||
}
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
// Core market state
|
||||
const markets = ref<Market[]>([])
|
||||
|
|
@ -98,7 +191,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
})
|
||||
|
||||
const sortOptions = ref<SortOptions>({
|
||||
|
|
@ -106,9 +201,21 @@ export const useMarketStore = defineStore('market', () => {
|
|||
order: 'asc'
|
||||
})
|
||||
|
||||
// Shopping cart
|
||||
// Enhanced shopping cart with stall-specific carts
|
||||
const stallCarts = ref<Record<string, StallCart>>({})
|
||||
|
||||
// Legacy shopping cart (to be deprecated)
|
||||
const shoppingCart = ref<Record<string, { product: Product; quantity: number }>>({})
|
||||
|
||||
// Checkout state
|
||||
const checkoutCart = ref<StallCart | null>(null)
|
||||
const checkoutStall = ref<Stall | null>(null)
|
||||
const activeOrder = ref<Order | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentRequest = ref<PaymentRequest | null>(null)
|
||||
const paymentStatus = ref<PaymentStatus | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const filteredProducts = computed(() => {
|
||||
let filtered = products.value
|
||||
|
|
@ -164,6 +271,20 @@ export const useMarketStore = defineStore('market', () => {
|
|||
)
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
if (filterData.value.inStock !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.inStock ? product.quantity > 0 : product.quantity === 0
|
||||
)
|
||||
}
|
||||
|
||||
// Payment methods filter
|
||||
if (filterData.value.paymentMethods.length > 0) {
|
||||
// For now, assume all products support Lightning payments
|
||||
// This can be enhanced later with product-specific payment method support
|
||||
filtered = filtered.filter(_product => true)
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
const aVal = a[sortOptions.value.field as keyof Product]
|
||||
|
|
@ -199,6 +320,27 @@ export const useMarketStore = defineStore('market', () => {
|
|||
}))
|
||||
})
|
||||
|
||||
// Enhanced cart computed properties
|
||||
const allStallCarts = computed(() => Object.values(stallCarts.value))
|
||||
|
||||
const totalCartItems = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalCartValue = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.subtotal
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const activeStallCart = computed(() => {
|
||||
if (!checkoutStall.value) return null
|
||||
return stallCarts.value[checkoutStall.value.id] || null
|
||||
})
|
||||
|
||||
// Legacy cart computed properties (to be deprecated)
|
||||
const cartTotal = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
|
|
@ -291,6 +433,448 @@ export const useMarketStore = defineStore('market', () => {
|
|||
shoppingCart.value = {}
|
||||
}
|
||||
|
||||
// Enhanced cart management methods
|
||||
const addToStallCart = (product: Product, quantity: number = 1) => {
|
||||
const stallId = product.stall_id
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (!stall) {
|
||||
console.error('Stall not found for product:', product.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize stall cart if it doesn't exist
|
||||
if (!stallCarts.value[stallId]) {
|
||||
stallCarts.value[stallId] = {
|
||||
id: stallId,
|
||||
merchant: stall.pubkey,
|
||||
products: [],
|
||||
subtotal: 0,
|
||||
currency: stall.currency || 'sats'
|
||||
}
|
||||
}
|
||||
|
||||
const cart = stallCarts.value[stallId]
|
||||
const existingItem = cart.products.find(item => item.product.id === product.id)
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity)
|
||||
} else {
|
||||
cart.products.push({
|
||||
product,
|
||||
quantity: Math.min(quantity, product.quantity),
|
||||
stallId
|
||||
})
|
||||
}
|
||||
|
||||
// Update cart subtotal
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
|
||||
const removeFromStallCart = (stallId: string, productId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.products = cart.products.filter(item => item.product.id !== productId)
|
||||
updateStallCartSubtotal(stallId)
|
||||
|
||||
// Remove empty carts
|
||||
if (cart.products.length === 0) {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
if (quantity <= 0) {
|
||||
removeFromStallCart(stallId, productId)
|
||||
} else {
|
||||
const item = cart.products.find(item => item.product.id === productId)
|
||||
if (item) {
|
||||
item.quantity = Math.min(quantity, item.product.quantity)
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartSubtotal = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.subtotal = cart.products.reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
|
||||
const clearAllStallCarts = () => {
|
||||
stallCarts.value = {}
|
||||
}
|
||||
|
||||
const setCheckoutCart = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (cart && stall) {
|
||||
checkoutCart.value = cart
|
||||
checkoutStall.value = stall
|
||||
}
|
||||
}
|
||||
|
||||
const clearCheckout = () => {
|
||||
checkoutCart.value = null
|
||||
checkoutStall.value = null
|
||||
activeOrder.value = null
|
||||
paymentRequest.value = null
|
||||
paymentStatus.value = null
|
||||
}
|
||||
|
||||
const setShippingZone = (stallId: string, shippingZone: ShippingZone) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.shippingZone = shippingZone
|
||||
}
|
||||
}
|
||||
|
||||
// Order management methods
|
||||
const createOrder = (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => {
|
||||
const order: Order = {
|
||||
...orderData,
|
||||
id: orderData.id || generateOrderId(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
orders.value[order.id] = order
|
||||
activeOrder.value = order
|
||||
|
||||
// Save to localStorage for persistence
|
||||
saveOrdersToStorage()
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
const createAndPlaceOrder = async (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
// Create the order
|
||||
const order = createOrder(orderData)
|
||||
|
||||
// Attempt to publish order via nostrmarket protocol
|
||||
let nostrmarketSuccess = false
|
||||
let nostrmarketError: string | undefined
|
||||
|
||||
try {
|
||||
// Publish the order event to nostrmarket using proper protocol
|
||||
const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey)
|
||||
nostrmarketSuccess = true
|
||||
order.sentViaNostr = true
|
||||
order.nostrEventId = eventId
|
||||
|
||||
console.log('Order published via nostrmarket successfully:', eventId)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error'
|
||||
order.nostrError = errorMessage
|
||||
order.sentViaNostr = false
|
||||
console.error('Failed to publish order via nostrmarket:', errorMessage)
|
||||
}
|
||||
|
||||
// Update order status to 'pending'
|
||||
updateOrderStatus(order.id, 'pending')
|
||||
|
||||
// Clear the checkout cart
|
||||
if (checkoutCart.value) {
|
||||
clearStallCart(checkoutCart.value.id)
|
||||
}
|
||||
|
||||
// Clear checkout state
|
||||
clearCheckout()
|
||||
|
||||
// Show appropriate success/error message
|
||||
if (nostrmarketSuccess) {
|
||||
console.log('Order created and published via nostrmarket successfully')
|
||||
} else {
|
||||
console.warn('Order created but nostrmarket publishing failed:', nostrmarketError)
|
||||
}
|
||||
|
||||
return order
|
||||
} catch (error) {
|
||||
console.error('Failed to create and place order:', error)
|
||||
throw new Error('Failed to place order. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
// nostrmarket integration methods
|
||||
const publishToNostrmarket = async () => {
|
||||
try {
|
||||
console.log('Publishing merchant catalog to nostrmarket...')
|
||||
|
||||
// Get all stalls and products
|
||||
const allStalls = Object.values(stalls.value)
|
||||
const allProducts = Object.values(products.value)
|
||||
|
||||
if (allStalls.length === 0) {
|
||||
console.warn('No stalls to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
if (allProducts.length === 0) {
|
||||
console.warn('No products to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
// Publish to nostrmarket
|
||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
||||
|
||||
console.log('Successfully published to nostrmarket:', result)
|
||||
|
||||
// Update stalls and products with event IDs
|
||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
if (stall) {
|
||||
stall.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
if (product) {
|
||||
product.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to publish to nostrmarket:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice management methods
|
||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||
try {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Create Lightning invoice with admin key and nostrmarket tag
|
||||
// For nostrmarket compatibility, we need to use the original order ID if it exists
|
||||
// If no originalOrderId exists, this order was created in the web-app, so use the current orderId
|
||||
const orderIdForInvoice = order.originalOrderId || orderId
|
||||
console.log('Creating invoice with order ID:', {
|
||||
webAppOrderId: orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
orderIdForInvoice: orderIdForInvoice,
|
||||
hasOriginalOrderId: !!order.originalOrderId
|
||||
})
|
||||
|
||||
const invoice = await invoiceService.createInvoice(order, adminKey, {
|
||||
tag: "nostrmarket",
|
||||
order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility
|
||||
merchant_pubkey: order.sellerPubkey,
|
||||
buyer_pubkey: order.buyerPubkey
|
||||
})
|
||||
|
||||
// Update order with invoice details
|
||||
order.lightningInvoice = invoice
|
||||
order.paymentHash = invoice.payment_hash
|
||||
order.paymentStatus = 'pending'
|
||||
order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response
|
||||
|
||||
// Save to localStorage after invoice creation
|
||||
saveOrdersToStorage()
|
||||
|
||||
// Start monitoring payment
|
||||
await paymentMonitor.startMonitoring(order, invoice)
|
||||
|
||||
// Set up payment update callback
|
||||
paymentMonitor.onPaymentUpdate(orderId, (update) => {
|
||||
handlePaymentUpdate(orderId, update)
|
||||
})
|
||||
|
||||
console.log('Lightning invoice created for order:', {
|
||||
orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
nostrmarketOrderId: order.originalOrderId || orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
|
||||
return invoice
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentUpdate = (orderId: string, update: any) => {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) return
|
||||
|
||||
// Update order payment status
|
||||
order.paymentStatus = update.status
|
||||
if (update.status === 'paid') {
|
||||
order.paidAt = update.paidAt
|
||||
updateOrderStatus(orderId, 'paid')
|
||||
|
||||
// Send payment confirmation via Nostr
|
||||
sendPaymentConfirmation(order)
|
||||
}
|
||||
|
||||
// Save to localStorage after payment update
|
||||
saveOrdersToStorage()
|
||||
|
||||
console.log('Payment status updated for order:', {
|
||||
orderId,
|
||||
status: update.status,
|
||||
amount: update.amount
|
||||
})
|
||||
}
|
||||
|
||||
const sendPaymentConfirmation = async (order: Order) => {
|
||||
try {
|
||||
if (!nostrOrders.isReady.value) {
|
||||
console.warn('Nostr not ready for payment confirmation')
|
||||
return
|
||||
}
|
||||
|
||||
// Create payment confirmation message
|
||||
// const confirmation = {
|
||||
// type: 'payment_confirmation',
|
||||
// orderId: order.id,
|
||||
// paymentHash: order.paymentHash,
|
||||
// amount: order.total,
|
||||
// currency: order.currency,
|
||||
// paidAt: order.paidAt,
|
||||
// message: 'Payment received! Your order is being processed.'
|
||||
// }
|
||||
|
||||
// Send confirmation to customer
|
||||
await nostrOrders.publishOrderEvent(order, order.buyerPubkey)
|
||||
|
||||
console.log('Payment confirmation sent via Nostr')
|
||||
} catch (error) {
|
||||
console.error('Failed to send payment confirmation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getOrderInvoice = (orderId: string): LightningInvoice | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.lightningInvoice || null
|
||||
}
|
||||
|
||||
const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.paymentStatus || null
|
||||
}
|
||||
|
||||
const updateOrderStatus = (orderId: string, status: OrderStatus) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
order.status = status
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const updateOrder = (orderId: string, updatedOrder: Partial<Order>) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
Object.assign(order, updatedOrder)
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const setPaymentRequest = (request: PaymentRequest) => {
|
||||
paymentRequest.value = request
|
||||
}
|
||||
|
||||
const setPaymentStatus = (status: PaymentStatus) => {
|
||||
paymentStatus.value = status
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
const generateOrderId = () => {
|
||||
return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
const saveOrdersToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem('market_orders', JSON.stringify(orders.value))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save orders to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadOrdersFromStorage = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('market_orders')
|
||||
if (stored) {
|
||||
const parsedOrders = JSON.parse(stored)
|
||||
orders.value = parsedOrders
|
||||
console.log('Loaded orders from localStorage:', Object.keys(parsedOrders).length)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load orders from localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Payment utility methods
|
||||
const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => {
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = shippingZone?.cost || 0
|
||||
return subtotal + shippingCost
|
||||
}
|
||||
|
||||
const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const errors: string[] = []
|
||||
|
||||
if (!cart || cart.products.length === 0) {
|
||||
errors.push('Cart is empty')
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
// Check if all products are still in stock
|
||||
for (const item of cart.products) {
|
||||
if (item.quantity > item.product.quantity) {
|
||||
errors.push(`${item.product.name} is out of stock`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cart has shipping zone selected
|
||||
if (!cart.shippingZone) {
|
||||
errors.push('Please select a shipping zone')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const getCartSummary = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (!cart) return null
|
||||
|
||||
const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0)
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = cart.shippingZone?.cost || 0
|
||||
const total = subtotal + shippingCost
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
total,
|
||||
currency: cart.currency
|
||||
}
|
||||
}
|
||||
|
||||
const updateFilterData = (newFilterData: Partial<FilterData>) => {
|
||||
filterData.value = { ...filterData.value, ...newFilterData }
|
||||
}
|
||||
|
|
@ -302,7 +886,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +915,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize orders from localStorage
|
||||
loadOrdersFromStorage()
|
||||
|
||||
return {
|
||||
// State
|
||||
markets: readonly(markets),
|
||||
|
|
@ -346,10 +935,20 @@ export const useMarketStore = defineStore('market', () => {
|
|||
filterData: readonly(filterData),
|
||||
sortOptions: readonly(sortOptions),
|
||||
shoppingCart: readonly(shoppingCart),
|
||||
stallCarts: readonly(stallCarts),
|
||||
checkoutCart: readonly(checkoutCart),
|
||||
checkoutStall: readonly(checkoutStall),
|
||||
activeOrder: readonly(activeOrder),
|
||||
paymentRequest: readonly(paymentRequest),
|
||||
paymentStatus: readonly(paymentStatus),
|
||||
|
||||
// Computed
|
||||
filteredProducts,
|
||||
allCategories,
|
||||
allStallCarts,
|
||||
totalCartItems,
|
||||
totalCartValue,
|
||||
activeStallCart,
|
||||
cartTotal,
|
||||
cartItemCount,
|
||||
|
||||
|
|
@ -371,6 +970,33 @@ export const useMarketStore = defineStore('market', () => {
|
|||
clearFilters,
|
||||
toggleCategoryFilter,
|
||||
updateSortOptions,
|
||||
formatPrice
|
||||
formatPrice,
|
||||
addToStallCart,
|
||||
removeFromStallCart,
|
||||
updateStallCartQuantity,
|
||||
updateStallCartSubtotal,
|
||||
clearStallCart,
|
||||
clearAllStallCarts,
|
||||
setCheckoutCart,
|
||||
clearCheckout,
|
||||
setShippingZone,
|
||||
createOrder,
|
||||
updateOrderStatus,
|
||||
setPaymentRequest,
|
||||
setPaymentStatus,
|
||||
generateOrderId,
|
||||
calculateOrderTotal,
|
||||
validateCartForCheckout,
|
||||
getCartSummary,
|
||||
createAndPlaceOrder,
|
||||
createLightningInvoice,
|
||||
handlePaymentUpdate,
|
||||
sendPaymentConfirmation,
|
||||
getOrderInvoice,
|
||||
getOrderPaymentStatus,
|
||||
updateOrder,
|
||||
saveOrdersToStorage,
|
||||
loadOrdersFromStorage,
|
||||
publishToNostrmarket
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue