diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b9beb43..41e1ba3 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -6,3 +6,5 @@ User | PR | SOON Reward | SMR Address | Authorized By | Comments | ---- | -- | ----------- | ----------- | ------------- | -------- | [@emmap3-do](https://github.com/emmap3-do) | https://github.com/soonaverse/app/pull/47 | 500 | smr1qzt5qs6m6s2us8ll0hdfefzpr43cdz2xmjzywmrkz0sc2uyegvzjwazr6f8 | [@adam_unchained](https://github.com/adam_unchained) | Testing, continuous support in #dev channel [@emmap3-do](https://github.com/emmap3-do) | https://github.com/soonaverse/app/pull/56 | 75 | smr1qzt5qs6m6s2us8ll0hdfefzpr43cdz2xmjzywmrkz0sc2uyegvzjwazr6f8 | [@adam_unchained](https://github.com/adam_unchained) | Minor fixes +[@amenconi](https://github.com/amenconi) | https://github.com/soonaverse/app/pull/128 | 76'000 | smr1qrncyy5lcfpr4hta0hg7qp2cmw6ssm0ycllx5nnz5pwcup8rxs0zzp2jp64 | [SOON_COMMITTEE - request 83](https://github.com/soonaverse/foundation/issues/83) | Bulk Buying feature +[@emmap3-do](https://github.com/emmap3-do) | https://github.com/soonaverse/app/pull/128 | 15'200 | smr1qzt5qs6m6s2us8ll0hdfefzpr43cdz2xmjzywmrkz0sc2uyegvzjwazr6f8 | [SOON_COMMITTEE - request 83](https://github.com/soonaverse/foundation/issues/83) | Bulk Buying feature diff --git a/src/app/@api/collection.api.ts b/src/app/@api/collection.api.ts index ffec82b..25e15ac 100644 --- a/src/app/@api/collection.api.ts +++ b/src/app/@api/collection.api.ts @@ -34,6 +34,10 @@ export class CollectionApi extends BaseApi { super(Dataset.COLLECTION, httpClient); } + public getCollectionById(collectionId: string): Observable { + return this.listen(collectionId); + } + public mintCollection = ( req: Build5Request, ): Observable => this.request(WEN_FUNC.mintCollection, req); diff --git a/src/app/@api/nft.api.ts b/src/app/@api/nft.api.ts index ca4fbe8..2d0bf7c 100644 --- a/src/app/@api/nft.api.ts +++ b/src/app/@api/nft.api.ts @@ -62,6 +62,10 @@ export class NftApi extends BaseApi { public stakeNft = (req: Build5Request): Observable => this.request(WEN_FUNC.stakeNft, req); + public getNftById(nftId: string): Observable { + return this.listen(nftId); + } + public successfullOrders( nftId: string, network?: Network, diff --git a/src/app/@api/order.api.ts b/src/app/@api/order.api.ts index 4ddae44..71784d7 100644 --- a/src/app/@api/order.api.ts +++ b/src/app/@api/order.api.ts @@ -7,6 +7,7 @@ import { WEN_FUNC, Build5Request, NftPurchaseRequest, + NftPurchaseBulkRequest, OrderTokenRequest, AddressValidationRequest, NftBidRequest, @@ -27,6 +28,10 @@ export class OrderApi extends BaseApi { public orderNft = (req: Build5Request): Observable => this.request(WEN_FUNC.orderNft, req); + public orderNfts = ( + req: Build5Request, + ): Observable => this.request(WEN_FUNC.orderNftBulk, req); + public orderToken = ( req: Build5Request, ): Observable => this.request(WEN_FUNC.orderToken, req); diff --git a/src/app/@core/services/filter-storage/filter-storage.service.ts b/src/app/@core/services/filter-storage/filter-storage.service.ts index 8705908..cda5d5d 100644 --- a/src/app/@core/services/filter-storage/filter-storage.service.ts +++ b/src/app/@core/services/filter-storage/filter-storage.service.ts @@ -51,6 +51,7 @@ export interface MarketCollectionsFilters { access?: string[]; space?: string[]; category?: string[]; + status?: string[]; }; range?: { price: string; @@ -194,7 +195,9 @@ export class FilterStorageService { public marketNftsResetVisible$: BehaviorSubject = new BehaviorSubject(false); public marketNftsFilters$: BehaviorSubject = new BehaviorSubject({ - sortBy: this.marketNftsFiltersOptions.sortItems[0].value, + sortBy: + this.marketNftsFiltersOptions.sortItems.find((item) => item.value === 'nft_price_asc') + ?.value || 'nft_availableFrom_asc', }); public marketCollectionsFiltersOptions = { diff --git a/src/app/@core/services/router/router.service.ts b/src/app/@core/services/router/router.service.ts index 529944a..b4e2399 100644 --- a/src/app/@core/services/router/router.service.ts +++ b/src/app/@core/services/router/router.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router, Event } from '@angular/router'; import { ROUTER_UTILS } from '@core/utils/router.utils'; import { BehaviorSubject } from 'rxjs'; import { DeviceService } from '../device'; +import { filter } from 'rxjs/operators'; @Injectable({ providedIn: 'root', @@ -21,6 +22,12 @@ export class RouterService { public urlToNewToken = '/' + ROUTER_UTILS.config.token.root + '/new'; constructor(private router: Router, private deviceService: DeviceService) { + // this.router.events.pipe( + // filter((event: Event): event is NavigationEnd => event instanceof NavigationEnd) + // ).subscribe((event: NavigationEnd) => { + // console.log('Navigation Event:', event); + // }); + this.updateVariables(); this.router.events.subscribe((obj) => { diff --git a/src/app/@core/utils/local-storage.utils.ts b/src/app/@core/utils/local-storage.utils.ts index a70f86d..b4d1c3e 100644 --- a/src/app/@core/utils/local-storage.utils.ts +++ b/src/app/@core/utils/local-storage.utils.ts @@ -25,6 +25,12 @@ export enum StorageItem { SelectedTradePriceOption = 'App/selectedTradePriceOption', DepositNftTransaction = 'App/depositNftTransaction-', StakeNftTransaction = 'App/stakeNftTransaction-', + CartItems = 'App/cartItems', +} + +interface CheckoutTransactionData { + transactionId: string | null; + source: 'nftCheckout' | 'cartCheckout' | 'bulkNftCheckout' | null; } export const getBitItemItem = (nftId: string): unknown | null => { @@ -134,3 +140,16 @@ export const setItem = (itemName: StorageItem, value: unknown): void => { export const removeItem = (itemName: StorageItem): void => { localStorage.removeItem(itemName); }; + +export const getCheckoutTransaction = (): CheckoutTransactionData | null => { + const item = localStorage.getItem(StorageItem.CheckoutTransaction); + return item ? JSON.parse(item) : { transactionId: null, source: null }; +}; + +export const setCheckoutTransaction = (value: CheckoutTransactionData): void => { + if (value.transactionId === null && value.source === null) { + localStorage.removeItem(StorageItem.CheckoutTransaction); + } else { + localStorage.setItem(StorageItem.CheckoutTransaction, JSON.stringify(value)); + } +}; diff --git a/src/app/@shell/ui/header/header.component.html b/src/app/@shell/ui/header/header.component.html index 31c4476..9098d2c 100644 --- a/src/app/@shell/ui/header/header.component.html +++ b/src/app/@shell/ui/header/header.component.html @@ -18,6 +18,7 @@ [getNotificationDetails]="getNotificationDetails" (wenOnVisibleChange)="isMobileMenuVisible = !isMobileMenuVisible" (wenOnNotificationVisibleChange)="notificationVisibleChange()" + [cartItemCount]="cartItemCount" > @@ -43,6 +44,24 @@ + + + + + + + +
NotificationContent; @Output() wenOnVisibleChange = new EventEmitter(); @Output() wenOnNotificationVisibleChange = new EventEmitter(); @@ -36,6 +38,7 @@ export class MobileHeaderComponent { public location: Location, public routerService: RouterService, public unitsService: UnitsService, + public cartService: CartService, ) {} public setMobileMenuVisible(isVisible: boolean): void { @@ -56,4 +59,8 @@ export class MobileHeaderComponent { public get isLoggedIn$(): BehaviorSubject { return this.auth.isLoggedIn$; } + + public handleOpenCartModal(): void { + this.cartService.showCartModal(); + } } diff --git a/src/app/components/algolia/services/algolia.service.ts b/src/app/components/algolia/services/algolia.service.ts index d713adc..07c63f0 100644 --- a/src/app/components/algolia/services/algolia.service.ts +++ b/src/app/components/algolia/services/algolia.service.ts @@ -4,7 +4,7 @@ import { RefinementMappings } from '@components/algolia/refinement/refinement.co import { enumToArray } from '@core/utils/manipulations.utils'; import { environment } from '@env/environment'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { Access, Categories, NftAvailable } from '@build-5/interfaces'; +import { Access, Categories, NftAvailable, CollectionStatus } from '@build-5/interfaces'; import algoliasearch from 'algoliasearch/lite'; const accessMapping: RefinementMappings = {}; @@ -27,6 +27,36 @@ export class AlgoliaService { }); } + public fetchAllOwnedNfts(memberId: string, indexName: string): Promise { + const index = this.searchClient.initIndex(indexName); + let page = 0; + const hitsPerPage = 20; + const allHits: any[] = []; + + return new Promise((resolve, reject) => { + const fetchPage = () => { + index + .search('', { + filters: `owner:${memberId}`, + hitsPerPage, + page, + }) + .then((response) => { + allHits.push(...response.hits); + if (page < response.nbPages - 1) { + page++; + fetchPage(); + } else { + resolve(allHits); + } + }) + .catch(reject); + }; + + fetchPage(); + }); + } + public convertToAccessName(algoliaItems: any[]) { return algoliaItems.map((algolia) => { let label = $localize`Open`; @@ -78,4 +108,16 @@ export class AlgoliaService { }; }); } + + public convertCollectionStatus(algoliaItems: any[]) { + const statuses = enumToArray(CollectionStatus); + return algoliaItems.map((algolia) => { + const label = statuses.find((status) => status.key === algolia.value)?.value; + return { + ...algolia, + label: label, + highlighted: label, + }; + }); + } } diff --git a/src/app/components/cart/cart.module.ts b/src/app/components/cart/cart.module.ts new file mode 100644 index 0000000..46e41f8 --- /dev/null +++ b/src/app/components/cart/cart.module.ts @@ -0,0 +1,66 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NzModalModule } from 'ng-zorro-antd/modal'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { CartModalComponent } from './components/cart-modal/cart-modal.component'; +import { IconModule } from '@components/icon/icon.module'; +import { NzNotificationModule } from 'ng-zorro-antd/notification'; +import { FormatTokenModule } from '@core/pipes/formatToken/format-token.module'; +import { CheckoutOverlayComponent } from './components/checkout/checkout-overlay.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NzInputNumberModule } from 'ng-zorro-antd/input-number'; +import { NzFormModule } from 'ng-zorro-antd/form'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzDividerModule } from 'ng-zorro-antd/divider'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { TermsAndConditionsModule } from '@components/terms-and-conditions/terms-and-conditions.module'; +import { TimeModule } from '@core/pipes/time/time.module'; +import { CountdownTimeModule } from '@core/pipes/countdown-time/countdown-time.module'; +import { NzAlertModule } from 'ng-zorro-antd/alert'; +import { NetworkModule } from '@components/network/network.module'; +import { RouterModule } from '@angular/router'; +import { WalletDeeplinkModule } from '@components/wallet-deeplink/wallet-deeplink.module'; +import { NzRadioModule } from 'ng-zorro-antd/radio'; +import { UsdBelowTwoDecimalsModule } from '@core/pipes/usd-below-two-decimals/usd-below-two-decimals.module'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { ConnectWalletModule } from '@components/connect-wallet/connect-wallet.module'; +// import { DataService } from '@pages/member/services/data.service'; +// import { AlgoliaModule } from '@components/algolia/algolia.module'; + +@NgModule({ + declarations: [CartModalComponent, CheckoutOverlayComponent], + imports: [ + CommonModule, + NzModalModule, + NzButtonModule, + IconModule, + NzNotificationModule, + FormatTokenModule, + FormsModule, + NzInputNumberModule, + ReactiveFormsModule, + NzFormModule, + NzInputModule, + NzDividerModule, + NzTableModule, + NzTagModule, + NzEmptyModule, + TermsAndConditionsModule, + TimeModule, + CountdownTimeModule, + NzAlertModule, + NetworkModule, + RouterModule, + WalletDeeplinkModule, + NzRadioModule, + UsdBelowTwoDecimalsModule, + NzToolTipModule, + ConnectWalletModule, + // DataService, + // AlgoliaModule, + ], + exports: [CartModalComponent, CheckoutOverlayComponent], +}) +export class CartModule {} diff --git a/src/app/components/cart/components/cart-modal/cart-modal.component.html b/src/app/components/cart/components/cart-modal/cart-modal.component.html new file mode 100644 index 0000000..ecc1467 --- /dev/null +++ b/src/app/components/cart/components/cart-modal/cart-modal.component.html @@ -0,0 +1,429 @@ + + + +
+ +
+
+
+ +
+ +
+ +
+ {{ item.nft.name }} + +
+ +
+
+ +
+
NFT
+
+ {{ item.nft.name }} +
+
+ + +
+
Collection
+
+ {{ item.collection.name }} +
+
+ + +
+
Royalties
+
{{ (item.collection.royaltiesFee || 0) * 100 }}%
+
+ + +
+ +
Status
+ + {{ cartStatus.status }} + + + + {{ cartStatus.status }} + + +
+
+ + +
+
Qty / Available
+ + + + + + / {{ cartService.getAvailableNftQuantity(item) | async }} + + + + +
+ + +
+
Price Each (USD Value)
+ +
+ {{ + item.pricing.originalPrice + | formatToken : item.pricing.tokenSymbol : true : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.pricing.originalPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + item.pricing.tokenSymbol + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +
+
+ {{ + item.pricing.discountedPrice + | formatToken : item.pricing.tokenSymbol : true : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.pricing.discountedPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + item.pricing.tokenSymbol + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +
+
+ +
-
+
+
+
+
+
+
+ + +
+
+
+
+
+
Info
+
+
+ +
+
Status
+
+ +
+
+ Qty Added / Available +
+
+ +
+
+ Price Each (USD Value) +
+
+ +
+
Remove
+
+
+
+ +
+
+
+ {{ item.nft.name }} +
+
+ Collection: + + {{ item.collection.name }} + +
+
+ NFT: + + {{ item.nft.name }} + +
+
Royalties: {{ (item.collection.royaltiesFee || 0) * 100 }}%
+
+
+ +
+ + + {{ cartStatus.status }} + + + + {{ cartStatus.status }} + + + +
+ +
+
+ + + + + + / {{ cartService.getAvailableNftQuantity(item) | async }} + + + + +
+
+ +
+ +
+ {{ + item.pricing.originalPrice + | formatToken : item.pricing.tokenSymbol : true : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.pricing.originalPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + item.pricing.tokenSymbol + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +
+
+ {{ + item.pricing.discountedPrice + | formatToken : item.pricing.tokenSymbol : true : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.pricing.discountedPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + item.pricing.tokenSymbol + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +
+
+ +
-
+
+
+ +
+ +
+
+
+
+
+ Your cart is empty. +
+ +
+ + + + + +
+
+
+
diff --git a/src/app/components/cart/components/cart-modal/cart-modal.component.less b/src/app/components/cart/components/cart-modal/cart-modal.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/cart/components/cart-modal/cart-modal.component.ts b/src/app/components/cart/components/cart-modal/cart-modal.component.ts new file mode 100644 index 0000000..ff69319 --- /dev/null +++ b/src/app/components/cart/components/cart-modal/cart-modal.component.ts @@ -0,0 +1,198 @@ +import { + Component, + OnDestroy, + ChangeDetectorRef, + ChangeDetectionStrategy, + OnInit, +} from '@angular/core'; +import { Network, Transaction } from '@build-5/interfaces'; +import { Subscription, take, of, Observable, BehaviorSubject, Subject } from 'rxjs'; +import { CartService, CartItem } from '@components/cart/services/cart.service'; +import { AuthService } from '@components/auth/services/auth.service'; +import { Router } from '@angular/router'; +import { ROUTER_UTILS } from '@core/utils/router.utils'; +import { UnitsService } from '@core/services/units/units.service'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { DeviceService } from '@core/services/device'; +import { HelperService } from '@pages/nft/services/helper.service'; +import {} from '@angular/core'; + +export enum StepType { + CONFIRM = 'Confirm', + TRANSACTION = 'Transaction', + WAIT = 'Wait', + COMPLETE = 'Complete', +} + +@Component({ + selector: 'wen-app-cart-modal', + templateUrl: './cart-modal.component.html', + styleUrls: ['./cart-modal.component.less'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CartModalComponent implements OnInit, OnDestroy { + private subscriptions$ = new Subscription(); + public collectionPath: string = ROUTER_UTILS.config.collection.root; + public nftPath: string = ROUTER_UTILS.config.nft.root; + public cartItemsQuantities: number[] = []; + public cartItemPrices: { + [key: string]: { originalPrice: number; discountedPrice: number; tokenSymbol: Network }; + } = {}; + public stepType = StepType; + public isLoadingCartData = true; + + public cartItems$!: Observable; + public cartItemsStatus$!: Observable<{ [key: string]: { status: string; message: string } }>; + + public cartModalOpen$ = this.cartService.cartModalOpen$; + public currentStep$ = this.cartService.currentStep$; + + public selectedNetwork$ = this.cartService.selectedNetwork$; + + public memberSpaces$ = this.cartService.memberSpaces$; + public memberGuardianSpaces$ = this.cartService.memberGuardianSpaces$; + public memberAwards$ = this.cartService.memberAwards$; + + public isLoading$ = this.cartService.isLoading$; + + public currentStep: StepType | null = null; + public isTransactionExpired: boolean | null = null; + public selectedNetwork: string | null = null; + public isLoading = false; + public pendingTransaction: Transaction | undefined = undefined; + + constructor( + public cartService: CartService, + private cd: ChangeDetectorRef, + public auth: AuthService, + private router: Router, + public unitsService: UnitsService, + public deviceService: DeviceService, + public helper: HelperService, + ) {} + + ngOnInit() { + this.subscriptions$.add( + this.cartService.currentStep$.subscribe((step) => { + this.currentStep = step; + this.triggerChangeDetection(); + }), + ); + + this.subscriptions$.add( + this.cartService.selectedNetwork$.subscribe((network) => { + this.selectedNetwork = network; + this.triggerChangeDetection(); + }), + ); + + this.subscriptions$.add( + this.cartService.pendingTransaction$.subscribe((transaction) => { + this.pendingTransaction = transaction; + this.triggerChangeDetection(); + }), + ); + + this.subscriptions$.add( + this.cartService.isLoading$.subscribe((loading) => { + this.isLoading = loading; + this.triggerChangeDetection(); + }), + ); + + this.subscriptions$.add( + this.cartService.cartUpdateObservable$.subscribe(() => { + this.triggerChangeDetection(); + }), + ); + + this.subscriptions$.add( + this.cartService.triggerChangeDetectionSubject$.subscribe(() => { + this.triggerChangeDetection(); + }), + ); + } + + public triggerChangeDetection(): void { + this.cd.markForCheck(); + } + + trackByItemId(index: number, item: CartItem): string { + return item.nft.uid; + } + + public removeFromCart(item: CartItem): void { + this.cartService.removeFromCart(item); + } + + public updateQuantity(event: Event, itemId: string): void { + const inputElement = event.target as HTMLInputElement; + let newQuantity = Math.round(Number(inputElement.value)); + + newQuantity = Math.max(1, newQuantity); + + this.cartService + .getCartItems() + .pipe( + take(1), + switchMap((cartItems) => { + const item = cartItems.find((item) => item.nft.uid === itemId); + if (item) { + return this.cartService.getAvailableNftQuantity(item).pipe( + map((maxQuantity) => ({ item, maxQuantity })), + tap(({ maxQuantity }) => { + newQuantity = Math.min(newQuantity, maxQuantity); + inputElement.value = String(newQuantity); + }), + ); + } else { + return of(null); + } + }), + ) + .subscribe((result) => { + if (result) { + this.cartService.updateCartItemQuantity(itemId, newQuantity); + } + }); + } + + public handleClose(): void { + this.cartService.hideCartModal(); + } + + public goToNft(nftUid: string): void { + if (!nftUid) { + return; + } + + this.router.navigate(['/', this.nftPath, nftUid]); + this.cartService.hideCartModal(); + } + + public goToCollection(colUid: string): void { + if (!colUid) { + return; + } + + this.router.navigate(['/', this.collectionPath, colUid]); + this.cartService.hideCartModal(); + } + + async handleCartCheckout(): Promise { + this.cartService.openCheckoutOverlay(); + } + + public clearCart(): void { + this.cartService.clearCart(); + this.cd.markForCheck(); + } + + public closeCheckoutOverlay(): void { + this.cartService.closeCheckoutOverlay(); + } + + ngOnDestroy() { + this.subscriptions$.unsubscribe(); + } +} diff --git a/src/app/components/cart/components/checkout/checkout-overlay.component.html b/src/app/components/cart/components/checkout/checkout-overlay.component.html new file mode 100644 index 0000000..b8932f6 --- /dev/null +++ b/src/app/components/cart/components/checkout/checkout-overlay.component.html @@ -0,0 +1,525 @@ + +
+ Notice {{ unavailableItemCount }} items were not included in + the checkout due to not being available for sale. +
+
+ Select Network Purchase Bulk purchase of + NFTs are only allowed on one network per transaction. Please select the network group of NFTs + you'd like to purchase below. You can click on the network to toggle visibility of the NFTs to + purchase for that network. After making your selection you can confirm and lock the group of + NFTs to generate the purchase transaction. +
+ + +
+
+
+
+

+ Network: {{ group.tokenSymbol }} - + {{ group.totalQuantity }} NFTs in network group with + total Price of + {{ group.totalPrice | formatToken : group.network : true : true | async }} ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: group.totalPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +

+
+
+ +
+
+ + + +
+ + + + + NFT Info + Qty + Price + + + + + + + +
+ NFT: + + {{ item.nft.name }} + +
+ + +
+ COL: + + {{ item.collection.name }} + +
+ + +
+ Royalties: + {{ (item.collection.royaltiesFee || 0) * 100 }}% +
+ + + {{ item.quantity }} + + {{ + item.salePrice + | formatToken + : (item.nft.placeholderNft + ? item.collection.mintingData?.network + : item.nft.mintingData?.network) + : true + : true + | async + }} +
+ ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.salePrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) + + +
+ + + Total + + + + {{ group.totalQuantity }} + + + + + {{ group.totalPrice | formatToken : group.network : true : true | async }} +
+ ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: group.totalPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) +
+ + + +
+
+ + + + + + NFT Name + Collection Name + Royalties Fee + Quantity Added + + Price ({{ group.tokenSymbol }}) (USD Value) + + Line Item Total (USD Value) + + + + + + + + {{ item.nft.name }} + + + + + {{ item.collection.name }} + + + {{ (item.collection.royaltiesFee || 0) * 100 }}% + {{ item.quantity }} + + {{ + item.salePrice + | formatToken + : (item.nft.placeholderNft + ? item.collection.mintingData?.network + : item.nft.mintingData?.network) + : true + : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.salePrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) + + + {{ + item.quantity * item.salePrice + | formatToken + : (item.nft.placeholderNft + ? item.collection.mintingData?.network + : item.nft.mintingData?.network) + : true + : true + | async + }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: item.quantity * item.salePrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) + + + + + + + Total + + {{ group.totalQuantity }} + + + + + {{ group.totalPrice | formatToken : group.network : true : true | async }} +   ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: group.totalPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + group.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }} + USD) + + + + + + +
+
+ +
+ + + +
+ + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ + +
+ + + + + + + +
+ Your NFTs will be locked for purchase for {{ lockTime }} + minutes. +
+
+
+ + +
+
+ +
+ {{ + expiryTicker$ | async | countdownTime + }} + + remaining to make the transfer. + Expired +
+
+ + + + + + + + +
+
+ + +
+
+
Transaction history
+ +
+
+
{{ t.date | Time }}
+ {{ t.label }} +
{{ t.label }}
+
+
+
+ + +
+
+ + +
+
+
Transaction history
+ +
+
+
{{ t.date | Time }}
+ {{ t.label }} +
{{ t.label }}
+
+
+
+ +
+
+ +
+
Transaction complete. Congratulations.
+
+ +
+ +
+ +
+ +
+
+
diff --git a/src/app/components/cart/components/checkout/checkout-overlay.component.less b/src/app/components/cart/components/checkout/checkout-overlay.component.less new file mode 100644 index 0000000..2739106 --- /dev/null +++ b/src/app/components/cart/components/checkout/checkout-overlay.component.less @@ -0,0 +1,17 @@ +.radio-option { + padding: 0.75rem; + border: 4px solid transparent; + cursor: pointer; +} + +.radio-option:hover { + border-color: rgb(217, 75, 8); +} + +.radio-option.selected { + border-color: rgb(217, 75, 8); +} + +.radio-option input[type='radio'] { + display: none; +} diff --git a/src/app/components/cart/components/checkout/checkout-overlay.component.ts b/src/app/components/cart/components/checkout/checkout-overlay.component.ts new file mode 100644 index 0000000..4b78109 --- /dev/null +++ b/src/app/components/cart/components/checkout/checkout-overlay.component.ts @@ -0,0 +1,724 @@ +import { + Component, + Input, + OnInit, + ChangeDetectorRef, + ChangeDetectionStrategy, + Output, + EventEmitter, + OnDestroy, + NgZone, +} from '@angular/core'; +import { CartItem, CartService } from '@components/cart/services/cart.service'; +import { + CollectionType, + Nft, + Timestamp, + Transaction, + TransactionType, + TRANSACTION_AUTO_EXPIRY_MS, + NftPurchaseRequest, + Network, + DEFAULT_NETWORK, +} from '@build-5/interfaces'; +import { AuthService } from '@components/auth/services/auth.service'; +import { NotificationService } from '@core/services/notification'; +import { OrderApi } from '@api/order.api'; +import { NftApi } from '@api/nft.api'; +import { + BehaviorSubject, + firstValueFrom, + interval, + Observable, + Subscription, + forkJoin, + of, +} from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { TransactionService } from '@core/services/transaction'; +import { + removeItem, + StorageItem, + setCheckoutTransaction, + getCheckoutTransaction, +} from '@core/utils'; +import dayjs from 'dayjs'; +import { HelperService } from '@pages/nft/services/helper.service'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { ROUTER_UTILS } from '@core/utils/router.utils'; +import { Router } from '@angular/router'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { ThemeList, ThemeService } from '@core/services/theme'; +import { UnitsService } from '@core/services/units/units.service'; +import { DeviceService } from '@core/services/device'; + +export enum StepType { + CONFIRM = 'Confirm', + TRANSACTION = 'Transaction', + WAIT = 'Wait', + COMPLETE = 'Complete', +} + +interface GroupedCartItem { + tokenSymbol: string; + items: CartItem[]; + totalQuantity: number; + totalPrice: number; + network: Network | undefined; +} + +interface HistoryItem { + uniqueId: string; + date: dayjs.Dayjs | Timestamp | null; + label: string; + transaction?: Transaction; + link?: string; +} + +@UntilDestroy() +@Component({ + selector: 'wen-app-checkout-overlay', + templateUrl: './checkout-overlay.component.html', + styleUrls: ['./checkout-overlay.component.less'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckoutOverlayComponent implements OnInit, OnDestroy { + @Input() currentStep = StepType.CONFIRM; + @Input() items: CartItem[] = []; + @Input() pendingTransaction: Transaction | undefined; + + @Output() wenOnClose = new EventEmitter(); + @Output() wenOnCloseCartCheckout = new EventEmitter(); + + public groupedCartItems: GroupedCartItem[] = []; + public unavailableItemCount = 0; + public agreeTermsConditions = false; + public transaction$: BehaviorSubject = new BehaviorSubject< + Transaction | undefined + >(undefined); + public history: HistoryItem[] = []; + public expiryTicker$: BehaviorSubject = + new BehaviorSubject(null); + public invalidPayment = false; + public receivedTransactions = false; + public targetAddress?: string; + public targetAmount?: number; + public purchasedNfts?: Nft[] | null; + public stepType = StepType; + public selectedNetwork: string | null = null; + public mintingDataNetwork: Network | undefined; + public formattedTotalPrice = ''; + private purchasedTokenSymbol: string | null = null; + private transSubscription$?: Subscription; + public nftPath = ROUTER_UTILS.config.nft.root; + public collectionPath = ROUTER_UTILS.config.collection.root; + public expandedGroups = new Set(); + private currentTransactionSubscription?: Subscription; + public theme = ThemeList; + + constructor( + public cartService: CartService, + public auth: AuthService, + private notification: NotificationService, + private orderApi: OrderApi, + public transactionService: TransactionService, + public helper: HelperService, + private cd: ChangeDetectorRef, + private nftApi: NftApi, + private router: Router, + private nzNotification: NzNotificationService, + public themeService: ThemeService, + public unitsService: UnitsService, + public deviceService: DeviceService, + private zone: NgZone, + ) {} + + public get themes(): typeof ThemeList { + return ThemeList; + } + + ngOnInit() { + this.subscribeToCurrentStep(); + this.subscribeToCurrentTransaction(); + this.subscribeToCartItems(); + this.listenToStorageForStepChanges(); + + this.groupItems(); + this.receivedTransactions = false; + const listeningToTransaction: string[] = []; + this.transaction$.pipe(untilDestroyed(this)).subscribe((val) => { + if (val && val.type === TransactionType.ORDER) { + this.targetAddress = val.payload.targetAddress; + this.targetAmount = val.payload.amount; + const expiresOn: dayjs.Dayjs = dayjs(val.payload.expiresOn!.toDate()); + if (expiresOn.isBefore(dayjs()) || val.payload?.void || val.payload?.reconciled) { + removeItem(StorageItem.CheckoutTransaction); + } + if (val.linkedTransactions && val.linkedTransactions?.length > 0) { + this.currentStep = StepType.WAIT; + this.updateStep(this.currentStep); + for (const tranId of val.linkedTransactions) { + if (listeningToTransaction.indexOf(tranId) > -1) { + continue; + } + + listeningToTransaction.push(tranId); + this.orderApi + .listen(tranId) + .pipe(untilDestroyed(this)) + .subscribe(this.transaction$); + } + } else if (!val.linkedTransactions || val.linkedTransactions.length === 0) { + this.currentStep = StepType.TRANSACTION; + this.updateStep(this.currentStep); + } + + this.expiryTicker$.next(expiresOn); + } + + if (val && val.type === TransactionType.PAYMENT && val.payload.reconciled === true) { + this.pushToHistory( + val, + val.uid + '_payment_received', + val.createdOn, + $localize`Payment received.`, + (val).payload?.chainReference, + ); + + this.currentStep = StepType.COMPLETE; + this.updateStep(this.currentStep); + this.cd.markForCheck(); + } + + if ( + val && + val.type === TransactionType.PAYMENT && + val.payload.reconciled === true && + (val).payload.invalidPayment === false + ) { + setTimeout(() => { + this.pushToHistory( + val, + val.uid + '_confirming_trans', + dayjs(), + $localize`Confirming transaction.`, + ); + }, 1000); + + setTimeout(() => { + this.pushToHistory( + val, + val.uid + '_confirmed_trans', + dayjs(), + $localize`Transaction confirmed.`, + ); + this.receivedTransactions = true; + this.currentStep = StepType.COMPLETE; + this.updateStep(this.currentStep); + this.removePurchasedGroupItems(); + this.cd.markForCheck(); + }, 2000); + + if (val.payload.nftOrders && val.payload.nftOrders.length > 0) { + this.purchasedNfts = this.purchasedNfts || []; + val.payload.nftOrders.forEach((nftOrder) => { + firstValueFrom(this.nftApi.listen(nftOrder.nft)).then((obj) => { + if (obj !== null && obj !== undefined) { + const purchasedNft = obj as Nft; + + this.purchasedNfts = [...(this.purchasedNfts || []), purchasedNft]; + this.cd.markForCheck(); + } + }); + }); + } + } + + if ( + val && + val.type === TransactionType.CREDIT && + val.payload.reconciled === true && + val.ignoreWallet === false && + !val.payload?.walletReference?.chainReference + ) { + this.pushToHistory( + val, + val.uid + '_false', + val.createdOn, + $localize`Invalid amount received. Refunding transaction...`, + ); + } + + const markInvalid = () => { + setTimeout(() => { + this.currentStep = StepType.TRANSACTION; + this.updateStep(this.currentStep); + this.invalidPayment = true; + this.cd.markForCheck(); + }, 2000); + }; + + if ( + val && + val.type === TransactionType.CREDIT && + val.payload.reconciled === true && + val.ignoreWallet === true && + !val.payload?.walletReference?.chainReference + ) { + this.pushToHistory( + val, + val.uid + '_false', + val.createdOn, + $localize`Invalid transaction.You must gift storage deposit.`, + ); + markInvalid(); + } + + if ( + val && + val.type === TransactionType.CREDIT && + val.payload.reconciled === true && + val.payload?.walletReference?.chainReference + ) { + this.pushToHistory( + val, + val.uid + '_true', + dayjs(), + $localize`Invalid payment refunded.`, + val.payload?.walletReference?.chainReference, + ); + + markInvalid(); + } + + this.cartService.selectedNetwork$.subscribe((network) => { + this.selectedNetwork = network; + this.cd.markForCheck(); + }); + + this.cd.markForCheck(); + }); + + const checkoutTransaction = getCheckoutTransaction(); + if (checkoutTransaction && checkoutTransaction.transactionId) { + this.transSubscription$ = this.orderApi + .listen(checkoutTransaction.transactionId) + .subscribe((transaction) => { + this.transaction$.next(transaction); + }); + } else { + this.transSubscription$?.unsubscribe(); + } + + const int: Subscription = interval(1000) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.expiryTicker$.next(this.expiryTicker$.value); + + if (this.expiryTicker$.value) { + const expiresOn: dayjs.Dayjs = dayjs(this.expiryTicker$.value).add( + TRANSACTION_AUTO_EXPIRY_MS, + 'ms', + ); + if (expiresOn.isBefore(dayjs())) { + this.expiryTicker$.next(null); + removeItem(StorageItem.CheckoutTransaction); + int.unsubscribe(); + this.reset(); + } + } + }); + + if (this.currentStep === StepType.CONFIRM) { + if (this.groupedCartItems.length === 1) { + this.cartService.setNetworkSelection(this.groupedCartItems[0].tokenSymbol); + } + } else { + const storedNetwork = this.cartService.getSelectedNetwork(); + if (storedNetwork) { + this.selectedNetwork = storedNetwork; + } + } + this.setDefaultGroupVisibility(); + } + + private listenToStorageForStepChanges() { + window.addEventListener('storage', (event) => { + if (event.key === 'cartCheckoutCurrentStep') { + const updatedStep = event.newValue as StepType; + if (updatedStep && updatedStep !== this.currentStep) { + this.zone.run(() => { + this.currentStep = updatedStep; + this.cd.markForCheck(); + }); + } + } + }); + } + + private subscribeToCartItems() { + this.cartService + .getCartItems() + .pipe(untilDestroyed(this)) + .subscribe((cartItems) => { + this.items = cartItems; + this.groupItems(); + this.cd.markForCheck(); + }); + } + + private subscribeToCurrentStep() { + this.cartService.currentStep$.pipe(untilDestroyed(this)).subscribe((step) => { + this.currentStep = step; + this.setDefaultGroupVisibility(); + this.cd.markForCheck(); + }); + } + + private subscribeToCurrentTransaction() { + const fetchedCurrentTransaction = this.cartService.getCurrentTransaction(); + + if (fetchedCurrentTransaction === undefined) { + return; + } + + this.currentTransactionSubscription = this.cartService + .getCurrentTransaction() + .pipe(untilDestroyed(this)) + .subscribe((transaction) => { + this.pendingTransaction = transaction; + this.cd.markForCheck(); + }); + } + + public setDefaultGroupVisibility() { + if (this.currentStep !== this.stepType.CONFIRM && this.selectedNetwork) { + this.expandedGroups.add(this.selectedNetwork); + this.expandedGroups.clear(); + } else { + if (this.currentStep === this.stepType.CONFIRM && this.groupedCartItems.length === 1) { + this.groupedCartItems.forEach((group) => { + this.expandedGroups.add(group.tokenSymbol); + }); + } + } + + this.cd.markForCheck(); + } + + public toggleGroup(event: MouseEvent, groupSymbol: string): void { + event.stopPropagation(); + if (this.expandedGroups.has(groupSymbol)) { + this.expandedGroups.delete(groupSymbol); + } else { + this.expandedGroups.add(groupSymbol); + } + this.cd.markForCheck(); + } + + public isGroupExpanded(groupSymbol: string): boolean { + return this.expandedGroups.has(groupSymbol); + } + + public setNetworkSelection(networkSymbol: string): void { + this.selectedNetwork = networkSymbol; + this.cartService.setNetworkSelection(networkSymbol); + this.expandedGroups.clear(); + this.expandedGroups.add(networkSymbol); + this.cd.markForCheck(); + } + + public clearNetworkSelection(): void { + this.cartService.clearNetworkSelection(); + this.selectedNetwork = null; + } + + public groupItems() { + const availabilityChecks$ = this.items.map((item) => + this.cartService.isCartItemAvailableForSale(item).pipe( + map((result) => ({ item, isAvailable: result.isAvailable })), + switchMap((result) => (result ? of(result) : of({ item, isAvailable: false }))), + ), + ); + + forkJoin(availabilityChecks$).subscribe((results) => { + const groups: { [tokenSymbol: string]: GroupedCartItem } = {}; + this.unavailableItemCount = 0; + + results.forEach(({ item, isAvailable }) => { + if (!isAvailable) { + this.unavailableItemCount++; + return; + } + + const tokenSymbol = + (item.nft?.placeholderNft + ? item.collection?.mintingData?.network + : item.nft?.mintingData?.network) || DEFAULT_NETWORK; + const discount = this.discount(item); + const originalPrice = this.calcPrice(item, 1); + const discountedPrice = this.calcPrice(item, discount); + const price = discount < 1 ? discountedPrice : originalPrice; + item.salePrice = price; + + const network = + (item.nft?.placeholderNft + ? item.collection?.mintingData?.network + : item.nft?.mintingData?.network) || DEFAULT_NETWORK; + + if (!groups[tokenSymbol]) { + groups[tokenSymbol] = { + tokenSymbol, + items: [], + totalQuantity: 0, + totalPrice: 0, + network, + }; + } + + groups[tokenSymbol].items.push(item); + groups[tokenSymbol].totalQuantity += item.quantity; + groups[tokenSymbol].totalPrice += item.quantity * item.salePrice; + }); + + this.groupedCartItems = Object.values(groups); + this.cd.markForCheck(); + }); + } + + public updateStep(step: StepType) { + this.cartService.setCurrentStep(step); + } + + private removePurchasedGroupItems(): void { + if (this.selectedNetwork) { + this.cartService.removeGroupItemsFromCart(this.selectedNetwork); + this.cartService.clearNetworkSelection(); + } + } + + private calcPrice(item: CartItem, discount: number): number { + return this.cartService.calcPrice(item.nft, discount); + } + + private discount(item: CartItem): number { + return this.cartService.discount(item.collection, item.nft); + } + + public isCartItemAvailableForSale(item: CartItem): Observable { + return this.cartService + .isCartItemAvailableForSale(item) + .pipe(map((result) => result.isAvailable)); + } + + public reset(): void { + this.receivedTransactions = false; + this.currentStep = StepType.CONFIRM; + this.purchasedNfts = undefined; + this.history = []; + this.cd.markForCheck(); + } + + public handleClose(alsoCloseCartModal = false): void { + this.cartService.closeCheckoutOverlay(); + if (alsoCloseCartModal) { + this.cartService.hideCartModal(); + } + } + + public goToNft(nftUid: string): void { + if (!nftUid) { + return; + } + + this.router.navigate(['/', this.nftPath, nftUid]); + this.handleClose(true); + } + + public goToCollection(colUid: string): void { + if (!colUid) { + return; + } + + this.router.navigate(['/', this.collectionPath, colUid]); + this.handleClose(true); + } + + public goToMemberNfts(): void { + const memberId = this.auth.member$.value?.uid; + + if (!memberId) { + return; + } + + this.router.navigate(['/member', memberId, 'nfts']); + this.handleClose(true); + } + + public getRecords(): Nft[] | null | undefined { + return this.purchasedNfts || null; + } + + public pushToHistory( + transaction: Transaction, + uniqueId: string, + date?: dayjs.Dayjs | Timestamp | null, + text?: string, + link?: string, + ): void { + if ( + this.history.find((s) => { + return s.uniqueId === uniqueId; + }) + ) { + return; + } + + if (date && text) { + this.history.unshift({ + transaction, + uniqueId: uniqueId, + date: date, + label: text, + link: link, + }); + } + } + + public get lockTime(): number { + return TRANSACTION_AUTO_EXPIRY_MS / 1000 / 60; + } + + public async initiateBulkOrder(): Promise { + if (this.cartService.hasPendingTransaction()) { + this.nzNotification.error( + 'You currently have an open order. Pay for it or let it expire.', + '', + ); + return; + } + + const selectedGroup = this.groupedCartItems.find( + (group) => group.tokenSymbol === this.selectedNetwork, + ); + + if (selectedGroup && selectedGroup.items.length > 0) { + this.purchasedTokenSymbol = selectedGroup.tokenSymbol; + const firstItem = selectedGroup.items[0]; + this.mintingDataNetwork = firstItem.nft?.placeholderNft + ? firstItem.collection?.mintingData?.network + : firstItem.nft?.mintingData?.network; + } + + if (!selectedGroup || selectedGroup.items.length === 0) { + this.nzNotification.error( + $localize`No network selected or no items in the selected network.`, + '', + ); + return; + } + + const nfts = this.convertGroupedCartItemsToNfts(selectedGroup); + + if (nfts.length === 0) { + this.nzNotification.error($localize`No NFTs to purchase.`, ''); + return; + } + + await this.proceedWithBulkOrder(nfts); + } + + public convertGroupedCartItemsToNfts(selectedGroup: GroupedCartItem): NftPurchaseRequest[] { + const nfts: NftPurchaseRequest[] = []; + + selectedGroup.items.forEach((item) => { + if (item.nft && item.collection) { + for (let i = 0; i < item.quantity; i++) { + const nftData: NftPurchaseRequest = { + collection: item.collection.uid, + }; + + if (item.nft.owner || item.collection.type === CollectionType.CLASSIC) { + nftData.nft = item.nft.uid; + } + + nfts.push(nftData); + } + } + }); + + return nfts; + } + + public async proceedWithBulkOrder(nfts: NftPurchaseRequest[]): Promise { + const selectedGroup = this.groupedCartItems.find( + (group) => group.tokenSymbol === this.selectedNetwork, + ); + + if (!selectedGroup) { + this.nzNotification.error( + $localize`No network selected or no items in the selected network.`, + '', + ); + return; + } + + if (nfts.length === 0 || !this.agreeTermsConditions) { + this.nzNotification.error( + $localize`No NFTs to purchase or terms and conditions are not agreed.`, + '', + ); + return; + } + + const bulkPurchaseRequest = { + orders: nfts, + }; + + await this.auth.sign(bulkPurchaseRequest, async (signedRequest, finish) => { + this.notification + .processRequest( + this.orderApi.orderNfts(signedRequest), + $localize`Bulk order created.`, + finish, + ) + .subscribe((transaction: Transaction | undefined) => { + if (transaction) { + this.transSubscription$?.unsubscribe(); + setCheckoutTransaction({ + transactionId: transaction.uid, + source: 'cartCheckout', + }); + this.cartService.setCurrentTransaction(transaction.uid); + this.cartService.setCurrentStep(StepType.TRANSACTION); + this.cartService.refreshCartItems(); + this.cd.markForCheck(); + this.transSubscription$ = this.orderApi + .listen(transaction.uid) + .subscribe(this.transaction$); + this.pushToHistory( + transaction, + transaction.uid, + dayjs(), + $localize`Waiting for transaction...`, + ); + } else { + this.nzNotification.error( + $localize`Transaction failed or did not return a valid transaction.`, + '', + ); + } + }); + }); + } + + public getSelectedNetwork(): any { + const selectedNetwork = this.cartService.getSelectedNetwork(); + return selectedNetwork; + } + + ngOnDestroy() { + this.currentTransactionSubscription?.unsubscribe(); + window.removeEventListener('storage', this.listenToStorageForStepChanges); + } +} diff --git a/src/app/components/cart/services/cart.service.ts b/src/app/components/cart/services/cart.service.ts new file mode 100644 index 0000000..b3eaee4 --- /dev/null +++ b/src/app/components/cart/services/cart.service.ts @@ -0,0 +1,913 @@ +import { Injectable, NgZone } from '@angular/core'; +import { + BehaviorSubject, + Observable, + Subscription, + map, + of, + take, + tap, + catchError, + finalize, + combineLatest, + Subject, +} from 'rxjs'; +import { + Nft, + Collection, + Transaction, + MIN_AMOUNT_TO_TRANSFER, + TRANSACTION_AUTO_EXPIRY_MS, + DEFAULT_NETWORK, + getDefDecimalIfNotSet, + CollectionStatus, + DEFAULT_NETWORK_DECIMALS, + Network, + COL, + Timestamp, +} from '@build-5/interfaces'; +import { getItem, removeItem, StorageItem, getCheckoutTransaction } from '@core/utils'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { HelperService } from '@pages/nft/services/helper.service'; +import { AuthService } from '@components/auth/services/auth.service'; +import { NzModalRef, NzModalService } from 'ng-zorro-antd/modal'; +import { CheckoutOverlayComponent } from '@components/cart/components/checkout/checkout-overlay.component'; +import { OrderApi } from '@api/order.api'; +import { SpaceApi } from '@api/space.api'; +import { MemberApi } from '@api/member.api'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import dayjs from 'dayjs'; +import { NftApi } from '@api/nft.api'; +import { FilterStorageService } from '@core/services/filter-storage'; +import { AlgoliaService } from '@components/algolia/services/algolia.service'; +import { InstantSearchConfig } from 'angular-instantsearch/instantsearch/instantsearch'; + +export interface CartItem { + nft: Nft; + collection: Collection; + quantity: number; + salePrice: number; + pricing: { + originalPrice: number; + discountedPrice: number; + tokenSymbol: Network; + }; +} + +export interface ConvertValue { + value: number | null | undefined; + exponents: number | null | undefined; +} + +export enum StepType { + CONFIRM = 'Confirm', + TRANSACTION = 'Transaction', + WAIT = 'Wait', + COMPLETE = 'Complete', +} + +export const CART_STORAGE_KEY = 'App/cartItems'; +export const NETWORK_STORAGE_KEY = 'cartCheckoutSelectedNetwork'; +export const STEP_STORAGE_KEY = 'cartCheckoutCurrentStep'; +export const TRAN_STORAGE_KEY = 'App/checkoutTransaction'; + +@Injectable({ + providedIn: 'root', +}) +@UntilDestroy() +export class CartService { + private cartItemsSubject$ = new BehaviorSubject(this.loadCartItems()); + private cartModalOpenSubject$ = new BehaviorSubject(false); + public cartModalOpen$ = this.cartModalOpenSubject$.asObservable(); + public checkoutOverlayOpenSubject$ = new BehaviorSubject(false); + public checkoutOverlayOpen$ = this.checkoutOverlayOpenSubject$.asObservable(); + private currentStepSubject$ = new BehaviorSubject(StepType.CONFIRM); + public currentStep$ = this.currentStepSubject$.asObservable(); + private checkoutOverlayModalRef: NzModalRef | null = null; + public pendingTransaction$: BehaviorSubject = new BehaviorSubject< + Transaction | undefined + >(undefined); + private memberSpacesSubject$ = new BehaviorSubject([]); + private memberGuardianSpacesSubject$ = new BehaviorSubject([]); + private memberAwardsSubject$ = new BehaviorSubject([]); + private memberNftCollectionIdsSubject$ = new BehaviorSubject([]); + private guardianSpaceSubscriptions$: { [key: string]: Subscription } = {}; + private isLoadingSubject$ = new BehaviorSubject(false); + public isLoading$ = this.isLoadingSubject$.asObservable(); + public config?: InstantSearchConfig; + private selectedNetworkSubject$ = new BehaviorSubject(this.getSelectedNetwork()); + public selectedNetwork$ = this.selectedNetworkSubject$.asObservable(); + private cartUpdateSubject$ = new Subject(); + public cartUpdateObservable$ = this.cartUpdateSubject$.asObservable(); + public triggerChangeDetectionSubject$ = new Subject(); + private transactionCheckInterval: any = null; + + constructor( + private notification: NzNotificationService, + private helperService: HelperService, + public auth: AuthService, + private modalService: NzModalService, + private orderApi: OrderApi, + private spaceApi: SpaceApi, + private memberApi: MemberApi, + private zone: NgZone, + private nftApi: NftApi, + public filterStorageService: FilterStorageService, + public readonly algoliaService: AlgoliaService, + ) { + this.subscribeToMemberChanges(); + this.listenToStorageChanges(); + } + + private listenToStorageChanges(): void { + window.addEventListener('storage', (event) => { + if (event.storageArea === localStorage) { + this.zone.run(() => { + switch (event.key) { + case CART_STORAGE_KEY: { + const updatedCartItems = JSON.parse(event.newValue || '[]'); + this.cartItemsSubject$.next(updatedCartItems); + break; + } + case NETWORK_STORAGE_KEY: { + const updatedNetwork = JSON.parse(event.newValue || 'null'); + this.selectedNetworkSubject$.next(updatedNetwork); + break; + } + case STEP_STORAGE_KEY: { + const newStep = JSON.parse(event.newValue || 'null'); + this.currentStepSubject$.next(newStep); + break; + } + case TRAN_STORAGE_KEY: { + const newTran = JSON.parse(event.newValue || 'null'); + this.pendingTransaction$.next(newTran); + break; + } + } + this.cartUpdateSubject$.next(); + }); + } + }); + } + + public startTransactionExpiryCheck(): void { + if (this.transactionCheckInterval) { + clearInterval(this.transactionCheckInterval); + } + + this.transactionCheckInterval = setInterval(() => { + const transaction = this.pendingTransaction$.getValue(); + const currentStep = this.getCurrentStep(); + if (currentStep === StepType.TRANSACTION || currentStep === StepType.WAIT) { + if (transaction && transaction.uid) { + const expiresOn: dayjs.Dayjs = dayjs(transaction.createdOn!.toDate()).add( + TRANSACTION_AUTO_EXPIRY_MS, + 'ms', + ); + this.triggerChangeDetectionSubject$.next(); + + if ( + expiresOn.isBefore(dayjs()) || + transaction.payload?.void || + transaction.payload?.reconciled + ) { + removeItem(StorageItem.CheckoutTransaction); + this.pendingTransaction$.next(undefined); + this.triggerChangeDetectionSubject$.next(); + clearInterval(this.transactionCheckInterval); + this.transactionCheckInterval = null; + } + } else { + removeItem(StorageItem.CheckoutTransaction); + this.pendingTransaction$.next(undefined); + this.triggerChangeDetectionSubject$.next(); + clearInterval(this.transactionCheckInterval); + this.transactionCheckInterval = null; + } + } + }, 1000); + } + + public getSelectedNetwork(): string | null { + const selectedNetwork = localStorage.getItem(NETWORK_STORAGE_KEY); + return selectedNetwork ? JSON.parse(selectedNetwork) : null; + } + + public setNetworkSelection(networkSymbol: string): void { + const newNetwork = JSON.stringify(networkSymbol); + localStorage.setItem(NETWORK_STORAGE_KEY, newNetwork); + this.selectedNetworkSubject$.next(networkSymbol); + this.triggerChangeDetectionSubject$.next(); + } + + public clearNetworkSelection(): void { + localStorage.removeItem(NETWORK_STORAGE_KEY); + this.selectedNetworkSubject$.next(null); + } + + public getDefaultNetworkDecimals(): number { + return DEFAULT_NETWORK_DECIMALS; + } + + public setCurrentStep(step: StepType): void { + const newStep = JSON.stringify(step); + localStorage.setItem(STEP_STORAGE_KEY, newStep); + this.currentStepSubject$.next(step); + this.triggerChangeDetectionSubject$.next(); + } + + public getCurrentStep(): StepType { + const stepValue = localStorage.getItem(STEP_STORAGE_KEY); + return stepValue ? JSON.parse(stepValue) : StepType.CONFIRM; + } + + public setCurrentTransaction(transactionId: string): void { + if (transactionId === null || transactionId === undefined) { + return; + } + + this.orderApi + .listen(transactionId) + .pipe(untilDestroyed(this)) + .subscribe((transaction) => { + this.pendingTransaction$.next(transaction); + this.startTransactionExpiryCheck(); + this.triggerChangeDetectionSubject$.next(); + }); + } + + public getCurrentTransaction(): Observable { + return this.pendingTransaction$.asObservable(); + } + + public hasPendingTransaction(): boolean { + const checkoutTransaction = getCheckoutTransaction(); + const transactionId = checkoutTransaction?.transactionId; + return !!transactionId; + } + + public saveCartItems(): void { + localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(this.cartItemsSubject$.getValue())); + } + + public loadCartItems(): CartItem[] { + const items = getItem(StorageItem.CartItems) as CartItem[]; + return items || []; + } + + public getCartItems(): Observable { + return this.cartItemsSubject$.asObservable(); + } + + public clearCart(): void { + this.cartItemsSubject$.next([]); + this.saveCartItems(); + this.notification.success($localize`All items have been removed from your cart.`, ''); + } + + public refreshCartItems(): void { + this.cartItemsSubject$.next(this.cartItemsSubject$.value); + } + + public removeFromCart(cartItem: CartItem): void { + const updatedCartItems = this.cartItemsSubject$.value.filter( + (item) => item.nft.uid !== cartItem.nft.uid, + ); + this.cartItemsSubject$.next(updatedCartItems); + this.saveCartItems(); + } + + public removeItemsFromCart(itemIds: string[]): void { + const updatedCartItems = this.cartItemsSubject$.value.filter( + (item) => !itemIds.includes(item.nft.uid), + ); + this.cartItemsSubject$.next(updatedCartItems); + this.saveCartItems(); + } + + private subscribeToMemberChanges() { + this.auth.member$.pipe(untilDestroyed(this)).subscribe((member) => { + if (member) { + this.refreshMemberData(); + } else { + this.resetMemberData(); + } + }); + } + + private refreshMemberData() { + const memberId = this.auth.member$.value?.uid; + if (memberId) { + this.loadMemberSpaces(memberId); + this.loadMemberAwards(memberId); + this.loadMemberNfts(memberId); + } else { + this.resetMemberData(); + } + } + + private resetMemberData() { + this.memberSpacesSubject$.next([]); + this.memberGuardianSpacesSubject$.next([]); + this.memberAwardsSubject$.next([]); + this.memberNftCollectionIdsSubject$.next([]); + this.cleanupGuardianSubscriptions(); + } + + private loadMemberSpaces(memberId: string): void { + this.memberApi + .allSpacesAsMember(memberId) + .pipe( + untilDestroyed(this), + map((spaces) => spaces.map((space) => space.uid)), + ) + .subscribe((spaceIds) => { + this.memberSpacesSubject$.next(spaceIds); + spaceIds.forEach((spaceId) => this.updateMemberGuardianStatus(spaceId)); + }); + } + + private loadMemberAwards(memberId: string): void { + this.memberApi + .topAwardsCompleted(memberId) + .pipe( + untilDestroyed(this), + map((awards) => awards.map((award) => award.uid)), + ) + .subscribe((awardIds) => { + this.memberAwardsSubject$.next(awardIds); + }); + } + + private loadMemberNfts(memberId: string): void { + this.algoliaService + .fetchAllOwnedNfts(memberId, COL.NFT) + .then((nfts) => { + const results = this.convertAllToSoonaverseModel(nfts); + + const collectionIds = results + .map((hit) => hit.collection) + .filter((value, index, self) => self.indexOf(value) === index); + const currentIds = this.memberNftCollectionIdsSubject$.getValue(); + const allIds = [...new Set([...currentIds, ...collectionIds])]; + this.memberNftCollectionIdsSubject$.next(allIds); + }) + .catch((error) => { + console.error('Error fetching owned NFTs:', error); + }); + } + + public convertAllToSoonaverseModel(algoliaItems: any[]) { + return algoliaItems.map((algolia) => ({ + ...algolia, + availableFrom: Timestamp.fromMillis(+algolia.availableFrom), + })); + } + + private updateMemberGuardianStatus(spaceId: string) { + if (this.auth.member$.value?.uid) { + this.guardianSpaceSubscriptions$[spaceId]?.unsubscribe(); + this.guardianSpaceSubscriptions$[spaceId] = this.spaceApi + .isGuardianWithinSpace(spaceId, this.auth.member$.value?.uid) + .pipe(untilDestroyed(this)) + .subscribe((isGuardian) => { + this.updateGuardianSpaces(isGuardian, spaceId); + }); + } + } + + private updateGuardianSpaces(isGuardian: boolean, spaceId: string) { + let currentGuardianSpaces = this.memberGuardianSpacesSubject$.getValue(); + if (isGuardian && !currentGuardianSpaces.includes(spaceId)) { + currentGuardianSpaces.push(spaceId); + } else if (!isGuardian && currentGuardianSpaces.includes(spaceId)) { + currentGuardianSpaces = currentGuardianSpaces.filter((id) => id !== spaceId); + } + this.memberGuardianSpacesSubject$.next(currentGuardianSpaces); + } + + private cleanupGuardianSubscriptions() { + Object.values(this.guardianSpaceSubscriptions$).forEach((subscription) => + subscription.unsubscribe(), + ); + this.guardianSpaceSubscriptions$ = {}; + } + + get memberSpaces$(): Observable { + return this.memberSpacesSubject$.asObservable(); + } + + get memberGuardianSpaces$(): Observable { + return this.memberGuardianSpacesSubject$.asObservable(); + } + + get memberAwards$(): Observable { + return this.memberAwardsSubject$.asObservable(); + } + + public showCartModal(): void { + this.isLoadingSubject$.next(true); + + const cartItems = this.cartItemsSubject$.getValue(); + if (cartItems.length === 0) { + this.isLoadingSubject$.next(false); + this.cartModalOpenSubject$.next(true); + return; + } + + const freshDataObservables = cartItems.map((item) => + this.nftApi.getNftById(item.nft.uid).pipe( + take(1), + map((freshNft) => (freshNft ? { ...item, nft: freshNft } : item)), + catchError(() => of(item)), + ), + ); + + combineLatest(freshDataObservables) + .pipe( + take(1), + finalize(() => this.isLoadingSubject$.next(false)), + ) + .subscribe((updatedCartItems) => { + this.cartItemsSubject$.next(updatedCartItems); + this.cartModalOpenSubject$.next(true); + }); + } + + public hideCartModal(): void { + this.cartModalOpenSubject$.next(false); + } + + public isCartModalOpen(): boolean { + return this.cartModalOpenSubject$.getValue(); + } + + public openCheckoutOverlay(): void { + if (!this.checkoutOverlayOpenSubject$.getValue()) { + const checkoutTransaction = getCheckoutTransaction(); + + if (checkoutTransaction && checkoutTransaction.transactionId) { + if (checkoutTransaction.source === 'nftCheckout') { + this.notification.error( + 'You currently have an open order. Pay for it or let it expire.', + '', + ); + return; + } + + if (checkoutTransaction.source === 'cartCheckout') { + this.setCurrentTransaction(checkoutTransaction.transactionId); + this.getCurrentTransaction() + .pipe(take(1)) + .subscribe((currentTransaction) => { + if (currentTransaction && currentTransaction.uid) { + const expiresOn: dayjs.Dayjs = dayjs(currentTransaction.createdOn!.toDate()).add( + TRANSACTION_AUTO_EXPIRY_MS, + 'ms', + ); + + if ( + expiresOn.isBefore(dayjs()) || + currentTransaction.payload?.void || + currentTransaction.payload?.reconciled + ) { + removeItem(StorageItem.CheckoutTransaction); + this.pendingTransaction$.next(undefined); + this.setCurrentStep(StepType.CONFIRM); + this.openModal(); + } else { + this.openModal(); + } + } else { + this.openModal(); + } + }); + } else { + this.notification.error( + 'CheckoutTransaction exists and source is not nftCheckout, the checkout-overlay is not open and the pending transaction not expired or complete and the source is not cartCheckout, this should never happen.', + '', + ); + return; + } + } else { + this.openModal(); + } + } + } + + private openModal(): void { + this.getCartItems() + .pipe(take(1)) + .subscribe((cartItems) => { + if (cartItems.length > 0) { + this.checkoutOverlayModalRef = this.modalService.create({ + nzTitle: 'Checkout', + nzContent: CheckoutOverlayComponent, + nzComponentParams: { items: cartItems }, + nzFooter: null, + nzWidth: '80%', + }); + + this.checkoutOverlayModalRef.afterClose.subscribe(() => { + this.closeCheckoutOverlay(); + }); + + this.checkoutOverlayOpenSubject$.next(true); + } + }); + } + + public closeCheckoutOverlay(): void { + const checkoutTransaction = getCheckoutTransaction(); + if (checkoutTransaction && checkoutTransaction.transactionId) { + if (checkoutTransaction.source === 'cartCheckout') { + this.setCurrentTransaction(checkoutTransaction.transactionId); + const currentTransaction = this.pendingTransaction$.getValue(); + if (this.checkoutOverlayModalRef) { + const currentStep = this.getCurrentStep(); + if (currentStep === StepType.TRANSACTION || currentStep === StepType.WAIT) { + if (currentTransaction && currentTransaction.uid) { + const expiresOn: dayjs.Dayjs = dayjs(currentTransaction.createdOn!.toDate()).add( + TRANSACTION_AUTO_EXPIRY_MS, + 'ms', + ); + + if ( + expiresOn.isBefore(dayjs()) || + currentTransaction.payload?.void || + currentTransaction.payload?.reconciled + ) { + removeItem(StorageItem.CheckoutTransaction); + this.pendingTransaction$.next(undefined); + this.setCurrentStep(StepType.CONFIRM); + } + } else { + removeItem(StorageItem.CheckoutTransaction); + this.pendingTransaction$.next(undefined); + this.setCurrentStep(StepType.CONFIRM); + } + + this.checkoutOverlayModalRef.close(); + this.checkoutOverlayOpenSubject$.next(false); + this.checkoutOverlayModalRef = null; + } else { + this.checkoutOverlayModalRef.close(); + this.checkoutOverlayOpenSubject$.next(false); + this.checkoutOverlayModalRef = null; + } + } else { + this.checkoutOverlayOpenSubject$.next(false); + } + } else { + if (this.checkoutOverlayModalRef) { + this.checkoutOverlayModalRef.close(); + this.checkoutOverlayOpenSubject$.next(false); + this.checkoutOverlayModalRef = null; + this.setCurrentStep(StepType.CONFIRM); + } + this.checkoutOverlayOpenSubject$.next(false); + } + } else { + if (this.checkoutOverlayModalRef) { + this.checkoutOverlayModalRef.close(); + this.checkoutOverlayOpenSubject$.next(false); + this.checkoutOverlayModalRef = null; + this.pendingTransaction$.next(undefined); + this.setCurrentStep(StepType.CONFIRM); + } + this.checkoutOverlayOpenSubject$.next(false); + } + } + + public isCheckoutOverlayOpen(): boolean { + return this.checkoutOverlayOpenSubject$.getValue(); + } + + public openCartAndCheckoutOverlay(): void { + const wasCartModalOpen = this.cartModalOpenSubject$.getValue(); + + if (!wasCartModalOpen) { + this.showCartModal(); + } + + if (!this.checkoutOverlayOpenSubject$.getValue()) { + if (!wasCartModalOpen) { + setTimeout(() => { + this.openCheckoutOverlay(); + }, 300); + } else { + this.openCheckoutOverlay(); + } + } + } + + public cartItemStatus(item: CartItem): Observable<{ status: string; message: string }> { + return this.isCartItemAvailableForSale(item).pipe( + map((availabilityResult) => ({ + status: availabilityResult.isAvailable ? 'Available' : 'Not Available', + message: availabilityResult.message, + })), + ); + } + + public addToCart(nft: Nft, collection: Collection, quantity = 1, hideNotif = false): void { + const currentItems = this.cartItemsSubject$.getValue(); + + if (currentItems.length >= 100) { + this.notification.error('You cannot add more than 100 unique NFTs to your cart.', ''); + return; + } + + const existingItemIndex = currentItems.findIndex((item) => item.nft.uid === nft.uid); + + if (existingItemIndex > -1) { + this.notification.error('This NFT already exists in your cart.', ''); + return; + } + + const discountRate = this.discount(collection, nft); + const originalPrice = this.calcPrice(nft, 1); + const discountedPrice = this.calcPrice(nft, discountRate); + const tokenSymbol = + (nft.placeholderNft ? collection.mintingData?.network : nft.mintingData?.network) || + DEFAULT_NETWORK; + + const cartItem: CartItem = { + nft: nft, + collection: collection, + quantity: quantity, + salePrice: discountRate < 1 ? discountedPrice : originalPrice, + pricing: { + originalPrice, + discountedPrice, + tokenSymbol, + }, + }; + + const updatedCartItems = [...currentItems, cartItem]; + this.cartItemsSubject$.next(updatedCartItems); + this.saveCartItems(); + + if (hideNotif === false) { + this.notification.success( + `NFT ${nft.name} from collection ${collection.name} has been added to your cart.`, + '', + ); + } + } + + public discount(collection?: Collection | null, nft?: Nft | null): number { + if (!collection?.space || !this.auth.member$.value || nft?.owner) { + return 1; + } + + const spaceRewards = (this.auth.member$.value.spaces || {})[collection.space]; + const descDiscounts = [...(collection.discounts || [])].sort((a, b) => b.amount - a.amount); + for (const discount of descDiscounts) { + const awardStat = (spaceRewards?.awardStat || {})[discount.tokenUid!]; + const memberTotalReward = awardStat?.totalReward || 0; + if (memberTotalReward >= discount.tokenReward) { + return 1 - discount.amount; + } + } + return 1; + } + + public calc(amount: number | null | undefined, discount: number): number { + let finalPrice = Math.ceil((amount || 0) * discount); + if (finalPrice < MIN_AMOUNT_TO_TRANSFER) { + finalPrice = MIN_AMOUNT_TO_TRANSFER; + } + + return finalPrice; + } + + public calcPrice(nft: Nft, discount: number): number { + const itemPrice = nft?.availablePrice || nft?.price || 0; + return this.calc(itemPrice, discount); + } + + public removeGroupItemsFromCart(tokenSymbol: string): void { + const updatedCartItems = this.cartItemsSubject$.value.filter((item) => { + const itemTokenSymbol = + (item.nft?.placeholderNft + ? item.collection?.mintingData?.network + : item.nft?.mintingData?.network) || DEFAULT_NETWORK; + return itemTokenSymbol !== tokenSymbol; + }); + this.cartItemsSubject$.next(updatedCartItems); + this.saveCartItems(); + } + + public updateCartItemQuantity(itemId: string, newQuantity: number): void { + this.cartItemsSubject$ + .pipe( + take(1), + tap((cartItems) => { + const updatedCartItems = cartItems.map((item) => { + if (item.nft.uid === itemId) { + return { ...item, quantity: newQuantity }; + } + return item; + }); + + this.cartItemsSubject$.next(updatedCartItems); + this.saveCartItems(); + }), + ) + .subscribe(); + } + + public isNftAvailableForSale( + nft: Nft, + collection: Collection, + checkCartPresence = false, + checkPendingTransaction = true, + ): Observable<{ isAvailable: boolean; message: string }> { + let message = 'NFT is available for sale.'; + const conditions: string[] = []; + + let isAvailable = false; + + const memberSpaces = this.memberSpacesSubject$.getValue(); + const memberGuardianSpaces = this.memberGuardianSpacesSubject$.getValue(); + const memberAwards = this.memberAwardsSubject$.getValue(); + const memberNftCollectionIds = this.memberNftCollectionIdsSubject$.getValue(); + + if (checkPendingTransaction) { + const pendingTrx = this.hasPendingTransaction(); + if (pendingTrx) { + message = + 'Finish pending cart checkout transaction or wait for it to expire before adding more items to cart.'; + return of({ + isAvailable, + message, + }); + } + } + + if (checkCartPresence) { + const isNftInCart = this.cartItemsSubject$ + .getValue() + .some((cartItem) => cartItem.nft.uid === nft.uid); + if (isNftInCart) { + message = 'This NFT is already in your cart.'; + return of({ + isAvailable, + message, + }); + } + } + + if (!collection) { + message = 'Internal Error: Collection data is null or undefined.'; + return of({ + isAvailable, + message, + }); + } + + if (!nft?.availableFrom) { + message = 'Internal Error: Nft and/or NFT Available From date is null or undefined.'; + return of({ + isAvailable, + message, + }); + } + + let validAvailableFromDate = + !!this.helperService.getDate(nft.availableFrom) && + dayjs(this.helperService.getDate(nft.availableFrom)).isSameOrBefore(dayjs(), 's'); + if (!validAvailableFromDate) { + validAvailableFromDate = + !!this.helperService.getDate(collection.availableFrom) && + dayjs(this.helperService.getDate(collection.availableFrom)).isSameOrBefore(dayjs(), 's'); + } + if (!validAvailableFromDate) conditions.push('NFT does not have a valid "availableFrom" date.'); + + const collectionApproved = collection.approved; + if (!collectionApproved) conditions.push('Collection is not Approved.'); + + const collectionStatusMinting = collection?.status === CollectionStatus.MINTING; + if (collectionStatusMinting) conditions.push('Collection is in minting status.'); + + const isLocked = this.helperService.isLocked(nft, collection, true); + if (isLocked) conditions.push('NFT is locked.'); + + const saleAccessMembersBlocked = + (nft?.saleAccessMembers?.length ?? 0) > 0 && + !nft?.saleAccessMembers?.includes(this.auth.member$.value?.uid ?? ''); + if (saleAccessMembersBlocked) conditions.push('Sale access is blocked for this member.'); + + let isOwner = false; + if (nft.owner != null && this.auth.member$.value?.uid != null) { + isOwner = nft.owner === this.auth.member$.value?.uid; + } + if (isOwner) conditions.push('You are the owner of this NFT.'); + + const availableValue = +nft?.available; + const nftAvailable = + availableValue === 1 || + availableValue === 3 || + nft?.available === null || + nft?.available === undefined; + if (!nftAvailable) conditions.push('NFT is not marked as available.'); + + let spaceMemberAccess = true; + let spaceGuardianAccess = true; + let spaceAwardAccess = true; + let nftOwnedAccess = true; + + if (nft?.isOwned === false) { + spaceMemberAccess = + collection?.access !== 1 || + (collection?.access === 1 && memberSpaces.includes(collection?.space ?? '')); + if (!spaceMemberAccess) conditions.push('Member does not have access to this space.'); + + spaceGuardianAccess = + collection?.access !== 2 || + (collection?.access === 2 && memberGuardianSpaces.includes(collection?.space ?? '')); + if (!spaceGuardianAccess) conditions.push('Member is not a guardian of this space.'); + + spaceAwardAccess = + collection?.access !== 3 || + (collection?.access === 3 && + collection?.accessAwards?.some((award) => memberAwards.includes(award))); + if (!spaceAwardAccess) + conditions.push('Member does not have the required awards for access.'); + + nftOwnedAccess = + collection?.access !== 4 || + (collection?.access === 4 && + collection?.accessCollections?.every((coll) => memberNftCollectionIds.includes(coll))); + if (!nftOwnedAccess) + conditions.push( + 'Member does not own at least one NFT from each of the required access collections.', + ); + } + + isAvailable = + !collectionStatusMinting && + collectionApproved && + validAvailableFromDate && + !isLocked && + !saleAccessMembersBlocked && + (!isOwner || !nft.owner) && + nftAvailable && + spaceMemberAccess && + spaceGuardianAccess && + spaceAwardAccess && + nftOwnedAccess; + + if (!isAvailable && conditions.length > 0) { + message = + 'NFT is not available for sale due to the following conditions: ' + conditions.join(' '); + } + + return of({ + isAvailable, + message, + }); + } + + public isCartItemAvailableForSale( + cartItem: CartItem, + checkCartPresence = false, + ): Observable<{ isAvailable: boolean; message: string }> { + return this.isNftAvailableForSale( + cartItem.nft, + cartItem.collection, + checkCartPresence, + false, + ).pipe( + map((result) => { + return { + isAvailable: result.isAvailable, + message: result.message, + }; + }), + ); + } + + public getAvailableNftQuantity(cartItem: CartItem): Observable { + return this.isCartItemAvailableForSale(cartItem).pipe( + map((result) => { + if (result.isAvailable) { + return cartItem.nft.placeholderNft ? cartItem.collection.availableNfts || 0 : 1; + } else { + return 0; + } + }), + map((quantity) => (quantity === null ? 0 : quantity)), + ); + } + + public valueDivideExponent(value: ConvertValue): number { + if (value.exponents === 0 || value.value === null || value.value === undefined) { + return value.value!; + } else { + return value.value! / Math.pow(10, getDefDecimalIfNotSet(value.exponents)); + } + } +} diff --git a/src/app/components/collection/components/collection-card/collection-card.component.html b/src/app/components/collection/components/collection-card/collection-card.component.html index 088f29a..802d069 100644 --- a/src/app/components/collection/components/collection-card/collection-card.component.html +++ b/src/app/components/collection/components/collection-card/collection-card.component.html @@ -106,9 +106,9 @@ class="text-sm font-medium truncate text-foregrounds-primary dark:text-foregrounds-primary-dark" > {{ - collection?.floorPrice - ? (collection?.floorPrice - | formatToken : collection?.mintingData?.network : false : true : 2 + collection.floorPrice + ? (collection.floorPrice + | formatToken : collection.mintingData?.network : false : true : 2 | async) : '-' }} diff --git a/src/app/components/icon/cart/cart.component.html b/src/app/components/icon/cart/cart.component.html new file mode 100644 index 0000000..4c29236 --- /dev/null +++ b/src/app/components/icon/cart/cart.component.html @@ -0,0 +1,11 @@ + + + + + diff --git a/src/app/components/icon/cart/cart.component.less b/src/app/components/icon/cart/cart.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/icon/cart/cart.component.ts b/src/app/components/icon/cart/cart.component.ts new file mode 100644 index 0000000..1886bfa --- /dev/null +++ b/src/app/components/icon/cart/cart.component.ts @@ -0,0 +1,9 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'wen-icon-cart', + templateUrl: './cart.component.html', + styleUrls: ['./cart.component.less'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CartIconComponent {} diff --git a/src/app/components/icon/icon.module.ts b/src/app/components/icon/icon.module.ts index dc57b80..4098511 100644 --- a/src/app/components/icon/icon.module.ts +++ b/src/app/components/icon/icon.module.ts @@ -12,6 +12,7 @@ import { AssemblyIconComponent } from './assembly/assembly.component'; import { AwardIconComponent } from './award/award.component'; import { BellIconComponent } from './bell/bell.component'; import { CalendarIconComponent } from './calendar/calendar.component'; +import { CartIconComponent } from './cart/cart.component'; import { CheckCircleIconComponent } from './check-circle/check-circle.component'; import { CheckIconComponent } from './check/check.component'; import { CloseIconComponent } from './close/close.component'; @@ -187,6 +188,7 @@ import { WalletIconComponent } from './wallet/wallet.component'; SpecificMembersOnlyIconComponent, SadCryIconComponent, LinkBrokenIconComponent, + CartIconComponent, ], exports: [ GlobeIconComponent, @@ -279,6 +281,7 @@ import { WalletIconComponent } from './wallet/wallet.component'; SpecificMembersOnlyIconComponent, SadCryIconComponent, LinkBrokenIconComponent, + CartIconComponent, ], imports: [CommonModule], }) diff --git a/src/app/components/member/components/member-edit-drawer/member-edit-drawer.component.html b/src/app/components/member/components/member-edit-drawer/member-edit-drawer.component.html index 7048756..31bf548 100644 --- a/src/app/components/member/components/member-edit-drawer/member-edit-drawer.component.html +++ b/src/app/components/member/components/member-edit-drawer/member-edit-drawer.component.html @@ -84,7 +84,7 @@

Avatar

[nzValue]="s.value" > Avatar
+ + + + + + +
diff --git a/src/app/components/nft/components/nft-card/nft-card.component.ts b/src/app/components/nft/components/nft-card/nft-card.component.ts index 3c70eb3..2ca245b 100644 --- a/src/app/components/nft/components/nft-card/nft-card.component.ts +++ b/src/app/components/nft/components/nft-card/nft-card.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; import { Router } from '@angular/router'; import { FileApi } from '@api/file.api'; import { MemberApi } from '@api/member.api'; @@ -10,6 +17,7 @@ import { UnitsService } from '@core/services/units'; import { ROUTER_UTILS } from '@core/utils/router.utils'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { HelperService } from '@pages/nft/services/helper.service'; +import { CartService } from '@components/cart/services/cart.service'; import { Access, Collection, @@ -30,19 +38,20 @@ import { BehaviorSubject, Subscription, take } from 'rxjs'; styleUrls: ['./nft-card.component.less'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class NftCardComponent { +export class NftCardComponent implements OnInit, OnDestroy { + private cartSubscription$!: Subscription; @Input() fullWidth?: boolean; @Input() enableWithdraw?: boolean; @Input() set nft(value: Nft | null | undefined) { - if (this.memberApiSubscription) { - this.memberApiSubscription.unsubscribe(); + if (this.memberApiSubscription$) { + this.memberApiSubscription$.unsubscribe(); } this._nft = value; const owner = this.nft?.owner || this.nft?.createdBy; if (owner) { - this.memberApiSubscription = this.memberApi + this.memberApiSubscription$ = this.memberApi .listen(owner) .pipe(untilDestroyed(this)) .subscribe(this.owner$); @@ -75,7 +84,7 @@ export class NftCardComponent { public owner$: BehaviorSubject = new BehaviorSubject( undefined, ); - private memberApiSubscription?: Subscription; + private memberApiSubscription$?: Subscription; private _nft?: Nft | null; constructor( @@ -89,8 +98,15 @@ export class NftCardComponent { private memberApi: MemberApi, private fileApi: FileApi, private cache: CacheService, + public cartService: CartService, ) {} + ngOnInit(): void { + this.cartSubscription$ = this.cartService.getCartItems().subscribe(() => { + this.cd.markForCheck(); + }); + } + public onBuy(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); @@ -182,4 +198,23 @@ export class NftCardComponent { public get collectionStatuses(): typeof CollectionStatus { return CollectionStatus; } + + public addToCart( + event: MouseEvent, + nft: Nft | null | undefined, + collection: Collection | null | undefined, + ): void { + event.stopPropagation(); + event.preventDefault(); + + if (nft && collection) { + this.cartService.addToCart(nft, collection); + } else { + console.error('Attempted to add a null or undefined NFT or Collection to the cart'); + } + } + + ngOnDestroy() { + this.cartSubscription$.unsubscribe(); + } } diff --git a/src/app/components/nft/components/nft-checkout/nft-checkout.component.html b/src/app/components/nft/components/nft-checkout/nft-checkout.component.html index 5130502..3c7e742 100644 --- a/src/app/components/nft/components/nft-checkout/nft-checkout.component.html +++ b/src/app/components/nft/components/nft-checkout/nft-checkout.component.html @@ -8,8 +8,8 @@ -
-
+
+
-
+ +

{{ getTitle() }}

{{ collection?.name }}
@@ -65,41 +66,405 @@

{{ getTitle() }}

-
- Total price -
-
-
- {{ targetPrice | formatToken : collection?.mintingData?.network : true | async }} + +
+ Total price
-
- {{ - calc(targetPrice, discount()) - | formatToken : collection?.mintingData?.network : true - | async - }} + +
+
+ {{ targetPrice | formatToken : collection?.mintingData?.network : true | async }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: targetPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+ {{ + calc(targetPrice, discount()) + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: calc(targetPrice, discount()) || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
-
-
+
+ {{ + (currentStep !== stepType.CONFIRM ? targetAmount : targetPrice) + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: currentStep !== stepType.CONFIRM ? targetAmount : targetPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+
+ + + -
- {{ - currentStep !== stepType.CONFIRM - ? (targetAmount | formatToken : collection?.mintingData?.network : true | async) - : (targetPrice | formatToken : collection?.mintingData?.network : true | async) - }} +
+
+ +
+ + You have selected to perform a bulk order of this NFT. Please review quantity + selected before finalizing and purchase. + +
+
-
+
+ +
+
+ Price +
+
+
+ {{ + targetPrice | formatToken : collection?.mintingData?.network : true | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: targetPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+ {{ + calc(targetPrice, discount()) + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: calc(targetPrice, discount()) || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+
+
+ {{ + (currentStep !== stepType.CONFIRM ? pricePerItem : targetPrice) + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: + currentStep !== stepType.CONFIRM ? pricePerItem : targetPrice || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+
+ + +
+
+ Quantity +
+
+
+ {{ nftQuantity }}
+   +
+
+
+ + +
+
+ Total +
+
+
+ {{ + targetPrice * nftQuantity + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: targetPrice * nftQuantity || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+ {{ + calc(targetPrice, discount()) * nftQuantity + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: calc(targetPrice, discount()) * nftQuantity || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+
+
+ {{ + (currentStep !== stepType.CONFIRM && targetAmount !== null + ? targetAmount + : targetPrice * nftQuantity + ) + | formatToken : collection?.mintingData?.network : true + | async + }} +
+ + ({{ + unitsService.getUsd( + cartService.valueDivideExponent({ + value: + currentStep !== stepType.CONFIRM && targetAmount !== null + ? targetAmount + : targetPrice * nftQuantity || 0, + exponents: cartService.getDefaultNetworkDecimals() + }), + collection?.mintingData?.network + ) + | async + | currency : 'USD' + | UsdBelowTwoDecimals + }}) + +
+
+
+
+
+ + + +
+
+ +
+ Warning: Missing or Invalid Data +
+
+ +
+
+
+
+ Target Price +
+
+ {{ targetPrice }} +
+
+
+ +
+
+
+ Target Amount +
+
+ {{ targetAmount }} +
+
+
+ +
+
+
+ Quantity Selected +
+
+ {{ nftQuantity }} +
+
+
+ +
+
+
+ Discount +
+
+ {{ discount() }} +
+
+
+ +
+
+
+ Purchase Workflow Step +
+
+ {{ currentStep }} +
+
+
+
+
+
@@ -287,6 +652,7 @@

{{ getTitle() }}

(click)="goToNft()" nzSize="large" class="w-full mb-6 lg:mb-0 lg:mt-1" + *ngIf="!nftQuantity || nftQuantity <= 1" i18n > Show my NFT diff --git a/src/app/components/nft/components/nft-checkout/nft-checkout.component.less b/src/app/components/nft/components/nft-checkout/nft-checkout.component.less index e69de29..dd760e2 100644 --- a/src/app/components/nft/components/nft-checkout/nft-checkout.component.less +++ b/src/app/components/nft/components/nft-checkout/nft-checkout.component.less @@ -0,0 +1,3 @@ +.alignCenter { + text-align: center; +} diff --git a/src/app/components/nft/components/nft-checkout/nft-checkout.component.ts b/src/app/components/nft/components/nft-checkout/nft-checkout.component.ts index ac136ea..b4b457d 100644 --- a/src/app/components/nft/components/nft-checkout/nft-checkout.component.ts +++ b/src/app/components/nft/components/nft-checkout/nft-checkout.component.ts @@ -20,7 +20,12 @@ import { NotificationService } from '@core/services/notification'; import { PreviewImageService } from '@core/services/preview-image'; import { TransactionService } from '@core/services/transaction'; import { UnitsService } from '@core/services/units'; -import { getItem, removeItem, setItem, StorageItem } from '@core/utils'; +import { + removeItem, + StorageItem, + setCheckoutTransaction, + getCheckoutTransaction, +} from '@core/utils'; import { ROUTER_UTILS } from '@core/utils/router.utils'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { HelperService } from '@pages/nft/services/helper.service'; @@ -34,9 +39,11 @@ import { Transaction, TransactionType, TRANSACTION_AUTO_EXPIRY_MS, + NftPurchaseRequest, } from '@build-5/interfaces'; import dayjs from 'dayjs'; import { BehaviorSubject, firstValueFrom, interval, Subscription, take } from 'rxjs'; +import { CartService } from '@components/cart/services/cart.service'; export enum StepType { CONFIRM = 'Confirm', @@ -112,6 +119,8 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { return this._collection; } + @Input() nftQuantity = 1; + @Output() wenOnClose = new EventEmitter(); public purchasedNft?: Nft | null; @@ -135,7 +144,7 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { private _nft?: Nft | null; private _collection?: Collection | null; - private transSubscription?: Subscription; + private transSubscription$?: Subscription; public path = ROUTER_UTILS.config.nft.root; constructor( @@ -153,6 +162,7 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { private nftApi: NftApi, private fileApi: FileApi, private cache: CacheService, + public cartService: CartService, ) {} public ngOnInit(): void { @@ -304,10 +314,11 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { this.cd.markForCheck(); }); - if (getItem(StorageItem.CheckoutTransaction)) { - this.transSubscription = this.orderApi - .listen(getItem(StorageItem.CheckoutTransaction)) - .subscribe(this.transaction$); + const checkoutTransaction = getCheckoutTransaction(); + if (checkoutTransaction && checkoutTransaction.transactionId) { + this.transSubscription$ = this.orderApi + .listen(checkoutTransaction.transactionId) + .subscribe(this.transaction$); } // Run ticker. @@ -414,34 +425,74 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { return this.purchasedNft || this.nft; } + get pricePerItem(): number { + return (this.targetAmount ?? 0) / (this.nftQuantity || 1); + } + public async proceedWithOrder(): Promise { if (!this.collection || !this.nft || !this.agreeTermsConditions) { return; } - const params: any = { - collection: this.collection.uid, - }; + if (this.nftQuantity > 1) { + const nfts: NftPurchaseRequest[] = []; - if (this.collection.type === CollectionType.CLASSIC) { - params.nft = this.nft.uid; - } + for (let i = 0; i < this.nftQuantity; i++) { + const nftData: NftPurchaseRequest = { + collection: this.collection.uid, + }; - // If owner is set CollectionType is not relevant. - if (this.nft.owner) { - params.nft = this.nft.uid; - } + nfts.push(nftData); + } - await this.auth.sign(params, (sc, finish) => { - this.notification - .processRequest(this.orderApi.orderNft(sc), $localize`Order created.`, finish) - .subscribe((val: any) => { - this.transSubscription?.unsubscribe(); - setItem(StorageItem.CheckoutTransaction, val.uid); - this.transSubscription = this.orderApi.listen(val.uid).subscribe(this.transaction$); - this.pushToHistory(val, val.uid, dayjs(), $localize`Waiting for transaction...`); - }); - }); + const bulkPurchaseRequest = { + orders: nfts, + }; + + await this.auth.sign(bulkPurchaseRequest, (sc, finish) => { + this.notification + .processRequest(this.orderApi.orderNfts(sc), $localize`Order created.`, finish) + .subscribe((val: any) => { + this.transSubscription$?.unsubscribe(); + setCheckoutTransaction({ + transactionId: val.uid, + source: 'nftCheckout', + }); + this.transSubscription$ = this.orderApi + .listen(val.uid) + .subscribe(this.transaction$); + this.pushToHistory(val, val.uid, dayjs(), $localize`Waiting for transaction...`); + }); + }); + } else { + const params: any = { + collection: this.collection.uid, + }; + + if (this.collection.type === CollectionType.CLASSIC) { + params.nft = this.nft.uid; + } + + if (this.nft.owner) { + params.nft = this.nft.uid; + } + + await this.auth.sign(params, (sc, finish) => { + this.notification + .processRequest(this.orderApi.orderNft(sc), $localize`Order created.`, finish) + .subscribe((val: any) => { + this.transSubscription$?.unsubscribe(); + setCheckoutTransaction({ + transactionId: val.uid, + source: 'nftCheckout', + }); + this.transSubscription$ = this.orderApi + .listen(val.uid) + .subscribe(this.transaction$); + this.pushToHistory(val, val.uid, dayjs(), $localize`Waiting for transaction...`); + }); + }); + } } public getTitle(): any { @@ -467,6 +518,6 @@ export class NftCheckoutComponent implements OnInit, OnDestroy { } public ngOnDestroy(): void { - this.transSubscription?.unsubscribe(); + this.transSubscription$?.unsubscribe(); } } diff --git a/src/app/components/nft/components/nft-checkout/nft-checkout.module.ts b/src/app/components/nft/components/nft-checkout/nft-checkout.module.ts index bad391e..20e441d 100644 --- a/src/app/components/nft/components/nft-checkout/nft-checkout.module.ts +++ b/src/app/components/nft/components/nft-checkout/nft-checkout.module.ts @@ -19,6 +19,7 @@ import { NzModalModule } from 'ng-zorro-antd/modal'; import { NzNotificationModule } from 'ng-zorro-antd/notification'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NftCheckoutComponent } from './nft-checkout.component'; +import { UsdBelowTwoDecimalsModule } from '@core/pipes/usd-below-two-decimals/usd-below-two-decimals.module'; @NgModule({ declarations: [NftCheckoutComponent], @@ -42,6 +43,7 @@ import { NftCheckoutComponent } from './nft-checkout.component'; ModalDrawerModule, WalletDeeplinkModule, TermsAndConditionsModule, + UsdBelowTwoDecimalsModule, ], exports: [NftCheckoutComponent], }) diff --git a/src/app/pages/award/pages/new/new.page.ts b/src/app/pages/award/pages/new/new.page.ts index c8a5f8d..700132b 100644 --- a/src/app/pages/award/pages/new/new.page.ts +++ b/src/app/pages/award/pages/new/new.page.ts @@ -17,6 +17,8 @@ import { TEST_AVAILABLE_MINTABLE_NETWORKS, Token, TokenStatus, + getDefDecimalIfNotSet, + Network, } from '@build-5/interfaces'; import { BehaviorSubject, of, Subscription, switchMap } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -27,7 +29,7 @@ import { NotificationService } from './../../../../@core/services/notification/n import { AuthService } from './../../../../components/auth/services/auth.service'; import { FileApi } from '@api/file.api'; -import { getDefDecimalIfNotSet, Network } from '@build-5/interfaces'; + import { NzNotificationService } from 'ng-zorro-antd/notification'; import { NzUploadChangeParam, NzUploadFile, NzUploadXHRArgs } from 'ng-zorro-antd/upload'; diff --git a/src/app/pages/collection/collection.module.ts b/src/app/pages/collection/collection.module.ts index c79cc5b..ce5f5ba 100644 --- a/src/app/pages/collection/collection.module.ts +++ b/src/app/pages/collection/collection.module.ts @@ -47,6 +47,7 @@ import { CollectionPage } from './pages/collection/collection.page'; import { CollectionNFTsPage } from './pages/collection/nfts/nfts.page'; import { UpsertPage } from './pages/upsert/upsert.page'; import { DataService } from './services/data.service'; +import { NzSliderModule } from 'ng-zorro-antd/slider'; @NgModule({ declarations: [CollectionPage, UpsertPage, CollectionAboutComponent, CollectionNFTsPage], @@ -97,6 +98,7 @@ import { DataService } from './services/data.service'; IpfsBadgeModule, IotaInputModule, CollectionMintNetworkModule, + NzSliderModule, ], providers: [DataService, FilterService], }) diff --git a/src/app/pages/collection/pages/collection/collection.page.ts b/src/app/pages/collection/pages/collection/collection.page.ts index 2094b3c..cd2c888 100644 --- a/src/app/pages/collection/pages/collection/collection.page.ts +++ b/src/app/pages/collection/pages/collection/collection.page.ts @@ -32,7 +32,7 @@ import { RANKING_TEST, } from '@build-5/interfaces'; import { NzNotificationService } from 'ng-zorro-antd/notification'; -import { BehaviorSubject, first, firstValueFrom, skip, Subscription } from 'rxjs'; +import { Subject, BehaviorSubject, first, firstValueFrom, skip, Subscription } from 'rxjs'; import { DataService } from '../../services/data.service'; import { NotificationService } from './../../../../@core/services/notification/notification.service'; @@ -51,6 +51,7 @@ export class CollectionPage implements OnInit, OnDestroy { private guardiansSubscription$?: Subscription; private guardiansRankModeratorSubscription$?: Subscription; private subscriptions$: Subscription[] = []; + private destroy$ = new Subject(); constructor( public deviceService: DeviceService, @@ -309,6 +310,8 @@ export class CollectionPage implements OnInit, OnDestroy { public ngOnDestroy(): void { this.cancelSubscriptions(); this.guardiansSubscription$?.unsubscribe(); + this.destroy$.next(); + this.destroy$.complete(); } public get networkTypes(): typeof Network { diff --git a/src/app/pages/collection/pages/collection/nfts/collectionNfts.service.ts b/src/app/pages/collection/pages/collection/nfts/collectionNfts.service.ts new file mode 100644 index 0000000..06fcbb5 --- /dev/null +++ b/src/app/pages/collection/pages/collection/nfts/collectionNfts.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { switchMap, map, take } from 'rxjs/operators'; +import { Nft, Collection } from '@build-5/interfaces'; +import { CartService } from '@components/cart/services/cart.service'; + +@Injectable({ + providedIn: 'root', +}) +export class CollectionNftStateService { + private listedNftsSubject$ = new BehaviorSubject([]); + public listedNfts$ = this.listedNftsSubject$.asObservable(); + private availableNftsCountSubject$ = new BehaviorSubject(0); + public availableNftsCount$ = this.availableNftsCountSubject$.asObservable(); + + constructor(private cartService: CartService) {} + + public getListedNftsObservable(collection: Collection): Observable { + return this.listedNfts$.pipe( + switchMap((nfts) => + combineLatest( + nfts.map((nft) => + this.cartService + .isNftAvailableForSale(nft, collection, true) + .pipe(map((availability) => ({ nft, isAvailable: availability.isAvailable }))), + ), + ), + ), + map((nftsWithAvailability) => + nftsWithAvailability.filter(({ isAvailable }) => isAvailable).map(({ nft }) => nft), + ), + ); + } + + public setListedNfts(nfts: Nft[], collection: Collection) { + this.listedNftsSubject$.next(nfts); + this.updateAvailableNftsCount(nfts, collection); + } + + private updateAvailableNftsCount(nfts: Nft[], collection: Collection) { + this.getListedNftsObservable(collection) + .pipe( + map((nftsForSale) => nftsForSale.length), + take(1), + ) + .subscribe((count) => this.availableNftsCountSubject$.next(count)); + } +} diff --git a/src/app/pages/collection/pages/collection/nfts/nfts.page.html b/src/app/pages/collection/pages/collection/nfts/nfts.page.html index 3ce717a..0e647f4 100644 --- a/src/app/pages/collection/pages/collection/nfts/nfts.page.html +++ b/src/app/pages/collection/pages/collection/nfts/nfts.page.html @@ -4,18 +4,50 @@
-
- - - - {{ state.nbHits | number }} records - - - + +
+
+ + +
+ + {{ state.nbHits | number }} records + +
+ + + {{ sweepCount }} + + + No NFTs available to sweep. + +
+ +
+
+
+
+
+
(); + availableNftsCount = 0; + collection: Collection | null = null; constructor( public filter: FilterService, public deviceService: DeviceService, public nftApi: NftApi, + private collectionApi: CollectionApi, public cd: ChangeDetectorRef, public filterStorageService: FilterStorageService, public cacheService: CacheService, public readonly algoliaService: AlgoliaService, + public cartService: CartService, + private notification: NzNotificationService, + private collectionNftStateService: CollectionNftStateService, ) {} public ngOnInit(): void { - // Algolia change detection bug fix - setInterval(() => this.cd.markForCheck(), 500); + this.collectionNftStateService.availableNftsCount$ + .pipe(takeUntil(this.destroy$)) + .subscribe((count) => { + this.availableNftsCount = count; + this.cd.markForCheck(); + }); + + if (this.collectionId) { + this.loadCollection(this.collectionId); + } } - public ngOnChanges(): void { - // TODO comeup with better process. - setTimeout(() => { + public ngOnChanges(changes: SimpleChanges): void { + if (changes.collectionId) { + this.resetComponentState(); if (this.collectionId) { - this.filterStorageService.marketNftsFilters$.next({ - ...this.filterStorageService.marketNftsFilters$.value, - refinementList: { - ...this.filterStorageService.marketNftsFilters$.value.refinementList, - collection: [this.collectionId], - }, - }); - - this.config = { - indexName: COL.NFT, - searchClient: this.algoliaService.searchClient, - initialUiState: { - nft: this.filterStorageService.marketNftsFilters$.value, - }, - }; + this.loadCollection(this.collectionId); } - }, 500); + } + } + + private resetComponentState(): void { + this.availableNftsCount = 0; + this.sweepCount = 1; + this.cd.markForCheck(); + } + + private loadCollection(collectionId: string): void { + this.collectionApi + .getCollectionById(collectionId) + .pipe(take(1)) + .subscribe({ + next: (collectionData) => { + if (collectionData) { + this.collection = collectionData; + this.initializeAlgoliaFilters(collectionId); + } else { + this.notification.error($localize`Error occurred while fetching collection.`, ''); + } + }, + error: (err) => { + this.notification.error($localize`Error occurred while fetching collection.`, ''); + }, + }); + } + + private initializeAlgoliaFilters(collectionId: string): void { + this.filterStorageService.marketNftsFilters$.next({ + ...this.filterStorageService.marketNftsFilters$.value, + refinementList: { + ...this.filterStorageService.marketNftsFilters$.value.refinementList, + collection: [collectionId], + }, + }); + + this.config = { + indexName: COL.NFT, + searchClient: this.algoliaService.searchClient, + initialUiState: { + nft: this.filterStorageService.marketNftsFilters$.value, + }, + }; + + this.cd.markForCheck(); + } + + public captureOriginalHits(hits: any[]) { + if (hits && hits.length > 0 && this.collection) { + this.originalNfts = hits; + this.collectionNftStateService.setListedNfts(hits, this.collection); + } } public trackByUid(_index: number, item: any): number { return item.uid; } - public convertAllToSoonaverseModel(algoliaItems: any[]) { - return algoliaItems.map((algolia) => ({ + public convertAllToSoonaverseModel = (algoliaItems: any[]) => { + if (this.originalNfts.length !== algoliaItems.length && algoliaItems.length > 0) { + this.captureOriginalHits(algoliaItems); + } + + const transformedItems = algoliaItems.map((algolia) => ({ ...algolia, availableFrom: Timestamp.fromMillis(+algolia.availableFrom), })); - } + + this.cd.markForCheck(); + + return transformedItems; + }; public get collapseTypes(): typeof CollapseType { return CollapseType; @@ -109,4 +180,49 @@ export class CollectionNFTsPage implements OnInit, OnChanges { public get algoliaCheckboxFilterTypes(): typeof AlgoliaCheckboxFilterType { return AlgoliaCheckboxFilterType; } + + public sweepToCart(count: number) { + if (!this.collectionId) { + this.notification.error($localize`Collection ID is not available.`, ''); + return; + } + + this.collectionApi + .getCollectionById(this.collectionId) + .pipe( + take(1), + filter((collection): collection is Collection => Boolean(collection)), + switchMap((collection) => { + // Ensure we're working with a defined collection + if (!collection) { + throw new Error('Collection is undefined after filtering'); + } + return this.collectionNftStateService.getListedNftsObservable(collection).pipe( + map((nftsForSale) => nftsForSale.slice(0, Math.min(count, nftsForSale.length))), + map((nftsToAdd) => ({ nftsToAdd, collection })), + ); + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: ({ nftsToAdd, collection }) => { + nftsToAdd.forEach((nft) => { + this.cartService.addToCart(nft, collection, 1, true); + }); + this.notification.success( + $localize`NFTs swept into your cart, open cart to review added items.`, + '', + ); + }, + error: (error) => + this.notification.error($localize`Error occurred while adding NFTs to cart.`, ''), + }); + + this.cd.markForCheck(); + } + + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/app/pages/market/pages/collections/collections.page.html b/src/app/pages/market/pages/collections/collections.page.html index a8fa766..1a9b87b 100644 --- a/src/app/pages/market/pages/collections/collections.page.html +++ b/src/app/pages/market/pages/collections/collections.page.html @@ -130,6 +130,7 @@ sortBy: filterStorageService.marketCollectionsFilters$.value.sortBy, range: filterStorageService.marketCollectionsFilters$.value.range, refinementList: { + status: filterStorageService.marketCollectionsFilters$.value.refinementList?.status, category: filterStorageService.marketCollectionsFilters$.value.refinementList?.category, space: filterStorageService.marketCollectionsFilters$.value.refinementList?.space, access: $event @@ -163,6 +164,7 @@ range: filterStorageService.marketCollectionsFilters$.value.range, refinementList: { access: filterStorageService.marketCollectionsFilters$.value.refinementList?.access, + status: filterStorageService.marketCollectionsFilters$.value.refinementList?.status, category: filterStorageService.marketCollectionsFilters$.value.refinementList?.category, space: $event } @@ -193,6 +195,7 @@ refinementList: { access: filterStorageService.marketCollectionsFilters$.value.refinementList?.access, space: filterStorageService.marketCollectionsFilters$.value.refinementList?.space, + status: filterStorageService.marketCollectionsFilters$.value.refinementList?.status, category: $event } } @@ -200,6 +203,36 @@ > + + + +
diff --git a/src/app/pages/market/pages/collections/collections.page.ts b/src/app/pages/market/pages/collections/collections.page.ts index cd60213..e5ee947 100644 --- a/src/app/pages/market/pages/collections/collections.page.ts +++ b/src/app/pages/market/pages/collections/collections.page.ts @@ -34,6 +34,7 @@ export class CollectionsPage implements OnInit { spaceFilterOpen = true; categoryFilterOpen = false; priceFilterOpen = false; + statusFilterOpen = false; constructor( public filter: FilterService, diff --git a/src/app/pages/nft/pages/nft/nft.page.html b/src/app/pages/nft/pages/nft/nft.page.html index 2c6a472..8f1e23a 100644 --- a/src/app/pages/nft/pages/nft/nft.page.html +++ b/src/app/pages/nft/pages/nft/nft.page.html @@ -4,8 +4,15 @@ NFT + NFT + + + {{ getCollectionTypeString(collectionType) }} + {{ getTitle(data.nft$ | async) }}
Current price
- {{(((data.nft$ | async)?.availablePrice || (data.nft$ | async)?.price) | + {{ (((data.nft$ | async)?.availablePrice || (data.nft$ | async)?.price) | formatToken: ((data.nft$ | async)?.placeholderNft ? (data.collection$ | - async)?.mintingData?.network : (data.nft$ | async)?.mintingData?.network): true | - async)}} + async)?.mintingData?.network : (data.nft$ | async)?.mintingData?.network):true) | + async }} ({{ unitsService.getUsd(cartService.valueDivideExponent({ value: + ((data.nft$ | async)?.availablePrice || (data.nft$ | async)?.price) || 0, exponents: + cartService.getDefaultNetworkDecimals() }), ((data.nft$ | async)?.placeholderNft ? + (data.collection$ | async)?.mintingData?.network : (data.nft$ | + async)?.mintingData?.network)) | async | currency: 'USD':'symbol':'1.0-2' }} USD)
- {{(calc(((data.nft$ | async)?.availablePrice || (data.nft$ | async)?.price), + {{ (calc(((data.nft$ | async)?.availablePrice || (data.nft$ | async)?.price), discount(data.collection$ | async, data.nft$ | async)) | formatToken: ((data.nft$ | async)?.placeholderNft ? (data.collection$ | async)?.mintingData?.network : - (data.nft$ | async)?.mintingData?.network): true | async)}} + (data.nft$ | async)?.mintingData?.network):true) | async }} ({{ + unitsService.getUsd(cartService.valueDivideExponent({ value: calc(((data.nft$ | + async)?.availablePrice || (data.nft$ | async)?.price), discount(data.collection$ | + async, data.nft$ | async)) || 0, exponents: cartService.getDefaultNetworkDecimals() + }), ((data.nft$ | async)?.placeholderNft ? (data.collection$ | + async)?.mintingData?.network : (data.nft$ | async)?.mintingData?.network)) | async | + currency: 'USD':'symbol':'1.0-2' }} USD)
+
-
@@ -248,28 +266,96 @@

{{ getTitle(data.nft$ | async) }}

>
-
- + + +
+ +
+ + + + + + + +
- -
+ + + + + + + + + + + + + + + + + + + +
+ +
@@ -775,6 +861,7 @@

[isOpen]="isCheckoutOpen" [nft]="data.nft$ | async" [collection]="data.collection$ | async" + [nftQuantity]="nftQtySelected" (wenOnClose)="isCheckoutOpen = false" > { + this.currentNft = nft; + }); this.deviceService.viewWithSearch$.next(false); this.route.params?.pipe(untilDestroyed(this)).subscribe((obj) => { const id: string | undefined = obj?.[ROUTER_UTILS.config.nft.nft.replace(':', '')]; @@ -268,6 +281,7 @@ export class NFTPage implements OnInit, OnDestroy { this.data.collection$.pipe(skip(1), untilDestroyed(this)).subscribe(async (p) => { if (p) { + this.collectionType = p.type; this.collectionSubscriptions$.forEach((s) => { s.unsubscribe(); }); @@ -394,6 +408,20 @@ export class NFTPage implements OnInit, OnDestroy { } } + public getCollectionTypeString(type: CollectionType | null | undefined): string { + if (type === null || type === undefined) { + return 'Unknown'; + } + return CollectionType[type]; + } + + public getCollectionStatusString(status: CollectionStatus | null | undefined): string { + if (status === null || status === undefined) { + return 'Unknown'; + } + return CollectionStatus[status]; + } + private listenToNft(id: string): void { this.cancelSubscriptions(); this.data.nftId = id; @@ -406,6 +434,65 @@ export class NFTPage implements OnInit, OnDestroy { return !!nft?.owner && nft?.owner === this.auth.member$.value?.uid; } + public getAvailableNftQuantity(col?: Collection | null, nft?: Nft | null): number { + return this.helper.getAvailNftQty(nft, col); + } + + public updateQuantity(): void { + const maxQuantity = this.getAvailableNftQuantity( + this.data.collection$.value, + this.data.nft$.value, + ); + const startingValue = this.nftQtySelected; + const parsedQuantity = Math.round(Number(this.nftQtySelected)); + + if (isNaN(parsedQuantity) || parsedQuantity <= 0) { + this.nftQtySelected = 1; + } else if (parsedQuantity > maxQuantity) { + this.nftQtySelected = maxQuantity; + } else { + this.nftQtySelected = parsedQuantity; + } + + this.cd.markForCheck(); + + if (startingValue === this.nftQtySelected) { + return; + } else { + this.resetInput(); + } + } + + public forceValidRange(event: ClipboardEvent): void { + event.preventDefault(); + + if (event.clipboardData) { + const pastedData = event.clipboardData.getData('text/plain'); + const parsedQuantity = Math.round(Number(pastedData)); + + const maxQuantity = this.getAvailableNftQuantity( + this.data.collection$.value, + this.data.nft$.value, + ); + + if (isNaN(parsedQuantity) || parsedQuantity <= 0) { + this.nftQtySelected = 1; + } else if (parsedQuantity > maxQuantity) { + this.nftQtySelected = maxQuantity; + } else { + this.nftQtySelected = parsedQuantity; + } + + this.resetInput(); + } + } + + private resetInput() { + if (this.quantityInput) { + this.quantityInput.reset(this.nftQtySelected); + } + } + public discount(collection?: Collection | null, nft?: Nft | null): number { if (!collection?.space || !this.auth.member$.value || nft?.owner) { return 1; @@ -437,7 +524,8 @@ export class NFTPage implements OnInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } - if (getItem(StorageItem.CheckoutTransaction)) { + const checkoutTransaction = getCheckoutTransaction(); + if (checkoutTransaction?.transactionId) { this.nzNotification.error('You currently have open order. Pay for it or let it expire.', ''); return; } @@ -449,7 +537,8 @@ export class NFTPage implements OnInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } - if (getItem(StorageItem.CheckoutTransaction)) { + const checkoutTransaction = getCheckoutTransaction(); + if (checkoutTransaction?.transactionId) { this.nzNotification.error('You currently have open order. Pay for it or let it expire.', ''); return; } @@ -523,6 +612,16 @@ export class NFTPage implements OnInit, OnDestroy { } } + public addToCart(nft: Nft): void { + if (nft && this.data.collection$) { + this.data.collection$.pipe(take(1)).subscribe((collection) => { + if (collection) { + this.cartService.addToCart(nft, collection, this.nftQtySelected); + } + }); + } + } + public generatedNft(nft?: Nft | null): boolean { if (!nft) { return false; diff --git a/src/app/pages/nft/services/helper.service.ts b/src/app/pages/nft/services/helper.service.ts index ae5f98a..b7a6cea 100644 --- a/src/app/pages/nft/services/helper.service.ts +++ b/src/app/pages/nft/services/helper.service.ts @@ -4,7 +4,7 @@ import { DescriptionItem, DescriptionItemType, } from '@components/description/description.component'; -import { getItem, StorageItem } from '@core/utils'; +import { getCheckoutTransaction } from '@core/utils'; import { Collection, CollectionStatus, @@ -111,6 +111,10 @@ export class HelperService { } public isDateInFuture(date?: Timestamp | null): boolean { + if (!date) { + return false; + } + if (!this.getDate(date)) { return false; } @@ -124,16 +128,36 @@ export class HelperService { } public getDate(date: any): any { - if (typeof date === 'object' && date?.toDate) { - const d = date.toDate(); - if (!(d instanceof Date) || isNaN(d.valueOf())) { - return undefined; + if (!date) { + return undefined; + } + + if (typeof date === 'number') { + return new Date(date); + } else if (typeof date === 'object') { + if (date.toDate && typeof date.toDate === 'function') { + try { + return date.toDate(); + } catch { + return undefined; + } } - return d; - } else { - return date || undefined; + if ('seconds' in date && !isNaN(Number(date.seconds))) { + const seconds = Number(date.seconds); + return new Date(seconds * 1000); + } + + if (date.toMillis && typeof date.toMillis === 'function') { + try { + return new Date(date.toMillis()); + } catch { + return undefined; + } + } } + + return undefined; } public getCountdownTitle(nft?: Nft | null): string { @@ -158,12 +182,14 @@ export class HelperService { return false; } + const checkoutTransaction = getCheckoutTransaction(); + return ( col.approved === true && ((nft?.locked === true && !exceptMember) || (exceptMember && nft?.locked === true && - nft?.lockedBy !== getItem(StorageItem.CheckoutTransaction))) + (!checkoutTransaction || nft?.lockedBy !== checkoutTransaction.transactionId))) ); } @@ -172,11 +198,23 @@ export class HelperService { return false; } - return ( + const isAvail = col.approved === true && !!this.getDate(nft.availableFrom) && - dayjs(this.getDate(nft.availableFrom)).isSameOrBefore(dayjs(), 's') - ); + dayjs(this.getDate(nft.availableFrom)).isSameOrBefore(dayjs(), 's'); + return isAvail; + } + + public getAvailNftQty(nft?: Nft | null, col?: Collection | null): number { + const isAvailableForSale = this.isAvailableForSale(nft, col); + + if (nft?.placeholderNft && isAvailableForSale) { + return col?.availableNfts || 0; + } else if (isAvailableForSale) { + return 1; + } + + return 0; } public canBeSetForSale(nft?: Nft | null): boolean { diff --git a/src/theme/01-base/font.less b/src/theme/01-base/font.less index 2eef6e6..c8cc91c 100644 --- a/src/theme/01-base/font.less +++ b/src/theme/01-base/font.less @@ -6,3 +6,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +.uppercase { + text-transform: uppercase; +} diff --git a/src/theme/02-components/modal.less b/src/theme/02-components/modal.less index 582a03b..f20460e 100644 --- a/src/theme/02-components/modal.less +++ b/src/theme/02-components/modal.less @@ -2,6 +2,10 @@ @apply bg-backgrounds-tertiary dark:bg-backgrounds-tertiary-dark text-foregrounds-primary dark:text-foregrounds-primary-dark; } +.ant-modal-header { + @apply bg-backgrounds-tertiary dark:bg-backgrounds-tertiary-dark text-foregrounds-primary dark:text-foregrounds-primary-dark; +} + .wen-transaction-select-container { @apply flex flex-col justify-between h-full mt-10 lg:items-center lg:justify-start; } @@ -29,3 +33,8 @@ .wen-modal-tabs { @apply absolute bottom-0 w-full top-56 lg:static; } + +.wen-modal-header { + @apply flex justify-between items-center; + // Additional styling if needed +}