Created Payment methods page

This commit is contained in:
Mitchell Magro 2025-12-18 22:14:11 +01:00
parent 0149c0f341
commit e723dde47f
17 changed files with 436 additions and 27 deletions

View File

@ -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;
}

View File

@ -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 (
<div className="app">
{PAYMENT_METHODS.length === 0 ? (
<EmptyPaymentMethods />
) : (
<PaymentMethodsList
methods={PAYMENT_METHODS}
onMethodSelect={handleMethodSelect}
/>
)}
<div className="cashier-app">
<Cashier />
</div>
)
}

View File

@ -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;
}
}

View File

@ -0,0 +1,22 @@
import './Error.scss'
interface IErrorProps {
message: string;
onRetry?: () => void;
}
function Error({ message, onRetry }: IErrorProps) {
return (
<div className="error">
<p className="error__message">{message}</p>
{onRetry && (
<button className="error__retry" onClick={onRetry}>
Retry
</button>
)}
</div>
);
}
export default Error;

View File

@ -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;
}

View File

@ -0,0 +1,13 @@
import './Loading.scss'
function Loading() {
return (
<div className="loading">
<div className="loading__spinner"></div>
<p className="loading__text">Loading payment methods...</p>
</div>
);
}
export default Loading;

31
src/config/api.ts Normal file
View File

@ -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, string> = {
[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, string> = {
[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;

View File

View File

@ -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 <Loading />
}
if (error) {
return <Error message={`Error loading payment methods: ${error.message}`} />
}
return (
<>
{apiMethods.length === 0 ? (
<EmptyPaymentMethods />
) : (
<PaymentMethodsList
methods={apiMethods}
onMethodSelect={handleMethodSelect}
/>
)}
</>
)
}
export default Cashier

View File

@ -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<IApiCurrency[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}

View File

@ -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<Error | null>(null);
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const initiatePayment = async (
paymentRequest: Omit<IPaymentRequest, 'merchant_id'>,
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,
};
}

View File

@ -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<IPaymentMethod[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}

View File

@ -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
}

View File

@ -0,0 +1,26 @@
import type { IApiMethod } from '../types';
import type { IPaymentMethod } from '@/features/payment-methods/types';
const METHOD_TYPES: Record<string, string> = {
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));
}

View File

@ -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(
<StrictMode>
<App />
</StrictMode>,
<App />
)

75
src/services/api.ts Normal file
View File

@ -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<IPaymentMethod[]> {
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<IApiCurrency[]> {
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<IPaymentResponse> {
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();

View File

@ -13,4 +13,13 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'https://cashier-backend.brgoperations.com',
changeOrigin: true,
secure: true,
},
},
},
})