Squash merge rely-on-nostrmarket-to-publish into main
This commit is contained in:
parent
08b172ab34
commit
c90def94a7
23 changed files with 1739 additions and 239 deletions
|
|
@ -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
16
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16
src/components/ui/table/Table.vue
Normal file
16
src/components/ui/table/Table.vue
Normal 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>
|
||||
17
src/components/ui/table/TableBody.vue
Normal file
17
src/components/ui/table/TableBody.vue
Normal 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>
|
||||
17
src/components/ui/table/TableCaption.vue
Normal file
17
src/components/ui/table/TableCaption.vue
Normal 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>
|
||||
22
src/components/ui/table/TableCell.vue
Normal file
22
src/components/ui/table/TableCell.vue
Normal 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>
|
||||
34
src/components/ui/table/TableEmpty.vue
Normal file
34
src/components/ui/table/TableEmpty.vue
Normal 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>
|
||||
17
src/components/ui/table/TableFooter.vue
Normal file
17
src/components/ui/table/TableFooter.vue
Normal 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>
|
||||
17
src/components/ui/table/TableHead.vue
Normal file
17
src/components/ui/table/TableHead.vue
Normal 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>
|
||||
17
src/components/ui/table/TableHeader.vue
Normal file
17
src/components/ui/table/TableHeader.vue
Normal 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>
|
||||
17
src/components/ui/table/TableRow.vue
Normal file
17
src/components/ui/table/TableRow.vue
Normal 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>
|
||||
9
src/components/ui/table/index.ts
Normal file
9
src/components/ui/table/index.ts
Normal 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"
|
||||
10
src/components/ui/table/utils.ts
Normal file
10
src/components/ui/table/utils.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
85
src/modules/market/components/DeleteConfirmDialog.vue
Normal file
85
src/modules/market/components/DeleteConfirmDialog.vue
Normal 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>
|
||||
744
src/modules/market/components/MerchantOrders.vue
Normal file
744
src/modules/market/components/MerchantOrders.vue
Normal 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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue