diff --git a/.vscode/settings.json b/.vscode/settings.json index 1947a0d..0eb3265 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,11 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" + }, + "angular-schematics.schematicsDefaultOptions": { + "angular-*": { + "skipStyle": true, + "externalTemplate": true + } } } diff --git a/package-lock.json b/package-lock.json index 3e8f0f9..748fdcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "date-fns": "^4.1.0", "idb-keyval": "^6.2.1", "jwt-decode": "^4.0.0", + "ngx-currency": "^19.0.0", "ngx-mask": "^19.0.6", "rxjs": "~7.8.2", "superjson": "^2.2.2", @@ -14801,6 +14802,20 @@ "dev": true, "license": "MIT" }, + "node_modules/ngx-currency": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-currency/-/ngx-currency-19.0.0.tgz", + "integrity": "sha512-N7SdB8D+ttPAf9GNiMyhChPUJs0t0mRTMiJ2UILWN83Vnx9dGc1ytjFrV7q3pCVA66svl1bpSFOfbZ2rhJ7zHA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0" + } + }, "node_modules/ngx-mask": { "version": "19.0.6", "resolved": "https://registry.npmjs.org/ngx-mask/-/ngx-mask-19.0.6.tgz", diff --git a/package.json b/package.json index 0dc8424..c254335 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "date-fns": "^4.1.0", "idb-keyval": "^6.2.1", "jwt-decode": "^4.0.0", + "ngx-currency": "^19.0.0", "ngx-mask": "^19.0.6", "rxjs": "~7.8.2", "superjson": "^2.2.2", diff --git a/public/assets/icons/bootstrapCash.svg b/public/assets/icons/bootstrapCash.svg new file mode 100644 index 0000000..18cbff3 --- /dev/null +++ b/public/assets/icons/bootstrapCash.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/bootstrapCreditCard.svg b/public/assets/icons/bootstrapCreditCard.svg new file mode 100644 index 0000000..406233d --- /dev/null +++ b/public/assets/icons/bootstrapCreditCard.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/bootstrapDebitCard.svg b/public/assets/icons/bootstrapDebitCard.svg new file mode 100644 index 0000000..b29419c --- /dev/null +++ b/public/assets/icons/bootstrapDebitCard.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/pix.svg b/public/assets/icons/pix.svg new file mode 100644 index 0000000..4304e41 --- /dev/null +++ b/public/assets/icons/pix.svg @@ -0,0 +1 @@ + diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a01683b..ccc0811 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { provideHttpClient } from '@angular/common/http'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; import { ApplicationConfig, inject, @@ -25,6 +25,8 @@ import { initializeApp, provideFirebaseApp } from '@angular/fire/app'; import { getAuth, provideAuth } from '@angular/fire/auth'; import { getFirestore, provideFirestore } from '@angular/fire/firestore'; +import { provideNgIconLoader } from '@ng-icons/core'; + import { routes } from '@rusbe/app.routes'; import { environment } from '@rusbe/environments/environment'; import { version } from '@rusbe/environments/version'; @@ -69,5 +71,9 @@ export const appConfig: ApplicationConfig = { }), ScreenTrackingService, UserTrackingService, + provideNgIconLoader((name) => { + const http = inject(HttpClient); + return http.get(`/assets/icons/${name}.svg`, { responseType: 'text' }); + }), ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1cfd3e2..0cf88c1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -84,6 +84,12 @@ export const routes: Routes = [ (m) => m.LegalTermsPageComponent, ), }, + { + path: 'top-up', + loadComponent: () => + import('./pages/top-up/top-up.component').then((m) => m.TopUpComponent), + ...canActivate(redirectFirebaseUnauthorizedToLogin), + }, { path: '404', loadComponent: () => diff --git a/src/app/components/cards/card-button/card-button.component.html b/src/app/components/cards/card-button/card-button.component.html new file mode 100644 index 0000000..0f5d132 --- /dev/null +++ b/src/app/components/cards/card-button/card-button.component.html @@ -0,0 +1,29 @@ +
+ @if (iconName()) { + + } @else { +
+ } +
+

+

+ {{ subtitle() }} +

+
+ @if (badgeText()) { +

+ {{ badgeText() }} +

+ } + +
diff --git a/src/app/components/cards/card-button/card-button.component.ts b/src/app/components/cards/card-button/card-button.component.ts new file mode 100644 index 0000000..b5ddc11 --- /dev/null +++ b/src/app/components/cards/card-button/card-button.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { NgIcon } from '@ng-icons/core'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'button[rusbe-card-button]', + imports: [NgIcon], + templateUrl: './card-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'grouping-card-strip-button w-full disabled:opacity-50' }, +}) +export class CardButtonComponent { + subtitle = input.required(); + iconName = input.required(); + badgeText = input(); +} diff --git a/src/app/components/cards/card-group/card-group.component.html b/src/app/components/cards/card-group/card-group.component.html new file mode 100644 index 0000000..5afe185 --- /dev/null +++ b/src/app/components/cards/card-group/card-group.component.html @@ -0,0 +1,2 @@ +

{{ title() }}

+ diff --git a/src/app/components/cards/card-group/card-group.component.ts b/src/app/components/cards/card-group/card-group.component.ts new file mode 100644 index 0000000..235d88b --- /dev/null +++ b/src/app/components/cards/card-group/card-group.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'section[rusbe-card-group]', + templateUrl: './card-group.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'grouping-card' }, +}) +export class CardGroupComponent { + title = input.required(); +} diff --git a/src/app/components/header/back-button/back-button.component.ts b/src/app/components/header/back-button/back-button.component.ts index 9bdbcfc..3428331 100644 --- a/src/app/components/header/back-button/back-button.component.ts +++ b/src/app/components/header/back-button/back-button.component.ts @@ -15,12 +15,16 @@ import { ButtonColorScheme } from '@rusbe/components/header/button-color-scheme' }) export class BackButtonComponent { colorScheme = input(ButtonColorScheme.Page); + customAction = input<(() => boolean) | null>(null); ButtonColorScheme = ButtonColorScheme; router = inject(Router); location = inject(Location); goBack() { + const customAction = this.customAction()?.(); + if (customAction) return; + if (this.router.lastSuccessfulNavigation?.previousNavigation != null) { this.location.back(); } else { diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index adc654a..4046a77 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -9,6 +9,7 @@

@if (hasBackButton()) { } diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index 58572d6..059c3b3 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -22,6 +22,7 @@ export class HeaderComponent { HeaderType = HeaderType; type = input(HeaderType.LogoWithUserAccountButton); + customAction = input<(() => boolean) | null>(null); readonly buttonColorSchemeMap: Partial< Record diff --git a/src/app/components/top-up/calculator/top-up-calculator.component.html b/src/app/components/top-up/calculator/top-up-calculator.component.html new file mode 100644 index 0000000..7d21b36 --- /dev/null +++ b/src/app/components/top-up/calculator/top-up-calculator.component.html @@ -0,0 +1,67 @@ +
+
+ R$ + + + @if (topUpValue.touched && topUpValue.hasError('lowTopUpValue')) { +

+ O valor da recarga deve ser no mínimo R$ 1,00 +

+ } @else if (topUpValue.touched && topUpValue.hasError('highTopUpValue')) { +

+ O valor da recarga deve ser no máximo R$ 100,00 +

+ } +
+
+
+ +
+
+ @if (currentBalance().value.toString() !== newBalance().toString()) { +

+ Você tem R$ {{ currentBalance().value }} de saldo. Após + a transação, você ficará com R$ {{ newBalance() }}. +

+ } @else { +

+ Você tem R$ {{ currentBalance().value }} de saldo. +

+ } + +
diff --git a/src/app/components/top-up/calculator/top-up-calculator.component.ts b/src/app/components/top-up/calculator/top-up-calculator.component.ts new file mode 100644 index 0000000..80d012d --- /dev/null +++ b/src/app/components/top-up/calculator/top-up-calculator.component.ts @@ -0,0 +1,114 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + model, + output, + signal, +} from '@angular/core'; +import { + AbstractControl, + FormControl, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; + +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideMoveRight } from '@ng-icons/lucide'; +import { NgxCurrencyDirective, NgxCurrencyInputMode } from 'ngx-currency'; + +import { MealBalanceBreakdownComponent } from '@rusbe/components/meal-balance-breakdown/meal-balance-breakdown.component'; +import { WarningCardComponent } from '@rusbe/components/warning-card/warning-card.component'; +import { GeneralGoodsPartialGrantBalance } from '@rusbe/services/general-goods/general-goods.service'; +import { BrlCurrency } from '@rusbe/types/brl-currency'; + +@Component({ + selector: 'rusbe-top-up-calculator', + imports: [ + NgIf, + MealBalanceBreakdownComponent, + FormsModule, + NgxCurrencyDirective, + ReactiveFormsModule, + WarningCardComponent, + NgIcon, + ], + templateUrl: './top-up-calculator.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'flex flex-grow flex-col items-start justify-start', + }, + viewProviders: [ + provideIcons({ + lucideMoveRight, + }), + ], +}) +export class TopUpCalculatorComponent { + submitted = output(); + + value = model.required(); + currentBalance = input.required(); + + inputDisabled = signal(false); + + newBalance = computed(() => { + const topUpValueFloat = parseFloat(this.value()); + const currentBalance = this.currentBalance().value; + + if (isNaN(topUpValueFloat)) return currentBalance; + + return currentBalance.add(BrlCurrency.fromNumber(topUpValueFloat)); + }); + + readonly topUpValue = new FormControl( + { value: '0', disabled: this.inputDisabled() }, + { + validators: [ + Validators.required, + lowTopUpValueValidator, + highTopUpValueValidator, + ], + }, + ); + + readonly maskConfig = { + align: 'left', + allowNegative: false, + allowZero: true, + decimal: ',', + precision: 2, + prefix: '', + suffix: '', + thousands: '', + nullable: true, + min: null, + max: null, + inputMode: NgxCurrencyInputMode.Financial, + }; + + onSubmitValue() { + this.topUpValue.markAsTouched(); + if (this.topUpValue.invalid) return; + this.submitted.emit(true); + } +} + +const lowTopUpValueValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const value = control.value; + return value < 1 ? { lowTopUpValue: true } : null; +}; + +const highTopUpValueValidator: ValidatorFn = ( + control: AbstractControl, +): ValidationErrors | null => { + const value = control.value; + return value > 100 ? { highTopUpValue: true } : null; +}; diff --git a/src/app/components/top-up/credit-card/top-up-credit-card.component.html b/src/app/components/top-up/credit-card/top-up-credit-card.component.html new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/top-up/credit-card/top-up-credit-card.component.ts b/src/app/components/top-up/credit-card/top-up-credit-card.component.ts new file mode 100644 index 0000000..0d49a93 --- /dev/null +++ b/src/app/components/top-up/credit-card/top-up-credit-card.component.ts @@ -0,0 +1,9 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'rusbe-top-up-credit-card', + imports: [], + templateUrl: './top-up-credit-card.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TopUpCreditCardComponent {} diff --git a/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.html b/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.html new file mode 100644 index 0000000..6873d44 --- /dev/null +++ b/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.html @@ -0,0 +1,48 @@ +
+
+
+ +
+
+

Dados pessoais

+ @if (parsedCpf()) { +

+ {{ parsedCpf() }} +

+ } +

+ {{ name() }} +

+
+
+

Valor da Recarga

+
+

R$

+

+ {{ parsedValue() }} +

+
+
+
+

Forma de pagamento

+
+

+ {{ paymentMethod() }} +

+
+
+
+ +
diff --git a/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.ts b/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.ts new file mode 100644 index 0000000..66b2284 --- /dev/null +++ b/src/app/components/top-up/in-loco-helper/top-up-in-loco-helper.component.ts @@ -0,0 +1,56 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; + +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideMoveRight } from '@ng-icons/lucide'; + +import { LogoComponent } from '@rusbe/components/logo/logo.component'; +import { BrlCurrency } from '@rusbe/types/brl-currency'; + +import { PaymentMethods } from '../payment-method/payment-method.component'; + +@Component({ + selector: 'rusbe-top-up-in-loco-helper', + imports: [LogoComponent, NgIcon], + templateUrl: './top-up-in-loco-helper.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + provideIcons({ + lucideMoveRight, + }), + ], + host: { + class: 'flex flex-grow flex-col items-start justify-start', + }, +}) +export class TopUpInLocoHelperComponent { + backClicked = output(); + + cpf = input.required(); + name = input.required(); + topUpValue = input.required(); + paymentMethod = input.required(); + + parsedCpf = computed(() => this.parseCpfNumber()); + parsedValue = computed(() => + BrlCurrency.fromNumber(parseFloat(this.topUpValue())), + ); + + onBackClicked(): void { + this.backClicked.emit(); + } + + private parseCpfNumber(): string { + const cpfNumber = this.cpf(); + + if (!cpfNumber) return ''; + if (cpfNumber.length !== 11) return cpfNumber; + + return cpfNumber.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); + } +} diff --git a/src/app/components/top-up/payment-method/payment-method.component.html b/src/app/components/top-up/payment-method/payment-method.component.html new file mode 100644 index 0000000..e0c9f18 --- /dev/null +++ b/src/app/components/top-up/payment-method/payment-method.component.html @@ -0,0 +1,29 @@ +
+
+ @for (paymentMethod of inAppPaymentMethods; track $index) { + + } +
+ +
+ @for (paymentMethod of inLocoPaymentMethods; track $index) { + + } +
+
diff --git a/src/app/components/top-up/payment-method/payment-method.component.ts b/src/app/components/top-up/payment-method/payment-method.component.ts new file mode 100644 index 0000000..97d930a --- /dev/null +++ b/src/app/components/top-up/payment-method/payment-method.component.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, output } from '@angular/core'; + +import { provideIcons } from '@ng-icons/core'; +import { + lucideBanknote, + lucideChevronRight, + lucideCreditCard, + lucideEraser, + lucideFullscreen, + lucideListStart, + lucidePalette, + lucideRotateCcw, + lucideUtensils, +} from '@ng-icons/lucide'; + +import { CardButtonComponent } from '@rusbe/components/cards/card-button/card-button.component'; +import { CardGroupComponent } from '@rusbe/components/cards/card-group/card-group.component'; +import { GeneralGoodsTransactionType } from '@rusbe/services/general-goods/general-goods.service'; + +@Component({ + selector: 'rusbe-top-up-payment-method', + imports: [CardGroupComponent, CardButtonComponent], + templateUrl: './payment-method.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + provideIcons({ + lucidePalette, + lucideUtensils, + lucideListStart, + lucideFullscreen, + lucideRotateCcw, + lucideEraser, + lucideChevronRight, + lucideCreditCard, + lucideBanknote, + }), + ], +}) +export class TopUpPaymentMethodComponent { + paymentMethodChanged = output(); + paymentLocationChanged = output(); + + readonly inAppPaymentMethods = Object.values(GeneralGoodsTransactionType); + readonly inLocoPaymentMethods = INLOCO_PAYMENT_METHODS; + readonly PAYMENT_METHOD_MESSAGES = { + [GeneralGoodsTransactionType.Pix]: + 'Use o código gerado para pagar usando Pix.', + [GeneralGoodsTransactionType.CreditCard]: + 'Use seu cartão de crédito para completar a transação.', + }; + + readonly PAYMENT_METHOD_ICONS = { + [GeneralGoodsTransactionType.Pix]: 'pix', + [GeneralGoodsTransactionType.CreditCard]: 'bootstrapCreditCard', + 'Em espécie': 'bootstrapCash', + 'Cartão de débito': 'bootstrapDebitCard', + }; + + handlePaymentMethodSelection( + selectedPaymentMethod: PaymentMethods, + selectedPaymentLocation: PaymentLocation, + ) { + this.paymentMethodChanged.emit(selectedPaymentMethod); + this.paymentLocationChanged.emit(selectedPaymentLocation); + } +} + +export type PaymentLocation = 'InLoco' | 'InApp'; + +export const INLOCO_PAYMENT_METHODS = [ + 'Em espécie', + 'Cartão de crédito', + 'Cartão de débito', + 'Pix', +] as const; + +export type PaymentMethods = + | (typeof INLOCO_PAYMENT_METHODS)[number] + | GeneralGoodsTransactionType; diff --git a/src/app/components/top-up/pix/top-up-pix.component.html b/src/app/components/top-up/pix/top-up-pix.component.html new file mode 100644 index 0000000..c0a9ff8 --- /dev/null +++ b/src/app/components/top-up/pix/top-up-pix.component.html @@ -0,0 +1,145 @@ +@if (pixTransactionData()) { +
+
+
+
+
+

R$

+

+ {{ parsedTopUpValue() }} +

+
+
+ +

+ {{ pixTransactionData()?.qrCodeString }} +

+ +
+ + +
+ + @if (showPixQrCode()) { + Código QR Pix + } + +

+ Realize o pagamento usando o app do banco ou instituição financeira de + sua preferência. A transação pode levar alguns minutos para ser + processada. +

+ +
+ +

+ {{ LOCALIZED_CURRENT_DATE }} +

+
+
+
+ +
+
+

+ {{ parsedCpf() }} +

+
+
+

+ {{ name() }} +

+
+
+
+ +

+ Você tem {{ timeLeft() }} para completar o + pagamento +

+
+
+
+
+

+ Sempre verifique se o destinatário do seu pagamento corresponde à + General Goods. +

+

+ Esta transação é feita diretamente à General Goods, e o Rusbé não + recebe, processa ou armazena dados de pagamento. A General Goods é + responsável por processar seu pagamento e pelas informações de créditos + e recarga mostradas. +

+
+
+ +
+
+} @else { +
+
+ +
+

Gerando código pix...

+
+} diff --git a/src/app/components/top-up/pix/top-up-pix.component.ts b/src/app/components/top-up/pix/top-up-pix.component.ts new file mode 100644 index 0000000..67e5271 --- /dev/null +++ b/src/app/components/top-up/pix/top-up-pix.component.ts @@ -0,0 +1,110 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; + +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + lucideAlarmClock, + lucideCalendarDays, + lucideCopy, + lucideInfo, + lucideMoveRight, + lucideScanQrCode, + lucideUserRound, +} from '@ng-icons/lucide'; + +import { CardGroupComponent } from '@rusbe/components/cards/card-group/card-group.component'; +import { SpinnerComponent } from '@rusbe/components/spinner/spinner.component'; +import { GeneralGoodsPixTransactionData } from '@rusbe/services/general-goods/general-goods.service'; +import { BrlCurrency } from '@rusbe/types/brl-currency'; + +@Component({ + selector: 'rusbe-top-up-pix', + imports: [CardGroupComponent, NgIcon, SpinnerComponent], + templateUrl: './top-up-pix.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + provideIcons({ + lucideCopy, + lucideScanQrCode, + lucideCalendarDays, + lucideUserRound, + lucideMoveRight, + lucideInfo, + lucideAlarmClock, + }), + ], + host: { + class: 'flex flex-grow flex-col items-start justify-start', + }, +}) +export class TopUpPixComponent { + finished = output(); + + readonly CURRENT_DATE = new Date(); + readonly LOCALIZED_CURRENT_DATE = this.parseCurrentDate(); + + topUpValue = input.required(); + cpf = input.required(); + name = input.required(); + timeLeft = input.required(); + + pixTransactionData = input(); + + pixQrCodeSource = computed(() => this.parseBase64ImageSource()); + parsedTopUpValue = computed(() => + BrlCurrency.fromNumber(parseFloat(this.topUpValue())).toString(), + ); + parsedCpf = computed(() => this.parseCpfNumber()); + + showPixQrCode = signal(false); + copiedToClipboard = signal(false); + + private readonly clipboard = inject(Clipboard); + + copyPasswordToClipboard() { + const pixTransactionData = this.pixTransactionData(); + if (!pixTransactionData) return; + this.clipboard.copy(pixTransactionData.qrCodeString); + this.copiedToClipboard.set(true); + } + + onFinish() { + this.finished.emit(); + } + + private parseCurrentDate(): string { + const dateString = this.CURRENT_DATE.toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric', + }); + const timeString = Intl.DateTimeFormat('pt-BR', { + hour: 'numeric', + minute: 'numeric', + }).format(this.CURRENT_DATE); + return `${dateString}, ${timeString}`; + } + + private parseBase64ImageSource(): string { + const pixTransactionData = this.pixTransactionData(); + if (!pixTransactionData) return ''; + return `data:image/png;base64, ${pixTransactionData.qrCodeBase64Image}`; + } + + private parseCpfNumber(): string { + const cpfNumber = this.cpf(); + + if (!cpfNumber) return ''; + if (cpfNumber.length !== 11) return cpfNumber; + + return cpfNumber.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '•••.$2.$3-••'); + } +} diff --git a/src/app/pages/home/home.component.html b/src/app/pages/home/home.component.html index 8dc97d4..4f4db1a 100644 --- a/src/app/pages/home/home.component.html +++ b/src/app/pages/home/home.component.html @@ -28,12 +28,11 @@ } @else { } - Adicionar créditos + @if (showAddCreditsCard()) { + Adicionar créditos + } Histórico de cardápios diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 282072c..015e955 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -17,6 +17,7 @@ import { AccountAuthState, AuthStateService, } from '@rusbe/services/auth-state/auth-state.service'; +import { GeneralGoodsBalanceType } from '@rusbe/services/general-goods/general-goods.service'; import { KnowledgeService } from '@rusbe/services/knowledge/knowledge.service'; @Component({ @@ -46,4 +47,9 @@ export class HomePageComponent { const authState = this.authStateService.accountAuthState(); return authState && authState !== AccountAuthState.LoggedIn; }); + public showAddCreditsCard = computed( + () => + this.authStateService.generalGoodsAccountData()?.balance.type !== + GeneralGoodsBalanceType.FullGrantStudentHousing, + ); } diff --git a/src/app/pages/top-up/top-up.component.html b/src/app/pages/top-up/top-up.component.html new file mode 100644 index 0000000..af62ce9 --- /dev/null +++ b/src/app/pages/top-up/top-up.component.html @@ -0,0 +1,98 @@ +
+
+
+ + Adicionar créditos + +
+ @let currentErrorValue = currentError(); + + @if (accountAuthState() === undefined) { +
+
+ +
+

Um momento...

+
+ } @else if (currentErrorValue) { +
+
+ + +

+ {{ ERROR_MESSAGES[currentErrorValue] }} +

+
+ +
+ + +
+
+ } @else { + @if (STAGE_MESSAGE[currentStage()]) { +
+

+ {{ STAGE_MESSAGE[currentStage()] }} +

+
+ } +
+ @if (!valueSubmitted()) { + + } @else if (!paymentLocation() && !paymentMethod()) { + + } @else if (paymentLocation() === 'InLoco') { + + } @else if (paymentMethod() === 'Pix') { + + } @else if (paymentMethod() === 'Cartão de crédito') { + + } +
+ } +
+
diff --git a/src/app/pages/top-up/top-up.component.ts b/src/app/pages/top-up/top-up.component.ts new file mode 100644 index 0000000..fa8aa19 --- /dev/null +++ b/src/app/pages/top-up/top-up.component.ts @@ -0,0 +1,257 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + OnDestroy, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { Router } from '@angular/router'; + +import { Subscription, interval, takeUntil, timer } from 'rxjs'; + +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideInfo } from '@ng-icons/lucide'; + +import { + HeaderComponent, + HeaderType, +} from '@rusbe/components/header/header.component'; +import { SpinnerComponent } from '@rusbe/components/spinner/spinner.component'; +import { TopUpCalculatorComponent } from '@rusbe/components/top-up/calculator/top-up-calculator.component'; +import { TopUpCreditCardComponent } from '@rusbe/components/top-up/credit-card/top-up-credit-card.component'; +import { TopUpInLocoHelperComponent } from '@rusbe/components/top-up/in-loco-helper/top-up-in-loco-helper.component'; +import { + PaymentLocation, + PaymentMethods, + TopUpPaymentMethodComponent, +} from '@rusbe/components/top-up/payment-method/payment-method.component'; +import { TopUpPixComponent } from '@rusbe/components/top-up/pix/top-up-pix.component'; +import { + AccountAuthState, + AuthStateService, +} from '@rusbe/services/auth-state/auth-state.service'; +import { + GeneralGoodsPartialGrantBalance, + GeneralGoodsPixTransactionData, + GeneralGoodsService, +} from '@rusbe/services/general-goods/general-goods.service'; +import { BrlCurrency } from '@rusbe/types/brl-currency'; +import { RusbeError } from '@rusbe/types/error-handling'; + +@Component({ + selector: 'rusbe-top-up', + imports: [ + HeaderComponent, + CommonModule, + TopUpPaymentMethodComponent, + TopUpCalculatorComponent, + TopUpPixComponent, + TopUpCreditCardComponent, + TopUpInLocoHelperComponent, + SpinnerComponent, + NgIcon, + ], + templateUrl: './top-up.component.html', + viewProviders: [ + provideIcons({ + lucideInfo, + }), + ], +}) +export class TopUpComponent implements OnDestroy { + readonly STAGE_MESSAGE = { + [TopUpStage.Calculator]: 'Quanto você quer adicionar?', + [TopUpStage.PaymentMethod]: 'Como você deseja adicionar créditos?', + [TopUpStage.InLocoHelper]: 'No guichê, mostre essas informações', + [TopUpStage.Pix]: '', + [TopUpStage.CreditCard]: '', + error: '', + }; + readonly ERROR_MESSAGES = { + [TopUpError.GeneralGoodsUnavailable]: + 'Infelizmente, o sistema da General Goods está fora do ar.', + [TopUpError.PixUnavailable]: + 'Ocorreu um erro ao tentar gerar o código Pix.', + [TopUpError.Generic]: + 'Ocorreu um erro desconhecido. Por favor, tente novamente.', + }; + readonly FIFTEEN_MINUTES = 15 * 60 * 1000; + + calculatorComponent = viewChild(TopUpCalculatorComponent); + paymentMethodComponent = viewChild(TopUpPaymentMethodComponent); + inLocoHelperComponent = viewChild(TopUpInLocoHelperComponent); + creditCardComponent = viewChild(TopUpCreditCardComponent); + pixComponent = viewChild(TopUpPixComponent); + + valueSubmitted = signal(false); + value = signal('0'); + paymentMethod = signal(null); + paymentLocation = signal(null); + pixTransactionData = signal(null); + currentError = signal(null); + remainingTimeString = signal(this.parseRemainingTime(0)); + + headerType = computed(() => { + if ( + this.currentStage() === TopUpStage.Calculator || + this.currentStage() === TopUpStage.PaymentMethod + ) { + return HeaderType.PageNameWithBackButton; + } + return HeaderType.PageNameWithCloseButton; + }); + accountData = computed(() => { + const accountData = this.authStateService.generalGoodsAccountData(); + if (!accountData) return { fullName: '', cpfNumber: '' }; + + return { + fullName: accountData.fullName, + cpfNumber: accountData.cpfNumber, + }; + }); + accountBalance = computed( + () => + this.authStateService.generalGoodsAccountData() + ?.balance as GeneralGoodsPartialGrantBalance, + ); + currentStage = computed(() => { + if (this.calculatorComponent()) { + return 'calculator'; + } else if (this.paymentMethodComponent()) { + return 'payment-method'; + } else if (this.inLocoHelperComponent()) { + return 'in-loco-helper'; + } else if (this.creditCardComponent()) { + return 'credit-card'; + } else if (this.pixComponent()) { + return 'pix'; + } + return 'error'; + }); + accountAuthState = computed(() => this.authStateService.accountAuthState()); + + pixTimerSubscription?: Subscription; + + private readonly router = inject(Router); + private readonly generalGoodsService = inject(GeneralGoodsService); + private readonly authStateService = inject(AuthStateService); + + constructor() { + effect(() => { + if (this.pixComponent()) this.startPixTransaction(); + }); + + effect(() => { + if (this.accountAuthState() === AccountAuthState.LoggedIn) { + this.currentError.set(null); + } else { + this.currentError.set(TopUpError.GeneralGoodsUnavailable); + } + }); + } + + ngOnDestroy(): void { + this.pixTimerSubscription?.unsubscribe(); + } + + goToPreviousStage(): boolean { + switch (this.currentStage()) { + case 'calculator': + return false; + case 'payment-method': + this.valueSubmitted.set(false); + return true; + case 'credit-card': + case 'in-loco-helper': + case 'pix': + this.paymentLocation.set(null); + this.paymentMethod.set(null); + return true; + case 'error': + return false; + default: + return false; + } + } + + goToHome() { + this.router.navigate(['/']); + } + + retry(): void { + if (this.currentError() === TopUpError.GeneralGoodsUnavailable) { + this.authStateService.updateAccountAuthState(); + } else if (this.currentError() === TopUpError.PixUnavailable) { + this.currentError.set(null); + } + } + + onPaymentMethodChange(paymentMethod: PaymentMethods) { + this.paymentMethod.set(paymentMethod); + } + + onPaymentLocaltionChange(paymentLocation: PaymentLocation) { + this.paymentLocation.set(paymentLocation); + } + + onCalculatorConfirm(value: boolean) { + this.valueSubmitted.set(value); + } + + private async startPixTransaction() { + try { + const pixTransactionResponse = + await this.generalGoodsService.startAddCreditsTransactionUsingPix( + BrlCurrency.fromNumber(parseFloat(this.value())), + ); + this.pixTransactionData.set(pixTransactionResponse); + + this.startPixTimer(); + } catch (error) { + if (error instanceof RusbeError) { + this.currentError.set(TopUpError.PixUnavailable); + return; + } + this.currentError.set(TopUpError.Generic); + } + } + + private startPixTimer() { + const source = interval(1000); + + const result = source.pipe(takeUntil(timer(this.FIFTEEN_MINUTES))); + + this.pixTimerSubscription = result.subscribe({ + next: (timeSpent) => { + this.remainingTimeString.set(this.parseRemainingTime(timeSpent)); + }, + complete: () => { + this.router.navigate(['/']); + }, + }); + } + + private parseRemainingTime(timeSpent: number): string { + const remainingTime = this.FIFTEEN_MINUTES - timeSpent * 1000; + const minutes = Math.floor(remainingTime / 60000); + const seconds = Math.floor((remainingTime % 60000) / 1000); + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; + } +} + +export enum TopUpStage { + Calculator = 'calculator', + PaymentMethod = 'payment-method', + Pix = 'pix', + CreditCard = 'credit-card', + InLocoHelper = 'in-loco-helper', +} + +enum TopUpError { + GeneralGoodsUnavailable = 'general-goods-unavailable', + PixUnavailable = 'pix-unavailable', + Generic = 'generic-error', +} diff --git a/src/app/services/general-goods/general-goods.service.ts b/src/app/services/general-goods/general-goods.service.ts index e50c27c..034144e 100644 --- a/src/app/services/general-goods/general-goods.service.ts +++ b/src/app/services/general-goods/general-goods.service.ts @@ -545,4 +545,5 @@ export enum GeneralGoodsBalanceType { export enum GeneralGoodsTransactionType { Pix = 'Pix', + CreditCard = 'Cartão de crédito', } diff --git a/src/styles.css b/src/styles.css index 6c10808..3c0deb8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -199,6 +199,10 @@ @apply w-full justify-center; } + .button-split { + @apply button-base w-full justify-between; + } + .button-large { @apply p-4 text-sm sm:text-lg; } @@ -220,7 +224,7 @@ } .grouping-card-strip-button { - @apply w-full touch-manipulation rounded-sm font-bold text-accent transition-all hover:bg-accent/10 focus:outline-none focus:ring focus:ring-accent focus:ring-offset-0 active:bg-accent/30 sm:gap-6; + @apply w-full touch-manipulation rounded-sm font-bold text-accent transition-all hover:bg-accent/10 focus:outline-none focus:ring focus:ring-accent focus:ring-offset-0 active:bg-accent/30 disabled:pointer-events-none sm:gap-6; } .grouping-card-strip-button-inner {