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
|
- **Events Module** (`src/modules/events/`) - Event ticketing with Lightning payments
|
||||||
- **Market Module** (`src/modules/market/`) - Nostr marketplace functionality
|
- **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:**
|
**Module Configuration:**
|
||||||
- Modules are configured in `src/app.config.ts`
|
- Modules are configured in `src/app.config.ts`
|
||||||
- Each module can be enabled/disabled and configured independently
|
- 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",
|
"name": "aio-shadcn-vite",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/components": "^12.5.0",
|
"@vueuse/components": "^12.5.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
|
@ -4950,9 +4950,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/table-core": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.2",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
@ -4973,12 +4973,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/vue-table": {
|
"node_modules/@tanstack/vue-table": {
|
||||||
"version": "8.21.2",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
|
||||||
"integrity": "sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==",
|
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/table-core": "8.21.2"
|
"@tanstack/table-core": "8.21.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"make": "electron-forge make"
|
"make": "electron-forge make"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/components": "^12.5.0",
|
"@vueuse/components": "^12.5.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@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')
|
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(
|
const newProduct = await nostrmarketAPI.createProduct(
|
||||||
adminKey,
|
adminKey,
|
||||||
productData
|
productData
|
||||||
|
|
|
||||||
|
|
@ -215,12 +215,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '../stores/market'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useMarket } from '../composables/useMarket'
|
import { useMarket } from '../composables/useMarket'
|
||||||
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
|
|
@ -231,53 +229,93 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock
|
Clock
|
||||||
} from 'lucide-vue-next'
|
} 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 router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const { isConnected } = useMarket()
|
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
|
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
|
// Computed properties
|
||||||
const orderStats = computed(() => {
|
const orderStats = computed(() => {
|
||||||
const orders = Object.values(marketStore.orders)
|
const allOrders = orders.value
|
||||||
const now = Date.now() / 1000
|
const now = Date.now() / 1000
|
||||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
|
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 {
|
return {
|
||||||
total: orders.length,
|
total: allOrders.length,
|
||||||
pending: orders.filter(o => o.status === 'pending').length,
|
pending: unpaidOrders.length,
|
||||||
paid: orders.filter(o => o.status === 'paid').length,
|
paid: paidOrders.length,
|
||||||
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
|
shipped: shippedOrders.length,
|
||||||
pendingAmount: orders
|
pendingPayments: unpaidOrders.length,
|
||||||
.filter(o => o.paymentStatus === 'pending')
|
pendingAmount: pendingAmount,
|
||||||
.reduce((sum, o) => sum + o.total, 0),
|
recentSales: recentSales.length,
|
||||||
recentSales: orders.filter(o =>
|
active: allOrders.filter(o => o.paid && !o.shipped).length,
|
||||||
o.status === 'paid' && o.createdAt > sevenDaysAgo
|
connected: isConnected.value
|
||||||
).length,
|
|
||||||
active: orders.filter(o =>
|
|
||||||
['pending', 'paid', 'processing'].includes(o.status)
|
|
||||||
).length,
|
|
||||||
connected: false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentActivity = computed(() => {
|
const recentActivity = computed(() => {
|
||||||
const orders = Object.values(marketStore.orders)
|
const allOrders = orders.value
|
||||||
const now = Date.now() / 1000
|
const now = Date.now() / 1000
|
||||||
const recentOrders = orders
|
const oneDayAgo = now - (24 * 60 * 60)
|
||||||
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
|
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
// 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)
|
.slice(0, 5)
|
||||||
|
|
||||||
return recentOrders.map(order => ({
|
return recentOrders.map(order => {
|
||||||
|
let status = 'pending'
|
||||||
|
if (order.shipped) status = 'shipped'
|
||||||
|
else if (order.paid) status = 'paid'
|
||||||
|
|
||||||
|
return {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
type: 'order',
|
type: 'order',
|
||||||
title: `Order ${order.id.slice(-8)} - ${order.status}`,
|
title: `Order ${order.id.slice(-8)}`,
|
||||||
status: order.status,
|
status: status,
|
||||||
timestamp: order.updatedAt
|
timestamp: order.time || 0
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
|
@ -308,5 +346,10 @@ const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
|
||||||
const navigateToCart = () => router.push('/cart')
|
const navigateToCart = () => router.push('/cart')
|
||||||
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
||||||
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
||||||
|
|
||||||
|
// Load orders when component mounts
|
||||||
|
onMounted(() => {
|
||||||
|
fetchOrders()
|
||||||
|
})
|
||||||
</script>
|
</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,8 +207,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Products Section -->
|
<!-- Store Tabs -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
|
<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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h3 class="text-xl font-semibold text-foreground">Products</h3>
|
<h3 class="text-xl font-semibold text-foreground">Products</h3>
|
||||||
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
|
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
|
||||||
|
|
@ -276,6 +284,19 @@
|
||||||
<Badge :variant="product.active ? 'default' : 'secondary'">
|
<Badge :variant="product.active ? 'default' : 'secondary'">
|
||||||
{{ product.active ? 'Active' : 'Inactive' }}
|
{{ product.active ? 'Active' : 'Inactive' }}
|
||||||
</Badge>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -292,18 +313,54 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Actions -->
|
<!-- Product Actions -->
|
||||||
<div class="flex justify-end pt-2 border-t">
|
<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
|
<Button
|
||||||
@click="editProduct(product)"
|
@click="editProduct(product)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Edit class="w-4 h-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Orders Tab -->
|
||||||
|
<TabsContent value="orders" class="mt-6">
|
||||||
|
<MerchantOrders :stall-id="activeStallId || undefined" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,6 +382,16 @@
|
||||||
@created="onProductCreated"
|
@created="onProductCreated"
|
||||||
@updated="onProductUpdated"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -334,15 +401,27 @@ import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '@/modules/market/stores/market'
|
import { useMarketStore } from '@/modules/market/stores/market'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Store,
|
Store,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Star,
|
Star,
|
||||||
Plus,
|
Plus,
|
||||||
User
|
User,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-vue-next'
|
} 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 type { Product } from '../types/market'
|
||||||
import { mapApiResponseToProduct } from '../types/market'
|
import { mapApiResponseToProduct } from '../types/market'
|
||||||
import { auth } from '@/composables/useAuthService'
|
import { auth } from '@/composables/useAuthService'
|
||||||
|
|
@ -350,7 +429,9 @@ import { useToast } from '@/core/composables/useToast'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import CreateStoreDialog from './CreateStoreDialog.vue'
|
import CreateStoreDialog from './CreateStoreDialog.vue'
|
||||||
import CreateProductDialog from './CreateProductDialog.vue'
|
import CreateProductDialog from './CreateProductDialog.vue'
|
||||||
|
import DeleteConfirmDialog from './DeleteConfirmDialog.vue'
|
||||||
import StoreCard from './StoreCard.vue'
|
import StoreCard from './StoreCard.vue'
|
||||||
|
import MerchantOrders from './MerchantOrders.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
|
|
@ -376,10 +457,25 @@ const activeStall = computed(() =>
|
||||||
const stallProducts = ref<Product[]>([])
|
const stallProducts = ref<Product[]>([])
|
||||||
const isLoadingProducts = ref(false)
|
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
|
// Dialog state
|
||||||
const showCreateStoreDialog = ref(false)
|
const showCreateStoreDialog = ref(false)
|
||||||
const showCreateProductDialog = ref(false)
|
const showCreateProductDialog = ref(false)
|
||||||
|
const showDeleteConfirmDialog = ref(false)
|
||||||
const editingProduct = ref<Product | null>(null)
|
const editingProduct = ref<Product | null>(null)
|
||||||
|
const productToDelete = ref<Product | null>(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const userHasMerchantProfile = computed(() => {
|
const userHasMerchantProfile = computed(() => {
|
||||||
|
|
@ -390,6 +486,17 @@ const userHasStalls = computed(() => {
|
||||||
return userStalls.value.length > 0
|
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 storeStats = computed(() => {
|
||||||
const currentUserPubkey = auth.currentUser?.value?.pubkey
|
const currentUserPubkey = auth.currentUser?.value?.pubkey
|
||||||
if (!currentUserPubkey) {
|
if (!currentUserPubkey) {
|
||||||
|
|
@ -534,6 +641,9 @@ const loadStallProducts = async () => {
|
||||||
.forEach(product => {
|
.forEach(product => {
|
||||||
marketStore.addProduct(product)
|
marketStore.addProduct(product)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize sync status for loaded products
|
||||||
|
initializeSyncStatus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load products:', error)
|
console.error('Failed to load products:', error)
|
||||||
stallProducts.value = []
|
stallProducts.value = []
|
||||||
|
|
@ -605,11 +715,248 @@ const editProduct = (product: Product) => {
|
||||||
showCreateProductDialog.value = true
|
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 = () => {
|
const closeProductDialog = () => {
|
||||||
showCreateProductDialog.value = false
|
showCreateProductDialog.value = false
|
||||||
editingProduct.value = null
|
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
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log('Merchant Store component loaded')
|
console.log('Merchant Store component loaded')
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,14 @@ export function useMarket() {
|
||||||
.map((tag: any) => tag[1])
|
.map((tag: any) => tag[1])
|
||||||
.filter((cat: string) => cat && cat.trim())
|
.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
|
// Look up the stall name from the stalls array
|
||||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||||
|
|
@ -489,6 +497,15 @@ export function useMarket() {
|
||||||
.map((tag: any) => tag[1])
|
.map((tag: any) => tag[1])
|
||||||
.filter((cat: string) => cat && cat.trim())
|
.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
|
// Look up the stall name from the stalls array
|
||||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||||
const stallName = stall?.name || 'Unknown Stall'
|
const stallName = stall?.name || 'Unknown Stall'
|
||||||
|
|
@ -516,17 +533,7 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish a product
|
// Publishing methods removed - now handled by LNbits API endpoints
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to market
|
// Connect to market
|
||||||
const connectToMarket = async () => {
|
const connectToMarket = async () => {
|
||||||
|
|
@ -617,8 +624,6 @@ export function useMarket() {
|
||||||
connectToMarket,
|
connectToMarket,
|
||||||
disconnectFromMarket,
|
disconnectFromMarket,
|
||||||
processPendingProducts,
|
processPendingProducts,
|
||||||
publishProduct,
|
|
||||||
publishStall,
|
|
||||||
subscribeToMarketUpdates,
|
subscribeToMarketUpdates,
|
||||||
subscribeToOrderUpdates
|
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 {
|
export class NostrmarketAPI extends BaseService {
|
||||||
// Service metadata
|
// Service metadata
|
||||||
protected readonly metadata = {
|
protected readonly metadata = {
|
||||||
|
|
@ -368,6 +424,20 @@ export class NostrmarketAPI extends BaseService {
|
||||||
walletAdminkey: string,
|
walletAdminkey: string,
|
||||||
productData: CreateProductRequest
|
productData: CreateProductRequest
|
||||||
): Promise<ProductApiResponse> {
|
): 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>(
|
const product = await this.request<ProductApiResponse>(
|
||||||
'/api/v1/product',
|
'/api/v1/product',
|
||||||
walletAdminkey,
|
walletAdminkey,
|
||||||
|
|
@ -377,10 +447,12 @@ export class NostrmarketAPI extends BaseService {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
this.debug('Created product:', {
|
this.debug('Created product response:', {
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
stallId: product.stall_id
|
stallId: product.stall_id,
|
||||||
|
returnedCategories: product.categories,
|
||||||
|
returnedCategoriesLength: product.categories?.length
|
||||||
})
|
})
|
||||||
|
|
||||||
return product
|
return product
|
||||||
|
|
@ -446,4 +518,153 @@ export class NostrmarketAPI extends BaseService {
|
||||||
|
|
||||||
this.debug('Deleted product:', { productId })
|
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 { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
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 {
|
export interface NostrmarketStall {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -27,6 +27,9 @@ export interface NostrmarketProduct {
|
||||||
currency: string
|
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 {
|
export interface NostrmarketOrder {
|
||||||
id: string
|
id: string
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|
@ -152,90 +155,8 @@ export class NostrmarketService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Removed publishStall() and publishProduct() methods
|
||||||
* Publish a stall event (kind 30017) to Nostr
|
// Stall and product publishing is now handled by LNbits API endpoints
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||||
|
|
@ -471,38 +392,6 @@ export class NostrmarketService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Removed publishMerchantCatalog() method
|
||||||
* Publish all stalls and products for a merchant
|
// Publishing is now handled by LNbits API endpoints
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -470,51 +470,8 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nostrmarket integration methods
|
// Removed publishToNostrmarket() method
|
||||||
const publishToNostrmarket = async () => {
|
// Publishing is now handled automatically by LNbits API endpoints
|
||||||
try {
|
|
||||||
console.log('Publishing merchant catalog to nostrmarket...')
|
|
||||||
|
|
||||||
// Get all stalls and products
|
|
||||||
const allStalls = Object.values(stalls.value)
|
|
||||||
const allProducts = Object.values(products.value)
|
|
||||||
|
|
||||||
if (allStalls.length === 0) {
|
|
||||||
console.warn('No stalls to publish to nostrmarket')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allProducts.length === 0) {
|
|
||||||
console.warn('No products to publish to nostrmarket')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to nostrmarket
|
|
||||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
|
||||||
|
|
||||||
console.log('Successfully published to nostrmarket:', result)
|
|
||||||
|
|
||||||
// Update stalls and products with event IDs
|
|
||||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
|
||||||
const stall = stalls.value.find(s => s.id === stallId)
|
|
||||||
if (stall) {
|
|
||||||
stall.nostrEventId = eventId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
|
||||||
const product = products.value.find(p => p.id === productId)
|
|
||||||
if (product) {
|
|
||||||
product.nostrEventId = eventId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to publish to nostrmarket:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoice management methods
|
// Invoice management methods
|
||||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||||
|
|
@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
saveOrdersToStorage,
|
saveOrdersToStorage,
|
||||||
loadOrdersFromStorage,
|
loadOrdersFromStorage,
|
||||||
clearOrdersForUserChange,
|
clearOrdersForUserChange,
|
||||||
publishToNostrmarket
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue