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...
|
// 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:
|
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
|
```js
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
import reactX from 'eslint-plugin-react-x'
|
import reactX from 'eslint-plugin-react-x';
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
import reactDom from 'eslint-plugin-react-dom';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
@ -69,5 +69,5 @@ export default defineConfig([
|
|||||||
// other options...
|
// other options...
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import globals from 'globals'
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
@ -20,4 +20,4 @@ export default defineConfig([
|
|||||||
globals: globals.browser,
|
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",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@ -23,6 +25,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
"sass": "^1.97.0",
|
"sass": "^1.97.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.cashier-app {
|
.cashier-app {
|
||||||
// Root container
|
// Root container
|
||||||
max-width: 1280px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -47,4 +47,3 @@
|
|||||||
.cashier-app__read-the-docs {
|
.cashier-app__read-the-docs {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
src/App.tsx
21
src/App.tsx
@ -1,13 +1,22 @@
|
|||||||
import Cashier from '@/features/cashier/Cashier'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import '@/App.scss'
|
import Cashier from '@/features/cashier/Cashier';
|
||||||
|
import { CashierProvider } from '@/features/cashier/context/CashierContext';
|
||||||
|
import PaymentStatus from '@/pages/PaymentStatus/PaymentStatus';
|
||||||
|
import '@/App.scss';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cashier-app">
|
<div className="cashier-app">
|
||||||
<Cashier />
|
<BrowserRouter>
|
||||||
|
<CashierProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Cashier />} />
|
||||||
|
<Route path="/result" element={<PaymentStatus />} />
|
||||||
|
</Routes>
|
||||||
|
</CashierProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
@ -1,19 +1,4 @@
|
|||||||
.error {
|
.close-button {
|
||||||
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;
|
padding: 0.5rem 1.5rem;
|
||||||
background-color: #646cff;
|
background-color: #646cff;
|
||||||
color: white;
|
color: white;
|
||||||
@ -27,4 +12,3 @@
|
|||||||
background-color: #535bf2;
|
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 {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% {
|
||||||
100% { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading__text {
|
.loading__text {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import './Loading.scss'
|
import './Loading.scss';
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@ -10,4 +10,3 @@ function Loading() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default 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
|
// Use proxy in development, direct URL in production
|
||||||
export const API_BASE_URL = import.meta.env.DEV
|
export const API_BASE_URL = import.meta.env.DEV
|
||||||
? '/api/v1'
|
? '/api/v1'
|
||||||
: 'https://cashier-backend.brgoperations.com/api/v1';
|
: 'https://cashier-backend.brgoperations.com/api/v1';
|
||||||
|
|
||||||
export const TMerchant = {
|
export const TMerchant = {
|
||||||
@ -10,7 +10,7 @@ export const TMerchant = {
|
|||||||
BETRISE: 'BETRISE',
|
BETRISE: 'BETRISE',
|
||||||
} as const;
|
} 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> = {
|
export const MERCHANT_API_KEYS: Record<TMerchant, string> = {
|
||||||
[TMerchant.DATA_SPIN]: '10000000-0000-0000-0000-000000000001',
|
[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)
|
// Default merchant (can be changed based on requirements)
|
||||||
export const DEFAULT_MERCHANT = TMerchant.WIN_BOT;
|
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[] = [
|
export const PAYMENT_METHODS: IPaymentMethod[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Credit Card',
|
name: 'Credit Card',
|
||||||
type: 'Card Payment',
|
type: 'Card Payment',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
minAmount: 10,
|
minAmount: 10,
|
||||||
maxAmount: 5000,
|
maxAmount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'PayPal',
|
name: 'PayPal',
|
||||||
type: 'Digital Wallet',
|
type: 'Digital Wallet',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
minAmount: 5,
|
minAmount: 5,
|
||||||
maxAmount: 10000,
|
maxAmount: 10000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
name: 'Bank Transfer',
|
name: 'Bank Transfer',
|
||||||
type: 'Banking',
|
type: 'Banking',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
minAmount: 50,
|
minAmount: 50,
|
||||||
maxAmount: 50000,
|
maxAmount: 50000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
name: 'Cryptocurrency',
|
name: 'Cryptocurrency',
|
||||||
type: 'Crypto',
|
type: 'Crypto',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
minAmount: 20,
|
minAmount: 20,
|
||||||
maxAmount: 20000,
|
maxAmount: 20000,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|||||||
@ -1,38 +1,83 @@
|
|||||||
import PaymentMethodsList from '@/features/payment-methods/PaymentMethodsList/PaymentMethodsList'
|
import { useEffect } from 'react';
|
||||||
import EmptyPaymentMethods from '@/features/payment-methods/EmptyPaymentMethods/EmptyPaymentMethods'
|
import PaymentMethodsList from '@/features/payment-methods/PaymentMethodsList/PaymentMethodsList';
|
||||||
import Loading from '@/components/Loading/Loading'
|
import EmptyPaymentMethods from '@/features/payment-methods/EmptyPaymentMethods/EmptyPaymentMethods';
|
||||||
import Error from '@/components/Error/Error'
|
import PaymentForm from './PaymentForm/PaymentForm';
|
||||||
import type { IPaymentMethod } from '@/features/payment-methods/types'
|
import Loading from '@/components/Loading/Loading';
|
||||||
import { usePaymentMethods } from './hooks/usePaymentMethods'
|
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() {
|
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) => {
|
const handleMethodSelect = (method: IPaymentMethod) => {
|
||||||
console.log('Selected payment method:', method)
|
setSelectedMethod(method);
|
||||||
// Handle payment method selection here
|
};
|
||||||
|
|
||||||
|
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) {
|
if (state === 'error' && error) {
|
||||||
return <Loading />
|
return <Status type="error" message={`Error: ${error.message}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (state === 'redirecting') {
|
||||||
return <Error message={`Error loading payment methods: ${error.message}`} />
|
return <div>Redirecting to payment...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMethod) {
|
||||||
|
return (
|
||||||
|
<PaymentForm
|
||||||
|
method={selectedMethod}
|
||||||
|
currencies={currencies}
|
||||||
|
config={getCashierConfig()}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onBack={handleBack}
|
||||||
|
isLoading={state === 'submitting'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{apiMethods.length === 0 ? (
|
{state === 'ready' && methods.length === 0 ? (
|
||||||
<EmptyPaymentMethods />
|
<EmptyPaymentMethods />
|
||||||
) : (
|
) : state === 'ready' ? (
|
||||||
<PaymentMethodsList
|
<PaymentMethodsList methods={methods} onMethodSelect={handleMethodSelect} />
|
||||||
methods={apiMethods}
|
) : null}
|
||||||
onMethodSelect={handleMethodSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
return { currencies, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,4 +41,3 @@ export function usePayment() {
|
|||||||
paymentUrl,
|
paymentUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,4 +27,3 @@ export function usePaymentMethods(merchant?: TMerchant) {
|
|||||||
|
|
||||||
return { methods, loading, error };
|
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 {
|
export interface IApiCurrency {
|
||||||
currency: string;
|
code: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IApiCurrenciesResponse {
|
export interface IApiCurrenciesResponse {
|
||||||
data: IApiCurrency[];
|
data: IApiCurrency[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TCashierFlowState = 'loading' | 'ready' | 'submitting' | 'redirecting' | 'error';
|
||||||
|
|
||||||
export interface ICustomer {
|
export interface ICustomer {
|
||||||
id?: string;
|
id: string;
|
||||||
name?: string;
|
first_name: string;
|
||||||
email?: string;
|
last_name: string;
|
||||||
phone?: string;
|
email: string;
|
||||||
account?: string; // IBAN or similar - required on withdrawal
|
|
||||||
ip?: string;
|
|
||||||
country?: string;
|
|
||||||
city?: string;
|
|
||||||
zip?: string;
|
|
||||||
address?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRedirect {
|
export interface IRedirect {
|
||||||
@ -42,9 +39,9 @@ export interface IPaymentRequest {
|
|||||||
currency: string;
|
currency: string;
|
||||||
amount: number; // float64
|
amount: number; // float64
|
||||||
redirect: IRedirect;
|
redirect: IRedirect;
|
||||||
|
account?: string; // Required for withdrawals (IBAN or account number)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPaymentResponse {
|
export interface IPaymentResponse {
|
||||||
payment_url: string; // URL where customer needs to continue with payment flow
|
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 {
|
export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number): IPaymentMethod {
|
||||||
const methodCode = apiMethod.code.toLowerCase();
|
const methodCode = apiMethod.code.toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${methodCode}-${index}`,
|
id: `${methodCode}-${index}`,
|
||||||
|
code: apiMethod.code, // Store original code for API requests
|
||||||
name: apiMethod.name, // Use the name from API
|
name: apiMethod.name, // Use the name from API
|
||||||
type: METHOD_TYPES[methodCode] || 'Payment',
|
type: METHOD_TYPES[methodCode] || 'Payment',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@ -23,4 +24,3 @@ export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number
|
|||||||
export function mapApiMethodsToPaymentMethods(apiMethods: IApiMethod[]): IPaymentMethod[] {
|
export function mapApiMethodsToPaymentMethods(apiMethods: IApiMethod[]): IPaymentMethod[] {
|
||||||
return apiMethods.map((apiMethod, index) => mapApiMethodToPaymentMethod(apiMethod, index));
|
return apiMethods.map((apiMethod, index) => mapApiMethodToPaymentMethod(apiMethod, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,4 +10,3 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
import { bem } from '@/utils/bem'
|
import { bem } from '@/utils/bem';
|
||||||
import './EmptyPaymentMethods.scss'
|
import './EmptyPaymentMethods.scss';
|
||||||
|
|
||||||
const block = 'empty-payment-methods'
|
const block = 'empty-payment-methods';
|
||||||
|
|
||||||
function EmptyPaymentMethods() {
|
function EmptyPaymentMethods() {
|
||||||
return (
|
return (
|
||||||
<div className={block}>
|
<div className={block}>
|
||||||
<p className={bem(block, 'message')}>
|
<p className={bem(block, 'message')}>No payment methods available</p>
|
||||||
No payment methods available
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EmptyPaymentMethods;
|
export default EmptyPaymentMethods;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// PaymentMethod component styles using BEM methodology
|
// PaymentMethod component styles using BEM methodology
|
||||||
|
|
||||||
.payment-method {
|
.payment-method {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@ -70,4 +71,3 @@
|
|||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
border-radius: 4px;
|
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 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 {
|
interface IPaymentMethodProps {
|
||||||
method: PaymentMethodType;
|
method: PaymentMethodType;
|
||||||
@ -10,17 +10,13 @@ interface IPaymentMethodProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
|
function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
|
||||||
|
|
||||||
const { name, type, isActive, icon } = method;
|
const { name, type, isActive, icon } = method;
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
onSelect?.(method);
|
onSelect?.(method);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={bem(block, null, isActive ? 'active' : null)} onClick={handleClick}>
|
||||||
className={bem(block, null, isActive ? 'active' : null)}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className={bem(block, 'icon')}>
|
<div className={bem(block, 'icon')}>
|
||||||
<img src={icon} alt={name} />
|
<img src={icon} alt={name} />
|
||||||
@ -29,10 +25,9 @@ function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
|
|||||||
<div className={bem(block, 'content')}>
|
<div className={bem(block, 'content')}>
|
||||||
<h3 className={bem(block, 'name')}>{name}</h3>
|
<h3 className={bem(block, 'name')}>{name}</h3>
|
||||||
<p className={bem(block, 'type')}>{type}</p>
|
<p className={bem(block, 'type')}>{type}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PaymentMethod;
|
export default PaymentMethod;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
.payment-methods-list {
|
.payment-methods-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-methods-list__title {
|
.payment-methods-list__title {
|
||||||
@ -14,7 +13,6 @@
|
|||||||
|
|
||||||
.payment-methods-list__grid {
|
.payment-methods-list__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { bem } from '@/utils/bem'
|
import { bem } from '@/utils/bem';
|
||||||
import type { IPaymentMethod } from '@/features/payment-methods/types'
|
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||||
import PaymentMethodComponent from '@/features/payment-methods/PaymentMethod/PaymentMethod'
|
import PaymentMethodComponent from '@/features/payment-methods/PaymentMethod/PaymentMethod';
|
||||||
import './PaymentMethodsList.scss'
|
import './PaymentMethodsList.scss';
|
||||||
|
|
||||||
const block = 'payment-methods-list'
|
const block = 'payment-methods-list';
|
||||||
|
|
||||||
interface IPaymentMethodsListProps {
|
interface IPaymentMethodsListProps {
|
||||||
methods: IPaymentMethod[];
|
methods: IPaymentMethod[];
|
||||||
@ -15,12 +15,8 @@ function PaymentMethodsList({ methods, onMethodSelect }: IPaymentMethodsListProp
|
|||||||
<div className={block}>
|
<div className={block}>
|
||||||
<h2 className={bem(block, 'title')}>Select Payment Method</h2>
|
<h2 className={bem(block, 'title')}>Select Payment Method</h2>
|
||||||
<div className={bem(block, 'grid')}>
|
<div className={bem(block, 'grid')}>
|
||||||
{methods.map((method) => (
|
{methods.map(method => (
|
||||||
<PaymentMethodComponent
|
<PaymentMethodComponent key={method.id} method={method} onSelect={onMethodSelect} />
|
||||||
key={method.id}
|
|
||||||
method={method}
|
|
||||||
onSelect={onMethodSelect}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,4 +24,3 @@ function PaymentMethodsList({ methods, onMethodSelect }: IPaymentMethodsListProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default PaymentMethodsList;
|
export default PaymentMethodsList;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export interface IPaymentMethod {
|
export interface IPaymentMethod {
|
||||||
id: string;
|
id: string;
|
||||||
|
code: string; // Original method code from API (e.g., "bankin", "pep")
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@ -7,4 +8,3 @@ export interface IPaymentMethod {
|
|||||||
minAmount?: number;
|
minAmount?: number;
|
||||||
maxAmount?: number;
|
maxAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Global styles
|
// Global styles
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
width: 100%;
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@ -73,4 +74,3 @@ button {
|
|||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@ -1,7 +1,5 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from '@/App.tsx'
|
import App from '@/App.tsx';
|
||||||
import './index.scss'
|
import './index.scss';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(<App />);
|
||||||
<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 { 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 { mapApiMethodsToPaymentMethods } from '@/features/cashier/utils/methodMapper';
|
||||||
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
import type { IPaymentMethod } from '@/features/payment-methods/types';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
|
private methods: IPaymentMethod[] = [];
|
||||||
private methods: IPaymentMethod[] = []
|
private currencies: IApiCurrency[] = [];
|
||||||
private currencies: IApiCurrency[] = []
|
|
||||||
|
|
||||||
private getAuthHeader(merchant: TMerchant = DEFAULT_MERCHANT): HeadersInit {
|
private getAuthHeader(merchant: TMerchant = DEFAULT_MERCHANT): HeadersInit {
|
||||||
const apiKey = MERCHANT_API_KEYS[merchant];
|
const apiKey = MERCHANT_API_KEYS[merchant];
|
||||||
return {
|
return {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMethods(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IPaymentMethod[]> {
|
async getMethods(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IPaymentMethod[]> {
|
||||||
if (this.methods.length > 0) {
|
if (this.methods.length > 0) {
|
||||||
return this.methods
|
return this.methods;
|
||||||
}
|
}
|
||||||
const response = await fetch(`${API_BASE_URL}/methods`, {
|
const response = await fetch(`${API_BASE_URL}/methods`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: this.getAuthHeader(merchant),
|
headers: this.getAuthHeader(merchant),
|
||||||
@ -38,7 +43,7 @@ class ApiService {
|
|||||||
|
|
||||||
async getCurrencies(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IApiCurrency[]> {
|
async getCurrencies(merchant: TMerchant = DEFAULT_MERCHANT): Promise<IApiCurrency[]> {
|
||||||
if (this.currencies.length > 0) {
|
if (this.currencies.length > 0) {
|
||||||
return this.currencies
|
return this.currencies;
|
||||||
}
|
}
|
||||||
const response = await fetch(`${API_BASE_URL}/currencies`, {
|
const response = await fetch(`${API_BASE_URL}/currencies`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -72,4 +77,3 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiService = new ApiService();
|
export const apiService = new ApiService();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* BEM (Block Element Modifier) utility functions
|
* BEM (Block Element Modifier) utility functions
|
||||||
*
|
*
|
||||||
* Usage examples:
|
* Usage examples:
|
||||||
* - bem('block') => 'block'
|
* - bem('block') => 'block'
|
||||||
* - bem('block', 'element') => 'block__element'
|
* - bem('block', 'element') => 'block__element'
|
||||||
@ -10,17 +10,17 @@
|
|||||||
|
|
||||||
export function bem(block: string, element?: string | null, modifier?: string | null): string {
|
export function bem(block: string, element?: string | null, modifier?: string | null): string {
|
||||||
const base = element ? `${block}__${element}` : block;
|
const base = element ? `${block}__${element}` : block;
|
||||||
|
|
||||||
if (modifier) {
|
if (modifier) {
|
||||||
return `${base} ${base}--${modifier}`;
|
return `${base} ${base}--${modifier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to create BEM class names with multiple modifiers
|
* Helper function to create BEM class names with multiple modifiers
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* - bemModifiers('block', ['active', 'large']) => 'block block--active block--large'
|
* - 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
|
* Helper function to create BEM element with multiple modifiers
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* - bemElementModifiers('block', 'element', ['active', 'large']) => 'block__element block__element--active block__element--large'
|
* - 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(' ');
|
return classes.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@ -22,4 +22,4 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user