Added Redirection Pages

This commit is contained in:
Mitchell Magro 2025-12-29 15:58:31 +01:00
parent 92d2d3f058
commit c405d22fbd
44 changed files with 4158 additions and 749 deletions

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
build
coverage
.vite
.env*

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf",
"arrowParens": "avoid"
}

View File

@ -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...
},
},
])
]);
```

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import './Loading.scss'
import './Loading.scss';
function Loading() {
return (
@ -10,4 +10,3 @@ function Loading() {
}
export default Loading;

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

View 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;

View File

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

View File

@ -1,4 +1,4 @@
import type { IPaymentMethod } from "@/features/payment-methods/types";
import type { IPaymentMethod } from '@/features/payment-methods/types';
export const PAYMENT_METHODS: IPaymentMethod[] = [
{
@ -33,4 +33,4 @@ export const PAYMENT_METHODS: IPaymentMethod[] = [
minAmount: 20,
maxAmount: 20000,
},
]
];

View File

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

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

View 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;

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

View File

@ -27,4 +27,3 @@ export function useCurrencies(merchant?: TMerchant) {
return { currencies, loading, error };
}

View File

@ -41,4 +41,3 @@ export function usePayment() {
paymentUrl,
};
}

View File

@ -27,4 +27,3 @@ export function usePaymentMethods(merchant?: TMerchant) {
return { methods, loading, error };
}

View 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();

View File

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

View File

@ -14,6 +14,7 @@ export function mapApiMethodToPaymentMethod(apiMethod: IApiMethod, index: number
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));
}

View File

@ -10,4 +10,3 @@
text-align: center;
}
}

View File

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

View File

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

View File

@ -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} />
@ -35,4 +31,3 @@ function PaymentMethod({ method, onSelect }: IPaymentMethodProps) {
}
export default PaymentMethod;

View File

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

View File

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

View File

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

View File

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

View File

@ -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 />);

View File

@ -0,0 +1,7 @@
.payment-status {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}

View 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;

View File

@ -1,24 +1,29 @@
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
return this.methods;
}
const response = await fetch(`${API_BASE_URL}/methods`, {
method: 'GET',
@ -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();

View File

@ -46,4 +46,3 @@ export function bemElementModifiers(block: string, element: string, modifiers: s
});
return classes.join(' ');
}

View File

@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@ -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({
},
},
},
})
});

773
yarn.lock

File diff suppressed because it is too large Load Diff