webapp/src/modules/market/services/nostrmarketAPI.ts
padreug 8821f604be feat: consolidate Stall types and create reusable CartButton component
## 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
2025-09-27 01:31:52 +02:00

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 })
}
}