diff --git a/models.py b/models.py index f1af073..58842d5 100644 --- a/models.py +++ b/models.py @@ -33,8 +33,13 @@ class Nostrable: class MerchantProfile(BaseModel): name: str | None = None + display_name: str | None = None about: str | None = None picture: str | None = None + banner: str | None = None + website: str | None = None + nip05: str | None = None + lud16: str | None = None class MerchantConfig(MerchantProfile): @@ -86,11 +91,23 @@ class Merchant(PartialMerchant, Nostrable): return merchant def to_nostr_event(self, pubkey: str) -> NostrEvent: - content = { - "name": self.config.name, - "about": self.config.about, - "picture": self.config.picture, - } + content: dict[str, str] = {} + if self.config.name: + content["name"] = self.config.name + 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( pubkey=pubkey, created_at=round(time.time()), diff --git a/services.py b/services.py index 4039dbb..b978e39 100644 --- a/services.py +++ b/services.py @@ -149,9 +149,8 @@ async def update_merchant_to_nostr( stall.event_id = event.id stall.event_created_at = event.created_at await update_stall(merchant.id, stall) - if delete_merchant: - # merchant profile updates not supported yet - event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) + # Always publish merchant profile (kind 0) + event = await sign_and_send_to_nostr(merchant, merchant, delete_merchant) assert event merchant.config.event_id = event.id return merchant diff --git a/static/components/edit-profile-dialog.js b/static/components/edit-profile-dialog.js new file mode 100644 index 0000000..0736695 --- /dev/null +++ b/static/components/edit-profile-dialog.js @@ -0,0 +1,91 @@ +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 + ) + // Publish to Nostr + await LNbits.api.request( + 'PUT', + `/nostrmarket/api/v1/merchant/${this.merchantId}/nostr`, + this.adminkey + ) + this.show = false + this.$q.notify({ + type: 'positive', + message: 'Profile saved and published to Nostr!' + }) + 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() + } + } + } +}) diff --git a/static/components/key-pair.js b/static/components/key-pair.js deleted file mode 100644 index 5bf9d23..0000000 --- a/static/components/key-pair.js +++ /dev/null @@ -1,22 +0,0 @@ -window.app.component('key-pair', { - name: 'key-pair', - template: '#key-pair', - delimiters: ['${', '}'], - props: ['public-key', 'private-key'], - data: function () { - return { - showPrivateKey: false - } - }, - methods: { - copyText: function (text, message, position) { - var notify = this.$q.notify - Quasar.copyToClipboard(text).then(function () { - notify({ - message: message || 'Copied to clipboard!', - position: position || 'bottom' - }) - }) - } - } -}) diff --git a/static/components/merchant-tab.js b/static/components/merchant-tab.js index c82d853..1194420 100644 --- a/static/components/merchant-tab.js +++ b/static/components/merchant-tab.js @@ -10,14 +10,46 @@ window.app.component('merchant-tab', { 'merchant-active', 'public-key', 'private-key', - 'is-admin' + 'is-admin', + 'merchant-config' ], + emits: [ + 'toggle-show-keys', + 'hide-keys', + 'merchant-deleted', + 'toggle-merchant-state', + 'restart-nostr-connection', + 'profile-updated', + 'import-key', + 'generate-key' + ], + data: function () { + return { + showEditProfileDialog: false, + showKeysDialog: false + } + }, computed: { marketClientUrl: function () { return '/nostrmarket/market' } }, 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 () { this.$emit('toggle-show-keys') }, @@ -27,11 +59,40 @@ window.app.component('merchant-tab', { handleMerchantDeleted: function () { this.$emit('merchant-deleted') }, + removeMerchant: function () { + const name = + this.merchantConfig?.display_name || + this.merchantConfig?.name || + 'this merchant' + LNbits.utils + .confirmDialog( + `Are you sure you want to remove "${name}"? This will delete all associated data (stalls, products, orders, messages).` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + `/nostrmarket/api/v1/merchant/${this.merchantId}`, + this.adminkey + ) + this.$emit('merchant-deleted') + this.$q.notify({ + type: 'positive', + message: 'Merchant removed' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, toggleMerchantState: function () { this.$emit('toggle-merchant-state') }, restartNostrConnection: function () { this.$emit('restart-nostr-connection') + }, + handleImageError: function (e) { + e.target.style.display = 'none' } } }) diff --git a/static/components/nostr-keys-dialog.js b/static/components/nostr-keys-dialog.js new file mode 100644 index 0000000..81c451f --- /dev/null +++ b/static/components/nostr-keys-dialog.js @@ -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 + } + } + } +}) diff --git a/static/js/index.js b/static/js/index.js index 00560df..e2421c2 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -19,6 +19,13 @@ window.app = Vue.createApp({ privateKey: null } }, + generateKeyDialog: { + show: false, + privateKey: null, + nsec: null, + npub: null, + showNsec: false + }, wsConnection: null, nostrStatus: { connected: false, @@ -42,9 +49,18 @@ window.app = Vue.createApp({ } }, methods: { - generateKeys: async function () { + generateKeys: function () { const privateKey = nostr.generatePrivateKey() - await this.createMerchant(privateKey) + const publicKey = nostr.getPublicKey(privateKey) + this.generateKeyDialog.privateKey = privateKey + this.generateKeyDialog.nsec = nostr.nip19.nsecEncode(privateKey) + this.generateKeyDialog.npub = nostr.nip19.npubEncode(publicKey) + this.generateKeyDialog.showNsec = false + this.generateKeyDialog.show = true + }, + confirmGenerateKey: async function () { + this.generateKeyDialog.show = false + await this.createMerchant(this.generateKeyDialog.privateKey) }, importKeys: async function () { this.importKeyDialog.show = false @@ -56,11 +72,21 @@ window.app = Vue.createApp({ if (privateKey.toLowerCase().startsWith('nsec')) { privateKey = nostr.nip19.decode(privateKey).data } + // Check if this key is already in use + const publicKey = nostr.getPublicKey(privateKey) + if (this.merchant?.public_key === publicKey) { + this.$q.notify({ + type: 'warning', + message: 'This key is already your current profile' + }) + return + } } catch (error) { this.$q.notify({ type: 'negative', message: `${error}` }) + return } await this.createMerchant(privateKey) }, diff --git a/templates/nostrmarket/components/edit-profile-dialog.html b/templates/nostrmarket/components/edit-profile-dialog.html new file mode 100644 index 0000000..a447237 --- /dev/null +++ b/templates/nostrmarket/components/edit-profile-dialog.html @@ -0,0 +1,68 @@ + + + +
Edit Profile
+ + + + + + + + +
+ Save & Publish + Cancel +
+
+
+
diff --git a/templates/nostrmarket/components/key-pair.html b/templates/nostrmarket/components/key-pair.html deleted file mode 100644 index 911e057..0000000 --- a/templates/nostrmarket/components/key-pair.html +++ /dev/null @@ -1,93 +0,0 @@ -
- - - -
-
Keys
- -
- - -
- -
- - -
Public Key
-
- - - -
-
- ... -
- -
-
-
- - -
- - -
- - Private Key (Keep Secret!) -
-
- - - -
-
- ... -
- -
-
-
-
-
diff --git a/templates/nostrmarket/components/merchant-tab.html b/templates/nostrmarket/components/merchant-tab.html index 41caca1..3d932d2 100644 --- a/templates/nostrmarket/components/merchant-tab.html +++ b/templates/nostrmarket/components/merchant-tab.html @@ -1,56 +1,277 @@
-
-
- -
-
-
- - + + +
+
+ Merchant Profile +
-
-
-
-
-
+
Hide KeysEdit + + Show Keys + + + + Saved Profiles + + + + + + + + + + + + + Remove profile + + + + + + + + + + Import Existing Key + Use an existing nsec + + + + + + + + Generate New Key + Create a fresh nsec + + + + + + + + + + + + Accepting Orders + Orders Paused + + New orders will be processed + + + New orders will be ignored + + + + + + + + + + Pause Orders + Resume Orders + + Stop accepting new orders + + + Start accepting new orders + + + + +
+ + + +
+ +
-
-
- + + + +
+ +
+ + + + +
+ + +
+
+
+
+ +
+
+ (No display name set) +
+
+ +
+
+
+ 0 Following + Not implemented yet +
+
+ 0 Followers + Not implemented yet +
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + + New Post (Coming soon) +
-
+ + + +
+ +
Coming Soon
+
+ Merchant posts will appear here +
+
+
+
+ + + + + +
diff --git a/templates/nostrmarket/components/nostr-keys-dialog.html b/templates/nostrmarket/components/nostr-keys-dialog.html new file mode 100644 index 0000000..dde7069 --- /dev/null +++ b/templates/nostrmarket/components/nostr-keys-dialog.html @@ -0,0 +1,75 @@ + + + +
Nostr Keys
+
+ + +
+ +
+ + +
+ + Public Key (npub) +
+ + + + + +
+ + Private Key (nsec) +
+ + + +
+ + Never share your private key! +
+
+ + + +
+
diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index c3ec16d..8f4e7b4 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -53,11 +53,15 @@ :public-key="merchant.public_key" :private-key="merchant.private_key" :is-admin="g.user.admin" + :merchant-config="merchant.config" @toggle-show-keys="toggleShowKeys" @hide-keys="showKeys = false" @merchant-deleted="handleMerchantDeleted" @toggle-merchant-state="toggleMerchantState" @restart-nostr-connection="restartNostrConnection" + @import-key="showImportKeysDialog" + @generate-key="generateKeys" + @profile-updated="getMerchant" > @@ -410,6 +414,63 @@
+ + + + +
Generate New Key
+
+
Public Key (npub)
+ + + +
+
+
+ + Private Key (nsec) +
+ + + +
+ + Never share your private key! +
+
+
+ Create Merchant + Cancel +
+
+
{% endblock%}{% block scripts %} {{ window_vars(user) }} @@ -427,10 +488,23 @@ margin-left: auto; width: 100%; } + + .profile-avatar { + border: 3px solid var(--q-dark-page); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .profile-avatar .q-avatar__content { + overflow: hidden; + border-radius: 50%; + } -{% include("nostrmarket/components/nostr-keys-dialog.html") %} + - + + diff --git a/views_api.py b/views_api.py index af675cd..1e9f5c5 100644 --- a/views_api.py +++ b/views_api.py @@ -62,6 +62,7 @@ from .models import ( DirectMessage, DirectMessageType, Merchant, + MerchantConfig, Order, OrderReissue, OrderStatusUpdate, @@ -97,9 +98,6 @@ async def api_create_merchant( merchant = await get_merchant_by_pubkey(data.public_key) assert merchant is None, "A merchant already uses this public key" - merchant = await get_merchant_for_user(wallet.wallet.user) - assert merchant is None, "A merchant already exists for this user" - merchant = await create_merchant(wallet.wallet.user, data) await create_zone( @@ -192,6 +190,35 @@ async def api_delete_merchant( 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") async def api_republish_merchant( merchant_id: str,