- Replaced direct wallet balance computation in Navbar and WalletPage with a centralized totalBalance property from PaymentService, improving code maintainability. - Updated CreateProductDialog, CreateStoreDialog, and MerchantStore components to utilize PaymentService for retrieving wallet admin and invoice keys, enhancing consistency across the application. - These changes streamline wallet management and improve the overall architecture of the wallet module.
524 lines
No EOL
17 KiB
Vue
524 lines
No EOL
17 KiB
Vue
<template>
|
|
<Dialog :open="isOpen" @update:open="(open) => !open && $emit('close')">
|
|
<DialogContent class="sm:max-w-2xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
|
<DialogHeader class="flex-shrink-0 p-6 pb-0">
|
|
<DialogTitle>Create New Store</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form @submit="onSubmit" class="flex flex-col flex-1 overflow-hidden" autocomplete="off">
|
|
<div class="flex-1 overflow-y-auto space-y-4 p-6 pt-4">
|
|
<!-- Basic Store Info -->
|
|
<div class="space-y-3">
|
|
<FormField v-slot="{ componentField }" name="name">
|
|
<FormItem>
|
|
<FormLabel>Store Name *</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Enter your store name"
|
|
:disabled="isCreating"
|
|
v-bind="componentField"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription class="text-xs sm:text-sm">
|
|
Choose a unique name for your store
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField v-slot="{ componentField }" name="description">
|
|
<FormItem>
|
|
<FormLabel>Description</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Describe your store and products"
|
|
:disabled="isCreating"
|
|
v-bind="componentField"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField v-slot="{ componentField }" name="currency">
|
|
<FormItem>
|
|
<FormLabel>Currency *</FormLabel>
|
|
<Select :disabled="isCreating" v-bind="componentField">
|
|
<FormControl>
|
|
<SelectTrigger class="w-full">
|
|
<SelectValue placeholder="Select currency" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
|
|
{{ currency }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
</FormField>
|
|
</div>
|
|
|
|
<!-- Shipping Zones Section -->
|
|
<FormField name="selectedZones">
|
|
<FormItem>
|
|
<div class="mb-3">
|
|
<FormLabel class="text-base">Shipping Zones *</FormLabel>
|
|
<FormDescription class="text-xs sm:text-sm">
|
|
Select existing zones or create new ones for your store
|
|
</FormDescription>
|
|
</div>
|
|
|
|
<!-- Existing Zones -->
|
|
<div v-if="availableZones.length > 0" class="space-y-2">
|
|
<FormLabel class="text-sm font-medium">Available Zones:</FormLabel>
|
|
<div class="space-y-2 max-h-24 overflow-y-auto border rounded-md p-2 relative">
|
|
<!-- Scroll indicator -->
|
|
<div v-if="availableZones.length > 3" class="absolute bottom-0 left-0 right-0 h-4 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
|
<FormField
|
|
v-for="zone in availableZones"
|
|
:key="zone.id"
|
|
v-slot="{ value, handleChange }"
|
|
type="checkbox"
|
|
:value="zone.id"
|
|
:unchecked-value="false"
|
|
name="selectedZones"
|
|
>
|
|
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
|
<FormControl>
|
|
<Checkbox
|
|
:model-value="Array.isArray(value) && value.includes(zone.id)"
|
|
@update:model-value="handleChange"
|
|
:disabled="isCreating"
|
|
/>
|
|
</FormControl>
|
|
<FormLabel class="text-xs sm:text-sm cursor-pointer font-normal">
|
|
<span class="block">{{ zone.name }} - {{ zone.cost }} {{ zone.currency }}</span>
|
|
<span class="text-muted-foreground text-xs">({{ zone.countries.slice(0, 2).join(', ') }}{{ zone.countries.length > 2 ? '...' : '' }})</span>
|
|
</FormLabel>
|
|
</FormItem>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<!-- Create New Zone (Collapsible) -->
|
|
<div class="border-t pt-3 mb-2">
|
|
<button
|
|
type="button"
|
|
@click="showNewZoneForm = !showNewZoneForm"
|
|
class="flex items-center justify-between w-full text-sm font-medium py-2 hover:text-primary transition-colors"
|
|
>
|
|
<span>Create New Zone</span>
|
|
<ChevronDown
|
|
:class="['w-4 h-4 transition-transform', showNewZoneForm ? 'rotate-180' : '']"
|
|
/>
|
|
</button>
|
|
<div v-show="showNewZoneForm" class="space-y-3 mt-2">
|
|
<FormField v-slot="{ componentField }" name="newZone.name">
|
|
<FormItem>
|
|
<FormLabel>Zone Name</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="e.g., Europe, Worldwide"
|
|
:disabled="isCreating"
|
|
v-bind="componentField"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField v-slot="{ componentField }" name="newZone.cost">
|
|
<FormItem>
|
|
<FormLabel>Shipping Cost</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
:placeholder="`Cost in ${values.currency || 'sat'}`"
|
|
:disabled="isCreating"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField v-slot="{ componentField }" name="newZone.selectedCountries">
|
|
<FormItem>
|
|
<FormLabel>Countries/Regions</FormLabel>
|
|
<FormControl>
|
|
<Select multiple :disabled="isCreating" v-bind="componentField">
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select countries/regions" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="country in countries" :key="country" :value="country">
|
|
{{ country }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<div v-if="values.newZone?.selectedCountries && values.newZone.selectedCountries.length > 0" class="mt-2">
|
|
<div class="flex flex-wrap gap-1 max-h-20 overflow-y-auto">
|
|
<Badge
|
|
v-for="country in (values.newZone?.selectedCountries || [])"
|
|
:key="country"
|
|
variant="secondary"
|
|
class="text-xs"
|
|
>
|
|
{{ country }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<Button
|
|
@click="addNewZone"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="isCreating || !values.newZone?.name || !values.newZone?.selectedCountries?.length"
|
|
>
|
|
<Plus class="w-4 h-4 mr-2" />
|
|
Add Zone
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Display -->
|
|
<div v-if="createError" class="text-sm text-destructive">
|
|
{{ createError }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer inside form for submit to work -->
|
|
<div class="flex-shrink-0 border-t bg-background p-6 pt-4">
|
|
<div class="flex justify-end space-x-2">
|
|
<Button
|
|
type="button"
|
|
@click="$emit('close')"
|
|
variant="outline"
|
|
:disabled="isCreating"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
:disabled="isCreating || !isFormValid"
|
|
>
|
|
<span v-if="isCreating">Creating...</span>
|
|
<span v-else>Create Store</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<!-- Scroll indicator for main form -->
|
|
<div v-if="showScrollIndicator" class="absolute bottom-20 left-0 right-0 flex justify-center pointer-events-none animate-bounce">
|
|
<ChevronDown class="w-5 h-5 text-muted-foreground" />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import { useForm } from 'vee-validate'
|
|
import { toTypedSchema } from '@vee-validate/zod'
|
|
import * as z from 'zod'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Plus, ChevronDown } from 'lucide-vue-next'
|
|
import type { NostrmarketAPI, Zone, CreateStallRequest } from '../services/nostrmarketAPI'
|
|
import { auth } from '@/composables/useAuthService'
|
|
import { useToast } from '@/core/composables/useToast'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
|
|
// Props and emits
|
|
interface Props {
|
|
isOpen: boolean
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<{
|
|
close: []
|
|
created: [stall: any]
|
|
}>()
|
|
|
|
// Services
|
|
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
|
|
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
|
const toast = useToast()
|
|
|
|
// Local state
|
|
const isCreating = ref(false)
|
|
const createError = ref<string | null>(null)
|
|
const availableCurrencies = ref<string[]>(['sat'])
|
|
const availableZones = ref<Zone[]>([])
|
|
const showNewZoneForm = ref(false)
|
|
const showScrollIndicator = ref(false)
|
|
const countries = ref([
|
|
'Free (digital)', 'Worldwide', 'Europe', 'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada',
|
|
'Denmark', 'Finland', 'France', 'Germany', 'Greece', 'Hong Kong', 'Hungary',
|
|
'Ireland', 'Indonesia', 'Israel', 'Italy', 'Japan', 'Kazakhstan', 'Korea',
|
|
'Luxembourg', 'Malaysia', 'Mexico', 'Netherlands', 'New Zealand', 'Norway',
|
|
'Poland', 'Portugal', 'Romania', 'Russia', 'Saudi Arabia', 'Singapore',
|
|
'Spain', 'Sweden', 'Switzerland', 'Thailand', 'Turkey', 'Ukraine',
|
|
'United Kingdom', 'United States', 'Vietnam', 'China'
|
|
])
|
|
|
|
// Form schema
|
|
const formSchema = toTypedSchema(z.object({
|
|
name: z.string().min(1, "Store name is required").max(100, "Store name must be less than 100 characters"),
|
|
description: z.string().max(500, "Description must be less than 500 characters"),
|
|
currency: z.string().min(1, "Currency is required"),
|
|
selectedZones: z.array(z.string()).min(1, "Select at least one shipping zone"),
|
|
newZone: z.object({
|
|
name: z.string().optional(),
|
|
cost: z.number().min(0, "Cost must be 0 or greater").optional(),
|
|
selectedCountries: z.array(z.string()).optional()
|
|
}).optional()
|
|
}))
|
|
|
|
// Form setup with vee-validate
|
|
const form = useForm({
|
|
validationSchema: formSchema,
|
|
initialValues: {
|
|
name: '',
|
|
description: '',
|
|
currency: 'sat',
|
|
selectedZones: [] as string[],
|
|
newZone: {
|
|
name: '',
|
|
cost: 0,
|
|
selectedCountries: [] as string[]
|
|
}
|
|
}
|
|
})
|
|
|
|
// Destructure form methods
|
|
const { setFieldValue, resetForm, values, meta } = form
|
|
|
|
// Form validation computed
|
|
const isFormValid = computed(() => meta.value.valid)
|
|
|
|
// Form submit handler
|
|
const onSubmit = form.handleSubmit(async (values) => {
|
|
await createStore(values)
|
|
})
|
|
|
|
// Methods
|
|
const loadAvailableCurrencies = async () => {
|
|
try {
|
|
const currencies = await nostrmarketAPI.getCurrencies()
|
|
availableCurrencies.value = currencies
|
|
} catch (error) {
|
|
console.error('Failed to load currencies:', error)
|
|
}
|
|
}
|
|
|
|
const loadAvailableZones = async () => {
|
|
const currentUser = auth.currentUser?.value
|
|
if (!currentUser?.wallets?.length) return
|
|
|
|
const inkey = paymentService.getPreferredWalletInvoiceKey()
|
|
if (!inkey) return
|
|
|
|
try {
|
|
const zones = await nostrmarketAPI.getZones(inkey)
|
|
availableZones.value = zones
|
|
|
|
// Auto-select the first available zone to make form valid
|
|
const currentSelectedZones = values.selectedZones || []
|
|
if (zones.length > 0 && Array.isArray(currentSelectedZones) && currentSelectedZones.length === 0) {
|
|
setFieldValue('selectedZones', [zones[0].id])
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load zones:', error)
|
|
}
|
|
}
|
|
|
|
const addNewZone = async () => {
|
|
const currentUser = auth.currentUser?.value
|
|
if (!currentUser?.wallets?.length) {
|
|
toast.error('No wallets available')
|
|
return
|
|
}
|
|
|
|
const newZone = values.newZone
|
|
|
|
if (!newZone?.name || !newZone.selectedCountries?.length || (newZone.cost ?? -1) < 0) {
|
|
toast.error('Please fill in all zone details')
|
|
return
|
|
}
|
|
|
|
const adminKey = paymentService.getPreferredWalletAdminKey()
|
|
if (!adminKey) {
|
|
throw new Error('No wallet admin key available')
|
|
}
|
|
|
|
try {
|
|
const createdZone = await nostrmarketAPI.createZone(
|
|
adminKey,
|
|
{
|
|
name: newZone.name!,
|
|
currency: values.currency!,
|
|
cost: newZone.cost!,
|
|
countries: newZone.selectedCountries!
|
|
}
|
|
)
|
|
|
|
// Add to available zones and select it
|
|
availableZones.value.push(createdZone)
|
|
const currentSelectedZones = values.selectedZones || []
|
|
setFieldValue('selectedZones', [...currentSelectedZones, createdZone.id])
|
|
|
|
// Reset the new zone form
|
|
setFieldValue('newZone', {
|
|
name: '',
|
|
cost: 0,
|
|
selectedCountries: []
|
|
})
|
|
|
|
toast.success(`Zone "${newZone.name}" created successfully`)
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create zone'
|
|
toast.error(`Failed to create zone: ${errorMessage}`)
|
|
}
|
|
}
|
|
|
|
const createStore = async (formData: any) => {
|
|
const currentUser = auth.currentUser?.value
|
|
if (!currentUser?.wallets?.length) {
|
|
toast.error('No wallets available')
|
|
return
|
|
}
|
|
|
|
const { name, description, currency, selectedZones } = formData
|
|
|
|
isCreating.value = true
|
|
createError.value = null
|
|
|
|
try {
|
|
// Get the selected zones data
|
|
const selectedZoneData = availableZones.value.filter(zone =>
|
|
selectedZones.includes(zone.id)
|
|
)
|
|
|
|
const wallet = paymentService.getPreferredWallet()
|
|
if (!wallet) {
|
|
throw new Error('No wallet available')
|
|
}
|
|
|
|
const stallData: CreateStallRequest = {
|
|
name,
|
|
wallet: wallet.id,
|
|
currency,
|
|
shipping_zones: selectedZoneData,
|
|
config: {
|
|
description: description || ''
|
|
}
|
|
}
|
|
|
|
const adminKey = paymentService.getPreferredWalletAdminKey()
|
|
if (!adminKey) {
|
|
throw new Error('No wallet admin key available')
|
|
}
|
|
|
|
const newStall = await nostrmarketAPI.createStall(
|
|
adminKey,
|
|
stallData
|
|
)
|
|
|
|
// Reset form
|
|
resetForm()
|
|
|
|
// Close dialog and emit created event
|
|
emit('created', newStall)
|
|
emit('close')
|
|
|
|
toast.success('Store created successfully! You can now add products.')
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create store'
|
|
console.error('Error creating stall:', error)
|
|
createError.value = errorMessage
|
|
toast.error(`Failed to create store: ${errorMessage}`)
|
|
} finally {
|
|
isCreating.value = false
|
|
}
|
|
}
|
|
|
|
// Check if content is scrollable
|
|
const checkScrollable = () => {
|
|
nextTick(() => {
|
|
const content = document.querySelector('.overflow-y-auto')
|
|
if (content) {
|
|
showScrollIndicator.value = content.scrollHeight > content.clientHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
// Initialize data when dialog opens
|
|
watch(() => props.isOpen, async (isOpen) => {
|
|
if (isOpen) {
|
|
const currentUser = auth.currentUser?.value
|
|
if (!currentUser?.wallets?.length) {
|
|
toast.error('No wallets available')
|
|
return
|
|
}
|
|
|
|
// Reset form to initial state
|
|
resetForm({
|
|
values: {
|
|
name: '',
|
|
description: '',
|
|
currency: 'sat',
|
|
selectedZones: [],
|
|
newZone: {
|
|
name: '',
|
|
cost: 0,
|
|
selectedCountries: []
|
|
}
|
|
}
|
|
})
|
|
|
|
// Wait for reactivity
|
|
await nextTick()
|
|
|
|
// Clear any previous errors
|
|
createError.value = null
|
|
|
|
// Load currencies and zones
|
|
await Promise.all([
|
|
loadAvailableCurrencies(),
|
|
loadAvailableZones()
|
|
])
|
|
|
|
// Check if scrollable after loading
|
|
checkScrollable()
|
|
}
|
|
})
|
|
</script> |