From bff158cb74ba6f3fa26416965d9dc7b050b38159 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 28 Sep 2025 04:05:20 +0200 Subject: [PATCH 1/9] feat: enhance product management with new dialog and image handling features - Introduced ProductDetailDialog component for displaying detailed product information, including images, price, and availability. - Implemented image cycling functionality in ProductCard for better user experience when viewing multiple product images. - Enhanced CreateProductDialog to support image uploads with improved validation and navigation protection during form editing. - Added logic to manage uploaded images and ensure proper handling of existing product images. - Updated MarketPage to integrate the new ProductDetailDialog, allowing users to view product details seamlessly. These changes significantly improve the product management experience, enhancing both the display and interaction with product images. --- src/components/ui/ProgressiveImageGallery.vue | 355 ++++++++++++++++++ .../market/components/CreateProductDialog.vue | 115 ++++-- src/modules/market/components/ProductCard.vue | 101 ++++- .../market/components/ProductDetailDialog.vue | 82 ++-- src/modules/market/views/MarketPage.vue | 1 - 5 files changed, 561 insertions(+), 93 deletions(-) create mode 100644 src/components/ui/ProgressiveImageGallery.vue diff --git a/src/components/ui/ProgressiveImageGallery.vue b/src/components/ui/ProgressiveImageGallery.vue new file mode 100644 index 0000000..935bbdb --- /dev/null +++ b/src/components/ui/ProgressiveImageGallery.vue @@ -0,0 +1,355 @@ + + + + + \ No newline at end of file diff --git a/src/modules/market/components/CreateProductDialog.vue b/src/modules/market/components/CreateProductDialog.vue index 93d6042..3232804 100644 --- a/src/modules/market/components/CreateProductDialog.vue +++ b/src/modules/market/components/CreateProductDialog.vue @@ -127,11 +127,17 @@ Product Images - Add images to showcase your product -
- -

Image upload coming soon

-
+ Add up to 5 images to showcase your product. The first image will be the primary display image. +
@@ -218,12 +224,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' -import { Package } from 'lucide-vue-next' import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI' import type { Product } from '../types/market' import { auth } from '@/composables/useAuthService' import { useToast } from '@/core/composables/useToast' import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import ImageUpload from '@/modules/base/components/ImageUpload.vue' +import type { ImageUploadService } from '@/modules/base/services/ImageUploadService' // Props and emits interface Props { @@ -242,11 +249,13 @@ const emit = defineEmits<{ // Services const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any +const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const toast = useToast() // Local state const isCreating = ref(false) const createError = ref(null) +const uploadedImages = ref([]) // Track uploaded images with their metadata // Computed properties const isEditMode = computed(() => !!props.product?.id) @@ -311,18 +320,31 @@ const updateProduct = async (formData: any) => { return } - const { - name, - description, - price, - quantity, - categories, - images, - active, - use_autoreply, - autoreply_message + const { + name, + description, + price, + quantity, + categories, + active, + use_autoreply, + autoreply_message } = formData + // Get uploaded image URLs from the image service + const images: string[] = [] + if (uploadedImages.value && uploadedImages.value.length > 0) { + for (const img of uploadedImages.value) { + if (img.alias) { + // Get the full URL for the image + const imageUrl = imageService.getImageUrl(img.alias) + if (imageUrl) { + images.push(imageUrl) + } + } + } + } + isCreating.value = true createError.value = null @@ -382,18 +404,31 @@ const createProduct = async (formData: any) => { return } - const { - name, - description, - price, - quantity, - categories, - images, - active, - use_autoreply, - autoreply_message + const { + name, + description, + price, + quantity, + categories, + active, + use_autoreply, + autoreply_message } = formData + // Get uploaded image URLs from the image service + const images: string[] = [] + if (uploadedImages.value && uploadedImages.value.length > 0) { + for (const img of uploadedImages.value) { + if (img.alias) { + // Get the full URL for the image + const imageUrl = imageService.getImageUrl(img.alias) + if (imageUrl) { + images.push(imageUrl) + } + } + } + } + isCreating.value = true createError.value = null @@ -470,6 +505,34 @@ watch(() => props.isOpen, async (isOpen) => { // Reset form with appropriate initial values resetForm({ values: initialValues }) + // Convert existing image URLs to the format expected by ImageUpload component + if (props.product?.images && props.product.images.length > 0) { + // For existing products, we need to convert URLs back to a format ImageUpload can display + uploadedImages.value = props.product.images.map((url, index) => { + let alias = url + + // If it's a full pict-rs URL, extract just the file ID + if (url.includes('/image/original/')) { + const parts = url.split('/image/original/') + if (parts.length > 1 && parts[1]) { + alias = parts[1] + } + } else if (url.startsWith('http://') || url.startsWith('https://')) { + // Keep full URLs as-is + alias = url + } + + return { + alias: alias, + delete_token: '', + isPrimary: index === 0, + details: {} + } + }) + } else { + uploadedImages.value = [] + } + // Wait for reactivity await nextTick() diff --git a/src/modules/market/components/ProductCard.vue b/src/modules/market/components/ProductCard.vue index c570930..b8fd899 100644 --- a/src/modules/market/components/ProductCard.vue +++ b/src/modules/market/components/ProductCard.vue @@ -1,11 +1,11 @@ \ No newline at end of file diff --git a/src/modules/market/index.ts b/src/modules/market/index.ts index 93b6bd6..cea3db1 100644 --- a/src/modules/market/index.ts +++ b/src/modules/market/index.ts @@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = { title: 'Stall', requiresAuth: false } + }, + { + path: '/market/product/:productId', + name: 'product-detail', + component: () => import('./views/ProductDetailPage.vue'), + meta: { + title: 'Product Details', + requiresAuth: false + } } ] as RouteRecordRaw[], diff --git a/src/modules/market/views/ProductDetailPage.vue b/src/modules/market/views/ProductDetailPage.vue new file mode 100644 index 0000000..8a0437d --- /dev/null +++ b/src/modules/market/views/ProductDetailPage.vue @@ -0,0 +1,309 @@ + + + + + \ No newline at end of file From ca0ac2b9ada78f4360df97087acf2d9a7a863a8f Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 28 Sep 2025 12:48:02 +0200 Subject: [PATCH 3/9] feat: introduce ImageLightbox and ImageViewer components for enhanced image handling - Added ImageLightbox component to provide a modal view for images with navigation and keyboard support. - Implemented ImageViewer component to display images with features like thumbnails, cycling controls, and lightbox integration. - Updated ProgressiveImage component for improved loading and error handling. - Refactored image imports in ProductCard, ProductDetailPage, and CheckoutPage to align with new component structure. These changes significantly enhance the user experience for viewing and interacting with product images across the application. --- .../ui/{ => image}/ImageLightbox.vue | 2 +- src/components/ui/{ => image}/ImageViewer.vue | 2 +- .../ui/{ => image}/ProgressiveImage.vue | 0 .../ui/image}/composables/useImageLightbox.ts | 0 src/components/ui/image/index.ts | 10 + src/modules/market/components/ProductCard.vue | 2 +- .../market/components/ProductDetailDialog.vue | 261 ------------------ src/modules/market/views/CheckoutPage.vue | 2 +- .../market/views/ProductDetailPage.vue | 2 +- 9 files changed, 15 insertions(+), 266 deletions(-) rename src/components/ui/{ => image}/ImageLightbox.vue (99%) rename src/components/ui/{ => image}/ImageViewer.vue (99%) rename src/components/ui/{ => image}/ProgressiveImage.vue (100%) rename src/{ => components/ui/image}/composables/useImageLightbox.ts (100%) create mode 100644 src/components/ui/image/index.ts delete mode 100644 src/modules/market/components/ProductDetailDialog.vue diff --git a/src/components/ui/ImageLightbox.vue b/src/components/ui/image/ImageLightbox.vue similarity index 99% rename from src/components/ui/ImageLightbox.vue rename to src/components/ui/image/ImageLightbox.vue index 418abce..cf96a87 100644 --- a/src/components/ui/ImageLightbox.vue +++ b/src/components/ui/image/ImageLightbox.vue @@ -93,7 +93,7 @@ import { ref, computed, watch, onMounted } from 'vue' import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import ProgressiveImage from './ProgressiveImage.vue' -import { useImageLightbox, type LightboxImage, type UseImageLightboxOptions } from '@/composables/useImageLightbox' +import { useImageLightbox, type LightboxImage, type UseImageLightboxOptions } from './composables/useImageLightbox' interface Props { /** diff --git a/src/components/ui/ImageViewer.vue b/src/components/ui/image/ImageViewer.vue similarity index 99% rename from src/components/ui/ImageViewer.vue rename to src/components/ui/image/ImageViewer.vue index 22d70c9..9259a15 100644 --- a/src/components/ui/ImageViewer.vue +++ b/src/components/ui/image/ImageViewer.vue @@ -106,7 +106,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import ProgressiveImage from './ProgressiveImage.vue' import ImageLightbox from './ImageLightbox.vue' -import type { LightboxImage, UseImageLightboxOptions } from '@/composables/useImageLightbox' +import type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox' interface Props { /** diff --git a/src/components/ui/ProgressiveImage.vue b/src/components/ui/image/ProgressiveImage.vue similarity index 100% rename from src/components/ui/ProgressiveImage.vue rename to src/components/ui/image/ProgressiveImage.vue diff --git a/src/composables/useImageLightbox.ts b/src/components/ui/image/composables/useImageLightbox.ts similarity index 100% rename from src/composables/useImageLightbox.ts rename to src/components/ui/image/composables/useImageLightbox.ts diff --git a/src/components/ui/image/index.ts b/src/components/ui/image/index.ts new file mode 100644 index 0000000..f6e1395 --- /dev/null +++ b/src/components/ui/image/index.ts @@ -0,0 +1,10 @@ +// Image Components +export { default as ImageLightbox } from './ImageLightbox.vue' +export { default as ImageViewer } from './ImageViewer.vue' +export { default as ProgressiveImage } from './ProgressiveImage.vue' + +// Composables +export { useImageLightbox } from './composables/useImageLightbox' + +// Types +export type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox' \ No newline at end of file diff --git a/src/modules/market/components/ProductCard.vue b/src/modules/market/components/ProductCard.vue index b8fd899..be865eb 100644 --- a/src/modules/market/components/ProductCard.vue +++ b/src/modules/market/components/ProductCard.vue @@ -155,7 +155,7 @@ import { ref, computed, watch } from 'vue' import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import ProgressiveImage from '@/components/ui/ProgressiveImage.vue' +import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue' import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next' import type { Product } from '@/modules/market/stores/market' diff --git a/src/modules/market/components/ProductDetailDialog.vue b/src/modules/market/components/ProductDetailDialog.vue deleted file mode 100644 index 4642a68..0000000 --- a/src/modules/market/components/ProductDetailDialog.vue +++ /dev/null @@ -1,261 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/modules/market/views/CheckoutPage.vue b/src/modules/market/views/CheckoutPage.vue index e8890b4..a5cbdb7 100644 --- a/src/modules/market/views/CheckoutPage.vue +++ b/src/modules/market/views/CheckoutPage.vue @@ -290,7 +290,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' -import ProgressiveImage from '@/components/ui/ProgressiveImage.vue' +import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue' import { Package, CheckCircle diff --git a/src/modules/market/views/ProductDetailPage.vue b/src/modules/market/views/ProductDetailPage.vue index 8a0437d..36c0840 100644 --- a/src/modules/market/views/ProductDetailPage.vue +++ b/src/modules/market/views/ProductDetailPage.vue @@ -166,7 +166,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import ImageViewer from '@/components/ui/ImageViewer.vue' +import ImageViewer from '@/components/ui/image/ImageViewer.vue' import { ShoppingCart, Store, Plus, Minus, ArrowLeft } from 'lucide-vue-next' import { useToast } from '@/core/composables/useToast' import type { Product } from '../types/market' From 3742937aeaad6917644d2f5bcb758baaa9a99f5a Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 28 Sep 2025 12:57:57 +0200 Subject: [PATCH 4/9] refactor: remove ImageDisplay component and update base module exports - Deleted the ImageDisplay component to streamline image handling. - Updated the base module to export only the ImageUpload component, simplifying the component structure. These changes enhance the clarity and maintainability of the image handling components in the application. --- src/modules/base/components/ImageDisplay.vue | 238 ------------------- src/modules/base/index.ts | 4 +- 2 files changed, 1 insertion(+), 241 deletions(-) delete mode 100644 src/modules/base/components/ImageDisplay.vue diff --git a/src/modules/base/components/ImageDisplay.vue b/src/modules/base/components/ImageDisplay.vue deleted file mode 100644 index 0e18969..0000000 --- a/src/modules/base/components/ImageDisplay.vue +++ /dev/null @@ -1,238 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index db6df6b..640482f 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -20,7 +20,6 @@ import { ImageUploadService } from './services/ImageUploadService' // Import components import ImageUpload from './components/ImageUpload.vue' -import ImageDisplay from './components/ImageDisplay.vue' // Create service instances const invoiceService = new InvoiceService() @@ -143,8 +142,7 @@ export const baseModule: ModulePlugin = { // Export components for use by other modules components: { - ImageUpload, - ImageDisplay + ImageUpload } } From 98934ed61db584cca5cc60a66a18f212240917cc Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 28 Sep 2025 12:58:11 +0200 Subject: [PATCH 5/9] refactor: streamline ImageLightbox and update ProductDetailPage for better image handling - Removed unused `closeOnBackdropClick` option from `useImageLightbox` for cleaner code. - Simplified the product assignment in `ProductDetailPage` by creating a mutable copy of product data, ensuring proper handling of images and categories. These changes enhance the maintainability and clarity of the image handling components in the application. --- src/components/ui/image/ImageLightbox.vue | 2 +- src/components/ui/image/composables/useImageLightbox.ts | 1 - src/modules/market/views/ProductDetailPage.vue | 7 ++++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/ui/image/ImageLightbox.vue b/src/components/ui/image/ImageLightbox.vue index cf96a87..e3049fe 100644 --- a/src/components/ui/image/ImageLightbox.vue +++ b/src/components/ui/image/ImageLightbox.vue @@ -89,7 +89,7 @@ + + diff --git a/src/components/ui/table/TableBody.vue b/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..58d1ba6 --- /dev/null +++ b/src/components/ui/table/TableBody.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCaption.vue b/src/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..baaa177 --- /dev/null +++ b/src/components/ui/table/TableCaption.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCell.vue b/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..d42cf26 --- /dev/null +++ b/src/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/table/TableEmpty.vue b/src/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..c454dab --- /dev/null +++ b/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/table/TableFooter.vue b/src/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..c3dc6b0 --- /dev/null +++ b/src/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHead.vue b/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..51ceec2 --- /dev/null +++ b/src/components/ui/table/TableHead.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHeader.vue b/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..4977b71 --- /dev/null +++ b/src/components/ui/table/TableHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableRow.vue b/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..fd363c0 --- /dev/null +++ b/src/components/ui/table/TableRow.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/index.ts b/src/components/ui/table/index.ts new file mode 100644 index 0000000..3be308b --- /dev/null +++ b/src/components/ui/table/index.ts @@ -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" diff --git a/src/components/ui/table/utils.ts b/src/components/ui/table/utils.ts new file mode 100644 index 0000000..3d4fd12 --- /dev/null +++ b/src/components/ui/table/utils.ts @@ -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(updaterOrValue: Updater, ref: Ref) { + ref.value = isFunction(updaterOrValue) + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/src/modules/market/components/CreateProductDialog.vue b/src/modules/market/components/CreateProductDialog.vue index 3232804..dd5b505 100644 --- a/src/modules/market/components/CreateProductDialog.vue +++ b/src/modules/market/components/CreateProductDialog.vue @@ -455,6 +455,17 @@ const createProduct = async (formData: any) => { throw new Error('No wallet admin key available') } + // Debug: Log what we're sending + console.log('🛒 CreateProductDialog: About to create product with categories:', { + name: productData.name, + categories: productData.categories, + categoriesType: typeof productData.categories, + categoriesLength: productData.categories?.length, + formCategories: categories, + formData: formData, + fullProductData: productData + }) + const newProduct = await nostrmarketAPI.createProduct( adminKey, productData diff --git a/src/modules/market/components/DashboardOverview.vue b/src/modules/market/components/DashboardOverview.vue index bfe1b73..103c07a 100644 --- a/src/modules/market/components/DashboardOverview.vue +++ b/src/modules/market/components/DashboardOverview.vue @@ -215,69 +215,107 @@ diff --git a/src/modules/market/components/DeleteConfirmDialog.vue b/src/modules/market/components/DeleteConfirmDialog.vue new file mode 100644 index 0000000..7bc2065 --- /dev/null +++ b/src/modules/market/components/DeleteConfirmDialog.vue @@ -0,0 +1,85 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/MerchantOrders.vue b/src/modules/market/components/MerchantOrders.vue new file mode 100644 index 0000000..f4428a8 --- /dev/null +++ b/src/modules/market/components/MerchantOrders.vue @@ -0,0 +1,744 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/MerchantStore.vue b/src/modules/market/components/MerchantStore.vue index 2ded586..50da407 100644 --- a/src/modules/market/components/MerchantStore.vue +++ b/src/modules/market/components/MerchantStore.vue @@ -207,15 +207,23 @@ - +
-
-

Products

- -
+ + + Products + Orders + + + + +
+

Products

+ +
@@ -276,6 +284,19 @@ {{ product.active ? 'Active' : 'Inactive' }} + + +
+ + + +
@@ -292,18 +313,54 @@ -
- + +
+ + + + + + + + @@ -325,6 +382,16 @@ @created="onProductCreated" @updated="onProductUpdated" /> + + + @@ -334,15 +401,27 @@ import { useRouter } from 'vue-router' import { useMarketStore } from '@/modules/market/stores/market' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' import { Package, Store, DollarSign, Star, Plus, - User + User, + Trash2, + Send, + Edit, + CheckCircle, + Clock, + AlertCircle } from 'lucide-vue-next' -import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI' +import type { NostrmarketAPI, Merchant, Stall, ProductApiResponse } from '../services/nostrmarketAPI' import type { Product } from '../types/market' import { mapApiResponseToProduct } from '../types/market' import { auth } from '@/composables/useAuthService' @@ -350,7 +429,9 @@ import { useToast } from '@/core/composables/useToast' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import CreateStoreDialog from './CreateStoreDialog.vue' import CreateProductDialog from './CreateProductDialog.vue' +import DeleteConfirmDialog from './DeleteConfirmDialog.vue' import StoreCard from './StoreCard.vue' +import MerchantOrders from './MerchantOrders.vue' const router = useRouter() const marketStore = useMarketStore() @@ -376,10 +457,25 @@ const activeStall = computed(() => const stallProducts = ref([]) const isLoadingProducts = ref(false) +// Product action state +const isDeletingProduct = ref(false) +const deletingProductId = ref(null) +const isResendingProduct = ref(false) +const resendingProductId = ref(null) + +// Nostr sync tracking +const pendingNostrConfirmation = ref>(new Map()) // productId -> timestamp +const confirmedOnNostr = ref>(new Set()) + +// Tab management +const activeTab = ref('products') + // Dialog state const showCreateStoreDialog = ref(false) const showCreateProductDialog = ref(false) +const showDeleteConfirmDialog = ref(false) const editingProduct = ref(null) +const productToDelete = ref(null) // Computed properties const userHasMerchantProfile = computed(() => { @@ -390,6 +486,17 @@ const userHasStalls = computed(() => { return userStalls.value.length > 0 }) +// Helper to get sync status for a product +const getProductSyncStatus = (productId: string) => { + if (confirmedOnNostr.value.has(productId)) { + return 'confirmed' + } + if (pendingNostrConfirmation.value.has(productId)) { + return 'pending' + } + return 'unknown' +} + const storeStats = computed(() => { const currentUserPubkey = auth.currentUser?.value?.pubkey if (!currentUserPubkey) { @@ -534,6 +641,9 @@ const loadStallProducts = async () => { .forEach(product => { marketStore.addProduct(product) }) + + // Initialize sync status for loaded products + initializeSyncStatus() } catch (error) { console.error('Failed to load products:', error) stallProducts.value = [] @@ -605,11 +715,248 @@ const editProduct = (product: Product) => { showCreateProductDialog.value = true } +const deleteProduct = (product: Product) => { + productToDelete.value = product + showDeleteConfirmDialog.value = true +} + +const confirmDeleteProduct = async () => { + if (!productToDelete.value) return + + const product = productToDelete.value + + try { + isDeletingProduct.value = true + deletingProductId.value = product.id + + const adminKey = paymentService.getPreferredWalletAdminKey() + if (!adminKey) { + throw new Error('No wallet admin key available') + } + + await nostrmarketAPI.deleteProduct(adminKey, product.id) + + // Remove from local state + stallProducts.value = stallProducts.value.filter(p => p.id !== product.id) + + showDeleteConfirmDialog.value = false + productToDelete.value = null + toast.success(`Product "${product.name}" deleted successfully!`) + } catch (error) { + console.error('Failed to delete product:', error) + toast.error('Failed to delete product. Please try again.') + } finally { + isDeletingProduct.value = false + deletingProductId.value = null + } +} + +const cancelDeleteProduct = () => { + showDeleteConfirmDialog.value = false + productToDelete.value = null +} + +const resendProduct = async (product: Product) => { + try { + isResendingProduct.value = true + resendingProductId.value = product.id + + const adminKey = paymentService.getPreferredWalletAdminKey() + if (!adminKey) { + throw new Error('No wallet admin key available') + } + + // Re-send by updating the product with its current data + // This will trigger LNbits to re-publish to Nostr + const productData: ProductApiResponse = { + id: product.id, + stall_id: product.stall_id, + name: product.name, + categories: product.categories || [], + images: product.images || [], + price: product.price, + quantity: product.quantity, + active: product.active ?? true, + pending: product.pending ?? false, + config: { + description: product.description || '', + currency: product.currency || 'sat', + use_autoreply: false, + autoreply_message: '', + shipping: [] + }, + event_id: product.nostrEventId, + event_created_at: product.createdAt + } + + await nostrmarketAPI.updateProduct(adminKey, product.id, productData) + + // Reset sync status - remove from confirmed and add to pending + confirmedOnNostr.value.delete(product.id) + pendingNostrConfirmation.value.set(product.id, Date.now()) + + console.log('🔄 Product re-sent - sync status reset to pending:', { + productId: product.id, + productName: product.name, + wasConfirmed: confirmedOnNostr.value.has(product.id), + nowPending: pendingNostrConfirmation.value.has(product.id) + }) + + toast.success(`Product "${product.name}" re-sent to LNbits for event publishing!`) + + // TODO: Consider adding a timeout to remove from pending if not confirmed within reasonable time + // (e.g., 30 seconds) to avoid keeping products in pending state indefinitely + } catch (error) { + console.error('Failed to re-send product:', error) + toast.error('Failed to re-send product. Please try again.') + } finally { + isResendingProduct.value = false + resendingProductId.value = null + } +} + const closeProductDialog = () => { showCreateProductDialog.value = false editingProduct.value = null } +// Watch for market store updates to detect confirmed products +watch(() => marketStore.products, (newProducts) => { + // Check if any pending products now appear in the market feed + for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) { + const foundProduct = newProducts.find(p => p.id === productId) + + if (foundProduct) { + // Find the corresponding local product to compare content + const localProduct = stallProducts.value.find(p => p.id === productId) + + if (localProduct) { + // Compare content to verify true sync + const localData = normalizeProductForComparison(localProduct) + const marketData = normalizeProductForComparison(foundProduct) + const localJson = JSON.stringify(localData) + const marketJson = JSON.stringify(marketData) + const isContentSynced = localJson === marketJson + + + if (isContentSynced) { + // Product content confirmed as synced on Nostr! + pendingNostrConfirmation.value.delete(productId) + confirmedOnNostr.value.add(productId) + + // Show confirmation toast + toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`) + + console.log('🎉 Product confirmed on Nostr with matching content:', { + productId, + productName: foundProduct.name, + pendingTime: Date.now() - timestamp, + contentVerified: true + }) + } else { + console.warn('⚠️ Product appeared in market but content differs:', { + productId, + productName: foundProduct.name, + localData, + marketData + }) + // Remove from pending - content doesn't match, so it's not properly synced + pendingNostrConfirmation.value.delete(productId) + // Don't add to confirmedOnNostr - it should show as unsynced + } + } else { + // No local product found - just mark as confirmed + pendingNostrConfirmation.value.delete(productId) + confirmedOnNostr.value.add(productId) + toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`) + } + } + } + + // Update sync status for any new products that appear in market feed + initializeSyncStatus() +}, { deep: true }) + +// Cleanup pending confirmations after timeout (30 seconds) +const cleanupPendingConfirmations = () => { + const timeout = 30 * 1000 // 30 seconds + const now = Date.now() + + for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) { + if (now - timestamp > timeout) { + pendingNostrConfirmation.value.delete(productId) + console.warn('⏰ Timeout: Product confirmation removed from pending after 30s:', productId) + } + } +} + +// Run cleanup every 10 seconds +setInterval(cleanupPendingConfirmations, 10 * 1000) + +// Helper function to normalize product data for comparison +const normalizeProductForComparison = (product: any) => { + return { + name: product.name, + description: product.description || '', + price: product.price, + quantity: product.quantity, + active: product.active ?? true, + categories: (product.categories ? [...product.categories] : []).sort(), // Sort for consistent comparison + images: (product.images ? [...product.images] : []).sort(), // Sort for consistent comparison + currency: product.currency || 'sat' + } +} + +// Enhanced sync status detection with JSON content comparison +const initializeSyncStatus = () => { + // Cross-reference stallProducts with market feed to detect already-synced products + for (const product of stallProducts.value) { + if (product.id) { + const foundInMarket = marketStore.products.find(p => p.id === product.id) + if (foundInMarket) { + // Compare the actual product content, not just IDs + const localData = normalizeProductForComparison(product) + const marketData = normalizeProductForComparison(foundInMarket) + + // Deep comparison of normalized data + const localJson = JSON.stringify(localData) + const marketJson = JSON.stringify(marketData) + const isContentSynced = localJson === marketJson + + if (isContentSynced) { + // Product content is truly synced - mark as confirmed + confirmedOnNostr.value.add(product.id) + console.log('✅ Product content verified as synced to Nostr:', { + productId: product.id, + productName: product.name + }) + } else { + // Product exists but content differs - needs re-sync + console.warn('⚠️ Product exists but content differs - needs re-sync:', { + productId: product.id, + productName: product.name, + localData, + marketData, + differences: { + local: localData, + market: marketData + } + }) + // Remove from both confirmed and pending - it's out of sync + confirmedOnNostr.value.delete(product.id) + pendingNostrConfirmation.value.delete(product.id) + // User should see unsynced indicator (no badge) + } + } else { + console.log('📤 Product not found in market feed - not synced:', { + productId: product.id, + productName: product.name + }) + } + } + } +} + // Lifecycle onMounted(async () => { console.log('Merchant Store component loaded') diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 63fba57..cadb14b 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -297,6 +297,14 @@ export function useMarket() { .map((tag: any) => tag[1]) .filter((cat: string) => cat && cat.trim()) + // Debug: Log category processing (when categories are present) + if (categories.length > 0) { + console.log('🛒 useMarket: Processing product with categories:', { + productName: productData.name, + processedCategories: categories, + eventTags: latestEvent.tags.filter((tag: string[]) => tag[0] === 't') + }) + } // Look up the stall name from the stalls array const stall = marketStore.stalls.find(s => s.id === stallId) @@ -489,6 +497,15 @@ export function useMarket() { .map((tag: any) => tag[1]) .filter((cat: string) => cat && cat.trim()) + // Debug: Log real-time category processing (when categories are present) + if (categories.length > 0) { + console.log('🛒 useMarket: Real-time product with categories:', { + productName: productData.name, + processedCategories: categories, + eventTags: event.tags.filter((tag: string[]) => tag[0] === 't') + }) + } + // Look up the stall name from the stalls array const stall = marketStore.stalls.find(s => s.id === stallId) const stallName = stall?.name || 'Unknown Stall' @@ -516,17 +533,7 @@ export function useMarket() { } } - // Publish a product - const publishProduct = async (_productData: any) => { - // Implementation would depend on your event creation logic - // TODO: Implement product publishing - } - - // Publish a stall - const publishStall = async (_stallData: any) => { - // Implementation would depend on your event creation logic - // TODO: Implement stall publishing - } + // Publishing methods removed - now handled by LNbits API endpoints // Connect to market const connectToMarket = async () => { @@ -617,8 +624,6 @@ export function useMarket() { connectToMarket, disconnectFromMarket, processPendingProducts, - publishProduct, - publishStall, subscribeToMarketUpdates, subscribeToOrderUpdates } diff --git a/src/modules/market/services/nostrmarketAPI.ts b/src/modules/market/services/nostrmarketAPI.ts index f4cae9f..4913aa0 100644 --- a/src/modules/market/services/nostrmarketAPI.ts +++ b/src/modules/market/services/nostrmarketAPI.ts @@ -99,6 +99,62 @@ export interface CreateStallRequest { } } +// Order related types +export interface OrderItem { + product_id: string + quantity: number +} + +export interface OrderContact { + nostr?: string + phone?: string + email?: string +} + +export interface ProductOverview { + id: string + name: string + price: number + product_shipping_cost?: number +} + +export interface OrderExtra { + products: ProductOverview[] + currency: string + btc_price: string + shipping_cost: number + shipping_cost_sat: number + fail_message?: string +} + +export interface OrderApiResponse { + id: string + event_id?: string + event_created_at?: number + public_key: string + stall_id: string + invoice_id: string + total: number + paid: boolean + shipped: boolean + time?: number + contact_data: string // JSON string + order_items: string // JSON string + extra_data: string // JSON string + address?: string + message?: string + contact?: OrderContact // Parsed from contact_data + items?: OrderItem[] // Parsed from order_items + extra?: OrderExtra // Parsed from extra_data +} + +export interface OrderStatusUpdate { + id: string + message?: string + paid?: boolean + shipped?: boolean +} + export class NostrmarketAPI extends BaseService { // Service metadata protected readonly metadata = { @@ -368,6 +424,20 @@ export class NostrmarketAPI extends BaseService { walletAdminkey: string, productData: CreateProductRequest ): Promise { + // 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( '/api/v1/product', walletAdminkey, @@ -377,10 +447,12 @@ export class NostrmarketAPI extends BaseService { } ) - this.debug('Created product:', { + this.debug('Created product response:', { productId: product.id, productName: product.name, - stallId: product.stall_id + stallId: product.stall_id, + returnedCategories: product.categories, + returnedCategoriesLength: product.categories?.length }) return product @@ -446,4 +518,153 @@ export class NostrmarketAPI extends BaseService { this.debug('Deleted product:', { productId }) } + + /** + * Get all orders for the merchant + */ + async getOrders( + walletInkey: string, + filters?: { paid?: boolean, shipped?: boolean, pubkey?: string } + ): Promise { + 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( + 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 { + 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( + 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 { + try { + const order = await this.request( + `/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 { + try { + const order = await this.request( + `/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 + } + } } diff --git a/src/modules/market/services/nostrmarketService.ts b/src/modules/market/services/nostrmarketService.ts index 7606e6b..733879e 100644 --- a/src/modules/market/services/nostrmarketService.ts +++ b/src/modules/market/services/nostrmarketService.ts @@ -1,6 +1,6 @@ import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' -import type { Stall, Product, Order } from '@/modules/market/stores/market' +import type { Order } from '@/modules/market/stores/market' export interface NostrmarketStall { id: string @@ -27,6 +27,9 @@ export interface NostrmarketProduct { currency: string } +// Note: Stall and Product publishing is handled by LNbits API endpoints +// NostrmarketService now only handles order DMs and status updates + export interface NostrmarketOrder { id: string items: Array<{ @@ -152,90 +155,8 @@ export class NostrmarketService extends BaseService { } } - /** - * Publish a stall event (kind 30017) to Nostr - */ - async publishStall(stall: Stall): Promise { - 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 { - const { prvkey } = this.getAuth() - - const productData: NostrmarketProduct = { - id: product.id, - stall_id: product.stall_id, - name: product.name, - description: product.description, - images: product.images || [], - categories: product.categories || [], - price: product.price, - quantity: product.quantity, - currency: product.currency - } - - const eventTemplate: EventTemplate = { - kind: 30018, - tags: [ - ['t', 'product'], - ['t', 'nostrmarket'], - ['t', 'stall', product.stall_id], - ...(product.categories || []).map(cat => ['t', cat]) - ], - content: JSON.stringify(productData), - created_at: Math.floor(Date.now() / 1000) - } - - const prvkeyBytes = this.hexToUint8Array(prvkey) - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await this.relayHub.publishEvent(event) - - console.log('Product published to nostrmarket:', { - productId: product.id, - eventId: result, - content: productData - }) - - return result.success.toString() - } + // Removed publishStall() and publishProduct() methods + // Stall and product publishing is now handled by LNbits API endpoints /** * Publish an order event (kind 4 encrypted DM) to nostrmarket @@ -471,38 +392,6 @@ export class NostrmarketService extends BaseService { } } - /** - * Publish all stalls and products for a merchant - */ - async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{ - stalls: Record, // stallId -> eventId - products: Record // productId -> eventId - }> { - const results = { - stalls: {} as Record, - products: {} as Record - } - - // Publish stalls first - for (const stall of stalls) { - try { - const eventId = await this.publishStall(stall) - results.stalls[stall.id] = eventId - } catch (error) { - console.error(`Failed to publish stall ${stall.id}:`, error) - } - } - - // Publish products - for (const product of products) { - try { - const eventId = await this.publishProduct(product) - results.products[product.id] = eventId - } catch (error) { - console.error(`Failed to publish product ${product.id}:`, error) - } - } - - return results - } + // Removed publishMerchantCatalog() method + // Publishing is now handled by LNbits API endpoints } diff --git a/src/modules/market/stores/market.ts b/src/modules/market/stores/market.ts index 20c89f5..b1a11ba 100644 --- a/src/modules/market/stores/market.ts +++ b/src/modules/market/stores/market.ts @@ -470,51 +470,8 @@ export const useMarketStore = defineStore('market', () => { } } - // nostrmarket integration methods - const publishToNostrmarket = async () => { - try { - console.log('Publishing merchant catalog to nostrmarket...') - - // Get all stalls and products - const allStalls = Object.values(stalls.value) - const allProducts = Object.values(products.value) - - if (allStalls.length === 0) { - console.warn('No stalls to publish to nostrmarket') - return null - } - - if (allProducts.length === 0) { - console.warn('No products to publish to nostrmarket') - return null - } - - // Publish to nostrmarket - const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts) - - console.log('Successfully published to nostrmarket:', result) - - // Update stalls and products with event IDs - for (const [stallId, eventId] of Object.entries(result.stalls)) { - const stall = stalls.value.find(s => s.id === stallId) - if (stall) { - stall.nostrEventId = eventId - } - } - - for (const [productId, eventId] of Object.entries(result.products)) { - const product = products.value.find(p => p.id === productId) - if (product) { - product.nostrEventId = eventId - } - } - - return result - } catch (error) { - console.error('Failed to publish to nostrmarket:', error) - throw error - } - } + // Removed publishToNostrmarket() method + // Publishing is now handled automatically by LNbits API endpoints // Invoice management methods const createLightningInvoice = async (orderId: string, adminKey: string): Promise => { @@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => { saveOrdersToStorage, loadOrdersFromStorage, clearOrdersForUserChange, - publishToNostrmarket } }) \ No newline at end of file