Add shipping zones management to Store Settings
- Added full CRUD for shipping zones in MarketSettings.vue - Added updateZone and deleteZone API methods to nostrmarketAPI.ts - Zone form supports name, cost, and countries/regions - Edit and delete zones with confirmation dialog - Loading states and proper error handling with toast notifications 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4c62daf46c
commit
fb36caa0b2
2 changed files with 331 additions and 15 deletions
|
|
@ -77,19 +77,11 @@
|
|||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Read-only Fields -->
|
||||
<div class="pt-4 border-t space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Currency</label>
|
||||
<div class="text-foreground">{{ currentStall.currency }}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Currency is set when the store is created and cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Shipping Zones</label>
|
||||
<div class="text-foreground">{{ currentStall.shipping_zones?.length || 0 }} zone(s) configured</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Manage shipping zones when creating a new store</p>
|
||||
</div>
|
||||
<!-- Read-only Currency Field -->
|
||||
<div class="pt-4 border-t">
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-1">Currency</label>
|
||||
<div class="text-foreground">{{ currentStall.currency }}</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Currency is set when the store is created and cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
|
|
@ -100,7 +92,148 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zones Section -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">Shipping Zones</h3>
|
||||
<p class="text-sm text-muted-foreground">Configure where you ship and the associated costs</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!showAddZoneForm"
|
||||
@click="showAddZoneForm = true"
|
||||
size="sm"
|
||||
:disabled="isZoneLoading"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Zone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Add Zone Form -->
|
||||
<div v-if="showAddZoneForm" class="mb-6 p-4 bg-muted/50 rounded-lg border">
|
||||
<h4 class="font-medium text-foreground mb-4">{{ editingZone ? 'Edit Zone' : 'Add New Zone' }}</h4>
|
||||
<form @submit.prevent="saveZone" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Zone Name *</label>
|
||||
<Input
|
||||
v-model="zoneForm.name"
|
||||
placeholder="e.g., Domestic, International"
|
||||
:disabled="isZoneSaving"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Shipping Cost *</label>
|
||||
<Input
|
||||
v-model.number="zoneForm.cost"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
:disabled="isZoneSaving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Countries/Regions</label>
|
||||
<Input
|
||||
v-model="zoneForm.countriesInput"
|
||||
placeholder="e.g., USA, Canada, Mexico (comma-separated)"
|
||||
:disabled="isZoneSaving"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">Comma-separated list of countries or regions this zone covers</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" :disabled="isZoneSaving || !isZoneFormValid" size="sm">
|
||||
<span v-if="isZoneSaving">Saving...</span>
|
||||
<span v-else>{{ editingZone ? 'Update Zone' : 'Add Zone' }}</span>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" @click="cancelZoneForm" size="sm" :disabled="isZoneSaving">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Zones List -->
|
||||
<div v-if="isZoneLoading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="zones.length === 0 && !showAddZoneForm" class="text-center py-8 text-muted-foreground">
|
||||
<Truck class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No shipping zones configured</p>
|
||||
<p class="text-sm">Add a shipping zone to enable shipping for your products</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="zone in zones"
|
||||
:key="zone.id"
|
||||
class="flex items-center justify-between p-4 bg-muted/30 rounded-lg border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-foreground">{{ zone.name }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>{{ formatCost(zone.cost) }} {{ zone.currency }}</span>
|
||||
<span v-if="zone.countries?.length" class="ml-2">
|
||||
· {{ zone.countries.join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="editZone(zone)"
|
||||
:disabled="isZoneSaving"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="confirmDeleteZone(zone)"
|
||||
:disabled="isZoneSaving"
|
||||
class="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Zone Confirmation Dialog -->
|
||||
<Dialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Shipping Zone</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{{ zoneToDelete?.name }}"?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteConfirm = false" :disabled="isZoneSaving">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="deleteZone"
|
||||
:disabled="isZoneSaving"
|
||||
variant="destructive"
|
||||
>
|
||||
<span v-if="isZoneSaving">Deleting...</span>
|
||||
<span v-else>Delete</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -120,9 +253,17 @@ import {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Store } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Store, Plus, Pencil, Trash2, Truck } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { NostrmarketAPI, Stall } from '../services/nostrmarketAPI'
|
||||
import type { NostrmarketAPI, Stall, Zone } from '../services/nostrmarketAPI'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
|
|
@ -136,6 +277,26 @@ const isLoading = ref(true)
|
|||
const isSaving = ref(false)
|
||||
const currentStall = ref<Stall | null>(null)
|
||||
|
||||
// Zone state
|
||||
const zones = ref<Zone[]>([])
|
||||
const isZoneLoading = ref(false)
|
||||
const isZoneSaving = ref(false)
|
||||
const showAddZoneForm = ref(false)
|
||||
const editingZone = ref<Zone | null>(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const zoneToDelete = ref<Zone | null>(null)
|
||||
|
||||
// Zone form
|
||||
const zoneForm = ref({
|
||||
name: '',
|
||||
cost: 0,
|
||||
countriesInput: ''
|
||||
})
|
||||
|
||||
const isZoneFormValid = computed(() => {
|
||||
return zoneForm.value.name.trim().length > 0 && zoneForm.value.cost >= 0
|
||||
})
|
||||
|
||||
// Form schema - only fields that exist in the Stall model
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1, "Store name is required").max(100, "Store name must be less than 100 characters"),
|
||||
|
|
@ -156,6 +317,11 @@ const form = useForm({
|
|||
const { resetForm, meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
|
||||
// Format cost for display
|
||||
const formatCost = (cost: number) => {
|
||||
return cost === 0 ? 'Free' : cost.toString()
|
||||
}
|
||||
|
||||
// Load store data
|
||||
const loadStoreData = async () => {
|
||||
const currentUser = auth.currentUser?.value
|
||||
|
|
@ -183,6 +349,9 @@ const loadStoreData = async () => {
|
|||
imageUrl: stalls[0].config?.image_url || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Load zones
|
||||
await loadZones()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load store data:', error)
|
||||
|
|
@ -192,6 +361,22 @@ const loadStoreData = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Load shipping zones
|
||||
const loadZones = async () => {
|
||||
const inkey = paymentService.getPreferredWalletInvoiceKey()
|
||||
if (!inkey) return
|
||||
|
||||
isZoneLoading.value = true
|
||||
try {
|
||||
zones.value = await nostrmarketAPI.getZones(inkey)
|
||||
} catch (error) {
|
||||
console.error('Failed to load zones:', error)
|
||||
toast.error('Failed to load shipping zones')
|
||||
} finally {
|
||||
isZoneLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save store settings
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (!currentStall.value?.id) return
|
||||
|
|
@ -223,6 +408,102 @@ const onSubmit = form.handleSubmit(async (values) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Zone form functions
|
||||
const resetZoneForm = () => {
|
||||
zoneForm.value = {
|
||||
name: '',
|
||||
cost: 0,
|
||||
countriesInput: ''
|
||||
}
|
||||
editingZone.value = null
|
||||
showAddZoneForm.value = false
|
||||
}
|
||||
|
||||
const cancelZoneForm = () => {
|
||||
resetZoneForm()
|
||||
}
|
||||
|
||||
const editZone = (zone: Zone) => {
|
||||
editingZone.value = zone
|
||||
zoneForm.value = {
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
countriesInput: zone.countries?.join(', ') || ''
|
||||
}
|
||||
showAddZoneForm.value = true
|
||||
}
|
||||
|
||||
const saveZone = async () => {
|
||||
const adminKey = paymentService.getPreferredWalletAdminKey()
|
||||
if (!adminKey || !currentStall.value) return
|
||||
|
||||
isZoneSaving.value = true
|
||||
|
||||
try {
|
||||
const countries = zoneForm.value.countriesInput
|
||||
.split(',')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.length > 0)
|
||||
|
||||
const zoneData: Zone = {
|
||||
id: editingZone.value?.id || '',
|
||||
name: zoneForm.value.name.trim(),
|
||||
currency: currentStall.value.currency,
|
||||
cost: zoneForm.value.cost,
|
||||
countries
|
||||
}
|
||||
|
||||
if (editingZone.value) {
|
||||
// Update existing zone
|
||||
await nostrmarketAPI.updateZone(adminKey, editingZone.value.id, zoneData)
|
||||
toast.success('Shipping zone updated!')
|
||||
} else {
|
||||
// Create new zone
|
||||
await nostrmarketAPI.createZone(adminKey, {
|
||||
name: zoneData.name,
|
||||
currency: zoneData.currency,
|
||||
cost: zoneData.cost,
|
||||
countries: zoneData.countries
|
||||
})
|
||||
toast.success('Shipping zone added!')
|
||||
}
|
||||
|
||||
// Reload zones and reset form
|
||||
await loadZones()
|
||||
resetZoneForm()
|
||||
} catch (error) {
|
||||
console.error('Failed to save zone:', error)
|
||||
toast.error(editingZone.value ? 'Failed to update zone' : 'Failed to add zone')
|
||||
} finally {
|
||||
isZoneSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteZone = (zone: Zone) => {
|
||||
zoneToDelete.value = zone
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const deleteZone = async () => {
|
||||
const adminKey = paymentService.getPreferredWalletAdminKey()
|
||||
if (!adminKey || !zoneToDelete.value) return
|
||||
|
||||
isZoneSaving.value = true
|
||||
|
||||
try {
|
||||
await nostrmarketAPI.deleteZone(adminKey, zoneToDelete.value.id)
|
||||
toast.success('Shipping zone deleted!')
|
||||
await loadZones()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete zone:', error)
|
||||
toast.error('Failed to delete zone')
|
||||
} finally {
|
||||
isZoneSaving.value = false
|
||||
showDeleteConfirm.value = false
|
||||
zoneToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for auth changes
|
||||
watch(() => auth.currentUser?.value?.pubkey, async (newPubkey, oldPubkey) => {
|
||||
if (newPubkey !== oldPubkey) {
|
||||
|
|
|
|||
|
|
@ -398,6 +398,41 @@ export class NostrmarketAPI extends BaseService {
|
|||
return zone
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing shipping zone
|
||||
*/
|
||||
async updateZone(
|
||||
walletAdminkey: string,
|
||||
zoneId: string,
|
||||
zoneData: Zone
|
||||
): Promise<Zone> {
|
||||
const zone = await this.request<Zone>(
|
||||
`/api/v1/zone/${zoneId}`,
|
||||
walletAdminkey,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(zoneData),
|
||||
}
|
||||
)
|
||||
|
||||
this.debug('Updated zone:', { zoneId: zone.id, zoneName: zone.name })
|
||||
|
||||
return zone
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a shipping zone
|
||||
*/
|
||||
async deleteZone(walletAdminkey: string, zoneId: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
`/api/v1/zone/${zoneId}`,
|
||||
walletAdminkey,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
this.debug('Deleted zone:', { zoneId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available currencies from the LNbits core API
|
||||
* This endpoint returns currencies allowed by the server configuration
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue