## Type Consolidation - Add StallApiResponse interface matching LNbits backend structure - Update domain Stall interface with cleaner, app-friendly properties - Create mapApiResponseToStall() mapper function for API-to-domain conversion - Remove duplicate Stall type definition from nostrmarketAPI.ts - Update CheckoutPage to use standardized shipping property - Verify types against LNbits reference implementation ## UI Components - Create reusable CartButton.vue component with proper separation of concerns - Remove duplicate cart button code from MarketPage and StallView - Add consistent cart functionality across all market pages - Fix missing cart button in StallView - Improve code maintainability with DRY principles ## Bug Fixes - Fix ProductDetailDialog add-to-cart functionality by using correct cart system - Resolve cart system mismatch between legacy addToCart and stall-based totalCartItems - Update ProductCard to emit events properly instead of direct store call - Ensure consistent event flow: ProductCard → ProductGrid → MarketPage → Store ## Technical Improvements - Follow established Product type consolidation pattern for consistency - Maintain type safety between API responses and domain models - Enable easier API evolution without breaking domain logic - Optimize bundle splitting with component extraction
449 lines
10 KiB
TypeScript
449 lines
10 KiB
TypeScript
import { BaseService } from '@/core/base/BaseService'
|
|
import appConfig from '@/app.config'
|
|
|
|
export interface Merchant {
|
|
id: string
|
|
private_key: string
|
|
public_key: string
|
|
time?: number
|
|
config: {
|
|
name?: string
|
|
about?: string
|
|
picture?: string
|
|
event_id?: string
|
|
sync_from_nostr?: boolean
|
|
active: boolean
|
|
restore_in_progress?: boolean
|
|
}
|
|
}
|
|
|
|
// Import StallApiResponse from types/market.ts
|
|
import type { StallApiResponse } from '../types/market'
|
|
|
|
// Use StallApiResponse as the API response type
|
|
export type Stall = StallApiResponse
|
|
|
|
export interface CreateMerchantRequest {
|
|
config: {
|
|
name?: string
|
|
about?: string
|
|
picture?: string
|
|
sync_from_nostr?: boolean
|
|
active?: boolean
|
|
}
|
|
}
|
|
|
|
export interface Zone {
|
|
id: string
|
|
name: string
|
|
currency: string
|
|
cost: number
|
|
countries: string[]
|
|
}
|
|
|
|
export interface ProductShippingCost {
|
|
id: string
|
|
cost: number
|
|
}
|
|
|
|
export interface ProductConfig {
|
|
description?: string
|
|
currency?: string
|
|
use_autoreply?: boolean
|
|
autoreply_message?: string
|
|
shipping: ProductShippingCost[]
|
|
}
|
|
|
|
// API Response Types - Raw data from LNbits API
|
|
export interface ProductApiResponse {
|
|
id?: string
|
|
stall_id: string
|
|
name: string
|
|
categories: string[]
|
|
images: string[]
|
|
price: number
|
|
quantity: number
|
|
active: boolean
|
|
pending: boolean
|
|
config: ProductConfig
|
|
event_id?: string
|
|
event_created_at?: number
|
|
}
|
|
|
|
export interface CreateProductRequest {
|
|
stall_id: string
|
|
name: string
|
|
categories: string[]
|
|
images: string[]
|
|
price: number
|
|
quantity: number
|
|
active: boolean
|
|
config: ProductConfig
|
|
}
|
|
|
|
export interface CreateZoneRequest {
|
|
name: string
|
|
currency: string
|
|
cost: number
|
|
countries: string[]
|
|
}
|
|
|
|
export interface CreateStallRequest {
|
|
wallet: string
|
|
name: string
|
|
currency: string
|
|
shipping_zones: Zone[]
|
|
config: {
|
|
image_url?: string
|
|
description?: string
|
|
}
|
|
}
|
|
|
|
export class NostrmarketAPI extends BaseService {
|
|
// Service metadata
|
|
protected readonly metadata = {
|
|
name: 'NostrmarketAPI',
|
|
version: '1.0.0',
|
|
dependencies: [] // No dependencies - this is a market-specific service
|
|
}
|
|
|
|
private baseUrl: string
|
|
|
|
constructor() {
|
|
super()
|
|
const config = appConfig.modules.market.config
|
|
if (!config?.apiConfig?.baseUrl) {
|
|
throw new Error('NostrmarketAPI: Missing apiConfig.baseUrl in market module config')
|
|
}
|
|
this.baseUrl = config.apiConfig.baseUrl
|
|
}
|
|
|
|
/**
|
|
* Service-specific initialization (called by BaseService)
|
|
*/
|
|
protected async onInitialize(): Promise<void> {
|
|
this.debug('NostrmarketAPI initialized with base URL:', this.baseUrl)
|
|
// Service is ready to use
|
|
}
|
|
|
|
private async request<T>(
|
|
endpoint: string,
|
|
walletKey: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const url = `${this.baseUrl}/nostrmarket${endpoint}`
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-API-KEY': walletKey,
|
|
}
|
|
|
|
// Merge with any additional headers
|
|
if (options.headers) {
|
|
Object.assign(headers, options.headers)
|
|
}
|
|
|
|
this.debug('NostrmarketAPI request:', {
|
|
endpoint,
|
|
fullUrl: url,
|
|
method: options.method || 'GET',
|
|
headers: {
|
|
'Content-Type': headers['Content-Type'],
|
|
'X-API-KEY': walletKey.substring(0, 8) + '...' + walletKey.substring(walletKey.length - 4)
|
|
},
|
|
bodyPreview: options.body ? JSON.stringify(JSON.parse(options.body as string), null, 2) : undefined
|
|
})
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
this.debug('NostrmarketAPI Error:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
errorText
|
|
})
|
|
|
|
// If 404, it means no merchant profile exists
|
|
if (response.status === 404) {
|
|
return null as T
|
|
}
|
|
|
|
// If 403, likely the nostrmarket extension is not enabled for this user
|
|
if (response.status === 403) {
|
|
throw new Error(`Access denied: Please ensure the NostrMarket extension is enabled for your LNbits account. Contact your administrator if needed.`)
|
|
}
|
|
|
|
throw new Error(`NostrmarketAPI request failed: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
return data
|
|
}
|
|
|
|
/**
|
|
* Get merchant profile for the current user
|
|
* Uses wallet invoice key (inkey) as per the API specification
|
|
*/
|
|
async getMerchant(walletInkey: string): Promise<Merchant | null> {
|
|
try {
|
|
const merchant = await this.request<Merchant>(
|
|
'/api/v1/merchant',
|
|
walletInkey,
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
this.debug('Retrieved merchant:', {
|
|
exists: !!merchant,
|
|
merchantId: merchant?.id,
|
|
active: merchant?.config?.active
|
|
})
|
|
|
|
return merchant
|
|
} catch (error) {
|
|
this.debug('Failed to get merchant:', error)
|
|
// Return null instead of throwing - no merchant profile exists
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new merchant profile
|
|
* Uses wallet admin key as per the API specification
|
|
*/
|
|
async createMerchant(
|
|
walletAdminkey: string,
|
|
merchantData: CreateMerchantRequest
|
|
): Promise<Merchant> {
|
|
const merchant = await this.request<Merchant>(
|
|
'/api/v1/merchant',
|
|
walletAdminkey,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(merchantData),
|
|
}
|
|
)
|
|
|
|
this.debug('Created merchant:', { merchantId: merchant.id })
|
|
|
|
return merchant
|
|
}
|
|
|
|
/**
|
|
* Get stalls for the current merchant
|
|
*/
|
|
async getStalls(walletInkey: string): Promise<Stall[]> {
|
|
try {
|
|
const stalls = await this.request<Stall[]>(
|
|
'/api/v1/stall',
|
|
walletInkey,
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
this.debug('Retrieved stalls:', { count: stalls?.length || 0 })
|
|
|
|
return stalls || []
|
|
} catch (error) {
|
|
this.debug('Failed to get stalls:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new stall
|
|
*/
|
|
async createStall(
|
|
walletAdminkey: string,
|
|
stallData: CreateStallRequest
|
|
): Promise<Stall> {
|
|
const stall = await this.request<Stall>(
|
|
'/api/v1/stall',
|
|
walletAdminkey,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(stallData),
|
|
}
|
|
)
|
|
|
|
this.debug('Created stall:', { stallId: stall.id })
|
|
|
|
return stall
|
|
}
|
|
|
|
/**
|
|
* Get available shipping zones
|
|
*/
|
|
async getZones(walletInkey: string): Promise<Zone[]> {
|
|
try {
|
|
const zones = await this.request<Zone[]>(
|
|
'/api/v1/zone',
|
|
walletInkey,
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
this.debug('Retrieved zones:', { count: zones?.length || 0 })
|
|
|
|
return zones || []
|
|
} catch (error) {
|
|
this.debug('Failed to get zones:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new shipping zone
|
|
*/
|
|
async createZone(
|
|
walletAdminkey: string,
|
|
zoneData: CreateZoneRequest
|
|
): Promise<Zone> {
|
|
const zone = await this.request<Zone>(
|
|
'/api/v1/zone',
|
|
walletAdminkey,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(zoneData),
|
|
}
|
|
)
|
|
|
|
this.debug('Created zone:', { zoneId: zone.id, zoneName: zone.name })
|
|
|
|
return zone
|
|
}
|
|
|
|
/**
|
|
* Get available currencies
|
|
*/
|
|
async getCurrencies(): Promise<string[]> {
|
|
const baseCurrencies = ['sat']
|
|
|
|
try {
|
|
const apiCurrencies = await this.request<string[]>(
|
|
'/api/v1/currencies',
|
|
'', // No authentication needed for currencies endpoint
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
if (apiCurrencies && Array.isArray(apiCurrencies)) {
|
|
// Combine base currencies with API currencies, removing duplicates
|
|
const allCurrencies = [...baseCurrencies, ...apiCurrencies.filter(currency => !baseCurrencies.includes(currency))]
|
|
this.debug('Retrieved currencies:', { count: allCurrencies.length, currencies: allCurrencies })
|
|
return allCurrencies
|
|
}
|
|
|
|
this.debug('No API currencies returned, using base currencies only')
|
|
return baseCurrencies
|
|
} catch (error) {
|
|
this.debug('Failed to get currencies, falling back to base currencies:', error)
|
|
return baseCurrencies
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get products for a stall
|
|
*/
|
|
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<ProductApiResponse[]> {
|
|
const products = await this.request<ProductApiResponse[]>(
|
|
`/api/v1/stall/product/${stallId}?pending=${pending}`,
|
|
walletInkey,
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
this.debug('Retrieved products:', {
|
|
stallId,
|
|
count: products?.length || 0,
|
|
pending
|
|
})
|
|
|
|
return products || []
|
|
}
|
|
|
|
/**
|
|
* Create a new product
|
|
*/
|
|
async createProduct(
|
|
walletAdminkey: string,
|
|
productData: CreateProductRequest
|
|
): Promise<ProductApiResponse> {
|
|
const product = await this.request<ProductApiResponse>(
|
|
'/api/v1/product',
|
|
walletAdminkey,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(productData),
|
|
}
|
|
)
|
|
|
|
this.debug('Created product:', {
|
|
productId: product.id,
|
|
productName: product.name,
|
|
stallId: product.stall_id
|
|
})
|
|
|
|
return product
|
|
}
|
|
|
|
/**
|
|
* Update an existing product
|
|
*/
|
|
async updateProduct(
|
|
walletAdminkey: string,
|
|
productId: string,
|
|
productData: ProductApiResponse
|
|
): Promise<ProductApiResponse> {
|
|
const product = await this.request<ProductApiResponse>(
|
|
`/api/v1/product/${productId}`,
|
|
walletAdminkey,
|
|
{
|
|
method: 'PATCH',
|
|
body: JSON.stringify(productData),
|
|
}
|
|
)
|
|
|
|
this.debug('Updated product:', {
|
|
productId: product.id,
|
|
productName: product.name
|
|
})
|
|
|
|
return product
|
|
}
|
|
|
|
/**
|
|
* Get a single product by ID
|
|
*/
|
|
async getProduct(walletInkey: string, productId: string): Promise<ProductApiResponse | null> {
|
|
try {
|
|
const product = await this.request<ProductApiResponse>(
|
|
`/api/v1/product/${productId}`,
|
|
walletInkey,
|
|
{ method: 'GET' }
|
|
)
|
|
|
|
this.debug('Retrieved product:', {
|
|
productId: product?.id,
|
|
productName: product?.name
|
|
})
|
|
|
|
return product
|
|
} catch (error) {
|
|
this.debug('Failed to get product:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a product
|
|
*/
|
|
async deleteProduct(walletAdminkey: string, productId: string): Promise<void> {
|
|
await this.request<void>(
|
|
`/api/v1/product/${productId}`,
|
|
walletAdminkey,
|
|
{ method: 'DELETE' }
|
|
)
|
|
|
|
this.debug('Deleted product:', { productId })
|
|
}
|
|
}
|