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:
padreug 2026-01-06 19:04:24 +01:00
parent 4c62daf46c
commit fb36caa0b2
2 changed files with 331 additions and 15 deletions

View file

@ -77,21 +77,13 @@
</FormItem>
</FormField>
<!-- Read-only Fields -->
<div class="pt-4 border-t space-y-4">
<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>
<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>
</div>
<div class="pt-4">
<Button type="submit" :disabled="isSaving || !isFormValid">
<span v-if="isSaving">Saving...</span>
@ -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) {

View file

@ -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