@bloonio/lokotro-pay 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -253,6 +253,7 @@ var LokotroPayScreenNavigation;
253
253
  LokotroPayScreenNavigation["MobileMoneyFormScreen"] = "mobilemoneyFormScreen";
254
254
  LokotroPayScreenNavigation["CardFormScreen"] = "cardFormScreen";
255
255
  LokotroPayScreenNavigation["BankTransferFormScreen"] = "bankTransferFormScreen";
256
+ LokotroPayScreenNavigation["BankTransferOfferScreen"] = "bankTransferOfferScreen";
256
257
  LokotroPayScreenNavigation["FlashFormScreen"] = "flashFormScreen";
257
258
  LokotroPayScreenNavigation["PaymentConfirmationScreen"] = "paymentConfirmationScreen";
258
259
  LokotroPayScreenNavigation["ProcessingScreen"] = "processingScreen";
@@ -275,6 +276,7 @@ const LokotroPayScreenNavigationInfo = {
275
276
  [LokotroPayScreenNavigation.MobileMoneyFormScreen]: 'Mobile Money Form',
276
277
  [LokotroPayScreenNavigation.CardFormScreen]: 'Card Form',
277
278
  [LokotroPayScreenNavigation.BankTransferFormScreen]: 'Bank Transfer Form',
279
+ [LokotroPayScreenNavigation.BankTransferOfferScreen]: 'Bank Transfer Offer',
278
280
  [LokotroPayScreenNavigation.FlashFormScreen]: 'Flash Form',
279
281
  [LokotroPayScreenNavigation.PaymentConfirmationScreen]: 'Payment Confirmation',
280
282
  [LokotroPayScreenNavigation.ProcessingScreen]: 'Processing',
@@ -629,6 +631,10 @@ const EN_TRANSLATIONS = {
629
631
  accountLabel: 'Account Label',
630
632
  bankTransferProofInstructions: 'A payment link will be sent to your email to upload the proof of transfer.',
631
633
  confirmBankTransfer: 'Confirm Transfer',
634
+ bankTransferSwitchTitle: 'Payment method unavailable',
635
+ bankTransferSwitchMessage: "This amount isn't available for the selected payment method. Would you like to continue with a bank transfer instead?",
636
+ bankTransferSwitchConfirm: 'Continue with bank transfer',
637
+ bankTransferSwitchDecline: 'Choose another method',
632
638
  // Auto-redirect countdown
633
639
  redirectingIn: 'Redirecting in {seconds}s',
634
640
  tapToCancel: 'Tap to cancel'
@@ -737,6 +743,10 @@ const FR_TRANSLATIONS = {
737
743
  accountLabel: 'Libellé du compte',
738
744
  bankTransferProofInstructions: 'Un lien de paiement sera envoyé à votre adresse e-mail pour télécharger la preuve de virement.',
739
745
  confirmBankTransfer: 'Confirmer le virement',
746
+ bankTransferSwitchTitle: 'Moyen de paiement indisponible',
747
+ bankTransferSwitchMessage: "Ce montant n'est pas disponible pour le moyen de paiement choisi. Souhaitez-vous continuer avec un virement bancaire ?",
748
+ bankTransferSwitchConfirm: 'Continuer avec un virement',
749
+ bankTransferSwitchDecline: 'Choisir un autre moyen',
740
750
  // Auto-redirect countdown
741
751
  redirectingIn: 'Redirection dans {seconds}s',
742
752
  tapToCancel: 'Appuyez pour annuler'
@@ -845,6 +855,10 @@ const ES_TRANSLATIONS = {
845
855
  accountLabel: 'Etiqueta de la cuenta',
846
856
  bankTransferProofInstructions: 'Se enviará un enlace de pago a su correo electrónico para cargar el comprobante de transferencia.',
847
857
  confirmBankTransfer: 'Confirmar transferencia',
858
+ bankTransferSwitchTitle: 'Método de pago no disponible',
859
+ bankTransferSwitchMessage: 'Este importe no está disponible para el método de pago seleccionado. ¿Desea continuar con una transferencia bancaria?',
860
+ bankTransferSwitchConfirm: 'Continuar con transferencia',
861
+ bankTransferSwitchDecline: 'Elegir otro método',
848
862
  // Auto-redirect countdown
849
863
  redirectingIn: 'Redirigiendo en {seconds}s',
850
864
  tapToCancel: 'Toque para cancelar'
@@ -2692,1568 +2706,2053 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
2692
2706
  }] } });
2693
2707
 
2694
2708
  /**
2695
- * Lokotro Pay - OTP Verification Component
2709
+ * Lokotro Pay - Feature Flags
2710
+ *
2711
+ * Compile-time switches for capabilities that are partially wired into the SDK
2712
+ * but not yet ready to expose to end users. Flip a flag to `true` once the
2713
+ * feature is implemented end-to-end.
2696
2714
  */
2697
- class LokotroOtpVerificationComponent {
2698
- constructor(localization) {
2699
- this.localization = localization;
2700
- this.otpLength = 6;
2701
- this.otpVerified = new EventEmitter();
2702
- this.resendOtp = new EventEmitter();
2703
- this.cancel = new EventEmitter();
2704
- this.otpDigits = [];
2705
- this.resendTimer = 60;
2715
+ const LOKOTRO_PAY_FEATURE_FLAGS = {
2716
+ /**
2717
+ * Native wallet payments — Google Pay and Apple Pay.
2718
+ *
2719
+ * The models and method-flag plumbing already understand these methods, but
2720
+ * the native tokenisation flows are not implemented end-to-end yet. While
2721
+ * this is `false` the SDK hides Google Pay / Apple Pay from the
2722
+ * payment-method selection UI so they can never be shown or selected. Set to
2723
+ * `true` once the native integrations are complete.
2724
+ */
2725
+ nativeWalletsEnabled: false,
2726
+ };
2727
+ /**
2728
+ * Whether a payment method is a Google Pay / Apple Pay native wallet method.
2729
+ *
2730
+ * Matches the provider flag as well as the raw name / id / channel strings, so
2731
+ * detection holds even when the backend channel does not map onto the enum.
2732
+ */
2733
+ function isNativeWalletMethod(method) {
2734
+ if (method.flag === 'google_pay' || method.flag === 'apple_pay') {
2735
+ return true;
2706
2736
  }
2707
- ngOnInit() {
2708
- this.otpDigits = new Array(this.otpLength).fill('');
2709
- this.startResendTimer();
2737
+ const looksNative = (value) => {
2738
+ const key = (value ?? '').toLowerCase().replace(/[^a-z]/g, '');
2739
+ return key.includes('googlepay') || key.includes('applepay');
2740
+ };
2741
+ return (looksNative(method.name) ||
2742
+ looksNative(method.displayName) ||
2743
+ looksNative(method.id) ||
2744
+ looksNative(method.channel));
2745
+ }
2746
+ /**
2747
+ * Returns the payment methods that should be visible to the user, dropping any
2748
+ * hidden behind a disabled feature flag (currently Google / Apple Pay). The
2749
+ * list is returned unchanged once the relevant flag is enabled.
2750
+ */
2751
+ function visiblePaymentMethods(methods) {
2752
+ if (LOKOTRO_PAY_FEATURE_FLAGS.nativeWalletsEnabled) {
2753
+ return methods;
2710
2754
  }
2711
- ngOnDestroy() {
2712
- if (this.timerInterval) {
2713
- clearInterval(this.timerInterval);
2714
- }
2755
+ return methods.filter((method) => !isNativeWalletMethod(method));
2756
+ }
2757
+
2758
+ /**
2759
+ * Lokotro Pay - Payment Service
2760
+ * Main payment service that follows the Lokotro Gateway API flow
2761
+ */
2762
+ /**
2763
+ * Initial payment state
2764
+ */
2765
+ const initialState = {
2766
+ isLoading: false,
2767
+ currentScreen: LokotroPayScreenNavigation.LoadingScreen
2768
+ };
2769
+ class LokotroPaymentService {
2770
+ static { this.MOBILE_MONEY_POLL_INTERVAL = 5000; }
2771
+ static { this.MOBILE_MONEY_MAX_ATTEMPTS = 60; }
2772
+ constructor(httpClient) {
2773
+ this.httpClient = httpClient;
2774
+ this.stateSubject = new BehaviorSubject(initialState);
2775
+ this.state$ = this.stateSubject.asObservable();
2776
+ this.mobileMoneyPollingAttempts = 0;
2715
2777
  }
2716
- get isOtpComplete() {
2717
- return this.otpDigits.every(digit => digit !== '');
2778
+ /**
2779
+ * Get current state
2780
+ */
2781
+ get currentState() {
2782
+ return this.stateSubject.value;
2718
2783
  }
2719
- get otpValue() {
2720
- return this.otpDigits.join('');
2784
+ /**
2785
+ * Update state
2786
+ */
2787
+ updateState(partialState) {
2788
+ this.stateSubject.next({
2789
+ ...this.currentState,
2790
+ ...partialState
2791
+ });
2721
2792
  }
2722
- startResendTimer() {
2723
- this.resendTimer = 60;
2724
- this.timerInterval = setInterval(() => {
2725
- this.resendTimer--;
2726
- if (this.resendTimer <= 0) {
2727
- clearInterval(this.timerInterval);
2728
- }
2729
- }, 1000);
2793
+ /**
2794
+ * Reset state to initial
2795
+ */
2796
+ resetState() {
2797
+ this.stopMobileMoneyPolling();
2798
+ this.currentPaymentBody = undefined;
2799
+ this.stateSubject.next(initialState);
2730
2800
  }
2731
- onOtpInput(event, index) {
2732
- const input = event.target;
2733
- const value = input.value.replace(/\D/g, '');
2734
- if (value) {
2735
- this.otpDigits[index] = value[0];
2736
- input.value = value[0];
2737
- // Focus next input
2738
- if (index < this.otpLength - 1) {
2739
- const inputs = document.querySelectorAll('.lokotro-otp-input');
2740
- inputs[index + 1]?.focus();
2741
- }
2742
- }
2743
- else {
2744
- this.otpDigits[index] = '';
2745
- }
2801
+ /**
2802
+ * Set app-key for authentication
2803
+ */
2804
+ setAppKey(appKey) {
2805
+ this.httpClient.setAppKey(appKey);
2746
2806
  }
2747
- onOtpKeydown(event, index) {
2748
- if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
2749
- const inputs = document.querySelectorAll('.lokotro-otp-input');
2750
- inputs[index - 1]?.focus();
2751
- }
2807
+ /**
2808
+ * Set accept language
2809
+ */
2810
+ setAcceptLanguage(language) {
2811
+ this.httpClient.setAcceptLanguage(language);
2752
2812
  }
2753
- onOtpPaste(event) {
2754
- event.preventDefault();
2755
- const pastedData = event.clipboardData?.getData('text').replace(/\D/g, '');
2756
- if (pastedData) {
2757
- for (let i = 0; i < Math.min(pastedData.length, this.otpLength); i++) {
2758
- this.otpDigits[i] = pastedData[i];
2813
+ /**
2814
+ * Step 1: Create payment transaction
2815
+ * POST /payments/collect
2816
+ */
2817
+ createPayment(paymentBody) {
2818
+ this.currentPaymentBody = paymentBody;
2819
+ this.updateState({ isLoading: true });
2820
+ const requestData = this.convertPaymentBodyToRequest(paymentBody);
2821
+ // PR-1.2 / CRIT-1: previous `console.log(requestData)` shipped PAN, CVV,
2822
+ // wallet PIN, flash PIN, and full PII to the browser console on every
2823
+ // checkout. Removed.
2824
+ return this.httpClient.post(LokotroPayEnv.endpoints.collect, requestData).pipe(switchMap(response => {
2825
+ // PR-1.2: response contains transaction IDs and (sometimes) redirect
2826
+ // URLs that are sensitive. Don't log it.
2827
+ if (!response.isSuccess || !response.data) {
2828
+ this.updateState({
2829
+ isLoading: false,
2830
+ error: {
2831
+ message: response.message || 'Failed to create payment',
2832
+ title: 'Payment Creation Failed',
2833
+ errorCode: response.apiResponseCode,
2834
+ timestamp: new Date()
2835
+ },
2836
+ currentScreen: LokotroPayScreenNavigation.ErrorScreen
2837
+ });
2838
+ return of(response);
2759
2839
  }
2760
- const inputs = document.querySelectorAll('.lokotro-otp-input');
2761
- inputs.forEach((input, i) => {
2762
- input.value = this.otpDigits[i] || '';
2840
+ return this.resolveCollectResponse(this.unwrapPayload(response.data), paymentBody).pipe(map(() => response));
2841
+ }), catchError(error => {
2842
+ this.updateState({
2843
+ isLoading: false,
2844
+ error: {
2845
+ message: error.message || 'Failed to create payment',
2846
+ title: 'Error',
2847
+ timestamp: new Date()
2848
+ },
2849
+ currentScreen: LokotroPayScreenNavigation.ErrorScreen
2763
2850
  });
2764
- // Focus last filled input or first empty
2765
- const focusIndex = Math.min(pastedData.length, this.otpLength - 1);
2766
- inputs[focusIndex]?.focus();
2767
- }
2768
- }
2769
- onVerify() {
2770
- if (this.isOtpComplete) {
2771
- this.otpVerified.emit(this.otpValue);
2772
- }
2773
- }
2774
- onResend() {
2775
- this.otpDigits = new Array(this.otpLength).fill('');
2776
- this.startResendTimer();
2777
- this.resendOtp.emit();
2851
+ return throwError(() => error);
2852
+ }));
2778
2853
  }
2779
- onCancelClick() {
2780
- this.cancel.emit();
2854
+ /**
2855
+ * Step 2: Get transaction details
2856
+ * GET /payments/transaction/{transaction_id}
2857
+ */
2858
+ getTransactionDetails(transactionId) {
2859
+ return this.httpClient.get(`${LokotroPayEnv.endpoints.transaction}/${transactionId}`);
2781
2860
  }
2782
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroOtpVerificationComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
2783
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroOtpVerificationComponent, isStandalone: true, selector: "lokotro-otp-verification", inputs: { transactionId: "transactionId", otpDestination: "otpDestination", otpLength: "otpLength" }, outputs: { otpVerified: "otpVerified", resendOtp: "resendOtp", cancel: "cancel" }, ngImport: i0, template: `
2784
- <div class="lokotro-otp-verification">
2785
- <div class="lokotro-otp-icon">
2786
- <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
2787
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
2788
- <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
2789
- </svg>
2790
- </div>
2791
-
2792
- <h3 class="lokotro-otp-title">{{ localization.translate('enterOtp') }}</h3>
2793
- @if (otpDestination) {
2794
- <p class="lokotro-otp-subtitle">
2795
- {{ localization.translate('otpSentTo') }} {{ otpDestination }}
2796
- </p>
2797
- }
2798
-
2799
- <div class="lokotro-otp-inputs">
2800
- @for (digit of otpDigits; track digit; let i = $index) {
2801
- <input
2802
- type="text"
2803
- class="lokotro-otp-input"
2804
- maxlength="1"
2805
- [value]="otpDigits[i]"
2806
- (input)="onOtpInput($event, i)"
2807
- (keydown)="onOtpKeydown($event, i)"
2808
- (paste)="onOtpPaste($event)"
2809
- #otpInput>
2810
- }
2811
- </div>
2812
-
2813
- @if (resendTimer > 0) {
2814
- <div class="lokotro-otp-timer">
2815
- Resend OTP in {{ resendTimer }}s
2816
- </div>
2817
- }
2818
-
2819
- @if (resendTimer === 0) {
2820
- <button
2821
- class="lokotro-resend-btn"
2822
- (click)="onResend()">
2823
- {{ localization.translate('resendOtp') }}
2824
- </button>
2825
- }
2826
-
2827
- <div class="lokotro-form-actions">
2828
- <button type="button" class="lokotro-btn-secondary" (click)="onCancelClick()">
2829
- {{ localization.translate('cancel') }}
2830
- </button>
2831
- <button
2832
- type="button"
2833
- class="lokotro-btn-primary"
2834
- [disabled]="!isOtpComplete"
2835
- (click)="onVerify()">
2836
- {{ localization.translate('verifyOtp') }}
2837
- </button>
2838
- </div>
2839
- </div>
2840
- `, isInline: true, styles: [".lokotro-otp-verification{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px 0}.lokotro-otp-icon{width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--lokotro-card, #3A4840);border-radius:50%;margin-bottom:24px;color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-title{font-size:24px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-otp-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-otp-inputs{display:flex;gap:12px;margin-bottom:24px}.lokotro-otp-input{width:50px;height:56px;text-align:center;font-size:24px;font-weight:600;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);transition:border-color .2s}.lokotro-otp-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-timer{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin-bottom:24px}.lokotro-resend-btn{background:none;border:none;color:var(--lokotro-accent, #3BFBDA);font-size:14px;font-weight:600;cursor:pointer;margin-bottom:24px;text-decoration:underline}.lokotro-resend-btn:hover{opacity:.8}.lokotro-form-actions{display:flex;gap:12px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }] }); }
2841
- }
2842
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroOtpVerificationComponent, decorators: [{
2843
- type: Component,
2844
- args: [{ selector: 'lokotro-otp-verification', standalone: true, imports: [FormsModule], template: `
2845
- <div class="lokotro-otp-verification">
2846
- <div class="lokotro-otp-icon">
2847
- <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
2848
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
2849
- <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
2850
- </svg>
2851
- </div>
2852
-
2853
- <h3 class="lokotro-otp-title">{{ localization.translate('enterOtp') }}</h3>
2854
- @if (otpDestination) {
2855
- <p class="lokotro-otp-subtitle">
2856
- {{ localization.translate('otpSentTo') }} {{ otpDestination }}
2857
- </p>
2858
- }
2859
-
2860
- <div class="lokotro-otp-inputs">
2861
- @for (digit of otpDigits; track digit; let i = $index) {
2862
- <input
2863
- type="text"
2864
- class="lokotro-otp-input"
2865
- maxlength="1"
2866
- [value]="otpDigits[i]"
2867
- (input)="onOtpInput($event, i)"
2868
- (keydown)="onOtpKeydown($event, i)"
2869
- (paste)="onOtpPaste($event)"
2870
- #otpInput>
2871
- }
2872
- </div>
2873
-
2874
- @if (resendTimer > 0) {
2875
- <div class="lokotro-otp-timer">
2876
- Resend OTP in {{ resendTimer }}s
2877
- </div>
2878
- }
2879
-
2880
- @if (resendTimer === 0) {
2881
- <button
2882
- class="lokotro-resend-btn"
2883
- (click)="onResend()">
2884
- {{ localization.translate('resendOtp') }}
2885
- </button>
2886
- }
2887
-
2888
- <div class="lokotro-form-actions">
2889
- <button type="button" class="lokotro-btn-secondary" (click)="onCancelClick()">
2890
- {{ localization.translate('cancel') }}
2891
- </button>
2892
- <button
2893
- type="button"
2894
- class="lokotro-btn-primary"
2895
- [disabled]="!isOtpComplete"
2896
- (click)="onVerify()">
2897
- {{ localization.translate('verifyOtp') }}
2898
- </button>
2899
- </div>
2900
- </div>
2901
- `, styles: [".lokotro-otp-verification{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px 0}.lokotro-otp-icon{width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--lokotro-card, #3A4840);border-radius:50%;margin-bottom:24px;color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-title{font-size:24px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-otp-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-otp-inputs{display:flex;gap:12px;margin-bottom:24px}.lokotro-otp-input{width:50px;height:56px;text-align:center;font-size:24px;font-weight:600;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);transition:border-color .2s}.lokotro-otp-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-timer{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin-bottom:24px}.lokotro-resend-btn{background:none;border:none;color:var(--lokotro-accent, #3BFBDA);font-size:14px;font-weight:600;cursor:pointer;margin-bottom:24px;text-decoration:underline}.lokotro-resend-btn:hover{opacity:.8}.lokotro-form-actions{display:flex;gap:12px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}\n"] }]
2902
- }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { transactionId: [{
2903
- type: Input
2904
- }], otpDestination: [{
2905
- type: Input
2906
- }], otpLength: [{
2907
- type: Input
2908
- }], otpVerified: [{
2909
- type: Output
2910
- }], resendOtp: [{
2911
- type: Output
2912
- }], cancel: [{
2913
- type: Output
2914
- }] } });
2915
-
2916
- /**
2917
- * Lokotro Pay - Processing Component
2918
- */
2919
- class LokotroProcessingComponent {
2920
- constructor(localization) {
2921
- this.localization = localization;
2922
- this.type = 'default';
2923
- this.currentStep = 1;
2924
- // Simulate step progression for mobile money
2925
- if (this.type === 'mobileMoney') {
2926
- setTimeout(() => this.currentStep = 2, 3000);
2927
- setTimeout(() => this.currentStep = 3, 6000);
2928
- }
2929
- }
2930
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroProcessingComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
2931
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroProcessingComponent, isStandalone: true, selector: "lokotro-processing", inputs: { type: "type", message: "message" }, ngImport: i0, template: `
2932
- <div class="lokotro-processing">
2933
- <div class="lokotro-processing-spinner">
2934
- <div class="lokotro-spinner-ring"></div>
2935
- <div class="lokotro-spinner-ring"></div>
2936
- <div class="lokotro-spinner-ring"></div>
2937
- </div>
2938
-
2939
- <h3 class="lokotro-processing-title">{{ message || localization.translate('processing') }}</h3>
2940
-
2941
- @if (type === 'mobileMoney') {
2942
- <p class="lokotro-processing-subtitle">
2943
- Please confirm the payment on your mobile device
2944
- </p>
2945
- }
2946
-
2947
- @if (type === 'mobileMoney') {
2948
- <div class="lokotro-processing-steps">
2949
- <div class="lokotro-step" [class.active]="currentStep >= 1">
2950
- <div class="lokotro-step-icon">📱</div>
2951
- <span>Check your phone</span>
2952
- </div>
2953
- <div class="lokotro-step" [class.active]="currentStep >= 2">
2954
- <div class="lokotro-step-icon">🔐</div>
2955
- <span>Enter your PIN</span>
2956
- </div>
2957
- <div class="lokotro-step" [class.active]="currentStep >= 3">
2958
- <div class="lokotro-step-icon">✅</div>
2959
- <span>Confirm payment</span>
2960
- </div>
2961
- </div>
2962
- }
2963
- </div>
2964
- `, isInline: true, styles: [".lokotro-processing{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 24px;min-height:300px}.lokotro-processing-spinner{position:relative;width:80px;height:80px;margin-bottom:32px}.lokotro-spinner-ring{position:absolute;width:100%;height:100%;border:3px solid transparent;border-radius:50%;animation:lokotro-spin 1.5s linear infinite}.lokotro-spinner-ring:nth-child(1){border-top-color:var(--lokotro-accent, #3BFBDA);animation-delay:0s}.lokotro-spinner-ring:nth-child(2){border-right-color:var(--lokotro-primary, #5A5E39);animation-delay:.15s;width:70%;height:70%;top:15%;left:15%}.lokotro-spinner-ring:nth-child(3){border-bottom-color:var(--lokotro-secondary, #6E7346);animation-delay:.3s;width:50%;height:50%;top:25%;left:25%}@keyframes lokotro-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.lokotro-processing-title{font-size:20px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-processing-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-processing-steps{display:flex;flex-direction:column;gap:16px;width:100%;max-width:280px}.lokotro-step{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--lokotro-card, #3A4840);border-radius:12px;opacity:.5;transition:opacity .3s}.lokotro-step.active{opacity:1}.lokotro-step-icon{font-size:20px}.lokotro-step span{font-size:14px;color:var(--lokotro-text-primary, #F2F0D5)}\n"] }); }
2965
- }
2966
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroProcessingComponent, decorators: [{
2967
- type: Component,
2968
- args: [{ selector: 'lokotro-processing', standalone: true, imports: [], template: `
2969
- <div class="lokotro-processing">
2970
- <div class="lokotro-processing-spinner">
2971
- <div class="lokotro-spinner-ring"></div>
2972
- <div class="lokotro-spinner-ring"></div>
2973
- <div class="lokotro-spinner-ring"></div>
2974
- </div>
2975
-
2976
- <h3 class="lokotro-processing-title">{{ message || localization.translate('processing') }}</h3>
2977
-
2978
- @if (type === 'mobileMoney') {
2979
- <p class="lokotro-processing-subtitle">
2980
- Please confirm the payment on your mobile device
2981
- </p>
2982
- }
2983
-
2984
- @if (type === 'mobileMoney') {
2985
- <div class="lokotro-processing-steps">
2986
- <div class="lokotro-step" [class.active]="currentStep >= 1">
2987
- <div class="lokotro-step-icon">📱</div>
2988
- <span>Check your phone</span>
2989
- </div>
2990
- <div class="lokotro-step" [class.active]="currentStep >= 2">
2991
- <div class="lokotro-step-icon">🔐</div>
2992
- <span>Enter your PIN</span>
2993
- </div>
2994
- <div class="lokotro-step" [class.active]="currentStep >= 3">
2995
- <div class="lokotro-step-icon">✅</div>
2996
- <span>Confirm payment</span>
2997
- </div>
2998
- </div>
2999
- }
3000
- </div>
3001
- `, styles: [".lokotro-processing{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 24px;min-height:300px}.lokotro-processing-spinner{position:relative;width:80px;height:80px;margin-bottom:32px}.lokotro-spinner-ring{position:absolute;width:100%;height:100%;border:3px solid transparent;border-radius:50%;animation:lokotro-spin 1.5s linear infinite}.lokotro-spinner-ring:nth-child(1){border-top-color:var(--lokotro-accent, #3BFBDA);animation-delay:0s}.lokotro-spinner-ring:nth-child(2){border-right-color:var(--lokotro-primary, #5A5E39);animation-delay:.15s;width:70%;height:70%;top:15%;left:15%}.lokotro-spinner-ring:nth-child(3){border-bottom-color:var(--lokotro-secondary, #6E7346);animation-delay:.3s;width:50%;height:50%;top:25%;left:25%}@keyframes lokotro-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.lokotro-processing-title{font-size:20px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-processing-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-processing-steps{display:flex;flex-direction:column;gap:16px;width:100%;max-width:280px}.lokotro-step{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--lokotro-card, #3A4840);border-radius:12px;opacity:.5;transition:opacity .3s}.lokotro-step.active{opacity:1}.lokotro-step-icon{font-size:20px}.lokotro-step span{font-size:14px;color:var(--lokotro-text-primary, #F2F0D5)}\n"] }]
3002
- }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { type: [{
3003
- type: Input
3004
- }], message: [{
3005
- type: Input
3006
- }] } });
3007
-
3008
- /**
3009
- * Lokotro Pay - Result Component
3010
- * Success, Error, Warning, Info screens
3011
- *
3012
- * Designed for financial UX: clean white surface, prominent amount focal
3013
- * on success, filled status badge, restrained transaction-detail panel.
3014
- */
3015
- class LokotroResultComponent {
3016
- constructor(localization) {
3017
- this.localization = localization;
3018
- this.type = 'success';
3019
- this.title = '';
3020
- /** Auto-redirect duration in seconds. If > 0, will auto-call onAutoRedirect after countdown. */
3021
- this.autoRedirectSeconds = 0;
3022
- this.primaryAction = new EventEmitter();
3023
- this.secondaryAction = new EventEmitter();
3024
- /** Emitted when auto-redirect triggers after countdown completes. */
3025
- this.autoRedirect = new EventEmitter();
3026
- // Countdown state
3027
- this.countdownSeconds = 0;
3028
- this.isCountdownCancelled = false;
3029
- }
3030
- ngOnInit() {
3031
- this.startAutoRedirectCountdown();
3032
- }
3033
- ngOnDestroy() {
3034
- this.clearCountdown();
3035
- }
3036
- startAutoRedirectCountdown() {
3037
- if (this.autoRedirectSeconds <= 0)
3038
- return;
3039
- this.countdownSeconds = this.autoRedirectSeconds;
3040
- this.countdownInterval = setInterval(() => {
3041
- if (this.isCountdownCancelled) {
3042
- this.clearCountdown();
3043
- return;
3044
- }
3045
- this.countdownSeconds--;
3046
- if (this.countdownSeconds <= 0) {
3047
- this.clearCountdown();
3048
- this.triggerAutoRedirect();
2861
+ /**
2862
+ * Step 3: Submit payment details
2863
+ * POST /payments/submit
2864
+ */
2865
+ submitPaymentDetails(request) {
2866
+ this.updateState({
2867
+ isLoading: true,
2868
+ currentScreen: LokotroPayScreenNavigation.ProcessingScreen
2869
+ });
2870
+ const requestData = this.convertSubmitRequestToData(request);
2871
+ return this.httpClient.post(LokotroPayEnv.endpoints.submit, requestData).pipe(tap(response => {
2872
+ if (response.isSuccess && response.data) {
2873
+ const responseData = this.unwrapPayload(response.data);
2874
+ const submitResponse = this.parseSubmitResponse(responseData);
2875
+ if (submitResponse.requiresOtp) {
2876
+ this.updateState({
2877
+ isLoading: false,
2878
+ transactionId: submitResponse.transactionId || this.resolvePaymentId(request),
2879
+ currentScreen: LokotroPayScreenNavigation.EWalletOtpScreen
2880
+ });
2881
+ return;
2882
+ }
2883
+ if (submitResponse.redirectUrl) {
2884
+ this.updateState({
2885
+ isLoading: false,
2886
+ transactionId: submitResponse.transactionId || this.resolvePaymentId(request)
2887
+ });
2888
+ // PR-1.2 / Angular HIGH-1: validate redirect URL against allow-list
2889
+ // before handing the tab over. Server-side allow-list (PR-2.0) is
2890
+ // the real defense; this is defense-in-depth on the client.
2891
+ this.redirectIfAllowed(submitResponse.redirectUrl, 'submit-response');
2892
+ return;
2893
+ }
2894
+ if (submitResponse.status === LokotroPaymentStatus.PendingBankProofUpload) {
2895
+ this.handlePaymentSuccess(responseData, LokotroPaymentStatus.PendingBankProofUpload, 'Upload Proof Required');
2896
+ return;
2897
+ }
2898
+ if (LokotroPaymentStatusInfo.isSuccess(submitResponse.status)) {
2899
+ this.handlePaymentSuccess(responseData);
2900
+ return;
2901
+ }
2902
+ if (LokotroPaymentStatusInfo.isPending(submitResponse.status)) {
2903
+ const txId = submitResponse.transactionId || this.resolvePaymentId(request);
2904
+ this.updateState({
2905
+ isLoading: false,
2906
+ transactionId: txId,
2907
+ currentScreen: LokotroPayScreenNavigation.MobileMoneyProcessingScreen
2908
+ });
2909
+ this.startMobileMoneyPolling(txId);
2910
+ return;
2911
+ }
2912
+ this.handlePaymentFailure(this.getString(responseData['message']) || response.message, response.apiResponseCode);
3049
2913
  }
3050
- }, 1000);
3051
- }
3052
- cancelCountdown() {
3053
- if (this.countdownInterval) {
3054
- this.isCountdownCancelled = true;
3055
- this.clearCountdown();
3056
- }
3057
- }
3058
- clearCountdown() {
3059
- if (this.countdownInterval) {
3060
- clearInterval(this.countdownInterval);
3061
- this.countdownInterval = undefined;
3062
- }
3063
- }
3064
- triggerAutoRedirect() {
3065
- if (this.isCountdownCancelled)
3066
- return;
3067
- this.autoRedirect.emit();
3068
- }
3069
- getCountdownProgress() {
3070
- if (this.autoRedirectSeconds <= 0)
3071
- return 0;
3072
- return (this.countdownSeconds / this.autoRedirectSeconds) * 100;
2914
+ else {
2915
+ this.handlePaymentFailure(response.message, response.apiResponseCode);
2916
+ }
2917
+ }), catchError(error => {
2918
+ this.handlePaymentFailure(error.message);
2919
+ return throwError(() => error);
2920
+ }));
3073
2921
  }
3074
- formatNumber(amount) {
3075
- return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount);
2922
+ /**
2923
+ * Step 4: Verify OTP (for e-wallet and flash payments)
2924
+ * POST /payments/verify-otp
2925
+ */
2926
+ verifyOtp(request) {
2927
+ this.updateState({ isLoading: true });
2928
+ return this.httpClient.post(LokotroPayEnv.endpoints.verifyOtp, {
2929
+ payment_id: request.paymentId || request.transactionId || this.currentState.transactionId,
2930
+ otp_code: request.otpCode || request.otp
2931
+ }).pipe(tap(response => {
2932
+ if (response.isSuccess && response.data) {
2933
+ const responseData = this.unwrapPayload(response.data);
2934
+ const status = LokotroPaymentStatusInfo.fromString(this.getString(responseData['status']));
2935
+ if (LokotroPaymentStatusInfo.isSuccess(status)) {
2936
+ this.handlePaymentSuccess(responseData);
2937
+ }
2938
+ else {
2939
+ this.handlePaymentFailure(this.getString(responseData['message']) || response.message, response.apiResponseCode);
2940
+ }
2941
+ }
2942
+ else {
2943
+ this.updateState({
2944
+ isLoading: false,
2945
+ error: {
2946
+ message: response.message || 'OTP verification failed',
2947
+ title: 'Verification Failed',
2948
+ errorCode: response.apiResponseCode,
2949
+ timestamp: new Date()
2950
+ },
2951
+ currentScreen: LokotroPayScreenNavigation.ErrorScreen
2952
+ });
2953
+ }
2954
+ }), catchError(error => {
2955
+ this.updateState({
2956
+ isLoading: false,
2957
+ error: {
2958
+ message: error.message || 'OTP verification failed',
2959
+ title: 'Error',
2960
+ timestamp: new Date()
2961
+ },
2962
+ currentScreen: LokotroPayScreenNavigation.ErrorScreen
2963
+ });
2964
+ return throwError(() => error);
2965
+ }));
3076
2966
  }
3077
- formatAmount(amount, currency) {
3078
- return new Intl.NumberFormat('en-US', {
3079
- style: 'currency',
3080
- currency: currency || 'USD',
3081
- minimumFractionDigits: 2
3082
- }).format(amount);
2967
+ /**
2968
+ * Step 5: Resend OTP (if needed)
2969
+ * POST /payments/resend-otp
2970
+ */
2971
+ resendOtp(request) {
2972
+ return this.httpClient.post(LokotroPayEnv.endpoints.resendOtp, {
2973
+ payment_id: request.paymentId || request.transactionId || this.currentState.transactionId
2974
+ });
3083
2975
  }
3084
- onPrimaryClick() {
3085
- this.cancelCountdown();
3086
- this.primaryAction.emit();
2976
+ /**
2977
+ * Select payment method
2978
+ */
2979
+ selectPaymentMethod(method) {
2980
+ this.updateState({
2981
+ selectedPaymentMethod: method,
2982
+ currentScreen: this.getFormScreenForChannel(method.channel)
2983
+ });
3087
2984
  }
3088
- onSecondaryClick() {
3089
- this.cancelCountdown();
3090
- this.secondaryAction.emit();
2985
+ /**
2986
+ * Navigate to screen
2987
+ */
2988
+ navigateToScreen(screen) {
2989
+ this.updateState({ currentScreen: screen });
3091
2990
  }
3092
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroResultComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
3093
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroResultComponent, isStandalone: true, selector: "lokotro-result", inputs: { type: "type", title: "title", message: "message", amount: "amount", currency: "currency", transactionId: "transactionId", primaryActionLabel: "primaryActionLabel", secondaryActionLabel: "secondaryActionLabel", autoRedirectSeconds: "autoRedirectSeconds" }, outputs: { primaryAction: "primaryAction", secondaryAction: "secondaryAction", autoRedirect: "autoRedirect" }, ngImport: i0, template: `
3094
- <div class="lokotro-result" [class]="'lokotro-result-' + type">
3095
- <div class="lokotro-result-card">
3096
- <!-- Status Badge (filled circle with icon) -->
3097
- <div class="lokotro-status-badge" [class]="'lokotro-status-' + type">
3098
- @if (type === 'success') {
3099
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3100
- <polyline points="5 12 10 17 19 8"/>
3101
- </svg>
3102
- }
3103
- @if (type === 'error') {
3104
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3105
- <line x1="18" y1="6" x2="6" y2="18"/>
3106
- <line x1="6" y1="6" x2="18" y2="18"/>
3107
- </svg>
3108
- }
3109
- @if (type === 'warning') {
3110
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3111
- <line x1="12" y1="8" x2="12" y2="13"/>
3112
- <line x1="12" y1="17" x2="12.01" y2="17"/>
3113
- </svg>
3114
- }
3115
- @if (type === 'info') {
3116
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3117
- <line x1="12" y1="11" x2="12" y2="16"/>
3118
- <line x1="12" y1="7" x2="12.01" y2="7"/>
3119
- </svg>
3120
- }
3121
- </div>
3122
-
3123
- <!-- Amount focal (success only, when amount provided) -->
3124
- @if (type === 'success' && amount !== undefined && amount !== null) {
3125
- <div class="lokotro-amount-focal">
3126
- <span class="lokotro-amount-currency">{{ (currency || '').toUpperCase() }}</span>
3127
- <span class="lokotro-amount-value">{{ formatNumber(amount) }}</span>
3128
- </div>
3129
- }
3130
-
3131
- <!-- Title -->
3132
- <h2 class="lokotro-result-title">{{ title }}</h2>
3133
-
3134
- <!-- Message -->
3135
- @if (message) {
3136
- <p class="lokotro-result-message">{{ message }}</p>
3137
- }
3138
-
3139
- <!-- Countdown Indicator -->
3140
- @if (autoRedirectSeconds > 0 && countdownSeconds > 0 && !isCountdownCancelled) {
3141
- <button type="button" class="lokotro-countdown-indicator" (click)="cancelCountdown()">
3142
- <svg viewBox="0 0 36 36" class="lokotro-countdown-circle">
3143
- <path class="lokotro-countdown-bg"
3144
- d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
3145
- <path class="lokotro-countdown-fill" [class]="'lokotro-countdown-' + type"
3146
- [attr.stroke-dasharray]="getCountdownProgress() + ', 100'"
3147
- d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
3148
- </svg>
3149
- <span class="lokotro-countdown-text">
3150
- {{ localization.translate('redirectingIn', { seconds: countdownSeconds }) }}
3151
- </span>
3152
- <svg class="lokotro-countdown-cancel" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
3153
- <path d="M18 6L6 18M6 6l12 12"/>
3154
- </svg>
3155
- </button>
3156
- }
3157
-
3158
- <!-- Transaction Details -->
3159
- @if (type === 'success' && transactionId) {
3160
- <div class="lokotro-result-details">
3161
- <div class="lokotro-detail-row">
3162
- <span class="lokotro-detail-label">Transaction ID</span>
3163
- <span class="lokotro-detail-value lokotro-mono">{{ transactionId }}</span>
3164
- </div>
3165
- </div>
3166
- }
3167
- </div>
3168
-
3169
- <!-- Actions -->
3170
- @if (primaryActionLabel || secondaryActionLabel) {
3171
- <div class="lokotro-result-actions">
3172
- @if (primaryActionLabel) {
3173
- <button type="button" class="lokotro-btn-primary" (click)="onPrimaryClick()">
3174
- {{ primaryActionLabel }}
3175
- </button>
3176
- }
3177
- @if (secondaryActionLabel) {
3178
- <button type="button" class="lokotro-btn-secondary" (click)="onSecondaryClick()">
3179
- {{ secondaryActionLabel }}
3180
- </button>
3181
- }
3182
- </div>
3183
- }
3184
- </div>
3185
- `, isInline: true, styles: [".lokotro-result{display:flex;flex-direction:column;align-items:stretch;max-width:420px;margin:0 auto;padding:24px 16px;gap:20px}.lokotro-result-card{display:flex;flex-direction:column;align-items:center;text-align:center;padding:32px 24px;background:var(--lokotro-card, #FFFFFF);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:20px;box-shadow:0 1px 3px var(--lokotro-shadow-light, rgba(15, 23, 42, .04)),0 8px 24px var(--lokotro-shadow-light, rgba(15, 23, 42, .04))}.lokotro-status-badge{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:24px;animation:lokotro-pop .5s cubic-bezier(.34,1.56,.64,1)}.lokotro-status-badge svg{width:36px;height:36px;color:#fff}.lokotro-status-success{background:var(--lokotro-success, #16A34A);box-shadow:0 0 0 8px #16a34a1f,0 8px 20px #16a34a40}.lokotro-status-error{background:var(--lokotro-error, #DC2626);box-shadow:0 0 0 8px #dc26261f,0 8px 20px #dc262640}.lokotro-status-warning{background:var(--lokotro-warning, #D97706);box-shadow:0 0 0 8px #d977061f,0 8px 20px #d9770640}.lokotro-status-info{background:var(--lokotro-info, #2563EB);box-shadow:0 0 0 8px #2563eb1f,0 8px 20px #2563eb40}@keyframes lokotro-pop{0%{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.lokotro-amount-focal{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:16px}.lokotro-amount-currency{font-size:12px;font-weight:600;letter-spacing:.08em;color:var(--lokotro-text-secondary, #64748B)}.lokotro-amount-value{font-size:40px;font-weight:700;line-height:1;color:var(--lokotro-text-primary, #0F172A);font-variant-numeric:tabular-nums}.lokotro-result-title{font-size:20px;font-weight:600;margin:0 0 6px;color:var(--lokotro-text-primary, #0F172A)}.lokotro-result-message{font-size:14px;color:var(--lokotro-text-secondary, #64748B);margin:0;line-height:1.5;max-width:320px}.lokotro-result-details{width:100%;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:12px;padding:12px 16px;margin-top:24px}.lokotro-detail-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0}.lokotro-detail-row:not(:last-child){border-bottom:1px solid var(--lokotro-divider, #F1F5F9)}.lokotro-detail-label{font-size:13px;color:var(--lokotro-text-secondary, #64748B)}.lokotro-detail-value{font-size:13px;font-weight:600;color:var(--lokotro-text-primary, #0F172A);text-align:right;overflow:hidden;text-overflow:ellipsis;max-width:200px;white-space:nowrap}.lokotro-mono{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Monaco,monospace;font-size:12px}.lokotro-result-actions{display:flex;flex-direction:column;gap:10px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{width:100%;padding:14px 24px;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}.lokotro-btn-primary{background:var(--lokotro-primary, #0F172A);color:#fff;border:none}.lokotro-btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 16px var(--lokotro-shadow-medium, rgba(15, 23, 42, .12))}.lokotro-btn-primary:active{transform:translateY(0)}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #0F172A);border:1.5px solid var(--lokotro-border, #E2E8F0)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #64748B);background:var(--lokotro-surface, #F7F8FA)}.lokotro-countdown-indicator{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:999px;cursor:pointer;margin-top:20px;transition:all .18s ease;color:var(--lokotro-text-secondary, #64748B);font-family:inherit}.lokotro-countdown-indicator:hover{background:var(--lokotro-card, #FFFFFF);border-color:var(--lokotro-text-tertiary, #64748B)}.lokotro-countdown-circle{width:14px;height:14px;transform:rotate(-90deg)}.lokotro-countdown-bg{fill:none;stroke:var(--lokotro-border, #E2E8F0);stroke-width:4}.lokotro-countdown-fill{fill:none;stroke-width:4;stroke-linecap:round;transition:stroke-dasharray .3s ease}.lokotro-countdown-success{stroke:var(--lokotro-success, #16A34A)}.lokotro-countdown-error{stroke:var(--lokotro-error, #DC2626)}.lokotro-countdown-warning{stroke:var(--lokotro-warning, #D97706)}.lokotro-countdown-info{stroke:var(--lokotro-info, #2563EB)}.lokotro-countdown-text{font-size:12px;font-weight:500}.lokotro-countdown-cancel{color:inherit;opacity:.7}\n"] }); }
3186
- }
3187
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroResultComponent, decorators: [{
3188
- type: Component,
3189
- args: [{ selector: 'lokotro-result', standalone: true, imports: [], template: `
3190
- <div class="lokotro-result" [class]="'lokotro-result-' + type">
3191
- <div class="lokotro-result-card">
3192
- <!-- Status Badge (filled circle with icon) -->
3193
- <div class="lokotro-status-badge" [class]="'lokotro-status-' + type">
3194
- @if (type === 'success') {
3195
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3196
- <polyline points="5 12 10 17 19 8"/>
3197
- </svg>
3198
- }
3199
- @if (type === 'error') {
3200
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3201
- <line x1="18" y1="6" x2="6" y2="18"/>
3202
- <line x1="6" y1="6" x2="18" y2="18"/>
3203
- </svg>
3204
- }
3205
- @if (type === 'warning') {
3206
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3207
- <line x1="12" y1="8" x2="12" y2="13"/>
3208
- <line x1="12" y1="17" x2="12.01" y2="17"/>
3209
- </svg>
3210
- }
3211
- @if (type === 'info') {
3212
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
3213
- <line x1="12" y1="11" x2="12" y2="16"/>
3214
- <line x1="12" y1="7" x2="12.01" y2="7"/>
3215
- </svg>
3216
- }
3217
- </div>
3218
-
3219
- <!-- Amount focal (success only, when amount provided) -->
3220
- @if (type === 'success' && amount !== undefined && amount !== null) {
3221
- <div class="lokotro-amount-focal">
3222
- <span class="lokotro-amount-currency">{{ (currency || '').toUpperCase() }}</span>
3223
- <span class="lokotro-amount-value">{{ formatNumber(amount) }}</span>
3224
- </div>
2991
+ /**
2992
+ * Start polling for mobile money payment status
2993
+ */
2994
+ startMobileMoneyPolling(transactionId) {
2995
+ this.stopMobileMoneyPolling();
2996
+ this.mobileMoneyPollingAttempts = 0;
2997
+ // PR-1.2: removed console.log of transactionId — operational logging
2998
+ // should be opt-in via merchant config, not on by default.
2999
+ this.mobileMoneyPollingTimer = setInterval(() => {
3000
+ this.mobileMoneyPollingAttempts++;
3001
+ if (this.mobileMoneyPollingAttempts > LokotroPaymentService.MOBILE_MONEY_MAX_ATTEMPTS) {
3002
+ this.stopMobileMoneyPolling();
3003
+ this.handlePaymentFailure('Payment took too long. Please try again or contact support.');
3004
+ return;
3005
+ }
3006
+ const endpoint = `${LokotroPayEnv.endpoints.mobileMoneyStatus}/${transactionId}`;
3007
+ this.httpClient.get(endpoint).subscribe({
3008
+ next: (response) => {
3009
+ if (!response.isSuccess || !response.data)
3010
+ return;
3011
+ const data = this.unwrapPayload(response.data);
3012
+ const status = this.getString(data['status']).toLowerCase();
3013
+ if (status === 'completed' || status === 'success' || status === 'approved') {
3014
+ this.stopMobileMoneyPolling();
3015
+ this.handlePaymentSuccess(data);
3016
+ }
3017
+ else if (status === 'failed' || status === 'error' || status === 'cancelled' || status === 'declined') {
3018
+ this.stopMobileMoneyPolling();
3019
+ this.handlePaymentFailure(this.getString(data['message']) || 'Payment failed or was declined');
3020
+ }
3021
+ },
3022
+ error: (_err) => {
3023
+ // PR-1.2: don't log full error object may contain backend
3024
+ // response details that leak server internals to console.
3025
+ }
3026
+ });
3027
+ }, LokotroPaymentService.MOBILE_MONEY_POLL_INTERVAL);
3028
+ }
3029
+ /**
3030
+ * Stop mobile money status polling
3031
+ */
3032
+ stopMobileMoneyPolling() {
3033
+ if (this.mobileMoneyPollingTimer) {
3034
+ clearInterval(this.mobileMoneyPollingTimer);
3035
+ this.mobileMoneyPollingTimer = undefined;
3036
+ this.mobileMoneyPollingAttempts = 0;
3225
3037
  }
3226
-
3227
- <!-- Title -->
3228
- <h2 class="lokotro-result-title">{{ title }}</h2>
3229
-
3230
- <!-- Message -->
3231
- @if (message) {
3232
- <p class="lokotro-result-message">{{ message }}</p>
3038
+ }
3039
+ /**
3040
+ * Validate a server-supplied redirect URL before handing the browser tab to
3041
+ * it. Defense-in-depth for Angular HIGH-1 — server-side per-merchant
3042
+ * allow-list (PR-2.0) is the real fix.
3043
+ *
3044
+ * Policy:
3045
+ * - Reject non-http(s) schemes (blocks `javascript:`, `data:`, `file:`,
3046
+ * `intent:`, custom schemes).
3047
+ * - Reject http:// in production.
3048
+ * - Reject malformed URLs.
3049
+ * - SSR safety: skip the redirect when window is unavailable.
3050
+ */
3051
+ redirectIfAllowed(url, _origin) {
3052
+ if (typeof window === 'undefined') {
3053
+ return;
3233
3054
  }
3234
-
3235
- <!-- Countdown Indicator -->
3236
- @if (autoRedirectSeconds > 0 && countdownSeconds > 0 && !isCountdownCancelled) {
3237
- <button type="button" class="lokotro-countdown-indicator" (click)="cancelCountdown()">
3238
- <svg viewBox="0 0 36 36" class="lokotro-countdown-circle">
3239
- <path class="lokotro-countdown-bg"
3240
- d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
3241
- <path class="lokotro-countdown-fill" [class]="'lokotro-countdown-' + type"
3242
- [attr.stroke-dasharray]="getCountdownProgress() + ', 100'"
3243
- d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
3244
- </svg>
3245
- <span class="lokotro-countdown-text">
3246
- {{ localization.translate('redirectingIn', { seconds: countdownSeconds }) }}
3247
- </span>
3248
- <svg class="lokotro-countdown-cancel" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
3249
- <path d="M18 6L6 18M6 6l12 12"/>
3250
- </svg>
3251
- </button>
3055
+ if (!url || typeof url !== 'string') {
3056
+ this.handlePaymentFailure('Invalid redirect URL');
3057
+ return;
3252
3058
  }
3253
-
3254
- <!-- Transaction Details -->
3255
- @if (type === 'success' && transactionId) {
3256
- <div class="lokotro-result-details">
3257
- <div class="lokotro-detail-row">
3258
- <span class="lokotro-detail-label">Transaction ID</span>
3259
- <span class="lokotro-detail-value lokotro-mono">{{ transactionId }}</span>
3260
- </div>
3261
- </div>
3059
+ let parsed;
3060
+ try {
3061
+ parsed = new URL(url, window.location.href);
3262
3062
  }
3263
- </div>
3264
-
3265
- <!-- Actions -->
3266
- @if (primaryActionLabel || secondaryActionLabel) {
3267
- <div class="lokotro-result-actions">
3268
- @if (primaryActionLabel) {
3269
- <button type="button" class="lokotro-btn-primary" (click)="onPrimaryClick()">
3270
- {{ primaryActionLabel }}
3271
- </button>
3272
- }
3273
- @if (secondaryActionLabel) {
3274
- <button type="button" class="lokotro-btn-secondary" (click)="onSecondaryClick()">
3275
- {{ secondaryActionLabel }}
3276
- </button>
3277
- }
3278
- </div>
3279
- }
3280
- </div>
3281
- `, styles: [".lokotro-result{display:flex;flex-direction:column;align-items:stretch;max-width:420px;margin:0 auto;padding:24px 16px;gap:20px}.lokotro-result-card{display:flex;flex-direction:column;align-items:center;text-align:center;padding:32px 24px;background:var(--lokotro-card, #FFFFFF);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:20px;box-shadow:0 1px 3px var(--lokotro-shadow-light, rgba(15, 23, 42, .04)),0 8px 24px var(--lokotro-shadow-light, rgba(15, 23, 42, .04))}.lokotro-status-badge{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:24px;animation:lokotro-pop .5s cubic-bezier(.34,1.56,.64,1)}.lokotro-status-badge svg{width:36px;height:36px;color:#fff}.lokotro-status-success{background:var(--lokotro-success, #16A34A);box-shadow:0 0 0 8px #16a34a1f,0 8px 20px #16a34a40}.lokotro-status-error{background:var(--lokotro-error, #DC2626);box-shadow:0 0 0 8px #dc26261f,0 8px 20px #dc262640}.lokotro-status-warning{background:var(--lokotro-warning, #D97706);box-shadow:0 0 0 8px #d977061f,0 8px 20px #d9770640}.lokotro-status-info{background:var(--lokotro-info, #2563EB);box-shadow:0 0 0 8px #2563eb1f,0 8px 20px #2563eb40}@keyframes lokotro-pop{0%{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.lokotro-amount-focal{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:16px}.lokotro-amount-currency{font-size:12px;font-weight:600;letter-spacing:.08em;color:var(--lokotro-text-secondary, #64748B)}.lokotro-amount-value{font-size:40px;font-weight:700;line-height:1;color:var(--lokotro-text-primary, #0F172A);font-variant-numeric:tabular-nums}.lokotro-result-title{font-size:20px;font-weight:600;margin:0 0 6px;color:var(--lokotro-text-primary, #0F172A)}.lokotro-result-message{font-size:14px;color:var(--lokotro-text-secondary, #64748B);margin:0;line-height:1.5;max-width:320px}.lokotro-result-details{width:100%;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:12px;padding:12px 16px;margin-top:24px}.lokotro-detail-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0}.lokotro-detail-row:not(:last-child){border-bottom:1px solid var(--lokotro-divider, #F1F5F9)}.lokotro-detail-label{font-size:13px;color:var(--lokotro-text-secondary, #64748B)}.lokotro-detail-value{font-size:13px;font-weight:600;color:var(--lokotro-text-primary, #0F172A);text-align:right;overflow:hidden;text-overflow:ellipsis;max-width:200px;white-space:nowrap}.lokotro-mono{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Monaco,monospace;font-size:12px}.lokotro-result-actions{display:flex;flex-direction:column;gap:10px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{width:100%;padding:14px 24px;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}.lokotro-btn-primary{background:var(--lokotro-primary, #0F172A);color:#fff;border:none}.lokotro-btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 16px var(--lokotro-shadow-medium, rgba(15, 23, 42, .12))}.lokotro-btn-primary:active{transform:translateY(0)}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #0F172A);border:1.5px solid var(--lokotro-border, #E2E8F0)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #64748B);background:var(--lokotro-surface, #F7F8FA)}.lokotro-countdown-indicator{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:999px;cursor:pointer;margin-top:20px;transition:all .18s ease;color:var(--lokotro-text-secondary, #64748B);font-family:inherit}.lokotro-countdown-indicator:hover{background:var(--lokotro-card, #FFFFFF);border-color:var(--lokotro-text-tertiary, #64748B)}.lokotro-countdown-circle{width:14px;height:14px;transform:rotate(-90deg)}.lokotro-countdown-bg{fill:none;stroke:var(--lokotro-border, #E2E8F0);stroke-width:4}.lokotro-countdown-fill{fill:none;stroke-width:4;stroke-linecap:round;transition:stroke-dasharray .3s ease}.lokotro-countdown-success{stroke:var(--lokotro-success, #16A34A)}.lokotro-countdown-error{stroke:var(--lokotro-error, #DC2626)}.lokotro-countdown-warning{stroke:var(--lokotro-warning, #D97706)}.lokotro-countdown-info{stroke:var(--lokotro-info, #2563EB)}.lokotro-countdown-text{font-size:12px;font-weight:500}.lokotro-countdown-cancel{color:inherit;opacity:.7}\n"] }]
3282
- }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { type: [{
3283
- type: Input
3284
- }], title: [{
3285
- type: Input
3286
- }], message: [{
3287
- type: Input
3288
- }], amount: [{
3289
- type: Input
3290
- }], currency: [{
3291
- type: Input
3292
- }], transactionId: [{
3293
- type: Input
3294
- }], primaryActionLabel: [{
3295
- type: Input
3296
- }], secondaryActionLabel: [{
3297
- type: Input
3298
- }], autoRedirectSeconds: [{
3299
- type: Input
3300
- }], primaryAction: [{
3301
- type: Output
3302
- }], secondaryAction: [{
3303
- type: Output
3304
- }], autoRedirect: [{
3305
- type: Output
3306
- }] } });
3307
-
3308
- /**
3309
- * Lokotro Pay - Loading Component
3310
- */
3311
- class LokotroLoadingComponent {
3312
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroLoadingComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3313
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroLoadingComponent, isStandalone: true, selector: "lokotro-loading", inputs: { message: "message" }, ngImport: i0, template: `
3314
- <div class="lokotro-loading">
3315
- <div class="lokotro-loading-spinner">
3316
- <div class="lokotro-pulse-ring"></div>
3317
- <div class="lokotro-pulse-ring"></div>
3318
- <div class="lokotro-pulse-dot"></div>
3319
- </div>
3320
- @if (message) {
3321
- <p class="lokotro-loading-message">{{ message }}</p>
3322
- }
3323
- </div>
3324
- `, isInline: true, styles: [".lokotro-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;padding:24px}.lokotro-loading-spinner{position:relative;width:60px;height:60px;margin-bottom:24px}.lokotro-pulse-ring{position:absolute;width:100%;height:100%;border:3px solid var(--lokotro-accent, #3BFBDA);border-radius:50%;opacity:0;animation:lokotro-pulse 2s cubic-bezier(.215,.61,.355,1) infinite}.lokotro-pulse-ring:nth-child(2){animation-delay:.5s}.lokotro-pulse-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:16px;height:16px;background:var(--lokotro-accent, #3BFBDA);border-radius:50%;animation:lokotro-dot-pulse 2s ease-in-out infinite}@keyframes lokotro-pulse{0%{transform:scale(.5);opacity:.8}to{transform:scale(1.5);opacity:0}}@keyframes lokotro-dot-pulse{0%,to{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.2)}}.lokotro-loading-message{font-size:16px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0}\n"] }); }
3325
- }
3326
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroLoadingComponent, decorators: [{
3327
- type: Component,
3328
- args: [{ selector: 'lokotro-loading', standalone: true, imports: [], template: `
3329
- <div class="lokotro-loading">
3330
- <div class="lokotro-loading-spinner">
3331
- <div class="lokotro-pulse-ring"></div>
3332
- <div class="lokotro-pulse-ring"></div>
3333
- <div class="lokotro-pulse-dot"></div>
3334
- </div>
3335
- @if (message) {
3336
- <p class="lokotro-loading-message">{{ message }}</p>
3337
- }
3338
- </div>
3339
- `, styles: [".lokotro-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;padding:24px}.lokotro-loading-spinner{position:relative;width:60px;height:60px;margin-bottom:24px}.lokotro-pulse-ring{position:absolute;width:100%;height:100%;border:3px solid var(--lokotro-accent, #3BFBDA);border-radius:50%;opacity:0;animation:lokotro-pulse 2s cubic-bezier(.215,.61,.355,1) infinite}.lokotro-pulse-ring:nth-child(2){animation-delay:.5s}.lokotro-pulse-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:16px;height:16px;background:var(--lokotro-accent, #3BFBDA);border-radius:50%;animation:lokotro-dot-pulse 2s ease-in-out infinite}@keyframes lokotro-pulse{0%{transform:scale(.5);opacity:.8}to{transform:scale(1.5);opacity:0}}@keyframes lokotro-dot-pulse{0%,to{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.2)}}.lokotro-loading-message{font-size:16px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0}\n"] }]
3340
- }], propDecorators: { message: [{
3341
- type: Input
3342
- }] } });
3343
-
3344
- /**
3345
- * Lokotro Pay - Feature Flags
3346
- *
3347
- * Compile-time switches for capabilities that are partially wired into the SDK
3348
- * but not yet ready to expose to end users. Flip a flag to `true` once the
3349
- * feature is implemented end-to-end.
3350
- */
3351
- const LOKOTRO_PAY_FEATURE_FLAGS = {
3063
+ catch {
3064
+ this.handlePaymentFailure('Untrusted redirect URL');
3065
+ return;
3066
+ }
3067
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
3068
+ this.handlePaymentFailure('Untrusted redirect scheme');
3069
+ return;
3070
+ }
3071
+ const isProduction = LokotroPayEnv.environment === 'production';
3072
+ if (isProduction && parsed.protocol !== 'https:') {
3073
+ this.handlePaymentFailure('Insecure redirect rejected');
3074
+ return;
3075
+ }
3076
+ window.location.href = parsed.toString();
3077
+ }
3352
3078
  /**
3353
- * Native wallet payments — Google Pay and Apple Pay.
3354
- *
3355
- * The models and method-flag plumbing already understand these methods, but
3356
- * the native tokenisation flows are not implemented end-to-end yet. While
3357
- * this is `false` the SDK hides Google Pay / Apple Pay from the
3358
- * payment-method selection UI so they can never be shown or selected. Set to
3359
- * `true` once the native integrations are complete.
3079
+ * Handle payment success
3360
3080
  */
3361
- nativeWalletsEnabled: false,
3362
- };
3363
- /**
3364
- * Whether a payment method is a Google Pay / Apple Pay native wallet method.
3365
- *
3366
- * Matches the provider flag as well as the raw name / id / channel strings, so
3367
- * detection holds even when the backend channel does not map onto the enum.
3368
- */
3369
- function isNativeWalletMethod(method) {
3370
- if (method.flag === 'google_pay' || method.flag === 'apple_pay') {
3371
- return true;
3372
- }
3373
- const looksNative = (value) => {
3374
- const key = (value ?? '').toLowerCase().replace(/[^a-z]/g, '');
3375
- return key.includes('googlepay') || key.includes('applepay');
3376
- };
3377
- return (looksNative(method.name) ||
3378
- looksNative(method.displayName) ||
3379
- looksNative(method.id) ||
3380
- looksNative(method.channel));
3381
- }
3382
- /**
3383
- * Returns the payment methods that should be visible to the user, dropping any
3384
- * hidden behind a disabled feature flag (currently Google / Apple Pay). The
3385
- * list is returned unchanged once the relevant flag is enabled.
3386
- */
3387
- function visiblePaymentMethods(methods) {
3388
- if (LOKOTRO_PAY_FEATURE_FLAGS.nativeWalletsEnabled) {
3389
- return methods;
3390
- }
3391
- return methods.filter((method) => !isNativeWalletMethod(method));
3392
- }
3393
-
3394
- /**
3395
- * Lokotro Pay - Payment Service
3396
- * Main payment service that follows the Lokotro Gateway API flow
3397
- */
3398
- /**
3399
- * Initial payment state
3400
- */
3401
- const initialState = {
3402
- isLoading: false,
3403
- currentScreen: LokotroPayScreenNavigation.LoadingScreen
3404
- };
3405
- class LokotroPaymentService {
3406
- static { this.MOBILE_MONEY_POLL_INTERVAL = 5000; }
3407
- static { this.MOBILE_MONEY_MAX_ATTEMPTS = 60; }
3408
- constructor(httpClient) {
3409
- this.httpClient = httpClient;
3410
- this.stateSubject = new BehaviorSubject(initialState);
3411
- this.state$ = this.stateSubject.asObservable();
3412
- this.mobileMoneyPollingAttempts = 0;
3081
+ handlePaymentSuccess(data, paymentStatus = LokotroPaymentStatus.Approved, defaultTitle = 'Success') {
3082
+ this.stopMobileMoneyPolling();
3083
+ const transactionId = this.getString(data['transaction_id']) || this.getString(data['payment_id']);
3084
+ const response = {
3085
+ message: this.getString(data['message']) || 'Payment successful',
3086
+ title: this.getString(data['title']) || defaultTitle,
3087
+ customRef: this.getString(data['custom_ref']) || this.getString(data['customer_reference']),
3088
+ amount: this.getNumber(data['amount']),
3089
+ apiResponseCode: LokotroPayApiResponseCode.LOK000,
3090
+ currency: this.getString(data['currency']) || this.getString(data['currency_str']),
3091
+ paymentStatus,
3092
+ systemRef: this.getString(data['system_reference']) || this.getString(data['system_ref']) || transactionId,
3093
+ transactionId,
3094
+ timestamp: new Date()
3095
+ };
3096
+ this.updateState({
3097
+ isLoading: false,
3098
+ response,
3099
+ currentScreen: LokotroPayScreenNavigation.SuccessScreen
3100
+ });
3413
3101
  }
3414
3102
  /**
3415
- * Get current state
3103
+ * Handle payment failure
3416
3104
  */
3417
- get currentState() {
3418
- return this.stateSubject.value;
3105
+ handlePaymentFailure(message, errorCode) {
3106
+ this.stopMobileMoneyPolling();
3107
+ this.updateState({
3108
+ isLoading: false,
3109
+ error: {
3110
+ message: message || 'Payment failed',
3111
+ title: 'Payment Failed',
3112
+ errorCode,
3113
+ timestamp: new Date()
3114
+ },
3115
+ currentScreen: LokotroPayScreenNavigation.ErrorScreen
3116
+ });
3419
3117
  }
3420
3118
  /**
3421
- * Update state
3119
+ * Resolve the collect response into the same flow the Flutter SDK uses.
3422
3120
  */
3423
- updateState(partialState) {
3424
- this.stateSubject.next({
3425
- ...this.currentState,
3426
- ...partialState
3121
+ resolveCollectResponse(collectData, paymentBody) {
3122
+ const paymentId = this.resolvePaymentId(collectData);
3123
+ const paymentStatus = this.getString(collectData['status']).toLowerCase();
3124
+ const paymentInfo = this.parsePaymentInfo(collectData);
3125
+ const additionalData = this.asRecord(collectData['additional_data']);
3126
+ // Bank-transfer switch offer: the amount is out of range for the chosen
3127
+ // method but fits bank transfer. Pre-transaction (no id yet) — detect it
3128
+ // before any status routing. NOT a silent switch: route to the confirm
3129
+ // screen; the customer accepts (confirmBankTransferSwitch → re-collect) or
3130
+ // declines (declineBankTransferSwitch → method selection).
3131
+ const bankTransferOffer = this.asRecord(additionalData?.['bank_transfer_offer']);
3132
+ if (bankTransferOffer && this.getBoolean(bankTransferOffer['available'])) {
3133
+ this.updateState({
3134
+ isLoading: false,
3135
+ bankTransferOffer,
3136
+ currentScreen: LokotroPayScreenNavigation.BankTransferOfferScreen
3137
+ });
3138
+ return of(void 0);
3139
+ }
3140
+ if (paymentStatus === 'pending_otp' || paymentStatus === 'pending_otp_verification') {
3141
+ this.updateState({
3142
+ isLoading: false,
3143
+ transactionId: paymentId,
3144
+ paymentInfo,
3145
+ currentScreen: LokotroPayScreenNavigation.EWalletOtpScreen
3146
+ });
3147
+ return of(void 0);
3148
+ }
3149
+ if (paymentStatus === 'pending_bank_proof_upload') {
3150
+ this.handlePaymentSuccess(collectData, LokotroPaymentStatus.PendingBankProofUpload, 'Upload Proof Required');
3151
+ return of(void 0);
3152
+ }
3153
+ if (paymentStatus === 'completed' || paymentStatus === 'approved' || paymentStatus === 'success') {
3154
+ this.handlePaymentSuccess(collectData);
3155
+ return of(void 0);
3156
+ }
3157
+ if (paymentStatus === 'session_created') {
3158
+ const redirectUrl = this.getString(collectData['mastercardUrl']) ||
3159
+ this.getString(collectData['redirect_url']) ||
3160
+ this.getString(collectData['payment_url']);
3161
+ if (!redirectUrl) {
3162
+ this.handlePaymentFailure('Hosted session created but no checkout URL was provided');
3163
+ return of(void 0);
3164
+ }
3165
+ this.updateState({
3166
+ isLoading: false,
3167
+ transactionId: paymentId
3168
+ });
3169
+ // PR-1.2 / Angular HIGH-1: validate before redirect.
3170
+ this.redirectIfAllowed(redirectUrl, 'session-created');
3171
+ return of(void 0);
3172
+ }
3173
+ if (this.getString(additionalData?.['action']) === 'select_payment_method') {
3174
+ this.updateState({
3175
+ isLoading: false,
3176
+ transactionId: paymentId,
3177
+ paymentInfo,
3178
+ currentScreen: LokotroPayScreenNavigation.PaymentMethodSelectionScreen
3179
+ });
3180
+ return of(void 0);
3181
+ }
3182
+ if (paymentStatus === 'failed' || paymentStatus === 'rejected' || paymentStatus === 'declined') {
3183
+ this.handlePaymentFailure(this.getString(collectData['message']) || 'Payment failed');
3184
+ return of(void 0);
3185
+ }
3186
+ if (!paymentId) {
3187
+ this.handlePaymentFailure('Transaction ID not received from payment collection');
3188
+ return of(void 0);
3189
+ }
3190
+ return this.getTransactionDetails(paymentId).pipe(switchMap(response => this.resolveTransactionResponse(response, paymentBody)));
3191
+ }
3192
+ /**
3193
+ * Resolve transaction details into the correct screen or auto-submit flow.
3194
+ */
3195
+ resolveTransactionResponse(response, paymentBody) {
3196
+ if (!response.isSuccess || !response.data) {
3197
+ this.handlePaymentFailure(response.message || 'Failed to load transaction details', response.apiResponseCode);
3198
+ return of(void 0);
3199
+ }
3200
+ const transactionData = this.unwrapPayload(response.data);
3201
+ const paymentId = this.resolvePaymentId(transactionData);
3202
+ if (!paymentId) {
3203
+ this.handlePaymentFailure('Transaction ID not found in transaction details');
3204
+ return of(void 0);
3205
+ }
3206
+ const paymentInfo = this.parseTransactionPaymentInfo(transactionData);
3207
+ const selectedPaymentMethod = this.resolveSelectedPaymentMethod(transactionData, paymentInfo);
3208
+ if (!paymentInfo.showUserInfoForm && !paymentInfo.showPaymentMethodForm) {
3209
+ this.updateState({
3210
+ transactionId: paymentId,
3211
+ paymentInfo,
3212
+ selectedPaymentMethod
3213
+ });
3214
+ if (selectedPaymentMethod?.channel === LokotroPayChannel.Card) {
3215
+ const hostedCheckoutUrl = this.getString(transactionData['mastercardUrl']) ||
3216
+ this.getString(transactionData['redirect_url']);
3217
+ if (hostedCheckoutUrl) {
3218
+ this.updateState({ isLoading: false });
3219
+ // PR-1.2 / Angular HIGH-1: validate hosted-checkout URL.
3220
+ this.redirectIfAllowed(hostedCheckoutUrl, 'hosted-checkout');
3221
+ return of(void 0);
3222
+ }
3223
+ }
3224
+ const paymentMethodId = selectedPaymentMethod?.id || this.getString(transactionData['payment_method_id']);
3225
+ if (!paymentMethodId) {
3226
+ this.handlePaymentFailure('Payment method not found for auto-processing');
3227
+ return of(void 0);
3228
+ }
3229
+ return this.submitPaymentDetails(this.buildAutoSubmitRequest(paymentId, paymentMethodId, paymentBody)).pipe(map(() => void 0));
3230
+ }
3231
+ const nextScreen = paymentInfo.showPaymentMethodForm
3232
+ ? LokotroPayScreenNavigation.PaymentMethodSelectionScreen
3233
+ : this.getFormScreenForChannel(selectedPaymentMethod?.channel || this.parseChannel(this.getString(transactionData['channel'])));
3234
+ this.updateState({
3235
+ isLoading: false,
3236
+ transactionId: paymentId,
3237
+ paymentInfo,
3238
+ selectedPaymentMethod,
3239
+ currentScreen: nextScreen
3427
3240
  });
3241
+ return of(void 0);
3428
3242
  }
3429
3243
  /**
3430
- * Reset state to initial
3244
+ * Get form screen for payment channel
3431
3245
  */
3432
- resetState() {
3433
- this.stopMobileMoneyPolling();
3434
- this.currentPaymentBody = undefined;
3435
- this.stateSubject.next(initialState);
3246
+ getFormScreenForChannel(channel) {
3247
+ switch (channel) {
3248
+ case LokotroPayChannel.EWallet:
3249
+ case LokotroPayChannel.LokotroWallet:
3250
+ return LokotroPayScreenNavigation.EWalletFormScreen;
3251
+ case LokotroPayChannel.MobileMoney:
3252
+ return LokotroPayScreenNavigation.MobileMoneyFormScreen;
3253
+ case LokotroPayChannel.Card:
3254
+ case LokotroPayChannel.VirtualCard:
3255
+ return LokotroPayScreenNavigation.CardFormScreen;
3256
+ case LokotroPayChannel.EFlash:
3257
+ return LokotroPayScreenNavigation.FlashFormScreen;
3258
+ case LokotroPayChannel.BankTransfer:
3259
+ return LokotroPayScreenNavigation.BankTransferFormScreen;
3260
+ default:
3261
+ return LokotroPayScreenNavigation.PaymentFormScreen;
3262
+ }
3436
3263
  }
3437
3264
  /**
3438
- * Set app-key for authentication
3265
+ * Fetch the banks / accounts / entities available for a bank-transfer
3266
+ * selection. Calls the gateway's /payments/get-config-bank (the same endpoint
3267
+ * the Flutter SDK uses) and maps the snake_case payload into the typed models.
3268
+ * Returns a LokotroHttpResponse<LokotroBank[]> so the bank form can read
3269
+ * response.isSuccess + response.data.
3439
3270
  */
3440
- setAppKey(appKey) {
3441
- this.httpClient.setAppKey(appKey);
3271
+ fetchAvailableBanks() {
3272
+ return this.httpClient.get('/payments/get-config-bank').pipe(map(response => {
3273
+ const rows = this.asArray(response.data?.['data']);
3274
+ return {
3275
+ isSuccess: response.isSuccess,
3276
+ message: response.message,
3277
+ data: rows.map(row => this.mapBank(row))
3278
+ };
3279
+ }));
3442
3280
  }
3443
3281
  /**
3444
- * Set accept language
3282
+ * Customer accepted the bank-transfer switch offer — re-collect with the
3283
+ * method forced to bank_transfer (analogue of the Flutter re-init). Clears the
3284
+ * offer first so the confirm screen doesn't flash again.
3445
3285
  */
3446
- setAcceptLanguage(language) {
3447
- this.httpClient.setAcceptLanguage(language);
3286
+ confirmBankTransferSwitch() {
3287
+ const body = this.currentPaymentBody;
3288
+ if (!body) {
3289
+ this.handlePaymentFailure('Cannot switch to bank transfer: no payment in progress');
3290
+ return;
3291
+ }
3292
+ this.updateState({ bankTransferOffer: null });
3293
+ this.createPayment({ ...body, paymentMethod: 'bank_transfer' }).subscribe();
3294
+ }
3295
+ /** Customer declined the switch — return to payment-method selection. */
3296
+ declineBankTransferSwitch() {
3297
+ this.updateState({ bankTransferOffer: null });
3298
+ this.navigateToScreen(LokotroPayScreenNavigation.PaymentMethodSelectionScreen);
3299
+ }
3300
+ asArray(value) {
3301
+ return Array.isArray(value) ? value : [];
3302
+ }
3303
+ mapBank(row) {
3304
+ return {
3305
+ id: this.getString(row['id']),
3306
+ name: this.getString(row['name']),
3307
+ abreviation: this.getString(row['abreviation']),
3308
+ bankLogoUrl: this.getString(row['bank_logo_url']),
3309
+ hasRibNomenclatureConstraint: this.getBoolean(row['has_rib_nomenclature_constraint']),
3310
+ ribAccountNumberFormatStr: this.getString(row['rib_account_number_format_str']),
3311
+ bankAccounts: this.asArray(row['bank_accounts']).map(a => this.mapBankAccount(a)),
3312
+ entityAvailables: this.asArray(row['entity_availables']).map(e => this.mapBankEntity(e))
3313
+ };
3314
+ }
3315
+ mapBankAccount(row) {
3316
+ const refBank = this.asRecord(row['ref_bank']);
3317
+ const refCurrency = this.asRecord(row['ref_currency']);
3318
+ return {
3319
+ id: this.getString(row['id']),
3320
+ identifier: this.getString(row['identifier']) || this.getString(row['id']),
3321
+ accountNumber: this.getString(row['account_number']),
3322
+ accountLabel: this.getString(row['account_label']),
3323
+ createdAt: this.getString(row['created_at']),
3324
+ refBank: refBank
3325
+ ? {
3326
+ id: this.getString(refBank['id']),
3327
+ name: this.getString(refBank['name']),
3328
+ abreviation: this.getString(refBank['abreviation']),
3329
+ bankLogoUrl: this.getString(refBank['logo_url']),
3330
+ hasRibNomenclatureConstraint: false,
3331
+ ribAccountNumberFormatStr: '',
3332
+ bankAccounts: [],
3333
+ entityAvailables: []
3334
+ }
3335
+ : undefined,
3336
+ refCurrency: refCurrency ? this.mapBankCurrency(refCurrency) : undefined
3337
+ };
3338
+ }
3339
+ mapBankEntity(row) {
3340
+ return {
3341
+ id: this.getString(row['id']),
3342
+ name: this.getString(row['name']),
3343
+ countryFlag: this.getString(row['country_flag'])
3344
+ };
3345
+ }
3346
+ mapBankCurrency(row) {
3347
+ return {
3348
+ id: this.getString(row['id']),
3349
+ name: this.getString(row['name']),
3350
+ code: this.getString(row['code']),
3351
+ symbol: this.getString(row['symbol'])
3352
+ };
3448
3353
  }
3449
3354
  /**
3450
- * Step 1: Create payment transaction
3451
- * POST /payments/collect
3355
+ * Convert payment body to API request format
3452
3356
  */
3453
- createPayment(paymentBody) {
3454
- this.currentPaymentBody = paymentBody;
3455
- this.updateState({ isLoading: true });
3456
- const requestData = this.convertPaymentBodyToRequest(paymentBody);
3457
- // PR-1.2 / CRIT-1: previous `console.log(requestData)` shipped PAN, CVV,
3458
- // wallet PIN, flash PIN, and full PII to the browser console on every
3459
- // checkout. Removed.
3460
- return this.httpClient.post(LokotroPayEnv.endpoints.collect, requestData).pipe(switchMap(response => {
3461
- // PR-1.2: response contains transaction IDs and (sometimes) redirect
3462
- // URLs that are sensitive. Don't log it.
3463
- if (!response.isSuccess || !response.data) {
3464
- this.updateState({
3465
- isLoading: false,
3466
- error: {
3467
- message: response.message || 'Failed to create payment',
3468
- title: 'Payment Creation Failed',
3469
- errorCode: response.apiResponseCode,
3470
- timestamp: new Date()
3471
- },
3472
- currentScreen: LokotroPayScreenNavigation.ErrorScreen
3473
- });
3474
- return of(response);
3475
- }
3476
- return this.resolveCollectResponse(this.unwrapPayload(response.data), paymentBody).pipe(map(() => response));
3477
- }), catchError(error => {
3478
- this.updateState({
3479
- isLoading: false,
3480
- error: {
3481
- message: error.message || 'Failed to create payment',
3482
- title: 'Error',
3483
- timestamp: new Date()
3484
- },
3485
- currentScreen: LokotroPayScreenNavigation.ErrorScreen
3486
- });
3487
- return throwError(() => error);
3488
- }));
3357
+ convertPaymentBodyToRequest(body) {
3358
+ const request = {
3359
+ payment_method: body.paymentMethod || 'wallet',
3360
+ user_info: body.userInfo || 'full',
3361
+ payment_method_info: body.paymentMethodInfo || 'full',
3362
+ fee_covered_by: body.feeCoveredBy || 'buyer',
3363
+ delivery_behaviour: body.deliveryBehaviour || 'direct_delivery'
3364
+ };
3365
+ // Auth-v3 refactor: customer_reference / amount / currency are bound
3366
+ // on the session_token. The gateway reads them straight from the
3367
+ // session sending them again on the wire is redundant, and was
3368
+ // the source of the `"1.00"` vs `"1"` formatting drift that 400'd
3369
+ // body-binding. Only include them when the merchant explicitly set
3370
+ // them (legacy / display reasons). The gateway treats them as a
3371
+ // soft check when present, ignores when absent.
3372
+ if (body.customerReference)
3373
+ request['customer_reference'] = body.customerReference;
3374
+ if (body.amount)
3375
+ request['amount'] = body.amount;
3376
+ if (body.currency)
3377
+ request['currency'] = body.currency.toLowerCase();
3378
+ if (body.notifyUrl)
3379
+ request['notify_url'] = body.notifyUrl;
3380
+ if (body.successRedirectUrl)
3381
+ request['success_redirect_url'] = body.successRedirectUrl;
3382
+ if (body.failRedirectUrl)
3383
+ request['fail_redirect_url'] = body.failRedirectUrl;
3384
+ if (body.userInfo === 'full') {
3385
+ if (body.firstName)
3386
+ request['first_name'] = body.firstName;
3387
+ if (body.lastName)
3388
+ request['last_name'] = body.lastName;
3389
+ if (body.phoneNumber)
3390
+ request['phone_number'] = body.phoneNumber;
3391
+ if (body.email)
3392
+ request['email'] = body.email;
3393
+ }
3394
+ if (body.paymentMethodInfo === 'full') {
3395
+ if (body.walletNumber)
3396
+ request['wallet_number'] = body.walletNumber;
3397
+ if (body.walletPin)
3398
+ request['wallet_pin'] = body.walletPin;
3399
+ if (body.mobileMoneyPhoneNumber)
3400
+ request['mobile_money_phone_number'] = body.mobileMoneyPhoneNumber;
3401
+ if (body.flashNumber)
3402
+ request['flash_number'] = body.flashNumber;
3403
+ if (body.flashPin)
3404
+ request['flash_pin'] = body.flashPin;
3405
+ if (body.cardNumber)
3406
+ request['card_number'] = body.cardNumber;
3407
+ if (body.cardExpiryDate)
3408
+ request['card_expiry_date'] = body.cardExpiryDate;
3409
+ if (body.cardCvv)
3410
+ request['card_cvv'] = body.cardCvv;
3411
+ if (body.cardHolderName)
3412
+ request['card_holder_name'] = body.cardHolderName;
3413
+ }
3414
+ if (body.merchant) {
3415
+ request['merchant'] = {
3416
+ name: body.merchant.name,
3417
+ url: body.merchant.website,
3418
+ logo: body.merchant.logoUrl
3419
+ };
3420
+ }
3421
+ if (body.mastercardPaymentMethod) {
3422
+ request['mastercard_payment_method'] = body.mastercardPaymentMethod;
3423
+ }
3424
+ if (body.transactionalCurrency) {
3425
+ request['transactional_currency'] = body.transactionalCurrency.toLowerCase();
3426
+ }
3427
+ if (body.nativePayToken) {
3428
+ request['native_pay_token'] = body.nativePayToken;
3429
+ }
3430
+ if (body.nativePayTokenType) {
3431
+ request['native_pay_token_type'] = body.nativePayTokenType;
3432
+ }
3433
+ if (body.metadata) {
3434
+ request['metadata'] = body.metadata;
3435
+ }
3436
+ return request;
3489
3437
  }
3490
3438
  /**
3491
- * Step 2: Get transaction details
3492
- * GET /payments/transaction/{transaction_id}
3439
+ * Convert submit request to API format
3493
3440
  */
3494
- getTransactionDetails(transactionId) {
3495
- return this.httpClient.get(`${LokotroPayEnv.endpoints.transaction}/${transactionId}`);
3441
+ convertSubmitRequestToData(request) {
3442
+ const data = {
3443
+ payment_id: this.resolvePaymentId(request),
3444
+ payment_method_id: request.paymentMethodId || request.paymentMethod || ''
3445
+ };
3446
+ if (request.firstName)
3447
+ data['first_name'] = request.firstName;
3448
+ if (request.lastName)
3449
+ data['last_name'] = request.lastName;
3450
+ if (request.email)
3451
+ data['email'] = request.email;
3452
+ if (request.phoneNumber)
3453
+ data['phone_number'] = request.phoneNumber;
3454
+ if (request.walletNumber)
3455
+ data['ewallet_number'] = request.walletNumber;
3456
+ if (request.walletPin)
3457
+ data['ewallet_pin'] = request.walletPin;
3458
+ if (request.mobileMoneyPhoneNumber)
3459
+ data['mobile_money_phone_number'] = request.mobileMoneyPhoneNumber;
3460
+ if (request.flashNumber)
3461
+ data['flash_number'] = request.flashNumber;
3462
+ if (request.flashPin)
3463
+ data['flash_pin'] = request.flashPin;
3464
+ if (request.cardNumber)
3465
+ data['card_number'] = request.cardNumber;
3466
+ if (request.cardExpiryDate)
3467
+ data['card_expiry_date'] = request.cardExpiryDate;
3468
+ if (request.cardCvv)
3469
+ data['card_cvv'] = request.cardCvv;
3470
+ if (request.cardHolderName)
3471
+ data['card_holder_name'] = request.cardHolderName;
3472
+ if (request.bankTransferAccountId)
3473
+ data['bank_account_id'] = request.bankTransferAccountId;
3474
+ if (request.bankAccountId)
3475
+ data['bank_account_id'] = request.bankAccountId;
3476
+ if (request.bankAccountNumber)
3477
+ data['bank_account_number'] = request.bankAccountNumber;
3478
+ if (request.bankAccountLabel)
3479
+ data['bank_account_label'] = request.bankAccountLabel;
3480
+ if (request.bankId)
3481
+ data['bank_id'] = request.bankId;
3482
+ if (request.bankEntityId)
3483
+ data['bank_entity_id'] = request.bankEntityId;
3484
+ if (request.mastercardPaymentMethod)
3485
+ data['mastercard_payment_method'] = request.mastercardPaymentMethod;
3486
+ if (request.sessionId)
3487
+ data['session_id'] = request.sessionId;
3488
+ if (request.cardData)
3489
+ data['card_data'] = request.cardData;
3490
+ if (typeof request.savePaymentMethod === 'boolean')
3491
+ data['save_payment_method'] = request.savePaymentMethod;
3492
+ return data;
3496
3493
  }
3497
3494
  /**
3498
- * Step 3: Submit payment details
3499
- * POST /payments/submit
3495
+ * Parse payment info from collect response.
3500
3496
  */
3501
- submitPaymentDetails(request) {
3502
- this.updateState({
3503
- isLoading: true,
3504
- currentScreen: LokotroPayScreenNavigation.ProcessingScreen
3505
- });
3506
- const requestData = this.convertSubmitRequestToData(request);
3507
- return this.httpClient.post(LokotroPayEnv.endpoints.submit, requestData).pipe(tap(response => {
3508
- if (response.isSuccess && response.data) {
3509
- const responseData = this.unwrapPayload(response.data);
3510
- const submitResponse = this.parseSubmitResponse(responseData);
3511
- if (submitResponse.requiresOtp) {
3512
- this.updateState({
3513
- isLoading: false,
3514
- transactionId: submitResponse.transactionId || this.resolvePaymentId(request),
3515
- currentScreen: LokotroPayScreenNavigation.EWalletOtpScreen
3516
- });
3517
- return;
3518
- }
3519
- if (submitResponse.redirectUrl) {
3520
- this.updateState({
3521
- isLoading: false,
3522
- transactionId: submitResponse.transactionId || this.resolvePaymentId(request)
3523
- });
3524
- // PR-1.2 / Angular HIGH-1: validate redirect URL against allow-list
3525
- // before handing the tab over. Server-side allow-list (PR-2.0) is
3526
- // the real defense; this is defense-in-depth on the client.
3527
- this.redirectIfAllowed(submitResponse.redirectUrl, 'submit-response');
3528
- return;
3529
- }
3530
- if (submitResponse.status === LokotroPaymentStatus.PendingBankProofUpload) {
3531
- this.handlePaymentSuccess(responseData, LokotroPaymentStatus.PendingBankProofUpload, 'Upload Proof Required');
3532
- return;
3533
- }
3534
- if (LokotroPaymentStatusInfo.isSuccess(submitResponse.status)) {
3535
- this.handlePaymentSuccess(responseData);
3536
- return;
3537
- }
3538
- if (LokotroPaymentStatusInfo.isPending(submitResponse.status)) {
3539
- const txId = submitResponse.transactionId || this.resolvePaymentId(request);
3540
- this.updateState({
3541
- isLoading: false,
3542
- transactionId: txId,
3543
- currentScreen: LokotroPayScreenNavigation.MobileMoneyProcessingScreen
3544
- });
3545
- this.startMobileMoneyPolling(txId);
3546
- return;
3547
- }
3548
- this.handlePaymentFailure(this.getString(responseData['message']) || response.message, response.apiResponseCode);
3549
- }
3550
- else {
3551
- this.handlePaymentFailure(response.message, response.apiResponseCode);
3552
- }
3553
- }), catchError(error => {
3554
- this.handlePaymentFailure(error.message);
3555
- return throwError(() => error);
3556
- }));
3497
+ parsePaymentInfo(data) {
3498
+ const additionalData = this.asRecord(data['additional_data']);
3499
+ // Drop feature-flag-hidden methods (e.g. Google/Apple Pay) so they never
3500
+ // enter the payment info model, get rendered, or get auto-selected.
3501
+ const availablePaymentMethods = visiblePaymentMethods(this.getRecordArray(data['available_payment_methods'] || additionalData?.['payment_methods'])
3502
+ .map(method => this.parsePaymentMethod(method)));
3503
+ const action = this.getString(additionalData?.['action']);
3504
+ return {
3505
+ id: this.resolvePaymentId(data),
3506
+ amount: this.getNumber(data['amount']),
3507
+ currency: this.getString(data['currency']) || 'USD',
3508
+ description: this.getString(data['description']),
3509
+ merchantName: this.getString(data['merchant_name']),
3510
+ merchantId: this.getString(data['merchant_id']),
3511
+ availablePaymentMethods,
3512
+ createdAt: new Date(this.getString(data['created_at']) || Date.now()),
3513
+ expiresAt: this.getString(data['expires_at']) ? new Date(this.getString(data['expires_at'])) : undefined,
3514
+ metadata: this.asRecord(data['metadata']),
3515
+ paymentUrl: this.getString(data['payment_url']) || undefined,
3516
+ showUserInfoForm: this.getBoolean(data['show_user_info_form']) || action === 'select_payment_method',
3517
+ showPaymentMethodForm: this.getBoolean(data['show_payment_method_form']) || action === 'select_payment_method'
3518
+ };
3557
3519
  }
3558
3520
  /**
3559
- * Step 4: Verify OTP (for e-wallet and flash payments)
3560
- * POST /payments/verify-otp
3521
+ * Parse payment info from transaction details response.
3561
3522
  */
3562
- verifyOtp(request) {
3563
- this.updateState({ isLoading: true });
3564
- return this.httpClient.post(LokotroPayEnv.endpoints.verifyOtp, {
3565
- payment_id: request.paymentId || request.transactionId || this.currentState.transactionId,
3566
- otp_code: request.otpCode || request.otp
3567
- }).pipe(tap(response => {
3568
- if (response.isSuccess && response.data) {
3569
- const responseData = this.unwrapPayload(response.data);
3570
- const status = LokotroPaymentStatusInfo.fromString(this.getString(responseData['status']));
3571
- if (LokotroPaymentStatusInfo.isSuccess(status)) {
3572
- this.handlePaymentSuccess(responseData);
3573
- }
3574
- else {
3575
- this.handlePaymentFailure(this.getString(responseData['message']) || response.message, response.apiResponseCode);
3576
- }
3577
- }
3578
- else {
3579
- this.updateState({
3580
- isLoading: false,
3581
- error: {
3582
- message: response.message || 'OTP verification failed',
3583
- title: 'Verification Failed',
3584
- errorCode: response.apiResponseCode,
3585
- timestamp: new Date()
3586
- },
3587
- currentScreen: LokotroPayScreenNavigation.ErrorScreen
3588
- });
3589
- }
3590
- }), catchError(error => {
3591
- this.updateState({
3592
- isLoading: false,
3593
- error: {
3594
- message: error.message || 'OTP verification failed',
3595
- title: 'Error',
3596
- timestamp: new Date()
3597
- },
3598
- currentScreen: LokotroPayScreenNavigation.ErrorScreen
3599
- });
3600
- return throwError(() => error);
3601
- }));
3523
+ parseTransactionPaymentInfo(data) {
3524
+ const fillingInfo = this.getString(data['filling_info']) || 'none';
3525
+ const channelInfo = this.getString(data['channel_info']) || 'none';
3526
+ return {
3527
+ id: this.resolvePaymentId(data),
3528
+ amount: this.getNumber(data['transactional_amount'] ?? data['amount']),
3529
+ currency: this.getString(data['transactional_currency']) || this.getString(data['currency_str']) || 'USD',
3530
+ description: this.getString(data['description']),
3531
+ merchantName: this.getString(data['merchant_name']),
3532
+ merchantId: this.getString(data['merchant_id']),
3533
+ availablePaymentMethods: visiblePaymentMethods(this.getRecordArray(data['payment_methods']).map(method => this.parsePaymentMethod(method))),
3534
+ createdAt: new Date(this.getString(data['created_at']) || Date.now()),
3535
+ metadata: this.asRecord(data['metadata']),
3536
+ paymentUrl: this.getString(data['payment_url']) || undefined,
3537
+ showUserInfoForm: fillingInfo === 'none',
3538
+ showPaymentMethodForm: channelInfo === 'none',
3539
+ fillingInfo,
3540
+ channelInfo
3541
+ };
3602
3542
  }
3603
3543
  /**
3604
- * Step 5: Resend OTP (if needed)
3605
- * POST /payments/resend-otp
3544
+ * Parse payment method from API response.
3606
3545
  */
3607
- resendOtp(request) {
3608
- return this.httpClient.post(LokotroPayEnv.endpoints.resendOtp, {
3609
- payment_id: request.paymentId || request.transactionId || this.currentState.transactionId
3610
- });
3546
+ parsePaymentMethod(data) {
3547
+ const nestedChannel = this.asRecord(data['channel']);
3548
+ const rawChannel = this.getString(nestedChannel?.['name']) ||
3549
+ this.getString(data['group']) ||
3550
+ this.getString(data['method']) ||
3551
+ this.getString(data['channel']) ||
3552
+ this.getString(data['name']);
3553
+ const name = this.getString(data['name']) || this.getString(data['method']) || rawChannel;
3554
+ return {
3555
+ id: this.getString(data['id']) || this.getString(data['payment_method_id']) || name,
3556
+ name,
3557
+ displayName: this.getString(data['display_name']) || name,
3558
+ channel: this.parseChannel(rawChannel),
3559
+ // PR-3.0 — pull the provider flag through. Legacy responses omit it →
3560
+ // remains undefined, consumers fall back to channel-level branching.
3561
+ flag: this.parsePaymentMethodFlag(this.getString(data['flag'])),
3562
+ iconUrl: this.getString(data['icon_url']) || this.getString(data['icon']),
3563
+ isEnabled: this.getBoolean(data['is_enabled'], this.getBoolean(data['available'], true)),
3564
+ configuration: this.asRecord(data['configuration']),
3565
+ supportedCurrencies: Array.isArray(data['supported_currencies'])
3566
+ ? data['supported_currencies']
3567
+ : undefined
3568
+ };
3611
3569
  }
3612
3570
  /**
3613
- * Select payment method
3571
+ * PR-3.0 narrow an arbitrary string to the LokotroPaymentMethodFlag union.
3572
+ * Unknown / empty inputs return undefined so the field stays optional.
3614
3573
  */
3615
- selectPaymentMethod(method) {
3616
- this.updateState({
3617
- selectedPaymentMethod: method,
3618
- currentScreen: this.getFormScreenForChannel(method.channel)
3619
- });
3574
+ parsePaymentMethodFlag(value) {
3575
+ const known = [
3576
+ 'none', 'all',
3577
+ 'onafriq_mobile_money', 'onafriq_credit_card', 'rawbank_credit_card',
3578
+ 'lokotro_wallet', 'lokotro_eflash',
3579
+ 'cash', 'bank_transfer', 'google_pay', 'apple_pay',
3580
+ ];
3581
+ return known.find(f => f === value);
3620
3582
  }
3621
3583
  /**
3622
- * Navigate to screen
3584
+ * Parse submit response from API.
3623
3585
  */
3624
- navigateToScreen(screen) {
3625
- this.updateState({ currentScreen: screen });
3586
+ parseSubmitResponse(data) {
3587
+ const rawStatus = this.getString(data['status']);
3588
+ return {
3589
+ success: this.getBoolean(data['success']),
3590
+ message: this.getString(data['message']),
3591
+ transactionId: this.resolvePaymentId(data),
3592
+ status: LokotroPaymentStatusInfo.fromString(rawStatus),
3593
+ requiresOtp: this.getBoolean(data['requires_otp']) || rawStatus === 'pending_otp' || rawStatus === 'pending_otp_verification',
3594
+ otpDestination: this.getString(data['otp_destination']) || this.getString(data['otp_sent_to']) || undefined,
3595
+ redirectUrl: this.getString(data['redirect_url']) || this.getString(data['mastercardUrl']) || undefined
3596
+ };
3626
3597
  }
3627
3598
  /**
3628
- * Start polling for mobile money payment status
3599
+ * Build an auto-submit request using the original payment body.
3629
3600
  */
3630
- startMobileMoneyPolling(transactionId) {
3631
- this.stopMobileMoneyPolling();
3632
- this.mobileMoneyPollingAttempts = 0;
3633
- // PR-1.2: removed console.log of transactionId — operational logging
3634
- // should be opt-in via merchant config, not on by default.
3635
- this.mobileMoneyPollingTimer = setInterval(() => {
3636
- this.mobileMoneyPollingAttempts++;
3637
- if (this.mobileMoneyPollingAttempts > LokotroPaymentService.MOBILE_MONEY_MAX_ATTEMPTS) {
3638
- this.stopMobileMoneyPolling();
3639
- this.handlePaymentFailure('Payment took too long. Please try again or contact support.');
3640
- return;
3641
- }
3642
- const endpoint = `${LokotroPayEnv.endpoints.mobileMoneyStatus}/${transactionId}`;
3643
- this.httpClient.get(endpoint).subscribe({
3644
- next: (response) => {
3645
- if (!response.isSuccess || !response.data)
3646
- return;
3647
- const data = this.unwrapPayload(response.data);
3648
- const status = this.getString(data['status']).toLowerCase();
3649
- if (status === 'completed' || status === 'success' || status === 'approved') {
3650
- this.stopMobileMoneyPolling();
3651
- this.handlePaymentSuccess(data);
3652
- }
3653
- else if (status === 'failed' || status === 'error' || status === 'cancelled' || status === 'declined') {
3654
- this.stopMobileMoneyPolling();
3655
- this.handlePaymentFailure(this.getString(data['message']) || 'Payment failed or was declined');
3656
- }
3657
- },
3658
- error: (_err) => {
3659
- // PR-1.2: don't log full error object — may contain backend
3660
- // response details that leak server internals to console.
3661
- }
3662
- });
3663
- }, LokotroPaymentService.MOBILE_MONEY_POLL_INTERVAL);
3601
+ buildAutoSubmitRequest(paymentId, paymentMethodId, paymentBody) {
3602
+ return {
3603
+ paymentId,
3604
+ paymentMethodId,
3605
+ paymentMethod: paymentBody.paymentMethod,
3606
+ firstName: paymentBody.firstName,
3607
+ lastName: paymentBody.lastName,
3608
+ email: paymentBody.email,
3609
+ phoneNumber: paymentBody.phoneNumber,
3610
+ walletNumber: paymentBody.walletNumber,
3611
+ walletPin: paymentBody.walletPin,
3612
+ mobileMoneyPhoneNumber: paymentBody.mobileMoneyPhoneNumber,
3613
+ flashNumber: paymentBody.flashNumber,
3614
+ flashPin: paymentBody.flashPin,
3615
+ cardNumber: paymentBody.cardNumber,
3616
+ cardExpiryDate: paymentBody.cardExpiryDate,
3617
+ cardCvv: paymentBody.cardCvv,
3618
+ cardHolderName: paymentBody.cardHolderName,
3619
+ mastercardPaymentMethod: paymentBody.mastercardPaymentMethod
3620
+ };
3664
3621
  }
3665
3622
  /**
3666
- * Stop mobile money status polling
3623
+ * Resolve the selected payment method from transaction details.
3667
3624
  */
3668
- stopMobileMoneyPolling() {
3669
- if (this.mobileMoneyPollingTimer) {
3670
- clearInterval(this.mobileMoneyPollingTimer);
3671
- this.mobileMoneyPollingTimer = undefined;
3672
- this.mobileMoneyPollingAttempts = 0;
3625
+ resolveSelectedPaymentMethod(transactionData, paymentInfo) {
3626
+ const paymentMethodId = this.getString(transactionData['payment_method_id']);
3627
+ const paymentMethodName = this.getString(transactionData['payment_method_str']) || this.getString(transactionData['payment_method']);
3628
+ const normalizedName = this.normalizeMethodKey(paymentMethodName);
3629
+ const exactMatch = paymentInfo.availablePaymentMethods.find(method => method.id === paymentMethodId ||
3630
+ this.normalizeMethodKey(method.name) === normalizedName ||
3631
+ this.normalizeMethodKey(method.displayName) === normalizedName);
3632
+ if (exactMatch) {
3633
+ return exactMatch;
3634
+ }
3635
+ const channel = this.parseChannel(this.getString(transactionData['channel']) || paymentMethodName);
3636
+ if (!paymentMethodId && !paymentMethodName && channel === LokotroPayChannel.None) {
3637
+ return paymentInfo.availablePaymentMethods[0];
3673
3638
  }
3639
+ return {
3640
+ id: paymentMethodId || paymentMethodName || channel,
3641
+ name: paymentMethodName || paymentMethodId || channel,
3642
+ displayName: paymentMethodName || paymentMethodId || channel,
3643
+ channel,
3644
+ iconUrl: '',
3645
+ isEnabled: true
3646
+ };
3674
3647
  }
3675
3648
  /**
3676
- * Validate a server-supplied redirect URL before handing the browser tab to
3677
- * it. Defense-in-depth for Angular HIGH-1 — server-side per-merchant
3678
- * allow-list (PR-2.0) is the real fix.
3679
- *
3680
- * Policy:
3681
- * - Reject non-http(s) schemes (blocks `javascript:`, `data:`, `file:`,
3682
- * `intent:`, custom schemes).
3683
- * - Reject http:// in production.
3684
- * - Reject malformed URLs.
3685
- * - SSR safety: skip the redirect when window is unavailable.
3649
+ * Normalize payloads that sometimes return data nested inside data.
3686
3650
  */
3687
- redirectIfAllowed(url, _origin) {
3688
- if (typeof window === 'undefined') {
3689
- return;
3690
- }
3691
- if (!url || typeof url !== 'string') {
3692
- this.handlePaymentFailure('Invalid redirect URL');
3693
- return;
3651
+ unwrapPayload(payload) {
3652
+ const record = this.asRecord(payload);
3653
+ if (!record) {
3654
+ return {};
3694
3655
  }
3695
- let parsed;
3696
- try {
3697
- parsed = new URL(url, window.location.href);
3656
+ const nestedData = this.asRecord(record['data']);
3657
+ return nestedData || record;
3658
+ }
3659
+ resolvePaymentId(source) {
3660
+ return this.getString(source['payment_id']) ||
3661
+ this.getString(source['transaction_id']) ||
3662
+ this.getString(source['paymentId']) ||
3663
+ this.getString(source['transactionId']) ||
3664
+ this.currentState.transactionId ||
3665
+ '';
3666
+ }
3667
+ parseChannel(channel) {
3668
+ switch (this.normalizeMethodKey(channel)) {
3669
+ case 'card':
3670
+ return LokotroPayChannel.Card;
3671
+ case 'mobilemoney':
3672
+ return LokotroPayChannel.MobileMoney;
3673
+ case 'wallet':
3674
+ case 'ewallet':
3675
+ case 'lokotrowallet':
3676
+ return LokotroPayChannel.EWallet;
3677
+ case 'flash':
3678
+ case 'eflash':
3679
+ return LokotroPayChannel.EFlash;
3680
+ case 'banktransfer':
3681
+ return LokotroPayChannel.BankTransfer;
3682
+ default:
3683
+ return LokotroPayChannel.None;
3698
3684
  }
3699
- catch {
3700
- this.handlePaymentFailure('Untrusted redirect URL');
3701
- return;
3685
+ }
3686
+ normalizeMethodKey(value) {
3687
+ return value.toLowerCase().replace(/[_\s-]/g, '');
3688
+ }
3689
+ getNumber(value, fallback = 0) {
3690
+ if (typeof value === 'number') {
3691
+ return Number.isFinite(value) ? value : fallback;
3702
3692
  }
3703
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
3704
- this.handlePaymentFailure('Untrusted redirect scheme');
3705
- return;
3693
+ if (typeof value === 'string') {
3694
+ const parsedValue = parseFloat(value);
3695
+ return Number.isFinite(parsedValue) ? parsedValue : fallback;
3706
3696
  }
3707
- const isProduction = LokotroPayEnv.environment === 'production';
3708
- if (isProduction && parsed.protocol !== 'https:') {
3709
- this.handlePaymentFailure('Insecure redirect rejected');
3710
- return;
3697
+ return fallback;
3698
+ }
3699
+ getString(value) {
3700
+ return typeof value === 'string' ? value : '';
3701
+ }
3702
+ getBoolean(value, fallback = false) {
3703
+ return typeof value === 'boolean' ? value : fallback;
3704
+ }
3705
+ asRecord(value) {
3706
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
3707
+ ? value
3708
+ : undefined;
3709
+ }
3710
+ getRecordArray(value) {
3711
+ if (!Array.isArray(value)) {
3712
+ return [];
3711
3713
  }
3712
- window.location.href = parsed.toString();
3714
+ return value
3715
+ .map(item => this.asRecord(item))
3716
+ .filter((item) => Boolean(item));
3717
+ }
3718
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, deps: [{ token: LokotroHttpClientService }], target: i0.ɵɵFactoryTarget.Injectable }); }
3719
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, providedIn: 'root' }); }
3720
+ }
3721
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, decorators: [{
3722
+ type: Injectable,
3723
+ args: [{
3724
+ providedIn: 'root'
3725
+ }]
3726
+ }], ctorParameters: () => [{ type: LokotroHttpClientService }] });
3727
+
3728
+ /**
3729
+ * Lokotro Pay - Bank Transfer Form Component
3730
+ */
3731
+ class LokotroBankTransferFormComponent {
3732
+ constructor(fb, localization, paymentService) {
3733
+ this.fb = fb;
3734
+ this.localization = localization;
3735
+ this.paymentService = paymentService;
3736
+ this.showUserInfoForm = false;
3737
+ this.formSubmitted = new EventEmitter();
3738
+ this.cancel = new EventEmitter();
3739
+ this.isLoading = true;
3740
+ this.allBanks = [];
3741
+ this.cities = [];
3742
+ this.filteredBanks = [];
3743
+ this.accounts = [];
3744
+ this.selectedCity = null;
3745
+ this.selectedBank = null;
3746
+ this.selectedAccount = null;
3713
3747
  }
3714
- /**
3715
- * Handle payment success
3716
- */
3717
- handlePaymentSuccess(data, paymentStatus = LokotroPaymentStatus.Approved, defaultTitle = 'Success') {
3718
- this.stopMobileMoneyPolling();
3719
- const transactionId = this.getString(data['transaction_id']) || this.getString(data['payment_id']);
3720
- const response = {
3721
- message: this.getString(data['message']) || 'Payment successful',
3722
- title: this.getString(data['title']) || defaultTitle,
3723
- customRef: this.getString(data['custom_ref']) || this.getString(data['customer_reference']),
3724
- amount: this.getNumber(data['amount']),
3725
- apiResponseCode: LokotroPayApiResponseCode.LOK000,
3726
- currency: this.getString(data['currency']) || this.getString(data['currency_str']),
3727
- paymentStatus,
3728
- systemRef: this.getString(data['system_reference']) || this.getString(data['system_ref']) || transactionId,
3729
- transactionId,
3730
- timestamp: new Date()
3748
+ ngOnInit() {
3749
+ this.initForm();
3750
+ this.fetchBanks();
3751
+ }
3752
+ initForm() {
3753
+ const config = {
3754
+ city: ['', Validators.required],
3755
+ bank: [{ value: '', disabled: true }, Validators.required],
3756
+ account: [{ value: '', disabled: true }, Validators.required]
3731
3757
  };
3732
- this.updateState({
3733
- isLoading: false,
3734
- response,
3735
- currentScreen: LokotroPayScreenNavigation.SuccessScreen
3736
- });
3758
+ if (this.showUserInfoForm) {
3759
+ config['firstName'] = ['', Validators.required];
3760
+ config['lastName'] = ['', Validators.required];
3761
+ config['email'] = ['', [Validators.required, Validators.email]];
3762
+ config['personalPhone'] = ['', Validators.required];
3763
+ }
3764
+ this.bankTransferForm = this.fb.group(config);
3737
3765
  }
3738
- /**
3739
- * Handle payment failure
3740
- */
3741
- handlePaymentFailure(message, errorCode) {
3742
- this.stopMobileMoneyPolling();
3743
- this.updateState({
3744
- isLoading: false,
3745
- error: {
3746
- message: message || 'Payment failed',
3747
- title: 'Payment Failed',
3748
- errorCode,
3749
- timestamp: new Date()
3766
+ fetchBanks() {
3767
+ this.isLoading = true;
3768
+ this.paymentService.fetchAvailableBanks().subscribe({
3769
+ next: (response) => {
3770
+ if (response.isSuccess && response.data) {
3771
+ this.allBanks = response.data;
3772
+ this.extractCities();
3773
+ }
3774
+ this.isLoading = false;
3750
3775
  },
3751
- currentScreen: LokotroPayScreenNavigation.ErrorScreen
3776
+ error: (err) => {
3777
+ console.error('Failed to fetch banks', err);
3778
+ this.isLoading = false;
3779
+ }
3752
3780
  });
3753
3781
  }
3754
- /**
3755
- * Resolve the collect response into the same flow the Flutter SDK uses.
3756
- */
3757
- resolveCollectResponse(collectData, paymentBody) {
3758
- const paymentId = this.resolvePaymentId(collectData);
3759
- const paymentStatus = this.getString(collectData['status']).toLowerCase();
3760
- const paymentInfo = this.parsePaymentInfo(collectData);
3761
- const additionalData = this.asRecord(collectData['additional_data']);
3762
- if (paymentStatus === 'pending_otp' || paymentStatus === 'pending_otp_verification') {
3763
- this.updateState({
3764
- isLoading: false,
3765
- transactionId: paymentId,
3766
- paymentInfo,
3767
- currentScreen: LokotroPayScreenNavigation.EWalletOtpScreen
3782
+ extractCities() {
3783
+ const cityMap = new Map();
3784
+ this.allBanks.forEach(bank => {
3785
+ bank.entityAvailables.forEach(city => {
3786
+ if (!cityMap.has(city.id)) {
3787
+ cityMap.set(city.id, city);
3788
+ }
3768
3789
  });
3769
- return of(void 0);
3790
+ });
3791
+ this.cities = Array.from(cityMap.values());
3792
+ }
3793
+ onCityChange() {
3794
+ const cityId = this.bankTransferForm.get('city')?.value;
3795
+ const bankControl = this.bankTransferForm.get('bank');
3796
+ const accountControl = this.bankTransferForm.get('account');
3797
+ bankControl?.reset('');
3798
+ bankControl?.disable();
3799
+ accountControl?.reset('');
3800
+ accountControl?.disable();
3801
+ this.filteredBanks = [];
3802
+ this.accounts = [];
3803
+ this.selectedAccount = null;
3804
+ if (cityId) {
3805
+ this.selectedCity = this.cities.find(c => c.id === cityId) || null;
3806
+ this.filteredBanks = this.allBanks.filter(bank => bank.entityAvailables.some(e => e.id === cityId));
3807
+ bankControl?.enable();
3808
+ }
3809
+ }
3810
+ onBankChange() {
3811
+ const bankId = this.bankTransferForm.get('bank')?.value;
3812
+ const accountControl = this.bankTransferForm.get('account');
3813
+ accountControl?.reset('');
3814
+ accountControl?.disable();
3815
+ this.accounts = [];
3816
+ this.selectedAccount = null;
3817
+ if (bankId) {
3818
+ this.selectedBank = this.filteredBanks.find(b => b.id === bankId) || null;
3819
+ if (this.selectedBank) {
3820
+ this.accounts = this.selectedBank.bankAccounts;
3821
+ accountControl?.enable();
3822
+ }
3770
3823
  }
3771
- if (paymentStatus === 'pending_bank_proof_upload') {
3772
- this.handlePaymentSuccess(collectData, LokotroPaymentStatus.PendingBankProofUpload, 'Upload Proof Required');
3773
- return of(void 0);
3824
+ }
3825
+ onAccountChange() {
3826
+ const accountId = this.bankTransferForm.get('account')?.value;
3827
+ if (accountId) {
3828
+ this.selectedAccount = this.accounts.find(a => a.id === accountId) || null;
3774
3829
  }
3775
- if (paymentStatus === 'completed' || paymentStatus === 'approved' || paymentStatus === 'success') {
3776
- this.handlePaymentSuccess(collectData);
3777
- return of(void 0);
3830
+ else {
3831
+ this.selectedAccount = null;
3778
3832
  }
3779
- if (paymentStatus === 'session_created') {
3780
- const redirectUrl = this.getString(collectData['mastercardUrl']) ||
3781
- this.getString(collectData['redirect_url']) ||
3782
- this.getString(collectData['payment_url']);
3783
- if (!redirectUrl) {
3784
- this.handlePaymentFailure('Hosted session created but no checkout URL was provided');
3785
- return of(void 0);
3833
+ }
3834
+ onSubmit() {
3835
+ if (this.bankTransferForm.valid && this.selectedAccount) {
3836
+ const submitData = {
3837
+ bankTransferAccountId: this.selectedAccount.id
3838
+ };
3839
+ if (this.showUserInfoForm) {
3840
+ submitData['firstName'] = this.bankTransferForm.value.firstName;
3841
+ submitData['lastName'] = this.bankTransferForm.value.lastName;
3842
+ submitData['email'] = this.bankTransferForm.value.email;
3843
+ submitData['phoneNumber'] = this.bankTransferForm.value.personalPhone;
3786
3844
  }
3787
- this.updateState({
3788
- isLoading: false,
3789
- transactionId: paymentId
3790
- });
3791
- // PR-1.2 / Angular HIGH-1: validate before redirect.
3792
- this.redirectIfAllowed(redirectUrl, 'session-created');
3793
- return of(void 0);
3845
+ this.formSubmitted.emit(submitData);
3794
3846
  }
3795
- if (this.getString(additionalData?.['action']) === 'select_payment_method') {
3796
- this.updateState({
3797
- isLoading: false,
3798
- transactionId: paymentId,
3799
- paymentInfo,
3800
- currentScreen: LokotroPayScreenNavigation.PaymentMethodSelectionScreen
3801
- });
3802
- return of(void 0);
3847
+ }
3848
+ onCancel() {
3849
+ this.cancel.emit();
3850
+ }
3851
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroBankTransferFormComponent, deps: [{ token: i1$1.FormBuilder }, { token: LokotroLocalizationService }, { token: LokotroPaymentService }], target: i0.ɵɵFactoryTarget.Component }); }
3852
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroBankTransferFormComponent, isStandalone: true, selector: "lokotro-bank-transfer-form", inputs: { showUserInfoForm: "showUserInfoForm" }, outputs: { formSubmitted: "formSubmitted", cancel: "cancel" }, ngImport: i0, template: `
3853
+ <div class="lokotro-bank-transfer-form">
3854
+ <form [formGroup]="bankTransferForm" (ngSubmit)="onSubmit()">
3855
+ <h3 class="lokotro-form-title">{{ localization.translate('bankTransfer') || 'Bank Transfer' }}</h3>
3856
+
3857
+ <!-- Personal Information Section -->
3858
+ @if (showUserInfoForm) {
3859
+ <div class="lokotro-user-info-section">
3860
+ <h4 class="lokotro-section-subtitle">{{ localization.translate('personalInfo') }}</h4>
3861
+ <div class="lokotro-form-row">
3862
+ <div class="lokotro-form-group">
3863
+ <label class="lokotro-label">{{ localization.translate('firstName') }}</label>
3864
+ <input type="text" class="lokotro-input" formControlName="firstName" placeholder="John">
3865
+ @if (bankTransferForm.get('firstName')?.touched && bankTransferForm.get('firstName')?.invalid) {
3866
+ <span class="lokotro-error">
3867
+ {{ localization.translate('required') }}
3868
+ </span>
3869
+ }
3870
+ </div>
3871
+ <div class="lokotro-form-group">
3872
+ <label class="lokotro-label">{{ localization.translate('lastName') }}</label>
3873
+ <input type="text" class="lokotro-input" formControlName="lastName" placeholder="Doe">
3874
+ @if (bankTransferForm.get('lastName')?.touched && bankTransferForm.get('lastName')?.invalid) {
3875
+ <span class="lokotro-error">
3876
+ {{ localization.translate('required') }}
3877
+ </span>
3878
+ }
3879
+ </div>
3880
+ </div>
3881
+ <div class="lokotro-form-group">
3882
+ <label class="lokotro-label">{{ localization.translate('email') }}</label>
3883
+ <input type="email" class="lokotro-input" formControlName="email" placeholder="john.doe@example.com">
3884
+ @if (bankTransferForm.get('email')?.touched && bankTransferForm.get('email')?.invalid) {
3885
+ <span class="lokotro-error">
3886
+ {{ localization.translate('invalidEmail') }}
3887
+ </span>
3888
+ }
3889
+ </div>
3890
+ <div class="lokotro-form-group">
3891
+ <label class="lokotro-label">{{ localization.translate('phoneNumber') }}</label>
3892
+ <input type="tel" class="lokotro-input" formControlName="personalPhone" placeholder="+243 XXX XXX XXX">
3893
+ @if (bankTransferForm.get('personalPhone')?.touched && bankTransferForm.get('personalPhone')?.invalid) {
3894
+ <span class="lokotro-error">
3895
+ {{ localization.translate('invalidPhoneNumber') }}
3896
+ </span>
3897
+ }
3898
+ </div>
3899
+ <hr class="lokotro-divider">
3900
+ </div>
3803
3901
  }
3804
- if (paymentStatus === 'failed' || paymentStatus === 'rejected' || paymentStatus === 'declined') {
3805
- this.handlePaymentFailure(this.getString(collectData['message']) || 'Payment failed');
3806
- return of(void 0);
3902
+
3903
+ <!-- Bank Selection Section -->
3904
+ @if (showUserInfoForm) {
3905
+ <h4 class="lokotro-section-subtitle">{{ localization.translate('bankDetails') || 'Bank Details' }}</h4>
3807
3906
  }
3808
- if (!paymentId) {
3809
- this.handlePaymentFailure('Transaction ID not received from payment collection');
3810
- return of(void 0);
3907
+
3908
+ <!-- Loading State -->
3909
+ @if (isLoading) {
3910
+ <div class="lokotro-loading-text">
3911
+ {{ localization.translate('loading') || 'Loading...' }}
3912
+ </div>
3811
3913
  }
3812
- return this.getTransactionDetails(paymentId).pipe(switchMap(response => this.resolveTransactionResponse(response, paymentBody)));
3813
- }
3814
- /**
3815
- * Resolve transaction details into the correct screen or auto-submit flow.
3816
- */
3817
- resolveTransactionResponse(response, paymentBody) {
3818
- if (!response.isSuccess || !response.data) {
3819
- this.handlePaymentFailure(response.message || 'Failed to load transaction details', response.apiResponseCode);
3820
- return of(void 0);
3914
+
3915
+ @if (!isLoading) {
3916
+ <div>
3917
+ <!-- City Selection -->
3918
+ <div class="lokotro-form-group">
3919
+ <label class="lokotro-label">{{ localization.translate('selectCity') || 'Select City' }}</label>
3920
+ <select class="lokotro-input" formControlName="city" (change)="onCityChange()">
3921
+ <option value="">{{ localization.translate('selectCity') || 'Select City' }}</option>
3922
+ @for (city of cities; track city) {
3923
+ <option [value]="city.id">{{ city.name }}</option>
3924
+ }
3925
+ </select>
3926
+ </div>
3927
+ <!-- Bank Selection -->
3928
+ <div class="lokotro-form-group">
3929
+ <label class="lokotro-label">{{ localization.translate('selectBank') || 'Select Bank' }}</label>
3930
+ <select class="lokotro-input" formControlName="bank" (change)="onBankChange()">
3931
+ <option value="">{{ localization.translate('selectBank') || 'Select Bank' }}</option>
3932
+ @for (bank of filteredBanks; track bank) {
3933
+ <option [value]="bank.id">{{ bank.name }}</option>
3934
+ }
3935
+ </select>
3936
+ </div>
3937
+ <!-- Account Selection -->
3938
+ <div class="lokotro-form-group">
3939
+ <label class="lokotro-label">{{ localization.translate('selectBankAccount') || 'Select Account' }}</label>
3940
+ <select class="lokotro-input" formControlName="account" (change)="onAccountChange()">
3941
+ <option value="">{{ localization.translate('selectBankAccount') || 'Select Account' }}</option>
3942
+ @for (account of accounts; track account) {
3943
+ <option [value]="account.id">{{ account.accountLabel }} ({{ account.accountNumber }})</option>
3944
+ }
3945
+ </select>
3946
+ </div>
3947
+ </div>
3821
3948
  }
3822
- const transactionData = this.unwrapPayload(response.data);
3823
- const paymentId = this.resolvePaymentId(transactionData);
3824
- if (!paymentId) {
3825
- this.handlePaymentFailure('Transaction ID not found in transaction details');
3826
- return of(void 0);
3949
+
3950
+ <!-- Selected Account Summary -->
3951
+ @if (selectedAccount) {
3952
+ <div class="lokotro-account-summary">
3953
+ <div class="lokotro-info-box">
3954
+ <p><strong>{{ localization.translate('bankAccountSummary') || 'Account Number' }}:</strong> {{ selectedAccount.accountNumber }}</p>
3955
+ <p><strong>{{ localization.translate('accountLabel') || 'Label' }}:</strong> {{ selectedAccount.accountLabel }}</p>
3956
+ <p class="lokotro-info-text">
3957
+ {{ localization.translate('bankTransferProofInstructions') || 'A payment link will be sent to your email to upload the proof of transfer.' }}
3958
+ </p>
3959
+ </div>
3960
+ </div>
3827
3961
  }
3828
- const paymentInfo = this.parseTransactionPaymentInfo(transactionData);
3829
- const selectedPaymentMethod = this.resolveSelectedPaymentMethod(transactionData, paymentInfo);
3830
- if (!paymentInfo.showUserInfoForm && !paymentInfo.showPaymentMethodForm) {
3831
- this.updateState({
3832
- transactionId: paymentId,
3833
- paymentInfo,
3834
- selectedPaymentMethod
3835
- });
3836
- if (selectedPaymentMethod?.channel === LokotroPayChannel.Card) {
3837
- const hostedCheckoutUrl = this.getString(transactionData['mastercardUrl']) ||
3838
- this.getString(transactionData['redirect_url']);
3839
- if (hostedCheckoutUrl) {
3840
- this.updateState({ isLoading: false });
3841
- // PR-1.2 / Angular HIGH-1: validate hosted-checkout URL.
3842
- this.redirectIfAllowed(hostedCheckoutUrl, 'hosted-checkout');
3843
- return of(void 0);
3962
+
3963
+ <div class="lokotro-form-actions">
3964
+ <button type="button" class="lokotro-btn-secondary" (click)="onCancel()">
3965
+ {{ localization.translate('cancel') }}
3966
+ </button>
3967
+ <button type="submit" class="lokotro-btn-primary" [disabled]="bankTransferForm.invalid || isLoading">
3968
+ {{ localization.translate('confirmBankTransfer') || 'Confirm Transfer' }}
3969
+ </button>
3970
+ </div>
3971
+ </form>
3972
+ </div>
3973
+ `, isInline: true, styles: [".lokotro-bank-transfer-form{width:100%}.lokotro-form-title{font-size:20px;font-weight:600;margin:0 0 24px;color:var(--lokotro-text-primary, #F2F0D5);text-align:center}.lokotro-form-group{margin-bottom:20px}.lokotro-form-row{display:flex;gap:16px}.lokotro-form-row .lokotro-form-group{flex:1}.lokotro-label{display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--lokotro-text-secondary, #D5D3B8)}.lokotro-input{width:100%;padding:14px 16px;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);font-size:16px;transition:border-color .2s;box-sizing:border-box}.lokotro-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-error{display:block;margin-top:6px;font-size:12px;color:var(--lokotro-error, #D97652)}.lokotro-user-info-section{margin-bottom:24px}.lokotro-section-subtitle{font-size:16px;font-weight:600;margin:0 0 16px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-divider{border:none;border-top:1px solid var(--lokotro-border, #3A473F);margin:24px 0}.lokotro-form-actions{display:flex;gap:12px;margin-top:32px}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}.lokotro-loading-text{text-align:center;color:var(--lokotro-text-secondary, #D5D3B8);padding:20px}.lokotro-account-summary{margin-top:20px;padding:16px;background:#3a484080;border-radius:12px;border:1px solid var(--lokotro-border, #3A473F)}.lokotro-info-box p{margin:8px 0;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-info-text{font-style:italic;color:var(--lokotro-text-secondary, #D5D3B8);font-size:14px;margin-top:12px!important}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }] }); }
3974
+ }
3975
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroBankTransferFormComponent, decorators: [{
3976
+ type: Component,
3977
+ args: [{ selector: 'lokotro-bank-transfer-form', standalone: true, imports: [FormsModule, ReactiveFormsModule], template: `
3978
+ <div class="lokotro-bank-transfer-form">
3979
+ <form [formGroup]="bankTransferForm" (ngSubmit)="onSubmit()">
3980
+ <h3 class="lokotro-form-title">{{ localization.translate('bankTransfer') || 'Bank Transfer' }}</h3>
3981
+
3982
+ <!-- Personal Information Section -->
3983
+ @if (showUserInfoForm) {
3984
+ <div class="lokotro-user-info-section">
3985
+ <h4 class="lokotro-section-subtitle">{{ localization.translate('personalInfo') }}</h4>
3986
+ <div class="lokotro-form-row">
3987
+ <div class="lokotro-form-group">
3988
+ <label class="lokotro-label">{{ localization.translate('firstName') }}</label>
3989
+ <input type="text" class="lokotro-input" formControlName="firstName" placeholder="John">
3990
+ @if (bankTransferForm.get('firstName')?.touched && bankTransferForm.get('firstName')?.invalid) {
3991
+ <span class="lokotro-error">
3992
+ {{ localization.translate('required') }}
3993
+ </span>
3844
3994
  }
3845
- }
3846
- const paymentMethodId = selectedPaymentMethod?.id || this.getString(transactionData['payment_method_id']);
3847
- if (!paymentMethodId) {
3848
- this.handlePaymentFailure('Payment method not found for auto-processing');
3849
- return of(void 0);
3850
- }
3851
- return this.submitPaymentDetails(this.buildAutoSubmitRequest(paymentId, paymentMethodId, paymentBody)).pipe(map(() => void 0));
3995
+ </div>
3996
+ <div class="lokotro-form-group">
3997
+ <label class="lokotro-label">{{ localization.translate('lastName') }}</label>
3998
+ <input type="text" class="lokotro-input" formControlName="lastName" placeholder="Doe">
3999
+ @if (bankTransferForm.get('lastName')?.touched && bankTransferForm.get('lastName')?.invalid) {
4000
+ <span class="lokotro-error">
4001
+ {{ localization.translate('required') }}
4002
+ </span>
4003
+ }
4004
+ </div>
4005
+ </div>
4006
+ <div class="lokotro-form-group">
4007
+ <label class="lokotro-label">{{ localization.translate('email') }}</label>
4008
+ <input type="email" class="lokotro-input" formControlName="email" placeholder="john.doe@example.com">
4009
+ @if (bankTransferForm.get('email')?.touched && bankTransferForm.get('email')?.invalid) {
4010
+ <span class="lokotro-error">
4011
+ {{ localization.translate('invalidEmail') }}
4012
+ </span>
4013
+ }
4014
+ </div>
4015
+ <div class="lokotro-form-group">
4016
+ <label class="lokotro-label">{{ localization.translate('phoneNumber') }}</label>
4017
+ <input type="tel" class="lokotro-input" formControlName="personalPhone" placeholder="+243 XXX XXX XXX">
4018
+ @if (bankTransferForm.get('personalPhone')?.touched && bankTransferForm.get('personalPhone')?.invalid) {
4019
+ <span class="lokotro-error">
4020
+ {{ localization.translate('invalidPhoneNumber') }}
4021
+ </span>
4022
+ }
4023
+ </div>
4024
+ <hr class="lokotro-divider">
4025
+ </div>
3852
4026
  }
3853
- const nextScreen = paymentInfo.showPaymentMethodForm
3854
- ? LokotroPayScreenNavigation.PaymentMethodSelectionScreen
3855
- : this.getFormScreenForChannel(selectedPaymentMethod?.channel || this.parseChannel(this.getString(transactionData['channel'])));
3856
- this.updateState({
3857
- isLoading: false,
3858
- transactionId: paymentId,
3859
- paymentInfo,
3860
- selectedPaymentMethod,
3861
- currentScreen: nextScreen
3862
- });
3863
- return of(void 0);
4027
+
4028
+ <!-- Bank Selection Section -->
4029
+ @if (showUserInfoForm) {
4030
+ <h4 class="lokotro-section-subtitle">{{ localization.translate('bankDetails') || 'Bank Details' }}</h4>
4031
+ }
4032
+
4033
+ <!-- Loading State -->
4034
+ @if (isLoading) {
4035
+ <div class="lokotro-loading-text">
4036
+ {{ localization.translate('loading') || 'Loading...' }}
4037
+ </div>
4038
+ }
4039
+
4040
+ @if (!isLoading) {
4041
+ <div>
4042
+ <!-- City Selection -->
4043
+ <div class="lokotro-form-group">
4044
+ <label class="lokotro-label">{{ localization.translate('selectCity') || 'Select City' }}</label>
4045
+ <select class="lokotro-input" formControlName="city" (change)="onCityChange()">
4046
+ <option value="">{{ localization.translate('selectCity') || 'Select City' }}</option>
4047
+ @for (city of cities; track city) {
4048
+ <option [value]="city.id">{{ city.name }}</option>
4049
+ }
4050
+ </select>
4051
+ </div>
4052
+ <!-- Bank Selection -->
4053
+ <div class="lokotro-form-group">
4054
+ <label class="lokotro-label">{{ localization.translate('selectBank') || 'Select Bank' }}</label>
4055
+ <select class="lokotro-input" formControlName="bank" (change)="onBankChange()">
4056
+ <option value="">{{ localization.translate('selectBank') || 'Select Bank' }}</option>
4057
+ @for (bank of filteredBanks; track bank) {
4058
+ <option [value]="bank.id">{{ bank.name }}</option>
4059
+ }
4060
+ </select>
4061
+ </div>
4062
+ <!-- Account Selection -->
4063
+ <div class="lokotro-form-group">
4064
+ <label class="lokotro-label">{{ localization.translate('selectBankAccount') || 'Select Account' }}</label>
4065
+ <select class="lokotro-input" formControlName="account" (change)="onAccountChange()">
4066
+ <option value="">{{ localization.translate('selectBankAccount') || 'Select Account' }}</option>
4067
+ @for (account of accounts; track account) {
4068
+ <option [value]="account.id">{{ account.accountLabel }} ({{ account.accountNumber }})</option>
4069
+ }
4070
+ </select>
4071
+ </div>
4072
+ </div>
4073
+ }
4074
+
4075
+ <!-- Selected Account Summary -->
4076
+ @if (selectedAccount) {
4077
+ <div class="lokotro-account-summary">
4078
+ <div class="lokotro-info-box">
4079
+ <p><strong>{{ localization.translate('bankAccountSummary') || 'Account Number' }}:</strong> {{ selectedAccount.accountNumber }}</p>
4080
+ <p><strong>{{ localization.translate('accountLabel') || 'Label' }}:</strong> {{ selectedAccount.accountLabel }}</p>
4081
+ <p class="lokotro-info-text">
4082
+ {{ localization.translate('bankTransferProofInstructions') || 'A payment link will be sent to your email to upload the proof of transfer.' }}
4083
+ </p>
4084
+ </div>
4085
+ </div>
4086
+ }
4087
+
4088
+ <div class="lokotro-form-actions">
4089
+ <button type="button" class="lokotro-btn-secondary" (click)="onCancel()">
4090
+ {{ localization.translate('cancel') }}
4091
+ </button>
4092
+ <button type="submit" class="lokotro-btn-primary" [disabled]="bankTransferForm.invalid || isLoading">
4093
+ {{ localization.translate('confirmBankTransfer') || 'Confirm Transfer' }}
4094
+ </button>
4095
+ </div>
4096
+ </form>
4097
+ </div>
4098
+ `, styles: [".lokotro-bank-transfer-form{width:100%}.lokotro-form-title{font-size:20px;font-weight:600;margin:0 0 24px;color:var(--lokotro-text-primary, #F2F0D5);text-align:center}.lokotro-form-group{margin-bottom:20px}.lokotro-form-row{display:flex;gap:16px}.lokotro-form-row .lokotro-form-group{flex:1}.lokotro-label{display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--lokotro-text-secondary, #D5D3B8)}.lokotro-input{width:100%;padding:14px 16px;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);font-size:16px;transition:border-color .2s;box-sizing:border-box}.lokotro-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-error{display:block;margin-top:6px;font-size:12px;color:var(--lokotro-error, #D97652)}.lokotro-user-info-section{margin-bottom:24px}.lokotro-section-subtitle{font-size:16px;font-weight:600;margin:0 0 16px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-divider{border:none;border-top:1px solid var(--lokotro-border, #3A473F);margin:24px 0}.lokotro-form-actions{display:flex;gap:12px;margin-top:32px}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}.lokotro-loading-text{text-align:center;color:var(--lokotro-text-secondary, #D5D3B8);padding:20px}.lokotro-account-summary{margin-top:20px;padding:16px;background:#3a484080;border-radius:12px;border:1px solid var(--lokotro-border, #3A473F)}.lokotro-info-box p{margin:8px 0;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-info-text{font-style:italic;color:var(--lokotro-text-secondary, #D5D3B8);font-size:14px;margin-top:12px!important}\n"] }]
4099
+ }], ctorParameters: () => [{ type: i1$1.FormBuilder }, { type: LokotroLocalizationService }, { type: LokotroPaymentService }], propDecorators: { showUserInfoForm: [{
4100
+ type: Input
4101
+ }], formSubmitted: [{
4102
+ type: Output
4103
+ }], cancel: [{
4104
+ type: Output
4105
+ }] } });
4106
+
4107
+ /**
4108
+ * Lokotro Pay - OTP Verification Component
4109
+ */
4110
+ class LokotroOtpVerificationComponent {
4111
+ constructor(localization) {
4112
+ this.localization = localization;
4113
+ this.otpLength = 6;
4114
+ this.otpVerified = new EventEmitter();
4115
+ this.resendOtp = new EventEmitter();
4116
+ this.cancel = new EventEmitter();
4117
+ this.otpDigits = [];
4118
+ this.resendTimer = 60;
3864
4119
  }
3865
- /**
3866
- * Get form screen for payment channel
3867
- */
3868
- getFormScreenForChannel(channel) {
3869
- switch (channel) {
3870
- case LokotroPayChannel.EWallet:
3871
- case LokotroPayChannel.LokotroWallet:
3872
- return LokotroPayScreenNavigation.EWalletFormScreen;
3873
- case LokotroPayChannel.MobileMoney:
3874
- return LokotroPayScreenNavigation.MobileMoneyFormScreen;
3875
- case LokotroPayChannel.Card:
3876
- case LokotroPayChannel.VirtualCard:
3877
- return LokotroPayScreenNavigation.CardFormScreen;
3878
- case LokotroPayChannel.EFlash:
3879
- return LokotroPayScreenNavigation.FlashFormScreen;
3880
- default:
3881
- return LokotroPayScreenNavigation.PaymentFormScreen;
4120
+ ngOnInit() {
4121
+ this.otpDigits = new Array(this.otpLength).fill('');
4122
+ this.startResendTimer();
4123
+ }
4124
+ ngOnDestroy() {
4125
+ if (this.timerInterval) {
4126
+ clearInterval(this.timerInterval);
3882
4127
  }
3883
4128
  }
3884
- /**
3885
- * Convert payment body to API request format
3886
- */
3887
- convertPaymentBodyToRequest(body) {
3888
- const request = {
3889
- payment_method: body.paymentMethod || 'wallet',
3890
- user_info: body.userInfo || 'full',
3891
- payment_method_info: body.paymentMethodInfo || 'full',
3892
- fee_covered_by: body.feeCoveredBy || 'buyer',
3893
- delivery_behaviour: body.deliveryBehaviour || 'direct_delivery'
3894
- };
3895
- // Auth-v3 refactor: customer_reference / amount / currency are bound
3896
- // on the session_token. The gateway reads them straight from the
3897
- // session — sending them again on the wire is redundant, and was
3898
- // the source of the `"1.00"` vs `"1"` formatting drift that 400'd
3899
- // body-binding. Only include them when the merchant explicitly set
3900
- // them (legacy / display reasons). The gateway treats them as a
3901
- // soft check when present, ignores when absent.
3902
- if (body.customerReference)
3903
- request['customer_reference'] = body.customerReference;
3904
- if (body.amount)
3905
- request['amount'] = body.amount;
3906
- if (body.currency)
3907
- request['currency'] = body.currency.toLowerCase();
3908
- if (body.notifyUrl)
3909
- request['notify_url'] = body.notifyUrl;
3910
- if (body.successRedirectUrl)
3911
- request['success_redirect_url'] = body.successRedirectUrl;
3912
- if (body.failRedirectUrl)
3913
- request['fail_redirect_url'] = body.failRedirectUrl;
3914
- if (body.userInfo === 'full') {
3915
- if (body.firstName)
3916
- request['first_name'] = body.firstName;
3917
- if (body.lastName)
3918
- request['last_name'] = body.lastName;
3919
- if (body.phoneNumber)
3920
- request['phone_number'] = body.phoneNumber;
3921
- if (body.email)
3922
- request['email'] = body.email;
4129
+ get isOtpComplete() {
4130
+ return this.otpDigits.every(digit => digit !== '');
4131
+ }
4132
+ get otpValue() {
4133
+ return this.otpDigits.join('');
4134
+ }
4135
+ startResendTimer() {
4136
+ this.resendTimer = 60;
4137
+ this.timerInterval = setInterval(() => {
4138
+ this.resendTimer--;
4139
+ if (this.resendTimer <= 0) {
4140
+ clearInterval(this.timerInterval);
4141
+ }
4142
+ }, 1000);
4143
+ }
4144
+ onOtpInput(event, index) {
4145
+ const input = event.target;
4146
+ const value = input.value.replace(/\D/g, '');
4147
+ if (value) {
4148
+ this.otpDigits[index] = value[0];
4149
+ input.value = value[0];
4150
+ // Focus next input
4151
+ if (index < this.otpLength - 1) {
4152
+ const inputs = document.querySelectorAll('.lokotro-otp-input');
4153
+ inputs[index + 1]?.focus();
4154
+ }
3923
4155
  }
3924
- if (body.paymentMethodInfo === 'full') {
3925
- if (body.walletNumber)
3926
- request['wallet_number'] = body.walletNumber;
3927
- if (body.walletPin)
3928
- request['wallet_pin'] = body.walletPin;
3929
- if (body.mobileMoneyPhoneNumber)
3930
- request['mobile_money_phone_number'] = body.mobileMoneyPhoneNumber;
3931
- if (body.flashNumber)
3932
- request['flash_number'] = body.flashNumber;
3933
- if (body.flashPin)
3934
- request['flash_pin'] = body.flashPin;
3935
- if (body.cardNumber)
3936
- request['card_number'] = body.cardNumber;
3937
- if (body.cardExpiryDate)
3938
- request['card_expiry_date'] = body.cardExpiryDate;
3939
- if (body.cardCvv)
3940
- request['card_cvv'] = body.cardCvv;
3941
- if (body.cardHolderName)
3942
- request['card_holder_name'] = body.cardHolderName;
4156
+ else {
4157
+ this.otpDigits[index] = '';
3943
4158
  }
3944
- if (body.merchant) {
3945
- request['merchant'] = {
3946
- name: body.merchant.name,
3947
- url: body.merchant.website,
3948
- logo: body.merchant.logoUrl
3949
- };
4159
+ }
4160
+ onOtpKeydown(event, index) {
4161
+ if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
4162
+ const inputs = document.querySelectorAll('.lokotro-otp-input');
4163
+ inputs[index - 1]?.focus();
3950
4164
  }
3951
- if (body.mastercardPaymentMethod) {
3952
- request['mastercard_payment_method'] = body.mastercardPaymentMethod;
4165
+ }
4166
+ onOtpPaste(event) {
4167
+ event.preventDefault();
4168
+ const pastedData = event.clipboardData?.getData('text').replace(/\D/g, '');
4169
+ if (pastedData) {
4170
+ for (let i = 0; i < Math.min(pastedData.length, this.otpLength); i++) {
4171
+ this.otpDigits[i] = pastedData[i];
4172
+ }
4173
+ const inputs = document.querySelectorAll('.lokotro-otp-input');
4174
+ inputs.forEach((input, i) => {
4175
+ input.value = this.otpDigits[i] || '';
4176
+ });
4177
+ // Focus last filled input or first empty
4178
+ const focusIndex = Math.min(pastedData.length, this.otpLength - 1);
4179
+ inputs[focusIndex]?.focus();
3953
4180
  }
3954
- if (body.transactionalCurrency) {
3955
- request['transactional_currency'] = body.transactionalCurrency.toLowerCase();
4181
+ }
4182
+ onVerify() {
4183
+ if (this.isOtpComplete) {
4184
+ this.otpVerified.emit(this.otpValue);
3956
4185
  }
3957
- if (body.nativePayToken) {
3958
- request['native_pay_token'] = body.nativePayToken;
4186
+ }
4187
+ onResend() {
4188
+ this.otpDigits = new Array(this.otpLength).fill('');
4189
+ this.startResendTimer();
4190
+ this.resendOtp.emit();
4191
+ }
4192
+ onCancelClick() {
4193
+ this.cancel.emit();
4194
+ }
4195
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroOtpVerificationComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
4196
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroOtpVerificationComponent, isStandalone: true, selector: "lokotro-otp-verification", inputs: { transactionId: "transactionId", otpDestination: "otpDestination", otpLength: "otpLength" }, outputs: { otpVerified: "otpVerified", resendOtp: "resendOtp", cancel: "cancel" }, ngImport: i0, template: `
4197
+ <div class="lokotro-otp-verification">
4198
+ <div class="lokotro-otp-icon">
4199
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
4200
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
4201
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
4202
+ </svg>
4203
+ </div>
4204
+
4205
+ <h3 class="lokotro-otp-title">{{ localization.translate('enterOtp') }}</h3>
4206
+ @if (otpDestination) {
4207
+ <p class="lokotro-otp-subtitle">
4208
+ {{ localization.translate('otpSentTo') }} {{ otpDestination }}
4209
+ </p>
4210
+ }
4211
+
4212
+ <div class="lokotro-otp-inputs">
4213
+ @for (digit of otpDigits; track digit; let i = $index) {
4214
+ <input
4215
+ type="text"
4216
+ class="lokotro-otp-input"
4217
+ maxlength="1"
4218
+ [value]="otpDigits[i]"
4219
+ (input)="onOtpInput($event, i)"
4220
+ (keydown)="onOtpKeydown($event, i)"
4221
+ (paste)="onOtpPaste($event)"
4222
+ #otpInput>
3959
4223
  }
3960
- if (body.nativePayTokenType) {
3961
- request['native_pay_token_type'] = body.nativePayTokenType;
4224
+ </div>
4225
+
4226
+ @if (resendTimer > 0) {
4227
+ <div class="lokotro-otp-timer">
4228
+ Resend OTP in {{ resendTimer }}s
4229
+ </div>
4230
+ }
4231
+
4232
+ @if (resendTimer === 0) {
4233
+ <button
4234
+ class="lokotro-resend-btn"
4235
+ (click)="onResend()">
4236
+ {{ localization.translate('resendOtp') }}
4237
+ </button>
4238
+ }
4239
+
4240
+ <div class="lokotro-form-actions">
4241
+ <button type="button" class="lokotro-btn-secondary" (click)="onCancelClick()">
4242
+ {{ localization.translate('cancel') }}
4243
+ </button>
4244
+ <button
4245
+ type="button"
4246
+ class="lokotro-btn-primary"
4247
+ [disabled]="!isOtpComplete"
4248
+ (click)="onVerify()">
4249
+ {{ localization.translate('verifyOtp') }}
4250
+ </button>
4251
+ </div>
4252
+ </div>
4253
+ `, isInline: true, styles: [".lokotro-otp-verification{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px 0}.lokotro-otp-icon{width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--lokotro-card, #3A4840);border-radius:50%;margin-bottom:24px;color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-title{font-size:24px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-otp-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-otp-inputs{display:flex;gap:12px;margin-bottom:24px}.lokotro-otp-input{width:50px;height:56px;text-align:center;font-size:24px;font-weight:600;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);transition:border-color .2s}.lokotro-otp-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-timer{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin-bottom:24px}.lokotro-resend-btn{background:none;border:none;color:var(--lokotro-accent, #3BFBDA);font-size:14px;font-weight:600;cursor:pointer;margin-bottom:24px;text-decoration:underline}.lokotro-resend-btn:hover{opacity:.8}.lokotro-form-actions{display:flex;gap:12px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }] }); }
4254
+ }
4255
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroOtpVerificationComponent, decorators: [{
4256
+ type: Component,
4257
+ args: [{ selector: 'lokotro-otp-verification', standalone: true, imports: [FormsModule], template: `
4258
+ <div class="lokotro-otp-verification">
4259
+ <div class="lokotro-otp-icon">
4260
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
4261
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
4262
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
4263
+ </svg>
4264
+ </div>
4265
+
4266
+ <h3 class="lokotro-otp-title">{{ localization.translate('enterOtp') }}</h3>
4267
+ @if (otpDestination) {
4268
+ <p class="lokotro-otp-subtitle">
4269
+ {{ localization.translate('otpSentTo') }} {{ otpDestination }}
4270
+ </p>
4271
+ }
4272
+
4273
+ <div class="lokotro-otp-inputs">
4274
+ @for (digit of otpDigits; track digit; let i = $index) {
4275
+ <input
4276
+ type="text"
4277
+ class="lokotro-otp-input"
4278
+ maxlength="1"
4279
+ [value]="otpDigits[i]"
4280
+ (input)="onOtpInput($event, i)"
4281
+ (keydown)="onOtpKeydown($event, i)"
4282
+ (paste)="onOtpPaste($event)"
4283
+ #otpInput>
3962
4284
  }
3963
- if (body.metadata) {
3964
- request['metadata'] = body.metadata;
4285
+ </div>
4286
+
4287
+ @if (resendTimer > 0) {
4288
+ <div class="lokotro-otp-timer">
4289
+ Resend OTP in {{ resendTimer }}s
4290
+ </div>
4291
+ }
4292
+
4293
+ @if (resendTimer === 0) {
4294
+ <button
4295
+ class="lokotro-resend-btn"
4296
+ (click)="onResend()">
4297
+ {{ localization.translate('resendOtp') }}
4298
+ </button>
4299
+ }
4300
+
4301
+ <div class="lokotro-form-actions">
4302
+ <button type="button" class="lokotro-btn-secondary" (click)="onCancelClick()">
4303
+ {{ localization.translate('cancel') }}
4304
+ </button>
4305
+ <button
4306
+ type="button"
4307
+ class="lokotro-btn-primary"
4308
+ [disabled]="!isOtpComplete"
4309
+ (click)="onVerify()">
4310
+ {{ localization.translate('verifyOtp') }}
4311
+ </button>
4312
+ </div>
4313
+ </div>
4314
+ `, styles: [".lokotro-otp-verification{display:flex;flex-direction:column;align-items:center;text-align:center;padding:24px 0}.lokotro-otp-icon{width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--lokotro-card, #3A4840);border-radius:50%;margin-bottom:24px;color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-title{font-size:24px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-otp-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-otp-inputs{display:flex;gap:12px;margin-bottom:24px}.lokotro-otp-input{width:50px;height:56px;text-align:center;font-size:24px;font-weight:600;background:var(--lokotro-card, #3A4840);border:2px solid var(--lokotro-border, #3A473F);border-radius:12px;color:var(--lokotro-text-primary, #F2F0D5);transition:border-color .2s}.lokotro-otp-input:focus{outline:none;border-color:var(--lokotro-accent, #3BFBDA)}.lokotro-otp-timer{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin-bottom:24px}.lokotro-resend-btn{background:none;border:none;color:var(--lokotro-accent, #3BFBDA);font-size:14px;font-weight:600;cursor:pointer;margin-bottom:24px;text-decoration:underline}.lokotro-resend-btn:hover{opacity:.8}.lokotro-form-actions{display:flex;gap:12px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{flex:1;padding:14px 24px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer;transition:all .2s ease}.lokotro-btn-primary{background:linear-gradient(135deg,var(--lokotro-primary, #5A5E39),var(--lokotro-secondary, #6E7346));color:var(--lokotro-text-primary, #F2F0D5);border:none}.lokotro-btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 16px #0003}.lokotro-btn-primary:disabled{opacity:.5;cursor:not-allowed}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #F2F0D5);border:2px solid var(--lokotro-border, #3A473F)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #D5D3B8)}\n"] }]
4315
+ }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { transactionId: [{
4316
+ type: Input
4317
+ }], otpDestination: [{
4318
+ type: Input
4319
+ }], otpLength: [{
4320
+ type: Input
4321
+ }], otpVerified: [{
4322
+ type: Output
4323
+ }], resendOtp: [{
4324
+ type: Output
4325
+ }], cancel: [{
4326
+ type: Output
4327
+ }] } });
4328
+
4329
+ /**
4330
+ * Lokotro Pay - Processing Component
4331
+ */
4332
+ class LokotroProcessingComponent {
4333
+ constructor(localization) {
4334
+ this.localization = localization;
4335
+ this.type = 'default';
4336
+ this.currentStep = 1;
4337
+ // Simulate step progression for mobile money
4338
+ if (this.type === 'mobileMoney') {
4339
+ setTimeout(() => this.currentStep = 2, 3000);
4340
+ setTimeout(() => this.currentStep = 3, 6000);
3965
4341
  }
3966
- return request;
3967
- }
3968
- /**
3969
- * Convert submit request to API format
3970
- */
3971
- convertSubmitRequestToData(request) {
3972
- const data = {
3973
- payment_id: this.resolvePaymentId(request),
3974
- payment_method_id: request.paymentMethodId || request.paymentMethod || ''
3975
- };
3976
- if (request.firstName)
3977
- data['first_name'] = request.firstName;
3978
- if (request.lastName)
3979
- data['last_name'] = request.lastName;
3980
- if (request.email)
3981
- data['email'] = request.email;
3982
- if (request.phoneNumber)
3983
- data['phone_number'] = request.phoneNumber;
3984
- if (request.walletNumber)
3985
- data['ewallet_number'] = request.walletNumber;
3986
- if (request.walletPin)
3987
- data['ewallet_pin'] = request.walletPin;
3988
- if (request.mobileMoneyPhoneNumber)
3989
- data['mobile_money_phone_number'] = request.mobileMoneyPhoneNumber;
3990
- if (request.flashNumber)
3991
- data['flash_number'] = request.flashNumber;
3992
- if (request.flashPin)
3993
- data['flash_pin'] = request.flashPin;
3994
- if (request.cardNumber)
3995
- data['card_number'] = request.cardNumber;
3996
- if (request.cardExpiryDate)
3997
- data['card_expiry_date'] = request.cardExpiryDate;
3998
- if (request.cardCvv)
3999
- data['card_cvv'] = request.cardCvv;
4000
- if (request.cardHolderName)
4001
- data['card_holder_name'] = request.cardHolderName;
4002
- if (request.bankTransferAccountId)
4003
- data['bank_account_id'] = request.bankTransferAccountId;
4004
- if (request.bankAccountId)
4005
- data['bank_account_id'] = request.bankAccountId;
4006
- if (request.bankAccountNumber)
4007
- data['bank_account_number'] = request.bankAccountNumber;
4008
- if (request.bankAccountLabel)
4009
- data['bank_account_label'] = request.bankAccountLabel;
4010
- if (request.bankId)
4011
- data['bank_id'] = request.bankId;
4012
- if (request.bankEntityId)
4013
- data['bank_entity_id'] = request.bankEntityId;
4014
- if (request.mastercardPaymentMethod)
4015
- data['mastercard_payment_method'] = request.mastercardPaymentMethod;
4016
- if (request.sessionId)
4017
- data['session_id'] = request.sessionId;
4018
- if (request.cardData)
4019
- data['card_data'] = request.cardData;
4020
- if (typeof request.savePaymentMethod === 'boolean')
4021
- data['save_payment_method'] = request.savePaymentMethod;
4022
- return data;
4023
- }
4024
- /**
4025
- * Parse payment info from collect response.
4026
- */
4027
- parsePaymentInfo(data) {
4028
- const additionalData = this.asRecord(data['additional_data']);
4029
- // Drop feature-flag-hidden methods (e.g. Google/Apple Pay) so they never
4030
- // enter the payment info model, get rendered, or get auto-selected.
4031
- const availablePaymentMethods = visiblePaymentMethods(this.getRecordArray(data['available_payment_methods'] || additionalData?.['payment_methods'])
4032
- .map(method => this.parsePaymentMethod(method)));
4033
- const action = this.getString(additionalData?.['action']);
4034
- return {
4035
- id: this.resolvePaymentId(data),
4036
- amount: this.getNumber(data['amount']),
4037
- currency: this.getString(data['currency']) || 'USD',
4038
- description: this.getString(data['description']),
4039
- merchantName: this.getString(data['merchant_name']),
4040
- merchantId: this.getString(data['merchant_id']),
4041
- availablePaymentMethods,
4042
- createdAt: new Date(this.getString(data['created_at']) || Date.now()),
4043
- expiresAt: this.getString(data['expires_at']) ? new Date(this.getString(data['expires_at'])) : undefined,
4044
- metadata: this.asRecord(data['metadata']),
4045
- paymentUrl: this.getString(data['payment_url']) || undefined,
4046
- showUserInfoForm: this.getBoolean(data['show_user_info_form']) || action === 'select_payment_method',
4047
- showPaymentMethodForm: this.getBoolean(data['show_payment_method_form']) || action === 'select_payment_method'
4048
- };
4049
- }
4050
- /**
4051
- * Parse payment info from transaction details response.
4052
- */
4053
- parseTransactionPaymentInfo(data) {
4054
- const fillingInfo = this.getString(data['filling_info']) || 'none';
4055
- const channelInfo = this.getString(data['channel_info']) || 'none';
4056
- return {
4057
- id: this.resolvePaymentId(data),
4058
- amount: this.getNumber(data['transactional_amount'] ?? data['amount']),
4059
- currency: this.getString(data['transactional_currency']) || this.getString(data['currency_str']) || 'USD',
4060
- description: this.getString(data['description']),
4061
- merchantName: this.getString(data['merchant_name']),
4062
- merchantId: this.getString(data['merchant_id']),
4063
- availablePaymentMethods: visiblePaymentMethods(this.getRecordArray(data['payment_methods']).map(method => this.parsePaymentMethod(method))),
4064
- createdAt: new Date(this.getString(data['created_at']) || Date.now()),
4065
- metadata: this.asRecord(data['metadata']),
4066
- paymentUrl: this.getString(data['payment_url']) || undefined,
4067
- showUserInfoForm: fillingInfo === 'none',
4068
- showPaymentMethodForm: channelInfo === 'none',
4069
- fillingInfo,
4070
- channelInfo
4071
- };
4072
4342
  }
4073
- /**
4074
- * Parse payment method from API response.
4075
- */
4076
- parsePaymentMethod(data) {
4077
- const nestedChannel = this.asRecord(data['channel']);
4078
- const rawChannel = this.getString(nestedChannel?.['name']) ||
4079
- this.getString(data['group']) ||
4080
- this.getString(data['method']) ||
4081
- this.getString(data['channel']) ||
4082
- this.getString(data['name']);
4083
- const name = this.getString(data['name']) || this.getString(data['method']) || rawChannel;
4084
- return {
4085
- id: this.getString(data['id']) || this.getString(data['payment_method_id']) || name,
4086
- name,
4087
- displayName: this.getString(data['display_name']) || name,
4088
- channel: this.parseChannel(rawChannel),
4089
- // PR-3.0 — pull the provider flag through. Legacy responses omit it →
4090
- // remains undefined, consumers fall back to channel-level branching.
4091
- flag: this.parsePaymentMethodFlag(this.getString(data['flag'])),
4092
- iconUrl: this.getString(data['icon_url']) || this.getString(data['icon']),
4093
- isEnabled: this.getBoolean(data['is_enabled'], this.getBoolean(data['available'], true)),
4094
- configuration: this.asRecord(data['configuration']),
4095
- supportedCurrencies: Array.isArray(data['supported_currencies'])
4096
- ? data['supported_currencies']
4097
- : undefined
4098
- };
4343
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroProcessingComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
4344
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroProcessingComponent, isStandalone: true, selector: "lokotro-processing", inputs: { type: "type", message: "message" }, ngImport: i0, template: `
4345
+ <div class="lokotro-processing">
4346
+ <div class="lokotro-processing-spinner">
4347
+ <div class="lokotro-spinner-ring"></div>
4348
+ <div class="lokotro-spinner-ring"></div>
4349
+ <div class="lokotro-spinner-ring"></div>
4350
+ </div>
4351
+
4352
+ <h3 class="lokotro-processing-title">{{ message || localization.translate('processing') }}</h3>
4353
+
4354
+ @if (type === 'mobileMoney') {
4355
+ <p class="lokotro-processing-subtitle">
4356
+ Please confirm the payment on your mobile device
4357
+ </p>
4358
+ }
4359
+
4360
+ @if (type === 'mobileMoney') {
4361
+ <div class="lokotro-processing-steps">
4362
+ <div class="lokotro-step" [class.active]="currentStep >= 1">
4363
+ <div class="lokotro-step-icon">📱</div>
4364
+ <span>Check your phone</span>
4365
+ </div>
4366
+ <div class="lokotro-step" [class.active]="currentStep >= 2">
4367
+ <div class="lokotro-step-icon">🔐</div>
4368
+ <span>Enter your PIN</span>
4369
+ </div>
4370
+ <div class="lokotro-step" [class.active]="currentStep >= 3">
4371
+ <div class="lokotro-step-icon">✅</div>
4372
+ <span>Confirm payment</span>
4373
+ </div>
4374
+ </div>
4375
+ }
4376
+ </div>
4377
+ `, isInline: true, styles: [".lokotro-processing{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 24px;min-height:300px}.lokotro-processing-spinner{position:relative;width:80px;height:80px;margin-bottom:32px}.lokotro-spinner-ring{position:absolute;width:100%;height:100%;border:3px solid transparent;border-radius:50%;animation:lokotro-spin 1.5s linear infinite}.lokotro-spinner-ring:nth-child(1){border-top-color:var(--lokotro-accent, #3BFBDA);animation-delay:0s}.lokotro-spinner-ring:nth-child(2){border-right-color:var(--lokotro-primary, #5A5E39);animation-delay:.15s;width:70%;height:70%;top:15%;left:15%}.lokotro-spinner-ring:nth-child(3){border-bottom-color:var(--lokotro-secondary, #6E7346);animation-delay:.3s;width:50%;height:50%;top:25%;left:25%}@keyframes lokotro-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.lokotro-processing-title{font-size:20px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-processing-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-processing-steps{display:flex;flex-direction:column;gap:16px;width:100%;max-width:280px}.lokotro-step{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--lokotro-card, #3A4840);border-radius:12px;opacity:.5;transition:opacity .3s}.lokotro-step.active{opacity:1}.lokotro-step-icon{font-size:20px}.lokotro-step span{font-size:14px;color:var(--lokotro-text-primary, #F2F0D5)}\n"] }); }
4378
+ }
4379
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroProcessingComponent, decorators: [{
4380
+ type: Component,
4381
+ args: [{ selector: 'lokotro-processing', standalone: true, imports: [], template: `
4382
+ <div class="lokotro-processing">
4383
+ <div class="lokotro-processing-spinner">
4384
+ <div class="lokotro-spinner-ring"></div>
4385
+ <div class="lokotro-spinner-ring"></div>
4386
+ <div class="lokotro-spinner-ring"></div>
4387
+ </div>
4388
+
4389
+ <h3 class="lokotro-processing-title">{{ message || localization.translate('processing') }}</h3>
4390
+
4391
+ @if (type === 'mobileMoney') {
4392
+ <p class="lokotro-processing-subtitle">
4393
+ Please confirm the payment on your mobile device
4394
+ </p>
4395
+ }
4396
+
4397
+ @if (type === 'mobileMoney') {
4398
+ <div class="lokotro-processing-steps">
4399
+ <div class="lokotro-step" [class.active]="currentStep >= 1">
4400
+ <div class="lokotro-step-icon">📱</div>
4401
+ <span>Check your phone</span>
4402
+ </div>
4403
+ <div class="lokotro-step" [class.active]="currentStep >= 2">
4404
+ <div class="lokotro-step-icon">🔐</div>
4405
+ <span>Enter your PIN</span>
4406
+ </div>
4407
+ <div class="lokotro-step" [class.active]="currentStep >= 3">
4408
+ <div class="lokotro-step-icon">✅</div>
4409
+ <span>Confirm payment</span>
4410
+ </div>
4411
+ </div>
4412
+ }
4413
+ </div>
4414
+ `, styles: [".lokotro-processing{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:48px 24px;min-height:300px}.lokotro-processing-spinner{position:relative;width:80px;height:80px;margin-bottom:32px}.lokotro-spinner-ring{position:absolute;width:100%;height:100%;border:3px solid transparent;border-radius:50%;animation:lokotro-spin 1.5s linear infinite}.lokotro-spinner-ring:nth-child(1){border-top-color:var(--lokotro-accent, #3BFBDA);animation-delay:0s}.lokotro-spinner-ring:nth-child(2){border-right-color:var(--lokotro-primary, #5A5E39);animation-delay:.15s;width:70%;height:70%;top:15%;left:15%}.lokotro-spinner-ring:nth-child(3){border-bottom-color:var(--lokotro-secondary, #6E7346);animation-delay:.3s;width:50%;height:50%;top:25%;left:25%}@keyframes lokotro-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.lokotro-processing-title{font-size:20px;font-weight:600;margin:0 0 8px;color:var(--lokotro-text-primary, #F2F0D5)}.lokotro-processing-subtitle{font-size:14px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0 0 32px}.lokotro-processing-steps{display:flex;flex-direction:column;gap:16px;width:100%;max-width:280px}.lokotro-step{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--lokotro-card, #3A4840);border-radius:12px;opacity:.5;transition:opacity .3s}.lokotro-step.active{opacity:1}.lokotro-step-icon{font-size:20px}.lokotro-step span{font-size:14px;color:var(--lokotro-text-primary, #F2F0D5)}\n"] }]
4415
+ }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { type: [{
4416
+ type: Input
4417
+ }], message: [{
4418
+ type: Input
4419
+ }] } });
4420
+
4421
+ /**
4422
+ * Lokotro Pay - Result Component
4423
+ * Success, Error, Warning, Info screens
4424
+ *
4425
+ * Designed for financial UX: clean white surface, prominent amount focal
4426
+ * on success, filled status badge, restrained transaction-detail panel.
4427
+ */
4428
+ class LokotroResultComponent {
4429
+ constructor(localization) {
4430
+ this.localization = localization;
4431
+ this.type = 'success';
4432
+ this.title = '';
4433
+ /** Auto-redirect duration in seconds. If > 0, will auto-call onAutoRedirect after countdown. */
4434
+ this.autoRedirectSeconds = 0;
4435
+ this.primaryAction = new EventEmitter();
4436
+ this.secondaryAction = new EventEmitter();
4437
+ /** Emitted when auto-redirect triggers after countdown completes. */
4438
+ this.autoRedirect = new EventEmitter();
4439
+ // Countdown state
4440
+ this.countdownSeconds = 0;
4441
+ this.isCountdownCancelled = false;
4099
4442
  }
4100
- /**
4101
- * PR-3.0 — narrow an arbitrary string to the LokotroPaymentMethodFlag union.
4102
- * Unknown / empty inputs return undefined so the field stays optional.
4103
- */
4104
- parsePaymentMethodFlag(value) {
4105
- const known = [
4106
- 'none', 'all',
4107
- 'onafriq_mobile_money', 'onafriq_credit_card', 'rawbank_credit_card',
4108
- 'lokotro_wallet', 'lokotro_eflash',
4109
- 'cash', 'bank_transfer', 'google_pay', 'apple_pay',
4110
- ];
4111
- return known.find(f => f === value);
4443
+ ngOnInit() {
4444
+ this.startAutoRedirectCountdown();
4112
4445
  }
4113
- /**
4114
- * Parse submit response from API.
4115
- */
4116
- parseSubmitResponse(data) {
4117
- const rawStatus = this.getString(data['status']);
4118
- return {
4119
- success: this.getBoolean(data['success']),
4120
- message: this.getString(data['message']),
4121
- transactionId: this.resolvePaymentId(data),
4122
- status: LokotroPaymentStatusInfo.fromString(rawStatus),
4123
- requiresOtp: this.getBoolean(data['requires_otp']) || rawStatus === 'pending_otp' || rawStatus === 'pending_otp_verification',
4124
- otpDestination: this.getString(data['otp_destination']) || this.getString(data['otp_sent_to']) || undefined,
4125
- redirectUrl: this.getString(data['redirect_url']) || this.getString(data['mastercardUrl']) || undefined
4126
- };
4446
+ ngOnDestroy() {
4447
+ this.clearCountdown();
4127
4448
  }
4128
- /**
4129
- * Build an auto-submit request using the original payment body.
4130
- */
4131
- buildAutoSubmitRequest(paymentId, paymentMethodId, paymentBody) {
4132
- return {
4133
- paymentId,
4134
- paymentMethodId,
4135
- paymentMethod: paymentBody.paymentMethod,
4136
- firstName: paymentBody.firstName,
4137
- lastName: paymentBody.lastName,
4138
- email: paymentBody.email,
4139
- phoneNumber: paymentBody.phoneNumber,
4140
- walletNumber: paymentBody.walletNumber,
4141
- walletPin: paymentBody.walletPin,
4142
- mobileMoneyPhoneNumber: paymentBody.mobileMoneyPhoneNumber,
4143
- flashNumber: paymentBody.flashNumber,
4144
- flashPin: paymentBody.flashPin,
4145
- cardNumber: paymentBody.cardNumber,
4146
- cardExpiryDate: paymentBody.cardExpiryDate,
4147
- cardCvv: paymentBody.cardCvv,
4148
- cardHolderName: paymentBody.cardHolderName,
4149
- mastercardPaymentMethod: paymentBody.mastercardPaymentMethod
4150
- };
4449
+ startAutoRedirectCountdown() {
4450
+ if (this.autoRedirectSeconds <= 0)
4451
+ return;
4452
+ this.countdownSeconds = this.autoRedirectSeconds;
4453
+ this.countdownInterval = setInterval(() => {
4454
+ if (this.isCountdownCancelled) {
4455
+ this.clearCountdown();
4456
+ return;
4457
+ }
4458
+ this.countdownSeconds--;
4459
+ if (this.countdownSeconds <= 0) {
4460
+ this.clearCountdown();
4461
+ this.triggerAutoRedirect();
4462
+ }
4463
+ }, 1000);
4151
4464
  }
4152
- /**
4153
- * Resolve the selected payment method from transaction details.
4154
- */
4155
- resolveSelectedPaymentMethod(transactionData, paymentInfo) {
4156
- const paymentMethodId = this.getString(transactionData['payment_method_id']);
4157
- const paymentMethodName = this.getString(transactionData['payment_method_str']) || this.getString(transactionData['payment_method']);
4158
- const normalizedName = this.normalizeMethodKey(paymentMethodName);
4159
- const exactMatch = paymentInfo.availablePaymentMethods.find(method => method.id === paymentMethodId ||
4160
- this.normalizeMethodKey(method.name) === normalizedName ||
4161
- this.normalizeMethodKey(method.displayName) === normalizedName);
4162
- if (exactMatch) {
4163
- return exactMatch;
4164
- }
4165
- const channel = this.parseChannel(this.getString(transactionData['channel']) || paymentMethodName);
4166
- if (!paymentMethodId && !paymentMethodName && channel === LokotroPayChannel.None) {
4167
- return paymentInfo.availablePaymentMethods[0];
4465
+ cancelCountdown() {
4466
+ if (this.countdownInterval) {
4467
+ this.isCountdownCancelled = true;
4468
+ this.clearCountdown();
4168
4469
  }
4169
- return {
4170
- id: paymentMethodId || paymentMethodName || channel,
4171
- name: paymentMethodName || paymentMethodId || channel,
4172
- displayName: paymentMethodName || paymentMethodId || channel,
4173
- channel,
4174
- iconUrl: '',
4175
- isEnabled: true
4176
- };
4177
4470
  }
4178
- /**
4179
- * Normalize payloads that sometimes return data nested inside data.
4180
- */
4181
- unwrapPayload(payload) {
4182
- const record = this.asRecord(payload);
4183
- if (!record) {
4184
- return {};
4471
+ clearCountdown() {
4472
+ if (this.countdownInterval) {
4473
+ clearInterval(this.countdownInterval);
4474
+ this.countdownInterval = undefined;
4185
4475
  }
4186
- const nestedData = this.asRecord(record['data']);
4187
- return nestedData || record;
4188
4476
  }
4189
- resolvePaymentId(source) {
4190
- return this.getString(source['payment_id']) ||
4191
- this.getString(source['transaction_id']) ||
4192
- this.getString(source['paymentId']) ||
4193
- this.getString(source['transactionId']) ||
4194
- this.currentState.transactionId ||
4195
- '';
4477
+ triggerAutoRedirect() {
4478
+ if (this.isCountdownCancelled)
4479
+ return;
4480
+ this.autoRedirect.emit();
4196
4481
  }
4197
- parseChannel(channel) {
4198
- switch (this.normalizeMethodKey(channel)) {
4199
- case 'card':
4200
- return LokotroPayChannel.Card;
4201
- case 'mobilemoney':
4202
- return LokotroPayChannel.MobileMoney;
4203
- case 'wallet':
4204
- case 'ewallet':
4205
- case 'lokotrowallet':
4206
- return LokotroPayChannel.EWallet;
4207
- case 'flash':
4208
- case 'eflash':
4209
- return LokotroPayChannel.EFlash;
4210
- case 'banktransfer':
4211
- return LokotroPayChannel.BankTransfer;
4212
- default:
4213
- return LokotroPayChannel.None;
4482
+ getCountdownProgress() {
4483
+ if (this.autoRedirectSeconds <= 0)
4484
+ return 0;
4485
+ return (this.countdownSeconds / this.autoRedirectSeconds) * 100;
4486
+ }
4487
+ formatNumber(amount) {
4488
+ return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount);
4489
+ }
4490
+ formatAmount(amount, currency) {
4491
+ return new Intl.NumberFormat('en-US', {
4492
+ style: 'currency',
4493
+ currency: currency || 'USD',
4494
+ minimumFractionDigits: 2
4495
+ }).format(amount);
4496
+ }
4497
+ onPrimaryClick() {
4498
+ this.cancelCountdown();
4499
+ this.primaryAction.emit();
4500
+ }
4501
+ onSecondaryClick() {
4502
+ this.cancelCountdown();
4503
+ this.secondaryAction.emit();
4504
+ }
4505
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroResultComponent, deps: [{ token: LokotroLocalizationService }], target: i0.ɵɵFactoryTarget.Component }); }
4506
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroResultComponent, isStandalone: true, selector: "lokotro-result", inputs: { type: "type", title: "title", message: "message", amount: "amount", currency: "currency", transactionId: "transactionId", primaryActionLabel: "primaryActionLabel", secondaryActionLabel: "secondaryActionLabel", autoRedirectSeconds: "autoRedirectSeconds" }, outputs: { primaryAction: "primaryAction", secondaryAction: "secondaryAction", autoRedirect: "autoRedirect" }, ngImport: i0, template: `
4507
+ <div class="lokotro-result" [class]="'lokotro-result-' + type">
4508
+ <div class="lokotro-result-card">
4509
+ <!-- Status Badge (filled circle with icon) -->
4510
+ <div class="lokotro-status-badge" [class]="'lokotro-status-' + type">
4511
+ @if (type === 'success') {
4512
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4513
+ <polyline points="5 12 10 17 19 8"/>
4514
+ </svg>
4515
+ }
4516
+ @if (type === 'error') {
4517
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4518
+ <line x1="18" y1="6" x2="6" y2="18"/>
4519
+ <line x1="6" y1="6" x2="18" y2="18"/>
4520
+ </svg>
4521
+ }
4522
+ @if (type === 'warning') {
4523
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4524
+ <line x1="12" y1="8" x2="12" y2="13"/>
4525
+ <line x1="12" y1="17" x2="12.01" y2="17"/>
4526
+ </svg>
4527
+ }
4528
+ @if (type === 'info') {
4529
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4530
+ <line x1="12" y1="11" x2="12" y2="16"/>
4531
+ <line x1="12" y1="7" x2="12.01" y2="7"/>
4532
+ </svg>
4533
+ }
4534
+ </div>
4535
+
4536
+ <!-- Amount focal (success only, when amount provided) -->
4537
+ @if (type === 'success' && amount !== undefined && amount !== null) {
4538
+ <div class="lokotro-amount-focal">
4539
+ <span class="lokotro-amount-currency">{{ (currency || '').toUpperCase() }}</span>
4540
+ <span class="lokotro-amount-value">{{ formatNumber(amount) }}</span>
4541
+ </div>
4542
+ }
4543
+
4544
+ <!-- Title -->
4545
+ <h2 class="lokotro-result-title">{{ title }}</h2>
4546
+
4547
+ <!-- Message -->
4548
+ @if (message) {
4549
+ <p class="lokotro-result-message">{{ message }}</p>
4550
+ }
4551
+
4552
+ <!-- Countdown Indicator -->
4553
+ @if (autoRedirectSeconds > 0 && countdownSeconds > 0 && !isCountdownCancelled) {
4554
+ <button type="button" class="lokotro-countdown-indicator" (click)="cancelCountdown()">
4555
+ <svg viewBox="0 0 36 36" class="lokotro-countdown-circle">
4556
+ <path class="lokotro-countdown-bg"
4557
+ d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
4558
+ <path class="lokotro-countdown-fill" [class]="'lokotro-countdown-' + type"
4559
+ [attr.stroke-dasharray]="getCountdownProgress() + ', 100'"
4560
+ d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
4561
+ </svg>
4562
+ <span class="lokotro-countdown-text">
4563
+ {{ localization.translate('redirectingIn', { seconds: countdownSeconds }) }}
4564
+ </span>
4565
+ <svg class="lokotro-countdown-cancel" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4566
+ <path d="M18 6L6 18M6 6l12 12"/>
4567
+ </svg>
4568
+ </button>
4214
4569
  }
4215
- }
4216
- normalizeMethodKey(value) {
4217
- return value.toLowerCase().replace(/[_\s-]/g, '');
4218
- }
4219
- getNumber(value, fallback = 0) {
4220
- if (typeof value === 'number') {
4221
- return Number.isFinite(value) ? value : fallback;
4570
+
4571
+ <!-- Transaction Details -->
4572
+ @if (type === 'success' && transactionId) {
4573
+ <div class="lokotro-result-details">
4574
+ <div class="lokotro-detail-row">
4575
+ <span class="lokotro-detail-label">Transaction ID</span>
4576
+ <span class="lokotro-detail-value lokotro-mono">{{ transactionId }}</span>
4577
+ </div>
4578
+ </div>
4222
4579
  }
4223
- if (typeof value === 'string') {
4224
- const parsedValue = parseFloat(value);
4225
- return Number.isFinite(parsedValue) ? parsedValue : fallback;
4580
+ </div>
4581
+
4582
+ <!-- Actions -->
4583
+ @if (primaryActionLabel || secondaryActionLabel) {
4584
+ <div class="lokotro-result-actions">
4585
+ @if (primaryActionLabel) {
4586
+ <button type="button" class="lokotro-btn-primary" (click)="onPrimaryClick()">
4587
+ {{ primaryActionLabel }}
4588
+ </button>
4589
+ }
4590
+ @if (secondaryActionLabel) {
4591
+ <button type="button" class="lokotro-btn-secondary" (click)="onSecondaryClick()">
4592
+ {{ secondaryActionLabel }}
4593
+ </button>
4594
+ }
4595
+ </div>
4596
+ }
4597
+ </div>
4598
+ `, isInline: true, styles: [".lokotro-result{display:flex;flex-direction:column;align-items:stretch;max-width:420px;margin:0 auto;padding:24px 16px;gap:20px}.lokotro-result-card{display:flex;flex-direction:column;align-items:center;text-align:center;padding:32px 24px;background:var(--lokotro-card, #FFFFFF);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:20px;box-shadow:0 1px 3px var(--lokotro-shadow-light, rgba(15, 23, 42, .04)),0 8px 24px var(--lokotro-shadow-light, rgba(15, 23, 42, .04))}.lokotro-status-badge{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:24px;animation:lokotro-pop .5s cubic-bezier(.34,1.56,.64,1)}.lokotro-status-badge svg{width:36px;height:36px;color:#fff}.lokotro-status-success{background:var(--lokotro-success, #16A34A);box-shadow:0 0 0 8px #16a34a1f,0 8px 20px #16a34a40}.lokotro-status-error{background:var(--lokotro-error, #DC2626);box-shadow:0 0 0 8px #dc26261f,0 8px 20px #dc262640}.lokotro-status-warning{background:var(--lokotro-warning, #D97706);box-shadow:0 0 0 8px #d977061f,0 8px 20px #d9770640}.lokotro-status-info{background:var(--lokotro-info, #2563EB);box-shadow:0 0 0 8px #2563eb1f,0 8px 20px #2563eb40}@keyframes lokotro-pop{0%{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.lokotro-amount-focal{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:16px}.lokotro-amount-currency{font-size:12px;font-weight:600;letter-spacing:.08em;color:var(--lokotro-text-secondary, #64748B)}.lokotro-amount-value{font-size:40px;font-weight:700;line-height:1;color:var(--lokotro-text-primary, #0F172A);font-variant-numeric:tabular-nums}.lokotro-result-title{font-size:20px;font-weight:600;margin:0 0 6px;color:var(--lokotro-text-primary, #0F172A)}.lokotro-result-message{font-size:14px;color:var(--lokotro-text-secondary, #64748B);margin:0;line-height:1.5;max-width:320px}.lokotro-result-details{width:100%;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:12px;padding:12px 16px;margin-top:24px}.lokotro-detail-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0}.lokotro-detail-row:not(:last-child){border-bottom:1px solid var(--lokotro-divider, #F1F5F9)}.lokotro-detail-label{font-size:13px;color:var(--lokotro-text-secondary, #64748B)}.lokotro-detail-value{font-size:13px;font-weight:600;color:var(--lokotro-text-primary, #0F172A);text-align:right;overflow:hidden;text-overflow:ellipsis;max-width:200px;white-space:nowrap}.lokotro-mono{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Monaco,monospace;font-size:12px}.lokotro-result-actions{display:flex;flex-direction:column;gap:10px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{width:100%;padding:14px 24px;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}.lokotro-btn-primary{background:var(--lokotro-primary, #0F172A);color:#fff;border:none}.lokotro-btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 16px var(--lokotro-shadow-medium, rgba(15, 23, 42, .12))}.lokotro-btn-primary:active{transform:translateY(0)}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #0F172A);border:1.5px solid var(--lokotro-border, #E2E8F0)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #64748B);background:var(--lokotro-surface, #F7F8FA)}.lokotro-countdown-indicator{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:999px;cursor:pointer;margin-top:20px;transition:all .18s ease;color:var(--lokotro-text-secondary, #64748B);font-family:inherit}.lokotro-countdown-indicator:hover{background:var(--lokotro-card, #FFFFFF);border-color:var(--lokotro-text-tertiary, #64748B)}.lokotro-countdown-circle{width:14px;height:14px;transform:rotate(-90deg)}.lokotro-countdown-bg{fill:none;stroke:var(--lokotro-border, #E2E8F0);stroke-width:4}.lokotro-countdown-fill{fill:none;stroke-width:4;stroke-linecap:round;transition:stroke-dasharray .3s ease}.lokotro-countdown-success{stroke:var(--lokotro-success, #16A34A)}.lokotro-countdown-error{stroke:var(--lokotro-error, #DC2626)}.lokotro-countdown-warning{stroke:var(--lokotro-warning, #D97706)}.lokotro-countdown-info{stroke:var(--lokotro-info, #2563EB)}.lokotro-countdown-text{font-size:12px;font-weight:500}.lokotro-countdown-cancel{color:inherit;opacity:.7}\n"] }); }
4599
+ }
4600
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroResultComponent, decorators: [{
4601
+ type: Component,
4602
+ args: [{ selector: 'lokotro-result', standalone: true, imports: [], template: `
4603
+ <div class="lokotro-result" [class]="'lokotro-result-' + type">
4604
+ <div class="lokotro-result-card">
4605
+ <!-- Status Badge (filled circle with icon) -->
4606
+ <div class="lokotro-status-badge" [class]="'lokotro-status-' + type">
4607
+ @if (type === 'success') {
4608
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4609
+ <polyline points="5 12 10 17 19 8"/>
4610
+ </svg>
4611
+ }
4612
+ @if (type === 'error') {
4613
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4614
+ <line x1="18" y1="6" x2="6" y2="18"/>
4615
+ <line x1="6" y1="6" x2="18" y2="18"/>
4616
+ </svg>
4617
+ }
4618
+ @if (type === 'warning') {
4619
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4620
+ <line x1="12" y1="8" x2="12" y2="13"/>
4621
+ <line x1="12" y1="17" x2="12.01" y2="17"/>
4622
+ </svg>
4623
+ }
4624
+ @if (type === 'info') {
4625
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4626
+ <line x1="12" y1="11" x2="12" y2="16"/>
4627
+ <line x1="12" y1="7" x2="12.01" y2="7"/>
4628
+ </svg>
4629
+ }
4630
+ </div>
4631
+
4632
+ <!-- Amount focal (success only, when amount provided) -->
4633
+ @if (type === 'success' && amount !== undefined && amount !== null) {
4634
+ <div class="lokotro-amount-focal">
4635
+ <span class="lokotro-amount-currency">{{ (currency || '').toUpperCase() }}</span>
4636
+ <span class="lokotro-amount-value">{{ formatNumber(amount) }}</span>
4637
+ </div>
4226
4638
  }
4227
- return fallback;
4228
- }
4229
- getString(value) {
4230
- return typeof value === 'string' ? value : '';
4231
- }
4232
- getBoolean(value, fallback = false) {
4233
- return typeof value === 'boolean' ? value : fallback;
4234
- }
4235
- asRecord(value) {
4236
- return value !== null && typeof value === 'object' && !Array.isArray(value)
4237
- ? value
4238
- : undefined;
4239
- }
4240
- getRecordArray(value) {
4241
- if (!Array.isArray(value)) {
4242
- return [];
4639
+
4640
+ <!-- Title -->
4641
+ <h2 class="lokotro-result-title">{{ title }}</h2>
4642
+
4643
+ <!-- Message -->
4644
+ @if (message) {
4645
+ <p class="lokotro-result-message">{{ message }}</p>
4243
4646
  }
4244
- return value
4245
- .map(item => this.asRecord(item))
4246
- .filter((item) => Boolean(item));
4247
- }
4248
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, deps: [{ token: LokotroHttpClientService }], target: i0.ɵɵFactoryTarget.Injectable }); }
4249
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, providedIn: 'root' }); }
4647
+
4648
+ <!-- Countdown Indicator -->
4649
+ @if (autoRedirectSeconds > 0 && countdownSeconds > 0 && !isCountdownCancelled) {
4650
+ <button type="button" class="lokotro-countdown-indicator" (click)="cancelCountdown()">
4651
+ <svg viewBox="0 0 36 36" class="lokotro-countdown-circle">
4652
+ <path class="lokotro-countdown-bg"
4653
+ d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
4654
+ <path class="lokotro-countdown-fill" [class]="'lokotro-countdown-' + type"
4655
+ [attr.stroke-dasharray]="getCountdownProgress() + ', 100'"
4656
+ d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
4657
+ </svg>
4658
+ <span class="lokotro-countdown-text">
4659
+ {{ localization.translate('redirectingIn', { seconds: countdownSeconds }) }}
4660
+ </span>
4661
+ <svg class="lokotro-countdown-cancel" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4662
+ <path d="M18 6L6 18M6 6l12 12"/>
4663
+ </svg>
4664
+ </button>
4665
+ }
4666
+
4667
+ <!-- Transaction Details -->
4668
+ @if (type === 'success' && transactionId) {
4669
+ <div class="lokotro-result-details">
4670
+ <div class="lokotro-detail-row">
4671
+ <span class="lokotro-detail-label">Transaction ID</span>
4672
+ <span class="lokotro-detail-value lokotro-mono">{{ transactionId }}</span>
4673
+ </div>
4674
+ </div>
4675
+ }
4676
+ </div>
4677
+
4678
+ <!-- Actions -->
4679
+ @if (primaryActionLabel || secondaryActionLabel) {
4680
+ <div class="lokotro-result-actions">
4681
+ @if (primaryActionLabel) {
4682
+ <button type="button" class="lokotro-btn-primary" (click)="onPrimaryClick()">
4683
+ {{ primaryActionLabel }}
4684
+ </button>
4685
+ }
4686
+ @if (secondaryActionLabel) {
4687
+ <button type="button" class="lokotro-btn-secondary" (click)="onSecondaryClick()">
4688
+ {{ secondaryActionLabel }}
4689
+ </button>
4690
+ }
4691
+ </div>
4692
+ }
4693
+ </div>
4694
+ `, styles: [".lokotro-result{display:flex;flex-direction:column;align-items:stretch;max-width:420px;margin:0 auto;padding:24px 16px;gap:20px}.lokotro-result-card{display:flex;flex-direction:column;align-items:center;text-align:center;padding:32px 24px;background:var(--lokotro-card, #FFFFFF);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:20px;box-shadow:0 1px 3px var(--lokotro-shadow-light, rgba(15, 23, 42, .04)),0 8px 24px var(--lokotro-shadow-light, rgba(15, 23, 42, .04))}.lokotro-status-badge{width:72px;height:72px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-bottom:24px;animation:lokotro-pop .5s cubic-bezier(.34,1.56,.64,1)}.lokotro-status-badge svg{width:36px;height:36px;color:#fff}.lokotro-status-success{background:var(--lokotro-success, #16A34A);box-shadow:0 0 0 8px #16a34a1f,0 8px 20px #16a34a40}.lokotro-status-error{background:var(--lokotro-error, #DC2626);box-shadow:0 0 0 8px #dc26261f,0 8px 20px #dc262640}.lokotro-status-warning{background:var(--lokotro-warning, #D97706);box-shadow:0 0 0 8px #d977061f,0 8px 20px #d9770640}.lokotro-status-info{background:var(--lokotro-info, #2563EB);box-shadow:0 0 0 8px #2563eb1f,0 8px 20px #2563eb40}@keyframes lokotro-pop{0%{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}}.lokotro-amount-focal{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:16px}.lokotro-amount-currency{font-size:12px;font-weight:600;letter-spacing:.08em;color:var(--lokotro-text-secondary, #64748B)}.lokotro-amount-value{font-size:40px;font-weight:700;line-height:1;color:var(--lokotro-text-primary, #0F172A);font-variant-numeric:tabular-nums}.lokotro-result-title{font-size:20px;font-weight:600;margin:0 0 6px;color:var(--lokotro-text-primary, #0F172A)}.lokotro-result-message{font-size:14px;color:var(--lokotro-text-secondary, #64748B);margin:0;line-height:1.5;max-width:320px}.lokotro-result-details{width:100%;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:12px;padding:12px 16px;margin-top:24px}.lokotro-detail-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0}.lokotro-detail-row:not(:last-child){border-bottom:1px solid var(--lokotro-divider, #F1F5F9)}.lokotro-detail-label{font-size:13px;color:var(--lokotro-text-secondary, #64748B)}.lokotro-detail-value{font-size:13px;font-weight:600;color:var(--lokotro-text-primary, #0F172A);text-align:right;overflow:hidden;text-overflow:ellipsis;max-width:200px;white-space:nowrap}.lokotro-mono{font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Monaco,monospace;font-size:12px}.lokotro-result-actions{display:flex;flex-direction:column;gap:10px;width:100%}.lokotro-btn-primary,.lokotro-btn-secondary{width:100%;padding:14px 24px;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:all .18s ease;font-family:inherit}.lokotro-btn-primary{background:var(--lokotro-primary, #0F172A);color:#fff;border:none}.lokotro-btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 16px var(--lokotro-shadow-medium, rgba(15, 23, 42, .12))}.lokotro-btn-primary:active{transform:translateY(0)}.lokotro-btn-secondary{background:transparent;color:var(--lokotro-text-primary, #0F172A);border:1.5px solid var(--lokotro-border, #E2E8F0)}.lokotro-btn-secondary:hover{border-color:var(--lokotro-text-secondary, #64748B);background:var(--lokotro-surface, #F7F8FA)}.lokotro-countdown-indicator{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:var(--lokotro-surface, #F7F8FA);border:1px solid var(--lokotro-border, #E2E8F0);border-radius:999px;cursor:pointer;margin-top:20px;transition:all .18s ease;color:var(--lokotro-text-secondary, #64748B);font-family:inherit}.lokotro-countdown-indicator:hover{background:var(--lokotro-card, #FFFFFF);border-color:var(--lokotro-text-tertiary, #64748B)}.lokotro-countdown-circle{width:14px;height:14px;transform:rotate(-90deg)}.lokotro-countdown-bg{fill:none;stroke:var(--lokotro-border, #E2E8F0);stroke-width:4}.lokotro-countdown-fill{fill:none;stroke-width:4;stroke-linecap:round;transition:stroke-dasharray .3s ease}.lokotro-countdown-success{stroke:var(--lokotro-success, #16A34A)}.lokotro-countdown-error{stroke:var(--lokotro-error, #DC2626)}.lokotro-countdown-warning{stroke:var(--lokotro-warning, #D97706)}.lokotro-countdown-info{stroke:var(--lokotro-info, #2563EB)}.lokotro-countdown-text{font-size:12px;font-weight:500}.lokotro-countdown-cancel{color:inherit;opacity:.7}\n"] }]
4695
+ }], ctorParameters: () => [{ type: LokotroLocalizationService }], propDecorators: { type: [{
4696
+ type: Input
4697
+ }], title: [{
4698
+ type: Input
4699
+ }], message: [{
4700
+ type: Input
4701
+ }], amount: [{
4702
+ type: Input
4703
+ }], currency: [{
4704
+ type: Input
4705
+ }], transactionId: [{
4706
+ type: Input
4707
+ }], primaryActionLabel: [{
4708
+ type: Input
4709
+ }], secondaryActionLabel: [{
4710
+ type: Input
4711
+ }], autoRedirectSeconds: [{
4712
+ type: Input
4713
+ }], primaryAction: [{
4714
+ type: Output
4715
+ }], secondaryAction: [{
4716
+ type: Output
4717
+ }], autoRedirect: [{
4718
+ type: Output
4719
+ }] } });
4720
+
4721
+ /**
4722
+ * Lokotro Pay - Loading Component
4723
+ */
4724
+ class LokotroLoadingComponent {
4725
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroLoadingComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4726
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: LokotroLoadingComponent, isStandalone: true, selector: "lokotro-loading", inputs: { message: "message" }, ngImport: i0, template: `
4727
+ <div class="lokotro-loading">
4728
+ <div class="lokotro-loading-spinner">
4729
+ <div class="lokotro-pulse-ring"></div>
4730
+ <div class="lokotro-pulse-ring"></div>
4731
+ <div class="lokotro-pulse-dot"></div>
4732
+ </div>
4733
+ @if (message) {
4734
+ <p class="lokotro-loading-message">{{ message }}</p>
4735
+ }
4736
+ </div>
4737
+ `, isInline: true, styles: [".lokotro-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;padding:24px}.lokotro-loading-spinner{position:relative;width:60px;height:60px;margin-bottom:24px}.lokotro-pulse-ring{position:absolute;width:100%;height:100%;border:3px solid var(--lokotro-accent, #3BFBDA);border-radius:50%;opacity:0;animation:lokotro-pulse 2s cubic-bezier(.215,.61,.355,1) infinite}.lokotro-pulse-ring:nth-child(2){animation-delay:.5s}.lokotro-pulse-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:16px;height:16px;background:var(--lokotro-accent, #3BFBDA);border-radius:50%;animation:lokotro-dot-pulse 2s ease-in-out infinite}@keyframes lokotro-pulse{0%{transform:scale(.5);opacity:.8}to{transform:scale(1.5);opacity:0}}@keyframes lokotro-dot-pulse{0%,to{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.2)}}.lokotro-loading-message{font-size:16px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0}\n"] }); }
4250
4738
  }
4251
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPaymentService, decorators: [{
4252
- type: Injectable,
4253
- args: [{
4254
- providedIn: 'root'
4255
- }]
4256
- }], ctorParameters: () => [{ type: LokotroHttpClientService }] });
4739
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroLoadingComponent, decorators: [{
4740
+ type: Component,
4741
+ args: [{ selector: 'lokotro-loading', standalone: true, imports: [], template: `
4742
+ <div class="lokotro-loading">
4743
+ <div class="lokotro-loading-spinner">
4744
+ <div class="lokotro-pulse-ring"></div>
4745
+ <div class="lokotro-pulse-ring"></div>
4746
+ <div class="lokotro-pulse-dot"></div>
4747
+ </div>
4748
+ @if (message) {
4749
+ <p class="lokotro-loading-message">{{ message }}</p>
4750
+ }
4751
+ </div>
4752
+ `, styles: [".lokotro-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;padding:24px}.lokotro-loading-spinner{position:relative;width:60px;height:60px;margin-bottom:24px}.lokotro-pulse-ring{position:absolute;width:100%;height:100%;border:3px solid var(--lokotro-accent, #3BFBDA);border-radius:50%;opacity:0;animation:lokotro-pulse 2s cubic-bezier(.215,.61,.355,1) infinite}.lokotro-pulse-ring:nth-child(2){animation-delay:.5s}.lokotro-pulse-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:16px;height:16px;background:var(--lokotro-accent, #3BFBDA);border-radius:50%;animation:lokotro-dot-pulse 2s ease-in-out infinite}@keyframes lokotro-pulse{0%{transform:scale(.5);opacity:.8}to{transform:scale(1.5);opacity:0}}@keyframes lokotro-dot-pulse{0%,to{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.2)}}.lokotro-loading-message{font-size:16px;color:var(--lokotro-text-secondary, #D5D3B8);margin:0}\n"] }]
4753
+ }], propDecorators: { message: [{
4754
+ type: Input
4755
+ }] } });
4257
4756
 
4258
4757
  /**
4259
4758
  * Lokotro Pay - Checkout Component
@@ -4484,6 +4983,12 @@ class LokotroPayCheckoutComponent {
4484
4983
  onCancel() {
4485
4984
  this.closed.emit();
4486
4985
  }
4986
+ onConfirmBankTransfer() {
4987
+ this.paymentService.confirmBankTransferSwitch();
4988
+ }
4989
+ onDeclineBankTransfer() {
4990
+ this.paymentService.declineBankTransferSwitch();
4991
+ }
4487
4992
  onClose() {
4488
4993
  this.closed.emit();
4489
4994
  }
@@ -4600,7 +5105,34 @@ class LokotroPayCheckoutComponent {
4600
5105
  (cancel)="onCancel()">
4601
5106
  </lokotro-payment-form>
4602
5107
  }
4603
-
5108
+
5109
+ <!-- Bank Transfer Form Screen (town/bank/account selection) -->
5110
+ @if (currentScreen === 'bankTransferFormScreen') {
5111
+ <lokotro-bank-transfer-form
5112
+ [showUserInfoForm]="state?.paymentInfo?.showUserInfoForm || false"
5113
+ (formSubmitted)="onFormSubmitted($event)"
5114
+ (cancel)="onCancel()">
5115
+ </lokotro-bank-transfer-form>
5116
+ }
5117
+
5118
+ <!-- Bank Transfer Switch Offer (amount out of range for chosen method) -->
5119
+ @if (currentScreen === 'bankTransferOfferScreen') {
5120
+ <div style="padding:24px;text-align:center;display:flex;flex-direction:column;gap:16px;">
5121
+ <h3 style="margin:0;font-size:18px;font-weight:600;">{{ localization.translate('bankTransferSwitchTitle') }}</h3>
5122
+ <p style="margin:0;opacity:0.75;">{{ localization.translate('bankTransferSwitchMessage') }}</p>
5123
+ <div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
5124
+ <button type="button" (click)="onDeclineBankTransfer()"
5125
+ style="padding:12px 20px;border-radius:10px;border:1px solid rgba(128,128,128,0.4);background:transparent;color:inherit;cursor:pointer;">
5126
+ {{ localization.translate('bankTransferSwitchDecline') }}
5127
+ </button>
5128
+ <button type="button" (click)="onConfirmBankTransfer()"
5129
+ style="padding:12px 20px;border-radius:10px;border:none;background:var(--lokotro-primary,#0891b2);color:#fff;cursor:pointer;font-weight:600;">
5130
+ {{ localization.translate('bankTransferSwitchConfirm') }}
5131
+ </button>
5132
+ </div>
5133
+ </div>
5134
+ }
5135
+
4604
5136
  <!-- OTP Verification Screen -->
4605
5137
  @if (currentScreen === 'ewalletOtpScreen') {
4606
5138
  <lokotro-otp-verification
@@ -4673,13 +5205,14 @@ class LokotroPayCheckoutComponent {
4673
5205
  }
4674
5206
  </div>
4675
5207
  </div>
4676
- `, isInline: true, styles: [".lokotro-checkout{display:flex;flex-direction:column;min-height:100%;background:var(--lokotro-background, #FFFFFF);color:var(--lokotro-text-primary, #0F172A);font-family:var(--lokotro-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif)}.lokotro-checkout-header{display:flex;align-items:center;justify-content:space-between;padding:16px;background:var(--lokotro-background, #FFFFFF);border-bottom:1px solid var(--lokotro-border, #E2E8F0)}.lokotro-title{flex:1;text-align:center;font-size:18px;font-weight:600;margin:0;color:var(--lokotro-text-primary, #0F172A)}.lokotro-back-btn,.lokotro-close-btn{background:none;border:none;padding:8px;cursor:pointer;color:var(--lokotro-text-secondary, #475569);border-radius:8px;transition:background-color .2s,color .2s}.lokotro-back-btn:hover,.lokotro-close-btn:hover{background:var(--lokotro-surface, #F7F8FA);color:var(--lokotro-text-primary, #0F172A)}.lokotro-checkout-content{flex:1;padding:16px;overflow-y:auto}.lokotro-dark{--lokotro-background: #0F172A;--lokotro-surface: #1E293B;--lokotro-card: #1E293B;--lokotro-text-primary: #F8FAFC;--lokotro-text-secondary: #CBD5E1;--lokotro-border: #334155}\n"], dependencies: [{ kind: "component", type: LokotroPaymentMethodSelectionComponent, selector: "lokotro-payment-method-selection", inputs: ["paymentMethods", "selectedMethod"], outputs: ["methodSelected"] }, { kind: "component", type: LokotroPaymentFormComponent, selector: "lokotro-payment-form", inputs: ["channel", "transactionId", "showUserInfoForm"], outputs: ["formSubmitted", "cancel"] }, { kind: "component", type: LokotroOtpVerificationComponent, selector: "lokotro-otp-verification", inputs: ["transactionId", "otpDestination", "otpLength"], outputs: ["otpVerified", "resendOtp", "cancel"] }, { kind: "component", type: LokotroProcessingComponent, selector: "lokotro-processing", inputs: ["type", "message"] }, { kind: "component", type: LokotroResultComponent, selector: "lokotro-result", inputs: ["type", "title", "message", "amount", "currency", "transactionId", "primaryActionLabel", "secondaryActionLabel", "autoRedirectSeconds"], outputs: ["primaryAction", "secondaryAction", "autoRedirect"] }, { kind: "component", type: LokotroLoadingComponent, selector: "lokotro-loading", inputs: ["message"] }] }); }
5208
+ `, isInline: true, styles: [".lokotro-checkout{display:flex;flex-direction:column;min-height:100%;background:var(--lokotro-background, #FFFFFF);color:var(--lokotro-text-primary, #0F172A);font-family:var(--lokotro-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif)}.lokotro-checkout-header{display:flex;align-items:center;justify-content:space-between;padding:16px;background:var(--lokotro-background, #FFFFFF);border-bottom:1px solid var(--lokotro-border, #E2E8F0)}.lokotro-title{flex:1;text-align:center;font-size:18px;font-weight:600;margin:0;color:var(--lokotro-text-primary, #0F172A)}.lokotro-back-btn,.lokotro-close-btn{background:none;border:none;padding:8px;cursor:pointer;color:var(--lokotro-text-secondary, #475569);border-radius:8px;transition:background-color .2s,color .2s}.lokotro-back-btn:hover,.lokotro-close-btn:hover{background:var(--lokotro-surface, #F7F8FA);color:var(--lokotro-text-primary, #0F172A)}.lokotro-checkout-content{flex:1;padding:16px;overflow-y:auto}.lokotro-dark{--lokotro-background: #0F172A;--lokotro-surface: #1E293B;--lokotro-card: #1E293B;--lokotro-text-primary: #F8FAFC;--lokotro-text-secondary: #CBD5E1;--lokotro-border: #334155}\n"], dependencies: [{ kind: "component", type: LokotroPaymentMethodSelectionComponent, selector: "lokotro-payment-method-selection", inputs: ["paymentMethods", "selectedMethod"], outputs: ["methodSelected"] }, { kind: "component", type: LokotroPaymentFormComponent, selector: "lokotro-payment-form", inputs: ["channel", "transactionId", "showUserInfoForm"], outputs: ["formSubmitted", "cancel"] }, { kind: "component", type: LokotroBankTransferFormComponent, selector: "lokotro-bank-transfer-form", inputs: ["showUserInfoForm"], outputs: ["formSubmitted", "cancel"] }, { kind: "component", type: LokotroOtpVerificationComponent, selector: "lokotro-otp-verification", inputs: ["transactionId", "otpDestination", "otpLength"], outputs: ["otpVerified", "resendOtp", "cancel"] }, { kind: "component", type: LokotroProcessingComponent, selector: "lokotro-processing", inputs: ["type", "message"] }, { kind: "component", type: LokotroResultComponent, selector: "lokotro-result", inputs: ["type", "title", "message", "amount", "currency", "transactionId", "primaryActionLabel", "secondaryActionLabel", "autoRedirectSeconds"], outputs: ["primaryAction", "secondaryAction", "autoRedirect"] }, { kind: "component", type: LokotroLoadingComponent, selector: "lokotro-loading", inputs: ["message"] }] }); }
4677
5209
  }
4678
5210
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: LokotroPayCheckoutComponent, decorators: [{
4679
5211
  type: Component,
4680
5212
  args: [{ selector: 'lokotro-pay-checkout', standalone: true, imports: [
4681
5213
  LokotroPaymentMethodSelectionComponent,
4682
5214
  LokotroPaymentFormComponent,
5215
+ LokotroBankTransferFormComponent,
4683
5216
  LokotroOtpVerificationComponent,
4684
5217
  LokotroProcessingComponent,
4685
5218
  LokotroResultComponent,
@@ -4734,7 +5267,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
4734
5267
  (cancel)="onCancel()">
4735
5268
  </lokotro-payment-form>
4736
5269
  }
4737
-
5270
+
5271
+ <!-- Bank Transfer Form Screen (town/bank/account selection) -->
5272
+ @if (currentScreen === 'bankTransferFormScreen') {
5273
+ <lokotro-bank-transfer-form
5274
+ [showUserInfoForm]="state?.paymentInfo?.showUserInfoForm || false"
5275
+ (formSubmitted)="onFormSubmitted($event)"
5276
+ (cancel)="onCancel()">
5277
+ </lokotro-bank-transfer-form>
5278
+ }
5279
+
5280
+ <!-- Bank Transfer Switch Offer (amount out of range for chosen method) -->
5281
+ @if (currentScreen === 'bankTransferOfferScreen') {
5282
+ <div style="padding:24px;text-align:center;display:flex;flex-direction:column;gap:16px;">
5283
+ <h3 style="margin:0;font-size:18px;font-weight:600;">{{ localization.translate('bankTransferSwitchTitle') }}</h3>
5284
+ <p style="margin:0;opacity:0.75;">{{ localization.translate('bankTransferSwitchMessage') }}</p>
5285
+ <div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
5286
+ <button type="button" (click)="onDeclineBankTransfer()"
5287
+ style="padding:12px 20px;border-radius:10px;border:1px solid rgba(128,128,128,0.4);background:transparent;color:inherit;cursor:pointer;">
5288
+ {{ localization.translate('bankTransferSwitchDecline') }}
5289
+ </button>
5290
+ <button type="button" (click)="onConfirmBankTransfer()"
5291
+ style="padding:12px 20px;border-radius:10px;border:none;background:var(--lokotro-primary,#0891b2);color:#fff;cursor:pointer;font-weight:600;">
5292
+ {{ localization.translate('bankTransferSwitchConfirm') }}
5293
+ </button>
5294
+ </div>
5295
+ </div>
5296
+ }
5297
+
4738
5298
  <!-- OTP Verification Screen -->
4739
5299
  @if (currentScreen === 'ewalletOtpScreen') {
4740
5300
  <lokotro-otp-verification
@@ -5632,5 +6192,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
5632
6192
  * Generated bundle index. Do not edit.
5633
6193
  */
5634
6194
 
5635
- export { LOKOTRO_ENV_CONFIG, LOKOTRO_PAY_CONFIG, LOKOTRO_PAY_FEATURE_FLAGS, LokotroHttpClientService, LokotroLoadingComponent, LokotroLocalizationService, LokotroOtpVerificationComponent, LokotroPayApiResponseCode, LokotroPayApiResponseCodeInfo, LokotroPayChannel, LokotroPayChannelInfo, LokotroPayCheckoutComponent, LokotroPayColors, LokotroPayEnv, LokotroPayFillingInfo, LokotroPayLanguage, LokotroPayLanguageInfo, LokotroPayModule, LokotroPayResultScreen, LokotroPayScreenNavigation, LokotroPayScreenNavigationInfo, LokotroPaymentFormComponent, LokotroPaymentMethodSelectionComponent, LokotroPaymentService, LokotroPaymentStatus, LokotroPaymentStatusComponent, LokotroProcessingComponent, LokotroResultComponent, visiblePaymentMethods };
6195
+ export { LOKOTRO_ENV_CONFIG, LOKOTRO_PAY_CONFIG, LOKOTRO_PAY_FEATURE_FLAGS, LokotroBankTransferFormComponent, LokotroHttpClientService, LokotroLoadingComponent, LokotroLocalizationService, LokotroOtpVerificationComponent, LokotroPayApiResponseCode, LokotroPayApiResponseCodeInfo, LokotroPayChannel, LokotroPayChannelInfo, LokotroPayCheckoutComponent, LokotroPayColors, LokotroPayEnv, LokotroPayFillingInfo, LokotroPayLanguage, LokotroPayLanguageInfo, LokotroPayModule, LokotroPayResultScreen, LokotroPayScreenNavigation, LokotroPayScreenNavigationInfo, LokotroPaymentFormComponent, LokotroPaymentMethodSelectionComponent, LokotroPaymentService, LokotroPaymentStatus, LokotroPaymentStatusComponent, LokotroProcessingComponent, LokotroResultComponent, visiblePaymentMethods };
5636
6196
  //# sourceMappingURL=bloonio-lokotro-pay.mjs.map