Added Redirection Pages
This commit is contained in:
parent
92d2d3f058
commit
c405d22fbd
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.vite
|
||||
.env*
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@ -40,15 +40,15 @@ export default defineConfig([
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
import reactX from 'eslint-plugin-react-x';
|
||||
import reactDom from 'eslint-plugin-react-dom';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
@ -69,5 +69,5 @@ export default defineConfig([
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
```
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
@ -20,4 +20,4 @@ export default defineConfig([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
2760
package-lock.json
generated
Normal file
2760
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,11 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@ -23,6 +25,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.97.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
.cashier-app {
|
||||
// Root container
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@ -47,4 +47,3 @@
|
||||
.cashier-app__read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@ -1,13 +1,22 @@
|
||||
import Cashier from '@/features/cashier/Cashier'
|
||||
import '@/App.scss'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Cashier from '@/features/cashier/Cashier';
|
||||
import { CashierProvider } from '@/features/cashier/context/CashierContext';
|
||||
import PaymentStatus from '@/pages/PaymentStatus/PaymentStatus';
|
||||
import '@/App.scss';
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<div className="cashier-app">
|
||||
<Cashier />
|
||||
<BrowserRouter>
|
||||
<CashierProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Cashier />} />
|
||||
<Route path="/result" element={<PaymentStatus />} />
|
||||
</Routes>
|
||||
</CashierProvider>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@ -1,19 +1,4 @@
|
||||
.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 {
|
||||
.close-button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
@ -27,4 +12,3 @@
|
||||
background-color: #535bf2;
|
||||
}
|
||||
}
|
||||
|
||||
16
src/components/Button/Button.tsx
Normal file
16
src/components/Button/Button.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import './Button.scss';
|
||||
|
||||
interface ICloseButtonProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function Button({ onClick, label = 'Close' }: ICloseButtonProps) {
|
||||
return (
|
||||
<button className="close-button" onClick={onClick}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@ -1,22 +0,0 @@
|
||||
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;
|
||||
|
||||
@ -17,12 +17,15 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading__text {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import './Loading.scss'
|
||||
import './Loading.scss';
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
@ -10,4 +10,3 @@ function Loading() {
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
|
||||
|
||||
25
src/components/Status/Status.scss
Normal file
25
src/components/Status/Status.scss
Normal file
@ -0,0 +1,25 @@
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status__message {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status--error .status__message {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.status--success .status__message {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status--cancel .status__message {
|
||||
color: #f57c00;
|
||||
}
|
||||
27
src/components/Status/Status.tsx
Normal file
27
src/components/Status/Status.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import './Status.scss';
|
||||
import Button from '../Button/Button';
|
||||
|
||||
type TStatusType = 'error' | 'success' | 'cancel';
|
||||
|
||||
interface IStatusProps {
|
||||
type: TStatusType;
|
||||
message: string;
|
||||
onAction?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
function Status({ type, message, onAction, actionLabel }: IStatusProps) {
|
||||
const getDefaultActionLabel = () => {
|
||||
if (actionLabel) return actionLabel;
|
||||
return type === 'error' ? 'Retry' : 'Close';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`status status--${type}`}>
|
||||
<p className="status__message">{message}</p>
|
||||
{onAction && <Button onClick={onAction} label={getDefaultActionLabel()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Status;
|
||||
@ -1,6 +1,6 @@
|
||||
// Use proxy in development, direct URL in production
|
||||
export const API_BASE_URL = import.meta.env.DEV
|
||||
? '/api/v1'
|
||||
export const API_BASE_URL = import.meta.env.DEV
|
||||
? '/api/v1'
|
||||
: 'https://cashier-backend.brgoperations.com/api/v1';
|
||||
|
||||
export const TMerchant = {
|
||||
@ -10,7 +10,7 @@ export const TMerchant = {
|
||||
BETRISE: 'BETRISE',
|
||||
} as const;
|
||||
|
||||
export type TMerchant = typeof TMerchant[keyof typeof TMerchant];
|
||||
export type TMerchant = (typeof TMerchant)[keyof typeof TMerchant];
|
||||
|
||||
export const MERCHANT_API_KEYS: Record<TMerchant, string> = {
|
||||
[TMerchant.DATA_SPIN]: '10000000-0000-0000-0000-000000000001',
|
||||
@ -28,4 +28,3 @@ export const MERCHANT_IDS: Record<TMerchant, string> = {
|
||||
|
||||
// Default merchant (can be changed based on requirements)
|
||||
export const DEFAULT_MERCHANT = TMerchant.WIN_BOT;
|
||||
|
||||
|
||||
220
src/config/cashierConfig.ts
Normal file
220
src/config/cashierConfig.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import type { ICustomer, IRedirect } from '@/features/cashier/types';
|
||||
|
||||
export interface ICashierConfig {
|
||||
// Core payment configuration
|
||||
paymentType?: 'deposit' | 'withdrawal';
|
||||
currency?: string;
|
||||
amount?: number;
|
||||
account?: string; // For withdrawal
|
||||
|
||||
// Customer information
|
||||
customer?: Partial<ICustomer>;
|
||||
|
||||
// Redirect URLs
|
||||
redirect?: Partial<IRedirect>;
|
||||
|
||||
// Merchant and session info
|
||||
merchantId?: string;
|
||||
userId?: string; // Maps to customer.id
|
||||
sessionId?: string;
|
||||
environment?: 'production' | 'staging' | 'development';
|
||||
|
||||
// UI/UX configuration
|
||||
locale?: string;
|
||||
fetchConfig?: boolean;
|
||||
predefinedValues?: Record<string, unknown>;
|
||||
prefillCreditcardHolder?: boolean;
|
||||
showAccounts?: string;
|
||||
autoOpenFirstPaymentMethod?: boolean;
|
||||
showTransactionOverview?: boolean;
|
||||
showAmountLimits?: boolean;
|
||||
receiptExcludeKeys?: string[];
|
||||
|
||||
// Attributes
|
||||
attributes?: {
|
||||
hostUri?: string;
|
||||
bootstrapVersion?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function getCashierConfig(): ICashierConfig {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const config: ICashierConfig = {};
|
||||
|
||||
// Payment type from URL (support both 'method' and 'payment_type' for backward compatibility)
|
||||
const method = params.get('method');
|
||||
const paymentType = params.get('payment_type') || method;
|
||||
if (paymentType === 'deposit' || paymentType === 'withdrawal') {
|
||||
config.paymentType = paymentType;
|
||||
}
|
||||
|
||||
// Currency from URL
|
||||
const currency = params.get('currency');
|
||||
if (currency) {
|
||||
config.currency = currency;
|
||||
}
|
||||
|
||||
// Amount from URL
|
||||
const amount = params.get('amount');
|
||||
if (amount && amount !== 'undefined') {
|
||||
const parsedAmount = parseFloat(amount);
|
||||
if (!isNaN(parsedAmount)) {
|
||||
config.amount = parsedAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// Account from URL (for withdrawal)
|
||||
const account = params.get('account');
|
||||
if (account) {
|
||||
config.account = account;
|
||||
}
|
||||
|
||||
// Merchant and session info
|
||||
const merchantId = params.get('merchantId');
|
||||
if (merchantId) {
|
||||
config.merchantId = merchantId;
|
||||
}
|
||||
|
||||
const userId = params.get('userId');
|
||||
if (userId) {
|
||||
config.userId = userId;
|
||||
}
|
||||
|
||||
const sessionId = params.get('sessionId');
|
||||
if (sessionId) {
|
||||
config.sessionId = sessionId;
|
||||
}
|
||||
|
||||
const environment = params.get('environment');
|
||||
if (environment === 'production' || environment === 'staging' || environment === 'development') {
|
||||
config.environment = environment;
|
||||
}
|
||||
|
||||
// Customer data from URL (support both new 'userId' and old 'customer_id' format)
|
||||
const customer: Partial<ICustomer> = {};
|
||||
// TODO: remove this default value
|
||||
const customerId =
|
||||
// params.get('customer_id') || userId ||
|
||||
'12345';
|
||||
|
||||
if (customerId) customer.id = customerId;
|
||||
|
||||
if (Object.keys(customer).length > 0) {
|
||||
config.customer = customer;
|
||||
}
|
||||
|
||||
// Redirect URLs from URL
|
||||
const redirect: Partial<IRedirect> = {};
|
||||
const successUrl = params.get('redirect_success');
|
||||
const cancelUrl = params.get('redirect_cancel');
|
||||
const errorUrl = params.get('redirect_error');
|
||||
|
||||
if (successUrl) redirect.success = successUrl;
|
||||
if (cancelUrl) redirect.cancel = cancelUrl;
|
||||
if (errorUrl) redirect.error = errorUrl;
|
||||
|
||||
if (Object.keys(redirect).length > 0) {
|
||||
config.redirect = redirect;
|
||||
}
|
||||
|
||||
// UI/UX configuration
|
||||
const locale = params.get('locale');
|
||||
if (locale) {
|
||||
config.locale = locale;
|
||||
}
|
||||
|
||||
const fetchConfig = params.get('fetchConfig');
|
||||
if (fetchConfig !== null) {
|
||||
config.fetchConfig = fetchConfig === 'true';
|
||||
}
|
||||
|
||||
// Parse predefinedValues (JSON object)
|
||||
const predefinedValuesStr = params.get('predefinedValues');
|
||||
if (predefinedValuesStr && predefinedValuesStr !== '[object Object]') {
|
||||
try {
|
||||
const decoded = decodeURIComponent(predefinedValuesStr);
|
||||
config.predefinedValues = JSON.parse(decoded);
|
||||
} catch {
|
||||
// If parsing fails, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
const prefillCreditcardHolder = params.get('prefillCreditcardHolder');
|
||||
if (prefillCreditcardHolder !== null) {
|
||||
config.prefillCreditcardHolder = prefillCreditcardHolder === 'true';
|
||||
}
|
||||
|
||||
const showAccounts = params.get('showAccounts');
|
||||
if (showAccounts) {
|
||||
config.showAccounts = showAccounts;
|
||||
}
|
||||
|
||||
const autoOpenFirstPaymentMethod = params.get('autoOpenFirstPaymentMethod');
|
||||
if (autoOpenFirstPaymentMethod !== null) {
|
||||
config.autoOpenFirstPaymentMethod = autoOpenFirstPaymentMethod === 'true';
|
||||
}
|
||||
|
||||
const showTransactionOverview = params.get('showTransactionOverview');
|
||||
if (showTransactionOverview !== null) {
|
||||
config.showTransactionOverview = showTransactionOverview === 'true';
|
||||
}
|
||||
|
||||
const showAmountLimits = params.get('showAmountLimits');
|
||||
if (showAmountLimits !== null) {
|
||||
config.showAmountLimits = showAmountLimits === 'true';
|
||||
}
|
||||
|
||||
// Receipt exclude keys (comma-separated string)
|
||||
const receiptExcludeKeys = params.get('receiptExcludeKeys');
|
||||
if (receiptExcludeKeys) {
|
||||
config.receiptExcludeKeys = receiptExcludeKeys.split(',').map(key => key.trim());
|
||||
}
|
||||
|
||||
// Attributes (handle attributes.* parameters)
|
||||
const attributes: Record<string, unknown> = {};
|
||||
params.forEach((value, key) => {
|
||||
if (key.startsWith('attributes.')) {
|
||||
const attrKey = key.replace('attributes.', '');
|
||||
attributes[attrKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(attributes).length > 0) {
|
||||
config.attributes = attributes;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Default redirect URLs (fallback if not provided)
|
||||
// Convert relative paths to absolute URLs for payment provider redirects
|
||||
export function getDefaultRedirectUrls(): IRedirect {
|
||||
const currentOrigin = window.location.origin;
|
||||
const currentPath = window.location.pathname.replace(/\/[^/]*$/, ''); // Remove last path segment if any
|
||||
|
||||
return {
|
||||
success: `${currentOrigin}${currentPath}/result?status=success`,
|
||||
cancel: `${currentOrigin}${currentPath}/result?status=cancel`,
|
||||
error: `${currentOrigin}${currentPath}/result?status=error`,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert relative URLs to absolute URLs
|
||||
export function normalizeRedirectUrl(url: string): string {
|
||||
// If it's already an absolute URL, return as-is
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If it's a relative path, convert to absolute
|
||||
const currentOrigin = window.location.origin;
|
||||
const currentPath = window.location.pathname.replace(/\/[^/]*$/, '');
|
||||
|
||||
// Handle query parameters - if URL already has query params, preserve them
|
||||
// Otherwise, ensure it starts with /
|
||||
const cleanPath = url.startsWith('/') ? url : `/${url}`;
|
||||
|
||||
return `${currentOrigin}${currentPath}${cleanPath}`;
|
||||
}
|
||||
@ -1,36 +1,36 @@
|
||||
import type { IPaymentMethod } from "@/features/payment-methods/types";
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
|
||||
export const PAYMENT_METHODS: IPaymentMethod[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Credit Card',
|
||||
type: 'Card Payment',
|
||||
isActive: true,
|
||||
minAmount: 10,
|
||||
maxAmount: 5000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'PayPal',
|
||||
type: 'Digital Wallet',
|
||||
isActive: false,
|
||||
minAmount: 5,
|
||||
maxAmount: 10000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bank Transfer',
|
||||
type: 'Banking',
|
||||
isActive: false,
|
||||
minAmount: 50,
|
||||
maxAmount: 50000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Cryptocurrency',
|
||||
type: 'Crypto',
|
||||
isActive: false,
|
||||
minAmount: 20,
|
||||
maxAmount: 20000,
|
||||
},
|
||||
]
|
||||
{
|
||||
id: '1',
|
||||
name: 'Credit Card',
|
||||
type: 'Card Payment',
|
||||
isActive: true,
|
||||
minAmount: 10,
|
||||
maxAmount: 5000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'PayPal',
|
||||
type: 'Digital Wallet',
|
||||
isActive: false,
|
||||
minAmount: 5,
|
||||
maxAmount: 10000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bank Transfer',
|
||||
type: 'Banking',
|
||||
isActive: false,
|
||||
minAmount: 50,
|
||||
maxAmount: 50000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Cryptocurrency',
|
||||
type: 'Crypto',
|
||||
isActive: false,
|
||||
minAmount: 20,
|
||||
maxAmount: 20000,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,38 +1,83 @@
|
||||
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'
|
||||
import { useEffect } from 'react';
|
||||
import PaymentMethodsList from '@/features/payment-methods/PaymentMethodsList/PaymentMethodsList';
|
||||
import EmptyPaymentMethods from '@/features/payment-methods/EmptyPaymentMethods/EmptyPaymentMethods';
|
||||
import PaymentForm from './PaymentForm/PaymentForm';
|
||||
import Loading from '@/components/Loading/Loading';
|
||||
import Status from '@/components/Status/Status';
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
import type { IPaymentRequest } from './types';
|
||||
import { useCashier } from './context/CashierContext';
|
||||
import { getCashierConfig } from '@/config/cashierConfig';
|
||||
|
||||
function Cashier() {
|
||||
const { methods: apiMethods, loading, error } = usePaymentMethods();
|
||||
const {
|
||||
state,
|
||||
methods,
|
||||
currencies,
|
||||
selectedMethod,
|
||||
error,
|
||||
paymentUrl,
|
||||
setSelectedMethod,
|
||||
initiatePayment,
|
||||
} = useCashier();
|
||||
|
||||
// Redirect to payment URL when state is redirecting
|
||||
useEffect(() => {
|
||||
if (state === 'redirecting' && paymentUrl) {
|
||||
window.location.href = paymentUrl;
|
||||
}
|
||||
}, [state, paymentUrl]);
|
||||
|
||||
const handleMethodSelect = (method: IPaymentMethod) => {
|
||||
console.log('Selected payment method:', method)
|
||||
// Handle payment method selection here
|
||||
setSelectedMethod(method);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Omit<IPaymentRequest, 'merchant_id'>) => {
|
||||
try {
|
||||
await initiatePayment(formData);
|
||||
} catch {
|
||||
// Error is handled by the service state
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedMethod(null);
|
||||
};
|
||||
|
||||
if (state === 'loading') {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading />
|
||||
if (state === 'error' && error) {
|
||||
return <Status type="error" message={`Error: ${error.message}`} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error message={`Error loading payment methods: ${error.message}`} />
|
||||
if (state === 'redirecting') {
|
||||
return <div>Redirecting to payment...</div>;
|
||||
}
|
||||
|
||||
if (selectedMethod) {
|
||||
return (
|
||||
<PaymentForm
|
||||
method={selectedMethod}
|
||||
currencies={currencies}
|
||||
config={getCashierConfig()}
|
||||
onSubmit={handleFormSubmit}
|
||||
onBack={handleBack}
|
||||
isLoading={state === 'submitting'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{apiMethods.length === 0 ? (
|
||||
{state === 'ready' && methods.length === 0 ? (
|
||||
<EmptyPaymentMethods />
|
||||
) : (
|
||||
<PaymentMethodsList
|
||||
methods={apiMethods}
|
||||
onMethodSelect={handleMethodSelect}
|
||||
/>
|
||||
)}
|
||||
) : state === 'ready' ? (
|
||||
<PaymentMethodsList methods={methods} onMethodSelect={handleMethodSelect} />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Cashier
|
||||
export default Cashier;
|
||||
|
||||
158
src/features/cashier/PaymentForm/PaymentForm.scss
Normal file
158
src/features/cashier/PaymentForm/PaymentForm.scss
Normal file
@ -0,0 +1,158 @@
|
||||
.payment-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.payment-form__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.payment-form__back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #646cff;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 1rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-form__title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.payment-form__method {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.payment-form__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.payment-form__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.payment-form__section-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.payment-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.payment-form__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.payment-form__required {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.payment-form__input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.payment-form__radio-group {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.payment-form__radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
|
||||
input[type='radio'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-form__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.payment-form__button {
|
||||
padding: 0.75rem 2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&--primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/features/cashier/PaymentForm/PaymentForm.tsx
Normal file
199
src/features/cashier/PaymentForm/PaymentForm.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { bem } from '@/utils/bem';
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
import type { IApiCurrency, ICustomer, IRedirect } from '../types';
|
||||
import type { ICashierConfig } from '@/config/cashierConfig';
|
||||
import { getDefaultRedirectUrls, normalizeRedirectUrl } from '@/config/cashierConfig';
|
||||
import './PaymentForm.scss';
|
||||
|
||||
const block = 'payment-form';
|
||||
|
||||
interface IPaymentFormProps {
|
||||
method: IPaymentMethod;
|
||||
currencies: IApiCurrency[];
|
||||
config: ICashierConfig;
|
||||
onSubmit: (data: {
|
||||
payment_type: 'deposit' | 'withdrawal';
|
||||
method: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
customer: ICustomer;
|
||||
redirect: IRedirect;
|
||||
account?: string;
|
||||
}) => void;
|
||||
onBack: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function PaymentForm({
|
||||
method,
|
||||
currencies,
|
||||
config,
|
||||
onSubmit,
|
||||
onBack,
|
||||
isLoading = false,
|
||||
}: IPaymentFormProps) {
|
||||
const [paymentType, setPaymentType] = useState<'deposit' | 'withdrawal'>(
|
||||
config?.paymentType || 'deposit'
|
||||
);
|
||||
const [currency, setCurrency] = useState<string>(config?.currency || currencies[0]?.code || '');
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [account, setAccount] = useState<string>(config.account || '');
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Build customer object from config (all fields required: id, first_name, last_name, email)
|
||||
const customer: ICustomer = {
|
||||
id: config.customer?.id || '',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
};
|
||||
|
||||
// Build redirect URLs (use config or defaults)
|
||||
// Convert relative URLs to absolute URLs for payment provider redirects
|
||||
const defaultRedirects = getDefaultRedirectUrls();
|
||||
const redirect: IRedirect = {
|
||||
success: normalizeRedirectUrl(config.redirect?.success || defaultRedirects.success),
|
||||
cancel: normalizeRedirectUrl(config.redirect?.cancel || defaultRedirects.cancel),
|
||||
error: normalizeRedirectUrl(config.redirect?.error || defaultRedirects.error),
|
||||
};
|
||||
|
||||
const submitData: {
|
||||
payment_type: 'deposit' | 'withdrawal';
|
||||
method: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
customer: ICustomer;
|
||||
redirect: IRedirect;
|
||||
account?: string;
|
||||
} = {
|
||||
payment_type: paymentType,
|
||||
method: method.code,
|
||||
currency,
|
||||
amount: parseFloat(amount),
|
||||
customer,
|
||||
redirect,
|
||||
};
|
||||
|
||||
// Include account for withdrawals
|
||||
if (paymentType === 'withdrawal') {
|
||||
submitData.account = account || config.account;
|
||||
}
|
||||
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
const showPaymentType = !config.paymentType;
|
||||
const showCurrency = !config.currency;
|
||||
const showAccount = paymentType === 'withdrawal' && !config.account;
|
||||
|
||||
return (
|
||||
<div className={block}>
|
||||
<div className={bem(block, 'header')}>
|
||||
<button className={bem(block, 'back')} onClick={onBack} type="button">
|
||||
← Back
|
||||
</button>
|
||||
<h2 className={bem(block, 'title')}>Payment Details</h2>
|
||||
<p className={bem(block, 'method')}>Method: {method.name}</p>
|
||||
</div>
|
||||
|
||||
<form className={bem(block, 'form')} onSubmit={handleSubmit}>
|
||||
{showPaymentType && (
|
||||
<div className={bem(block, 'section')}>
|
||||
<h3 className={bem(block, 'section-title')}>Payment Type</h3>
|
||||
<div className={bem(block, 'radio-group')}>
|
||||
<label className={bem(block, 'radio')}>
|
||||
<input
|
||||
type="radio"
|
||||
value="deposit"
|
||||
checked={paymentType === 'deposit'}
|
||||
onChange={e => setPaymentType(e.target.value as 'deposit' | 'withdrawal')}
|
||||
/>
|
||||
<span>Deposit</span>
|
||||
</label>
|
||||
<label className={bem(block, 'radio')}>
|
||||
<input
|
||||
type="radio"
|
||||
value="withdrawal"
|
||||
checked={paymentType === 'withdrawal'}
|
||||
onChange={e => setPaymentType(e.target.value as 'withdrawal')}
|
||||
/>
|
||||
<span>Withdrawal</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={bem(block, 'section')}>
|
||||
<h3 className={bem(block, 'section-title')}>Payment Information</h3>
|
||||
|
||||
{showCurrency && (
|
||||
<div className={bem(block, 'field')}>
|
||||
<label className={bem(block, 'label')}>
|
||||
Currency <span className={bem(block, 'required')}>*</span>
|
||||
</label>
|
||||
<select
|
||||
className={bem(block, 'input')}
|
||||
value={currency}
|
||||
onChange={e => setCurrency(e.target.value)}
|
||||
required
|
||||
>
|
||||
{currencies.map(curr => (
|
||||
<option key={curr.code} value={curr.code}>
|
||||
{curr.name} ({curr.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={bem(block, 'field')}>
|
||||
<label className={bem(block, 'label')}>
|
||||
Amount <span className={bem(block, 'required')}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className={bem(block, 'input')}
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
placeholder="Enter amount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAccount && (
|
||||
<div className={bem(block, 'field')}>
|
||||
<label className={bem(block, 'label')}>
|
||||
Account (IBAN) <span className={bem(block, 'required')}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={bem(block, 'input')}
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
required
|
||||
placeholder="Enter IBAN or account number"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={bem(block, 'actions')}>
|
||||
<button type="button" className={bem(block, 'button', 'secondary')} onClick={onBack}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={bem(block, 'button', 'primary')} disabled={isLoading}>
|
||||
{isLoading ? 'Processing...' : 'Submit Payment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentForm;
|
||||
116
src/features/cashier/context/CashierContext.tsx
Normal file
116
src/features/cashier/context/CashierContext.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cashierService } from '../services/CashierService';
|
||||
import type { TCashierFlowState, IApiCurrency, IPaymentRequest } from '../types';
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
import { DEFAULT_MERCHANT, type TMerchant } from '@/config/api';
|
||||
|
||||
interface ICashierContextValue {
|
||||
state: TCashierFlowState;
|
||||
methods: IPaymentMethod[];
|
||||
currencies: IApiCurrency[];
|
||||
selectedMethod: IPaymentMethod | null;
|
||||
error: Error | null;
|
||||
paymentUrl: string | null;
|
||||
loadData: (merchant?: TMerchant) => Promise<void>;
|
||||
setSelectedMethod: (method: IPaymentMethod | null) => void;
|
||||
initiatePayment: (
|
||||
paymentRequest: Omit<IPaymentRequest, 'merchant_id'>,
|
||||
merchant?: TMerchant
|
||||
) => Promise<void>;
|
||||
goBack: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const CashierContext = createContext<ICashierContextValue | null>(null);
|
||||
|
||||
interface ICashierProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ICashierState {
|
||||
state: TCashierFlowState;
|
||||
methods: IPaymentMethod[];
|
||||
currencies: IApiCurrency[];
|
||||
selectedMethod: IPaymentMethod | null;
|
||||
error: Error | null;
|
||||
paymentUrl: string | null;
|
||||
}
|
||||
|
||||
export function CashierProvider({ children }: ICashierProviderProps) {
|
||||
const [cashierState, setCashierState] = useState<ICashierState>(() => ({
|
||||
state: cashierService.getState(),
|
||||
methods: cashierService.getMethods(),
|
||||
currencies: cashierService.getCurrencies(),
|
||||
selectedMethod: cashierService.getSelectedMethod(),
|
||||
error: cashierService.getError(),
|
||||
paymentUrl: cashierService.getPaymentUrl(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = cashierService.subscribe(() => {
|
||||
setCashierState({
|
||||
state: cashierService.getState(),
|
||||
methods: cashierService.getMethods(),
|
||||
currencies: cashierService.getCurrencies(),
|
||||
selectedMethod: cashierService.getSelectedMethod(),
|
||||
error: cashierService.getError(),
|
||||
paymentUrl: cashierService.getPaymentUrl(),
|
||||
});
|
||||
});
|
||||
|
||||
// Initial load
|
||||
cashierService.loadData(DEFAULT_MERCHANT).catch(() => {
|
||||
// Error is handled by state
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const loadData = async (merchant: TMerchant = DEFAULT_MERCHANT) => {
|
||||
await cashierService.loadData(merchant);
|
||||
};
|
||||
|
||||
const initiatePayment = async (
|
||||
paymentRequest: Omit<IPaymentRequest, 'merchant_id'>,
|
||||
merchant: TMerchant = DEFAULT_MERCHANT
|
||||
) => {
|
||||
await cashierService.initiatePayment(paymentRequest, merchant);
|
||||
};
|
||||
|
||||
const setSelectedMethod = (method: IPaymentMethod | null) => {
|
||||
cashierService.setSelectedMethod(method);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
cashierService.goBack();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
cashierService.reset();
|
||||
};
|
||||
|
||||
const value: ICashierContextValue = {
|
||||
state: cashierState.state,
|
||||
methods: cashierState.methods,
|
||||
currencies: cashierState.currencies,
|
||||
selectedMethod: cashierState.selectedMethod,
|
||||
error: cashierState.error,
|
||||
paymentUrl: cashierState.paymentUrl,
|
||||
loadData,
|
||||
setSelectedMethod,
|
||||
initiatePayment,
|
||||
goBack,
|
||||
reset,
|
||||
};
|
||||
|
||||
return <CashierContext.Provider value={value}>{children}</CashierContext.Provider>;
|
||||
}
|
||||
|
||||
export const useCashier = (): ICashierContextValue => {
|
||||
const context = useContext(CashierContext);
|
||||
if (!context) {
|
||||
throw new Error('useCashier must be used within CashierProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -27,4 +27,3 @@ export function useCurrencies(merchant?: TMerchant) {
|
||||
|
||||
return { currencies, loading, error };
|
||||
}
|
||||
|
||||
|
||||
@ -41,4 +41,3 @@ export function usePayment() {
|
||||
paymentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -27,4 +27,3 @@ export function usePaymentMethods(merchant?: TMerchant) {
|
||||
|
||||
return { methods, loading, error };
|
||||
}
|
||||
|
||||
|
||||
122
src/features/cashier/services/CashierService.ts
Normal file
122
src/features/cashier/services/CashierService.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { apiService } from '@/services/api';
|
||||
import { DEFAULT_MERCHANT, MERCHANT_IDS, type TMerchant } from '@/config/api';
|
||||
import type { TCashierFlowState, IApiCurrency, IPaymentRequest } from '../types';
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
|
||||
type StateChangeListener = (state: TCashierFlowState) => void;
|
||||
|
||||
class CashierService {
|
||||
private state: TCashierFlowState = 'loading';
|
||||
private methods: IPaymentMethod[] = [];
|
||||
private currencies: IApiCurrency[] = [];
|
||||
private selectedMethod: IPaymentMethod | null = null;
|
||||
private error: Error | null = null;
|
||||
private paymentUrl: string | null = null;
|
||||
private listeners: Set<StateChangeListener> = new Set();
|
||||
|
||||
getState(): TCashierFlowState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getMethods(): IPaymentMethod[] {
|
||||
return this.methods;
|
||||
}
|
||||
|
||||
getCurrencies(): IApiCurrency[] {
|
||||
return this.currencies;
|
||||
}
|
||||
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
getPaymentUrl(): string | null {
|
||||
return this.paymentUrl;
|
||||
}
|
||||
|
||||
getSelectedMethod(): IPaymentMethod | null {
|
||||
return this.selectedMethod;
|
||||
}
|
||||
|
||||
setSelectedMethod(method: IPaymentMethod | null): void {
|
||||
this.selectedMethod = method;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private setState(newState: TCashierFlowState): void {
|
||||
if (this.state !== newState) {
|
||||
this.state = newState;
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: StateChangeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach(listener => listener(this.state));
|
||||
}
|
||||
|
||||
async loadData(merchant: TMerchant = DEFAULT_MERCHANT): Promise<void> {
|
||||
try {
|
||||
this.setState('loading');
|
||||
this.error = null;
|
||||
|
||||
const [methodsData, currenciesData] = await Promise.all([
|
||||
apiService.getMethods(merchant),
|
||||
apiService.getCurrencies(merchant),
|
||||
]);
|
||||
|
||||
this.methods = methodsData;
|
||||
this.currencies = currenciesData;
|
||||
this.setState('ready');
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err : new Error('Failed to load cashier data');
|
||||
this.setState('error');
|
||||
throw this.error;
|
||||
}
|
||||
}
|
||||
|
||||
async initiatePayment(
|
||||
paymentRequest: Omit<IPaymentRequest, 'merchant_id'>,
|
||||
merchant: TMerchant = DEFAULT_MERCHANT
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.setState('submitting');
|
||||
this.error = null;
|
||||
|
||||
const fullRequest: IPaymentRequest = {
|
||||
...paymentRequest,
|
||||
merchant_id: MERCHANT_IDS[merchant],
|
||||
};
|
||||
|
||||
const response = await apiService.initiatePayment(fullRequest, merchant);
|
||||
|
||||
this.paymentUrl = response.payment_url;
|
||||
this.setState('redirecting');
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err : new Error('Failed to initiate payment');
|
||||
this.setState('error');
|
||||
throw this.error;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = 'loading';
|
||||
this.selectedMethod = null;
|
||||
this.error = null;
|
||||
this.paymentUrl = null;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.selectedMethod = null;
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export const cashierService = new CashierService();
|
||||
@ -8,24 +8,21 @@ export interface IApiMethodsResponse {
|
||||
}
|
||||
|
||||
export interface IApiCurrency {
|
||||
currency: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IApiCurrenciesResponse {
|
||||
data: IApiCurrency[];
|
||||
}
|
||||
|
||||
export type TCashierFlowState = 'loading' | 'ready' | 'submitting' | 'redirecting' | 'error';
|
||||
|
||||
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;
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface IRedirect {
|
||||
@ -42,9 +39,9 @@ export interface IPaymentRequest {
|
||||
currency: string;
|
||||
amount: number; // float64
|
||||
redirect: IRedirect;
|
||||
account?: string; // Required for withdrawals (IBAN or account number)
|
||||
}
|
||||
|
||||
export interface IPaymentResponse {
|
||||
payment_url: string; // URL where customer needs to continue with payment flow
|
||||
}
|
||||
|
||||
|
||||
@ -11,9 +11,10 @@ const METHOD_TYPES: Record<string, string> = {
|
||||
|
||||
export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number): IPaymentMethod {
|
||||
const methodCode = apiMethod.code.toLowerCase();
|
||||
|
||||
|
||||
return {
|
||||
id: `${methodCode}-${index}`,
|
||||
code: apiMethod.code, // Store original code for API requests
|
||||
name: apiMethod.name, // Use the name from API
|
||||
type: METHOD_TYPES[methodCode] || 'Payment',
|
||||
isActive: true,
|
||||
@ -23,4 +24,3 @@ export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number
|
||||
export function mapApiMethodsToPaymentMethods(apiMethods: IApiMethod[]): IPaymentMethod[] {
|
||||
return apiMethods.map((apiMethod, index) => mapApiMethodToPaymentMethod(apiMethod, index));
|
||||
}
|
||||
|
||||
|
||||
@ -10,4 +10,3 @@
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
import { bem } from '@/utils/bem'
|
||||
import './EmptyPaymentMethods.scss'
|
||||
import { bem } from '@/utils/bem';
|
||||
import './EmptyPaymentMethods.scss';
|
||||
|
||||
const block = 'empty-payment-methods'
|
||||
const block = 'empty-payment-methods';
|
||||
|
||||
function EmptyPaymentMethods() {
|
||||
return (
|
||||
<div className={block}>
|
||||
<p className={bem(block, 'message')}>
|
||||
No payment methods available
|
||||
</p>
|
||||
<p className={bem(block, 'message')}>No payment methods available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyPaymentMethods;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// PaymentMethod component styles using BEM methodology
|
||||
|
||||
.payment-method {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
@ -70,4 +71,3 @@
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { bem } from '@/utils/bem'
|
||||
import { bem } from '@/utils/bem';
|
||||
import type { IPaymentMethod as PaymentMethodType } from '@/features/payment-methods/types';
|
||||
import './PaymentMethod.scss'
|
||||
import './PaymentMethod.scss';
|
||||
|
||||
const block = 'payment-method'
|
||||
const block = 'payment-method';
|
||||
|
||||
interface IPaymentMethodProps {
|
||||
method: PaymentMethodType;
|
||||
@ -10,17 +10,13 @@ interface IPaymentMethodProps {
|
||||
}
|
||||
|
||||
function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
|
||||
|
||||
const { name, type, isActive, icon } = method;
|
||||
const handleClick = () => {
|
||||
onSelect?.(method);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={bem(block, null, isActive ? 'active' : null)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={bem(block, null, isActive ? 'active' : null)} onClick={handleClick}>
|
||||
{icon && (
|
||||
<div className={bem(block, 'icon')}>
|
||||
<img src={icon} alt={name} />
|
||||
@ -29,10 +25,9 @@ function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
|
||||
<div className={bem(block, 'content')}>
|
||||
<h3 className={bem(block, 'name')}>{name}</h3>
|
||||
<p className={bem(block, 'type')}>{type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentMethod;
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
.payment-methods-list {
|
||||
width: 100%;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.payment-methods-list__title {
|
||||
@ -14,7 +13,6 @@
|
||||
|
||||
.payment-methods-list__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { bem } from '@/utils/bem'
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types'
|
||||
import PaymentMethodComponent from '@/features/payment-methods/PaymentMethod/PaymentMethod'
|
||||
import './PaymentMethodsList.scss'
|
||||
import { bem } from '@/utils/bem';
|
||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||
import PaymentMethodComponent from '@/features/payment-methods/PaymentMethod/PaymentMethod';
|
||||
import './PaymentMethodsList.scss';
|
||||
|
||||
const block = 'payment-methods-list'
|
||||
const block = 'payment-methods-list';
|
||||
|
||||
interface IPaymentMethodsListProps {
|
||||
methods: IPaymentMethod[];
|
||||
@ -15,12 +15,8 @@ function PaymentMethodsList({ methods, onMethodSelect }: IPaymentMethodsListProp
|
||||
<div className={block}>
|
||||
<h2 className={bem(block, 'title')}>Select Payment Method</h2>
|
||||
<div className={bem(block, 'grid')}>
|
||||
{methods.map((method) => (
|
||||
<PaymentMethodComponent
|
||||
key={method.id}
|
||||
method={method}
|
||||
onSelect={onMethodSelect}
|
||||
/>
|
||||
{methods.map(method => (
|
||||
<PaymentMethodComponent key={method.id} method={method} onSelect={onMethodSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -28,4 +24,3 @@ function PaymentMethodsList({ methods, onMethodSelect }: IPaymentMethodsListProp
|
||||
}
|
||||
|
||||
export default PaymentMethodsList;
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export interface IPaymentMethod {
|
||||
id: string;
|
||||
code: string; // Original method code from API (e.g., "bankin", "pep")
|
||||
name: string;
|
||||
type: string;
|
||||
icon?: string;
|
||||
@ -7,4 +8,3 @@ export interface IPaymentMethod {
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// Global styles
|
||||
|
||||
:root {
|
||||
width: 100%;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@ -73,4 +74,3 @@ button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
src/main.tsx
10
src/main.tsx
@ -1,7 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from '@/App.tsx'
|
||||
import './index.scss'
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from '@/App.tsx';
|
||||
import './index.scss';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
)
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
7
src/pages/PaymentStatus/PaymentStatus.scss
Normal file
7
src/pages/PaymentStatus/PaymentStatus.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.payment-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
54
src/pages/PaymentStatus/PaymentStatus.tsx
Normal file
54
src/pages/PaymentStatus/PaymentStatus.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Status from '@/components/Status/Status';
|
||||
import './PaymentStatus.scss';
|
||||
|
||||
function PaymentStatus() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get status from URL query params
|
||||
const statusParam = searchParams.get('status');
|
||||
const status: 'success' | 'cancel' | 'error' =
|
||||
statusParam === 'success' || statusParam === 'cancel' || statusParam === 'error'
|
||||
? statusParam
|
||||
: 'error'; // Default to error if invalid or missing
|
||||
|
||||
// Get message from URL params or use default
|
||||
const message = searchParams.get('message') || getDefaultMessage(status);
|
||||
|
||||
const handleAction = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const getActionLabel = () => {
|
||||
if (status === 'error') return 'Retry';
|
||||
if (status === 'cancel') return 'Back to Cashier';
|
||||
return 'Close';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="payment-status">
|
||||
<Status
|
||||
type={status}
|
||||
message={message}
|
||||
onAction={handleAction}
|
||||
actionLabel={getActionLabel()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultMessage(type: 'success' | 'cancel' | 'error'): string {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'Payment completed successfully!';
|
||||
case 'cancel':
|
||||
return 'Payment was cancelled.';
|
||||
case 'error':
|
||||
return 'An error occurred during payment processing.';
|
||||
default:
|
||||
return 'Payment status unknown.';
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentStatus;
|
||||
@ -1,25 +1,30 @@
|
||||
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 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 methods: IPaymentMethod[] = [];
|
||||
private currencies: IApiCurrency[] = [];
|
||||
|
||||
private getAuthHeader(merchant: TMerchant = DEFAULT_MERCHANT): HeadersInit {
|
||||
const apiKey = MERCHANT_API_KEYS[merchant];
|
||||
return {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async getMethods(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IPaymentMethod[]> {
|
||||
if (this.methods.length > 0) {
|
||||
return this.methods
|
||||
}
|
||||
if (this.methods.length > 0) {
|
||||
return this.methods;
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/methods`, {
|
||||
method: 'GET',
|
||||
headers: this.getAuthHeader(merchant),
|
||||
@ -38,7 +43,7 @@ class ApiService {
|
||||
|
||||
async getCurrencies(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IApiCurrency[]> {
|
||||
if (this.currencies.length > 0) {
|
||||
return this.currencies
|
||||
return this.currencies;
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/currencies`, {
|
||||
method: 'GET',
|
||||
@ -72,4 +77,3 @@ class ApiService {
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* BEM (Block Element Modifier) utility functions
|
||||
*
|
||||
*
|
||||
* Usage examples:
|
||||
* - bem('block') => 'block'
|
||||
* - bem('block', 'element') => 'block__element'
|
||||
@ -10,17 +10,17 @@
|
||||
|
||||
export function bem(block: string, element?: string | null, modifier?: string | null): string {
|
||||
const base = element ? `${block}__${element}` : block;
|
||||
|
||||
|
||||
if (modifier) {
|
||||
return `${base} ${base}--${modifier}`;
|
||||
}
|
||||
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create BEM class names with multiple modifiers
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* - bemModifiers('block', ['active', 'large']) => 'block block--active block--large'
|
||||
*/
|
||||
@ -34,7 +34,7 @@ export function bemModifiers(block: string, modifiers: string[]): string {
|
||||
|
||||
/**
|
||||
* Helper function to create BEM element with multiple modifiers
|
||||
*
|
||||
*
|
||||
* Usage:
|
||||
* - bemElementModifiers('block', 'element', ['active', 'large']) => 'block__element block__element--active block__element--large'
|
||||
*/
|
||||
@ -46,4 +46,3 @@ export function bemElementModifiers(block: string, element: string, modifiers: s
|
||||
});
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@ -22,4 +22,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user