refactor: consolidate Product types into single source of truth

- Replace dual Product interfaces with unified domain model in types/market.ts
  - Rename API response type to ProductApiResponse for clarity
  - Add mapApiResponseToProduct() function for clean API-to-domain conversion
  - Update MerchantStore.vue to use mapping function instead of manual property assignment
  - Fix CreateProductDialog.vue to properly handle type conversions for API calls
  - Resolve TypeScript readonly type conflicts using type assertions instead of array spreading
  - Add missing reactive references (searchQuery) and remove unused imports
  - Prevent Vue 3 recursion issues by maintaining original reactive object references

  This eliminates confusion between API response structure and application domain model,
  following single source of truth principle for better maintainability and type safety.
This commit is contained in:
padreug 2025-09-26 17:29:06 +02:00
parent 478b83ddd3
commit 25d17b481d
4 changed files with 100 additions and 40 deletions

View file

@ -219,7 +219,8 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Package } from 'lucide-vue-next' import { Package } from 'lucide-vue-next'
import type { NostrmarketAPI, Stall, Product, CreateProductRequest } from '../services/nostrmarketAPI' import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -326,9 +327,9 @@ const updateProduct = async (formData: any) => {
createError.value = null createError.value = null
try { try {
const productData: Product = { const productData = {
id: props.product.id, id: props.product?.id,
stall_id: props.product.stall_id, stall_id: props.product?.stall_id || props.stall?.id || '',
name, name,
categories: categories || [], categories: categories || [],
images: images || [], images: images || [],
@ -338,11 +339,13 @@ const updateProduct = async (formData: any) => {
pending: false, pending: false,
config: { config: {
description: description || '', description: description || '',
currency: props.stall?.currency || props.product.config.currency, currency: props.stall?.currency || props.product?.config?.currency || 'sats',
use_autoreply, use_autoreply,
autoreply_message: use_autoreply ? autoreply_message || '' : '', autoreply_message: use_autoreply ? autoreply_message || '' : '',
shipping: props.product.config.shipping || [] shipping: props.product?.config?.shipping || []
} },
event_id: props.product?.nostrEventId,
event_created_at: props.product?.createdAt
} }
const adminKey = paymentService.getPreferredWalletAdminKey() const adminKey = paymentService.getPreferredWalletAdminKey()

View file

@ -258,7 +258,7 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<h4 class="font-semibold text-foreground">{{ product.name }}</h4> <h4 class="font-semibold text-foreground">{{ product.name }}</h4>
<p v-if="product.config.description" class="text-sm text-muted-foreground mt-1"> <p v-if="product.config?.description" class="text-sm text-muted-foreground mt-1">
{{ product.config.description }} {{ product.config.description }}
</p> </p>
</div> </div>
@ -266,7 +266,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<span class="text-lg font-bold text-foreground"> <span class="text-lg font-bold text-foreground">
{{ product.price }} {{ product.config.currency || activeStall?.currency || 'sat' }} {{ product.price }} {{ product.config?.currency || activeStall?.currency || 'sat' }}
</span> </span>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
Qty: {{ product.quantity }} Qty: {{ product.quantity }}
@ -342,7 +342,9 @@ import {
AlertCircle, AlertCircle,
User User
} from 'lucide-vue-next' } from 'lucide-vue-next'
import type { NostrmarketAPI, Merchant, Stall, Product } from '../services/nostrmarketAPI' import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { mapApiResponseToProduct } from '../types/market'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -516,26 +518,14 @@ const loadStallProducts = async () => {
inkey, inkey,
activeStall.value.id! activeStall.value.id!
) )
// Enrich products with stall name and missing properties to match Product interface // Convert API responses to domain models using clean mapping function
const enrichedProducts = (products || []).map(product => ({ const enrichedProducts = (products || []).map(product =>
id: product.id || `${product.stall_id}-${Date.now()}`, // Ensure id is always string mapApiResponseToProduct(
stall_id: product.stall_id, product,
stallName: activeStall.value?.name || 'Unknown Stall', activeStall.value?.name || 'Unknown Stall',
name: product.name, activeStall.value?.currency || 'sats'
description: product.config?.description || '', )
price: product.price, )
currency: activeStall.value?.currency || 'sats', // Use stall currency
quantity: product.quantity,
images: product.images,
categories: product.categories,
createdAt: product.event_created_at || Date.now(),
updatedAt: Date.now(),
nostrEventId: product.event_id,
// API-specific properties that are expected by the template
active: product.active ?? true,
pending: product.pending ?? false,
config: product.config || { currency: activeStall.value?.currency || 'sats' }
}))
stallProducts.value = enrichedProducts stallProducts.value = enrichedProducts
// Only add active products to the market store so they appear in the main market // Only add active products to the market store so they appear in the main market

View file

@ -68,7 +68,8 @@ export interface ProductConfig {
shipping: ProductShippingCost[] shipping: ProductShippingCost[]
} }
export interface Product { // API Response Types - Raw data from LNbits API
export interface ProductApiResponse {
id?: string id?: string
stall_id: string stall_id: string
name: string name: string
@ -358,8 +359,8 @@ export class NostrmarketAPI extends BaseService {
/** /**
* Get products for a stall * Get products for a stall
*/ */
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<Product[]> { async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<ProductApiResponse[]> {
const products = await this.request<Product[]>( const products = await this.request<ProductApiResponse[]>(
`/api/v1/stall/product/${stallId}?pending=${pending}`, `/api/v1/stall/product/${stallId}?pending=${pending}`,
walletInkey, walletInkey,
{ method: 'GET' } { method: 'GET' }
@ -380,8 +381,8 @@ export class NostrmarketAPI extends BaseService {
async createProduct( async createProduct(
walletAdminkey: string, walletAdminkey: string,
productData: CreateProductRequest productData: CreateProductRequest
): Promise<Product> { ): Promise<ProductApiResponse> {
const product = await this.request<Product>( const product = await this.request<ProductApiResponse>(
'/api/v1/product', '/api/v1/product',
walletAdminkey, walletAdminkey,
{ {
@ -405,9 +406,9 @@ export class NostrmarketAPI extends BaseService {
async updateProduct( async updateProduct(
walletAdminkey: string, walletAdminkey: string,
productId: string, productId: string,
productData: Product productData: ProductApiResponse
): Promise<Product> { ): Promise<ProductApiResponse> {
const product = await this.request<Product>( const product = await this.request<ProductApiResponse>(
`/api/v1/product/${productId}`, `/api/v1/product/${productId}`,
walletAdminkey, walletAdminkey,
{ {
@ -427,9 +428,9 @@ export class NostrmarketAPI extends BaseService {
/** /**
* Get a single product by ID * Get a single product by ID
*/ */
async getProduct(walletInkey: string, productId: string): Promise<Product | null> { async getProduct(walletInkey: string, productId: string): Promise<ProductApiResponse | null> {
try { try {
const product = await this.request<Product>( const product = await this.request<ProductApiResponse>(
`/api/v1/product/${productId}`, `/api/v1/product/${productId}`,
walletInkey, walletInkey,
{ method: 'GET' } { method: 'GET' }

View file

@ -26,6 +26,7 @@ export interface Stall {
nostrEventId?: string nostrEventId?: string
} }
// Domain Model - Single source of truth for Product
export interface Product { export interface Product {
id: string id: string
stall_id: string stall_id: string
@ -46,6 +47,71 @@ export interface Product {
config?: { currency?: string, [key: string]: any } config?: { currency?: string, [key: string]: any }
} }
// Type aliases for API responses - imported from services
export type { ProductApiResponse, Stall as StallApiResponse } from '../services/nostrmarketAPI'
// Mapping function to convert API response to domain model
export function mapApiResponseToProduct(
apiProduct: import('../services/nostrmarketAPI').ProductApiResponse,
stallName: string,
stallCurrency: string = 'sats'
): Product {
return {
id: apiProduct.id || `${apiProduct.stall_id}-${Date.now()}`,
stall_id: apiProduct.stall_id,
stallName,
name: apiProduct.name,
description: apiProduct.config?.description || '',
price: apiProduct.price,
currency: stallCurrency,
quantity: apiProduct.quantity,
images: apiProduct.images,
categories: apiProduct.categories,
createdAt: apiProduct.event_created_at || Date.now(),
updatedAt: Date.now(),
nostrEventId: apiProduct.event_id,
active: apiProduct.active,
pending: apiProduct.pending,
config: apiProduct.config
}
}
// Mapping function to convert API response to domain model for Stall
export function mapApiResponseToStall(
apiStall: import('../services/nostrmarketAPI').Stall
): Stall {
return {
id: apiStall.id,
pubkey: '', // API Stall doesn't have pubkey, need to set from merchant context
name: apiStall.name,
description: apiStall.config?.description,
logo: apiStall.config?.image_url,
categories: [], // API Stall doesn't have categories
shipping: apiStall.shipping_zones?.map(zone => ({
id: zone.id,
name: zone.name,
cost: zone.cost,
currency: apiStall.currency,
countries: zone.countries,
description: `${zone.name} shipping`,
estimatedDays: undefined,
requiresPhysicalShipping: true
})),
shipping_zones: apiStall.shipping_zones?.map(zone => ({
id: zone.id,
name: zone.name,
cost: zone.cost,
currency: apiStall.currency,
countries: zone.countries,
description: `${zone.name} shipping`,
estimatedDays: undefined,
requiresPhysicalShipping: true
})),
currency: apiStall.currency,
nostrEventId: apiStall.event_id
}
}
export interface Order { export interface Order {
id: string id: string
cartId: string cartId: string