web-app/src/modules/market/stores/market.ts
padreug e40ac91417 Enhance market module with new chat and events features
- Introduce chat module with components, services, and composables for real-time messaging.
- Implement events module with API service, components, and ticket purchasing functionality.
- Update app configuration to include new modules and their respective settings.
- Refactor existing components to integrate with the new chat and events features.
- Enhance market store and services to support new functionalities and improve order management.
- Update routing to accommodate new views for chat and events, ensuring seamless navigation.
2025-09-05 00:01:40 +02:00

884 lines
No EOL
26 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed, readonly, watch } from 'vue'
import { nostrOrders } from '@/composables/useNostrOrders'
import { invoiceService } from '@/lib/services/invoiceService'
import { paymentMonitor } from '@/lib/services/paymentMonitor'
import { nostrmarketService } from '../services/nostrmarketService'
import { useAuth } from '@/composables/useAuth'
import type { LightningInvoice } from '@/lib/services/invoiceService'
import type {
Market, Stall, Product, Order, ShippingZone,
OrderStatus, StallCart, FilterData, SortOptions,
PaymentRequest, PaymentStatus
} from '../types/market'
// Import types that are used in the store implementation
export const useMarketStore = defineStore('market', () => {
const auth = useAuth()
// Helper function to get user-specific storage key
const getUserStorageKey = (baseKey: string) => {
const userPubkey = auth.currentUser?.value?.pubkey
return userPubkey ? `${baseKey}_${userPubkey}` : baseKey
}
// Core market state
const markets = ref<Market[]>([])
const stalls = ref<Stall[]>([])
const products = ref<Product[]>([])
const orders = ref<Record<string, Order>>({})
const profiles = ref<Record<string, any>>({})
// Active selections
const activeMarket = ref<Market | null>(null)
const activeStall = ref<Stall | null>(null)
const activeProduct = ref<Product | null>(null)
// UI state
const isLoading = ref(false)
const error = ref<string | null>(null)
const searchText = ref('')
const showFilterDetails = ref(false)
// Filtering and sorting
const filterData = ref<FilterData>({
categories: [],
merchants: [],
stalls: [],
currency: null,
priceFrom: null,
priceTo: null,
inStock: null,
paymentMethods: []
})
const sortOptions = ref<SortOptions>({
field: 'name',
order: 'asc'
})
// 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
// Search filter
if (searchText.value) {
const searchLower = searchText.value.toLowerCase()
filtered = filtered.filter(product =>
product.name.toLowerCase().includes(searchLower) ||
product.description?.toLowerCase().includes(searchLower) ||
product.stallName.toLowerCase().includes(searchLower)
)
}
// Category filter
if (filterData.value.categories.length > 0) {
filtered = filtered.filter(product =>
product.categories?.some(cat => filterData.value.categories.includes(cat))
)
}
// Merchant filter
if (filterData.value.merchants.length > 0) {
filtered = filtered.filter(product =>
filterData.value.merchants.includes(product.stall_id)
)
}
// Stall filter
if (filterData.value.stalls.length > 0) {
filtered = filtered.filter(product =>
filterData.value.stalls.includes(product.stall_id)
)
}
// Currency filter
if (filterData.value.currency) {
filtered = filtered.filter(product =>
product.currency === filterData.value.currency
)
}
// Price range filter
if (filterData.value.priceFrom !== null) {
filtered = filtered.filter(product =>
product.price >= filterData.value.priceFrom!
)
}
if (filterData.value.priceTo !== null) {
filtered = filtered.filter(product =>
product.price <= filterData.value.priceTo!
)
}
// 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]
const bVal = b[sortOptions.value.field as keyof Product]
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortOptions.value.order === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortOptions.value.order === 'asc'
? aVal - bVal
: bVal - aVal
}
return 0
})
return filtered
})
const allCategories = computed(() => {
const categories = new Set<string>()
products.value.forEach(product => {
product.categories?.forEach(cat => categories.add(cat))
})
return Array.from(categories).map(category => ({
category,
count: products.value.filter(p => p.categories?.includes(category)).length,
selected: filterData.value.categories.includes(category)
}))
})
// 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)
}, 0)
})
const cartItemCount = computed(() => {
return Object.values(shoppingCart.value).reduce((count, item) => {
return count + item.quantity
}, 0)
})
// Actions
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
const setError = (errorMessage: string | null) => {
error.value = errorMessage
}
const setSearchText = (text: string) => {
searchText.value = text
}
const setActiveMarket = (market: Market | null) => {
activeMarket.value = market
}
const setActiveStall = (stall: Stall | null) => {
activeStall.value = stall
}
const setActiveProduct = (product: Product | null) => {
activeProduct.value = product
}
const addProduct = (product: Product) => {
const existingIndex = products.value.findIndex(p => p.id === product.id)
if (existingIndex >= 0) {
products.value[existingIndex] = product
} else {
products.value.push(product)
}
}
const addStall = (stall: Stall) => {
const existingIndex = stalls.value.findIndex(s => s.id === stall.id)
if (existingIndex >= 0) {
stalls.value[existingIndex] = stall
} else {
stalls.value.push(stall)
}
}
const addMarket = (market: Market) => {
const existingIndex = markets.value.findIndex(m => m.d === market.d)
if (existingIndex >= 0) {
markets.value[existingIndex] = market
} else {
markets.value.push(market)
}
}
const addToCart = (product: Product, quantity: number = 1) => {
const existing = shoppingCart.value[product.id]
if (existing) {
existing.quantity += quantity
} else {
shoppingCart.value[product.id] = { product, quantity }
}
}
const removeFromCart = (productId: string) => {
delete shoppingCart.value[productId]
}
const updateCartQuantity = (productId: string, quantity: number) => {
if (quantity <= 0) {
removeFromCart(productId)
} else {
const item = shoppingCart.value[productId]
if (item) {
item.quantity = quantity
}
}
}
const clearCart = () => {
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 {
const storageKey = getUserStorageKey('market_orders')
localStorage.setItem(storageKey, JSON.stringify(orders.value))
console.log('Saved orders to localStorage with key:', storageKey)
} catch (error) {
console.warn('Failed to save orders to localStorage:', error)
}
}
const loadOrdersFromStorage = () => {
try {
const storageKey = getUserStorageKey('market_orders')
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsedOrders = JSON.parse(stored)
orders.value = parsedOrders
console.log('Loaded orders from localStorage with key:', storageKey, 'Orders count:', Object.keys(parsedOrders).length)
} else {
console.log('No orders found in localStorage for key:', storageKey)
// Clear any existing orders when switching users
orders.value = {}
}
} catch (error) {
console.warn('Failed to load orders from localStorage:', error)
// Clear orders on error
orders.value = {}
}
}
// Clear orders when user changes
const clearOrdersForUserChange = () => {
orders.value = {}
console.log('Cleared orders for user change')
}
// 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 }
}
const clearFilters = () => {
filterData.value = {
categories: [],
merchants: [],
stalls: [],
currency: null,
priceFrom: null,
priceTo: null,
inStock: null,
paymentMethods: []
}
}
const toggleCategoryFilter = (category: string) => {
const index = filterData.value.categories.indexOf(category)
if (index >= 0) {
filterData.value.categories.splice(index, 1)
} else {
filterData.value.categories.push(category)
}
}
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
sortOptions.value = { field, order }
}
const formatPrice = (price: number, currency: string) => {
if (currency === 'sat') {
return `${price} sats`
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase()
}).format(price)
}
// Initialize orders from localStorage
loadOrdersFromStorage()
// Watch for user changes and reload orders
watch(() => auth.currentUser?.value?.pubkey, (newPubkey, oldPubkey) => {
if (newPubkey !== oldPubkey) {
console.log('User changed, clearing and reloading orders. Old:', oldPubkey, 'New:', newPubkey)
clearOrdersForUserChange()
loadOrdersFromStorage()
}
})
return {
// State
markets: readonly(markets),
stalls: readonly(stalls),
products: readonly(products),
orders: readonly(orders),
profiles: readonly(profiles),
activeMarket: readonly(activeMarket),
activeStall: readonly(activeStall),
activeProduct: readonly(activeProduct),
isLoading: readonly(isLoading),
error: readonly(error),
searchText: readonly(searchText),
showFilterDetails: readonly(showFilterDetails),
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,
// Actions
setLoading,
setError,
setSearchText,
setActiveMarket,
setActiveStall,
setActiveProduct,
addProduct,
addStall,
addMarket,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
updateFilterData,
clearFilters,
toggleCategoryFilter,
updateSortOptions,
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,
clearOrdersForUserChange,
publishToNostrmarket
}
})