feat: Implement market functionality with ProductCard, useMarket composable, and market store

- Add ProductCard.vue component for displaying product details, including image, name, description, price, and stock status.
- Create useMarket.ts composable to manage market loading, data fetching, and real-time updates from Nostr.
- Introduce market.ts store to handle market, stall, product, and order states, along with filtering and sorting capabilities.
- Develop Market.vue page to present market content, including loading states, error handling, and product grid.
- Update router to include a new market route for user navigation.
This commit is contained in:
padreug 2025-08-02 16:50:25 +02:00
parent 2fc87fa032
commit 4d3d69f527
6 changed files with 1079 additions and 1 deletions

369
src/stores/market.ts Normal file
View file

@ -0,0 +1,369 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { config } from '@/lib/config'
// Types
export interface Market {
d: string
pubkey: string
relays: string[]
selected: boolean
opts: {
name: string
description?: string
logo?: string
banner?: string
merchants: string[]
ui: Record<string, any>
}
}
export interface Stall {
id: string
pubkey: string
name: string
description?: string
logo?: string
categories?: string[]
shipping?: Record<string, any>
}
export interface Product {
id: string
stall_id: string
stallName: string
name: string
description?: string
price: number
currency: string
quantity: number
images?: string[]
categories?: string[]
createdAt: number
updatedAt: number
}
export interface Order {
id: string
stall_id: string
product_id: string
buyer_pubkey: string
seller_pubkey: string
quantity: number
total_price: number
currency: string
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
payment_request?: string
created_at: number
updated_at: number
}
export interface FilterData {
categories: string[]
merchants: string[]
stalls: string[]
currency: string | null
priceFrom: number | null
priceTo: number | null
}
export interface SortOptions {
field: string
order: 'asc' | 'desc'
}
export const useMarketStore = defineStore('market', () => {
// 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 searchText = ref('')
const showFilterDetails = ref(false)
// Filtering and sorting
const filterData = ref<FilterData>({
categories: [],
merchants: [],
stalls: [],
currency: null,
priceFrom: null,
priceTo: null
})
const sortOptions = ref<SortOptions>({
field: 'name',
order: 'asc'
})
// Shopping cart
const shoppingCart = ref<Record<string, { product: Product; quantity: number }>>({})
// 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!
)
}
// 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)
}))
})
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 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 = {}
}
const updateFilterData = (newFilterData: Partial<FilterData>) => {
filterData.value = { ...filterData.value, ...newFilterData }
}
const clearFilters = () => {
filterData.value = {
categories: [],
merchants: [],
stalls: [],
currency: null,
priceFrom: null,
priceTo: null
}
}
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)
}
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),
searchText: readonly(searchText),
showFilterDetails: readonly(showFilterDetails),
filterData: readonly(filterData),
sortOptions: readonly(sortOptions),
shoppingCart: readonly(shoppingCart),
// Computed
filteredProducts,
allCategories,
cartTotal,
cartItemCount,
// Actions
setLoading,
setSearchText,
setActiveMarket,
setActiveStall,
setActiveProduct,
addProduct,
addStall,
addMarket,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
updateFilterData,
clearFilters,
toggleCategoryFilter,
updateSortOptions,
formatPrice
}
})