@akinon/pz-blik 1.126.5-v1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +13 -0
- package/CHANGELOG.md +7 -0
- package/README.md +95 -0
- package/package.json +19 -0
- package/src/hooks/use-blik.tsx +120 -0
- package/src/index.tsx +2 -0
- package/src/views/index.tsx +89 -0
package/.prettierrc
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bracketSameLine": false,
|
|
3
|
+
"tabWidth": 2,
|
|
4
|
+
"singleQuote": true,
|
|
5
|
+
"jsxSingleQuote": false,
|
|
6
|
+
"bracketSpacing": true,
|
|
7
|
+
"semi": true,
|
|
8
|
+
"useTabs": false,
|
|
9
|
+
"arrowParens": "always",
|
|
10
|
+
"endOfLine": "lf",
|
|
11
|
+
"proseWrap": "never",
|
|
12
|
+
"trailingComma": "none"
|
|
13
|
+
}
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Project Zero - BLIK Plugin
|
|
2
|
+
|
|
3
|
+
PayU Europe **BLIK** payment method for the Project Zero storefront. The shopper enters the 6-digit one-time BLIK code generated in their banking app; the code is submitted through the generic digital-wallet checkout flow (server-to-server, no redirect) and the order is finalized on PayU's asynchronous order-confirmation webhook.
|
|
4
|
+
|
|
5
|
+
Markets: Poland (and Slovakia when enabled on the POS).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
You can use the following command to install the extension with the latest plugins:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @akinon/projectzero@latest --plugins
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Adding BLIK to Checkout
|
|
16
|
+
|
|
17
|
+
BLIK arrives from the backend as a wallet payment option (`payment_type: "wallet"`). All PayU Europe wallet methods share that type, so the storefront selects the BLIK component by the payment option's **`slug`** through the brand `PaymentOptionViews` registry. That registry is resolved before the generic wallet branches, so no core change is required.
|
|
18
|
+
|
|
19
|
+
### 1. Create the BLIK option file
|
|
20
|
+
|
|
21
|
+
**views/checkout/steps/payment/options/blik.tsx**
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import Blik from '@akinon/pz-blik';
|
|
25
|
+
|
|
26
|
+
export default function BlikOption() {
|
|
27
|
+
return <Blik />;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Register it in the payment step
|
|
32
|
+
|
|
33
|
+
**views/checkout/steps/payment/index.tsx**
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import BlikOption from './options/blik';
|
|
37
|
+
|
|
38
|
+
export const PaymentOptionViews: Array<CheckoutPaymentOption> = [
|
|
39
|
+
{
|
|
40
|
+
slug: 'blik', // must match the BLIK payment option slug configured in the Akinon backend
|
|
41
|
+
view: BlikOption
|
|
42
|
+
}
|
|
43
|
+
// Other payment methods can be added here
|
|
44
|
+
];
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> The `slug` must exactly match the BLIK payment option `slug` provisioned on the POS in the Akinon backend.
|
|
48
|
+
|
|
49
|
+
## Translations
|
|
50
|
+
|
|
51
|
+
The default UI reads the following localization keys — add them to the brand translations:
|
|
52
|
+
|
|
53
|
+
| Key | Example (en) |
|
|
54
|
+
| --------------------------------- | -------------------------------------- |
|
|
55
|
+
| `checkout.blik.code_label` | BLIK code |
|
|
56
|
+
| `checkout.blik.code_placeholder` | 6-digit code |
|
|
57
|
+
| `checkout.blik.pay_button` | Pay |
|
|
58
|
+
| `checkout.blik.processing` | Processing... |
|
|
59
|
+
| `checkout.blik.errors.context` | Could not proceed to the payment step. |
|
|
60
|
+
| `checkout.blik.errors.incomplete` | Payment could not be completed. |
|
|
61
|
+
| `checkout.blik.errors.unexpected` | An unexpected error occurred. |
|
|
62
|
+
|
|
63
|
+
## Props
|
|
64
|
+
|
|
65
|
+
| Prop | Type | Description |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `className` | `string` | Applied to the default wrapper element. |
|
|
68
|
+
| `customUIRender` | `(props) => JSX.Element \| null` | Fully replaces the default UI. Receives `{ code, setCode, handleSubmit, isProcessing, isValid, paymentErrors }`. |
|
|
69
|
+
|
|
70
|
+
### customUIRender example
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
<Blik
|
|
74
|
+
customUIRender={({
|
|
75
|
+
code,
|
|
76
|
+
setCode,
|
|
77
|
+
handleSubmit,
|
|
78
|
+
isProcessing,
|
|
79
|
+
isValid,
|
|
80
|
+
paymentErrors
|
|
81
|
+
}) => (
|
|
82
|
+
<div>
|
|
83
|
+
<input
|
|
84
|
+
value={code}
|
|
85
|
+
inputMode="numeric"
|
|
86
|
+
onChange={(event) => setCode(event.target.value)}
|
|
87
|
+
/>
|
|
88
|
+
<button onClick={handleSubmit} disabled={!isValid || isProcessing}>
|
|
89
|
+
Pay with BLIK
|
|
90
|
+
</button>
|
|
91
|
+
{paymentErrors && <p>{paymentErrors}</p>}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
/>
|
|
95
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akinon/pz-blik",
|
|
3
|
+
"version": "1.126.5-v1-rc.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^18.0.0",
|
|
8
|
+
"react-dom": "^18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/node": "^18.7.8",
|
|
12
|
+
"@types/react": "^18.0.17",
|
|
13
|
+
"@types/react-dom": "^18.0.6",
|
|
14
|
+
"prettier": "^3.0.3",
|
|
15
|
+
"react": "^18.2.0",
|
|
16
|
+
"react-dom": "^18.2.0",
|
|
17
|
+
"typescript": "^5.2.2"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { checkoutApi } from '@akinon/next/data/client/checkout';
|
|
2
|
+
import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
|
|
3
|
+
import { getCookie } from '@akinon/next/utils';
|
|
4
|
+
import { getUrlPathWithLocale } from '@akinon/next/utils/localization';
|
|
5
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
6
|
+
import { RootState } from '@theme/redux/store';
|
|
7
|
+
import { useMemo, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
export default function useBlik() {
|
|
10
|
+
const dispatch = useAppDispatch();
|
|
11
|
+
const { preOrder } = useAppSelector((state: RootState) => state.checkout);
|
|
12
|
+
const { t } = useLocalization();
|
|
13
|
+
const [errors, setErrors] = useState<any>(null);
|
|
14
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
15
|
+
|
|
16
|
+
const paymentErrors = useMemo(() => {
|
|
17
|
+
if (typeof errors === 'string') return errors;
|
|
18
|
+
if (Array.isArray(errors)) return errors.join(', ');
|
|
19
|
+
if (typeof errors === 'object')
|
|
20
|
+
return Object.values(errors ?? {}).join(', ');
|
|
21
|
+
return null;
|
|
22
|
+
}, [errors]);
|
|
23
|
+
|
|
24
|
+
const redirectToThankYouPage = (contextList: any[]) => {
|
|
25
|
+
const thankYouPageContext = contextList?.find(
|
|
26
|
+
(c: any) => c.page_name === 'ThankYouPage'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (thankYouPageContext) {
|
|
30
|
+
const redirectUrl =
|
|
31
|
+
thankYouPageContext.page_context.context_data.redirect_url;
|
|
32
|
+
const redirectUrlWithLocale = `${
|
|
33
|
+
window.location.origin
|
|
34
|
+
}${getUrlPathWithLocale(redirectUrl, getCookie('pz-locale'))}`;
|
|
35
|
+
window.location.href = redirectUrlWithLocale;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleBlikSubmit = async (authorizationCode: string) => {
|
|
40
|
+
if (isProcessing) return;
|
|
41
|
+
|
|
42
|
+
setErrors(null);
|
|
43
|
+
setIsProcessing(true);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const walletSelectionPageResponse = await dispatch(
|
|
47
|
+
checkoutApi.endpoints.setWalletSelectionPage.initiate({
|
|
48
|
+
payment_option: preOrder.payment_option?.pk
|
|
49
|
+
})
|
|
50
|
+
).unwrap();
|
|
51
|
+
|
|
52
|
+
const walletPaymentPageContext =
|
|
53
|
+
walletSelectionPageResponse.context_list?.find(
|
|
54
|
+
(c: any) => c.page_name === 'WalletPaymentPage'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!walletPaymentPageContext) {
|
|
58
|
+
setErrors(t('checkout.blik.errors.context'));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The BLIK 6-digit authorization code travels in the generic wallet
|
|
63
|
+
// `payment_token` slot; the backend maps it into pre_order.data['token'].
|
|
64
|
+
const walletPaymentPageResponse = await dispatch(
|
|
65
|
+
checkoutApi.endpoints.setWalletPaymentPage.initiate({
|
|
66
|
+
payment_token: authorizationCode
|
|
67
|
+
})
|
|
68
|
+
).unwrap();
|
|
69
|
+
|
|
70
|
+
if (walletPaymentPageResponse.errors) {
|
|
71
|
+
setErrors(walletPaymentPageResponse.errors);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// BLIK's authorization-code flow completes server-to-server (no redirect),
|
|
76
|
+
// but honour a redirect context if the backend ever returns one.
|
|
77
|
+
const redirectPageContext = walletPaymentPageResponse.context_list?.find(
|
|
78
|
+
(c: any) => c.page_name === 'WalletRedirectCompletePage'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (redirectPageContext) {
|
|
82
|
+
window.location.href = redirectPageContext.page_context.redirect_url;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const completePageContext = walletPaymentPageResponse.context_list?.find(
|
|
87
|
+
(c: any) => c.page_name === 'WalletCompletePage'
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (completePageContext) {
|
|
91
|
+
const paymentCompleteResponse = await dispatch(
|
|
92
|
+
checkoutApi.endpoints.setWalletCompletePage.initiate({
|
|
93
|
+
success: true
|
|
94
|
+
})
|
|
95
|
+
).unwrap();
|
|
96
|
+
|
|
97
|
+
if (paymentCompleteResponse.errors) {
|
|
98
|
+
setErrors(paymentCompleteResponse.errors);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
redirectToThankYouPage(paymentCompleteResponse.context_list);
|
|
103
|
+
} else {
|
|
104
|
+
setErrors(t('checkout.blik.errors.incomplete'));
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('BLIK Flow Error:', error);
|
|
108
|
+
setErrors(t('checkout.blik.errors.unexpected'));
|
|
109
|
+
} finally {
|
|
110
|
+
setIsProcessing(false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
paymentErrors,
|
|
116
|
+
setPaymentErrors: setErrors,
|
|
117
|
+
handleBlikSubmit,
|
|
118
|
+
isProcessing
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useState } from 'react';
|
|
4
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
5
|
+
import useBlik from '../hooks/use-blik';
|
|
6
|
+
|
|
7
|
+
const BLIK_CODE_LENGTH = 6;
|
|
8
|
+
const BLIK_CODE_REGEX = /^\d{6}$/;
|
|
9
|
+
|
|
10
|
+
export interface BlikProps {
|
|
11
|
+
customUIRender?: (props: {
|
|
12
|
+
code: string;
|
|
13
|
+
setCode: (code: string) => void;
|
|
14
|
+
handleSubmit: () => void;
|
|
15
|
+
isProcessing: boolean;
|
|
16
|
+
isValid: boolean;
|
|
17
|
+
paymentErrors?: string | null;
|
|
18
|
+
}) => ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Blik = ({ customUIRender, className }: BlikProps) => {
|
|
23
|
+
const { t } = useLocalization();
|
|
24
|
+
const { paymentErrors, handleBlikSubmit, isProcessing } = useBlik();
|
|
25
|
+
const [code, setCode] = useState('');
|
|
26
|
+
|
|
27
|
+
const isValid = BLIK_CODE_REGEX.test(code);
|
|
28
|
+
|
|
29
|
+
const handleCodeChange = (value: string) => {
|
|
30
|
+
setCode(value.replace(/\D/g, '').slice(0, BLIK_CODE_LENGTH));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleSubmit = () => {
|
|
34
|
+
if (!isValid || isProcessing) return;
|
|
35
|
+
handleBlikSubmit(code);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (customUIRender) {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{customUIRender({
|
|
42
|
+
code,
|
|
43
|
+
setCode: handleCodeChange,
|
|
44
|
+
handleSubmit,
|
|
45
|
+
isProcessing,
|
|
46
|
+
isValid,
|
|
47
|
+
paymentErrors
|
|
48
|
+
})}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={className}>
|
|
55
|
+
<label htmlFor="blik-code" className="block text-sm font-medium mb-2">
|
|
56
|
+
{t('checkout.blik.code_label')}
|
|
57
|
+
</label>
|
|
58
|
+
|
|
59
|
+
<input
|
|
60
|
+
id="blik-code"
|
|
61
|
+
type="text"
|
|
62
|
+
inputMode="numeric"
|
|
63
|
+
autoComplete="one-time-code"
|
|
64
|
+
maxLength={BLIK_CODE_LENGTH}
|
|
65
|
+
value={code}
|
|
66
|
+
onChange={(event) => handleCodeChange(event.target.value)}
|
|
67
|
+
placeholder={t('checkout.blik.code_placeholder')}
|
|
68
|
+
className="w-full border rounded px-3 py-2 text-center tracking-[0.5em]"
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={handleSubmit}
|
|
74
|
+
disabled={!isValid || isProcessing}
|
|
75
|
+
className="mt-4 w-full rounded bg-black py-3 text-white disabled:opacity-50"
|
|
76
|
+
>
|
|
77
|
+
{isProcessing
|
|
78
|
+
? t('checkout.blik.processing')
|
|
79
|
+
: t('checkout.blik.pay_button')}
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
{paymentErrors && (
|
|
83
|
+
<div className="text-error-100 text-xs mt-5">{paymentErrors}</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default Blik;
|