diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5bc81d0ac..31a3c9883 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -157,16 +157,16 @@ import { SanitizeVideoUrlPipe } from "../lib/pipes/sanitize-video-url-pipe"; import { AdminNodeFeesComponent } from "./admin/admin-node-fees/admin-node-fees.component"; import { AdminNodeAddFeesComponent } from "./admin/admin-node-fees/admin-node-add-fee/admin-node-add-fees.component"; import { FreeDesoMessageComponent } from "./free-deso-message/free-deso-message.component"; +import { SupplyMonitoringStatsPageComponent } from "./supply-monitoring-stats-page/supply-monitoring-stats-page.component"; +import { SupplyMonitoringStatsComponent } from "./supply-monitoring-stats-page/supply-monitoring-stats/supply-monitoring-stats.component"; +import { TransferNftModalComponent } from "./transfer-nft-modal/transfer-nft-modal.component"; +import { TransferNftAcceptModalComponent } from "./transfer-nft-accept-modal/transfer-nft-accept-modal.component"; +import { NftBurnModalComponent } from "./nft-burn-modal/nft-burn-modal.component"; +import { NftSelectSerialNumberComponent } from "./nft-select-serial-number/nft-select-serial-number.component"; // Modular Themes for DeSo by Carsen Klock @carsenk import { ThemeModule } from "./theme/theme.module"; import { Theme } from "./theme/symbols"; -import { - SupplyMonitoringStatsPageComponent -} from "./supply-monitoring-stats-page/supply-monitoring-stats-page.component"; -import { - SupplyMonitoringStatsComponent -} from "./supply-monitoring-stats-page/supply-monitoring-stats/supply-monitoring-stats.component"; const lightTheme: Theme = { key: "light", name: "Light Theme" }; const darkTheme: Theme = { key: "dark", name: "Dark Theme" }; const icydarkTheme: Theme = { key: "icydark", name: "Icy Dark Theme" }; @@ -311,6 +311,10 @@ const greenishTheme: Theme = { key: "greenish", name: "Green Theme" }; SupplyMonitoringStatsPageComponent, SupplyMonitoringStatsComponent, FreeDesoMessageComponent, + TransferNftAcceptModalComponent, + TransferNftModalComponent, + NftBurnModalComponent, + NftSelectSerialNumberComponent, ], imports: [ BrowserModule, diff --git a/src/app/backend-api.service.ts b/src/app/backend-api.service.ts index 56e822972..7d2bca4a4 100644 --- a/src/app/backend-api.service.ts +++ b/src/app/backend-api.service.ts @@ -84,6 +84,9 @@ export class BackendRoutes { static RoutePathGetNextNFTShowcase = "/api/v0/get-next-nft-showcase"; static RoutePathGetNFTCollectionSummary = "/api/v0/get-nft-collection-summary"; static RoutePathGetNFTEntriesForPostHash = "/api/v0/get-nft-entries-for-nft-post"; + static RoutePathTransferNFT = "/api/v0/transfer-nft"; + static RoutePathAcceptNFTTransfer = "/api/v0/accept-nft-transfer"; + static RoutePathBurnNFT = "/api/v0/burn-nft"; // ETH static RoutePathSubmitETHTx = "/api/v0/submit-eth-tx"; @@ -154,7 +157,7 @@ export class BackendRoutes { // Supply Monitoring endpoints static RoutePathGetTotalSupply = "/api/v0/total-supply"; static RoutePathGetRichList = "/api/v0/rich-list"; - static RoutePathGetCountKeysWithDESO = "/api/v0/count-keys-with-deso" + static RoutePathGetCountKeysWithDESO = "/api/v0/count-keys-with-deso"; } export class Transaction { @@ -334,6 +337,7 @@ export class NFTEntryResponse { PostEntryResponse: PostEntryResponse | undefined; SerialNumber: number; IsForSale: boolean; + IsPending?: boolean; MinBidAmountNanos: number; LastAcceptedBidAmountNanos: number; @@ -977,6 +981,75 @@ export class BackendApiService { return this.signAndSubmitTransaction(endpoint, request, UpdaterPublicKeyBase58Check); } + TransferNFT( + endpoint: string, + SenderPublicKeyBase58Check: string, + ReceiverPublicKeyBase58Check: string, + NFTPostHashHex: string, + SerialNumber: number, + UnencryptedUnlockableText: string, + MinFeeRateNanosPerKB: number + ): Observable { + let request = UnencryptedUnlockableText + ? this.identityService.encrypt({ + ...this.identityService.identityServiceParamsForKey(SenderPublicKeyBase58Check), + recipientPublicKey: ReceiverPublicKeyBase58Check, + message: UnencryptedUnlockableText, + }) + : of({ encryptedMessage: "" }); + request = request.pipe( + switchMap((encrypted) => { + const EncryptedUnlockableText = encrypted.encryptedMessage; + return this.post(endpoint, BackendRoutes.RoutePathTransferNFT, { + SenderPublicKeyBase58Check, + ReceiverPublicKeyBase58Check, + NFTPostHashHex, + SerialNumber, + EncryptedUnlockableText, + MinFeeRateNanosPerKB, + }).pipe( + map((request) => { + return { ...request }; + }) + ); + }) + ); + + return this.signAndSubmitTransaction(endpoint, request, SenderPublicKeyBase58Check); + } + + AcceptNFTTransfer( + endpoint: string, + UpdaterPublicKeyBase58Check: string, + NFTPostHashHex: string, + SerialNumber: number, + MinFeeRateNanosPerKB: number + ): Observable { + const request = this.post(endpoint, BackendRoutes.RoutePathAcceptNFTTransfer, { + UpdaterPublicKeyBase58Check, + NFTPostHashHex, + SerialNumber, + MinFeeRateNanosPerKB, + }); + return this.signAndSubmitTransaction(endpoint, request, UpdaterPublicKeyBase58Check); + } + + BurnNFT( + endpoint: string, + UpdaterPublicKeyBase58Check: string, + NFTPostHashHex: string, + SerialNumber: number, + MinFeeRateNanosPerKB: number + ): Observable { + const request = this.post(endpoint, BackendRoutes.RoutePathBurnNFT, { + UpdaterPublicKeyBase58Check, + NFTPostHashHex, + SerialNumber, + MinFeeRateNanosPerKB, + }); + return this.signAndSubmitTransaction(endpoint, request, UpdaterPublicKeyBase58Check); + } + DecryptUnlockableTexts( ReaderPublicKeyBase58Check: string, UnlockableNFTEntryResponses: NFTEntryResponse[] @@ -1016,12 +1089,14 @@ export class BackendApiService { endpoint: string, UserPublicKeyBase58Check: string, ReaderPublicKeyBase58Check: string, - IsForSale: boolean | null = null + IsForSale: boolean | null = null, + IsPending: boolean | null = null ): Observable { return this.post(endpoint, BackendRoutes.RoutePathGetNFTsForUser, { UserPublicKeyBase58Check, ReaderPublicKeyBase58Check, IsForSale, + IsPending, }); } diff --git a/src/app/close-nft-auction-modal/close-nft-auction-modal.component.ts b/src/app/close-nft-auction-modal/close-nft-auction-modal.component.ts index 7aed6b9cd..8e1de3f59 100644 --- a/src/app/close-nft-auction-modal/close-nft-auction-modal.component.ts +++ b/src/app/close-nft-auction-modal/close-nft-auction-modal.component.ts @@ -52,8 +52,8 @@ export class CloseNftAuctionModalComponent { .subscribe( (res) => { // Hide this modal and open the next one. - this.bsModalRef.hide(); this.modalService.setDismissReason("auction cancelled"); + this.bsModalRef.hide(); }, (err) => { console.error(err); diff --git a/src/app/create-nft-auction-modal/create-nft-auction-modal.component.ts b/src/app/create-nft-auction-modal/create-nft-auction-modal.component.ts index d8276f2ce..bcfc19a0a 100644 --- a/src/app/create-nft-auction-modal/create-nft-auction-modal.component.ts +++ b/src/app/create-nft-auction-modal/create-nft-auction-modal.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from "@angular/core"; -import { BsModalRef } from "ngx-bootstrap/modal"; +import { BsModalRef, BsModalService } from "ngx-bootstrap/modal"; import { GlobalVarsService } from "../global-vars.service"; import { BackendApiService, NFTEntryResponse, PostEntryResponse } from "../backend-api.service"; import { concatMap, last, map } from "rxjs/operators"; @@ -25,6 +25,7 @@ export class CreateNftAuctionModalComponent { private backendApi: BackendApiService, public globalVars: GlobalVarsService, public bsModalRef: BsModalRef, + private modalService: BsModalService, private router: Router ) {} @@ -70,6 +71,7 @@ export class CreateNftAuctionModalComponent { .subscribe( (res) => { this.router.navigate(["/" + this.globalVars.RouteNames.NFT + "/" + this.post.PostHashHex]); + this.modalService.setDismissReason("auction created"); this.bsModalRef.hide(); }, (err) => { @@ -84,6 +86,7 @@ export class CreateNftAuctionModalComponent { return this.nftEntryResponses.filter( (nftEntryResponse) => !nftEntryResponse.IsForSale && + !nftEntryResponse.IsPending && nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser?.PublicKeyBase58Check ); } diff --git a/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.html b/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.html index 86a64226c..2fa9d9cf6 100644 --- a/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.html +++ b/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.html @@ -46,6 +46,32 @@ +
+
+ No transferable NFTs right now. + + @{{ profile.Username }} has no transferable NFTs yet. + +
+
+
+
+ No pending NFTs right now. + + @{{ profile.Username }} has no pending NFTs yet. + +
+
NFTs that @{{ profile.Username }} is currently selling
+
+ NFTs that @{{ profile.Username }} can transfer +
+
+ Transferred NFTs pending acceptance by @{{ profile.Username }} +
diff --git a/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.ts b/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.ts index 403777c2c..ed43b4a71 100644 --- a/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.ts +++ b/src/app/creator-profile-page/creator-profile-nfts/creator-profile-nfts.component.ts @@ -39,6 +39,8 @@ export class CreatorProfileNftsComponent implements OnInit { static FOR_SALE = "For Sale"; static MY_BIDS = "My Bids"; static MY_GALLERY = "Gallery"; + static TRANSFERABLE = "Transferable"; + static MY_PENDING_TRANSFERS = "Pending Transfers"; tabs = [CreatorProfileNftsComponent.FOR_SALE, CreatorProfileNftsComponent.MY_GALLERY]; activeTab: string; @@ -46,12 +48,16 @@ export class CreatorProfileNftsComponent implements OnInit { my_bids: CreatorProfileNftsComponent.MY_BIDS, for_sale: CreatorProfileNftsComponent.FOR_SALE, my_gallery: CreatorProfileNftsComponent.MY_GALLERY, + transferable: CreatorProfileNftsComponent.TRANSFERABLE, + my_pending_transfers: CreatorProfileNftsComponent.MY_PENDING_TRANSFERS, }; nftTabInverseMap = { [CreatorProfileNftsComponent.FOR_SALE]: "for_sale", [CreatorProfileNftsComponent.MY_BIDS]: "my_bids", [CreatorProfileNftsComponent.MY_GALLERY]: "my_gallery", + [CreatorProfileNftsComponent.TRANSFERABLE]: "transferable", + [CreatorProfileNftsComponent.MY_PENDING_TRANSFERS]: "my_pending_transfers", }; CreatorProfileNftsComponent = CreatorProfileNftsComponent; @@ -68,13 +74,19 @@ export class CreatorProfileNftsComponent implements OnInit { ) {} ngOnInit(): void { - if (this.globalVars.loggedInUser?.PublicKeyBase58Check === this.profile.PublicKeyBase58Check) { - this.tabs.push(CreatorProfileNftsComponent.MY_BIDS); + if (this.profileBelongsToLoggedInUser()) { + this.tabs.push( + CreatorProfileNftsComponent.MY_BIDS, + CreatorProfileNftsComponent.MY_PENDING_TRANSFERS, + CreatorProfileNftsComponent.TRANSFERABLE + ); } this.route.queryParams.subscribe((queryParams) => { if (queryParams.nftTab && queryParams.nftTab in this.nftTabMap) { if ( - queryParams.nftTab === this.nftTabInverseMap[CreatorProfileNftsComponent.MY_BIDS] && + (queryParams.nftTab === this.nftTabInverseMap[CreatorProfileNftsComponent.MY_BIDS] || + queryParams.nftTab === this.nftTabInverseMap[CreatorProfileNftsComponent.TRANSFERABLE] || + queryParams.nftTab === this.nftTabInverseMap[CreatorProfileNftsComponent.MY_PENDING_TRANSFERS]) && this.globalVars.loggedInUser?.PublicKeyBase58Check !== this.profile.PublicKeyBase58Check ) { this.updateNFTTabParam(CreatorProfileNftsComponent.MY_GALLERY); @@ -121,13 +133,14 @@ export class CreatorProfileNftsComponent implements OnInit { ); } - getNFTs(isForSale: boolean | null = null): Subscription { + getNFTs(isForSale: boolean | null = null, isPending: boolean | null = null): Subscription { return this.backendApi .GetNFTsForUser( this.globalVars.localNode, this.profile.PublicKeyBase58Check, this.globalVars.loggedInUser?.PublicKeyBase58Check, - isForSale + isForSale, + isPending ) .subscribe( (res: { @@ -136,13 +149,16 @@ export class CreatorProfileNftsComponent implements OnInit { this.nftResponse = []; for (const k in res.NFTsMap) { const responseElement = res.NFTsMap[k]; + // Exclude NFTs created by profile from Gallery and don't show pending NFTs in galley. if ( - (this.activeTab === CreatorProfileNftsComponent.MY_GALLERY && - responseElement.PostEntryResponse.PosterPublicKeyBase58Check !== this.profile.PublicKeyBase58Check) || - this.activeTab === CreatorProfileNftsComponent.FOR_SALE + this.activeTab === CreatorProfileNftsComponent.MY_GALLERY && + (responseElement.PostEntryResponse.PosterPublicKeyBase58Check === this.profile.PublicKeyBase58Check || + responseElement.NFTEntryResponses.filter((nftEntryResponse) => !nftEntryResponse.IsPending).length === + 0) ) { - this.nftResponse.push(responseElement); + continue; } + this.nftResponse.push(responseElement); } this.lastPage = Math.floor(this.nftResponse.length / CreatorProfileNftsComponent.PAGE_SIZE); return this.nftResponse; @@ -218,7 +234,7 @@ export class CreatorProfileNftsComponent implements OnInit { this.resetDatasource(event); }); } else { - return this.getNFTs(this.getIsForSaleValue()).add(() => { + return this.getNFTs(this.getIsForSaleValue(), this.getIsPendingValue()).add(() => { this.resetDatasource(event); }); } @@ -290,6 +306,25 @@ export class CreatorProfileNftsComponent implements OnInit { } getIsForSaleValue(): boolean | null { - return this.activeTab === CreatorProfileNftsComponent.MY_GALLERY ? null : true; + if (this.activeTab === CreatorProfileNftsComponent.FOR_SALE) { + return true; + } else if (this.activeTab === CreatorProfileNftsComponent.TRANSFERABLE) { + return false; + } else { + return null; + } + } + + getIsPendingValue(): boolean | null { + if (this.activeTab === CreatorProfileNftsComponent.MY_PENDING_TRANSFERS) { + return true; + } else if ( + this.activeTab === CreatorProfileNftsComponent.MY_GALLERY || + this.activeTab === CreatorProfileNftsComponent.TRANSFERABLE + ) { + return false; + } else { + return null; + } } } diff --git a/src/app/feed/feed-post-dropdown/feed-post-dropdown.component.html b/src/app/feed/feed-post-dropdown/feed-post-dropdown.component.html index c05ab82e4..f0347584b 100644 --- a/src/app/feed/feed-post-dropdown/feed-post-dropdown.component.html +++ b/src/app/feed/feed-post-dropdown/feed-post-dropdown.component.html @@ -12,7 +12,7 @@ (click)="copyPostLinkToClipboard($event)" > - Link to Post + Link to {{ post.IsNFT ? "NFT" : "Post" }} - Share Post + Share {{ post.IsNFT ? "NFT" : "Post" }} + Put On Sale + + + Transfer NFT + + + + Burn NFT + + !nftEntryResponse.IsPending && + !nftEntryResponse.IsForSale && + nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser?.PublicKeyBase58Check + )?.length + ); + } + + showBurnNFT(): boolean { + return ( + this.post.IsNFT && + !!this.nftEntryResponses?.filter( + (nftEntryResponse) => + !nftEntryResponse.IsForSale && + nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser?.PublicKeyBase58Check + )?.length + ); + } + hidePost() { this.postHidden.emit(); } @@ -228,9 +255,47 @@ export class FeedPostDropdownComponent { } openCreateNFTAuctionModal(event): void { - this.modalService.show(CreateNftAuctionModalComponent, { + const modalDetails = this.modalService.show(CreateNftAuctionModalComponent, { class: "modal-dialog-centered", initialState: { post: this.post, nftEntryResponses: this.nftEntryResponses }, }); + const onHideEvent = modalDetails.onHide; + onHideEvent.subscribe((response) => { + if (response === "auction created") { + this.refreshNFTEntries.emit(); + } + }); + } + + openTransferNFTModal(event): void { + const modalDetails = this.modalService.show(TransferNftModalComponent, { + class: "modal-dialog-centered modal-lg", + initialState: { post: this.post, postHashHex: this.post.PostHashHex }, + }); + const onHideEvent = modalDetails.onHide; + onHideEvent.subscribe((response) => { + if (response === "nft transferred") { + this.refreshNFTEntries.emit(); + } + }); + } + + openBurnNFTModal(event): void { + const burnNFTEntryResponses = filter(this.nftEntryResponses, (nftEntryResponse: NFTEntryResponse) => { + return ( + !nftEntryResponse.IsForSale && + nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser?.PublicKeyBase58Check + ); + }); + const modalDetails = this.modalService.show(NftBurnModalComponent, { + class: "modal-dialog-centered modal-lg", + initialState: { post: this.post, postHashHex: this.post.PostHashHex, burnNFTEntryResponses }, + }); + const onHideEvent = modalDetails.onHide; + onHideEvent.subscribe((response) => { + if (response === "nft burned") { + this.refreshNFTEntries.emit(); + } + }); } } diff --git a/src/app/feed/feed-post/feed-post.component.html b/src/app/feed/feed-post/feed-post.component.html index d9eb98979..c01bf4a7c 100755 --- a/src/app/feed/feed-post/feed-post.component.html +++ b/src/app/feed/feed-post/feed-post.component.html @@ -105,6 +105,7 @@ (postHidden)="hidePost()" (userBlocked)="blockUser()" (toggleGlobalFeed)="_addPostToGlobalFeed()" + (refreshNFTEntries)="refreshNFTEntriesHandler()" >
@@ -225,6 +226,7 @@ (userBlocked)="blockUser()" (toggleGlobalFeed)="_addPostToGlobalFeed($event)" (togglePostPin)="_pinPostToGlobalFeed($event)" + (refreshNFTEntries)="refreshNFTEntriesHandler()" >
@@ -539,6 +541,16 @@ > Place a bid +
diff --git a/src/app/feed/feed-post/feed-post.component.ts b/src/app/feed/feed-post/feed-post.component.ts index 932594a4e..147d8a947 100755 --- a/src/app/feed/feed-post/feed-post.component.ts +++ b/src/app/feed/feed-post/feed-post.component.ts @@ -16,6 +16,7 @@ import { PlaceBidModalComponent } from "../../place-bid-modal/place-bid-modal.co import { EmbedUrlParserService } from "../../../lib/services/embed-url-parser-service/embed-url-parser-service"; import { SharedDialogs } from "../../../lib/shared-dialogs"; import { environment } from "src/environments/environment"; +import { TransferNftAcceptModalComponent } from "../../transfer-nft-accept-modal/transfer-nft-accept-modal.component"; @Component({ selector: "feed-post", @@ -111,6 +112,9 @@ export class FeedPostComponent implements OnInit { @Input() inTutorial: boolean = false; + // If this is a pending NFT post that still needs to be accepted by the user + @Input() acceptNFT: boolean = false; + // emits the PostEntryResponse @Output() postDeleted = new EventEmitter(); @@ -123,6 +127,9 @@ export class FeedPostComponent implements OnInit { // emits diamondSent event @Output() diamondSent = new EventEmitter(); + // Emit tells parent component to refresh NFT data + @Output() refreshNFTEntries = new EventEmitter(); + AppRoutingModule = AppRoutingModule; addingPostToGlobalFeed = false; repost: any; @@ -206,6 +213,11 @@ export class FeedPostComponent implements OnInit { }); } + refreshNFTEntriesHandler() { + this.getNFTEntries(); + this.refreshNFTEntries.emit(); + } + ngOnInit() { if (!this.post.RepostCount) { this.post.RepostCount = 0; @@ -547,6 +559,30 @@ export class FeedPostComponent implements OnInit { return imgURL; } + acceptTransfer(event) { + event.stopPropagation(); + const transferNFTEntryResponses = _.filter(this.nftEntryResponses, (nftEntryResponse: NFTEntryResponse) => { + return ( + nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser.PublicKeyBase58Check && + nftEntryResponse.IsPending + ); + }); + + const modalDetails = this.modalService.show(TransferNftAcceptModalComponent, { + class: "modal-dialog-centered modal-lg", + initialState: { + post: this.postContent, + transferNFTEntryResponses, + }, + }); + const onHideEvent = modalDetails.onHide; + onHideEvent.subscribe((response) => { + if (response === "transfer accepted") { + this.getNFTEntries(); + } + }); + } + openPlaceBidModal(event: any) { if (!this.globalVars.loggedInUser?.ProfileEntryResponse) { SharedDialogs.showCreateProfileToPerformActionDialog(this.router, "place a bid"); diff --git a/src/app/nft-burn-modal/nft-burn-modal.component.html b/src/app/nft-burn-modal/nft-burn-modal.component.html new file mode 100644 index 000000000..7df8e3057 --- /dev/null +++ b/src/app/nft-burn-modal/nft-burn-modal.component.html @@ -0,0 +1,103 @@ + diff --git a/src/app/nft-burn-modal/nft-burn-modal.component.ts b/src/app/nft-burn-modal/nft-burn-modal.component.ts new file mode 100644 index 000000000..c0184d3c6 --- /dev/null +++ b/src/app/nft-burn-modal/nft-burn-modal.component.ts @@ -0,0 +1,151 @@ +import { Component, OnInit, Input, Output, EventEmitter } from "@angular/core"; +import { GlobalVarsService } from "../global-vars.service"; +import { BackendApiService, NFTEntryResponse, PostEntryResponse } from "../backend-api.service"; +import { Router } from "@angular/router"; +import { isNumber } from "lodash"; +import { ToastrService } from "ngx-toastr"; +import {BsModalRef, BsModalService} from "ngx-bootstrap/modal"; +import { Location } from "@angular/common"; +import { SwalHelper } from "../../lib/helpers/swal-helper"; + +@Component({ + selector: "nft-burn-modal", + templateUrl: "./nft-burn-modal.component.html", +}) +export class NftBurnModalComponent implements OnInit { + static PAGE_SIZE = 50; + static BUFFER_SIZE = 10; + static WINDOW_VIEWPORT = false; + static PADDING = 0.5; + + @Input() postHashHex: string; + @Input() post: PostEntryResponse; + @Input() burnNFTEntryResponses: NFTEntryResponse[]; + @Output() closeModal = new EventEmitter(); + @Output() changeTitle = new EventEmitter(); + + bidAmountDeSo: number; + bidAmountUSD: number; + selectedSerialNumber: NFTEntryResponse = null; + availableCount: number; + availableSerialNumbers: NFTEntryResponse[]; + filteredSerialNumbers: NFTEntryResponse[]; + highBid: number = null; + lowBid: number = null; + loading = true; + isSelectingSerialNumber = true; + saveSelectionDisabled = false; + showSelectedSerialNumbers = false; + burningNft: boolean = false; + errors: string[] = []; + minBidCurrency: string = "USD"; + minBidInput: number = 0; + transferringUser: string; + + constructor( + public globalVars: GlobalVarsService, + private backendApi: BackendApiService, + private modalService: BsModalService, + private router: Router, + private toastr: ToastrService, + private location: Location, + public bsModalRef: BsModalRef + ) {} + + ngOnInit(): void { + this.backendApi + .GetNFTCollectionSummary( + this.globalVars.localNode, + this.globalVars.loggedInUser.PublicKeyBase58Check, + this.post.PostHashHex + ) + .subscribe((res) => { + this.availableSerialNumbers = Object.values(res.SerialNumberToNFTEntryResponse); + this.availableCount = res.NFTCollectionResponse.PostEntryResponse.NumNFTCopiesForSale; + this.filteredSerialNumbers = this.burnNFTEntryResponses; + }) + .add(() => (this.loading = false)); + } + + burnNft() { + this.saveSelectionDisabled = true; + this.burningNft = true; + SwalHelper.fire({ + target: this.globalVars.getTargetComponentSelector(), + title: "Burn NFT", + html: `You are about to burn this NFT - this cannot be undone. Are you sure?`, + showConfirmButton: true, + showCancelButton: true, + reverseButtons: true, + customClass: { + confirmButton: "btn btn-light", + cancelButton: "btn btn-light no", + }, + confirmButtonText: "Ok", + cancelButtonText: "Cancel", + }).then((res) => { + if (res.isConfirmed) { + this.backendApi + .BurnNFT( + this.globalVars.localNode, + this.globalVars.loggedInUser.PublicKeyBase58Check, + this.post.PostHashHex, + this.selectedSerialNumber.SerialNumber, + this.globalVars.defaultFeeRateNanosPerKB + ) + .subscribe( + (res) => { + this.modalService.setDismissReason("nft burned"); + this.bsModalRef.hide(); + this.toastr.show("Your nft was burned", null, { + toastClass: "info-toast", + positionClass: "toast-bottom-center", + }); + }, + (err) => { + console.error(err); + } + ) + .add(() => { + this.burningNft = false; + this.saveSelectionDisabled = false; + }); + } else { + this.burningNft = false; + this.saveSelectionDisabled = false; + } + }); + } + + saveSelection(): void { + if (!this.saveSelectionDisabled) { + this.isSelectingSerialNumber = false; + this.showSelectedSerialNumbers = true; + this.changeTitle.emit("Confirm Transfer"); + this.highBid = this.selectedSerialNumber.HighestBidAmountNanos; + this.lowBid = this.selectedSerialNumber.LowestBidAmountNanos; + } + } + + goBackToSerialSelection(): void { + this.isSelectingSerialNumber = true; + this.showSelectedSerialNumbers = false; + this.changeTitle.emit("Choose an edition"); + this.highBid = null; + this.lowBid = null; + this.selectedSerialNumber = null; + } + + selectSerialNumber(serialNumber: NFTEntryResponse) { + this.selectedSerialNumber = serialNumber; + this.saveSelection(); + } + + bidAmountUSDFormatted() { + return isNumber(this.bidAmountUSD) ? `~${this.globalVars.formatUSD(this.bidAmountUSD, 0)}` : ""; + } + + bidAmountDeSoFormatted() { + return isNumber(this.bidAmountDeSo) ? `~${this.bidAmountDeSo.toFixed(2)} $DESO` : ""; + } +} diff --git a/src/app/nft-post-page/nft-post/nft-post.component.html b/src/app/nft-post-page/nft-post/nft-post.component.html index e6780c6fe..1f6add424 100644 --- a/src/app/nft-post-page/nft-post/nft-post.component.html +++ b/src/app/nft-post-page/nft-post/nft-post.component.html @@ -11,8 +11,8 @@
- -
+ +
@@ -215,6 +216,7 @@
+
Pending
{{ globalVars.nanosToUSD(owner.ProfileEntryResponse.CoinPriceDeSoNanos, 2) }}
diff --git a/src/app/nft-post-page/nft-post/nft-post.component.ts b/src/app/nft-post-page/nft-post/nft-post.component.ts index 8710dadfa..54a79068e 100644 --- a/src/app/nft-post-page/nft-post/nft-post.component.ts +++ b/src/app/nft-post-page/nft-post/nft-post.component.ts @@ -102,6 +102,7 @@ export class NftPostComponent { } refreshPosts() { + this.loading = true; // Fetch the post entry this.getPost().subscribe( (res) => { @@ -160,6 +161,9 @@ export class NftPostComponent { if (!this.nftBidData.BidEntryResponses) { this.nftBidData.BidEntryResponses = []; } + if (!this.nftBidData.NFTEntryResponses) { + this.nftBidData.NFTEntryResponses = []; + } this.availableSerialNumbers = this.nftBidData.NFTEntryResponses.filter( (nftEntryResponse) => nftEntryResponse.IsForSale ); diff --git a/src/app/nft-select-serial-number/nft-select-serial-number.component.html b/src/app/nft-select-serial-number/nft-select-serial-number.component.html new file mode 100644 index 000000000..c2b08d2ac --- /dev/null +++ b/src/app/nft-select-serial-number/nft-select-serial-number.component.html @@ -0,0 +1,52 @@ +
+
+ Number + +
+
+ Highest Bid + +
+
+ Min Bid Amount + +
+
+
+
+
+ + #{{ nft.SerialNumber }} +
+
+
+
{{ globalVars.nanosToDeSo(nft.HighestBidAmountNanos) }} DESO
+
( ~{{globalVars.nanosToUSD(nft.HighestBidAmountNanos, 2) }})
+
+
+
+
+
{{ globalVars.nanosToDeSo(nft.MinBidAmountNanos) }} DESO
+
(~{{ globalVars.nanosToUSD(nft.MinBidAmountNanos, 2) }})
+
+
+
+
diff --git a/src/app/nft-select-serial-number/nft-select-serial-number.component.ts b/src/app/nft-select-serial-number/nft-select-serial-number.component.ts new file mode 100644 index 000000000..86ed57150 --- /dev/null +++ b/src/app/nft-select-serial-number/nft-select-serial-number.component.ts @@ -0,0 +1,47 @@ +import { Component, Input, Output, EventEmitter, OnInit } from "@angular/core"; +import { GlobalVarsService } from "../global-vars.service"; +import { NFTEntryResponse } from "../backend-api.service"; +import * as _ from "lodash"; + +@Component({ + selector: "nft-select-serial-number", + templateUrl: "./nft-select-serial-number.component.html", +}) +export class NftSelectSerialNumberComponent implements OnInit { + static PAGE_SIZE = 50; + static BUFFER_SIZE = 10; + static WINDOW_VIEWPORT = false; + static PADDING = 0.5; + + @Input() serialNumbers: NFTEntryResponse[]; + @Output() serialNumberSelected = new EventEmitter(); + + SN_FIELD = "SerialNumber"; + HIGH_BID_FIELD = "HighestBidAmountNanos"; + MIN_BID_FIELD = "MinBidAmountNanos"; + selectedSerialNumber: NFTEntryResponse = null; + sortedSerialNumbers: NFTEntryResponse[]; + sortByField = this.SN_FIELD; + sortByOrder: "desc" | "asc" = "desc"; + + constructor(public globalVars: GlobalVarsService) {} + + ngOnInit() { + this.updateBidSort(this.SN_FIELD); + } + + selectSerialNumber(idx: number) { + this.selectedSerialNumber = this.serialNumbers.find((sn) => sn.SerialNumber === idx); + this.serialNumberSelected.emit(this.selectedSerialNumber); + } + + updateBidSort(sortField: string) { + if (this.sortByField === sortField) { + this.sortByOrder = this.sortByOrder === "asc" ? "desc" : "asc"; + } else { + this.sortByOrder = "asc"; + } + this.sortByField = sortField; + this.sortedSerialNumbers = _.orderBy(this.serialNumbers, [this.sortByField], [this.sortByOrder]); + } +} diff --git a/src/app/place-bid-modal/place-bid-modal.component.html b/src/app/place-bid-modal/place-bid-modal.component.html index 6de75421b..7f57ae612 100644 --- a/src/app/place-bid-modal/place-bid-modal.component.html +++ b/src/app/place-bid-modal/place-bid-modal.component.html @@ -108,7 +108,7 @@
-
-
- Serial Number -
-
- Highest Bid -
-
- Min Bid Amount -
-
-
-
-
- #{{nft.SerialNumber}} -
-
-
-
{{globalVars.nanosToDeSo(nft.HighestBidAmountNanos)}} DESO
-
(~{{globalVars.nanosToUSD(nft.HighestBidAmountNanos, 2)}})
-
-
-
-
-
{{ globalVars.nanosToDeSo(nft.MinBidAmountNanos) }} DESO
-
(~{{globalVars.nanosToUSD(nft.MinBidAmountNanos, 2)}})
-
-
-
-
+
diff --git a/src/app/place-bid-modal/place-bid-modal.component.ts b/src/app/place-bid-modal/place-bid-modal.component.ts index 4a32fdc22..90c72c4df 100644 --- a/src/app/place-bid-modal/place-bid-modal.component.ts +++ b/src/app/place-bid-modal/place-bid-modal.component.ts @@ -142,8 +142,8 @@ export class PlaceBidModalComponent implements OnInit { } } - selectSerialNumber(idx: number) { - this.selectedSerialNumber = this.availableSerialNumbers.find((sn) => sn.SerialNumber === idx); + selectSerialNumber(serialNumber: NFTEntryResponse) { + this.selectedSerialNumber = serialNumber; this.saveSelection(); } diff --git a/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.html b/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.html new file mode 100644 index 000000000..14a8dbaca --- /dev/null +++ b/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.html @@ -0,0 +1,103 @@ +
+ + +
+
+
+ You are about to accept an NFT transferred by @{{ this.transferringUser }}. +
+ +
+
+ Number + #{{ selectedSerialNumber?.SerialNumber }} +
+
+ Highest Bid + {{ globalVars.nanosToDeSo(highBid) }} DESO (~{{globalVars.nanosToUSD(highBid, 2)}}) +
+
+ Min Bid Amount + {{ globalVars.nanosToDeSo(selectedSerialNumber?.MinBidAmountNanos) }} DESO (~{{globalVars.nanosToUSD(selectedSerialNumber?.MinBidAmountNanos, 2)}}) +
+
+ +
+ +
+
+ Highest Bid + + {{ globalVars.nanosToDeSo(highBid) }} DESO (~{{ globalVars.nanosToUSD(highBid, 2) }}) + +
+
+ Min Bid Amount + + {{ globalVars.nanosToDeSo(selectedSerialNumber?.MinBidAmountNanos) }} DESO (~{{ + globalVars.nanosToUSD(selectedSerialNumber?.MinBidAmountNanos, 2) + }}) + +
+
+
+ + +
+
+
+ + + +
+ + {{ error }} +
+
+ +
+ +
+ + +
+
An NFT can have multiple editions, each with its own unique serial number.
+
+ +
+
+
+ There are no serial numbers available for you to accept. +
+
+
diff --git a/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.ts b/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.ts new file mode 100644 index 000000000..13799d7f0 --- /dev/null +++ b/src/app/transfer-nft-accept-modal/transfer-nft-accept-modal.component.ts @@ -0,0 +1,119 @@ +import { Component, Input, Output, EventEmitter } from "@angular/core"; +import { GlobalVarsService } from "../global-vars.service"; +import { BackendApiService, NFTEntryResponse, PostEntryResponse } from "../backend-api.service"; +import { Router } from "@angular/router"; +import { isNumber } from "lodash"; +import { ToastrService } from "ngx-toastr"; +import { BsModalRef, BsModalService } from "ngx-bootstrap/modal"; +import { Location } from "@angular/common"; + +@Component({ + selector: "transfer-nft-accept-modal", + templateUrl: "./transfer-nft-accept-modal.component.html", +}) +export class TransferNftAcceptModalComponent { + static PAGE_SIZE = 50; + static BUFFER_SIZE = 10; + static WINDOW_VIEWPORT = false; + static PADDING = 0.5; + + @Input() postHashHex: string; + @Input() post: PostEntryResponse; + @Input() transferNFTEntryResponses: NFTEntryResponse[]; + @Output() closeModal = new EventEmitter(); + @Output() changeTitle = new EventEmitter(); + + bidAmountDeSo: number; + bidAmountUSD: number; + selectedSerialNumber: NFTEntryResponse = null; + highBid: number = null; + lowBid: number = null; + loading = false; + isSelectingSerialNumber = true; + saveSelectionDisabled = false; + showSelectedSerialNumbers = false; + acceptingTransfer: boolean = false; + errors: string[] = []; + minBidCurrency: string = "USD"; + minBidInput: number = 0; + transferringUser: string; + + constructor( + public globalVars: GlobalVarsService, + private backendApi: BackendApiService, + private modalService: BsModalService, + private router: Router, + private toastr: ToastrService, + private location: Location, + public bsModalRef: BsModalRef + ) {} + + acceptTransfer() { + this.saveSelectionDisabled = true; + this.acceptingTransfer = true; + this.backendApi + .AcceptNFTTransfer( + this.globalVars.localNode, + this.globalVars.loggedInUser.PublicKeyBase58Check, + this.post.PostHashHex, + this.selectedSerialNumber.SerialNumber, + this.globalVars.defaultFeeRateNanosPerKB + ) + .subscribe( + (res) => { + this.modalService.setDismissReason("transfer accepted"); + this.bsModalRef.hide(); + this.toastr.show("Your transfer was completed", null, { + toastClass: "info-toast", + positionClass: "toast-bottom-center", + }); + }, + (err) => { + console.error(err); + } + ) + .add(() => { + this.acceptingTransfer = false; + this.saveSelectionDisabled = false; + }); + } + + saveSelection(): void { + if (!this.saveSelectionDisabled) { + this.isSelectingSerialNumber = false; + this.showSelectedSerialNumbers = true; + this.changeTitle.emit("Confirm Transfer"); + this.highBid = this.selectedSerialNumber.HighestBidAmountNanos; + this.lowBid = this.selectedSerialNumber.LowestBidAmountNanos; + } + } + + goBackToSerialSelection(): void { + this.isSelectingSerialNumber = true; + this.showSelectedSerialNumbers = false; + this.changeTitle.emit("Choose an edition"); + this.highBid = null; + this.lowBid = null; + this.selectedSerialNumber = null; + } + + selectSerialNumber(serialNumber: NFTEntryResponse) { + this.selectedSerialNumber = serialNumber; + this.backendApi + .GetSingleProfile(this.globalVars.localNode, this.selectedSerialNumber.LastOwnerPublicKeyBase58Check, "") + .subscribe((res) => { + if (res && !res.IsBlacklisted) { + this.transferringUser = res.Profile?.Username; + } + }); + this.saveSelection(); + } + + bidAmountUSDFormatted() { + return isNumber(this.bidAmountUSD) ? `~${this.globalVars.formatUSD(this.bidAmountUSD, 0)}` : ""; + } + + bidAmountDeSoFormatted() { + return isNumber(this.bidAmountDeSo) ? `~${this.bidAmountDeSo.toFixed(2)} $DESO` : ""; + } +} diff --git a/src/app/transfer-nft-modal/transfer-nft-modal.component.html b/src/app/transfer-nft-modal/transfer-nft-modal.component.html new file mode 100644 index 000000000..53dfa3fa6 --- /dev/null +++ b/src/app/transfer-nft-modal/transfer-nft-modal.component.html @@ -0,0 +1,108 @@ +
+ + +
+
+
+ You are about to transfer the NFT shown below. +
+ +
+ Number #{{ selectedSerialNumber?.SerialNumber }} + Change +
+ +
+
+ Number #{{ selectedSerialNumber?.SerialNumber }} +
+ Change +
+
+
+ +
+ Search for a public key or username to whom you want to transfer this NFT +
+ + + +
+
+ This NFT includes unlockable content. Enter it below. +
+ + +
+ +
+ +
+ + +
+
+
+ + + +
+ + +
+
These are the serial numbers that are transferable
+
+ +
+
+
+ There are no serial numbers available for you to transfer. +
+
+
diff --git a/src/app/transfer-nft-modal/transfer-nft-modal.component.ts b/src/app/transfer-nft-modal/transfer-nft-modal.component.ts new file mode 100644 index 000000000..cfe394ee5 --- /dev/null +++ b/src/app/transfer-nft-modal/transfer-nft-modal.component.ts @@ -0,0 +1,188 @@ +import { Component, OnInit, Input, Output, EventEmitter } from "@angular/core"; +import { GlobalVarsService } from "../global-vars.service"; +import { BackendApiService, NFTEntryResponse, PostEntryResponse, ProfileEntryResponse } from "../backend-api.service"; +import { orderBy } from "lodash"; +import { Router } from "@angular/router"; +import { ToastrService } from "ngx-toastr"; +import { BsModalRef, BsModalService } from "ngx-bootstrap/modal"; +import { Location } from "@angular/common"; +import { SwalHelper } from "../../lib/helpers/swal-helper"; + +@Component({ + selector: "transfer-nft-modal", + templateUrl: "./transfer-nft-modal.component.html", +}) +export class TransferNftModalComponent implements OnInit { + static PAGE_SIZE = 50; + static BUFFER_SIZE = 10; + static WINDOW_VIEWPORT = false; + static PADDING = 0.5; + + @Input() postHashHex: string; + @Input() post: PostEntryResponse; + @Output() closeModal = new EventEmitter(); + @Output() changeTitle = new EventEmitter(); + + selectedSerialNumber: NFTEntryResponse = null; + loading = true; + transferringNFT: boolean = false; + isSelectingSerialNumber = true; + saveSelectionDisabled = false; + showSelectedSerialNumbers = false; + transferableSerialNumbers: NFTEntryResponse[]; + SN_FIELD = "SerialNumber"; + LAST_PRICE_FIELD = "LastAcceptedBidAmountNanos"; + sortByField = this.SN_FIELD; + sortByOrder: "desc" | "asc" = "asc"; + selectedCreator: ProfileEntryResponse; + unlockableText: string = ""; + + constructor( + public globalVars: GlobalVarsService, + private backendApi: BackendApiService, + private modalService: BsModalService, + private router: Router, + private toastr: ToastrService, + private location: Location, + public bsModalRef: BsModalRef + ) {} + + ngOnInit(): void { + this.backendApi + .GetNFTEntriesForNFTPost( + this.globalVars.localNode, + this.globalVars.loggedInUser.PublicKeyBase58Check, + this.post.PostHashHex + ) + .subscribe((res) => { + this.transferableSerialNumbers = orderBy( + (res.NFTEntryResponses as NFTEntryResponse[]).filter( + (nftEntryResponse) => + nftEntryResponse.OwnerPublicKeyBase58Check === this.globalVars.loggedInUser?.PublicKeyBase58Check && + !nftEntryResponse.IsPending && + !nftEntryResponse.IsForSale + ), + [this.sortByField], + [this.sortByOrder] + ); + }) + .add(() => (this.loading = false)); + } + + transferNFT() { + this.saveSelectionDisabled = true; + this.transferringNFT = true; + SwalHelper.fire({ + target: this.globalVars.getTargetComponentSelector(), + title: "Transfer NFT", + html: `You are about to transfer this NFT to ${ + this.selectedCreator?.Username || this.selectedCreator?.PublicKeyBase58Check + }`, + showConfirmButton: true, + showCancelButton: true, + reverseButtons: true, + customClass: { + confirmButton: "btn btn-light", + cancelButton: "btn btn-light no", + }, + confirmButtonText: "Ok", + cancelButtonText: "Cancel", + }).then((res) => { + if (res.isConfirmed) { + this.backendApi + .TransferNFT( + this.globalVars.localNode, + this.globalVars.loggedInUser?.PublicKeyBase58Check, + this.selectedCreator?.PublicKeyBase58Check, + this.post.PostHashHex, + this.selectedSerialNumber?.SerialNumber, + this.unlockableText, + this.globalVars.defaultFeeRateNanosPerKB + ) + .subscribe( + (res) => { + this.modalService.setDismissReason("nft transferred"); + this.bsModalRef.hide(); + this.showToast(); + }, + (err) => { + console.error(err); + this.globalVars._alertError(this.backendApi.parseMessageError(err)); + } + ) + .add(() => { + this.transferringNFT = false; + this.saveSelectionDisabled = false; + }); + } else { + this.transferringNFT = false; + this.saveSelectionDisabled = false; + } + }); + } + + showToast(): void { + const link = `/${this.globalVars.RouteNames.NFT}/${this.post.PostHashHex}`; + this.toastr.show(`NFT TransferredView`, null, { + toastClass: "info-toast", + enableHtml: true, + positionClass: "toast-bottom-center", + }); + } + + saveSelection(): void { + if (!this.saveSelectionDisabled) { + this.isSelectingSerialNumber = false; + this.showSelectedSerialNumbers = true; + this.changeTitle.emit("Transfer NFT"); + } + } + + goBackToSerialSelection(): void { + this.isSelectingSerialNumber = true; + this.showSelectedSerialNumbers = false; + this.changeTitle.emit("Choose an edition"); + this.selectedSerialNumber = null; + } + + selectSerialNumber(serialNumber: NFTEntryResponse) { + this.selectedSerialNumber = serialNumber; + this.saveSelection(); + } + + deselectSerialNumber() { + if (this.transferringNFT) { + return; + } + this.selectedSerialNumber = null; + this.showSelectedSerialNumbers = false; + } + + lastPage = null; + + getPage(page: number) { + if (this.lastPage != null && page > this.lastPage) { + return []; + } + const startIdx = page * TransferNftModalComponent.PAGE_SIZE; + const endIdx = (page + 1) * TransferNftModalComponent.PAGE_SIZE; + + return new Promise((resolve, reject) => { + resolve(this.transferableSerialNumbers.slice(startIdx, Math.min(endIdx, this.transferableSerialNumbers.length))); + }); + } + + updateSort(sortField: string) { + if (this.sortByField === sortField) { + this.sortByOrder = this.sortByOrder === "asc" ? "desc" : "asc"; + } else { + this.sortByOrder = "asc"; + } + this.sortByField = sortField; + this.transferableSerialNumbers = orderBy(this.transferableSerialNumbers, [this.sortByField], [this.sortByOrder]); + } + + _selectCreator(selectedCreator: ProfileEntryResponse) { + this.selectedCreator = selectedCreator; + } +} diff --git a/src/styles.scss b/src/styles.scss index ff8c9b653..2d2693c14 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2831,3 +2831,31 @@ input { .referral-link-info__blue-border { border-left: 5px solid $buy-button-blue !important; } + +.checkbox-circle { + i { + display: none; + position: relative; + top: -5px; + } + padding: 3px; + width: 22px; + height: 22px; + border: 1px solid var(--border); + border-radius: 100%; + background: none; + transition: background-color .5s; + &.radio { + transition: background-color 0s; + } + &.checked { + background: var(--highlight); + i { + display: inline; + color: var(--tile-bg); + } + &.radio { + transition: background-color .5s; + } + } +}