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

View file

@ -0,0 +1,146 @@
<template>
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
<!-- Product Image -->
<div class="relative">
<img
:src="product.images?.[0] || '/placeholder-product.png'"
:alt="product.name"
class="w-full h-48 object-cover"
@error="handleImageError"
/>
<!-- 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"
>
<ShoppingCartIcon class="w-4 h-4" />
</Button>
<!-- Out of Stock Badge -->
<Badge
v-if="product.quantity < 1"
variant="destructive"
class="absolute top-2 left-2"
>
Out of Stock
</Badge>
</div>
<CardContent class="p-4">
<!-- Product Name -->
<CardTitle class="text-lg font-semibold mb-2 line-clamp-2">
{{ product.name }}
</CardTitle>
<!-- Product Description -->
<p v-if="product.description" class="text-gray-600 text-sm mb-3 line-clamp-2">
{{ product.description }}
</p>
<!-- Price and Quantity -->
<div class="flex items-center justify-between mb-3">
<span class="text-xl font-bold text-green-600">
{{ formatPrice(product.price, product.currency) }}
</span>
<span class="text-sm text-gray-500">
{{ product.quantity }} left
</span>
</div>
<!-- Categories -->
<div v-if="product.categories && product.categories.length > 0" class="mb-3">
<div class="flex flex-wrap gap-1">
<Badge
v-for="category in product.categories.slice(0, 3)"
:key="category"
variant="secondary"
class="text-xs"
>
{{ category }}
</Badge>
<Badge
v-if="product.categories.length > 3"
variant="outline"
class="text-xs"
>
+{{ product.categories.length - 3 }} more
</Badge>
</div>
</div>
<!-- Stall Name -->
<div class="text-sm text-gray-500 mb-3">
{{ product.stallName }}
</div>
</CardContent>
<CardFooter class="p-4 pt-0">
<div class="flex w-full space-x-2">
<Button
variant="outline"
size="sm"
class="flex-1"
@click="$emit('view-stall', product.stall_id)"
>
Visit Stall
</Button>
<Button
size="sm"
class="flex-1"
@click="$emit('view-details', product)"
>
View Details
</Button>
</div>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ShoppingCartIcon } from '@heroicons/vue/24/outline'
import type { Product } from '@/stores/market'
interface Props {
product: Product
}
defineProps<Props>()
defineEmits<{
'add-to-cart': [product: Product]
'view-details': [product: Product]
'view-stall': [stallId: string]
}>()
const imageError = ref(false)
const handleImageError = () => {
imageError.value = true
}
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)
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,367 @@
import { ref, computed, readonly } from 'vue'
import { useNostrStore } from '@/stores/nostr'
import { useMarketStore, type Market, type Stall, type Product } from '@/stores/market'
import { config } from '@/lib/config'
// Nostr event kinds for market functionality
const MARKET_EVENT_KINDS = {
MARKET: 30019,
STALL: 30017,
PRODUCT: 30018,
ORDER: 30020
} as const
export function useMarket() {
const nostrStore = useNostrStore()
const marketStore = useMarketStore()
const isLoading = ref(false)
const error = ref<string | null>(null)
const isConnected = ref(false)
// Market loading state
const loadMarket = async (naddr: string) => {
try {
isLoading.value = true
error.value = null
// Decode naddr
const { type, data } = window.NostrTools.nip19.decode(naddr)
if (type !== 'naddr' || data.kind !== MARKET_EVENT_KINDS.MARKET) {
throw new Error('Invalid market naddr')
}
// Load market data from Nostr
await loadMarketData(data)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load market'
throw err
} finally {
isLoading.value = false
}
}
const loadMarketData = async (marketData: any) => {
try {
// Get Nostr client
const client = nostrStore.getClient()
// Load market configuration
await loadMarketConfig(marketData)
// Load stalls for this market
await loadStalls(marketData.pubkey)
// Load products for all stalls
await loadProducts()
// Subscribe to real-time updates
subscribeToMarketUpdates()
} catch (err) {
console.error('Error loading market data:', err)
throw err
}
}
const loadMarketConfig = async (marketData: any) => {
try {
const client = nostrStore.getClient()
// Fetch market configuration event
const filters = [{
kinds: [MARKET_EVENT_KINDS.MARKET],
authors: [marketData.pubkey],
'#d': [marketData.d]
}]
const events = await client.fetchNotes({ filters })
if (events.length > 0) {
const marketEvent = events[0]
const market: Market = {
d: marketData.d,
pubkey: marketData.pubkey,
relays: config.market.supportedRelays,
selected: true,
opts: JSON.parse(marketEvent.content)
}
marketStore.addMarket(market)
marketStore.setActiveMarket(market)
}
} catch (err) {
console.error('Error loading market config:', err)
throw err
}
}
const loadStalls = async (marketPubkey: string) => {
try {
const client = nostrStore.getClient()
// Fetch stall events for this market
const filters = [{
kinds: [MARKET_EVENT_KINDS.STALL],
authors: [marketPubkey]
}]
const events = await client.fetchNotes({ filters })
events.forEach(event => {
try {
const stallData = JSON.parse(event.content)
const stall: Stall = {
id: event.id,
pubkey: event.pubkey,
name: stallData.name,
description: stallData.description,
logo: stallData.logo,
categories: stallData.categories,
shipping: stallData.shipping
}
marketStore.addStall(stall)
} catch (err) {
console.warn('Failed to parse stall event:', err)
}
})
} catch (err) {
console.error('Error loading stalls:', err)
throw err
}
}
const loadProducts = async () => {
try {
const client = nostrStore.getClient()
// Get all stall pubkeys
const stallPubkeys = marketStore.stalls.map(stall => stall.pubkey)
if (stallPubkeys.length === 0) return
// Fetch product events from all stalls
const filters = [{
kinds: [MARKET_EVENT_KINDS.PRODUCT],
authors: stallPubkeys
}]
const events = await client.fetchNotes({ filters })
events.forEach(event => {
try {
const productData = JSON.parse(event.content)
const stall = marketStore.stalls.find(s => s.pubkey === event.pubkey)
if (stall) {
const product: Product = {
id: event.id,
stall_id: stall.id,
stallName: stall.name,
name: productData.name,
description: productData.description,
price: productData.price,
currency: productData.currency,
quantity: productData.quantity,
images: productData.images,
categories: productData.categories,
createdAt: event.created_at,
updatedAt: event.created_at
}
marketStore.addProduct(product)
}
} catch (err) {
console.warn('Failed to parse product event:', err)
}
})
} catch (err) {
console.error('Error loading products:', err)
throw err
}
}
const subscribeToMarketUpdates = () => {
try {
const client = nostrStore.getClient()
// Subscribe to real-time market updates
const filters = [
{
kinds: [MARKET_EVENT_KINDS.STALL, MARKET_EVENT_KINDS.PRODUCT],
since: Math.floor(Date.now() / 1000)
}
]
const unsubscribe = client.subscribeToNotes((event) => {
handleMarketEvent(event)
}, filters)
// Store unsubscribe function for cleanup
return unsubscribe
} catch (err) {
console.error('Error subscribing to market updates:', err)
}
}
const handleMarketEvent = (event: any) => {
try {
switch (event.kind) {
case MARKET_EVENT_KINDS.STALL:
handleStallEvent(event)
break
case MARKET_EVENT_KINDS.PRODUCT:
handleProductEvent(event)
break
case MARKET_EVENT_KINDS.ORDER:
handleOrderEvent(event)
break
}
} catch (err) {
console.error('Error handling market event:', err)
}
}
const handleStallEvent = (event: any) => {
try {
const stallData = JSON.parse(event.content)
const stall: Stall = {
id: event.id,
pubkey: event.pubkey,
name: stallData.name,
description: stallData.description,
logo: stallData.logo,
categories: stallData.categories,
shipping: stallData.shipping
}
marketStore.addStall(stall)
} catch (err) {
console.warn('Failed to parse stall event:', err)
}
}
const handleProductEvent = (event: any) => {
try {
const productData = JSON.parse(event.content)
const stall = marketStore.stalls.find(s => s.pubkey === event.pubkey)
if (stall) {
const product: Product = {
id: event.id,
stall_id: stall.id,
stallName: stall.name,
name: productData.name,
description: productData.description,
price: productData.price,
currency: productData.currency,
quantity: productData.quantity,
images: productData.images,
categories: productData.categories,
createdAt: event.created_at,
updatedAt: event.created_at
}
marketStore.addProduct(product)
}
} catch (err) {
console.warn('Failed to parse product event:', err)
}
}
const handleOrderEvent = (event: any) => {
try {
const orderData = JSON.parse(event.content)
// Handle order events (for future order management)
console.log('Order event received:', orderData)
} catch (err) {
console.warn('Failed to parse order event:', err)
}
}
const publishProduct = async (productData: any) => {
try {
const client = nostrStore.getClient()
const event = {
kind: MARKET_EVENT_KINDS.PRODUCT,
content: JSON.stringify(productData),
tags: [
['d', productData.id],
['t', 'product']
]
}
await client.publishEvent(event)
} catch (err) {
console.error('Error publishing product:', err)
throw err
}
}
const publishStall = async (stallData: any) => {
try {
const client = nostrStore.getClient()
const event = {
kind: MARKET_EVENT_KINDS.STALL,
content: JSON.stringify(stallData),
tags: [
['d', stallData.id],
['t', 'stall']
]
}
await client.publishEvent(event)
} catch (err) {
console.error('Error publishing stall:', err)
throw err
}
}
const connectToMarket = async () => {
try {
if (!nostrStore.isConnected) {
await nostrStore.connect()
}
isConnected.value = nostrStore.isConnected
if (!isConnected.value) {
throw new Error('Failed to connect to Nostr relays')
}
} catch (err) {
console.error('Error connecting to market:', err)
throw err
}
}
const disconnectFromMarket = () => {
// Cleanup subscriptions and connections
isConnected.value = false
}
return {
// State
isLoading: readonly(isLoading),
error: readonly(error),
isConnected: readonly(isConnected),
// Actions
loadMarket,
connectToMarket,
disconnectFromMarket,
publishProduct,
publishStall,
subscribeToMarketUpdates
}
}

View file

@ -16,10 +16,18 @@ interface PushConfig {
enabled: boolean enabled: boolean
} }
interface MarketConfig {
defaultNaddr: string
supportedRelays: string[]
lightningEnabled: boolean
defaultCurrency: string
}
interface AppConfig { interface AppConfig {
nostr: NostrConfig nostr: NostrConfig
api: ApiConfig api: ApiConfig
push: PushConfig push: PushConfig
market: MarketConfig
support: { support: {
npub: string npub: string
} }
@ -51,6 +59,18 @@ export const config: AppConfig = {
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY || '', vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY || '',
enabled: Boolean(import.meta.env.VITE_PUSH_NOTIFICATIONS_ENABLED) enabled: Boolean(import.meta.env.VITE_PUSH_NOTIFICATIONS_ENABLED)
}, },
market: {
defaultNaddr: import.meta.env.VITE_MARKET_NADDR || '',
supportedRelays: parseJsonEnv(import.meta.env.VITE_MARKET_RELAYS, [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://nostr.walletofsatoshi.com'
]),
lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED),
defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat'
},
support: { support: {
npub: import.meta.env.VITE_SUPPORT_NPUB || '' npub: import.meta.env.VITE_SUPPORT_NPUB || ''
} }
@ -74,10 +94,18 @@ export const configUtils = {
return Boolean(config.push.vapidPublicKey && config.push.enabled) return Boolean(config.push.vapidPublicKey && config.push.enabled)
}, },
hasMarketConfig: (): boolean => {
return Boolean(config.market.defaultNaddr)
},
getDefaultRelays: (): string[] => { getDefaultRelays: (): string[] => {
return config.nostr.relays return config.nostr.relays
},
getMarketRelays: (): string[] => {
return config.market.supportedRelays
} }
} }
// Export individual config sections for convenience // Export individual config sections for convenience
export const { nostr: nostrConfig, api: apiConfig } = config export const { nostr: nostrConfig, api: apiConfig, market: marketConfig } = config

159
src/pages/Market.vue Normal file
View file

@ -0,0 +1,159 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Loading State -->
<div v-if="marketStore.isLoading || market.isLoading" 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-blue-600"></div>
<p class="text-gray-600">Loading market...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="market.error" class="flex justify-center items-center min-h-64">
<div class="text-center">
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
<p class="text-gray-600 mb-4">{{ market.error }}</p>
<Button @click="retryLoadMarket" variant="outline">
Try Again
</Button>
</div>
</div>
<!-- Market Content -->
<div v-else>
<!-- Market Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center space-x-4">
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
<AvatarFallback>M</AvatarFallback>
</Avatar>
<div>
<h1 class="text-3xl font-bold">
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
</h1>
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
{{ marketStore.activeMarket.opts.description }}
</p>
</div>
</div>
<!-- Search Bar -->
<div class="flex-1 max-w-md ml-8">
<Input
v-model="marketStore.searchText"
type="text"
placeholder="Search products..."
class="w-full"
/>
</div>
</div>
<!-- Category Filters -->
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
<div class="flex flex-wrap gap-2">
<Badge
v-for="category in marketStore.allCategories"
:key="category.category"
:variant="category.selected ? 'default' : 'secondary'"
class="cursor-pointer hover:bg-blue-100"
@click="marketStore.toggleCategoryFilter(category.category)"
>
{{ category.category }}
<span class="ml-1 text-xs">({{ category.count }})</span>
</Badge>
</div>
</div>
<!-- No Products State -->
<div v-if="marketStore.filteredProducts.length === 0 && !marketStore.isLoading" class="text-center py-12">
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
<p class="text-gray-500">Try adjusting your search or filters</p>
</div>
<!-- Product Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="product in marketStore.filteredProducts"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
@view-details="viewProduct"
/>
</div>
<!-- Cart Summary -->
<div v-if="marketStore.cartItemCount > 0" class="fixed bottom-4 right-4">
<Button @click="viewCart" class="shadow-lg">
<ShoppingCartIcon class="w-5 h-5 mr-2" />
Cart ({{ marketStore.cartItemCount }})
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useMarketStore } from '@/stores/market'
import { useMarket } from '@/composables/useMarket'
import { config } from '@/lib/config'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ShoppingCartIcon } from '@heroicons/vue/24/outline'
import ProductCard from '@/components/market/ProductCard.vue'
const marketStore = useMarketStore()
const market = useMarket()
let unsubscribe: (() => void) | null = null
const loadMarket = async () => {
try {
const naddr = config.market.defaultNaddr
if (!naddr) {
throw new Error('No market naddr configured')
}
await market.connectToMarket()
await market.loadMarket(naddr)
// Subscribe to real-time updates
unsubscribe = market.subscribeToMarketUpdates()
} catch (error) {
console.error('Failed to load market:', error)
}
}
const retryLoadMarket = () => {
loadMarket()
}
const addToCart = (product: any) => {
marketStore.addToCart(product)
}
const viewProduct = (product: any) => {
// TODO: Navigate to product detail page
console.log('View product:', product)
}
const viewCart = () => {
// TODO: Navigate to cart page
console.log('View cart')
}
onMounted(() => {
loadMarket()
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
}
market.disconnectFromMarket()
})
</script>

View file

@ -39,6 +39,15 @@ const router = createRouter({
title: 'My Tickets', title: 'My Tickets',
requiresAuth: true requiresAuth: true
} }
},
{
path: '/market',
name: 'market',
component: () => import('@/pages/Market.vue'),
meta: {
title: 'Market',
requiresAuth: true
}
} }
] ]
}) })

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