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 @@
+
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 @@
+
+
+
+ @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.
+
+ }
+
+ Confirmar
+
+
+
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() }}
+
+
+
+
+
+ Finalizar
+
+
+
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) {
+
+ {{ paymentMethod }}
+
+ }
+
+
+
+ @for (paymentMethod of inLocoPaymentMethods; track $index) {
+
+ {{ paymentMethod }}
+
+ }
+
+
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 }}
+
+
+
+
+ Copiar código
+
+ @if (copiedToClipboard()) {
+ Copiado!
+ }
+
+
+
+
+
+ {{ showPixQrCode() ? 'Esconder' : 'Mostrar' }} código QR Pix
+
+
+
+
+ @if (showPixQrCode()) {
+
+ }
+
+
+ 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() }}
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+ Finalizar
+
+
+
+
+} @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) {
+
+ } @else if (currentErrorValue) {
+
+
+
+
+
+ {{ ERROR_MESSAGES[currentErrorValue] }}
+
+
+
+
+
+ Voltar
+
+
+ Tentar novamente
+
+
+
+ } @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 {