diff --git a/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.html b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.html new file mode 100644 index 000000000..d6125e4d4 --- /dev/null +++ b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.html @@ -0,0 +1,24 @@ + +
+
+ Gift storage + +
+
+ + +
+
diff --git a/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.scss b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.scss new file mode 100644 index 000000000..1a1806fa4 --- /dev/null +++ b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.scss @@ -0,0 +1,46 @@ +@import 'variables'; + +:host { + display: block; + width: 100%; +} + +.header { + @include tabbedDialogHeader; +} + +.content { + @include tabbedDialogContent; + flex-direction: column +} + +.tabs { + @include tabbedDialogNav; +} + +.panel { + @include tabbedDialogPanel; +} + +.panel-title { + @include tabbedDialogPanelTitle; +} + +form { + @include tabbedDialogPanelForm; + max-width: 600px; + padding-top: 0; +} + +pr-archive-small { + margin-bottom: $grid-unit; + + &.waiting { + opacity: 0.7; + } +} + +.popup-text { + text-align: center; + padding: 20px; +} diff --git a/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.spec.ts b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.spec.ts new file mode 100644 index 000000000..897d06ddf --- /dev/null +++ b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.spec.ts @@ -0,0 +1,84 @@ +/* @format */ +import { + ComponentFixture, + TestBed, + TestModuleMetadata, +} from '@angular/core/testing'; +import { DialogRef, DIALOG_DATA } from '@root/app/dialog/dialog.module'; +import { SharedModule } from '@shared/shared.module'; +import * as Testing from '@root/test/testbedConfig'; +import { cloneDeep } from 'lodash'; +import { ConfirmGiftDialogComponent } from './confirm-gift-dialog.component'; +import { Observable, Observer, Subject } from 'rxjs'; + +describe('ConfirmGiftDialogComponent', () => { + let component: ConfirmGiftDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DialogRef; + let dialogData: { + email: string; + amount: string; + message: string; + giftResult: Observable; + }; + + beforeEach(async () => { + const config: TestModuleMetadata = cloneDeep(Testing.BASE_TEST_CONFIG); + + dialogData = { + email: 'test@email.com', + amount: '10', + message: 'test message', + giftResult: new Observable(() => {}), + }; + + dialogRef = new DialogRef(1, null); + + config.imports.push(SharedModule); + config.declarations.push(ConfirmGiftDialogComponent); + config.providers.push({ + provide: DIALOG_DATA, + useValue: { + email: 'test@email.com', + amount: 10, + message: 'test message', + giftResult: new Observable(() => {}), + }, + }); + config.providers.push({ + provide: DialogRef, + useValue: dialogRef, + }); + await TestBed.configureTestingModule(config).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmGiftDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should take the email from the dialog data', () => { + expect(component.email).toEqual('test@email.com'); + }); + + it('should take the amount from the dialog data', () => { + expect(component.amount).toEqual(10); + }); + + it('should take the message from the dialog data', () => { + expect(component.message).toEqual('test message'); + }); + + it('should close when close method is called', () => { + const dialogRefSpy = spyOn(dialogRef, 'close'); + + component.onDoneClick(); + + expect(dialogRefSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.ts b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.ts new file mode 100644 index 000000000..a1ace041e --- /dev/null +++ b/src/app/core/components/confirm-gift-dialog/confirm-gift-dialog.component.ts @@ -0,0 +1,49 @@ +import { BehaviorSubject } from 'rxjs'; +/* @format */ +import { Observable, Subscription } from 'rxjs'; +import { Component, Inject, OnDestroy } from '@angular/core'; +import { DialogRef, DIALOG_DATA } from '@root/app/dialog/dialog.service'; +import { ApiService } from '@shared/services/api/api.service'; +import { MessageService } from '@shared/services/message/message.service'; + +@Component({ + selector: 'pr-confirm-gift-dialog', + templateUrl: './confirm-gift-dialog.component.html', + styleUrls: ['./confirm-gift-dialog.component.scss'], +}) +export class ConfirmGiftDialogComponent { + email: string; + amount: number; + message: string; + giftResult: BehaviorSubject; + + constructor( + private dialogRef: DialogRef, + private api: ApiService, + @Inject(DIALOG_DATA) public data: any, + private msg: MessageService + ) { + this.email = this.data.email; + this.amount = this.data.amount; + this.message = this.data.message; + this.giftResult = this.data.giftResult; + } + + public onDoneClick(): void { + this.dialogRef.close(); + } + + public async onConfirmClick() { + try { + await this.api.billing.giftStorage( + this.email, + Number(this.amount) + ); + this.giftResult.next(true) + } catch (e) { + this.msg.showError('Something went wrong! Please try again.'); + this.giftResult.next(false) + } + this.dialogRef?.close(); + } +} diff --git a/src/app/core/components/gift-storage/gift-storage.component.html b/src/app/core/components/gift-storage/gift-storage.component.html new file mode 100644 index 000000000..6f8853ba6 --- /dev/null +++ b/src/app/core/components/gift-storage/gift-storage.component.html @@ -0,0 +1,102 @@ + + +
+

Gift Storage

+

+ Gift any amount of your unused storage to someone else. You can send + storage to both current Permanent users and those who do not yet have an + account; they must log in to/create an account in order to claim their + storage. +

+
+
+
+ +
+ +
+
+
+ + {{ this.availableSpace }} GB available +
+ + gigabytes +
+
+
+ + Optional +
+ +
+
+ +
+
+
+
+ + + + + + + + + + + +

Storage successfully gifted

+

+ Success! You sent {{ giftForm.value.amount }} GB of Permanent storage to + {{ giftForm.value.email }}. +

+

+ You have + {{ (+(+availableSpace) - +(+giftForm.value.amount)).toFixed(2) }} GB of + storage available. +

+
+
diff --git a/src/app/core/components/gift-storage/gift-storage.component.scss b/src/app/core/components/gift-storage/gift-storage.component.scss new file mode 100644 index 000000000..e24a689fd --- /dev/null +++ b/src/app/core/components/gift-storage/gift-storage.component.scss @@ -0,0 +1,87 @@ +/* @format */ +@import 'variables'; + +.gift-storage { + & > form { + margin: 0; + max-width: 800px; + } +} + +.dialog-form-field { + display: flex; + margin-bottom: 23px; + + @include beforeDesktop { + flex-direction: column; + + & > div { + margin-bottom: 10px; + } + } + + & .text { + width: 200px; + display: flex; + justify-content: center; + flex-direction: column; + } + & .label { + font-weight: 700; + font-family: 'Open-Sans', sans-serif; + margin: 0; + } + + & .label-info { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-size: 14px; + line-height: 17px; + } + + & .gigabytes { + display: inline-flex; + align-items: center; + + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-size: 16px; + line-height: 17px; + color: $gray-dark; + margin-left: 10px; + + @include beforeDesktop { + margin-left: 0; + margin-top: 10px; + } + } + + & > input, + textarea { + width: 400px; + border: 1px solid $gray-light; + border-radius: 6px; + height: 42px; + } + + & > textarea { + height: 150px; + resize: none; + } + + & .btn { + width: 200px; + + &-disabled { + background-color: $gray-light; + color: $gray-dark; + border-color: $gray-light; + } + } +} + +.panel-title { + @include tabbedDialogPanelTitle; +} diff --git a/src/app/core/components/gift-storage/gift-storage.component.spec.ts b/src/app/core/components/gift-storage/gift-storage.component.spec.ts new file mode 100644 index 000000000..8badb6260 --- /dev/null +++ b/src/app/core/components/gift-storage/gift-storage.component.spec.ts @@ -0,0 +1,24 @@ +/* @format */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClient, HttpHandler } from '@angular/common/http'; +import { GiftStorageComponent } from './gift-storage.component'; + +describe('GiftStorageComponent', () => { + let component: GiftStorageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [GiftStorageComponent], + providers: [HttpClient, HttpHandler], + }).compileComponents(); + + fixture = TestBed.createComponent(GiftStorageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/components/gift-storage/gift-storage.component.ts b/src/app/core/components/gift-storage/gift-storage.component.ts new file mode 100644 index 000000000..9d0d73e68 --- /dev/null +++ b/src/app/core/components/gift-storage/gift-storage.component.ts @@ -0,0 +1,95 @@ +import { FormControl } from '@angular/forms'; +/* @format */ +import { Dialog } from './../../../dialog/dialog.service'; +import { AccountService } from './../../../shared/services/account/account.service'; +import { Component, OnDestroy } from '@angular/core'; +import { + UntypedFormGroup, + UntypedFormBuilder, + Validators, +} from '@angular/forms'; +import { AccountVO } from '@models/index'; +import { Observable, BehaviorSubject, Subscription } from 'rxjs'; + +@Component({ + selector: 'pr-gift-storage', + templateUrl: './gift-storage.component.html', + styleUrls: ['./gift-storage.component.scss'], +}) +export class GiftStorageComponent implements OnDestroy { + giftForm: UntypedFormGroup; + availableSpace: string; + account: AccountVO; + bytesPerGigabyte = 1073741824; + + public isSuccessful: boolean = false; + public giftResult: BehaviorSubject = new BehaviorSubject( + false + ); + private sub:Subscription; + + constructor( + private fb: UntypedFormBuilder, + private accountService: AccountService, + private dialog: Dialog + ) { + this.account = this.accountService.getAccount(); + this.availableSpace = this.bytesToGigabytes(this.account?.spaceLeft); + this.giftForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + amount: [ + '', + [ + Validators.required, + Validators.min(0), + Validators.max(Number(this.availableSpace)), + this.integerValidator, + ], + ], + message: ['', []], + }); + this.sub = this.giftResult.subscribe((isSuccessful) => { + this.isSuccessful = isSuccessful; + }); + } + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + submitStorageGiftForm(value: { + email: string; + amount: number; + message: string; + }) { + this.dialog.open( + 'ConfirmGiftDialogComponent', + { + ...value, + giftResult: this.giftResult, + }, + { + width: '700px', + } + ); + } + + closeSuccessMessage() { + const remainingSpaceAfterGift = + Number(this.availableSpace) - Number(this.giftForm.value.amount); + this.availableSpace = this.bytesToGigabytes( + remainingSpaceAfterGift * this.bytesPerGigabyte + ); + this.isSuccessful = false; + } + + bytesToGigabytes(bytes: number): string { + return (bytes / this.bytesPerGigabyte).toFixed(2); + } + + integerValidator(control: FormControl) { + const isInteger = Number.isInteger(Number(control.value)); + const hasDecimalPoint = control.value.toString().includes('.'); + + return isInteger && !hasDecimalPoint ? null : { notInteger: true }; + } +} diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.html b/src/app/core/components/storage-dialog/storage-dialog.component.html index 23c4dc357..a393ad832 100644 --- a/src/app/core/components/storage-dialog/storage-dialog.component.html +++ b/src/app/core/components/storage-dialog/storage-dialog.component.html @@ -11,6 +11,10 @@ (click)="setTab('add')" [class.active]="activeTab === 'add'"> Add Storage +
+ Gift Storage +
Redeem Gift
@@ -32,7 +36,9 @@ Click here to learn more about our endowment model.

- + + +
Redeem Gift
diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.scss b/src/app/core/components/storage-dialog/storage-dialog.component.scss index 76078c65b..c23ffceec 100644 --- a/src/app/core/components/storage-dialog/storage-dialog.component.scss +++ b/src/app/core/components/storage-dialog/storage-dialog.component.scss @@ -1,3 +1,4 @@ +/* @format */ @import 'variables'; :host { @@ -6,7 +7,7 @@ } .header { - @include tabbedDialogHeader($PR-purple) + @include tabbedDialogHeader($PR-purple); } .content { @@ -36,4 +37,4 @@ form { pr-storage-meter { margin: $grid-unit 0; -} \ No newline at end of file +} diff --git a/src/app/core/components/storage-dialog/storage-dialog.component.ts b/src/app/core/components/storage-dialog/storage-dialog.component.ts index f632fb3c2..b71c30e7c 100644 --- a/src/app/core/components/storage-dialog/storage-dialog.component.ts +++ b/src/app/core/components/storage-dialog/storage-dialog.component.ts @@ -1,10 +1,17 @@ import { ActivatedRoute, ParamMap } from '@angular/router'; import { Component, OnInit } from '@angular/core'; import { IsTabbedDialog, DialogRef } from '@root/app/dialog/dialog.module'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { + UntypedFormGroup, + UntypedFormBuilder, + Validators, +} from '@angular/forms'; import { PromoVOData } from '@models'; import { ApiService } from '@shared/services/api/api.service'; -import { BillingResponse, AccountResponse } from '@shared/services/api/index.repo'; +import { + BillingResponse, + AccountResponse, +} from '@shared/services/api/index.repo'; import { MessageService } from '@shared/services/message/message.service'; import { FileSizePipe } from '@shared/pipes/filesize.pipe'; import { AccountService } from '@shared/services/account/account.service'; @@ -14,7 +21,7 @@ type StorageDialogTab = 'add' | 'file' | 'transaction' | 'promo'; @Component({ selector: 'pr-storage-dialog', templateUrl: './storage-dialog.component.html', - styleUrls: ['./storage-dialog.component.scss'] + styleUrls: ['./storage-dialog.component.scss'], }) export class StorageDialogComponent implements OnInit, IsTabbedDialog { activeTab: StorageDialogTab = 'add'; @@ -23,28 +30,31 @@ export class StorageDialogComponent implements OnInit, IsTabbedDialog { waiting: boolean; + tabs = ['add', 'gift', 'promo', 'transaction', 'file']; + constructor( private fb: UntypedFormBuilder, private dialogRef: DialogRef, private account: AccountService, private api: ApiService, private message: MessageService, - private route: ActivatedRoute, + private route: ActivatedRoute ) { this.promoForm = this.fb.group({ - code: ['', [ Validators.required ]] + code: ['', [Validators.required]], }); } ngOnInit(): void { - this.route.paramMap.subscribe((params: ParamMap) => { + this.route.paramMap.subscribe((params: ParamMap) => { const path = params.get('path') as StorageDialogTab; - - if(path) { - this.activeTab = path; - } - }); + if (path && this.tabs.includes(path)) { + this.activeTab = path; + } else { + this.activeTab = 'add'; + } + }); } setTab(tab: StorageDialogTab) { @@ -63,7 +73,10 @@ export class StorageDialogComponent implements OnInit, IsTabbedDialog { const promo = response.getPromoVO(); const bytes = promo.sizeInMB * (1024 * 1024); const pipe = new FileSizePipe(); - this.message.showMessage(`Gift code redeemed for ${pipe.transform(bytes)} of storage`, 'success'); + this.message.showMessage( + `Gift code redeemed for ${pipe.transform(bytes)} of storage`, + 'success' + ); this.promoForm.reset(); } catch (err) { if (err instanceof BillingResponse || err instanceof AccountResponse) { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index faa4f283d..94c7addbf 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -62,6 +62,8 @@ import { ArchiveTypeChangeDialogComponent } from './components/archive-type-chan import { DirectiveModule } from '../directive/directive.module'; import { ArchivePayerComponent } from './components/archive-payer/archive-payer.component'; import { ConfirmPayerDialogComponent } from './components/confirm-payer-dialog/confirm-payer-dialog.component'; +import { GiftStorageComponent } from './components/gift-storage/gift-storage.component'; +import { ConfirmGiftDialogComponent } from './components/confirm-gift-dialog/confirm-gift-dialog.component'; @NgModule({ imports: [ @@ -112,6 +114,8 @@ import { ConfirmPayerDialogComponent } from './components/confirm-payer-dialog/c ArchiveTypeChangeDialogComponent, ArchivePayerComponent, ConfirmPayerDialogComponent, + GiftStorageComponent, + ConfirmGiftDialogComponent, ], providers: [ DataService, @@ -180,6 +184,10 @@ export class CoreModule { token: 'ConfirmPayerDialogComponent', component: ConfirmPayerDialogComponent, }, + { + token: 'ConfirmGiftDialogComponent', + component: ConfirmGiftDialogComponent, + }, ]; constructor( diff --git a/src/app/dialog/dialog.service.ts b/src/app/dialog/dialog.service.ts index c85b3e439..19b6476fc 100644 --- a/src/app/dialog/dialog.service.ts +++ b/src/app/dialog/dialog.service.ts @@ -33,7 +33,8 @@ export type DialogComponentToken = 'WelcomeInvitationDialogComponent' | 'CreateAccountDialogComponent' | 'ArchiveTypeChangeDialogComponent' | - 'ConfirmPayerDialogComponent' + 'ConfirmPayerDialogComponent' | + 'ConfirmGiftDialogComponent' ; export interface DialogChildComponentData { diff --git a/src/app/shared/services/api/billing.repo.ts b/src/app/shared/services/api/billing.repo.ts index acfb14a9c..fef793bb7 100644 --- a/src/app/shared/services/api/billing.repo.ts +++ b/src/app/shared/services/api/billing.repo.ts @@ -70,6 +70,15 @@ export class BillingRepo extends BaseRepo { BillingResponse ); } + + public giftStorage(recipientEmail, storageAmount) { + const data = { + recipientEmail, + storageAmount, + }; + + return this.httpV2.post('/billing/giftStorage', data).toPromise(); + } } export class BillingResponse extends BaseResponse {