Squash merge rely-on-nostrmarket-to-publish into main

This commit is contained in:
padreug 2025-10-08 09:19:07 +02:00
parent 08b172ab34
commit c90def94a7
23 changed files with 1739 additions and 239 deletions

View file

@ -33,6 +33,12 @@ The application uses a plugin-based modular architecture with dependency injecti
- **Events Module** (`src/modules/events/`) - Event ticketing with Lightning payments
- **Market Module** (`src/modules/market/`) - Nostr marketplace functionality
**IMPORTANT - Market Event Publishing Strategy:**
- **LNbits "nostrmarket" extension handles ALL market event publishing** (merchants, stalls, products) to Nostr relays
- **Web-app does NOT publish** merchant/stall/product events - only processes incoming events from relays
- **Exception: Checkout/Order events** - Web-app publishes order events directly to Nostr during checkout process
- This division ensures consistency and prevents duplicate publishing while allowing real-time order placement
**Module Configuration:**
- Modules are configured in `src/app.config.ts`
- Each module can be enabled/disabled and configured independently

16
package-lock.json generated
View file

@ -8,7 +8,7 @@
"name": "aio-shadcn-vite",
"version": "0.0.0",
"dependencies": {
"@tanstack/vue-table": "^8.21.2",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^12.5.0",
"@vueuse/core": "^12.8.2",
@ -4950,9 +4950,9 @@
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -4973,12 +4973,12 @@
}
},
"node_modules/@tanstack/vue-table": {
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.2.tgz",
"integrity": "sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.2"
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"

View file

@ -17,7 +17,7 @@
"make": "electron-forge make"
},
"dependencies": {
"@tanstack/vue-table": "^8.21.2",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^12.5.0",
"@vueuse/core": "^12.8.2",

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>

View file

@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"

View file

@ -0,0 +1,10 @@
import type { Updater } from "@tanstack/vue-table"
import type { Ref } from "vue"
import { isFunction } from "@tanstack/vue-table"
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}

View file

@ -455,6 +455,17 @@ const createProduct = async (formData: any) => {
throw new Error('No wallet admin key available')
}
// Debug: Log what we're sending
console.log('🛒 CreateProductDialog: About to create product with categories:', {
name: productData.name,
categories: productData.categories,
categoriesType: typeof productData.categories,
categoriesLength: productData.categories?.length,
formCategories: categories,
formData: formData,
fullProductData: productData
})
const newProduct = await nostrmarketAPI.createProduct(
adminKey,
productData

View file

@ -215,69 +215,107 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '../stores/market'
import { useAuth } from '@/composables/useAuthService'
import { useMarket } from '../composables/useMarket'
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Package,
Store,
ShoppingCart,
DollarSign,
import {
Package,
Store,
ShoppingCart,
DollarSign,
BarChart3,
Clock
} from 'lucide-vue-next'
import type { OrderApiResponse, NostrmarketAPI } from '../services/nostrmarketAPI'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
const router = useRouter()
const marketStore = useMarketStore()
const auth = useAuth()
const { isConnected } = useMarket()
// const orderEvents = useOrderEvents() // TODO: Move to market module
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
// State
const orders = ref<OrderApiResponse[]>([])
const isLoadingOrders = ref(false)
const orderEvents = { isSubscribed: ref(false) } // Temporary mock
// Methods to fetch orders
const fetchOrders = async () => {
isLoadingOrders.value = true
try {
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (inkey) {
const apiOrders = await nostrmarketAPI.getOrders(inkey)
orders.value = apiOrders
}
} catch (error) {
console.error('Failed to fetch orders:', error)
} finally {
isLoadingOrders.value = false
}
}
// Computed properties
const orderStats = computed(() => {
const orders = Object.values(marketStore.orders)
const allOrders = orders.value
const now = Date.now() / 1000
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
const unpaidOrders = allOrders.filter(o => !o.paid)
const paidOrders = allOrders.filter(o => o.paid)
const shippedOrders = allOrders.filter(o => o.shipped)
// Calculate pending amount (unpaid orders)
const pendingAmount = unpaidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
// Calculate recent sales (paid orders in last 7 days)
const recentSales = paidOrders.filter(o => {
const orderTime = o.time || 0
return orderTime > sevenDaysAgo
})
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
total: allOrders.length,
pending: unpaidOrders.length,
paid: paidOrders.length,
shipped: shippedOrders.length,
pendingPayments: unpaidOrders.length,
pendingAmount: pendingAmount,
recentSales: recentSales.length,
active: allOrders.filter(o => o.paid && !o.shipped).length,
connected: isConnected.value
}
})
const recentActivity = computed(() => {
const orders = Object.values(marketStore.orders)
const allOrders = orders.value
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)
const oneDayAgo = now - (24 * 60 * 60)
// Sort by time and get recent orders
const recentOrders = allOrders
.filter(o => (o.time || 0) > oneDayAgo) // Last 24 hours
.sort((a, b) => (b.time || 0) - (a.time || 0))
.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
}))
return recentOrders.map(order => {
let status = 'pending'
if (order.shipped) status = 'shipped'
else if (order.paid) status = 'paid'
return {
id: order.id,
type: 'order',
title: `Order ${order.id.slice(-8)}`,
status: status,
timestamp: order.time || 0
}
})
})
// Methods
@ -308,5 +346,10 @@ 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')
// Load orders when component mounts
onMounted(() => {
fetchOrders()
})
</script>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Trash2, AlertTriangle } from 'lucide-vue-next'
interface Props {
isOpen: boolean
productName: string
isDeleting?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
(e: 'update:isOpen', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
isDeleting: false
})
const emit = defineEmits<Emits>()
// Use external control for dialog state
const isOpen = computed({
get: () => props.isOpen,
set: (value: boolean) => {
emit('update:isOpen', value)
}
})
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
isOpen.value = false
emit('cancel')
}
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-gradient-to-br from-destructive to-destructive/80 p-0.5">
<div class="w-full h-full rounded-full bg-background flex items-center justify-center">
<AlertTriangle class="h-6 w-6 text-destructive" />
</div>
</div>
<div class="text-center space-y-2">
<DialogTitle class="text-xl font-semibold text-foreground">
Delete Product
</DialogTitle>
<DialogDescription class="text-muted-foreground">
Are you sure you want to delete <strong>"{{ productName }}"</strong>? This action cannot be undone and will remove the product from your store.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
variant="ghost"
@click="handleCancel"
class="flex-1 sm:flex-none"
:disabled="isDeleting"
>
Cancel
</Button>
<Button
variant="destructive"
@click="handleConfirm"
class="flex-1 sm:flex-none"
:disabled="isDeleting"
>
<div class="flex items-center">
<Trash2 class="h-4 w-4 mr-2" />
{{ isDeleting ? 'Deleting...' : 'Delete Product' }}
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,744 @@
<template>
<div class="space-y-6">
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Orders</CardTitle>
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center">
<Package class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ orderStats.totalOrders }}</div>
<p class="text-xs text-muted-foreground">
<span class="text-green-600 dark:text-green-400 font-medium">{{ orderStats.paidOrders }} paid</span>,
<span class="text-orange-600 dark:text-orange-400 font-medium">{{ orderStats.unpaidOrders }} pending</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Revenue</CardTitle>
<div class="w-8 h-8 bg-yellow-100 dark:bg-yellow-900/20 rounded-full flex items-center justify-center">
<Zap class="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-green-700 dark:text-green-300">{{ formatSats(orderStats.totalRevenue) }}</div>
<p class="text-xs text-muted-foreground">
<span class="text-orange-600 dark:text-orange-400 font-medium">{{ formatSats(orderStats.pendingRevenue) }} pending</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Pending Payment</CardTitle>
<div class="w-8 h-8 bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center">
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-orange-700 dark:text-orange-300">{{ orderStats.unpaidOrders }}</div>
<p class="text-xs text-muted-foreground">
{{ formatSats(orderStats.pendingRevenue) }} sats total
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">To Ship</CardTitle>
<div class="w-8 h-8 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center">
<Truck class="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-purple-700 dark:text-purple-300">{{ orderStats.toShipOrders }}</div>
<p class="text-xs text-muted-foreground">
Paid but not shipped
</p>
</CardContent>
</Card>
</div>
<!-- Orders Table -->
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Orders</CardTitle>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@click="refreshOrders"
:disabled="isLoading"
class="border-blue-200 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-900/20"
>
<RefreshCw :class="{ 'animate-spin text-blue-600': isLoading }" class="h-4 w-4 mr-2 text-blue-600" />
Refresh
</Button>
<Select v-model="filterStatus">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Orders</SelectItem>
<SelectItem value="unpaid">Unpaid</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="shipped">Shipped</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div v-if="isLoading && orders.length === 0" class="text-center py-8">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Loader2 class="h-8 w-8 animate-spin text-blue-600 dark:text-blue-400" />
</div>
<p class="text-muted-foreground">Loading orders...</p>
</div>
<div v-else-if="filteredOrders.length === 0" class="text-center py-8">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<Package class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
<p class="text-muted-foreground">No orders found</p>
<p class="text-sm text-muted-foreground mt-1">Orders will appear here when customers place them</p>
</div>
<div v-else class="space-y-4">
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Items</TableHead>
<TableHead>Total</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="order in paginatedOrders" :key="order.id">
<TableCell class="font-mono text-sm">
{{ order.id.slice(-8) }}
</TableCell>
<TableCell>
<div class="text-sm">
{{ getCustomerDisplay(order) }}
</div>
</TableCell>
<TableCell>
<div class="text-sm">
{{ getItemsDisplay(order) }}
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-1">
<Zap class="h-3 w-3 text-yellow-500" />
<span class="font-medium text-green-700 dark:text-green-300">{{ formatSats(order.total) }}</span>
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
:class="getStatusDotColor(order)"
></div>
<Badge :class="getStatusBadgeClass(order)">
{{ getStatusDisplay(order) }}
</Badge>
</div>
</TableCell>
<TableCell>
{{ formatDate(order.time) }}
</TableCell>
<TableCell class="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="viewOrderDetails(order)">
<Eye class="h-4 w-4 mr-2 text-blue-600" />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator v-if="!order.paid || !order.shipped" />
<DropdownMenuItem
v-if="!order.paid"
@click="markAsPaid(order)"
>
<CheckCircle class="h-4 w-4 mr-2 text-green-600" />
Mark as Paid
</DropdownMenuItem>
<DropdownMenuItem
v-if="order.paid && !order.shipped"
@click="markAsShipped(order)"
>
<Truck class="h-4 w-4 mr-2 text-purple-600" />
Mark as Shipped
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ filteredOrders.length }} orders
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@click="currentPage--"
:disabled="currentPage === 1"
>
Previous
</Button>
<div class="text-sm">
Page {{ currentPage }} of {{ totalPages }}
</div>
<Button
variant="outline"
size="sm"
@click="currentPage++"
:disabled="currentPage === totalPages"
>
Next
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Order Details Dialog -->
<Dialog v-model:open="showOrderDetails">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Order Details</DialogTitle>
</DialogHeader>
<div v-if="selectedOrder" class="space-y-4">
<!-- Order Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<Label>Order ID</Label>
<div class="font-mono text-sm">{{ selectedOrder.id }}</div>
</div>
<div>
<Label>Status</Label>
<div class="flex items-center gap-2 mt-1">
<div
class="w-2 h-2 rounded-full"
:class="getStatusDotColor(selectedOrder)"
></div>
<Badge :class="getStatusBadgeClass(selectedOrder)">
{{ getStatusDisplay(selectedOrder) }}
</Badge>
</div>
</div>
<div>
<Label>Date</Label>
<div>{{ formatDate(selectedOrder.time) }}</div>
</div>
<div>
<Label>Total</Label>
<div class="font-bold text-green-700 dark:text-green-300 text-lg">{{ formatSats(selectedOrder.total) }}</div>
</div>
</div>
<!-- Customer Info -->
<div>
<Label>Customer</Label>
<Card class="p-3 mt-2">
<div class="space-y-1 text-sm">
<div v-if="selectedOrder.contact?.nostr">
<span class="font-medium">Nostr:</span>
<span class="ml-2 font-mono">{{ selectedOrder.contact.nostr.slice(0, 16) }}...</span>
</div>
<div v-if="selectedOrder.contact?.email">
<span class="font-medium">Email:</span>
<span class="ml-2">{{ selectedOrder.contact.email }}</span>
</div>
<div v-if="selectedOrder.contact?.phone">
<span class="font-medium">Phone:</span>
<span class="ml-2">{{ selectedOrder.contact.phone }}</span>
</div>
<!-- Fallback: Show public key if no contact info available -->
<div v-if="!selectedOrder.contact?.nostr && !selectedOrder.contact?.email && !selectedOrder.contact?.phone && selectedOrder.public_key">
<span class="font-medium">Public Key:</span>
<span class="ml-2 font-mono text-xs">{{ selectedOrder.public_key.slice(0, 16) }}...{{ selectedOrder.public_key.slice(-8) }}</span>
</div>
<!-- Show message if no customer info at all -->
<div v-if="!selectedOrder.contact?.nostr && !selectedOrder.contact?.email && !selectedOrder.contact?.phone && !selectedOrder.public_key" class="text-muted-foreground">
No customer information available
</div>
</div>
</Card>
</div>
<!-- Shipping Address -->
<div v-if="selectedOrder.address">
<Label>Shipping Address</Label>
<Card class="p-3 mt-2">
<div class="text-sm whitespace-pre-line">{{ selectedOrder.address }}</div>
</Card>
</div>
<!-- Order Items -->
<div>
<Label>Items</Label>
<Card class="mt-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Price</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(item, index) in getOrderItems(selectedOrder)" :key="index">
<TableCell>{{ item.name }}</TableCell>
<TableCell>{{ formatSats(item.price) }}</TableCell>
<TableCell>{{ item.quantity }}</TableCell>
<TableCell>{{ formatSats(item.price * item.quantity) }}</TableCell>
</TableRow>
<TableRow v-if="selectedOrder.extra?.shipping_cost">
<TableCell colspan="3" class="text-right font-medium">Shipping</TableCell>
<TableCell>{{ formatSats(selectedOrder.extra.shipping_cost_sat) }}</TableCell>
</TableRow>
<TableRow>
<TableCell colspan="3" class="text-right font-bold">Total</TableCell>
<TableCell class="font-bold">{{ formatSats(selectedOrder.total) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
</div>
<!-- Customer Message -->
<div v-if="selectedOrder.message">
<Label>Customer Message</Label>
<Card class="p-3 mt-2">
<div class="text-sm">{{ selectedOrder.message }}</div>
</Card>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showOrderDetails = false">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrmarketAPI, OrderApiResponse } from '../services/nostrmarketAPI'
import type { PaymentService } from '@/core/services/PaymentService'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Package,
Zap,
Clock,
Truck,
RefreshCw,
MoreHorizontal,
Eye,
CheckCircle,
Loader2,
} from 'lucide-vue-next'
const props = defineProps<{
stallId?: string
}>()
const toast = useToast()
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
// State
const orders = ref<OrderApiResponse[]>([])
const isLoading = ref(false)
const filterStatus = ref<'all' | 'unpaid' | 'paid' | 'shipped'>('all')
const showOrderDetails = ref(false)
const selectedOrder = ref<OrderApiResponse | null>(null)
const currentPage = ref(1)
const itemsPerPage = 10
// Computed properties
const filteredOrders = computed(() => {
switch (filterStatus.value) {
case 'unpaid':
return orders.value.filter(o => !o.paid)
case 'paid':
return orders.value.filter(o => o.paid && !o.shipped)
case 'shipped':
return orders.value.filter(o => o.shipped)
default:
return orders.value
}
})
const totalPages = computed(() => Math.ceil(filteredOrders.value.length / itemsPerPage))
const startIndex = computed(() => (currentPage.value - 1) * itemsPerPage)
const endIndex = computed(() => Math.min(startIndex.value + itemsPerPage, filteredOrders.value.length))
const paginatedOrders = computed(() => {
return filteredOrders.value.slice(startIndex.value, endIndex.value)
})
const orderStats = computed(() => {
const allOrders = orders.value
const paidOrders = allOrders.filter(o => o.paid)
const unpaidOrders = allOrders.filter(o => !o.paid)
const toShipOrders = allOrders.filter(o => o.paid && !o.shipped)
const totalRevenue = paidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
const pendingRevenue = unpaidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
return {
totalOrders: allOrders.length,
paidOrders: paidOrders.length,
unpaidOrders: unpaidOrders.length,
toShipOrders: toShipOrders.length,
totalRevenue,
pendingRevenue,
}
})
// Watch for filter changes to reset pagination
watch(filterStatus, () => {
currentPage.value = 1
})
// Methods
const fetchOrders = async () => {
isLoading.value = true
try {
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (!inkey) {
toast.error('No wallet configured. Please configure a wallet first.')
return
}
let apiOrders: OrderApiResponse[]
if (props.stallId) {
// Try the main orders endpoint first, then filter by stall if needed
apiOrders = await nostrmarketAPI.getOrders(inkey)
// Filter by stall_id if we have orders
if (apiOrders.length > 0) {
apiOrders = apiOrders.filter(order => order.stall_id === props.stallId)
}
// If no orders found or filtering failed, try the stall-specific endpoint as fallback
if (apiOrders.length === 0) {
console.log('🔍 Main orders endpoint returned no results, trying stall-specific endpoint...')
apiOrders = await nostrmarketAPI.getStallOrders(inkey, props.stallId)
}
} else {
apiOrders = await nostrmarketAPI.getOrders(inkey)
}
// Sort by date, newest first
orders.value = apiOrders.sort((a, b) => (b.time || 0) - (a.time || 0))
// Debug: Log first order structure
if (apiOrders.length > 0) {
console.log('🔍 First order structure in MerchantOrders:', {
id: apiOrders[0].id,
contact: apiOrders[0].contact,
items: apiOrders[0].items,
extra: apiOrders[0].extra,
raw_contact_data: apiOrders[0].contact_data,
raw_order_items: apiOrders[0].order_items,
raw_extra_data: apiOrders[0].extra_data
})
// Detailed extra data debugging
if (apiOrders[0].extra) {
console.log('🔍 Extra data details:', {
hasProducts: !!apiOrders[0].extra.products,
productsLength: apiOrders[0].extra.products?.length || 0,
products: apiOrders[0].extra.products,
currency: apiOrders[0].extra.currency,
fullExtra: apiOrders[0].extra
})
} else {
console.log('🔍 No extra data found - attempting manual parse:', {
raw_extra_data: apiOrders[0].extra_data,
typeofExtraData: typeof apiOrders[0].extra_data
})
// Try manual parsing
if (apiOrders[0].extra_data) {
try {
const manualParsed = JSON.parse(apiOrders[0].extra_data)
console.log('🔍 Manual parse result:', manualParsed)
} catch (e) {
console.log('🔍 Manual parse failed:', e)
}
}
}
}
} catch (error) {
console.error('Failed to fetch orders:', error)
toast.error(`Failed to load orders: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
} finally {
isLoading.value = false
}
}
const refreshOrders = () => {
fetchOrders()
}
const formatSats = (value: number) => {
return `${value.toLocaleString()} sats`
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return 'N/A'
return new Date(timestamp * 1000).toLocaleString()
}
const getCustomerDisplay = (order: OrderApiResponse) => {
// Debug: Log customer data
console.log('🔍 Customer data for order', order.id, {
contact: order.contact,
contact_data: order.contact_data
})
// Try parsed contact first
if (order.contact?.email) return order.contact.email
if (order.contact?.nostr) return `${order.contact.nostr.slice(0, 8)}...`
if (order.contact?.phone) return order.contact.phone
// Fallback: try to parse raw contact_data if contact is missing
if (order.contact_data) {
try {
const rawContact = JSON.parse(order.contact_data)
if (rawContact?.email) return rawContact.email
if (rawContact?.nostr) return `${rawContact.nostr.slice(0, 8)}...`
if (rawContact?.phone) return rawContact.phone
} catch (error) {
console.warn('Failed to parse contact_data:', error)
}
}
// Show public key as fallback
if (order.public_key) return `${order.public_key.slice(0, 8)}...`
return 'Unknown'
}
const getItemsDisplay = (order: OrderApiResponse) => {
// Debug: Log items data
console.log('🔍 Items data for order', order.id, {
items: order.items,
order_items: order.order_items,
extra: order.extra
})
// Best case: Use product names from extra.products with quantities from items
if (order.extra?.products && Array.isArray(order.extra.products) && order.extra.products.length > 0) {
// If we have items with quantities, combine them with product names
if (order.items && Array.isArray(order.items) && order.items.length > 0) {
const itemsDisplay = order.items.map(item => {
const product = order.extra?.products?.find((p: any) => p.id === item.product_id)
const productName = product?.name || item.product_id
return `${item.quantity}x ${productName}`
})
// Return first 2 items with ellipsis if more
if (itemsDisplay.length > 2) {
return `${itemsDisplay.slice(0, 2).join(', ')}... (${itemsDisplay.length} items)`
}
return itemsDisplay.join(', ')
}
// If no items but have products, show product names
const productNames = order.extra.products.map((p: any) => p.name)
if (productNames.length > 2) {
return `${productNames.slice(0, 2).join(', ')}... (${productNames.length} items)`
}
return productNames.join(', ')
}
// Fallback: Try parsed items with just counts
if (order.items && order.items.length > 0) {
const totalItems = order.items.reduce((sum, item) => sum + item.quantity, 0)
return `${totalItems} item${totalItems !== 1 ? 's' : ''}`
}
// Fallback: try to parse raw order_items if items is missing
if (order.order_items) {
try {
const rawItems = JSON.parse(order.order_items)
if (rawItems && rawItems.length > 0) {
const totalItems = rawItems.reduce((sum: number, item: any) => sum + (item.quantity || 0), 0)
return `${totalItems} item${totalItems !== 1 ? 's' : ''}`
}
} catch (error) {
console.warn('Failed to parse order_items:', error)
}
}
return 'No items'
}
const getOrderItems = (order: OrderApiResponse) => {
if (!order.extra?.products) return []
return order.extra.products.map(product => {
const item = order.items?.find(i => i.product_id === product.id)
return {
...product,
quantity: item?.quantity || 0
}
})
}
const getStatusDisplay = (order: OrderApiResponse) => {
if (order.shipped) return 'Shipped'
if (order.paid) return 'Paid'
return 'Unpaid'
}
const getStatusDotColor = (order: OrderApiResponse) => {
if (order.shipped) return 'bg-green-500'
if (order.paid) return 'bg-blue-500'
return 'bg-red-500'
}
const getStatusBadgeClass = (order: OrderApiResponse) => {
if (order.shipped) {
return 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800'
}
if (order.paid) {
return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800'
}
return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800'
}
const viewOrderDetails = (order: OrderApiResponse) => {
selectedOrder.value = order
showOrderDetails.value = true
}
const markAsPaid = async (order: OrderApiResponse) => {
try {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
toast.error('Admin key is required to update order status')
return
}
const updated = await nostrmarketAPI.updateOrderStatus(adminKey, {
id: order.id,
paid: true,
})
if (updated) {
// Update local order
const index = orders.value.findIndex(o => o.id === order.id)
if (index !== -1) {
orders.value[index] = updated
}
toast.success(`Order ${order.id.slice(-8)} has been marked as paid`)
}
} catch (error) {
console.error('Failed to update order status:', error)
toast.error(`Failed to update order: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
}
}
const markAsShipped = async (order: OrderApiResponse) => {
try {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
toast.error('Admin key is required to update order status')
return
}
const updated = await nostrmarketAPI.updateOrderStatus(adminKey, {
id: order.id,
shipped: true,
})
if (updated) {
// Update local order
const index = orders.value.findIndex(o => o.id === order.id)
if (index !== -1) {
orders.value[index] = updated
}
toast.success(`Order ${order.id.slice(-8)} has been marked as shipped`)
}
} catch (error) {
console.error('Failed to update order status:', error)
toast.error(`Failed to update order: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
}
}
// Fetch orders on mount
onMounted(() => {
fetchOrders()
})
</script>

View file

@ -207,15 +207,23 @@
</div>
</div>
<!-- Products Section -->
<!-- Store Tabs -->
<div class="mt-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Products</h3>
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="orders">Orders</TabsTrigger>
</TabsList>
<!-- Products Tab -->
<TabsContent value="products" class="mt-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Products</h3>
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
<!-- Loading Products -->
<div v-if="isLoadingProducts" class="flex items-center justify-center py-12">
@ -276,6 +284,19 @@
<Badge :variant="product.active ? 'default' : 'secondary'">
{{ product.active ? 'Active' : 'Inactive' }}
</Badge>
<!-- Nostr Sync Status Indicator -->
<div class="flex items-center">
<template v-if="getProductSyncStatus(product.id) === 'confirmed'">
<CheckCircle class="w-4 h-4 text-green-600" title="Confirmed on Nostr" />
</template>
<template v-else-if="getProductSyncStatus(product.id) === 'pending'">
<Clock class="w-4 h-4 text-blue-600 animate-pulse" title="Awaiting Nostr confirmation" />
</template>
<template v-else>
<AlertCircle class="w-4 h-4 text-gray-400" title="Sync status unknown" />
</template>
</div>
</div>
</div>
@ -292,18 +313,54 @@
</div>
<!-- Product Actions -->
<div class="flex justify-end pt-2 border-t">
<Button
<div class="flex justify-between pt-2 border-t">
<div class="flex gap-2">
<Button
@click="deleteProduct(product)"
variant="ghost"
size="sm"
class="text-red-600 hover:text-red-700 hover:bg-red-50"
:disabled="isDeletingProduct"
>
<div class="flex items-center">
<Trash2 class="w-4 h-4 mr-1" />
{{ isDeletingProduct && deletingProductId === product.id ? 'Deleting...' : 'Delete' }}
</div>
</Button>
<Button
@click="resendProduct(product)"
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
:disabled="isResendingProduct"
>
<div class="flex items-center">
<Send class="w-4 h-4 mr-1" />
{{ isResendingProduct && resendingProductId === product.id ? 'Re-sending...' : 'Re-send' }}
</div>
</Button>
</div>
<Button
@click="editProduct(product)"
variant="ghost"
variant="ghost"
size="sm"
>
Edit
<div class="flex items-center">
<Edit class="w-4 h-4 mr-1" />
Edit
</div>
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Orders Tab -->
<TabsContent value="orders" class="mt-6">
<MerchantOrders :stall-id="activeStallId || undefined" />
</TabsContent>
</Tabs>
</div>
</div>
</div>
@ -325,6 +382,16 @@
@created="onProductCreated"
@updated="onProductUpdated"
/>
<!-- Delete Confirm Dialog -->
<DeleteConfirmDialog
:is-open="showDeleteConfirmDialog"
:product-name="productToDelete?.name || ''"
:is-deleting="isDeletingProduct && deletingProductId === productToDelete?.id"
@confirm="confirmDeleteProduct"
@cancel="cancelDeleteProduct"
@update:is-open="showDeleteConfirmDialog = $event"
/>
</div>
</template>
@ -334,15 +401,27 @@ import { useRouter } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Package,
Store,
DollarSign,
Star,
Plus,
User
User,
Trash2,
Send,
Edit,
CheckCircle,
Clock,
AlertCircle
} from 'lucide-vue-next'
import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI'
import type { NostrmarketAPI, Merchant, Stall, ProductApiResponse } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { mapApiResponseToProduct } from '../types/market'
import { auth } from '@/composables/useAuthService'
@ -350,7 +429,9 @@ import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import CreateStoreDialog from './CreateStoreDialog.vue'
import CreateProductDialog from './CreateProductDialog.vue'
import DeleteConfirmDialog from './DeleteConfirmDialog.vue'
import StoreCard from './StoreCard.vue'
import MerchantOrders from './MerchantOrders.vue'
const router = useRouter()
const marketStore = useMarketStore()
@ -376,10 +457,25 @@ const activeStall = computed(() =>
const stallProducts = ref<Product[]>([])
const isLoadingProducts = ref(false)
// Product action state
const isDeletingProduct = ref(false)
const deletingProductId = ref<string | null>(null)
const isResendingProduct = ref(false)
const resendingProductId = ref<string | null>(null)
// Nostr sync tracking
const pendingNostrConfirmation = ref<Map<string, number>>(new Map()) // productId -> timestamp
const confirmedOnNostr = ref<Set<string>>(new Set())
// Tab management
const activeTab = ref<string>('products')
// Dialog state
const showCreateStoreDialog = ref(false)
const showCreateProductDialog = ref(false)
const showDeleteConfirmDialog = ref(false)
const editingProduct = ref<Product | null>(null)
const productToDelete = ref<Product | null>(null)
// Computed properties
const userHasMerchantProfile = computed(() => {
@ -390,6 +486,17 @@ const userHasStalls = computed(() => {
return userStalls.value.length > 0
})
// Helper to get sync status for a product
const getProductSyncStatus = (productId: string) => {
if (confirmedOnNostr.value.has(productId)) {
return 'confirmed'
}
if (pendingNostrConfirmation.value.has(productId)) {
return 'pending'
}
return 'unknown'
}
const storeStats = computed(() => {
const currentUserPubkey = auth.currentUser?.value?.pubkey
if (!currentUserPubkey) {
@ -534,6 +641,9 @@ const loadStallProducts = async () => {
.forEach(product => {
marketStore.addProduct(product)
})
// Initialize sync status for loaded products
initializeSyncStatus()
} catch (error) {
console.error('Failed to load products:', error)
stallProducts.value = []
@ -605,11 +715,248 @@ const editProduct = (product: Product) => {
showCreateProductDialog.value = true
}
const deleteProduct = (product: Product) => {
productToDelete.value = product
showDeleteConfirmDialog.value = true
}
const confirmDeleteProduct = async () => {
if (!productToDelete.value) return
const product = productToDelete.value
try {
isDeletingProduct.value = true
deletingProductId.value = product.id
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
await nostrmarketAPI.deleteProduct(adminKey, product.id)
// Remove from local state
stallProducts.value = stallProducts.value.filter(p => p.id !== product.id)
showDeleteConfirmDialog.value = false
productToDelete.value = null
toast.success(`Product "${product.name}" deleted successfully!`)
} catch (error) {
console.error('Failed to delete product:', error)
toast.error('Failed to delete product. Please try again.')
} finally {
isDeletingProduct.value = false
deletingProductId.value = null
}
}
const cancelDeleteProduct = () => {
showDeleteConfirmDialog.value = false
productToDelete.value = null
}
const resendProduct = async (product: Product) => {
try {
isResendingProduct.value = true
resendingProductId.value = product.id
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
// Re-send by updating the product with its current data
// This will trigger LNbits to re-publish to Nostr
const productData: ProductApiResponse = {
id: product.id,
stall_id: product.stall_id,
name: product.name,
categories: product.categories || [],
images: product.images || [],
price: product.price,
quantity: product.quantity,
active: product.active ?? true,
pending: product.pending ?? false,
config: {
description: product.description || '',
currency: product.currency || 'sat',
use_autoreply: false,
autoreply_message: '',
shipping: []
},
event_id: product.nostrEventId,
event_created_at: product.createdAt
}
await nostrmarketAPI.updateProduct(adminKey, product.id, productData)
// Reset sync status - remove from confirmed and add to pending
confirmedOnNostr.value.delete(product.id)
pendingNostrConfirmation.value.set(product.id, Date.now())
console.log('🔄 Product re-sent - sync status reset to pending:', {
productId: product.id,
productName: product.name,
wasConfirmed: confirmedOnNostr.value.has(product.id),
nowPending: pendingNostrConfirmation.value.has(product.id)
})
toast.success(`Product "${product.name}" re-sent to LNbits for event publishing!`)
// TODO: Consider adding a timeout to remove from pending if not confirmed within reasonable time
// (e.g., 30 seconds) to avoid keeping products in pending state indefinitely
} catch (error) {
console.error('Failed to re-send product:', error)
toast.error('Failed to re-send product. Please try again.')
} finally {
isResendingProduct.value = false
resendingProductId.value = null
}
}
const closeProductDialog = () => {
showCreateProductDialog.value = false
editingProduct.value = null
}
// Watch for market store updates to detect confirmed products
watch(() => marketStore.products, (newProducts) => {
// Check if any pending products now appear in the market feed
for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) {
const foundProduct = newProducts.find(p => p.id === productId)
if (foundProduct) {
// Find the corresponding local product to compare content
const localProduct = stallProducts.value.find(p => p.id === productId)
if (localProduct) {
// Compare content to verify true sync
const localData = normalizeProductForComparison(localProduct)
const marketData = normalizeProductForComparison(foundProduct)
const localJson = JSON.stringify(localData)
const marketJson = JSON.stringify(marketData)
const isContentSynced = localJson === marketJson
if (isContentSynced) {
// Product content confirmed as synced on Nostr!
pendingNostrConfirmation.value.delete(productId)
confirmedOnNostr.value.add(productId)
// Show confirmation toast
toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`)
console.log('🎉 Product confirmed on Nostr with matching content:', {
productId,
productName: foundProduct.name,
pendingTime: Date.now() - timestamp,
contentVerified: true
})
} else {
console.warn('⚠️ Product appeared in market but content differs:', {
productId,
productName: foundProduct.name,
localData,
marketData
})
// Remove from pending - content doesn't match, so it's not properly synced
pendingNostrConfirmation.value.delete(productId)
// Don't add to confirmedOnNostr - it should show as unsynced
}
} else {
// No local product found - just mark as confirmed
pendingNostrConfirmation.value.delete(productId)
confirmedOnNostr.value.add(productId)
toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`)
}
}
}
// Update sync status for any new products that appear in market feed
initializeSyncStatus()
}, { deep: true })
// Cleanup pending confirmations after timeout (30 seconds)
const cleanupPendingConfirmations = () => {
const timeout = 30 * 1000 // 30 seconds
const now = Date.now()
for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) {
if (now - timestamp > timeout) {
pendingNostrConfirmation.value.delete(productId)
console.warn('⏰ Timeout: Product confirmation removed from pending after 30s:', productId)
}
}
}
// Run cleanup every 10 seconds
setInterval(cleanupPendingConfirmations, 10 * 1000)
// Helper function to normalize product data for comparison
const normalizeProductForComparison = (product: any) => {
return {
name: product.name,
description: product.description || '',
price: product.price,
quantity: product.quantity,
active: product.active ?? true,
categories: (product.categories ? [...product.categories] : []).sort(), // Sort for consistent comparison
images: (product.images ? [...product.images] : []).sort(), // Sort for consistent comparison
currency: product.currency || 'sat'
}
}
// Enhanced sync status detection with JSON content comparison
const initializeSyncStatus = () => {
// Cross-reference stallProducts with market feed to detect already-synced products
for (const product of stallProducts.value) {
if (product.id) {
const foundInMarket = marketStore.products.find(p => p.id === product.id)
if (foundInMarket) {
// Compare the actual product content, not just IDs
const localData = normalizeProductForComparison(product)
const marketData = normalizeProductForComparison(foundInMarket)
// Deep comparison of normalized data
const localJson = JSON.stringify(localData)
const marketJson = JSON.stringify(marketData)
const isContentSynced = localJson === marketJson
if (isContentSynced) {
// Product content is truly synced - mark as confirmed
confirmedOnNostr.value.add(product.id)
console.log('✅ Product content verified as synced to Nostr:', {
productId: product.id,
productName: product.name
})
} else {
// Product exists but content differs - needs re-sync
console.warn('⚠️ Product exists but content differs - needs re-sync:', {
productId: product.id,
productName: product.name,
localData,
marketData,
differences: {
local: localData,
market: marketData
}
})
// Remove from both confirmed and pending - it's out of sync
confirmedOnNostr.value.delete(product.id)
pendingNostrConfirmation.value.delete(product.id)
// User should see unsynced indicator (no badge)
}
} else {
console.log('📤 Product not found in market feed - not synced:', {
productId: product.id,
productName: product.name
})
}
}
}
}
// Lifecycle
onMounted(async () => {
console.log('Merchant Store component loaded')

View file

@ -297,6 +297,14 @@ export function useMarket() {
.map((tag: any) => tag[1])
.filter((cat: string) => cat && cat.trim())
// Debug: Log category processing (when categories are present)
if (categories.length > 0) {
console.log('🛒 useMarket: Processing product with categories:', {
productName: productData.name,
processedCategories: categories,
eventTags: latestEvent.tags.filter((tag: string[]) => tag[0] === 't')
})
}
// Look up the stall name from the stalls array
const stall = marketStore.stalls.find(s => s.id === stallId)
@ -489,6 +497,15 @@ export function useMarket() {
.map((tag: any) => tag[1])
.filter((cat: string) => cat && cat.trim())
// Debug: Log real-time category processing (when categories are present)
if (categories.length > 0) {
console.log('🛒 useMarket: Real-time product with categories:', {
productName: productData.name,
processedCategories: categories,
eventTags: event.tags.filter((tag: string[]) => tag[0] === 't')
})
}
// Look up the stall name from the stalls array
const stall = marketStore.stalls.find(s => s.id === stallId)
const stallName = stall?.name || 'Unknown Stall'
@ -516,17 +533,7 @@ export function useMarket() {
}
}
// Publish a product
const publishProduct = async (_productData: any) => {
// Implementation would depend on your event creation logic
// TODO: Implement product publishing
}
// Publish a stall
const publishStall = async (_stallData: any) => {
// Implementation would depend on your event creation logic
// TODO: Implement stall publishing
}
// Publishing methods removed - now handled by LNbits API endpoints
// Connect to market
const connectToMarket = async () => {
@ -617,8 +624,6 @@ export function useMarket() {
connectToMarket,
disconnectFromMarket,
processPendingProducts,
publishProduct,
publishStall,
subscribeToMarketUpdates,
subscribeToOrderUpdates
}

View file

@ -99,6 +99,62 @@ export interface CreateStallRequest {
}
}
// Order related types
export interface OrderItem {
product_id: string
quantity: number
}
export interface OrderContact {
nostr?: string
phone?: string
email?: string
}
export interface ProductOverview {
id: string
name: string
price: number
product_shipping_cost?: number
}
export interface OrderExtra {
products: ProductOverview[]
currency: string
btc_price: string
shipping_cost: number
shipping_cost_sat: number
fail_message?: string
}
export interface OrderApiResponse {
id: string
event_id?: string
event_created_at?: number
public_key: string
stall_id: string
invoice_id: string
total: number
paid: boolean
shipped: boolean
time?: number
contact_data: string // JSON string
order_items: string // JSON string
extra_data: string // JSON string
address?: string
message?: string
contact?: OrderContact // Parsed from contact_data
items?: OrderItem[] // Parsed from order_items
extra?: OrderExtra // Parsed from extra_data
}
export interface OrderStatusUpdate {
id: string
message?: string
paid?: boolean
shipped?: boolean
}
export class NostrmarketAPI extends BaseService {
// Service metadata
protected readonly metadata = {
@ -368,6 +424,20 @@ export class NostrmarketAPI extends BaseService {
walletAdminkey: string,
productData: CreateProductRequest
): Promise<ProductApiResponse> {
// Debug: Log the exact payload being sent
this.debug('Creating product with payload:', {
name: productData.name,
stall_id: productData.stall_id,
categories: productData.categories,
categoriesType: typeof productData.categories,
categoriesLength: productData.categories?.length,
price: productData.price,
quantity: productData.quantity,
active: productData.active,
config: productData.config,
fullPayload: JSON.stringify(productData, null, 2)
})
const product = await this.request<ProductApiResponse>(
'/api/v1/product',
walletAdminkey,
@ -377,10 +447,12 @@ export class NostrmarketAPI extends BaseService {
}
)
this.debug('Created product:', {
this.debug('Created product response:', {
productId: product.id,
productName: product.name,
stallId: product.stall_id
stallId: product.stall_id,
returnedCategories: product.categories,
returnedCategoriesLength: product.categories?.length
})
return product
@ -446,4 +518,153 @@ export class NostrmarketAPI extends BaseService {
this.debug('Deleted product:', { productId })
}
/**
* Get all orders for the merchant
*/
async getOrders(
walletInkey: string,
filters?: { paid?: boolean, shipped?: boolean, pubkey?: string }
): Promise<OrderApiResponse[]> {
try {
const params = new URLSearchParams()
if (filters?.paid !== undefined) params.append('paid', filters.paid.toString())
if (filters?.shipped !== undefined) params.append('shipped', filters.shipped.toString())
if (filters?.pubkey) params.append('pubkey', filters.pubkey)
const queryString = params.toString()
const endpoint = queryString ? `/api/v1/order?${queryString}` : '/api/v1/order'
const orders = await this.request<OrderApiResponse[]>(
endpoint,
walletInkey,
{ method: 'GET' }
)
// The API already returns parsed objects, no need to parse JSON strings
const parsedOrders = (orders || []).map((order, index) => {
// Debug: Log the first order's structure
if (index === 0) {
this.debug('First order structure:', {
id: order.id,
contact: order.contact,
items: order.items,
extra: order.extra,
hasContactData: !!order.contact,
hasItemsData: !!order.items,
hasExtraData: !!order.extra,
hasProductsInExtra: !!(order.extra?.products)
})
}
return order
})
this.debug('Retrieved orders:', { count: parsedOrders.length, filters })
return parsedOrders
} catch (error) {
this.debug('Failed to get orders:', error)
return []
}
}
/**
* Get orders for a specific stall
*/
async getStallOrders(
walletInkey: string,
stallId: string,
filters?: { paid?: boolean, shipped?: boolean, pubkey?: string }
): Promise<OrderApiResponse[]> {
try {
const params = new URLSearchParams()
if (filters?.paid !== undefined) params.append('paid', filters.paid.toString())
if (filters?.shipped !== undefined) params.append('shipped', filters.shipped.toString())
if (filters?.pubkey) params.append('pubkey', filters.pubkey)
const queryString = params.toString()
const endpoint = queryString
? `/api/v1/stall/order/${stallId}?${queryString}`
: `/api/v1/stall/order/${stallId}`
const orders = await this.request<OrderApiResponse[]>(
endpoint,
walletInkey,
{ method: 'GET' }
)
// The API already returns parsed objects, no need to parse JSON strings
const parsedOrders = (orders || []).map((order, index) => {
// Debug: Log the first order's structure for stall orders too
if (index === 0) {
this.debug('First stall order structure:', {
id: order.id,
contact: order.contact,
items: order.items,
extra: order.extra,
hasContactData: !!order.contact,
hasItemsData: !!order.items,
hasExtraData: !!order.extra,
hasProductsInExtra: !!(order.extra?.products)
})
}
return order
})
this.debug('Retrieved stall orders:', { stallId, count: parsedOrders.length, filters })
return parsedOrders
} catch (error) {
this.debug('Failed to get stall orders:', error)
return []
}
}
/**
* Get a single order by ID
*/
async getOrder(walletInkey: string, orderId: string): Promise<OrderApiResponse | null> {
try {
const order = await this.request<OrderApiResponse>(
`/api/v1/order/${orderId}`,
walletInkey,
{ method: 'GET' }
)
// The API already returns parsed objects, no parsing needed
this.debug('Retrieved order:', { orderId })
return order
} catch (error) {
this.debug('Failed to get order:', error)
return null
}
}
/**
* Update order status (mark as paid/shipped)
*/
async updateOrderStatus(
walletAdminkey: string,
statusUpdate: OrderStatusUpdate
): Promise<OrderApiResponse | null> {
try {
const order = await this.request<OrderApiResponse>(
`/api/v1/order/${statusUpdate.id}`,
walletAdminkey,
{
method: 'PATCH',
body: JSON.stringify(statusUpdate)
}
)
// The API already returns parsed objects, no parsing needed
this.debug('Updated order status:', statusUpdate)
return order
} catch (error) {
this.debug('Failed to update order status:', error)
return null
}
}
}

View file

@ -1,6 +1,6 @@
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
import { BaseService } from '@/core/base/BaseService'
import type { Stall, Product, Order } from '@/modules/market/stores/market'
import type { Order } from '@/modules/market/stores/market'
export interface NostrmarketStall {
id: string
@ -27,6 +27,9 @@ export interface NostrmarketProduct {
currency: string
}
// Note: Stall and Product publishing is handled by LNbits API endpoints
// NostrmarketService now only handles order DMs and status updates
export interface NostrmarketOrder {
id: string
items: Array<{
@ -152,90 +155,8 @@ export class NostrmarketService extends BaseService {
}
}
/**
* Publish a stall event (kind 30017) to Nostr
*/
async publishStall(stall: Stall): Promise<string> {
const { 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 prvkeyBytes = this.hexToUint8Array(prvkey)
const event = finalizeEvent(eventTemplate, prvkeyBytes)
const result = await this.relayHub.publishEvent(event)
console.log('Stall published to nostrmarket:', {
stallId: stall.id,
eventId: result,
content: stallData
})
return result.success.toString()
}
/**
* Publish a product event (kind 30018) to Nostr
*/
async publishProduct(product: Product): Promise<string> {
const { 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 prvkeyBytes = this.hexToUint8Array(prvkey)
const event = finalizeEvent(eventTemplate, prvkeyBytes)
const result = await this.relayHub.publishEvent(event)
console.log('Product published to nostrmarket:', {
productId: product.id,
eventId: result,
content: productData
})
return result.success.toString()
}
// Removed publishStall() and publishProduct() methods
// Stall and product publishing is now handled by LNbits API endpoints
/**
* Publish an order event (kind 4 encrypted DM) to nostrmarket
@ -471,38 +392,6 @@ export class NostrmarketService extends BaseService {
}
}
/**
* 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
}
// Removed publishMerchantCatalog() method
// Publishing is now handled by LNbits API endpoints
}

View file

@ -470,51 +470,8 @@ export const useMarketStore = defineStore('market', () => {
}
}
// 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
}
}
// Removed publishToNostrmarket() method
// Publishing is now handled automatically by LNbits API endpoints
// Invoice management methods
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => {
saveOrdersToStorage,
loadOrdersFromStorage,
clearOrdersForUserChange,
publishToNostrmarket
}
})