diff --git a/src/app/features/home/details/actions/actions.page.ts b/src/app/features/home/details/actions/actions.page.ts index 71449434c..37e28c9ef 100644 --- a/src/app/features/home/details/actions/actions.page.ts +++ b/src/app/features/home/details/actions/actions.page.ts @@ -4,8 +4,8 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; import { TranslocoService } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { combineLatest, of } from 'rxjs'; -import { catchError, concatMap, first, map, tap } from 'rxjs/operators'; +import { combineLatest, forkJoin, of } from 'rxjs'; +import { catchError, concatMap, first, map, take, tap } from 'rxjs/operators'; import { ActionsDialogComponent } from '../../../../shared/actions/actions-dialog/actions-dialog.component'; import { Action, @@ -14,13 +14,17 @@ import { import { OrderHistoryService } from '../../../../shared/actions/service/order-history.service'; import { BlockingActionService } from '../../../../shared/blocking-action/blocking-action.service'; import { DiaBackendAuthService } from '../../../../shared/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendSeriesRepository } from '../../../../shared/dia-backend/series/dia-backend-series-repository.service'; import { DiaBackendStoreService, NetworkAppOrder, } from '../../../../shared/dia-backend/store/dia-backend-store.service'; import { ErrorService } from '../../../../shared/error/error.service'; import { OrderDetailDialogComponent } from '../../../../shared/order-detail-dialog/order-detail-dialog.component'; -import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; +import { + isNonNullable, + VOID$, +} from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy() @Component({ @@ -34,7 +38,8 @@ export class ActionsPage { .pipe(catchError((err: unknown) => this.errorService.toastError$(err))); private readonly id$ = this.route.paramMap.pipe( - map(params => params.get('id')) + map(params => params.get('id')), + isNonNullable() ); constructor( @@ -47,9 +52,71 @@ export class ActionsPage { private readonly snackBar: MatSnackBar, private readonly dialog: MatDialog, private readonly storeService: DiaBackendStoreService, - private readonly orderHistoryService: OrderHistoryService + private readonly orderHistoryService: OrderHistoryService, + private readonly diaBackendStoreService: DiaBackendStoreService, + private readonly diaBackendSeriesRepository: DiaBackendSeriesRepository ) {} + canPerformAction$(action: Action) { + if (action.title_text === 'List in CaptureClub') { + /* + Workaround: + Currently there isn't a simple way to check whether an asset is listed in + CaptureClub or not. So I first query List all Products API with + associated_id parameter set to the assets cid. And then use list series + API and check through all nested collections. See discussion here + https://app.asana.com/0/0/1201558520076805/1201995911008176/f + */ + return this.id$.pipe( + concatMap(cid => + forkJoin([ + this.diaBackendStoreService.listAllProducts$({ + associated_id: cid, + service_name: 'CaptureClub', + }), + of(cid), + ]) + ), + concatMap(([response, cid]) => { + if (response.count > 0) { + throw new Error( + this.translocoService.translate('message.hasListedInCaptureClub') + ); + } + return of(cid); + }), + concatMap(async cid => { + let currentOffset = 0; + const limit = 100; + while (true) { + const response = await this.diaBackendSeriesRepository + .fetchAll$({ offset: currentOffset, limit }) + .toPromise(); + const listedAsSeries = response.results.some(serie => + serie.collections.some(collection => + collection.assets.some(asset => asset.cid === cid) + ) + ); + if (listedAsSeries) { + throw new Error( + this.translocoService.translate( + 'message.hasListedInCaptureClub' + ) + ); + } + if (response.next == null) { + break; + } + currentOffset += response.results.length; + } + return VOID$; + }), + take(1) + ); + } + return VOID$; + } + openActionDialog$(action: Action) { return combineLatest([ this.actionsService.getParams$(action.params_list_custom_param1), @@ -128,8 +195,13 @@ export class ActionsPage { } doAction(action: Action) { - this.openActionDialog$(action) + this.blockingActionService + .run$(this.canPerformAction$(action)) .pipe( + catchError((err: unknown) => { + return this.errorService.toastError$(err); + }), + concatMap(() => this.openActionDialog$(action)), concatMap(createOrderInput => this.blockingActionService.run$( this.createOrder$( diff --git a/src/app/shared/dia-backend/series/dia-backend-series-repository.service.ts b/src/app/shared/dia-backend/series/dia-backend-series-repository.service.ts index aca9bf2f8..a60a7e441 100644 --- a/src/app/shared/dia-backend/series/dia-backend-series-repository.service.ts +++ b/src/app/shared/dia-backend/series/dia-backend-series-repository.service.ts @@ -10,13 +10,13 @@ import { BASE_URL } from '../secret'; providedIn: 'root', }) export class DiaBackendSeriesRepository { - private readonly fetchCollectedSeriesCount$ = this.fetch$({ + private readonly fetchCollectedSeriesCount$ = this.fetchAll$({ limit: 1, collected: true, }).pipe(pluck('count')); readonly fetchCollectedSeriesList$ = this.fetchCollectedSeriesCount$.pipe( - switchMap(count => this.fetch$({ limit: count, collected: true })) + switchMap(count => this.fetchAll$({ limit: count, collected: true })) ); constructor( @@ -28,23 +28,16 @@ export class DiaBackendSeriesRepository { return this.read$(id); } - private fetch$({ - limit, - collected, - }: { - limit?: number; - collected?: boolean; - }) { + fetchAll$( + queryParams: { + limit?: number; + collected?: boolean; + offset?: number; + } = {} + ) { return defer(() => this.authService.getAuthHeaders()).pipe( concatMap(headers => { - let params = new HttpParams(); - - if (limit !== undefined) { - params = params.set('limit', `${limit}`); - } - if (collected !== undefined) { - params = params.set('collected', `${collected}`); - } + const params = new HttpParams({ fromObject: queryParams }); return this.httpClient.get>( `${BASE_URL}/api/v3/series/`, { headers, params } @@ -70,6 +63,7 @@ export interface DiaBackendSeries { readonly collections: { readonly assets: { readonly id: string; + readonly cid: string; readonly asset_file_thumbnail: string; readonly collected: boolean; }[]; diff --git a/src/app/shared/dia-backend/store/dia-backend-store.service.ts b/src/app/shared/dia-backend/store/dia-backend-store.service.ts index f48edde97..a55f8397a 100644 --- a/src/app/shared/dia-backend/store/dia-backend-store.service.ts +++ b/src/app/shared/dia-backend/store/dia-backend-store.service.ts @@ -1,8 +1,9 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { defer } from 'rxjs'; import { concatMap } from 'rxjs/operators'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; +import { PaginatedResponse } from '../pagination'; import { BASE_URL } from '../secret'; @Injectable({ @@ -41,11 +42,25 @@ export class DiaBackendStoreService { }) ); } + + listAllProducts$( + queryParams: { associated_id?: string; service_name?: string } = {} + ) { + return defer(() => this.authService.getAuthHeadersWithApiKey()).pipe( + concatMap(headers => { + const params = new HttpParams({ fromObject: queryParams }); + return this.httpClient.get>( + `${BASE_URL}/api/v3/store/products/`, + { headers, params } + ); + }) + ); + } } export interface NetworkAppOrder { id: string; - status: 'completed' | 'canceled' | 'pending'; + status: 'created' | 'success' | 'failure' | 'pending'; network_app_id: string; network_app_name: string; action: string; @@ -63,3 +78,32 @@ export interface NetworkAppOrder { created_at: string; expired_at: string; } + +export interface Product { + id: string; + associated_id: string; + type: string; + service_name: string; + subject: string; + description: string; + tags: string[]; + cover_image: string; + cover_image_thumbnail: string; + product_url: string; + external_product_url: string; + price: string; + price_base: 'usd' | 'num'; + quantity: number; + in_stock: number; + nft_token_id: number; + nft_token_uri: string; + nft_blockchain_name: string; + nft_contract_address: string; + owner: string; + owner_name: string; + original_creator: string; + original_creator_name: string; + enabled: boolean; + created_at: string; + updated_at: string; +} diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json index c57c21916..9b431c3e2 100644 --- a/src/assets/i18n/en-us.json +++ b/src/assets/i18n/en-us.json @@ -182,7 +182,8 @@ "mintNftToken": "Mint NFT token", "mintNftAlert": "Once the NFT is minted, photo and its information can be accessed publicly. Do you still want to proceed?", "sentSuccessfully": "Sent Successfully", - "confirmCopyPrivateKey": "Warning: Please do not expose your private key to anyone. Lost of private key may cause your account to be lost permanently." + "confirmCopyPrivateKey": "Warning: Please do not expose your private key to anyone. Lost of private key may cause your account to be lost permanently.", + "hasListedInCaptureClub": "This asset has already been listed in CaptureClub." }, "error": { "validationError": "Invalid operation. Please check your input again.", diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index 4a7757b71..d3c2e4e20 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -182,7 +182,8 @@ "mintNftToken": "鑄造 NFT 代幣", "mintNftAlert": "NFT 代幣鑄造後,影像以及所有資訊都將可被公開檢視。確定要繼續嗎?", "sentSuccessfully": "成功送出", - "confirmCopyPrivateKey": "警告:請不要向任何人揭露您的金鑰,未保管好金鑰可能導致您永遠失去您的帳號。" + "confirmCopyPrivateKey": "警告:請不要向任何人揭露您的金鑰,未保管好金鑰可能導致您永遠失去您的帳號。", + "hasListedInCaptureClub": "該資產已在 CaptureClub 上架。" }, "error": { "validationError": "操作或輸入有誤,請確認您的操作正確。",