feat: add merchant profile edit, keys dialog, and publish to Nostr

- Add PATCH endpoint for updating merchant profile config
- Add website field to MerchantProfile model
- Fix to_nostr_event to include all profile fields (display_name, banner, website, nip05, lud16)
- Always publish merchant profile (kind 0) when publishing to Nostr
- Extract edit-profile-dialog and nostr-keys-dialog into separate components
- Fix profile avatar alignment to match original design
- Simplify keys dialog to show only npub QR code (no nsec QR)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ben Weeks 2025-12-24 12:42:03 +00:00
parent c3dea9f01d
commit a21b5289c1
12 changed files with 635 additions and 109 deletions

View file

@ -37,6 +37,7 @@ class MerchantProfile(BaseModel):
about: str | None = None about: str | None = None
picture: str | None = None picture: str | None = None
banner: str | None = None banner: str | None = None
website: str | None = None
nip05: str | None = None nip05: str | None = None
lud16: str | None = None lud16: str | None = None
@ -90,11 +91,23 @@ class Merchant(PartialMerchant, Nostrable):
return merchant return merchant
def to_nostr_event(self, pubkey: str) -> NostrEvent: def to_nostr_event(self, pubkey: str) -> NostrEvent:
content = { content: dict[str, str] = {}
"name": self.config.name, if self.config.name:
"about": self.config.about, content["name"] = self.config.name
"picture": self.config.picture, if self.config.display_name:
} content["display_name"] = self.config.display_name
if self.config.about:
content["about"] = self.config.about
if self.config.picture:
content["picture"] = self.config.picture
if self.config.banner:
content["banner"] = self.config.banner
if self.config.website:
content["website"] = self.config.website
if self.config.nip05:
content["nip05"] = self.config.nip05
if self.config.lud16:
content["lud16"] = self.config.lud16
event = NostrEvent( event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=round(time.time()), created_at=round(time.time()),

View file

@ -149,8 +149,7 @@ async def update_merchant_to_nostr(
stall.event_id = event.id stall.event_id = event.id
stall.event_created_at = event.created_at stall.event_created_at = event.created_at
await update_stall(merchant.id, stall) await update_stall(merchant.id, stall)
if delete_merchant: # Always publish merchant profile (kind 0)
# merchant profile updates not supported yet
event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant)
assert event assert event
merchant.config.event_id = event.id merchant.config.event_id = event.id

View file

@ -0,0 +1,85 @@
window.app.component('edit-profile-dialog', {
name: 'edit-profile-dialog',
template: '#edit-profile-dialog',
delimiters: ['${', '}'],
props: ['model-value', 'merchant-id', 'merchant-config', 'adminkey'],
emits: ['update:model-value', 'profile-updated'],
data: function () {
return {
saving: false,
formData: {
name: '',
display_name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
lud16: ''
}
}
},
computed: {
show: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:model-value', value)
}
}
},
methods: {
saveProfile: async function () {
this.saving = true
try {
const config = {
...this.merchantConfig,
name: this.formData.name || null,
display_name: this.formData.display_name || null,
about: this.formData.about || null,
picture: this.formData.picture || null,
banner: this.formData.banner || null,
website: this.formData.website || null,
nip05: this.formData.nip05 || null,
lud16: this.formData.lud16 || null
}
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/merchant/${this.merchantId}`,
this.adminkey,
config
)
this.show = false
this.$q.notify({
type: 'positive',
message: 'Profile updated!'
})
this.$emit('profile-updated')
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.saving = false
}
},
loadFormData: function () {
if (this.merchantConfig) {
this.formData.name = this.merchantConfig.name || ''
this.formData.display_name = this.merchantConfig.display_name || ''
this.formData.about = this.merchantConfig.about || ''
this.formData.picture = this.merchantConfig.picture || ''
this.formData.banner = this.merchantConfig.banner || ''
this.formData.website = this.merchantConfig.website || ''
this.formData.nip05 = this.merchantConfig.nip05 || ''
this.formData.lud16 = this.merchantConfig.lud16 || ''
}
}
},
watch: {
modelValue(newVal) {
if (newVal) {
this.loadFormData()
}
}
}
})

View file

@ -1,11 +0,0 @@
window.app.component('key-pair', {
name: 'key-pair',
template: '#key-pair',
delimiters: ['${', '}'],
props: ['public-key', 'private-key', 'merchant-config'],
methods: {
handleImageError: function (event) {
event.target.style.display = 'none'
}
}
})

View file

@ -13,12 +13,41 @@ window.app.component('merchant-tab', {
'is-admin', 'is-admin',
'merchant-config' 'merchant-config'
], ],
emits: [
'toggle-show-keys',
'hide-keys',
'merchant-deleted',
'toggle-merchant-state',
'restart-nostr-connection',
'profile-updated'
],
data: function () {
return {
showEditProfileDialog: false,
showKeysDialog: false
}
},
computed: { computed: {
marketClientUrl: function () { marketClientUrl: function () {
return '/nostrmarket/market' return '/nostrmarket/market'
} }
}, },
methods: { methods: {
publishProfile: async function () {
try {
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`,
this.adminkey
)
this.$q.notify({
type: 'positive',
message: 'Profile published to Nostr!'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
toggleShowKeys: function () { toggleShowKeys: function () {
this.$emit('toggle-show-keys') this.$emit('toggle-show-keys')
}, },
@ -33,6 +62,9 @@ window.app.component('merchant-tab', {
}, },
restartNostrConnection: function () { restartNostrConnection: function () {
this.$emit('restart-nostr-connection') this.$emit('restart-nostr-connection')
},
handleImageError: function (e) {
e.target.style.display = 'none'
} }
} }
}) })

View file

@ -0,0 +1,56 @@
window.app.component('nostr-keys-dialog', {
name: 'nostr-keys-dialog',
template: '#nostr-keys-dialog',
delimiters: ['${', '}'],
props: ['public-key', 'private-key', 'model-value'],
emits: ['update:model-value'],
data: function () {
return {
showNsec: false
}
},
computed: {
show: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:model-value', value)
}
},
npub: function () {
if (!this.publicKey) return ''
try {
return window.NostrTools.nip19.npubEncode(this.publicKey)
} catch (e) {
return this.publicKey
}
},
nsec: function () {
if (!this.privateKey) return ''
try {
return window.NostrTools.nip19.nsecEncode(this.privateKey)
} catch (e) {
return this.privateKey
}
}
},
methods: {
copyText: function (text, message) {
var notify = this.$q.notify
Quasar.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: 'bottom'
})
})
}
},
watch: {
modelValue(newVal) {
if (!newVal) {
this.showNsec = false
}
}
}
})

View file

@ -0,0 +1,63 @@
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveProfile" class="q-gutter-md">
<div class="text-h6 q-mb-md">Edit Profile</div>
<q-input
filled
dense
v-model.trim="formData.name"
label="Name (username)"
></q-input>
<q-input
filled
dense
v-model.trim="formData.display_name"
label="Display Name"
></q-input>
<q-input
filled
dense
v-model.trim="formData.about"
label="About"
type="textarea"
rows="3"
></q-input>
<q-input
filled
dense
v-model.trim="formData.picture"
label="Profile Picture URL"
></q-input>
<q-input
filled
dense
v-model.trim="formData.banner"
label="Banner Image URL"
></q-input>
<q-input
filled
dense
v-model.trim="formData.website"
label="Website"
></q-input>
<q-input
filled
dense
v-model.trim="formData.nip05"
label="NIP-05 Identifier"
></q-input>
<q-input
filled
dense
v-model.trim="formData.lud16"
label="Lightning Address (lud16)"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="saving"
>Save</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>

View file

@ -1,82 +0,0 @@
<q-card v-if="publicKey" flat bordered>
<!-- Banner Section -->
<div
v-if="merchantConfig && merchantConfig.banner"
class="banner-container"
:style="{
height: '120px',
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: '4px 4px 0 0',
backgroundImage: 'url(' + merchantConfig.banner + ')'
}"
></div>
<div
v-else
class="banner-placeholder bg-grey-9"
style="height: 120px; border-radius: 4px 4px 0 0"
></div>
<!-- Profile Section -->
<q-card-section class="q-pt-none" style="margin-top: -50px">
<div class="row">
<!-- Profile Image -->
<div class="col-auto">
<q-avatar size="100px" class="shadow-2">
<img
v-if="merchantConfig && merchantConfig.picture"
:src="merchantConfig.picture"
@error="handleImageError"
/>
<q-icon
v-else
name="person"
size="60px"
color="grey-5"
style="background: var(--q-dark); border-radius: 50%"
/>
</q-avatar>
</div>
<!-- Name, About and NIP-05 -->
<div class="col q-pl-md" style="padding-top: 50px">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-if="merchantConfig && (merchantConfig.display_name || merchantConfig.name)"
>
<span
v-text="merchantConfig.display_name || merchantConfig.name"
></span>
</div>
<div class="text-caption text-grey" v-else>(No name set)</div>
</div>
</div>
<div
class="text-body2 text-grey q-mt-xs"
v-if="merchantConfig && merchantConfig.about"
style="max-width: 400px"
>
<span v-text="merchantConfig.about"></span>
</div>
<div class="row q-mt-xs q-gutter-sm">
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.nip05"
>
<q-icon name="verified" color="primary" size="14px"></q-icon>
<span v-text="merchantConfig.nip05" class="q-ml-xs"></span>
</div>
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.lud16"
>
<q-icon name="bolt" color="warning" size="14px"></q-icon>
<span v-text="merchantConfig.lud16" class="q-ml-xs"></span>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>

View file

@ -1,11 +1,255 @@
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<key-pair <q-card v-if="publicKey" flat bordered>
:public-key="publicKey" <q-card-section>
:private-key="privateKey" <div class="row items-center no-wrap q-mb-md">
:merchant-config="merchantConfig" <div class="col">
></key-pair> <span class="text-subtitle1">Merchant Profile</span>
</div>
</div>
<div class="row q-mb-md q-gutter-sm">
<q-btn
outline
color="primary"
@click="showEditProfileDialog = true"
icon="edit"
>Edit</q-btn
>
<q-btn
outline
color="primary"
icon="qr_code"
@click="showKeysDialog = true"
>
<q-tooltip>Show Keys</q-tooltip>
</q-btn>
<q-btn
outline
color="primary"
@click="publishProfile"
icon="publish"
>Publish</q-btn
>
<q-btn-dropdown
split
outline
color="primary"
icon="swap_horiz"
label="Switch"
>
<q-list>
<q-item-label header>Saved Profiles</q-item-label>
<q-item clickable v-close-popup>
<q-item-section avatar>
<q-icon name="check" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label
><span
v-text="merchantConfig?.name || 'Current Profile'"
></span
></q-item-label>
<q-item-label
caption
class="text-mono"
style="font-size: 10px"
>
<span
v-text="publicKey ? publicKey.slice(0, 16) + '...' : ''"
></span>
</q-item-label>
</q-item-section>
</q-item>
<q-item disable>
<q-item-section>
<q-item-label caption class="text-grey-6">
Multi-profile support coming soon
</q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="$emit('import-key')">
<q-item-section avatar>
<q-icon name="add" color="primary"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Add New Profile</q-item-label>
<q-item-label caption>Import a different nsec</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn-dropdown
split
outline
:color="merchantActive ? 'positive' : 'negative'"
:icon="merchantActive ? 'shopping_cart' : 'pause_circle'"
:label="merchantActive ? 'Orders On' : 'Orders Off'"
@click="toggleMerchantState"
>
<q-list>
<q-item>
<q-item-section avatar>
<q-icon
:name="merchantActive ? 'check_circle' : 'pause_circle'"
:color="merchantActive ? 'positive' : 'negative'"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label v-if="merchantActive"
>Accepting Orders</q-item-label
>
<q-item-label v-else>Orders Paused</q-item-label>
<q-item-label caption v-if="merchantActive">
New orders will be processed
</q-item-label>
<q-item-label caption v-else>
New orders will be ignored
</q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="toggleMerchantState">
<q-item-section avatar>
<q-icon
:name="merchantActive ? 'pause_circle' : 'play_circle'"
:color="merchantActive ? 'negative' : 'positive'"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label v-if="merchantActive"
>Pause Orders</q-item-label
>
<q-item-label v-else>Resume Orders</q-item-label>
<q-item-label caption v-if="merchantActive">
Stop accepting new orders
</q-item-label>
<q-item-label caption v-else>
Start accepting new orders
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
</q-card-section>
<!-- Banner Section -->
<div class="q-px-md">
<div
v-if="merchantConfig && merchantConfig.banner"
class="banner-container rounded-borders"
:style="{
height: '120px',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundImage: 'url(' + merchantConfig.banner + ')'
}"
></div>
<div
v-else
class="banner-placeholder bg-grey-3 text-center rounded-borders"
style="height: 120px"
></div>
</div>
<!-- Profile Section -->
<q-card-section class="q-pt-none" style="margin-top: -50px">
<div class="row">
<!-- Profile Image -->
<div class="col-auto">
<q-avatar
size="100px"
style="border: 4px solid white; background: white"
class="flex flex-center"
>
<img
v-if="merchantConfig && merchantConfig.picture"
:src="merchantConfig.picture"
@error="handleImageError"
/>
<q-icon
v-else
name="person"
size="60px"
color="grey-5"
></q-icon>
</q-avatar>
</div>
<!-- Name, About and NIP-05 -->
<div class="col q-pl-md" style="padding-top: 55px">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-if="merchantConfig && merchantConfig.display_name"
>
<span v-text="merchantConfig.display_name"></span>
</div>
<div class="text-caption text-grey" v-else>
(No display name set)
</div>
</div>
<div class="col-auto q-mr-sm">
<div class="row q-gutter-md">
<div class="text-caption text-grey-6">
<span class="text-weight-bold">0</span> Following
<q-tooltip>Not implemented yet</q-tooltip>
</div>
<div class="text-caption text-grey-6">
<span class="text-weight-bold">0</span> Followers
<q-tooltip>Not implemented yet</q-tooltip>
</div>
</div>
</div>
</div>
<div
class="text-body2 text-grey-8 q-mt-xs"
v-if="merchantConfig && merchantConfig.about"
style="max-width: 400px"
>
<span v-text="merchantConfig.about"></span>
</div>
<div class="row q-mt-xs q-gutter-sm">
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.nip05"
>
<q-icon name="verified" color="primary" size="14px"></q-icon>
<span v-text="merchantConfig.nip05" class="q-ml-xs"></span>
</div>
<div
class="text-caption text-grey-5"
v-if="merchantConfig && merchantConfig.lud16"
>
<q-icon name="bolt" color="warning" size="14px"></q-icon>
<span v-text="merchantConfig.lud16" class="q-ml-xs"></span>
</div>
</div>
</div>
</div>
</q-card-section>
<div class="row items-center q-px-md">
<q-separator class="col"></q-separator>
<q-btn fab icon="add" color="primary" class="q-ml-md" disable>
<q-tooltip>New Post (Coming soon)</q-tooltip>
</q-btn>
</div>
<!-- Feed Section (Not Implemented) -->
<q-card-section>
<div class="text-center q-pa-lg" style="opacity: 0.5">
<q-icon name="chat" size="48px" class="q-mb-sm text-grey"></q-icon>
<div class="text-subtitle2 text-grey">Coming Soon</div>
<div class="text-caption text-grey">
Merchant posts will appear here
</div>
</div>
</q-card-section>
</q-card>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<q-card flat bordered> <q-card flat bordered>
@ -43,4 +287,20 @@
</q-card> </q-card>
</div> </div>
</div> </div>
<!-- Edit Profile Dialog -->
<edit-profile-dialog
v-model="showEditProfileDialog"
:merchant-id="merchantId"
:merchant-config="merchantConfig"
:adminkey="adminkey"
@profile-updated="$emit('profile-updated')"
></edit-profile-dialog>
<!-- Nostr Keys Dialog -->
<nostr-keys-dialog
v-model="showKeysDialog"
:public-key="publicKey"
:private-key="privateKey"
></nostr-keys-dialog>
</div> </div>

View file

@ -0,0 +1,75 @@
<q-dialog v-model="show">
<q-card style="min-width: 400px; max-width: 450px">
<q-card-section>
<div class="text-h6">Nostr Keys</div>
</q-card-section>
<q-card-section>
<!-- QR Code for npub only -->
<div class="q-mx-auto q-mb-md text-center" style="max-width: 200px">
<lnbits-qrcode
:value="npub"
:options="{ width: 200 }"
:show-buttons="false"
class="rounded-borders"
></lnbits-qrcode>
</div>
<!-- Public Key (npub) -->
<div class="text-subtitle2 q-mb-xs">
<q-icon name="public" class="q-mr-xs"></q-icon>
Public Key (npub)
</div>
<q-input :model-value="npub" readonly dense outlined class="q-mb-md">
<template v-slot:append>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(npub, 'npub copied!')"
>
<q-tooltip>Copy npub</q-tooltip>
</q-btn>
</template>
</q-input>
<!-- Private Key (nsec) -->
<div class="text-subtitle2 q-mb-xs text-warning">
<q-icon name="warning" class="q-mr-xs"></q-icon>
Private Key (nsec)
</div>
<q-input
:model-value="showNsec ? nsec : '••••••••••••••••••••••••••••••••••••••••••••••'"
readonly
dense
outlined
class="q-mb-xs"
>
<template v-slot:append>
<q-btn
flat
dense
:icon="showNsec ? 'visibility_off' : 'visibility'"
@click="showNsec = !showNsec"
>
<q-tooltip v-text="showNsec ? 'Hide' : 'Show'"></q-tooltip>
</q-btn>
<q-btn
flat
dense
icon="content_copy"
@click="copyText(nsec, 'nsec copied! Keep it secret!')"
>
<q-tooltip>Copy nsec</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption text-negative">
<q-icon name="error" size="14px"></q-icon>
Never share your private key!
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn flat label="CLOSE" color="grey" v-close-popup></q-btn>
</q-card-actions>
</q-card>
</q-dialog>

View file

@ -158,6 +158,8 @@
@merchant-deleted="handleMerchantDeleted" @merchant-deleted="handleMerchantDeleted"
@toggle-merchant-state="toggleMerchantState" @toggle-merchant-state="toggleMerchantState"
@restart-nostr-connection="restartNostrConnection" @restart-nostr-connection="restartNostrConnection"
@import-key="showImportKeysDialog"
@profile-updated="getMerchant"
></merchant-tab> ></merchant-tab>
</q-tab-panel> </q-tab-panel>
@ -424,8 +426,11 @@
} }
</style> </style>
<template id="key-pair" <template id="nostr-keys-dialog"
>{% include("nostrmarket/components/key-pair.html") %}</template >{% include("nostrmarket/components/nostr-keys-dialog.html") %}</template
>
<template id="edit-profile-dialog"
>{% include("nostrmarket/components/edit-profile-dialog.html") %}</template
> >
<template id="shipping-zones" <template id="shipping-zones"
>{% include("nostrmarket/components/shipping-zones.html") %}</template >{% include("nostrmarket/components/shipping-zones.html") %}</template
@ -459,7 +464,8 @@
<script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/utils.js') }}"></script>
<script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script> <script src="{{ url_for('nostrmarket_static', path='js/index.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/key-pair.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/nostr-keys-dialog.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/edit-profile-dialog.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/shipping-zones.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-details.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/stall-details.js') }}"></script>
<script src="{{ static_url_for('nostrmarket/static', 'components/stall-list.js') }}"></script> <script src="{{ static_url_for('nostrmarket/static', 'components/stall-list.js') }}"></script>

View file

@ -62,6 +62,7 @@ from .models import (
DirectMessage, DirectMessage,
DirectMessageType, DirectMessageType,
Merchant, Merchant,
MerchantConfig,
Order, Order,
OrderReissue, OrderReissue,
OrderStatusUpdate, OrderStatusUpdate,
@ -192,6 +193,35 @@ async def api_delete_merchant(
await subscribe_to_all_merchants() await subscribe_to_all_merchants()
@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
async def api_update_merchant(
merchant_id: str,
config: MerchantConfig,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
updated_merchant = await update_merchant(
wallet.wallet.user, merchant_id, config
)
return updated_merchant
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update merchant",
) from ex
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr") @nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
async def api_republish_merchant( async def api_republish_merchant(
merchant_id: str, merchant_id: str,