Compare commits
No commits in common. "main" and "feat/payment-ux-story-1-1" have entirely different histories.
main
...
feat/payme
|
|
@ -32,7 +32,3 @@ docs/
|
||||||
*.sw?
|
*.sw?
|
||||||
.bmad/
|
.bmad/
|
||||||
.trae/
|
.trae/
|
||||||
|
|
||||||
# Temporary test files
|
|
||||||
temp/
|
|
||||||
!temp/README.md
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
apps : [
|
|
||||||
{
|
|
||||||
name : "dev-backend-midtrans",
|
|
||||||
script : "server/index.cjs",
|
|
||||||
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "development",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name : "dev-frontend-midtrans",
|
|
||||||
interpreter: "serve",
|
|
||||||
script : "dist",
|
|
||||||
args : "-s -p 3001",
|
|
||||||
exec_mode : "fork",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
@ -5,9 +5,6 @@
|
||||||
<link rel="icon" type="image/png" href="/simaya.png"/>
|
<link rel="icon" type="image/png" href="/simaya.png"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Simaya Midtrans | Retail Payment</title>
|
<title>Simaya Midtrans | Retail Payment</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"fs": "^0.0.1-security",
|
|
||||||
"midtrans-client": "^1.4.3",
|
"midtrans-client": "^1.4.3",
|
||||||
"path": "^0.12.7",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
|
|
@ -92,7 +90,6 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1786,7 +1783,6 @@
|
||||||
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1797,7 +1793,6 @@
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1858,7 +1853,6 @@
|
||||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.3",
|
"@typescript-eslint/scope-manager": "8.46.3",
|
||||||
"@typescript-eslint/types": "8.46.3",
|
"@typescript-eslint/types": "8.46.3",
|
||||||
|
|
@ -2145,7 +2139,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2339,7 +2332,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
|
|
@ -2830,7 +2822,6 @@
|
||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -3320,12 +3311,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fs": {
|
|
||||||
"version": "0.0.1-security",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
|
||||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -4339,16 +4324,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path": {
|
|
||||||
"version": "0.12.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
|
||||||
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"process": "^0.11.1",
|
|
||||||
"util": "^0.10.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
|
@ -4419,7 +4394,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -4466,7 +4440,6 @@
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -4556,15 +4529,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process": {
|
|
||||||
"version": "0.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
|
||||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
|
@ -4675,7 +4639,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4685,7 +4648,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5127,8 +5089,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
|
|
@ -5185,7 +5146,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5288,7 +5248,6 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -5378,15 +5337,6 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/util": {
|
|
||||||
"version": "0.10.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
|
||||||
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
@ -5394,12 +5344,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/util/node_modules/inherits": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
@ -5415,7 +5359,6 @@
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -5509,7 +5452,6 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"fs": "^0.0.1-security",
|
|
||||||
"midtrans-client": "^1.4.3",
|
"midtrans-client": "^1.4.3",
|
||||||
"path": "^0.12.7",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8000/createtransaksi'
|
|
||||||
const API_KEY = 'dev-key'
|
|
||||||
|
|
||||||
const orderId = `SNAPTEST-${Date.now()}`
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
mercant_id: 'TESTMERCHANT',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
deskripsi: 'Testing Snap Payment Mode',
|
|
||||||
nominal: 150000,
|
|
||||||
nama: 'Test Snap User',
|
|
||||||
no_telepon: '081234567890',
|
|
||||||
email: 'test@snap.com',
|
|
||||||
item: [
|
|
||||||
{
|
|
||||||
item_id: orderId,
|
|
||||||
nama: 'Test Product Snap',
|
|
||||||
harga: 150000,
|
|
||||||
qty: 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-api-key': API_KEY
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text()
|
|
||||||
console.error('❌ Error:', response.status, error)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
const paymentUrl = data.data?.url || data.payment_url
|
|
||||||
const token = paymentUrl ? paymentUrl.split('/pay/')[1] : null
|
|
||||||
|
|
||||||
console.log('✅ Payment link created successfully!')
|
|
||||||
console.log('\n🔗 Snap Mode Payment Link:')
|
|
||||||
console.log(paymentUrl.replace('https://midtrans-cifo.winteraccess.id', 'http://localhost:5173'))
|
|
||||||
console.log('\n📋 Order ID:', orderId)
|
|
||||||
console.log('💰 Amount: Rp 150,000')
|
|
||||||
console.log('🔑 Mode: SNAP (Hosted UI)')
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
console.log('\n📄 Token:', token.substring(0, 50) + '...')
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to create payment link:', error.message)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
490
server/README.md
490
server/README.md
|
|
@ -1,490 +0,0 @@
|
||||||
# Simaya Midtrans Payment Server
|
|
||||||
|
|
||||||
Backend Express.js server untuk integrasi pembayaran Midtrans dengan sistem ERP.
|
|
||||||
|
|
||||||
## 📋 Daftar Isi
|
|
||||||
|
|
||||||
- [Fitur Utama](#fitur-utama)
|
|
||||||
- [Konfigurasi Environment](#konfigurasi-environment)
|
|
||||||
- [API Endpoints](#api-endpoints)
|
|
||||||
- [Payment Flow](#payment-flow)
|
|
||||||
- [Testing](#testing)
|
|
||||||
- [Logging](#logging)
|
|
||||||
|
|
||||||
## 🚀 Fitur Utama
|
|
||||||
|
|
||||||
### 1. **Dual Mode Payment**
|
|
||||||
- **CORE API**: Bank Transfer, Credit Card, GoPay/QRIS, Convenience Store
|
|
||||||
- **SNAP**: Hosted payment interface dengan UI Midtrans
|
|
||||||
|
|
||||||
### 2. **Payment Link Generation**
|
|
||||||
- Generate secure payment link dengan signature validation
|
|
||||||
- Configurable TTL (Time To Live)
|
|
||||||
- Token-based authentication
|
|
||||||
|
|
||||||
### 3. **ERP Integration**
|
|
||||||
- Notifikasi otomatis ke sistem ERP setelah pembayaran sukses
|
|
||||||
- Multi-endpoint support (comma-separated URLs)
|
|
||||||
- Signature verification untuk keamanan
|
|
||||||
|
|
||||||
### 4. **Webhook Handler**
|
|
||||||
- Unified webhook untuk CORE dan SNAP
|
|
||||||
- Signature verification
|
|
||||||
- Idempotent notification handling
|
|
||||||
|
|
||||||
### 5. **Advanced Logging**
|
|
||||||
- Level-based logging (debug, info, warn, error)
|
|
||||||
- In-memory log buffer
|
|
||||||
- Payload masking untuk sensitive data
|
|
||||||
- Jakarta timezone (WIB/UTC+7)
|
|
||||||
|
|
||||||
## ⚙️ Konfigurasi Environment
|
|
||||||
|
|
||||||
### Midtrans Configuration
|
|
||||||
```env
|
|
||||||
# Required
|
|
||||||
MIDTRANS_SERVER_KEY=your-server-key
|
|
||||||
MIDTRANS_CLIENT_KEY=your-client-key
|
|
||||||
MIDTRANS_IS_PRODUCTION=false
|
|
||||||
|
|
||||||
# Payment Method Toggles
|
|
||||||
ENABLE_BANK_TRANSFER=true
|
|
||||||
ENABLE_CREDIT_CARD=true
|
|
||||||
ENABLE_GOPAY=true
|
|
||||||
ENABLE_CSTORE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payment Link Configuration
|
|
||||||
```env
|
|
||||||
# External API Access
|
|
||||||
EXTERNAL_API_KEY=your-api-key
|
|
||||||
|
|
||||||
# Payment Link Settings
|
|
||||||
PAYMENT_LINK_SECRET=your-secret-for-signing
|
|
||||||
PAYMENT_LINK_TTL_MINUTES=1440
|
|
||||||
PAYMENT_LINK_BASE=http://localhost:5174/pay
|
|
||||||
```
|
|
||||||
|
|
||||||
### ERP Integration
|
|
||||||
```env
|
|
||||||
# Single URL (legacy)
|
|
||||||
ERP_NOTIFICATION_URL=https://your-erp.com/api/payment-notification
|
|
||||||
ERP_CLIENT_SECRET=your-erp-client-secret
|
|
||||||
|
|
||||||
# Multi-URL (recommended)
|
|
||||||
ERP_NOTIFICATION_URLS=https://erp1.com/api/notif,https://erp2.com/api/notif
|
|
||||||
|
|
||||||
# Toggle
|
|
||||||
ERP_ENABLE_NOTIF=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging Configuration
|
|
||||||
```env
|
|
||||||
# Logging Level: debug, info, warn, error
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Expose /api/logs endpoint (dev only)
|
|
||||||
LOG_EXPOSE_API=true
|
|
||||||
|
|
||||||
# In-memory buffer size
|
|
||||||
LOG_BUFFER_SIZE=1000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Configuration
|
|
||||||
```env
|
|
||||||
PORT=8000
|
|
||||||
NODE_ENV=development
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📡 API Endpoints
|
|
||||||
|
|
||||||
### Health & Config
|
|
||||||
|
|
||||||
#### `GET /api/health`
|
|
||||||
Health check endpoint.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"env": {
|
|
||||||
"isProduction": false,
|
|
||||||
"hasServerKey": true,
|
|
||||||
"hasClientKey": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/config`
|
|
||||||
Get current payment configuration.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"paymentToggles": {
|
|
||||||
"bank_transfer": true,
|
|
||||||
"credit_card": true,
|
|
||||||
"gopay": true,
|
|
||||||
"cstore": true
|
|
||||||
},
|
|
||||||
"midtransEnv": "sandbox",
|
|
||||||
"clientKey": "SB-Mid-client-xxx"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `POST /api/config` (Dev Only)
|
|
||||||
Update payment toggles at runtime.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"paymentToggles": {
|
|
||||||
"bank_transfer": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payment Operations
|
|
||||||
|
|
||||||
#### `POST /api/payments/charge`
|
|
||||||
Create payment transaction via Midtrans Core API.
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"payment_type": "bank_transfer",
|
|
||||||
"transaction_details": {
|
|
||||||
"order_id": "order-123",
|
|
||||||
"gross_amount": 150000
|
|
||||||
},
|
|
||||||
"bank_transfer": {
|
|
||||||
"bank": "bca"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status_code": "201",
|
|
||||||
"status_message": "Success",
|
|
||||||
"transaction_id": "xxx",
|
|
||||||
"order_id": "order-123",
|
|
||||||
"va_numbers": [
|
|
||||||
{
|
|
||||||
"bank": "bca",
|
|
||||||
"va_number": "12345678901"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `POST /api/payments/snap/token`
|
|
||||||
Generate Snap token for hosted payment.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transaction_details": {
|
|
||||||
"order_id": "order-123",
|
|
||||||
"gross_amount": 150000
|
|
||||||
},
|
|
||||||
"customer_details": {
|
|
||||||
"first_name": "John",
|
|
||||||
"email": "john@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "snap-token-xxx"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/payments/:orderId/status`
|
|
||||||
Check payment status.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status_code": "200",
|
|
||||||
"transaction_status": "settlement",
|
|
||||||
"order_id": "order-123",
|
|
||||||
"gross_amount": "150000.00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Payment Link
|
|
||||||
|
|
||||||
#### `POST /createtransaksi`
|
|
||||||
Generate payment link (external ERP endpoint).
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
```
|
|
||||||
X-API-KEY: your-external-api-key
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mercant_id": "merchant-001",
|
|
||||||
"nominal": 150000,
|
|
||||||
"nama": "John Doe",
|
|
||||||
"email": "john@example.com",
|
|
||||||
"no_telepon": "081234567890",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"item_id": "product-123",
|
|
||||||
"nama": "Product Name",
|
|
||||||
"harga": 150000,
|
|
||||||
"qty": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"allowed_methods": ["bank_transfer", "gopay"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "200",
|
|
||||||
"messages": "SUCCESS",
|
|
||||||
"data": {
|
|
||||||
"url": "http://localhost:5174/pay/eyJ2Ijox..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/payment-links/:token`
|
|
||||||
Resolve payment link token.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"order_id": "merchant-001:product-123",
|
|
||||||
"nominal": 150000,
|
|
||||||
"customer": {
|
|
||||||
"name": "John Doe",
|
|
||||||
"email": "john@example.com",
|
|
||||||
"phone": "081234567890"
|
|
||||||
},
|
|
||||||
"expire_at": 1733280000000,
|
|
||||||
"allowed_methods": ["bank_transfer", "gopay"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook
|
|
||||||
|
|
||||||
#### `POST /api/payments/notification`
|
|
||||||
Midtrans webhook handler (unified for CORE & SNAP).
|
|
||||||
|
|
||||||
**Request (from Midtrans):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"order_id": "order-123",
|
|
||||||
"transaction_status": "settlement",
|
|
||||||
"gross_amount": "150000.00",
|
|
||||||
"signature_key": "xxx"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging (Dev Only)
|
|
||||||
|
|
||||||
#### `GET /api/logs?limit=100&level=info&q=payment`
|
|
||||||
Get recent logs.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `limit`: Max entries (1-1000, default: 100)
|
|
||||||
- `level`: Filter by level (debug|info|warn|error)
|
|
||||||
- `q`: Search keyword
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 50,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"ts": "2024-12-04T12:00:00.000+07:00",
|
|
||||||
"level": "info",
|
|
||||||
"msg": "charge.request",
|
|
||||||
"meta": {
|
|
||||||
"id": "abc123",
|
|
||||||
"payment_type": "bank_transfer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing (Dev Only)
|
|
||||||
|
|
||||||
#### `POST /api/echo`
|
|
||||||
Echo endpoint untuk testing ERP notification.
|
|
||||||
|
|
||||||
#### `POST /api/test/notify-erp`
|
|
||||||
Manual trigger ERP notification.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"orderId": "order-123",
|
|
||||||
"nominal": "150000",
|
|
||||||
"mercant_id": "merchant-001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Payment Flow
|
|
||||||
|
|
||||||
### 1. Payment Link Creation Flow
|
|
||||||
```
|
|
||||||
ERP System → POST /createtransaksi → Server generates token → Payment URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Payment Execution Flow
|
|
||||||
```
|
|
||||||
Customer → Payment URL → Frontend resolves token →
|
|
||||||
Choose method → POST /api/payments/charge or SNAP → Midtrans
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Payment Completion Flow
|
|
||||||
```
|
|
||||||
Midtrans → Webhook → Verify signature → Update ledger →
|
|
||||||
Notify ERP → Mark order complete
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. ERP Notification Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mercant_id": "merchant-001",
|
|
||||||
"status_code": "200",
|
|
||||||
"nominal": "150000",
|
|
||||||
"signature": "sha512-hash"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Signature Calculation:**
|
|
||||||
```
|
|
||||||
SHA512(mercant_id + status_code + nominal + ERP_CLIENT_SECRET)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
Lihat folder `tests/` untuk file-file testing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test create payment link
|
|
||||||
node tests/test-create-payment-link.cjs
|
|
||||||
|
|
||||||
# Test frontend payload
|
|
||||||
node tests/test-frontend-payload.cjs
|
|
||||||
|
|
||||||
# Test snap token
|
|
||||||
node tests/test-snap-token.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Lihat `tests/README.md` untuk detail lengkap.
|
|
||||||
|
|
||||||
## 📝 Logging
|
|
||||||
|
|
||||||
### Log Levels
|
|
||||||
- **debug**: Detailed information, typically of interest only when diagnosing problems
|
|
||||||
- **info**: General informational messages
|
|
||||||
- **warn**: Warning messages for potentially harmful situations
|
|
||||||
- **error**: Error events that might still allow the application to continue running
|
|
||||||
|
|
||||||
### Log Format
|
|
||||||
```
|
|
||||||
[2024-12-04T12:00:00.000+07:00] [info] charge.request {"id": "abc123", "payment_type": "bank_transfer"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Important Log Events
|
|
||||||
|
|
||||||
#### Payment Lifecycle
|
|
||||||
- `charge.request`: Payment charge initiated
|
|
||||||
- `charge.success`: Charge successful
|
|
||||||
- `charge.error`: Charge failed
|
|
||||||
- `status.request`: Status check requested
|
|
||||||
- `webhook.received`: Webhook notification received
|
|
||||||
|
|
||||||
#### ERP Integration
|
|
||||||
- `erp.notify.start`: ERP notification started
|
|
||||||
- `erp.notify.success`: ERP notified successfully
|
|
||||||
- `erp.notify.error`: ERP notification failed
|
|
||||||
- `erp.notify.skip`: Notification skipped (already sent or disabled)
|
|
||||||
|
|
||||||
#### Security
|
|
||||||
- `webhook.signature.invalid`: Invalid webhook signature
|
|
||||||
- `createtransaksi.unauthorized`: Unauthorized API key
|
|
||||||
|
|
||||||
## 🔒 Security Features
|
|
||||||
|
|
||||||
1. **Signature Verification**
|
|
||||||
- Webhook signature validation
|
|
||||||
- Payment link token signing
|
|
||||||
- ERP notification signing
|
|
||||||
|
|
||||||
2. **Idempotency**
|
|
||||||
- Prevent duplicate order creation
|
|
||||||
- Prevent duplicate ERP notifications
|
|
||||||
- Block re-charge for pending orders
|
|
||||||
|
|
||||||
3. **API Key Authentication**
|
|
||||||
- External API key for `/createtransaksi`
|
|
||||||
- Dev mode fallback for easier local testing
|
|
||||||
|
|
||||||
4. **Payload Masking**
|
|
||||||
- Sensitive fields masked in logs
|
|
||||||
- Card numbers, CVV, tokens automatically hidden
|
|
||||||
|
|
||||||
## 🚀 Running the Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
node server/index.cjs
|
|
||||||
|
|
||||||
# Server runs on http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Related Documentation
|
|
||||||
|
|
||||||
- [Tests README](../tests/README.md) - Testing documentation
|
|
||||||
- [Temp Files README](../temp/README.md) - Temporary files info
|
|
||||||
- [Frontend README](../README.md) - Main project README
|
|
||||||
|
|
||||||
## 🐛 Common Issues
|
|
||||||
|
|
||||||
### Issue: "Transaction already pending"
|
|
||||||
**Cause**: Order ID already has pending transaction in Midtrans
|
|
||||||
**Solution**: Use existing payment instructions or create new order with different ID
|
|
||||||
|
|
||||||
### Issue: "ERP notification failed"
|
|
||||||
**Cause**: ERP endpoint unreachable or signature mismatch
|
|
||||||
**Solution**: Check `ERP_NOTIFICATION_URLS` and `ERP_CLIENT_SECRET` configuration
|
|
||||||
|
|
||||||
### Issue: "Invalid signature on webhook"
|
|
||||||
**Cause**: Incorrect server key or webhook from unauthorized source
|
|
||||||
**Solution**: Verify `MIDTRANS_SERVER_KEY` matches your Midtrans account
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
Untuk bantuan lebih lanjut, hubungi tim development atau lihat dokumentasi Midtrans:
|
|
||||||
- [Midtrans API Documentation](https://docs.midtrans.com/)
|
|
||||||
- [Midtrans Node.js Library](https://github.com/Midtrans/midtrans-nodejs-client)
|
|
||||||
834
server/index.cjs
834
server/index.cjs
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
import { CheckoutPage } from '../pages/CheckoutPage'
|
// import { CheckoutPage } from '../pages/CheckoutPage'
|
||||||
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
|
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
|
||||||
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
|
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
|
||||||
import { NotFoundPage } from '../pages/NotFoundPage'
|
import { NotFoundPage } from '../pages/NotFoundPage'
|
||||||
|
|
@ -15,8 +15,7 @@ const router = createBrowserRouter([
|
||||||
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
|
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <InitPage /> },
|
{ index: true, element: <InitPage /> },
|
||||||
{ path: 'checkout', element: <CheckoutPage /> },
|
// { path: 'checkout', element: <CheckoutPage /> },
|
||||||
// { path: 'demo', element: <DemoStorePage /> },
|
|
||||||
{ path: 'pay/:token', element: <PayPage /> },
|
{ path: 'pay/:token', element: <PayPage /> },
|
||||||
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
||||||
{ path: 'history', element: <PaymentHistoryPage /> },
|
{ path: 'history', element: <PaymentHistoryPage /> },
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Button } from '../../../components/ui/button'
|
import { Button } from '../../../components/ui/button'
|
||||||
import { usePaymentNavigation } from '../lib/navigation'
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { BcaInstructionList } from '../components/BcaInstructionList'
|
import { BcaInstructionList } from './BcaInstructionList'
|
||||||
import { type BankKey } from '../components/PaymentLogos'
|
import { type BankKey } from './PaymentLogos'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
import { Alert } from '../../../components/alert/Alert'
|
||||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
import { toast } from '../../../components/ui/toast'
|
import { toast } from '../../../components/ui/toast'
|
||||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||||
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||||
|
|
@ -145,15 +145,15 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-medium text-sm sm:text-base">Transfer Bank</div>
|
<div className="font-medium">Transfer Bank</div>
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="flex items-center gap-2 text-sm sm:text-base">
|
<div className="flex items-center gap-2 text-base">
|
||||||
<span className="text-black/60">Bank:</span>
|
<span className="text-black/60">Bank:</span>
|
||||||
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs sm:text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
<div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert title="Gagal membuat VA">
|
<Alert title="Gagal membuat VA">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|
@ -165,31 +165,31 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="pt-0.5 sm:pt-1">
|
<div className="pt-1">
|
||||||
<div className="rounded-lg p-2.5 sm:p-3 border-2 border-black/30">
|
<div className="rounded-lg p-3 border-2 border-black/30">
|
||||||
<div className="text-xs sm:text-sm font-medium mb-1.5 sm:mb-2">Virtual Account</div>
|
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
||||||
<div className="text-xs sm:text-sm text-black/70">
|
<div className="text-sm text-black/70">
|
||||||
{vaCode ? (
|
{vaCode ? (
|
||||||
<span>
|
<span>
|
||||||
Nomor VA:
|
Nomor VA:
|
||||||
<span className="block break-all mt-1 font-mono text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
|
<span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5 sm:gap-2" role="status" aria-live="polite">
|
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||||
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />}
|
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />}
|
||||||
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
|
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{billKey && (
|
{billKey && (
|
||||||
<span className="ml-2 sm:ml-3 block sm:inline mt-1 sm:mt-0">Bill Key: <span className="font-mono text-base sm:text-lg font-semibold text-black">{billKey}</span></span>
|
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
|
||||||
)}
|
)}
|
||||||
{billerCode && (
|
{billerCode && (
|
||||||
<span className="ml-2 sm:ml-3 block sm:inline mt-1 sm:mt-0">Biller Code: <span className="font-mono text-base sm:text-lg font-semibold text-black">{billerCode}</span></span>
|
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode} className="text-xs sm:text-sm">Copy VA</Button>
|
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey} className="text-xs sm:text-sm">Copy Bill Key</Button>
|
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2,9 +2,9 @@ import { Button } from '../../../components/ui/button'
|
||||||
import { toast } from '../../../components/ui/toast'
|
import { toast } from '../../../components/ui/toast'
|
||||||
import { usePaymentNavigation } from '../lib/navigation'
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
import { Alert } from '../../../components/alert/Alert'
|
||||||
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Button } from '../../../components/ui/button'
|
import { Button } from '../../../components/ui/button'
|
||||||
import { usePaymentNavigation } from '../lib/navigation'
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { CardLogosRow } from '../components/PaymentLogos'
|
import { CardLogosRow } from './PaymentLogos'
|
||||||
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
|
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
|
||||||
import { Logger } from '../../../lib/logger'
|
import { Logger } from '../../../lib/logger'
|
||||||
import { Env } from '../../../lib/env'
|
import { Env } from '../../../lib/env'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
import { toast } from '../../../components/ui/toast'
|
import { toast } from '../../../components/ui/toast'
|
||||||
|
|
||||||
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Button } from '../../../components/ui/button'
|
import { Button } from '../../../components/ui/button'
|
||||||
import { usePaymentNavigation } from '../lib/navigation'
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
import { PaymentInstructions } from './PaymentInstructions'
|
||||||
import { GoPayLogosRow } from '../components/PaymentLogos'
|
import { GoPayLogosRow } from './PaymentLogos'
|
||||||
import { postCharge } from '../../../services/api'
|
import { postCharge } from '../../../services/api'
|
||||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||||
import { toast } from '../../../components/ui/toast'
|
import { toast } from '../../../components/ui/toast'
|
||||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
import { Alert } from '../../../components/alert/Alert'
|
||||||
|
|
@ -30,43 +30,43 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
|
||||||
}
|
}
|
||||||
const items = baseItems.filter((it) => enabledMap[it.key])
|
const items = baseItems.filter((it) => enabledMap[it.key])
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs sm:text-sm font-medium">Metode pembayaran</div>
|
<div className="text-sm font-medium">Metode pembayaran</div>
|
||||||
<div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
|
<div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.key}>
|
<div key={it.key}>
|
||||||
<button
|
<button
|
||||||
onClick={() => !disabled && onSelect(it.key)}
|
onClick={() => !disabled && onSelect(it.key)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`w-full text-left p-3 sm:p-4 min-h-[48px] sm:min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10'} ${selected === it.key ? 'bg-black/10' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white`}
|
className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10'} ${selected === it.key ? 'bg-black/10' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white`}
|
||||||
aria-pressed={selected === it.key}
|
aria-pressed={selected === it.key}
|
||||||
aria-expanded={selected === it.key}
|
aria-expanded={selected === it.key}
|
||||||
aria-controls={`panel-${it.key}`}
|
aria-controls={`panel-${it.key}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm sm:text-base font-semibold text-black">{it.title}</div>
|
<div className="text-base font-semibold text-black">{it.title}</div>
|
||||||
{it.key === 'bank_transfer' && it.subtitle && (
|
{it.key === 'bank_transfer' && it.subtitle && (
|
||||||
<div className="mt-0.5 sm:mt-1 text-[10px] sm:text-xs text-black/60">
|
<div className="mt-1 text-xs text-black/60">
|
||||||
{it.subtitle}
|
{it.subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{it.key === 'cpay' && it.subtitle && (
|
{it.key === 'cpay' && it.subtitle && (
|
||||||
<div className="mt-0.5 sm:mt-1 text-[10px] sm:text-xs text-black/60">
|
<div className="mt-1 text-xs text-black/60">
|
||||||
{it.subtitle}
|
{it.subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{it.icon && (
|
{it.icon && (
|
||||||
<span aria-hidden>
|
<span aria-hidden>
|
||||||
{it.icon}
|
{it.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span aria-hidden className={`text-black/60 text-base sm:text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
<span aria-hidden className={`text-black/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{selected === it.key && renderPanel && (
|
{selected === it.key && renderPanel && (
|
||||||
<div id={`panel-${it.key}`} className="p-2.5 sm:p-3 bg-white">
|
<div id={`panel-${it.key}`} className="p-3 bg-white">
|
||||||
{renderPanel(it.key)}
|
{renderPanel(it.key)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,31 @@ function formatCurrencyIDR(amount: number) {
|
||||||
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(amount)
|
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useCountdown(expireAt: number) {
|
||||||
|
const [now, setNow] = React.useState(() => Date.now())
|
||||||
|
React.useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const remainMs = Math.max(0, expireAt - now)
|
||||||
|
const totalSec = Math.floor(remainMs / 1000)
|
||||||
|
const hh = String(Math.floor(totalSec / 3600)).padStart(2, '0')
|
||||||
|
const mm = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0')
|
||||||
|
const ss = String(totalSec % 60).padStart(2, '0')
|
||||||
|
return `${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaymentSheetProps {
|
export interface PaymentSheetProps {
|
||||||
merchantName?: string
|
merchantName?: string
|
||||||
orderId: string
|
orderId: string
|
||||||
amount: number
|
amount: number
|
||||||
customerName?: string
|
expireAt: number // epoch ms
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
showStatusCTA?: boolean
|
showStatusCTA?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentSheet({ orderId, amount, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
|
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||||
|
const countdown = useCountdown(expireAt)
|
||||||
const [expanded, setExpanded] = React.useState(true)
|
const [expanded, setExpanded] = React.useState(true)
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
|
|
@ -23,12 +38,19 @@ export function PaymentSheet({ orderId, amount, customerName, children, showStat
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
|
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<img
|
<div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden>
|
||||||
src="/simaya.png"
|
SIMAYA
|
||||||
alt="SIMAYA"
|
</div>
|
||||||
className="h-8 w-8 sm:h-10 sm:w-10 rounded bg-white object-contain p-1"
|
<div className="font-semibold text-sm sm:text-base">{merchantName}</div>
|
||||||
/>
|
</div>
|
||||||
<div className="font-semibold text-sm sm:text-base text-white">Simaya Retail Payment</div>
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div
|
||||||
|
className="text-xs sm:text-sm text-white/80"
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={`Kedaluwarsa dalam ${countdown}`}
|
||||||
|
>
|
||||||
|
Kedaluwarsa dalam <span className="font-semibold text-white">{countdown}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
|
@ -41,28 +63,28 @@ export function PaymentSheet({ orderId, amount, customerName, children, showStat
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="p-3 sm:p-4 border-b border-black/10 flex items-start justify-between">
|
<div className="p-4 border-b border-black/10 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] sm:text-xs text-black">Total</div>
|
<div className="text-xs text-black">Total</div>
|
||||||
<div className="text-lg sm:text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-black/60">Order ID #{orderId}</div>
|
<div className="text-xs text-black/60">Order ID #{orderId}</div>
|
||||||
{customerName && <div className="text-[10px] sm:text-xs text-black/60 mt-1">Nama: {customerName}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-3 sm:p-4">
|
<div className="p-4">
|
||||||
{children}
|
{children}
|
||||||
<TrustStrip location="sheet" />
|
<TrustStrip location="sheet" />
|
||||||
</div>
|
</div>
|
||||||
{showStatusCTA && (
|
{showStatusCTA && (
|
||||||
<div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-2.5 sm:p-3 pb-[env(safe-area-inset-bottom)]">
|
<div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]">
|
||||||
<Link
|
<Link
|
||||||
to={`/payments/${orderId}/status`}
|
to={`/payments/${orderId}/status`}
|
||||||
aria-label="Buka halaman Status Pembayaran"
|
aria-label="Buka halaman Status Pembayaran"
|
||||||
className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-2.5 sm:py-3 text-sm sm:text-base font-semibold hover:bg-[#0a1a35] hover:!text-white focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f] visited:!text-white active:!text-white"
|
className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-3 text-base font-semibold hover:bg-[#0a1a35] hover:!text-white focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f] visited:!text-white active:!text-white"
|
||||||
>
|
>
|
||||||
Cek status pembayaran
|
Cek status pembayaran
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { getPaymentMode } from './paymentMode'
|
|
||||||
|
|
||||||
export type PaymentMethod = 'bank_transfer' | 'credit_card' | 'gopay' | 'cstore'
|
|
||||||
|
|
||||||
export class PaymentAdapter {
|
|
||||||
static getPaymentComponent(method: PaymentMethod) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
if (mode === 'SNAP') {
|
|
||||||
return this.getSnapComponent(method)
|
|
||||||
} else {
|
|
||||||
return this.getCoreComponent(method)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getCoreComponent(method: PaymentMethod) {
|
|
||||||
switch (method) {
|
|
||||||
case 'bank_transfer':
|
|
||||||
return import('../core/BankTransferPanel')
|
|
||||||
case 'credit_card':
|
|
||||||
return import('../core/CardPanel')
|
|
||||||
case 'gopay':
|
|
||||||
return import('../core/GoPayPanel')
|
|
||||||
case 'cstore':
|
|
||||||
return import('../core/CStorePanel')
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown payment method: ${method}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getSnapComponent(_method: PaymentMethod) {
|
|
||||||
// For Snap, most methods are handled by hosted interface
|
|
||||||
// But we still need to return the SnapPaymentTrigger for consistency
|
|
||||||
return import('../snap/SnapPaymentTrigger')
|
|
||||||
}
|
|
||||||
|
|
||||||
static shouldUseHostedInterface(_method: PaymentMethod): boolean {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
return mode === 'SNAP'
|
|
||||||
}
|
|
||||||
|
|
||||||
static getAvailableMethods(): PaymentMethod[] {
|
|
||||||
// Return methods available in current mode
|
|
||||||
// This could be extended to filter based on environment config
|
|
||||||
return ['bank_transfer', 'credit_card', 'gopay', 'cstore']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -68,7 +68,6 @@ export interface PaymentStatusResponse {
|
||||||
// Common
|
// Common
|
||||||
transactionTime?: string
|
transactionTime?: string
|
||||||
grossAmount?: string
|
grossAmount?: string
|
||||||
customerName?: string // From localStorage, not from Midtrans API
|
|
||||||
// Bank transfer
|
// Bank transfer
|
||||||
vaNumber?: string
|
vaNumber?: string
|
||||||
bank?: string
|
bank?: string
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
export const PaymentMode = {
|
|
||||||
CORE: 'CORE',
|
|
||||||
SNAP: 'SNAP'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type PaymentModeType = typeof PaymentMode[keyof typeof PaymentMode]
|
|
||||||
|
|
||||||
export function getPaymentMode(): PaymentModeType {
|
|
||||||
return (import.meta.env.VITE_PAYMENT_GATEWAY_MODE as PaymentModeType) || 'SNAP'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSnapMode(): boolean {
|
|
||||||
return getPaymentMode() === PaymentMode.SNAP
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCoreMode(): boolean {
|
|
||||||
return getPaymentMode() === PaymentMode.CORE
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
import { TransactionLogger } from './TransactionLogger'
|
|
||||||
import { getPaymentMode } from '../lib/paymentMode'
|
|
||||||
|
|
||||||
export interface CustomerData {
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
name?: string
|
|
||||||
email?: string
|
|
||||||
phone?: string
|
|
||||||
address?: string
|
|
||||||
city?: string
|
|
||||||
postal_code?: string
|
|
||||||
country_code?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SanitizedCustomerData {
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
phone?: string
|
|
||||||
address?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CustomerDataHandler {
|
|
||||||
static sanitizeCustomerData(customer: CustomerData): SanitizedCustomerData {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Combine first_name and last_name if available, otherwise use name
|
|
||||||
const name = customer.name ||
|
|
||||||
(customer.first_name && customer.last_name
|
|
||||||
? `${customer.first_name} ${customer.last_name}`
|
|
||||||
: customer.first_name || customer.last_name || 'Unknown')
|
|
||||||
|
|
||||||
// Basic email validation
|
|
||||||
const email = customer.email?.toLowerCase().trim()
|
|
||||||
if (!email || !this.isValidEmail(email)) {
|
|
||||||
TransactionLogger.log(mode, 'customer.data.warning', {
|
|
||||||
field: 'email',
|
|
||||||
reason: 'invalid_or_missing'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize phone number (remove non-numeric characters except +)
|
|
||||||
const phone = customer.phone?.replace(/[^\d+]/g, '')
|
|
||||||
|
|
||||||
// Create sanitized address if available
|
|
||||||
const address = this.buildAddressString(customer)
|
|
||||||
|
|
||||||
const sanitized: SanitizedCustomerData = {
|
|
||||||
name: name.trim(),
|
|
||||||
email: email || '',
|
|
||||||
phone,
|
|
||||||
address
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionLogger.log(mode, 'customer.data.sanitized', {
|
|
||||||
hasName: !!sanitized.name,
|
|
||||||
hasEmail: !!sanitized.email,
|
|
||||||
hasPhone: !!sanitized.phone,
|
|
||||||
hasAddress: !!sanitized.address
|
|
||||||
})
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
TransactionLogger.log(mode, 'customer.data.sanitization.error', {
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
})
|
|
||||||
// Return minimal safe data on error
|
|
||||||
return {
|
|
||||||
name: 'Unknown Customer',
|
|
||||||
email: '',
|
|
||||||
phone: customer.phone,
|
|
||||||
address: customer.address
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateCustomerData(customer: CustomerData): { isValid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (!customer.name && !customer.first_name && !customer.last_name) {
|
|
||||||
errors.push('Name is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer.email) {
|
|
||||||
errors.push('Email is required')
|
|
||||||
} else if (!this.isValidEmail(customer.email)) {
|
|
||||||
errors.push('Invalid email format')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer.phone) {
|
|
||||||
errors.push('Phone number is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static buildAddressString(customer: CustomerData): string | undefined {
|
|
||||||
const parts: string[] = []
|
|
||||||
|
|
||||||
if (customer.address) parts.push(customer.address)
|
|
||||||
if (customer.city) parts.push(customer.city)
|
|
||||||
if (customer.postal_code) parts.push(customer.postal_code)
|
|
||||||
if (customer.country_code) parts.push(customer.country_code)
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(', ') : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private static isValidEmail(email: string): boolean {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
||||||
return emailRegex.test(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatForMidtrans(customer: SanitizedCustomerData) {
|
|
||||||
// Format customer data for Midtrans API (both Core and Snap)
|
|
||||||
return {
|
|
||||||
first_name: customer.name.split(' ')[0] || '',
|
|
||||||
last_name: customer.name.split(' ').slice(1).join(' ') || '',
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone || '',
|
|
||||||
address: customer.address || '',
|
|
||||||
city: '',
|
|
||||||
postal_code: '',
|
|
||||||
country_code: 'IDN'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { api } from '../../../services/api'
|
|
||||||
import { TransactionLogger } from './TransactionLogger'
|
|
||||||
import { getPaymentMode } from '../lib/paymentMode'
|
|
||||||
|
|
||||||
export class OrderManager {
|
|
||||||
static async validateOrder(orderId: string, amount: number): Promise<boolean> {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Basic validation
|
|
||||||
if (!orderId || amount <= 0) {
|
|
||||||
TransactionLogger.log(mode, 'order.validation.failed', {
|
|
||||||
orderId,
|
|
||||||
amount,
|
|
||||||
reason: 'invalid_parameters'
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional business rules can be added here
|
|
||||||
// For now, just check if order exists in our system
|
|
||||||
const orderDetails = await this.getOrderDetails(orderId)
|
|
||||||
|
|
||||||
if (!orderDetails) {
|
|
||||||
TransactionLogger.log(mode, 'order.validation.failed', {
|
|
||||||
orderId,
|
|
||||||
reason: 'order_not_found'
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check amount matches
|
|
||||||
if (orderDetails.amount !== amount) {
|
|
||||||
TransactionLogger.log(mode, 'order.validation.failed', {
|
|
||||||
orderId,
|
|
||||||
expectedAmount: orderDetails.amount,
|
|
||||||
providedAmount: amount,
|
|
||||||
reason: 'amount_mismatch'
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionLogger.log(mode, 'order.validation.success', { orderId, amount })
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
TransactionLogger.logPaymentError(mode, orderId, error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getOrderDetails(orderId: string) {
|
|
||||||
try {
|
|
||||||
// This would typically call your ERP or order management system
|
|
||||||
// For now, return mock data or call existing API
|
|
||||||
const response = await api.get(`/orders/${orderId}`)
|
|
||||||
return response.data
|
|
||||||
} catch (error) {
|
|
||||||
// If API doesn't exist yet, return null (will be implemented in Epic 5)
|
|
||||||
console.warn(`Order details API not available for ${orderId}:`, error instanceof Error ? error.message : String(error))
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async updateOrderStatus(orderId: string, status: string, source: string) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// This would update your ERP system to unfreeze inventory, etc.
|
|
||||||
// For now, just log the update
|
|
||||||
TransactionLogger.log(mode, 'order.status.updated', {
|
|
||||||
orderId,
|
|
||||||
status,
|
|
||||||
source,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: Implement actual ERP integration in Epic 5
|
|
||||||
// await api.post('/erp/orders/update-status', { orderId, status, source })
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
TransactionLogger.logPaymentError(mode, orderId, error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Logger } from '../../../lib/logger'
|
|
||||||
|
|
||||||
export class TransactionLogger {
|
|
||||||
static log(mode: 'CORE' | 'SNAP', event: string, data: any) {
|
|
||||||
Logger.info(`[${mode}] ${event}`, {
|
|
||||||
...data,
|
|
||||||
mode,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static logPaymentInit(mode: 'CORE' | 'SNAP', orderId: string, amount: number) {
|
|
||||||
this.log(mode, 'payment.init', { orderId, amount })
|
|
||||||
}
|
|
||||||
|
|
||||||
static logPaymentSuccess(mode: 'CORE' | 'SNAP', orderId: string, transactionId: string) {
|
|
||||||
this.log(mode, 'payment.success', { orderId, transactionId })
|
|
||||||
}
|
|
||||||
|
|
||||||
static logPaymentError(mode: 'CORE' | 'SNAP', orderId: string, error: any) {
|
|
||||||
this.log(mode, 'payment.error', { orderId, error: error.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
static logWebhookReceived(mode: 'CORE' | 'SNAP', orderId: string, status: string) {
|
|
||||||
this.log(mode, 'webhook.received', { orderId, status })
|
|
||||||
}
|
|
||||||
|
|
||||||
static logWebhookProcessed(mode: 'CORE' | 'SNAP', orderId: string, internalStatus: string) {
|
|
||||||
this.log(mode, 'webhook.processed', { orderId, internalStatus })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { Button } from '../../../components/ui/button'
|
|
||||||
import { getPaymentMode } from '../lib/paymentMode'
|
|
||||||
import { Alert } from '../../../components/alert/Alert'
|
|
||||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
|
||||||
import { mapErrorToUserMessage } from '../../../lib/errorMessages'
|
|
||||||
import { Logger } from '../../../lib/logger'
|
|
||||||
import { SnapTokenService } from './SnapTokenService'
|
|
||||||
|
|
||||||
interface SnapPaymentTriggerProps {
|
|
||||||
orderId: string
|
|
||||||
amount: number
|
|
||||||
customer?: { name?: string; phone?: string; email?: string }
|
|
||||||
paymentMethod?: string
|
|
||||||
onSuccess?: (result: any) => void
|
|
||||||
onError?: (error: any) => void
|
|
||||||
onChargeInitiated?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SnapPaymentTrigger({
|
|
||||||
orderId,
|
|
||||||
amount,
|
|
||||||
customer,
|
|
||||||
paymentMethod,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
onChargeInitiated
|
|
||||||
}: SnapPaymentTriggerProps) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
// If Core mode, render the appropriate Core component
|
|
||||||
if (mode === 'CORE') {
|
|
||||||
return (
|
|
||||||
<CorePaymentComponent
|
|
||||||
paymentMethod={paymentMethod || 'bank_transfer'}
|
|
||||||
orderId={orderId}
|
|
||||||
amount={amount}
|
|
||||||
onChargeInitiated={onChargeInitiated}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snap mode - use hosted payment interface
|
|
||||||
return (
|
|
||||||
<SnapHostedPayment
|
|
||||||
orderId={orderId}
|
|
||||||
amount={amount}
|
|
||||||
customer={customer}
|
|
||||||
onSuccess={onSuccess}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CorePaymentComponent({ paymentMethod, orderId, amount, onChargeInitiated }: {
|
|
||||||
paymentMethod: string
|
|
||||||
orderId: string
|
|
||||||
amount: number
|
|
||||||
onChargeInitiated?: () => void
|
|
||||||
}) {
|
|
||||||
const [Component, setComponent] = React.useState<React.ComponentType<any> | null>(null)
|
|
||||||
const [loading, setLoading] = React.useState(true)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const loadComponent = async () => {
|
|
||||||
try {
|
|
||||||
let componentModule: any
|
|
||||||
|
|
||||||
switch (paymentMethod) {
|
|
||||||
case 'bank_transfer':
|
|
||||||
componentModule = await import('../core/BankTransferPanel')
|
|
||||||
setComponent(() => componentModule.BankTransferPanel)
|
|
||||||
break
|
|
||||||
case 'credit_card':
|
|
||||||
componentModule = await import('../core/CardPanel')
|
|
||||||
setComponent(() => componentModule.CardPanel)
|
|
||||||
break
|
|
||||||
case 'gopay':
|
|
||||||
componentModule = await import('../core/GoPayPanel')
|
|
||||||
setComponent(() => componentModule.GoPayPanel)
|
|
||||||
break
|
|
||||||
case 'cstore':
|
|
||||||
componentModule = await import('../core/CStorePanel')
|
|
||||||
setComponent(() => componentModule.CStorePanel)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
componentModule = await import('../core/BankTransferPanel')
|
|
||||||
setComponent(() => componentModule.BankTransferPanel)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load payment component:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadComponent()
|
|
||||||
}, [paymentMethod])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading payment component...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Component) {
|
|
||||||
return <div>Payment method not available</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component orderId={orderId} amount={amount} onChargeInitiated={onChargeInitiated} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Omit<SnapPaymentTriggerProps, 'paymentMethod' | 'onChargeInitiated'>) {
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState('')
|
|
||||||
|
|
||||||
const handleSnapPayment = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
Logger.paymentInfo('snap.payment.init', { orderId, amount, customer })
|
|
||||||
|
|
||||||
// Create Snap transaction token using service
|
|
||||||
const token = await SnapTokenService.createToken({
|
|
||||||
transaction_details: {
|
|
||||||
order_id: orderId,
|
|
||||||
gross_amount: amount
|
|
||||||
},
|
|
||||||
customer_details: customer ? {
|
|
||||||
first_name: customer.name,
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone
|
|
||||||
} : undefined,
|
|
||||||
item_details: [{
|
|
||||||
id: orderId,
|
|
||||||
name: 'Payment',
|
|
||||||
price: amount,
|
|
||||||
quantity: 1
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
Logger.paymentInfo('snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
|
||||||
|
|
||||||
// Trigger Snap payment popup
|
|
||||||
if (window.snap && typeof window.snap.pay === 'function') {
|
|
||||||
window.snap.pay(token, {
|
|
||||||
onSuccess: (result: any) => {
|
|
||||||
Logger.paymentInfo('snap.payment.success', { orderId, transactionId: result.transaction_id })
|
|
||||||
onSuccess?.(result)
|
|
||||||
},
|
|
||||||
onPending: (result: any) => {
|
|
||||||
Logger.paymentInfo('snap.payment.pending', { orderId, transactionId: result.transaction_id })
|
|
||||||
// Handle pending state
|
|
||||||
},
|
|
||||||
onError: (result: any) => {
|
|
||||||
Logger.paymentError('snap.payment.error', { orderId, error: result })
|
|
||||||
const message = mapErrorToUserMessage(result)
|
|
||||||
setError(message)
|
|
||||||
onError?.(result)
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
Logger.paymentInfo('snap.popup.closed', { orderId })
|
|
||||||
// User closed the popup without completing payment
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
throw new Error('Snap.js not loaded')
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
Logger.paymentError('snap.payment.error', { orderId, error: e.message })
|
|
||||||
const message = mapErrorToUserMessage(e)
|
|
||||||
setError(message)
|
|
||||||
onError?.(e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert title="Pembayaran Gagal">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Klik tombol di bawah untuk melanjutkan pembayaran dengan Midtrans Snap
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSnapPayment}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full max-w-xs"
|
|
||||||
>
|
|
||||||
{loading ? 'Memproses...' : 'Bayar Sekarang'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && <LoadingOverlay isLoading={loading} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { api } from '../../../services/api'
|
|
||||||
import { TransactionLogger } from '../shared/TransactionLogger'
|
|
||||||
import { getPaymentMode } from '../lib/paymentMode'
|
|
||||||
|
|
||||||
export interface SnapTokenRequest {
|
|
||||||
transaction_details: {
|
|
||||||
order_id: string
|
|
||||||
gross_amount: number
|
|
||||||
}
|
|
||||||
customer_details?: {
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
email?: string
|
|
||||||
phone?: string
|
|
||||||
}
|
|
||||||
item_details?: Array<{
|
|
||||||
id: string
|
|
||||||
price: number
|
|
||||||
quantity: number
|
|
||||||
name: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SnapTokenResponse {
|
|
||||||
token: string | {
|
|
||||||
token: string
|
|
||||||
redirect_url: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SnapTokenService {
|
|
||||||
static async createToken(request: SnapTokenRequest): Promise<string> {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
|
|
||||||
if (mode !== 'SNAP') {
|
|
||||||
throw new Error('Snap token creation only available in SNAP mode')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
TransactionLogger.logPaymentInit('SNAP', request.transaction_details.order_id, request.transaction_details.gross_amount)
|
|
||||||
|
|
||||||
const response = await api.post<SnapTokenResponse>('/payments/snap/token', request)
|
|
||||||
|
|
||||||
// Handle both response formats:
|
|
||||||
// 1. Direct string: { token: "abc123" }
|
|
||||||
// 2. Nested object: { token: { token: "abc123", redirect_url: "..." } }
|
|
||||||
let tokenString: string
|
|
||||||
if (typeof response.data?.token === 'string') {
|
|
||||||
tokenString = response.data.token
|
|
||||||
} else if (response.data?.token && typeof response.data.token === 'object') {
|
|
||||||
tokenString = response.data.token.token
|
|
||||||
} else {
|
|
||||||
throw new Error('Invalid token response from server')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tokenString) {
|
|
||||||
throw new Error('Empty token received from server')
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionLogger.log('SNAP', 'token.created', {
|
|
||||||
orderId: request.transaction_details.order_id,
|
|
||||||
tokenLength: tokenString.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return tokenString
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
TransactionLogger.logPaymentError('SNAP', request.transaction_details.order_id, error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateTokenRequest(request: SnapTokenRequest): { isValid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
if (!request.transaction_details?.order_id) {
|
|
||||||
errors.push('Order ID is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!request.transaction_details?.gross_amount || request.transaction_details.gross_amount <= 0) {
|
|
||||||
errors.push('Valid gross amount is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate item details sum matches gross amount
|
|
||||||
if (request.item_details && request.item_details.length > 0) {
|
|
||||||
const totalFromItems = request.item_details.reduce((sum, item) => {
|
|
||||||
return sum + (item.price * item.quantity)
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
if (totalFromItems !== request.transaction_details.gross_amount) {
|
|
||||||
errors.push(`Item total (${totalFromItems}) does not match gross amount (${request.transaction_details.gross_amount})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,6 @@ export const Env = {
|
||||||
MIDTRANS_CLIENT_KEY: import.meta.env.VITE_MIDTRANS_CLIENT_KEY || '',
|
MIDTRANS_CLIENT_KEY: import.meta.env.VITE_MIDTRANS_CLIENT_KEY || '',
|
||||||
MIDTRANS_ENV: (import.meta.env.VITE_MIDTRANS_ENV as 'sandbox' | 'production') || 'sandbox',
|
MIDTRANS_ENV: (import.meta.env.VITE_MIDTRANS_ENV as 'sandbox' | 'production') || 'sandbox',
|
||||||
LOG_LEVEL: ((import.meta.env.VITE_LOG_LEVEL as string) || 'info').toLowerCase() as 'debug' | 'info' | 'warn' | 'error',
|
LOG_LEVEL: ((import.meta.env.VITE_LOG_LEVEL as string) || 'info').toLowerCase() as 'debug' | 'info' | 'warn' | 'error',
|
||||||
// Payment gateway mode: CORE (custom UI) or SNAP (hosted interface)
|
|
||||||
PAYMENT_GATEWAY_MODE: (import.meta.env.VITE_PAYMENT_GATEWAY_MODE as 'CORE' | 'SNAP') || 'SNAP',
|
|
||||||
// Feature toggles per payment type (frontend)
|
// Feature toggles per payment type (frontend)
|
||||||
ENABLE_BANK_TRANSFER: parseEnable(import.meta.env.VITE_ENABLE_BANK_TRANSFER),
|
ENABLE_BANK_TRANSFER: parseEnable(import.meta.env.VITE_ENABLE_BANK_TRANSFER),
|
||||||
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
|
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Env } from './env'
|
import { Env } from './env'
|
||||||
import { getPaymentMode } from '../features/payments/lib/paymentMode'
|
|
||||||
|
|
||||||
type Level = 'debug' | 'info' | 'warn' | 'error'
|
type Level = 'debug' | 'info' | 'warn' | 'error'
|
||||||
const order: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 }
|
const order: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||||
|
|
@ -63,21 +62,4 @@ export const Logger = {
|
||||||
mask(meta: any) {
|
mask(meta: any) {
|
||||||
return maskSensitive(meta)
|
return maskSensitive(meta)
|
||||||
},
|
},
|
||||||
// Payment-specific logging with mode prefix
|
|
||||||
paymentInfo(msg: string, meta?: any) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
this.info(`[${mode}] ${msg}`, meta)
|
|
||||||
},
|
|
||||||
paymentDebug(msg: string, meta?: any) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
this.debug(`[${mode}] ${msg}`, meta)
|
|
||||||
},
|
|
||||||
paymentWarn(msg: string, meta?: any) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
this.warn(`[${mode}] ${msg}`, meta)
|
|
||||||
},
|
|
||||||
paymentError(msg: string, meta?: any) {
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
this.error(`[${mode}] ${msg}`, meta)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import { Env } from './env'
|
|
||||||
import { Logger } from './logger'
|
|
||||||
|
|
||||||
let snapLoaded = false
|
|
||||||
let snapPromise: Promise<void> | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically loads Midtrans Snap.js script
|
|
||||||
* Returns a promise that resolves when Snap.js is ready
|
|
||||||
*/
|
|
||||||
export function loadSnapScript(): Promise<void> {
|
|
||||||
// Return existing promise if already loading
|
|
||||||
if (snapPromise) {
|
|
||||||
return snapPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already loaded
|
|
||||||
if (snapLoaded && window.snap) {
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start loading
|
|
||||||
snapPromise = new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const clientKey = Env.MIDTRANS_CLIENT_KEY
|
|
||||||
const midtransEnv = Env.MIDTRANS_ENV || 'sandbox'
|
|
||||||
|
|
||||||
if (!clientKey) {
|
|
||||||
const error = 'MIDTRANS_CLIENT_KEY not configured'
|
|
||||||
Logger.error('snap.load.error', { error })
|
|
||||||
reject(new Error(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine Snap.js URL based on environment
|
|
||||||
const snapUrl = midtransEnv === 'production'
|
|
||||||
? 'https://app.midtrans.com/snap/snap.js'
|
|
||||||
: 'https://app.sandbox.midtrans.com/snap/snap.js'
|
|
||||||
|
|
||||||
Logger.info('snap.load.start', { snapUrl, clientKey: clientKey.substring(0, 10) + '...' })
|
|
||||||
|
|
||||||
// Check if script already exists
|
|
||||||
const existingScript = document.querySelector(`script[src="${snapUrl}"]`)
|
|
||||||
if (existingScript) {
|
|
||||||
Logger.info('snap.load.exists', { snapUrl })
|
|
||||||
// Wait a bit and check if window.snap is available
|
|
||||||
setTimeout(() => {
|
|
||||||
if (window.snap) {
|
|
||||||
snapLoaded = true
|
|
||||||
Logger.info('snap.load.ready', { hasSnap: true })
|
|
||||||
resolve()
|
|
||||||
} else {
|
|
||||||
Logger.error('snap.load.error', { error: 'Script loaded but window.snap not available' })
|
|
||||||
reject(new Error('Snap.js loaded but window.snap not available'))
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create script element
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = snapUrl
|
|
||||||
script.setAttribute('data-client-key', clientKey)
|
|
||||||
|
|
||||||
script.onload = () => {
|
|
||||||
Logger.info('snap.script.loaded', { snapUrl })
|
|
||||||
console.log('Snap.js script loaded, waiting for initialization...')
|
|
||||||
// Wait a bit for Snap to initialize
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('After 500ms delay - window.snap:', window.snap)
|
|
||||||
console.log('After 500ms delay - window.snap?.pay:', window.snap?.pay)
|
|
||||||
if (window.snap && typeof window.snap.pay === 'function') {
|
|
||||||
snapLoaded = true
|
|
||||||
Logger.info('snap.load.success', { hasSnap: true, hasPay: true })
|
|
||||||
console.log('✓ Snap.js ready!')
|
|
||||||
resolve()
|
|
||||||
} else {
|
|
||||||
const error = 'Snap.js loaded but window.snap.pay not available'
|
|
||||||
Logger.error('snap.load.error', { error, hasSnap: !!window.snap })
|
|
||||||
console.error('✗ Snap.js error:', error, { hasSnap: !!window.snap, snapObj: window.snap })
|
|
||||||
reject(new Error(error))
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
script.onerror = (error) => {
|
|
||||||
Logger.error('snap.script.error', { error, snapUrl })
|
|
||||||
reject(new Error('Failed to load Snap.js script'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append script to document
|
|
||||||
document.head.appendChild(script)
|
|
||||||
Logger.info('snap.script.appended', { snapUrl })
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
Logger.error('snap.load.exception', { error: error.message })
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return snapPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Snap.js is already loaded and ready
|
|
||||||
*/
|
|
||||||
export function isSnapReady(): boolean {
|
|
||||||
return snapLoaded && !!window.snap && typeof window.snap.pay === 'function'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for Snap.js to be ready, with timeout
|
|
||||||
*/
|
|
||||||
export function waitForSnap(timeoutMs: number = 5000): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (isSnapReady()) {
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
const checkInterval = setInterval(() => {
|
|
||||||
if (isSnapReady()) {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
resolve()
|
|
||||||
} else if (Date.now() - startTime > timeoutMs) {
|
|
||||||
clearInterval(checkInterval)
|
|
||||||
reject(new Error(`Snap.js not ready after ${timeoutMs}ms`))
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
26
src/main.tsx
26
src/main.tsx
|
|
@ -1,7 +1,6 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
import { getPaymentMode } from './features/payments/lib/paymentMode'
|
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const html = document.documentElement
|
const html = document.documentElement
|
||||||
|
|
@ -15,31 +14,6 @@ import { getPaymentMode } from './features/payments/lib/paymentMode'
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// Validate payment gateway mode on startup
|
|
||||||
const mode = getPaymentMode()
|
|
||||||
if (!['CORE', 'SNAP'].includes(mode)) {
|
|
||||||
throw new Error(`Invalid PAYMENT_GATEWAY_MODE: ${mode}. Must be 'CORE' or 'SNAP'`)
|
|
||||||
}
|
|
||||||
console.log(`[PAYMENT] Mode: ${mode}`)
|
|
||||||
|
|
||||||
// Load Snap.js script conditionally for Snap mode
|
|
||||||
if (mode === 'SNAP') {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = 'https://app.sandbox.midtrans.com/snap/snap.js'
|
|
||||||
script.setAttribute('data-client-key', import.meta.env.VITE_MIDTRANS_CLIENT_KEY || '')
|
|
||||||
script.async = true
|
|
||||||
document.head.appendChild(script)
|
|
||||||
|
|
||||||
script.onload = () => {
|
|
||||||
console.log('[SNAP] Snap.js loaded successfully')
|
|
||||||
}
|
|
||||||
|
|
||||||
script.onerror = () => {
|
|
||||||
console.error('[SNAP] Failed to load Snap.js')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import { AppRouter } from './app/router'
|
import { AppRouter } from './app/router'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,206 +1,17 @@
|
||||||
import React from 'react'
|
|
||||||
import { Alert } from '../components/alert/Alert'
|
import { Alert } from '../components/alert/Alert'
|
||||||
import { Button } from '../components/ui/button'
|
import { Button } from '../components/ui/button'
|
||||||
import { Env } from '../lib/env'
|
import { Env } from '../lib/env'
|
||||||
import { Logger } from '../lib/logger'
|
|
||||||
import { loadSnapScript } from '../lib/snapLoader'
|
|
||||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||||
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||||
import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
|
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
|
||||||
interface AutoSnapPaymentProps {
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||||
orderId: string
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||||
amount: number
|
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||||
customer?: { name?: string; phone?: string; email?: string }
|
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
|
||||||
onChargeInitiated?: () => void
|
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||||
onSuccess?: (result: any) => void
|
import { Logger } from '../lib/logger'
|
||||||
onError?: (error: any) => void
|
import React from 'react'
|
||||||
onModalClosed?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError, onModalClosed }: Omit<AutoSnapPaymentProps, 'onChargeInitiated'>) {
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState('')
|
|
||||||
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
|
||||||
|
|
||||||
// Debug log immediately on component mount
|
|
||||||
console.log('AutoSnapPayment mounted with:', { orderId, amount, customer })
|
|
||||||
Logger.info('autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
console.log('AutoSnapPayment useEffect triggered', { orderId, amount, paymentTriggered })
|
|
||||||
// Only trigger when we have valid orderId and amount and not already triggered
|
|
||||||
if (!orderId || !amount || paymentTriggered) {
|
|
||||||
console.log('AutoSnapPayment useEffect early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerPayment = async () => {
|
|
||||||
console.log('triggerPayment function called!')
|
|
||||||
setPaymentTriggered(true) // Mark as triggered immediately
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.init', { orderId, amount, customer })
|
|
||||||
|
|
||||||
// Load Snap.js first
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.loading_script', { orderId })
|
|
||||||
await loadSnapScript()
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
|
|
||||||
|
|
||||||
// Create Snap transaction token
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.calling_api', { orderId, amount })
|
|
||||||
const token = await SnapTokenService.createToken({
|
|
||||||
transaction_details: {
|
|
||||||
order_id: orderId,
|
|
||||||
gross_amount: amount
|
|
||||||
},
|
|
||||||
customer_details: customer ? {
|
|
||||||
first_name: customer.name,
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone
|
|
||||||
} : undefined,
|
|
||||||
item_details: [{
|
|
||||||
id: orderId,
|
|
||||||
name: 'Payment',
|
|
||||||
price: amount,
|
|
||||||
quantity: 1
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
|
||||||
console.log('Token berhasil dibuat:', token)
|
|
||||||
|
|
||||||
// Store customer name in localStorage for status page
|
|
||||||
if (customer?.name) {
|
|
||||||
try {
|
|
||||||
const customerCache = JSON.parse(localStorage.getItem('customerCache') || '{}')
|
|
||||||
customerCache[orderId] = { name: customer.name, timestamp: Date.now() }
|
|
||||||
localStorage.setItem('customerCache', JSON.stringify(customerCache))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to cache customer name:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify Snap.js is loaded
|
|
||||||
console.log('window.snap:', window.snap)
|
|
||||||
console.log('window.snap.pay:', window.snap?.pay)
|
|
||||||
console.log('typeof window.snap?.pay:', typeof window.snap?.pay)
|
|
||||||
|
|
||||||
if (!window.snap || typeof window.snap.pay !== 'function') {
|
|
||||||
const errorMsg = `Snap.js not properly loaded: hasSnap=${!!window.snap}, hasPay=${typeof window.snap?.pay}`
|
|
||||||
console.error(errorMsg)
|
|
||||||
throw new Error(errorMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-trigger Snap payment popup
|
|
||||||
console.log('Memanggil window.snap.pay dengan token:', token.substring(0, 20) + '...')
|
|
||||||
console.log('Full token:', token)
|
|
||||||
setLoading(false) // Stop loading indicator before showing modal
|
|
||||||
|
|
||||||
window.snap.pay(token, {
|
|
||||||
onSuccess: (result: any) => {
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
|
|
||||||
onSuccess?.(result)
|
|
||||||
},
|
|
||||||
onPending: (result: any) => {
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
|
|
||||||
},
|
|
||||||
onError: (result: any) => {
|
|
||||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: result })
|
|
||||||
const message = 'Pembayaran gagal. Silakan coba lagi.'
|
|
||||||
setError(message)
|
|
||||||
setLoading(false)
|
|
||||||
onError?.(result)
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
|
|
||||||
console.log('🔵 Snap modal closed - calling onModalClosed')
|
|
||||||
setLoading(false)
|
|
||||||
onModalClosed?.() // Enable status button when modal closed
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message, stack: e.stack })
|
|
||||||
console.error('Error membuat token Snap:', e)
|
|
||||||
|
|
||||||
// Handle specific error: order_id already taken
|
|
||||||
const errorMessage = e.response?.data?.message || e.message || ''
|
|
||||||
const isOrderTaken = errorMessage.includes('already been taken') ||
|
|
||||||
errorMessage.includes('order_id has already been taken')
|
|
||||||
|
|
||||||
if (isOrderTaken) {
|
|
||||||
const message = 'Order ID sudah digunakan. Pembayaran untuk order ini sudah dibuat. Silakan cek halaman status pembayaran.'
|
|
||||||
setError(message)
|
|
||||||
} else {
|
|
||||||
const message = e.response?.data?.message || e.message || 'Gagal memuat pembayaran. Silakan refresh halaman.'
|
|
||||||
setError(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
onError?.(e)
|
|
||||||
onModalClosed?.() // Enable status button on error
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to ensure UI is rendered
|
|
||||||
console.log('Setting timeout to call triggerPayment in 500ms...')
|
|
||||||
const timer = setTimeout(triggerPayment, 500)
|
|
||||||
return () => {
|
|
||||||
console.log('Cleanup: clearing timeout')
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
|
|
||||||
|
|
||||||
// Don't render anything until we have valid data
|
|
||||||
if (!orderId || !amount) {
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
|
||||||
<p className="text-sm text-gray-600">Memuat data pembayaran...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert title="Pembayaran Gagal">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>{error}</p>
|
|
||||||
<details className="text-xs">
|
|
||||||
<summary className="cursor-pointer">Detail Error</summary>
|
|
||||||
<pre className="mt-2 bg-gray-100 p-2 rounded overflow-auto">{JSON.stringify({ orderId, amount, customer }, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
|
||||||
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-red-600">Gagal memuat pembayaran</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="text-sm text-blue-600 underline"
|
|
||||||
>
|
|
||||||
Coba lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckoutPage() {
|
export function CheckoutPage() {
|
||||||
const apiBase = Env.API_BASE_URL
|
const apiBase = Env.API_BASE_URL
|
||||||
|
|
@ -215,11 +26,13 @@ export function CheckoutPage() {
|
||||||
}
|
}
|
||||||
const orderId = orderIdRef.current
|
const orderId = orderIdRef.current
|
||||||
const amount = 3500000
|
const amount = 3500000
|
||||||
|
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
|
||||||
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
||||||
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
const [locked, setLocked] = React.useState(false)
|
||||||
|
const [currentStep, setCurrentStep] = React.useState<1 | 2 | 3>(1)
|
||||||
const [isBusy, setIsBusy] = React.useState(false)
|
const [isBusy, setIsBusy] = React.useState(false)
|
||||||
const [modalClosed, setModalClosed] = React.useState(false)
|
const [selectedBank, setSelectedBank] = React.useState<'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata' | null>(null)
|
||||||
const [snapModalClosed, setSnapModalClosed] = React.useState(false)
|
const [selectedStore, setSelectedStore] = React.useState<'alfamart' | 'indomaret' | null>(null)
|
||||||
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
||||||
name: 'Demo User',
|
name: 'Demo User',
|
||||||
contact: 'demo@example.com',
|
contact: 'demo@example.com',
|
||||||
|
|
@ -259,8 +72,8 @@ export function CheckoutPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaymentSheet orderId={orderId} amount={amount} customerName={form.name} showStatusCTA={modalClosed}>
|
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
|
||||||
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm font-medium">Konfirmasi data checkout</div>
|
<div className="text-sm font-medium">Konfirmasi data checkout</div>
|
||||||
|
|
@ -307,8 +120,6 @@ export function CheckoutPage() {
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsBusy(true)
|
setIsBusy(true)
|
||||||
// Set default payment method (bank_transfer for demo)
|
|
||||||
setSelected('bank_transfer')
|
|
||||||
setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400)
|
setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -317,66 +128,134 @@ export function CheckoutPage() {
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||||||
Memuat…
|
Memuat…
|
||||||
</span>
|
</span>
|
||||||
) : 'Lanjut ke Pembayaran'}
|
) : 'Next'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-3" aria-live="polite">
|
<div className="space-y-3">
|
||||||
{(() => {
|
<PaymentMethodList
|
||||||
console.log('Rendering step 2 - AutoSnapPayment', { orderId, amount, currentStep })
|
selected={selected ?? undefined}
|
||||||
Logger.info('checkout.step2.render', { orderId, amount })
|
onSelect={(m) => {
|
||||||
// Fallback: Force show status button after 3 seconds if callback not called
|
setSelected(m)
|
||||||
setTimeout(() => {
|
if (m === 'bank_transfer' || m === 'cstore') {
|
||||||
if (!modalClosed) {
|
// Panel akan tampil di bawah item menggunakan renderPanel
|
||||||
console.log('⚠️ Fallback timer: Forcing status button to show')
|
} else if (m === 'cpay') {
|
||||||
setModalClosed(true)
|
// Redirect ke aplikasi cPay (CIFO Token) di Play Store
|
||||||
|
try {
|
||||||
|
Logger.info('cpay.redirect.start')
|
||||||
|
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
|
||||||
|
Logger.info('cpay.redirect.done')
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('cpay.redirect.error', { message: (e as Error)?.message })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
}
|
}
|
||||||
}, 3000)
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
<AutoSnapPayment
|
|
||||||
orderId={orderId}
|
|
||||||
amount={amount}
|
|
||||||
customer={{
|
|
||||||
name: form.name,
|
|
||||||
email: form.contact.includes('@') ? form.contact : undefined,
|
|
||||||
phone: !form.contact.includes('@') ? form.contact : undefined
|
|
||||||
}}
|
}}
|
||||||
onSuccess={(result) => {
|
disabled={locked}
|
||||||
Logger.info('checkout.payment.success', { orderId, result })
|
enabled={runtimeCfg?.paymentToggles
|
||||||
// Handle successful payment
|
? {
|
||||||
}}
|
bank_transfer: runtimeCfg.paymentToggles.bank_transfer,
|
||||||
onError={(error) => {
|
credit_card: runtimeCfg.paymentToggles.credit_card,
|
||||||
Logger.error('checkout.payment.error', { orderId, error })
|
gopay: runtimeCfg.paymentToggles.gopay,
|
||||||
setModalClosed(true) // Enable status button on error
|
cstore: runtimeCfg.paymentToggles.cstore,
|
||||||
// Handle payment error
|
cpay: !!runtimeCfg.paymentToggles.cpay,
|
||||||
}}
|
}
|
||||||
onModalClosed={() => {
|
: undefined}
|
||||||
console.log('🟢 onModalClosed callback fired - setting modalClosed to TRUE')
|
renderPanel={(m) => {
|
||||||
setModalClosed(true) // Enable status button when modal closed
|
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
||||||
setSnapModalClosed(true) // Show reload button
|
if (!methodEnabled[m]) {
|
||||||
}}
|
return (
|
||||||
/>
|
<div className="p-2">
|
||||||
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||||
{/* Tombol reload jika Snap modal ditutup tanpa pembayaran */}
|
|
||||||
{snapModalClosed && (
|
|
||||||
<div className="mt-3 sm:mt-4 space-y-2">
|
|
||||||
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
|
|
||||||
Ingin memilih metode pembayaran lain?
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
)
|
||||||
variant="secondary"
|
}
|
||||||
className="w-full text-xs sm:text-sm"
|
if (m === 'bank_transfer') {
|
||||||
onClick={() => window.location.reload()}
|
return (
|
||||||
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||||
|
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
|
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||||
|
<button
|
||||||
|
key={bk}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBank(bk)
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
}}
|
||||||
|
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||||
|
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
Pilih Metode Pembayaran
|
<BankLogo bank={bk} />
|
||||||
</Button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isBusy && (
|
||||||
|
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||||
|
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||||
|
Menyiapkan VA…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (m === 'cstore') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||||
|
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
|
{(['alfamart','indomaret'] as const).map((st) => (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStore(st)
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
}}
|
||||||
|
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||||
|
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<div className="space-y-3" aria-live="polite">
|
||||||
|
{selected === 'bank_transfer' && (
|
||||||
|
<BankTransferPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultBank={(selectedBank ?? 'bca')} />
|
||||||
|
)}
|
||||||
|
{selected === 'credit_card' && (
|
||||||
|
<CardPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
||||||
|
)}
|
||||||
|
{selected === 'gopay' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).gopay && (
|
||||||
|
<GoPayPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
||||||
|
)}
|
||||||
|
{selected === 'cstore' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).cstore && (
|
||||||
|
<CStorePanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultStore={selectedStore ?? undefined} />
|
||||||
|
)}
|
||||||
|
{selected && !(runtimeCfg?.paymentToggles ?? defaultEnabled())[selected] && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* No back/next controls on Step 3 as requested */}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</PaymentSheet>
|
</PaymentSheet>
|
||||||
|
|
||||||
|
|
@ -386,3 +265,12 @@ export function CheckoutPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
function defaultEnabled(): Record<PaymentMethod, boolean> {
|
||||||
|
return {
|
||||||
|
bank_transfer: Env.ENABLE_BANK_TRANSFER,
|
||||||
|
credit_card: Env.ENABLE_CREDIT_CARD,
|
||||||
|
gopay: Env.ENABLE_GOPAY,
|
||||||
|
cstore: Env.ENABLE_CSTORE,
|
||||||
|
cpay: Env.ENABLE_CPAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,54 +2,11 @@ import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center px-4 py-8">
|
<div className="space-y-3">
|
||||||
<div className="max-w-md w-full">
|
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
|
<p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
|
||||||
<div className="bg-gradient-to-r from-[#0c1f3f] to-[#1a3a5f] p-6 sm:p-8 text-center">
|
{/* <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> */}
|
||||||
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-white/10 flex items-center justify-center ring-4 ring-white/20">
|
<Link to="/" className="text-brand-600 underline">Kembali</Link>
|
||||||
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-white tracking-tight mb-2">404</h1>
|
|
||||||
<p className="text-sm sm:text-base font-medium text-white/90">Halaman Tidak Ditemukan</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 sm:p-8 text-center space-y-4 sm:space-y-5">
|
|
||||||
<p className="text-sm sm:text-base text-slate-700 leading-relaxed">
|
|
||||||
Maaf, halaman yang Anda cari tidak dapat ditemukan. Silakan periksa kembali URL yang Anda masukkan atau kembali ke halaman utama.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:gap-3 pt-2">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="inline-flex items-center justify-center gap-2 bg-[#0c1f3f] text-white px-6 py-3 rounded-lg font-semibold text-sm sm:text-base hover:bg-[#1a3a5f] transition-colors shadow-md hover:shadow-lg focus:outline-none focus:ring-3 focus:ring-[#0c1f3f] focus:ring-offset-2 !text-white hover:!text-white visited:!text-white active:!text-white"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span>Kembali ke Halaman Utama</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/checkout"
|
|
||||||
className="inline-flex items-center justify-center gap-2 bg-slate-100 text-slate-700 px-6 py-3 rounded-lg font-semibold text-sm sm:text-base hover:bg-slate-200 transition-colors border border-slate-300 focus:outline-none focus:ring-3 focus:ring-slate-300 focus:ring-offset-2 !text-slate-700 hover:!text-slate-800 visited:!text-slate-700"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
<span>Ke Halaman Checkout</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-xs sm:text-sm text-slate-600">
|
|
||||||
Butuh bantuan? Hubungi customer service kami
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,213 +1,37 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||||
|
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||||
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||||
|
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
|
||||||
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||||
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||||
|
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||||
|
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
|
||||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||||
import { Alert } from '../components/alert/Alert'
|
import { Alert } from '../components/alert/Alert'
|
||||||
import { Button } from '../components/ui/button'
|
import { Button } from '../components/ui/button'
|
||||||
import { getPaymentLinkPayload } from '../services/api'
|
import { getPaymentLinkPayload } from '../services/api'
|
||||||
import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock'
|
import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock'
|
||||||
import { usePaymentNavigation } from '../features/payments/lib/navigation'
|
import { usePaymentNavigation } from '../features/payments/lib/navigation'
|
||||||
import { Logger } from '../lib/logger'
|
|
||||||
import { loadSnapScript } from '../lib/snapLoader'
|
|
||||||
import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
type Method = PaymentMethod | null
|
type Method = PaymentMethod | null
|
||||||
|
|
||||||
interface AutoSnapPaymentProps {
|
|
||||||
orderId: string
|
|
||||||
amount: number
|
|
||||||
customer?: { name?: string; phone?: string; email?: string }
|
|
||||||
onSuccess?: (result: any) => void
|
|
||||||
onError?: (error: any) => void
|
|
||||||
onModalClosed?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError, onModalClosed }: AutoSnapPaymentProps) {
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState('')
|
|
||||||
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
|
||||||
|
|
||||||
console.log('[PayPage] AutoSnapPayment mounted:', { orderId, amount, customer })
|
|
||||||
Logger.info('paypage.autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
console.log('[PayPage] useEffect triggered', { orderId, amount, paymentTriggered })
|
|
||||||
if (!orderId || !amount || paymentTriggered) {
|
|
||||||
console.log('[PayPage] Early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerPayment = async () => {
|
|
||||||
console.log('[PayPage] triggerPayment called')
|
|
||||||
setPaymentTriggered(true)
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.init', { orderId, amount, customer })
|
|
||||||
|
|
||||||
// Load Snap.js first
|
|
||||||
await loadSnapScript()
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
|
|
||||||
|
|
||||||
// Create Snap transaction token
|
|
||||||
const token = await SnapTokenService.createToken({
|
|
||||||
transaction_details: {
|
|
||||||
order_id: orderId,
|
|
||||||
gross_amount: amount
|
|
||||||
},
|
|
||||||
customer_details: customer ? {
|
|
||||||
first_name: customer.name,
|
|
||||||
email: customer.email,
|
|
||||||
phone: customer.phone
|
|
||||||
} : undefined,
|
|
||||||
item_details: [{
|
|
||||||
id: orderId,
|
|
||||||
name: 'Payment',
|
|
||||||
price: amount,
|
|
||||||
quantity: 1
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
|
|
||||||
console.log('[PayPage] Token received:', token)
|
|
||||||
|
|
||||||
// Store customer name in localStorage for status page
|
|
||||||
if (customer?.name) {
|
|
||||||
try {
|
|
||||||
const customerCache = JSON.parse(localStorage.getItem('customerCache') || '{}')
|
|
||||||
customerCache[orderId] = { name: customer.name, timestamp: Date.now() }
|
|
||||||
localStorage.setItem('customerCache', JSON.stringify(customerCache))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to cache customer name:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.snap || typeof window.snap.pay !== 'function') {
|
|
||||||
throw new Error(`Snap.js not loaded: hasSnap=${!!window.snap}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[PayPage] Calling window.snap.pay')
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
window.snap.pay(token, {
|
|
||||||
onSuccess: (result: any) => {
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
|
|
||||||
onSuccess?.(result)
|
|
||||||
},
|
|
||||||
onPending: (result: any) => {
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
|
|
||||||
},
|
|
||||||
onError: (result: any) => {
|
|
||||||
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: result })
|
|
||||||
setError('Pembayaran gagal. Silakan coba lagi.')
|
|
||||||
setLoading(false)
|
|
||||||
onError?.(result)
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId })
|
|
||||||
setLoading(false)
|
|
||||||
onModalClosed?.() // Trigger callback when modal closed
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
|
|
||||||
console.error('[PayPage] Error:', e)
|
|
||||||
|
|
||||||
// Handle specific errors with user-friendly messages
|
|
||||||
const errorMessage = e.response?.data?.message || e.message || ''
|
|
||||||
const errorMessages = e.response?.data?.error_messages || []
|
|
||||||
|
|
||||||
// Check for "order_id already used" from Midtrans
|
|
||||||
const isOrderIdUsed = errorMessage.includes('sudah digunakan') ||
|
|
||||||
errorMessage.includes('already been taken') ||
|
|
||||||
errorMessage.includes('order_id has already been taken') ||
|
|
||||||
errorMessages.some((msg: string) => msg.includes('sudah digunakan'))
|
|
||||||
|
|
||||||
if (isOrderIdUsed) {
|
|
||||||
// Order already has payment, redirect to status page
|
|
||||||
Logger.paymentInfo('paypage.order.already_exists', { orderId })
|
|
||||||
console.log('[PayPage] Order already has payment, redirecting to status...')
|
|
||||||
|
|
||||||
// Show user-friendly message then redirect
|
|
||||||
setError('Pembayaran untuk pesanan ini sudah dibuat sebelumnya. Anda akan diarahkan ke halaman status pembayaran...')
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = `/payments/${orderId}/status`
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
// Generic error with user-friendly message
|
|
||||||
const userMessage = 'Maaf, terjadi kesalahan saat memuat pembayaran. Silakan coba lagi atau hubungi customer service.'
|
|
||||||
setError(userMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
onError?.(e)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[PayPage] Setting timeout')
|
|
||||||
const timer = setTimeout(triggerPayment, 500)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
|
|
||||||
|
|
||||||
// Don't render anything until we have valid data
|
|
||||||
if (!orderId || !amount) {
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
|
||||||
<p className="text-sm text-gray-600">Memuat data pembayaran...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert title="Pembayaran Gagal">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
|
||||||
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-red-600">Gagal memuat pembayaran</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="text-sm text-blue-600 underline"
|
|
||||||
>
|
|
||||||
Coba lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PayPage() {
|
export function PayPage() {
|
||||||
const { token } = useParams()
|
const { token } = useParams()
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
const [orderId, setOrderId] = useState<string>('')
|
const [orderId, setOrderId] = useState<string>('')
|
||||||
const [amount, setAmount] = useState<number>(0)
|
const [amount, setAmount] = useState<number>(0)
|
||||||
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
|
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
|
||||||
const [selectedMethod] = useState<Method>(null)
|
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||||
const [locked, setLocked] = useState<boolean>(false)
|
const [locked, setLocked] = useState<boolean>(false)
|
||||||
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
|
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
|
||||||
|
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
|
||||||
|
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||||
const [snapModalClosed, setSnapModalClosed] = useState(false)
|
const { data: runtimeCfg } = usePaymentConfig()
|
||||||
usePaymentConfig()
|
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
|
||||||
const currentStep = 2
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -219,7 +43,7 @@ export function PayPage() {
|
||||||
setOrderId(payload.order_id)
|
setOrderId(payload.order_id)
|
||||||
setAmount(payload.nominal)
|
setAmount(payload.nominal)
|
||||||
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
|
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
|
||||||
setCustomer(payload.customer)
|
setAllowedMethods(payload.allowed_methods)
|
||||||
setError(null)
|
setError(null)
|
||||||
if (isOrderLocked(payload.order_id)) setLocked(true)
|
if (isOrderLocked(payload.order_id)) setLocked(true)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -234,7 +58,26 @@ export function PayPage() {
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
const merchantName = useMemo(() => '', [])
|
const merchantName = useMemo(() => '', [])
|
||||||
|
|
||||||
const isExpired = expireAt ? Date.now() > expireAt : false
|
const isExpired = expireAt ? Date.now() > expireAt : false
|
||||||
|
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
|
||||||
|
const base = runtimeCfg?.paymentToggles
|
||||||
|
const allow = allowedMethods
|
||||||
|
const all: Record<PaymentMethod, boolean> = {
|
||||||
|
bank_transfer: base?.bank_transfer ?? true,
|
||||||
|
credit_card: base?.credit_card ?? true,
|
||||||
|
gopay: base?.gopay ?? true,
|
||||||
|
cstore: base?.cstore ?? true,
|
||||||
|
cpay: base?.cpay ?? false,
|
||||||
|
}
|
||||||
|
if (allow && Array.isArray(allow)) {
|
||||||
|
for (const k of (Object.keys(all) as PaymentMethod[])) {
|
||||||
|
if (k === 'cpay') continue
|
||||||
|
all[k] = allow.includes(k) && all[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}, [runtimeCfg, allowedMethods])
|
||||||
|
|
||||||
if (error || isExpired) {
|
if (error || isExpired) {
|
||||||
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
|
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
|
||||||
|
|
@ -244,6 +87,7 @@ export function PayPage() {
|
||||||
merchantName={merchantName}
|
merchantName={merchantName}
|
||||||
orderId={orderId || (token ?? '')}
|
orderId={orderId || (token ?? '')}
|
||||||
amount={amount}
|
amount={amount}
|
||||||
|
expireAt={expireAt}
|
||||||
showStatusCTA={false}
|
showStatusCTA={false}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 px-4 py-6">
|
<div className="space-y-4 px-4 py-6">
|
||||||
|
|
@ -273,7 +117,8 @@ export function PayPage() {
|
||||||
merchantName={merchantName}
|
merchantName={merchantName}
|
||||||
orderId={orderId}
|
orderId={orderId}
|
||||||
amount={amount}
|
amount={amount}
|
||||||
showStatusCTA={currentStep === 2}
|
expireAt={expireAt}
|
||||||
|
showStatusCTA={currentStep === 3}
|
||||||
>
|
>
|
||||||
<div className="space-y-4 px-4 py-6">
|
<div className="space-y-4 px-4 py-6">
|
||||||
{locked && currentStep === 2 && (
|
{locked && currentStep === 2 && (
|
||||||
|
|
@ -287,41 +132,129 @@ export function PayPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="space-y-3" aria-live="polite">
|
<div className="space-y-3">
|
||||||
<AutoSnapPayment
|
<PaymentMethodList
|
||||||
orderId={orderId}
|
selected={selectedMethod ?? undefined}
|
||||||
amount={amount}
|
onSelect={(m) => {
|
||||||
customer={customer}
|
setSelectedMethod(m as Method)
|
||||||
onSuccess={(result) => {
|
if (m === 'bank_transfer' || m === 'cstore') {
|
||||||
console.log('[PayPage] Payment success:', result)
|
void 0
|
||||||
lockOrder(orderId)
|
} else if (m === 'cpay') {
|
||||||
setLocked(true)
|
try {
|
||||||
nav.toStatus(orderId, selectedMethod || undefined)
|
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
|
||||||
|
} catch { void 0 }
|
||||||
|
} else {
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={(error) => {
|
disabled={locked}
|
||||||
console.error('[PayPage] Payment error:', error)
|
enabled={enabledMap}
|
||||||
setSnapModalClosed(true)
|
renderPanel={(m) => {
|
||||||
|
const enabled = enabledMap[m]
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<div className="p-2">
|
||||||
|
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (m === 'bank_transfer') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||||
|
<div className={`grid grid-cols-2 md:grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
|
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
|
||||||
|
<button
|
||||||
|
key={bk}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBank(bk)
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
}}
|
}}
|
||||||
onModalClosed={() => {
|
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||||
console.log('[PayPage] Snap modal closed')
|
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||||
setSnapModalClosed(true)
|
>
|
||||||
|
<BankLogo bank={bk} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isBusy && (
|
||||||
|
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||||
|
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||||
|
Menyiapkan VA…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (m === 'cstore') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||||
|
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||||
|
{/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
|
||||||
|
{(['alfamart'] as const).map((st) => (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStore(st)
|
||||||
|
setIsBusy(true)
|
||||||
|
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||||
|
}}
|
||||||
|
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
|
||||||
|
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tombol reload jika Snap modal ditutup tanpa pembayaran */}
|
{currentStep === 3 && (
|
||||||
{snapModalClosed && (
|
<div className="space-y-3" aria-live="polite">
|
||||||
<div className="mt-3 sm:mt-4 space-y-2">
|
{selectedMethod === 'bank_transfer' && (
|
||||||
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
|
<BankTransferPanel
|
||||||
Ingin memilih metode pembayaran lain?
|
locked={locked}
|
||||||
</div>
|
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||||
<Button
|
orderId={orderId}
|
||||||
variant="secondary"
|
amount={amount}
|
||||||
className="w-full text-xs sm:text-sm"
|
defaultBank={(selectedBank ?? 'bca')}
|
||||||
onClick={() => window.location.reload()}
|
/>
|
||||||
>
|
)}
|
||||||
Pilih Metode Pembayaran
|
{selectedMethod === 'credit_card' && (
|
||||||
</Button>
|
<CardPanel
|
||||||
</div>
|
locked={locked}
|
||||||
|
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMethod === 'gopay' && (
|
||||||
|
<GoPayPanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMethod === 'cstore' && (
|
||||||
|
<CStorePanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
defaultStore={selectedStore ?? undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,23 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { Alert } from '../components/alert/Alert'
|
||||||
|
import { usePaymentNavigation } from '../features/payments/lib/navigation'
|
||||||
import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus'
|
import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus'
|
||||||
|
import { Env } from '../lib/env'
|
||||||
import type { PaymentStatusResponse } from '../features/payments/lib/midtrans'
|
import type { PaymentStatusResponse } from '../features/payments/lib/midtrans'
|
||||||
import { Logger } from '../lib/logger'
|
import { Logger } from '../lib/logger'
|
||||||
import { BankLogo, LogoGoPay, LogoQRIS, LogoAlfamart, LogoIndomaret, CardLogosRow, type BankKey } from '../features/payments/components/PaymentLogos'
|
import { CountdownRedirect } from '../components/CountdownRedirect'
|
||||||
|
|
||||||
export function PaymentStatusPage() {
|
export function PaymentStatusPage() {
|
||||||
const { orderId } = useParams()
|
const { orderId } = useParams()
|
||||||
|
const nav = usePaymentNavigation()
|
||||||
const [search] = useSearchParams()
|
const [search] = useSearchParams()
|
||||||
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
||||||
const { data, isLoading, error } = usePaymentStatus(orderId)
|
const { data, isLoading, error } = usePaymentStatus(orderId)
|
||||||
const [copyNotification, setCopyNotification] = React.useState('')
|
|
||||||
|
|
||||||
// Check if error is "transaction not found" from Midtrans
|
|
||||||
const errorData = (error as any)?.response?.data
|
|
||||||
const isTransactionNotFound = error &&
|
|
||||||
(String(error).includes("doesn't exist") ||
|
|
||||||
String(error).includes("404") ||
|
|
||||||
String(error).includes("Transaction doesn't exist") ||
|
|
||||||
errorData?.message?.includes("doesn't exist") ||
|
|
||||||
errorData?.message?.includes("404"))
|
|
||||||
|
|
||||||
const statusText = data?.status ?? 'pending'
|
const statusText = data?.status ?? 'pending'
|
||||||
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
||||||
|
const isSuccess = statusText === 'settlement' || statusText === 'capture'
|
||||||
// Handle copy to clipboard with notification
|
|
||||||
const handleCopy = async (text: string, label: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
setCopyNotification(`${label} berhasil disalin`)
|
|
||||||
setTimeout(() => setCopyNotification(''), 3000)
|
|
||||||
} catch (err) {
|
|
||||||
setCopyNotification('Gagal menyalin')
|
|
||||||
setTimeout(() => setCopyNotification(''), 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeUrl(u?: string) {
|
function sanitizeUrl(u?: string) {
|
||||||
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
||||||
}
|
}
|
||||||
|
|
@ -74,15 +56,9 @@ export function PaymentStatusPage() {
|
||||||
const [qrSrc, setQrSrc] = React.useState<string>('')
|
const [qrSrc, setQrSrc] = React.useState<string>('')
|
||||||
React.useEffect(() => { setQrSrc(qrCandidates[0] || '') }, [statusText, method, orderId, data, qrCandidates])
|
React.useEffect(() => { setQrSrc(qrCandidates[0] || '') }, [statusText, method, orderId, data, qrCandidates])
|
||||||
|
|
||||||
// Get customer name from localStorage
|
function handleRedirect() {
|
||||||
const customerName = React.useMemo(() => {
|
nav.toHistory()
|
||||||
try {
|
|
||||||
const customerCache = JSON.parse(localStorage.getItem('customerCache') || '{}')
|
|
||||||
return customerCache[orderId || '']?.name
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
}, [orderId])
|
|
||||||
|
|
||||||
// Logs for debugging status lifecycle
|
// Logs for debugging status lifecycle
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -95,416 +71,105 @@ export function PaymentStatusPage() {
|
||||||
}
|
}
|
||||||
}, [isLoading, error, data])
|
}, [isLoading, error, data])
|
||||||
|
|
||||||
// User-friendly status messages
|
function statusBadgeClass(s: PaymentStatusResponse['status']) {
|
||||||
function getStatusMessage(s: PaymentStatusResponse['status']) {
|
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return { title: 'Menunggu Pembayaran', desc: 'Silakan selesaikan pembayaran Anda', icon: '⏳', color: 'yellow' }
|
return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800'
|
||||||
case 'settlement':
|
case 'settlement':
|
||||||
case 'capture':
|
case 'capture':
|
||||||
return { title: 'Pembayaran Berhasil', desc: 'Terima kasih! Pembayaran Anda telah dikonfirmasi', icon: '✅', color: 'green' }
|
return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800'
|
||||||
case 'deny':
|
case 'deny':
|
||||||
return { title: 'Pembayaran Ditolak', desc: 'Maaf, pembayaran Anda ditolak. Silakan coba metode lain', icon: '❌', color: 'red' }
|
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
return { title: 'Pembayaran Dibatalkan', desc: 'Transaksi telah dibatalkan', icon: '🚫', color: 'red' }
|
|
||||||
case 'expire':
|
case 'expire':
|
||||||
return { title: 'Pembayaran Kedaluwarsa', desc: 'Waktu pembayaran habis. Silakan buat transaksi baru', icon: '⏰', color: 'red' }
|
|
||||||
case 'refund':
|
case 'refund':
|
||||||
return { title: 'Pembayaran Dikembalikan', desc: 'Dana telah dikembalikan ke rekening Anda', icon: '↩️', color: 'blue' }
|
case 'chargeback':
|
||||||
|
return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800'
|
||||||
default:
|
default:
|
||||||
return { title: 'Status Tidak Diketahui', desc: 'Hubungi customer service untuk bantuan', icon: 'ℹ️', color: 'gray' }
|
return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMsg = getStatusMessage(statusText)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-4 sm:py-6 md:py-8">
|
<div className="space-y-4">
|
||||||
{/* Copy Notification Toast */}
|
<h1 className="text-xl font-semibold">Status Pembayaran</h1>
|
||||||
{copyNotification && (
|
<div className="card p-4">
|
||||||
<div className="fixed top-2 right-2 sm:top-4 sm:right-4 left-2 sm:left-auto z-50 animate-in slide-in-from-top-2 duration-300">
|
<div className="text-sm">Order ID: {orderId}</div>
|
||||||
<div className="bg-[#0c1f3f] text-white px-4 py-2.5 sm:px-6 sm:py-3 rounded-lg shadow-lg flex items-center gap-2 sm:gap-3">
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-emerald-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium text-sm sm:text-base">{copyNotification}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto px-3 sm:px-4 md:px-6">
|
|
||||||
{/* Header Card */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden mb-6">
|
|
||||||
<div className={`p-4 sm:p-6 md:p-8 text-center ${
|
|
||||||
statusMsg.color === 'green' ? 'bg-gradient-to-br from-emerald-50 to-teal-50 border-b border-emerald-100' :
|
|
||||||
statusMsg.color === 'yellow' ? 'bg-gradient-to-br from-amber-50 to-yellow-50 border-b border-amber-100' :
|
|
||||||
statusMsg.color === 'red' ? 'bg-gradient-to-br from-rose-50 to-red-50 border-b border-rose-100' :
|
|
||||||
statusMsg.color === 'blue' ? 'bg-gradient-to-br from-blue-50 to-indigo-50 border-b border-blue-100' :
|
|
||||||
'bg-gradient-to-br from-slate-50 to-gray-50 border-b border-slate-100'
|
|
||||||
}`}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 animate-spin rounded-full border-4 border-slate-200 border-t-[#0c1f3f]"></div>
|
|
||||||
<div className="text-xl sm:text-2xl font-bold text-slate-800 tracking-tight">Memuat status...</div>
|
|
||||||
<div className="text-xs sm:text-sm font-medium text-slate-600 mt-1.5 sm:mt-2">Mohon tunggu sebentar</div>
|
|
||||||
</>
|
|
||||||
) : isTransactionNotFound ? (
|
|
||||||
<>
|
|
||||||
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-[#0c1f3f]/10 flex items-center justify-center ring-4 ring-[#0c1f3f]/5">
|
|
||||||
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl sm:text-2xl font-bold text-[#0c1f3f] tracking-tight">Transaksi Belum Dibuat</div>
|
|
||||||
<div className="text-xs sm:text-sm font-medium text-slate-700 mt-1.5 sm:mt-2 max-w-md mx-auto px-2">Silakan kembali ke halaman checkout untuk membuat pembayaran</div>
|
|
||||||
</>
|
|
||||||
) : error ? (
|
|
||||||
<>
|
|
||||||
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-rose-100 flex items-center justify-center ring-4 ring-rose-50">
|
|
||||||
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-rose-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl sm:text-2xl font-bold text-rose-700 tracking-tight">Gagal Memuat Status</div>
|
|
||||||
<div className="text-xs sm:text-sm font-medium text-rose-600 mt-1.5 sm:mt-2">Terjadi kesalahan. Silakan refresh halaman</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={`h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full flex items-center justify-center ring-4 ${
|
|
||||||
statusMsg.color === 'green' ? 'bg-emerald-100 ring-emerald-50' :
|
|
||||||
statusMsg.color === 'yellow' ? 'bg-amber-100 ring-amber-50' :
|
|
||||||
statusMsg.color === 'red' ? 'bg-rose-100 ring-rose-50' :
|
|
||||||
statusMsg.color === 'blue' ? 'bg-[#0c1f3f]/10 ring-[#0c1f3f]/5' :
|
|
||||||
'bg-slate-100 ring-slate-50'
|
|
||||||
}`}>
|
|
||||||
{statusMsg.color === 'green' ? (
|
|
||||||
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
) : statusMsg.color === 'yellow' ? (
|
|
||||||
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
) : statusMsg.color === 'red' ? (
|
|
||||||
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-rose-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
) : statusMsg.color === 'blue' ? (
|
|
||||||
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xl sm:text-2xl font-bold text-slate-900 mb-1.5 sm:mb-2 tracking-tight">{statusMsg.title}</div>
|
|
||||||
<div className="text-xs sm:text-sm font-medium text-slate-600 max-w-md mx-auto px-2">{statusMsg.desc}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Info */}
|
|
||||||
<div className="p-4 sm:p-6 bg-white">
|
|
||||||
<div className="flex items-center justify-between mb-4 sm:mb-5 pb-4 sm:pb-5 border-b border-slate-200">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">ID Pesanan</div>
|
|
||||||
<div className="font-mono text-xs sm:text-sm font-bold text-slate-900 break-all">{orderId}</div>
|
|
||||||
</div>
|
|
||||||
{!isLoading && !isFinal && !isTransactionNotFound && (
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-medium text-[#0c1f3f]">
|
|
||||||
<div className="h-1.5 w-1.5 sm:h-2 sm:w-2 bg-[#0c1f3f] rounded-full animate-pulse flex-shrink-0"></div>
|
|
||||||
<span className="hidden sm:inline">Memperbarui otomatis</span>
|
|
||||||
<span className="sm:hidden">Auto update</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{customerName ? (
|
|
||||||
<div className="mb-4 sm:mb-5">
|
|
||||||
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Nama Pelanggan</div>
|
|
||||||
<div className="text-xs sm:text-sm font-bold text-slate-900">{customerName}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{method || data?.method ? (
|
{method || data?.method ? (
|
||||||
<div className="mb-4 sm:mb-5">
|
<div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
|
||||||
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Metode Pembayaran</div>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
{(data?.method === 'bank_transfer' || method === 'bank_transfer') && data?.bank && (
|
|
||||||
<BankLogo bank={data.bank.toLowerCase() as BankKey} size="sm" />
|
|
||||||
)}
|
|
||||||
{(data?.method === 'gopay' || method === 'gopay') && (
|
|
||||||
<LogoGoPay size="sm" />
|
|
||||||
)}
|
|
||||||
{(data?.method === 'qris' || method === 'qris') && (
|
|
||||||
<LogoQRIS size="sm" />
|
|
||||||
)}
|
|
||||||
{(data?.method === 'cstore' || method === 'cstore') && (
|
|
||||||
data?.store?.toLowerCase() === 'alfamart' ? <LogoAlfamart size="sm" /> :
|
|
||||||
data?.store?.toLowerCase() === 'indomaret' ? <LogoIndomaret size="sm" /> : null
|
|
||||||
)}
|
|
||||||
{(data?.method === 'credit_card' || method === 'credit_card') && (
|
|
||||||
<CardLogosRow size="xs" compact />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
<div className="mt-2">Status: {isLoading ? (
|
||||||
</div>
|
<span className="font-medium">memuat…</span>
|
||||||
|
) : error ? (
|
||||||
{/* Payment Instructions - Only show for pending status */}
|
<span className="font-medium text-brand-600">gagal memuat</span>
|
||||||
{!isLoading && !error && data && statusText === 'pending' && (
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden mb-6">
|
|
||||||
<div className="bg-gradient-to-r from-[#0c1f3f] to-[#1a3a5f] border-b border-[#0c1f3f] px-4 sm:px-6 py-3 sm:py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-white flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-xs sm:text-sm font-bold text-white tracking-wide">Cara Pembayaran</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
|
|
||||||
{/* Bank Transfer OR Mandiri E-Channel */}
|
|
||||||
{(!method || method === 'bank_transfer' || data.method === 'bank_transfer' || data.method === 'echannel') && (data.vaNumber || (data.billKey && data.billerCode)) ? (
|
|
||||||
<>
|
|
||||||
{data.vaNumber ? (
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
|
||||||
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 bg-white rounded border border-gray-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
|
||||||
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.vaNumber}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(data.vaNumber || '', 'Nomor VA')}
|
|
||||||
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-[#0c1f3f] text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-[#1a3a5f] transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">Salin</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{data.bank ? (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-[10px] sm:text-xs text-gray-500 mb-1.5">Bank</div>
|
|
||||||
<BankLogo bank={data.bank.toLowerCase() as BankKey} size="sm" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Mandiri E-Channel specific */}
|
|
||||||
{data.billKey && data.billerCode ? (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4">
|
|
||||||
<div className="flex items-center gap-2 font-semibold text-yellow-900 mb-2 sm:mb-3 text-sm sm:text-base">
|
|
||||||
<svg className="h-5 w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
||||||
</svg>
|
|
||||||
<span>Mandiri E-Channel</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] sm:text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Perusahaan (Biller Code)</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 bg-white rounded border border-yellow-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
|
||||||
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.billerCode}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(data.billerCode || '', 'Kode Perusahaan')}
|
|
||||||
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-amber-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-amber-700 transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">Salin</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] sm:text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Bayar (Bill Key)</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 bg-white rounded border border-yellow-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
|
||||||
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.billKey}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(data.billKey || '', 'Kode Bayar')}
|
|
||||||
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-amber-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-amber-700 transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">Salin</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
|
||||||
{data.billKey && data.billerCode ? (
|
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
|
||||||
<li>Buka aplikasi Livin' by Mandiri atau ATM Mandiri</li>
|
|
||||||
<li>Pilih menu <strong>Bayar</strong> / <strong>Multi Payment</strong></li>
|
|
||||||
<li>Pilih penyedia jasa: <strong>Midtrans</strong> (atau cari dengan Biller Code)</li>
|
|
||||||
<li>Masukkan Kode Perusahaan: <strong>{data.billerCode}</strong></li>
|
|
||||||
<li>Masukkan Kode Bayar: <strong>{data.billKey}</strong></li>
|
|
||||||
<li>Periksa detail tagihan dan konfirmasi pembayaran</li>
|
|
||||||
<li>Simpan bukti transaksi</li>
|
|
||||||
</ol>
|
|
||||||
) : (
|
) : (
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
<span className={statusBadgeClass(statusText)}>{statusText}</span>
|
||||||
<li>Buka aplikasi mobile banking atau ATM</li>
|
)}</div>
|
||||||
<li>Pilih menu Transfer / Bayar</li>
|
<div className="mt-1 text-xs text-gray-600">
|
||||||
<li>Masukkan nomor Virtual Account di atas</li>
|
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
|
||||||
<li>Konfirmasi pembayaran</li>
|
</div>
|
||||||
<li>Simpan bukti transaksi</li>
|
{isSuccess ? (
|
||||||
</ol>
|
<div className="mt-4">
|
||||||
)}
|
<div className="text-lg font-semibold">✅ Pembayaran Berhasil!</div>
|
||||||
|
<CountdownRedirect seconds={5} destination="dashboard" onComplete={handleRedirect} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* Method-specific details */}
|
||||||
|
{!isLoading && !error && data ? (
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
{(!method || method === 'bank_transfer') && data.vaNumber ? (
|
||||||
|
<div className="rounded border border-gray-200 p-2">
|
||||||
|
<div className="font-medium">Virtual Account</div>
|
||||||
|
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
|
||||||
|
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
|
||||||
|
{data.billKey && data.billerCode ? (
|
||||||
|
<div className="mt-1 text-xs text-gray-600">Mandiri E-Channel — Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
||||||
<>
|
<div className="rounded border border-gray-200 p-2">
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="font-medium">Convenience Store</div>
|
||||||
{data.store ? (
|
{data.store ? <div>Store: {data.store}</div> : null}
|
||||||
<div className="mb-3">
|
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1.5">Toko</div>
|
|
||||||
{data.store.toLowerCase() === 'alfamart' && <LogoAlfamart size="sm" />}
|
|
||||||
{data.store.toLowerCase() === 'indomaret' && <LogoIndomaret size="sm" />}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{data.paymentCode ? (
|
{(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
||||||
<>
|
<div className="rounded border border-gray-200 p-2">
|
||||||
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Kode Pembayaran</div>
|
<div className="font-medium">QR / Deeplink</div>
|
||||||
<div className="flex items-center justify-between gap-2 bg-white rounded border border-gray-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
|
||||||
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.paymentCode}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(data.paymentCode || '', 'Kode Pembayaran')}
|
|
||||||
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-[#0c1f3f] text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-[#1a3a5f] transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">Salin</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
|
||||||
<li>Kunjungi toko {data.store || 'convenience store'} terdekat</li>
|
|
||||||
<li>Berikan kode pembayaran kepada kasir</li>
|
|
||||||
<li>Lakukan pembayaran tunai</li>
|
|
||||||
<li>Simpan bukti pembayaran</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{(!method || method === 'gopay' || method === 'qris' || data.method === 'qris' || data.method === 'gopay') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
|
||||||
<>
|
|
||||||
{qrSrc ? (
|
{qrSrc ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
<div className="mt-2 grid place-items-center">
|
||||||
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide text-center mb-3">Scan QR Code</div>
|
<img src={qrSrc} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto rounded border border-black/10" onError={(e) => {
|
||||||
<div className="bg-white rounded-lg p-3 sm:p-4 inline-block mx-auto">
|
|
||||||
<img src={qrSrc} alt="QR Code Pembayaran" className="w-48 h-48 sm:w-64 sm:h-64 mx-auto" onError={(e) => {
|
|
||||||
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
||||||
if (next) setQrSrc(next)
|
if (next) setQrSrc(next)
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : null}
|
<div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
|
||||||
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
)}
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
{(Array.isArray(data?.actions) ? data!.actions : []).map((a, i) => (
|
||||||
<li>Buka aplikasi {method === 'gopay' || data.method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking yang mendukung QRIS'}</li>
|
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
|
||||||
<li>Pilih menu Scan QR atau QRIS</li>
|
{a.name || a.method || 'Buka'}
|
||||||
<li>Arahkan kamera ke QR code di atas</li>
|
|
||||||
<li>Konfirmasi pembayaran</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
{(Array.isArray(data?.actions) && data.actions.length > 0) ? (
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
|
||||||
{data.actions.map((a, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={a.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 sm:gap-2 bg-blue-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-blue-700 text-xs sm:text-sm font-medium"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
{a.name || 'Buka Aplikasi'}
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (data.method === 'qris' || data.method === 'gopay') ? (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
|
|
||||||
<div className="flex items-start gap-2 sm:gap-3">
|
|
||||||
<svg className="h-6 w-6 sm:h-7 sm:w-7 text-blue-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold text-blue-900 mb-2 text-sm sm:text-base">QR Code Pembayaran</div>
|
|
||||||
<div className="text-xs sm:text-sm text-blue-700 space-y-2">
|
|
||||||
<p>QR code untuk pembayaran ini ditampilkan di jendela pembayaran Snap.</p>
|
|
||||||
<p>Jika Anda menutup jendela tersebut, silakan:</p>
|
|
||||||
<ol className="list-decimal list-inside ml-2 sm:ml-3 mt-2 space-y-1">
|
|
||||||
<li>Kembali ke halaman checkout</li>
|
|
||||||
<li>Buat pembayaran baru dengan order ID yang sama</li>
|
|
||||||
<li>QR code akan muncul kembali di jendela Snap</li>
|
|
||||||
</ol>
|
|
||||||
<p className="mt-3 text-blue-600 italic">Atau tunggu hingga pembayaran kedaluwarsa dan buat transaksi baru.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
<div className="rounded border border-gray-200 p-2">
|
||||||
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
|
<div className="font-medium">Kartu</div>
|
||||||
<div className="font-mono text-base sm:text-lg font-bold text-gray-900">{data.maskedCard}</div>
|
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
|
||||||
<div className="text-xs sm:text-sm text-gray-600 mt-3">
|
|
||||||
Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* Aksi bawah dihilangkan sesuai permintaan */}
|
||||||
</div>
|
</div>
|
||||||
|
{!Env.API_BASE_URL && (
|
||||||
|
<Alert title="API Base belum diatur">
|
||||||
|
Tambahkan <code>VITE_API_BASE_URL</code> di env agar status memuat dari backend; saat ini menggunakan stub.
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{/* Help Section */}
|
|
||||||
{!isLoading && !error && (
|
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-4 sm:p-6">
|
|
||||||
<div className="flex items-start gap-2 sm:gap-3">
|
|
||||||
<div className="flex-shrink-0 h-8 w-8 sm:h-10 sm:w-10 bg-[#0c1f3f]/10 rounded-full flex items-center justify-center">
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-bold text-slate-900 mb-2 sm:mb-3 text-sm sm:text-base">Butuh Bantuan?</p>
|
|
||||||
<ul className="space-y-2 sm:space-y-2.5 text-xs sm:text-sm font-medium text-slate-700">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
|
||||||
<span>Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
|
||||||
<span>Simpan nomor pesanan untuk referensi</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
|
||||||
<span>Halaman ini akan diperbarui otomatis saat status berubah</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -53,15 +53,7 @@ api.interceptors.response.use(
|
||||||
const url = error.config?.url || ''
|
const url = error.config?.url || ''
|
||||||
const status = error.response?.status
|
const status = error.response?.status
|
||||||
const fullUrl = `${baseURL}${url}`
|
const fullUrl = `${baseURL}${url}`
|
||||||
const responseData = error.response?.data
|
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
|
||||||
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message, responseData })
|
|
||||||
console.error('API Error:', {
|
|
||||||
fullUrl,
|
|
||||||
status,
|
|
||||||
message: error.message,
|
|
||||||
responseData,
|
|
||||||
config: error.config
|
|
||||||
})
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// Midtrans Snap.js type definitions
|
|
||||||
interface SnapPaymentOptions {
|
|
||||||
onSuccess?: (result: any) => void
|
|
||||||
onPending?: (result: any) => void
|
|
||||||
onError?: (result: any) => void
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Snap {
|
|
||||||
pay: (token: string, options?: SnapPaymentOptions) => void
|
|
||||||
hide: () => void
|
|
||||||
show: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
|
||||||
snap?: Snap
|
|
||||||
}
|
|
||||||
|
|
@ -5,9 +5,6 @@ export default {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
|
||||||
},
|
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: '#f1f5fb',
|
50: '#f1f5fb',
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# Tests
|
|
||||||
|
|
||||||
Folder ini berisi file-file testing untuk Simaya Midtrans Payment Integration.
|
|
||||||
|
|
||||||
## File Testing
|
|
||||||
|
|
||||||
### 1. test-create-payment-link.cjs
|
|
||||||
Test untuk membuat payment link menggunakan endpoint `/createtransaksi`.
|
|
||||||
|
|
||||||
**Cara menjalankan:**
|
|
||||||
```bash
|
|
||||||
node tests/test-create-payment-link.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirement:**
|
|
||||||
- Server harus running di `http://localhost:8000`
|
|
||||||
- File `temp/tmp-createtransaksi.json` harus ada dengan payload yang valid
|
|
||||||
|
|
||||||
### 2. test-frontend-payload.cjs
|
|
||||||
Test untuk mensimulasikan payload dari frontend (CheckoutPage.tsx AutoSnapPayment).
|
|
||||||
|
|
||||||
**Cara menjalankan:**
|
|
||||||
```bash
|
|
||||||
node tests/test-frontend-payload.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirement:**
|
|
||||||
- Server harus running di `http://localhost:8000`
|
|
||||||
|
|
||||||
### 3. test-snap-token.cjs
|
|
||||||
Test untuk mendapatkan Snap token dari Midtrans.
|
|
||||||
|
|
||||||
**Cara menjalankan:**
|
|
||||||
```bash
|
|
||||||
node tests/test-snap-token.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
**Requirement:**
|
|
||||||
- Server harus running di `http://localhost:8000`
|
|
||||||
|
|
||||||
### 4. coreApiSimpleExample.js
|
|
||||||
Contoh sederhana penggunaan Midtrans Core API.
|
|
||||||
|
|
||||||
**Cara menjalankan:**
|
|
||||||
```bash
|
|
||||||
node tests/coreApiSimpleExample.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Catatan
|
|
||||||
|
|
||||||
- Semua test file menggunakan `axios` untuk HTTP requests
|
|
||||||
- Pastikan server backend sudah running sebelum menjalankan test
|
|
||||||
- File temporary/payload test ada di folder `temp/`
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
const axios = require('axios');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
async function createPaymentLink() {
|
|
||||||
// Read file and remove BOM if present
|
|
||||||
let jsonContent = fs.readFileSync('temp/tmp-createtransaksi.json', 'utf8');
|
|
||||||
// Remove BOM
|
|
||||||
if (jsonContent.charCodeAt(0) === 0xFEFF) {
|
|
||||||
jsonContent = jsonContent.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.parse(jsonContent);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Creating payment link...');
|
|
||||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
|
||||||
|
|
||||||
const response = await axios.post('http://localhost:8000/createtransaksi', payload, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-API-KEY': 'dev-key'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n✓ Success!');
|
|
||||||
console.log('Response:', JSON.stringify(response.data, null, 2));
|
|
||||||
console.log('\n🔗 Payment URL:', response.data.data.url);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('✗ Error:', error.response?.status, error.response?.data);
|
|
||||||
console.log('Full error:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createPaymentLink();
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function testFrontendPayload() {
|
|
||||||
// Simulate the exact payload sent from CheckoutPage.tsx AutoSnapPayment
|
|
||||||
const payload = {
|
|
||||||
transaction_details: {
|
|
||||||
order_id: 'order-1733280000000-12345', // example orderId
|
|
||||||
gross_amount: 3500000
|
|
||||||
},
|
|
||||||
customer_details: {
|
|
||||||
first_name: 'Demo User',
|
|
||||||
email: 'demo@example.com',
|
|
||||||
phone: undefined // as sent from frontend when contact is email
|
|
||||||
},
|
|
||||||
item_details: [{
|
|
||||||
id: 'order-1733280000000-12345',
|
|
||||||
name: 'Payment',
|
|
||||||
price: 3500000,
|
|
||||||
quantity: 1
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Testing frontend-like payload...');
|
|
||||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
|
||||||
|
|
||||||
const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
|
|
||||||
console.log('Success:', response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Error:', error.response?.status, error.response?.data);
|
|
||||||
console.log('Full error:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testFrontendPayload();
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function testSnapToken() {
|
|
||||||
const payload = {
|
|
||||||
transaction_details: {
|
|
||||||
order_id: 'test-order-123',
|
|
||||||
gross_amount: 100000
|
|
||||||
},
|
|
||||||
customer_details: {
|
|
||||||
first_name: 'Test User',
|
|
||||||
email: 'test@example.com',
|
|
||||||
phone: '08123456789'
|
|
||||||
},
|
|
||||||
item_details: [{
|
|
||||||
id: 'test-order-123',
|
|
||||||
name: 'Test Payment',
|
|
||||||
price: 100000,
|
|
||||||
quantity: 1
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Testing Snap token creation...');
|
|
||||||
console.log('Payload:', JSON.stringify(payload, null, 2));
|
|
||||||
|
|
||||||
const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
|
|
||||||
console.log('Success:', response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Error:', error.response?.status, error.response?.data);
|
|
||||||
console.log('Full error:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testSnapToken();
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"mercant_id": "REFNO-001",
|
||||||
|
"timestamp": 1731300000000,
|
||||||
|
"deskripsi": "Bayar Internet",
|
||||||
|
"nominal": 200000,
|
||||||
|
"nama": "Demo User",
|
||||||
|
"no_telepon": "081234567890",
|
||||||
|
"email": "demo@example.com",
|
||||||
|
"item": [
|
||||||
|
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a
|
||||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667
|
||||||
Loading…
Reference in New Issue