From e723dde47f5ba5296812a02df5720757bad30c7d Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Thu, 18 Dec 2025 22:14:11 +0100 Subject: [PATCH] Created Payment methods page --- src/App.scss | 8 +- src/App.tsx | 24 ++---- src/components/Error/Error.scss | 30 ++++++++ src/components/Error/Error.tsx | 22 ++++++ src/components/Loading/Loading.scss | 28 +++++++ src/components/Loading/Loading.tsx | 13 ++++ src/config/api.ts | 31 ++++++++ src/features/cashier/Cashier.scss | 0 src/features/cashier/Cashier.tsx | 38 ++++++++++ src/features/cashier/hooks/useCurrencies.ts | 30 ++++++++ src/features/cashier/hooks/usePayment.ts | 44 +++++++++++ .../cashier/hooks/usePaymentMethods.ts | 30 ++++++++ src/features/cashier/types.ts | 50 +++++++++++++ src/features/cashier/utils/methodMapper.ts | 26 +++++++ src/main.tsx | 5 +- src/services/api.ts | 75 +++++++++++++++++++ vite.config.ts | 9 +++ 17 files changed, 436 insertions(+), 27 deletions(-) create mode 100644 src/components/Error/Error.scss create mode 100644 src/components/Error/Error.tsx create mode 100644 src/components/Loading/Loading.scss create mode 100644 src/components/Loading/Loading.tsx create mode 100644 src/config/api.ts create mode 100644 src/features/cashier/Cashier.scss create mode 100644 src/features/cashier/Cashier.tsx create mode 100644 src/features/cashier/hooks/useCurrencies.ts create mode 100644 src/features/cashier/hooks/usePayment.ts create mode 100644 src/features/cashier/hooks/usePaymentMethods.ts create mode 100644 src/features/cashier/types.ts create mode 100644 src/features/cashier/utils/methodMapper.ts create mode 100644 src/services/api.ts diff --git a/src/App.scss b/src/App.scss index 230f20f..4d7abed 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,6 +1,6 @@ // App component styles using BEM methodology -.app { +.cashier-app { // Root container max-width: 1280px; margin: 0 auto; @@ -8,7 +8,7 @@ text-align: center; } -.app__logo { +.cashier-app__logo { height: 6em; padding: 1.5em; will-change: filter; @@ -40,11 +40,11 @@ } } -.app__card { +.cashier-app__card { padding: 2em; } -.app__read-the-docs { +.cashier-app__read-the-docs { color: #888; } diff --git a/src/App.tsx b/src/App.tsx index 489c642..f18b9dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,11 @@ -import PaymentMethodsList from '@/features/payment-methods/PaymentMethodsList/PaymentMethodsList' -import EmptyPaymentMethods from '@/features/payment-methods/EmptyPaymentMethods/EmptyPaymentMethods' -import type { IPaymentMethod } from '@/features/payment-methods/types' -import { PAYMENT_METHODS } from '@/constants/payment' -import './App.scss' +import Cashier from '@/features/cashier/Cashier' +import '@/App.scss' function App() { - const handleMethodSelect = (method: IPaymentMethod) => { - console.log('Selected payment method:', method) - // Handle payment method selection here - } - + return ( -
- {PAYMENT_METHODS.length === 0 ? ( - - ) : ( - - )} +
+
) } diff --git a/src/components/Error/Error.scss b/src/components/Error/Error.scss new file mode 100644 index 0000000..ddc4c28 --- /dev/null +++ b/src/components/Error/Error.scss @@ -0,0 +1,30 @@ +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1rem; +} + +.error__message { + color: #d32f2f; + font-size: 1rem; + text-align: center; +} + +.error__retry { + padding: 0.5rem 1.5rem; + background-color: #646cff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; + + &:hover { + background-color: #535bf2; + } +} + diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx new file mode 100644 index 0000000..6a02a8c --- /dev/null +++ b/src/components/Error/Error.tsx @@ -0,0 +1,22 @@ +import './Error.scss' + +interface IErrorProps { + message: string; + onRetry?: () => void; +} + +function Error({ message, onRetry }: IErrorProps) { + return ( +
+

{message}

+ {onRetry && ( + + )} +
+ ); +} + +export default Error; + diff --git a/src/components/Loading/Loading.scss b/src/components/Loading/Loading.scss new file mode 100644 index 0000000..d727a89 --- /dev/null +++ b/src/components/Loading/Loading.scss @@ -0,0 +1,28 @@ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1rem; +} + +.loading__spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #646cff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading__text { + color: #666; + font-size: 1rem; +} + diff --git a/src/components/Loading/Loading.tsx b/src/components/Loading/Loading.tsx new file mode 100644 index 0000000..f4a0a9a --- /dev/null +++ b/src/components/Loading/Loading.tsx @@ -0,0 +1,13 @@ +import './Loading.scss' + +function Loading() { + return ( +
+
+

Loading payment methods...

+
+ ); +} + +export default Loading; + diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..cc3101e --- /dev/null +++ b/src/config/api.ts @@ -0,0 +1,31 @@ +// Use proxy in development, direct URL in production +export const API_BASE_URL = import.meta.env.DEV + ? '/api/v1' + : 'https://cashier-backend.brgoperations.com/api/v1'; + +export const TMerchant = { + DATA_SPIN: 'DATA_SPIN', + WIN_BOT: 'WIN_BOT', + VARK_SERVICES: 'VARK_SERVICES', + BETRISE: 'BETRISE', +} as const; + +export type TMerchant = typeof TMerchant[keyof typeof TMerchant]; + +export const MERCHANT_API_KEYS: Record = { + [TMerchant.DATA_SPIN]: '10000000-0000-0000-0000-000000000001', + [TMerchant.WIN_BOT]: '20000000-0000-0000-0000-000000000002', + [TMerchant.VARK_SERVICES]: '30000000-0000-0000-0000-000000000003', + [TMerchant.BETRISE]: '40000000-0000-0000-0000-000000000004', +}; + +export const MERCHANT_IDS: Record = { + [TMerchant.DATA_SPIN]: '10000000-0000-0000-0000-000000000001', + [TMerchant.WIN_BOT]: '20000000-0000-0000-0000-000000000002', + [TMerchant.VARK_SERVICES]: '30000000-0000-0000-0000-000000000003', + [TMerchant.BETRISE]: '40000000-0000-0000-0000-000000000004', +}; + +// Default merchant (can be changed based on requirements) +export const DEFAULT_MERCHANT = TMerchant.WIN_BOT; + diff --git a/src/features/cashier/Cashier.scss b/src/features/cashier/Cashier.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/features/cashier/Cashier.tsx b/src/features/cashier/Cashier.tsx new file mode 100644 index 0000000..ffb6840 --- /dev/null +++ b/src/features/cashier/Cashier.tsx @@ -0,0 +1,38 @@ +import PaymentMethodsList from '@/features/payment-methods/PaymentMethodsList/PaymentMethodsList' +import EmptyPaymentMethods from '@/features/payment-methods/EmptyPaymentMethods/EmptyPaymentMethods' +import Loading from '@/components/Loading/Loading' +import Error from '@/components/Error/Error' +import type { IPaymentMethod } from '@/features/payment-methods/types' +import { usePaymentMethods } from './hooks/usePaymentMethods' + +function Cashier() { + const { methods: apiMethods, loading, error } = usePaymentMethods(); + + const handleMethodSelect = (method: IPaymentMethod) => { + console.log('Selected payment method:', method) + // Handle payment method selection here + } + + if (loading) { + return + } + + if (error) { + return + } + + return ( + <> + {apiMethods.length === 0 ? ( + + ) : ( + + )} + + ) +} + +export default Cashier diff --git a/src/features/cashier/hooks/useCurrencies.ts b/src/features/cashier/hooks/useCurrencies.ts new file mode 100644 index 0000000..e018eaf --- /dev/null +++ b/src/features/cashier/hooks/useCurrencies.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { apiService } from '@/services/api'; +import type { IApiCurrency } from '../types'; +import type { TMerchant } from '@/config/api'; + +export function useCurrencies(merchant?: TMerchant) { + const [currencies, setCurrencies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchCurrencies = async () => { + try { + setLoading(true); + setError(null); + const data = await apiService.getCurrencies(merchant); + setCurrencies(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch currencies')); + } finally { + setLoading(false); + } + }; + + fetchCurrencies(); + }, [merchant]); + + return { currencies, loading, error }; +} + diff --git a/src/features/cashier/hooks/usePayment.ts b/src/features/cashier/hooks/usePayment.ts new file mode 100644 index 0000000..81bf40d --- /dev/null +++ b/src/features/cashier/hooks/usePayment.ts @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { apiService } from '@/services/api'; +import { MERCHANT_IDS, DEFAULT_MERCHANT, type TMerchant } from '@/config/api'; +import type { IPaymentRequest, IPaymentResponse } from '../types'; + +export function usePayment() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [paymentUrl, setPaymentUrl] = useState(null); + + const initiatePayment = async ( + paymentRequest: Omit, + merchant: TMerchant = DEFAULT_MERCHANT + ) => { + try { + setLoading(true); + setError(null); + setPaymentUrl(null); + + const fullRequest: IPaymentRequest = { + ...paymentRequest, + merchant_id: MERCHANT_IDS[merchant], + }; + + const response = await apiService.initiatePayment(fullRequest, merchant); + setPaymentUrl(response.payment_url); + return response; + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to initiate payment'); + setError(error); + throw error; + } finally { + setLoading(false); + } + }; + + return { + initiatePayment, + loading, + error, + paymentUrl, + }; +} + diff --git a/src/features/cashier/hooks/usePaymentMethods.ts b/src/features/cashier/hooks/usePaymentMethods.ts new file mode 100644 index 0000000..a004576 --- /dev/null +++ b/src/features/cashier/hooks/usePaymentMethods.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { apiService } from '@/services/api'; +import type { TMerchant } from '@/config/api'; +import type { IPaymentMethod } from '@/features/payment-methods/types'; + +export function usePaymentMethods(merchant?: TMerchant) { + const [methods, setMethods] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMethods = async () => { + try { + setLoading(true); + setError(null); + const data = await apiService.getMethods(merchant); + setMethods(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch payment methods')); + } finally { + setLoading(false); + } + }; + + fetchMethods(); + }, [merchant]); + + return { methods, loading, error }; +} + diff --git a/src/features/cashier/types.ts b/src/features/cashier/types.ts new file mode 100644 index 0000000..4c1f4a7 --- /dev/null +++ b/src/features/cashier/types.ts @@ -0,0 +1,50 @@ +export interface IApiMethod { + code: string; + name: string; +} + +export interface IApiMethodsResponse { + data: IApiMethod[]; +} + +export interface IApiCurrency { + currency: string; +} + +export interface IApiCurrenciesResponse { + data: IApiCurrency[]; +} + +export interface ICustomer { + id?: string; + name?: string; + email?: string; + phone?: string; + account?: string; // IBAN or similar - required on withdrawal + ip?: string; + country?: string; + city?: string; + zip?: string; + address?: string; +} + +export interface IRedirect { + success: string; // URL where to redirect after payment success + cancel: string; // URL where to redirect after payment cancel + error: string; // URL where to redirect if error occur +} + +export interface IPaymentRequest { + merchant_id: string; // same id as in header + payment_type: 'deposit' | 'withdrawal'; + method: string; + customer: ICustomer; + currency: string; + amount: number; // float64 + redirect: IRedirect; +} + +export interface IPaymentResponse { + payment_url: string; // URL where customer needs to continue with payment flow +} + diff --git a/src/features/cashier/utils/methodMapper.ts b/src/features/cashier/utils/methodMapper.ts new file mode 100644 index 0000000..24e851d --- /dev/null +++ b/src/features/cashier/utils/methodMapper.ts @@ -0,0 +1,26 @@ +import type { IApiMethod } from '../types'; +import type { IPaymentMethod } from '@/features/payment-methods/types'; + +const METHOD_TYPES: Record = { + bankin: 'Banking', + bankpay: 'Banking', + papel: 'Digital Wallet', + mefete: 'Digital Wallet', + pep: 'Digital Wallet', +}; + +export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number): IPaymentMethod { + const methodCode = apiMethod.code.toLowerCase(); + + return { + id: `${methodCode}-${index}`, + name: apiMethod.name, // Use the name from API + type: METHOD_TYPES[methodCode] || 'Payment', + isActive: true, + }; +} + +export function mapApiMethodsToPaymentMethods(apiMethods: IApiMethod[]): IPaymentMethod[] { + return apiMethods.map((apiMethod, index) => mapApiMethodToPaymentMethod(apiMethod, index)); +} + diff --git a/src/main.tsx b/src/main.tsx index 28bbff5..7d831d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,7 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from '@/App.tsx' import './index.scss' createRoot(document.getElementById('root')!).render( - - - , + ) diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..ea14f85 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,75 @@ +import { API_BASE_URL, MERCHANT_API_KEYS, TMerchant, DEFAULT_MERCHANT } from '@/config/api'; +import type { IApiMethodsResponse, IApiCurrency, IApiCurrenciesResponse, IPaymentRequest, IPaymentResponse } from '@/features/cashier/types'; +import { mapApiMethodsToPaymentMethods } from '@/features/cashier/utils/methodMapper'; +import type { IPaymentMethod } from '@/features/payment-methods/types'; + +class ApiService { + + private methods: IPaymentMethod[] = [] + private currencies: IApiCurrency[] = [] + + private getAuthHeader(merchant: TMerchant = DEFAULT_MERCHANT): HeadersInit { + const apiKey = MERCHANT_API_KEYS[merchant]; + return { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }; + } + + async getMethods(merchant: TMerchant = DEFAULT_MERCHANT): Promise { + if (this.methods.length > 0) { + return this.methods + } + const response = await fetch(`${API_BASE_URL}/methods`, { + method: 'GET', + headers: this.getAuthHeader(merchant), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch methods: ${response.statusText}`); + } + + const data: IApiMethodsResponse = await response.json(); + + this.methods = mapApiMethodsToPaymentMethods(data.data); + + return this.methods; + } + + async getCurrencies(merchant: TMerchant = DEFAULT_MERCHANT): Promise { + if (this.currencies.length > 0) { + return this.currencies + } + const response = await fetch(`${API_BASE_URL}/currencies`, { + method: 'GET', + headers: this.getAuthHeader(merchant), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch currencies: ${response.statusText}`); + } + + const data: IApiCurrenciesResponse = await response.json(); + return data.data; + } + + async initiatePayment( + paymentRequest: IPaymentRequest, + merchant: TMerchant = DEFAULT_MERCHANT + ): Promise { + const response = await fetch(`${API_BASE_URL}/payment`, { + method: 'POST', + headers: this.getAuthHeader(merchant), + body: JSON.stringify(paymentRequest), + }); + + if (!response.ok) { + throw new Error(`Failed to initiate payment: ${response.statusText}`); + } + + return response.json(); + } +} + +export const apiService = new ApiService(); + diff --git a/vite.config.ts b/vite.config.ts index 574f6c2..0f3cc94 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,4 +13,13 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + proxy: { + '/api': { + target: 'https://cashier-backend.brgoperations.com', + changeOrigin: true, + secure: true, + }, + }, + }, })