Connect Store Settings to actual store data via API
- Rewrote MarketSettings.vue to use real Stall model fields - Added updateStall API method to nostrmarketAPI.ts - Editable fields: name, description, imageUrl - Read-only fields: currency, shipping zones (set at creation) - Form validation with Zod schema - Loading states and proper error handling 🤖 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
39a7dc2096
commit
4c62daf46c
2 changed files with 228 additions and 294 deletions
|
|
@ -2,331 +2,237 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Market Settings</h2>
|
||||
<p class="text-muted-foreground mt-1">Configure your store and market preferences</p>
|
||||
<h2 class="text-2xl font-bold text-foreground">Store Settings</h2>
|
||||
<p class="text-muted-foreground mt-1">Configure your store information</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tabs -->
|
||||
<div class="border-b border-border">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.id"
|
||||
@click="activeSettingsTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeSettingsTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Content -->
|
||||
<div class="min-h-[500px]">
|
||||
<!-- Store Settings Tab -->
|
||||
<div v-if="activeSettingsTab === 'store'" class="space-y-6">
|
||||
<!-- No Store State -->
|
||||
<div v-else-if="!currentStall" class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Store class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No Store Found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Create a store first to manage its settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Store Settings Form -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Store Information -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Name</label>
|
||||
<Input v-model="storeSettings.name" placeholder="Enter store name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Description</label>
|
||||
<Input v-model="storeSettings.description" placeholder="Enter store description" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Contact Email</label>
|
||||
<Input v-model="storeSettings.contactEmail" type="email" placeholder="Enter contact email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Category</label>
|
||||
<select v-model="storeSettings.category" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">Select category</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
<option value="books">Books</option>
|
||||
<option value="food">Food & Beverages</option>
|
||||
<option value="services">Services</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveStoreSettings" variant="default">
|
||||
Save Store Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'payment'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Default Currency</label>
|
||||
<select v-model="paymentSettings.defaultCurrency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
<option value="eur">Euro (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Invoice Expiry (minutes)</label>
|
||||
<Input v-model="paymentSettings.invoiceExpiry" type="number" min="5" max="1440" placeholder="60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Auto-generate Invoices</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="paymentSettings.autoGenerateInvoices"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-input rounded"
|
||||
<form @submit="onSubmit" class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Store Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter store name"
|
||||
:disabled="isSaving"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Automatically generate Lightning invoices for new orders
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="savePaymentSettings" variant="default">
|
||||
Save Payment Settings
|
||||
</Button>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your store and what you sell"
|
||||
:disabled="isSaving"
|
||||
v-bind="componentField"
|
||||
rows="3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This description will be shown to customers browsing your store
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="imageUrl">
|
||||
<FormItem>
|
||||
<FormLabel>Store Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/store-image.jpg"
|
||||
:disabled="isSaving"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional image to represent your store
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'nostr'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Nostr Network Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Relay Connections</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="relay in nostrSettings.relays" :key="relay" class="flex items-center gap-2">
|
||||
<Input :value="relay" readonly class="flex-1" />
|
||||
<Button @click="removeRelay(relay)" variant="outline" size="sm">
|
||||
<X class="w-4 h-4" />
|
||||
<div class="pt-4">
|
||||
<Button type="submit" :disabled="isSaving || !isFormValid">
|
||||
<span v-if="isSaving">Saving...</span>
|
||||
<span v-else>Save Changes</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="newRelay" placeholder="wss://relay.example.com" class="flex-1" />
|
||||
<Button @click="addRelay" variant="outline">
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Nostr Public Key</label>
|
||||
<Input :value="nostrSettings.pubkey" readonly class="font-mono text-sm" />
|
||||
<p class="text-xs text-muted-foreground mt-1">Your Nostr public key for receiving orders</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Connection Status</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Connected to Nostr network' : 'Connecting to Nostr network...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveNostrSettings" variant="default">
|
||||
Save Nostr Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'shipping'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Zones</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="zone in shippingSettings.zones" :key="zone.id" class="border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Zone Name</label>
|
||||
<Input v-model="zone.name" placeholder="Zone name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Cost</label>
|
||||
<Input v-model="zone.cost" type="number" min="0" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Currency</label>
|
||||
<select v-model="zone.currency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Button @click="removeShippingZone(zone.id)" variant="outline" size="sm">
|
||||
Remove Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addShippingZone" variant="outline">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Shipping Zone
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveShippingSettings" variant="default">
|
||||
Save Shipping Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
|
||||
import { ref, computed, onMounted, watch } 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 { Input } from '@/components/ui/input'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Store } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { NostrmarketAPI, Stall } from '../services/nostrmarketAPI'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// const marketStore = useMarketStore()
|
||||
// const orderEvents = useOrderEvents() // TODO: Move to market module
|
||||
const orderEvents = { isSubscribed: ref(false) } // Temporary mock
|
||||
// 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 activeSettingsTab = ref('store')
|
||||
const newRelay = ref('')
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const currentStall = ref<Stall | null>(null)
|
||||
|
||||
// Settings data
|
||||
const storeSettings = ref({
|
||||
name: 'My Store',
|
||||
description: 'A great place to shop',
|
||||
contactEmail: 'store@example.com',
|
||||
category: 'other'
|
||||
// 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"),
|
||||
description: z.string().max(500, "Description must be less than 500 characters").optional(),
|
||||
imageUrl: z.string().url("Must be a valid URL").optional().or(z.literal(''))
|
||||
}))
|
||||
|
||||
// Form setup
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
imageUrl: ''
|
||||
}
|
||||
})
|
||||
|
||||
const paymentSettings = ref({
|
||||
defaultCurrency: 'sat',
|
||||
invoiceExpiry: 60,
|
||||
autoGenerateInvoices: true
|
||||
const { resetForm, meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
|
||||
// Load store data
|
||||
const loadStoreData = async () => {
|
||||
const currentUser = auth.currentUser?.value
|
||||
if (!currentUser?.wallets?.length) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const inkey = paymentService.getPreferredWalletInvoiceKey()
|
||||
if (!inkey) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stalls = await nostrmarketAPI.getStalls(inkey)
|
||||
if (stalls && stalls.length > 0) {
|
||||
currentStall.value = stalls[0]
|
||||
|
||||
// Update form with current values
|
||||
resetForm({
|
||||
values: {
|
||||
name: stalls[0].name || '',
|
||||
description: stalls[0].config?.description || '',
|
||||
imageUrl: stalls[0].config?.image_url || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load store data:', error)
|
||||
toast.error('Failed to load store settings')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save store settings
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (!currentStall.value?.id) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const adminKey = paymentService.getPreferredWalletAdminKey()
|
||||
if (!adminKey) {
|
||||
throw new Error('No wallet admin key available')
|
||||
}
|
||||
|
||||
// Update the stall with new values
|
||||
const updatedStall = await nostrmarketAPI.updateStall(adminKey, currentStall.value.id, {
|
||||
name: values.name,
|
||||
config: {
|
||||
description: values.description || '',
|
||||
image_url: values.imageUrl || undefined
|
||||
}
|
||||
})
|
||||
|
||||
currentStall.value = updatedStall
|
||||
toast.success('Store settings saved successfully!')
|
||||
} catch (error) {
|
||||
console.error('Failed to save store settings:', error)
|
||||
toast.error('Failed to save store settings')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const nostrSettings = ref({
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
],
|
||||
pubkey: 'npub1...' // TODO: Get from auth
|
||||
// Watch for auth changes
|
||||
watch(() => auth.currentUser?.value?.pubkey, async (newPubkey, oldPubkey) => {
|
||||
if (newPubkey !== oldPubkey) {
|
||||
isLoading.value = true
|
||||
await loadStoreData()
|
||||
}
|
||||
})
|
||||
|
||||
const shippingSettings = ref({
|
||||
zones: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Local',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '1-2 days'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Domestic',
|
||||
cost: 1000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'International',
|
||||
cost: 5000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '7-14 days'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Settings tabs
|
||||
const settingsTabs = [
|
||||
{ id: 'store', name: 'Store Settings' },
|
||||
{ id: 'payment', name: 'Payment Settings' },
|
||||
{ id: 'nostr', name: 'Nostr Network' },
|
||||
{ id: 'shipping', name: 'Shipping Zones' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const saveStoreSettings = () => {
|
||||
// TODO: Save store settings
|
||||
console.log('Saving store settings:', storeSettings.value)
|
||||
}
|
||||
|
||||
const savePaymentSettings = () => {
|
||||
// TODO: Save payment settings
|
||||
console.log('Saving payment settings:', paymentSettings.value)
|
||||
}
|
||||
|
||||
const saveNostrSettings = () => {
|
||||
// TODO: Save Nostr settings
|
||||
console.log('Saving Nostr settings:', nostrSettings.value)
|
||||
}
|
||||
|
||||
const saveShippingSettings = () => {
|
||||
// TODO: Save shipping settings
|
||||
console.log('Saving shipping settings:', shippingSettings.value)
|
||||
}
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.value && !nostrSettings.value.relays.includes(newRelay.value)) {
|
||||
nostrSettings.value.relays.push(newRelay.value)
|
||||
newRelay.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeRelay = (relay: string) => {
|
||||
const index = nostrSettings.value.relays.indexOf(relay)
|
||||
if (index > -1) {
|
||||
nostrSettings.value.relays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addShippingZone = () => {
|
||||
const newZone = {
|
||||
id: Date.now().toString(),
|
||||
name: 'New Zone',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
}
|
||||
shippingSettings.value.zones.push(newZone)
|
||||
}
|
||||
|
||||
const removeShippingZone = (zoneId: string) => {
|
||||
const index = shippingSettings.value.zones.findIndex(z => z.id === zoneId)
|
||||
if (index > -1) {
|
||||
shippingSettings.value.zones.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Settings component loaded')
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
await loadStoreData()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -329,6 +329,34 @@ export class NostrmarketAPI extends BaseService {
|
|||
return stall
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing stall
|
||||
*/
|
||||
async updateStall(
|
||||
walletAdminkey: string,
|
||||
stallId: string,
|
||||
stallData: Partial<{
|
||||
name: string
|
||||
config: {
|
||||
description?: string
|
||||
image_url?: string
|
||||
}
|
||||
}>
|
||||
): Promise<Stall> {
|
||||
const stall = await this.request<Stall>(
|
||||
`/api/v1/stall/${stallId}`,
|
||||
walletAdminkey,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(stallData),
|
||||
}
|
||||
)
|
||||
|
||||
this.debug('Updated stall:', { stallId: stall.id, stallName: stall.name })
|
||||
|
||||
return stall
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available shipping zones
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue