Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
H
haiyen-payment
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Giang Tran
haiyen-payment
Commits
fb6e381b
Commit
fb6e381b
authored
Sep 05, 2026
by
tdgiang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
update code
parent
c40b1f40
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
941 additions
and
575 deletions
+941
-575
admin.server.controller.js
app/controllers/admin.server.controller.js
+4
-2
transactions-list.server.view.html
app/views/admin/transactions-list.server.view.html
+63
-2
transactions-new.server.view.html
app/views/admin/transactions-new.server.view.html
+874
-571
No files found.
app/controllers/admin.server.controller.js
View file @
fb6e381b
...
...
@@ -26,7 +26,7 @@ exports.createTransaction = function (req, res) {
return
res
.
status
(
400
).
json
({
code
:
"99"
,
data
:
"MISSING_FIELDS"
});
}
if
(
!
/^
[
0-9
]{
9,
12}
$/
.
test
(
String
(
customerIdCard
).
trim
()))
{
if
(
!
/^
[
0-9
]{12}
$/
.
test
(
String
(
customerIdCard
).
trim
()))
{
return
res
.
status
(
400
).
json
({
code
:
"99"
,
data
:
"INVALID_ID_CARD"
});
}
...
...
@@ -443,6 +443,7 @@ exports.listTransactions = function (req, res) {
resultMsg
:
tx
.
resultMsg
,
createdByUsername
:
tx
.
createdByUsername
,
createdAt
:
moment
(
tx
.
createdAt
).
format
(
"DD/MM/YYYY HH:mm"
),
updatedAt
:
moment
(
tx
.
updatedAt
).
format
(
"DD/MM/YYYY HH:mm"
),
paymentUrl
:
config
.
epay
.
req_domain
+
"/admin/pay/"
+
tx
.
merTrxId
,
};
});
...
...
@@ -513,13 +514,14 @@ exports.exportTransactions = function (req, res) {
}
var
header
=
[
"Mã giao dịch"
,
"Thời gian"
,
"Người tạo"
,
"Khách hàng"
,
"SĐT"
,
"Mã giao dịch"
,
"Thời gian"
,
"
Thời gian giao dịch"
,
"
Người tạo"
,
"Khách hàng"
,
"SĐT"
,
"CMND/CCCD"
,
"Số tiền"
,
"Trạng thái"
,
"Ghi chú"
,
];
var
rows
=
list
.
map
(
function
(
tx
)
{
return
[
tx
.
merTrxId
,
moment
(
tx
.
createdAt
).
format
(
"DD/MM/YYYY HH:mm"
),
moment
(
tx
.
updatedAt
).
format
(
"DD/MM/YYYY HH:mm"
),
tx
.
createdByUsername
||
""
,
tx
.
customerName
,
tx
.
customerPhone
,
...
...
app/views/admin/transactions-list.server.view.html
View file @
fb6e381b
...
...
@@ -200,6 +200,32 @@
.btn-ghost
:hover
{
border-color
:
var
(
--color-primary
);
background
:
var
(
--color-primary-soft
);
}
.btn-ghost
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
.export-dialog
{
border
:
none
;
border-radius
:
var
(
--radius
);
box-shadow
:
var
(
--shadow-card
);
padding
:
24px
;
width
:
100%
;
max-width
:
340px
;
}
.export-dialog
::backdrop
{
background
:
rgba
(
15
,
23
,
42
,
0.45
);
}
.export-dialog
h2
{
font-size
:
17px
;
margin
:
0
0
4px
;
}
.export-dialog
.subtitle
{
font-size
:
13px
;
color
:
var
(
--color-text-muted
);
margin
:
0
0
20px
;
}
.export-dialog
.field
{
margin-bottom
:
16px
;
}
.export-dialog
.field
label
{
display
:
block
;
font-size
:
13px
;
font-weight
:
600
;
margin-bottom
:
6px
;
}
.export-dialog
.field
input
[
type
=
"date"
]
{
width
:
100%
;
height
:
40px
;
padding
:
0
12px
;
font-size
:
14px
;
font-family
:
inherit
;
border
:
1px
solid
var
(
--color-border
);
border-radius
:
var
(
--radius-sm
);
color
:
var
(
--color-text
);
background
:
var
(
--color-surface
);
}
.export-dialog
.dialog-actions
{
display
:
flex
;
justify-content
:
flex-end
;
gap
:
10px
;
margin-top
:
8px
;
}
/* Stat strip */
.stats
{
display
:
grid
;
...
...
@@ -489,10 +515,10 @@
<p
class=
"subtitle"
>
Danh sách giao dịch đã tạo và trạng thái thanh toán
</p>
</div>
<div
class=
"header-actions"
>
<
a
class=
"btn-ghost"
href=
"/admin/transactions/export?staff={{selectedStaff}}&status={{selectedStatus}}&fromDate={{selectedFromDate}}&toDate={{selectedToDate}}
"
>
<
button
type=
"button"
class=
"btn-ghost"
id=
"openExportDialogBtn
"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><path
d=
"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/><polyline
points=
"7 10 12 15 17 10"
/><line
x1=
"12"
y1=
"15"
x2=
"12"
y2=
"3"
/></svg>
Xuất Excel
</
a
>
</
button
>
<a
class=
"btn-primary"
href=
"/admin/transactions/new"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><line
x1=
"12"
y1=
"5"
x2=
"12"
y2=
"19"
/><line
x1=
"5"
y1=
"12"
x2=
"19"
y2=
"12"
/></svg>
Thêm mới giao dịch
...
...
@@ -500,6 +526,27 @@
</div>
</div>
<dialog
id=
"exportDialog"
class=
"export-dialog"
>
<form
method=
"GET"
action=
"/admin/transactions/export"
>
<h2>
Xuất Excel
</h2>
<p
class=
"subtitle"
>
Chọn khoảng thời gian giao dịch cần xuất, độc lập với bộ lọc đang xem
</p>
<input
type=
"hidden"
name=
"staff"
value=
"{{selectedStaff}}"
>
<input
type=
"hidden"
name=
"status"
value=
"{{selectedStatus}}"
>
<div
class=
"field"
>
<label
for=
"exportFromDate"
>
Từ ngày
</label>
<input
type=
"date"
id=
"exportFromDate"
name=
"fromDate"
value=
"{{selectedFromDate}}"
>
</div>
<div
class=
"field"
>
<label
for=
"exportToDate"
>
Đến ngày
</label>
<input
type=
"date"
id=
"exportToDate"
name=
"toDate"
value=
"{{selectedToDate}}"
>
</div>
<div
class=
"dialog-actions"
>
<button
type=
"button"
class=
"filter-clear"
id=
"cancelExportDialogBtn"
>
Hủy
</button>
<button
type=
"submit"
class=
"btn-primary"
>
Xuất Excel
</button>
</div>
</form>
</dialog>
<div
class=
"stats"
id=
"statsStrip"
>
<div
class=
"stat-card is-revenue"
>
<p
class=
"stat-label"
>
Sản lượng giao dịch hôm nay
</p>
...
...
@@ -560,6 +607,7 @@
<tr>
<th>
Hành động
</th>
<th>
Thời gian
</th>
<th>
Thời gian giao dịch
</th>
<th>
Mã giao dịch
</th>
<th>
Người tạo
</th>
<th>
Khách hàng
</th>
...
...
@@ -580,6 +628,7 @@
</button>
</td>
<td
class=
"cell-muted"
>
{{tx.createdAt}}
</td>
<td
class=
"cell-muted"
>
{{tx.updatedAt}}
</td>
<td
class=
"cell-mono"
>
{{tx.merTrxIdShort}}
</td>
<td
class=
"cell-muted"
>
{{tx.createdByUsername}}
</td>
<td>
{{tx.customerName}}
</td>
...
...
@@ -616,6 +665,18 @@
<script>
(
function
()
{
var
exportDialog
=
document
.
getElementById
(
'exportDialog'
);
var
openExportBtn
=
document
.
getElementById
(
'openExportDialogBtn'
);
var
cancelExportBtn
=
document
.
getElementById
(
'cancelExportDialogBtn'
);
if
(
exportDialog
&&
openExportBtn
)
{
openExportBtn
.
addEventListener
(
'click'
,
function
()
{
exportDialog
.
showModal
();
});
}
if
(
exportDialog
&&
cancelExportBtn
)
{
cancelExportBtn
.
addEventListener
(
'click'
,
function
()
{
exportDialog
.
close
();
});
}
})();
(
function
()
{
var
rows
=
Array
.
prototype
.
slice
.
call
(
document
.
querySelectorAll
(
'#txTableBody tr'
));
if
(
!
rows
.
length
)
return
;
...
...
app/views/admin/transactions-new.server.view.html
View file @
fb6e381b
<!
DOCTYPE
html>
<!
doctype
html>
<html
lang=
"vi"
>
<head>
<meta
charset=
"utf-8"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1"
>
<title>
Thêm mới giao dịch
</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"
>
<style>
:root
{
--color-bg
:
#F1F5F9
;
--color-surface
:
#FFFFFF
;
--color-primary
:
#1E40AF
;
--color-primary-hover
:
#1D4ED8
;
--color-primary-soft
:
#EFF6FF
;
--color-text
:
#0F172A
;
--color-text-muted
:
#64748B
;
--color-border
:
#E2E8F0
;
--color-danger
:
#DC2626
;
--color-danger-bg
:
#FEF2F2
;
--color-danger-border
:
#FECACA
;
--color-success
:
#15803D
;
--color-success-bg
:
#F0FDF4
;
--color-success-border
:
#BBF7D0
;
--radius
:
10px
;
--radius-sm
:
8px
;
--shadow-card
:
0
1px
2px
rgba
(
15
,
23
,
42
,
0.04
),
0
8px
24px
rgba
(
15
,
23
,
42
,
0.06
);
}
*
{
box-sizing
:
border-box
;
}
body
{
margin
:
0
;
min-height
:
100vh
;
background
:
var
(
--color-bg
);
font-family
:
'Inter'
,
-apple-system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
Helvetica
,
Arial
,
sans-serif
;
color
:
var
(
--color-text
);
display
:
flex
;
justify-content
:
center
;
padding
:
40px
20px
;
}
.page
{
width
:
100%
;
max-width
:
520px
;
}
.nav
{
display
:
flex
;
gap
:
16px
;
font-size
:
13px
;
color
:
var
(
--color-text-muted
);
margin-bottom
:
12px
;
flex-wrap
:
wrap
;
align-items
:
center
;
}
.nav
a
{
color
:
inherit
;
text-decoration
:
none
;
font-weight
:
500
;
cursor
:
pointer
;
}
.nav
a
:hover
{
color
:
var
(
--color-primary
);
}
.nav
form
{
display
:
inline
;
}
.breadcrumb
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
14px
;
font-weight
:
500
;
color
:
var
(
--color-text-muted
);
text-decoration
:
none
;
margin-bottom
:
20px
;
transition
:
color
150ms
ease
;
}
.breadcrumb
:hover
{
color
:
var
(
--color-primary
);
}
.breadcrumb
svg
{
flex-shrink
:
0
;
}
.card
{
background
:
var
(
--color-surface
);
border
:
1px
solid
var
(
--color-border
);
border-radius
:
var
(
--radius
);
box-shadow
:
var
(
--shadow-card
);
padding
:
32px
;
}
h1
{
font-size
:
22px
;
font-weight
:
700
;
letter-spacing
:
-0.01em
;
margin
:
0
0
4px
;
}
.subtitle
{
font-size
:
14px
;
color
:
var
(
--color-text-muted
);
margin
:
0
0
28px
;
}
.field
{
margin-bottom
:
20px
;
}
label
{
display
:
block
;
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-text
);
margin-bottom
:
6px
;
}
.field-error
{
font-size
:
12.5px
;
font-weight
:
500
;
color
:
var
(
--color-danger
);
margin-top
:
6px
;
display
:
none
;
align-items
:
center
;
gap
:
5px
;
}
.field.is-invalid
.field-error
{
display
:
flex
;
}
.field.is-invalid
input
{
border-color
:
var
(
--color-danger
);
}
.field.is-invalid
input
:focus
{
box-shadow
:
0
0
0
3px
rgba
(
220
,
38
,
38
,
0.12
);
}
input
{
width
:
100%
;
height
:
44px
;
padding
:
0
14px
;
font-size
:
15px
;
font-family
:
inherit
;
color
:
var
(
--color-text
);
background
:
var
(
--color-surface
);
border
:
1px
solid
var
(
--color-border
);
border-radius
:
var
(
--radius-sm
);
outline
:
none
;
transition
:
border-color
150ms
ease
,
box-shadow
150ms
ease
;
}
input
::placeholder
{
color
:
#A0AEC0
;
}
input
:focus
{
border-color
:
var
(
--color-primary
);
box-shadow
:
0
0
0
3px
rgba
(
30
,
64
,
175
,
0.12
);
}
.amount-field
{
position
:
relative
;
}
.amount-field
input
{
padding-right
:
52px
;
}
.amount-suffix
{
position
:
absolute
;
right
:
14px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-text-muted
);
pointer-events
:
none
;
}
.amount-preview
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
margin-top
:
6px
;
min-height
:
18px
;
font-variant-numeric
:
tabular-nums
;
}
.amount-presets
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
margin-top
:
10px
;
}
.chip
{
height
:
30px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12.5px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
background
:
var
(
--color-primary-soft
);
border
:
1px
solid
transparent
;
border-radius
:
999px
;
cursor
:
pointer
;
transition
:
background
150ms
ease
,
border-color
150ms
ease
;
}
.chip
:hover
{
border-color
:
var
(
--color-primary
);
}
.chip.is-active
{
background
:
var
(
--color-primary
);
color
:
#fff
;
}
.chip
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
#submitBtn
{
width
:
100%
;
height
:
46px
;
margin-top
:
8px
;
background
:
var
(
--color-primary
);
color
:
#fff
;
font-family
:
inherit
;
font-size
:
15px
;
font-weight
:
600
;
border
:
none
;
border-radius
:
var
(
--radius-sm
);
cursor
:
pointer
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
8px
;
transition
:
background
150ms
ease
,
transform
150ms
ease
;
}
#submitBtn
:hover
{
background
:
var
(
--color-primary-hover
);
}
#submitBtn
:active
{
transform
:
scale
(
0.99
);
}
#submitBtn
:disabled
{
opacity
:
0.7
;
cursor
:
not-allowed
;
}
#submitBtn
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
.spinner
{
width
:
16px
;
height
:
16px
;
border
:
2px
solid
rgba
(
255
,
255
,
255
,
0.4
);
border-top-color
:
#fff
;
border-radius
:
50%
;
animation
:
spin
700ms
linear
infinite
;
display
:
none
;
}
#submitBtn
.loading
.spinner
{
display
:
inline-block
;
}
#submitBtn
.loading
.btn-label
::after
{
content
:
"Đang tạo…"
;
}
#submitBtn
.loading
.btn-label
{
font-size
:
0
;
}
#submitBtn
.loading
.btn-label
::after
{
font-size
:
15px
;
}
@keyframes
spin
{
to
{
transform
:
rotate
(
360deg
);
}
}
#result
{
margin-top
:
20px
;
}
.alert
{
display
:
flex
;
gap
:
12px
;
align-items
:
flex-start
;
padding
:
16px
;
border-radius
:
8px
;
border
:
1px
solid
;
font-size
:
14px
;
line-height
:
1.5
;
animation
:
alertIn
220ms
ease
both
;
}
@keyframes
alertIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
.alert
svg
{
flex-shrink
:
0
;
margin-top
:
1px
;
}
.alert-success
{
background
:
var
(
--color-success-bg
);
border-color
:
var
(
--color-success-border
);
color
:
#14532D
;
}
.alert-error
{
background
:
var
(
--color-danger-bg
);
border-color
:
var
(
--color-danger-border
);
color
:
#7F1D1D
;
}
.alert-title
{
font-weight
:
600
;
margin
:
0
0
6px
;
}
.trx-badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
12px
;
font-weight
:
600
;
color
:
#14532D
;
background
:
rgba
(
21
,
128
,
61
,
0.1
);
border-radius
:
6px
;
padding
:
3px
8px
;
margin-bottom
:
10px
;
font-variant-numeric
:
tabular-nums
;
}
.link-row
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
margin-top
:
8px
;
background
:
#fff
;
border
:
1px
solid
var
(
--color-success-border
);
border-radius
:
6px
;
padding
:
8px
10px
;
}
.link-row
input
{
flex
:
1
;
height
:
auto
;
border
:
none
;
padding
:
0
;
font-size
:
13px
;
color
:
var
(
--color-text
);
background
:
transparent
;
}
.link-row
input
:focus
{
box-shadow
:
none
;
}
.copy-btn
{
flex-shrink
:
0
;
height
:
30px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12px
;
font-weight
:
600
;
color
:
var
(
--color-success
);
background
:
#fff
;
border
:
1px
solid
var
(
--color-success-border
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
background
150ms
ease
;
}
.copy-btn
:hover
{
background
:
var
(
--color-success-bg
);
}
.copy-btn
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
.result-actions
{
margin-top
:
10px
;
}
.btn-ghost
{
height
:
34px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12.5px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
background
:
transparent
;
border
:
1px
solid
var
(
--color-border
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
border-color
150ms
ease
,
background
150ms
ease
;
}
.btn-ghost
:hover
{
border-color
:
var
(
--color-primary
);
background
:
var
(
--color-primary-soft
);
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.spinner
{
animation
:
none
;
}
.alert
{
animation
:
none
;
}
*
{
transition
:
none
!important
;
}
}
</style>
</head>
<body>
<div
class=
"page"
>
<div
class=
"nav"
>
<span>
Xin chào,
<strong>
{{username}}
</strong></span>
{% if isAdmin %}
<a
href=
"/admin/accounts"
>
Quản lý tài khoản
</a>
{% endif %}
<a
href=
"/admin/account/password"
>
Đổi mật khẩu
</a>
<form
method=
"POST"
action=
"/admin/logout"
>
<button
type=
"submit"
>
Đăng xuất
</button>
</form>
</div>
<a
class=
"breadcrumb"
href=
"/admin/transactions"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><path
d=
"M19 12H5M12 19l-7-7 7-7"
/></svg>
Lịch sử giao dịch
</a>
<div
class=
"card"
>
<h1>
Thêm mới giao dịch
</h1>
<p
class=
"subtitle"
>
Nhập thông tin khách hàng để tạo link thanh toán
</p>
<form
id=
"txForm"
novalidate
>
<div
class=
"field"
data-field=
"customerName"
>
<label
for=
"customerName"
>
Tên khách hàng
</label>
<input
type=
"text"
id=
"customerName"
name=
"customerName"
placeholder=
"Nguyễn Văn A"
autocomplete=
"name"
required
aria-describedby=
"customerName-error"
>
<p
class=
"field-error"
id=
"customerName-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><circle
cx=
"12"
cy=
"12"
r=
"10"
/><line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/><line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/></svg>
Vui lòng nhập tên khách hàng
</p>
</div>
<div
class=
"field"
data-field=
"customerPhone"
>
<label
for=
"customerPhone"
>
Số điện thoại
</label>
<input
type=
"tel"
id=
"customerPhone"
name=
"customerPhone"
placeholder=
"09xx xxx xxx"
autocomplete=
"tel"
inputmode=
"numeric"
required
aria-describedby=
"customerPhone-error"
>
<p
class=
"field-error"
id=
"customerPhone-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><circle
cx=
"12"
cy=
"12"
r=
"10"
/><line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/><line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/></svg>
Số điện thoại không hợp lệ (VD: 0901234567)
</p>
</div>
<div
class=
"field"
data-field=
"customerAddress"
>
<label
for=
"customerAddress"
>
Địa chỉ
</label>
<input
type=
"text"
id=
"customerAddress"
name=
"customerAddress"
placeholder=
"Số nhà, đường, phường/xã"
autocomplete=
"street-address"
required
aria-describedby=
"customerAddress-error"
>
<p
class=
"field-error"
id=
"customerAddress-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><circle
cx=
"12"
cy=
"12"
r=
"10"
/><line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/><line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/></svg>
Vui lòng nhập địa chỉ
</p>
</div>
<div
class=
"field"
data-field=
"customerIdCard"
>
<label
for=
"customerIdCard"
>
Số CMND/CCCD
</label>
<input
type=
"text"
id=
"customerIdCard"
name=
"customerIdCard"
placeholder=
"012345678901"
autocomplete=
"off"
inputmode=
"numeric"
maxlength=
"12"
required
aria-describedby=
"customerIdCard-error"
>
<p
class=
"field-error"
id=
"customerIdCard-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><circle
cx=
"12"
cy=
"12"
r=
"10"
/><line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/><line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/></svg>
Số CMND/CCCD phải từ 9 đến 12 chữ số
</p>
</div>
<div
class=
"field"
data-field=
"amount"
>
<label
for=
"amount"
>
Số tiền thanh toán
</label>
<div
class=
"amount-field"
>
<input
type=
"number"
id=
"amount"
name=
"amount"
placeholder=
"0"
min=
"10000"
max=
"20000000"
step=
"1"
inputmode=
"numeric"
required
aria-describedby=
"amount-error amount-preview"
>
<span
class=
"amount-suffix"
>
VNĐ
</span>
<head>
<meta
charset=
"utf-8"
/>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1"
/>
<title>
Thêm mới giao dịch
</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"
/>
<style>
:root
{
--color-bg
:
#f1f5f9
;
--color-surface
:
#ffffff
;
--color-primary
:
#1e40af
;
--color-primary-hover
:
#1d4ed8
;
--color-primary-soft
:
#eff6ff
;
--color-text
:
#0f172a
;
--color-text-muted
:
#64748b
;
--color-border
:
#e2e8f0
;
--color-danger
:
#dc2626
;
--color-danger-bg
:
#fef2f2
;
--color-danger-border
:
#fecaca
;
--color-success
:
#15803d
;
--color-success-bg
:
#f0fdf4
;
--color-success-border
:
#bbf7d0
;
--radius
:
10px
;
--radius-sm
:
8px
;
--shadow-card
:
0
1px
2px
rgba
(
15
,
23
,
42
,
0.04
),
0
8px
24px
rgba
(
15
,
23
,
42
,
0.06
);
}
*
{
box-sizing
:
border-box
;
}
body
{
margin
:
0
;
min-height
:
100vh
;
background
:
var
(
--color-bg
);
font-family
:
"Inter"
,
-apple-system
,
BlinkMacSystemFont
,
"Segoe UI"
,
Roboto
,
Helvetica
,
Arial
,
sans-serif
;
color
:
var
(
--color-text
);
display
:
flex
;
justify-content
:
center
;
padding
:
40px
20px
;
}
.page
{
width
:
100%
;
max-width
:
520px
;
}
.nav
{
display
:
flex
;
gap
:
16px
;
font-size
:
13px
;
color
:
var
(
--color-text-muted
);
margin-bottom
:
12px
;
flex-wrap
:
wrap
;
align-items
:
center
;
}
.nav
a
{
color
:
inherit
;
text-decoration
:
none
;
font-weight
:
500
;
cursor
:
pointer
;
}
.nav
a
:hover
{
color
:
var
(
--color-primary
);
}
.nav
form
{
display
:
inline
;
}
.breadcrumb
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
14px
;
font-weight
:
500
;
color
:
var
(
--color-text-muted
);
text-decoration
:
none
;
margin-bottom
:
20px
;
transition
:
color
150ms
ease
;
}
.breadcrumb
:hover
{
color
:
var
(
--color-primary
);
}
.breadcrumb
svg
{
flex-shrink
:
0
;
}
.card
{
background
:
var
(
--color-surface
);
border
:
1px
solid
var
(
--color-border
);
border-radius
:
var
(
--radius
);
box-shadow
:
var
(
--shadow-card
);
padding
:
32px
;
}
h1
{
font-size
:
22px
;
font-weight
:
700
;
letter-spacing
:
-0.01em
;
margin
:
0
0
4px
;
}
.subtitle
{
font-size
:
14px
;
color
:
var
(
--color-text-muted
);
margin
:
0
0
28px
;
}
.field
{
margin-bottom
:
20px
;
}
label
{
display
:
block
;
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-text
);
margin-bottom
:
6px
;
}
.field-error
{
font-size
:
12.5px
;
font-weight
:
500
;
color
:
var
(
--color-danger
);
margin-top
:
6px
;
display
:
none
;
align-items
:
center
;
gap
:
5px
;
}
.field.is-invalid
.field-error
{
display
:
flex
;
}
.field.is-invalid
input
{
border-color
:
var
(
--color-danger
);
}
.field.is-invalid
input
:focus
{
box-shadow
:
0
0
0
3px
rgba
(
220
,
38
,
38
,
0.12
);
}
input
{
width
:
100%
;
height
:
44px
;
padding
:
0
14px
;
font-size
:
15px
;
font-family
:
inherit
;
color
:
var
(
--color-text
);
background
:
var
(
--color-surface
);
border
:
1px
solid
var
(
--color-border
);
border-radius
:
var
(
--radius-sm
);
outline
:
none
;
transition
:
border-color
150ms
ease
,
box-shadow
150ms
ease
;
}
input
::placeholder
{
color
:
#a0aec0
;
}
input
:focus
{
border-color
:
var
(
--color-primary
);
box-shadow
:
0
0
0
3px
rgba
(
30
,
64
,
175
,
0.12
);
}
.amount-field
{
position
:
relative
;
}
.amount-field
input
{
padding-right
:
52px
;
}
.amount-suffix
{
position
:
absolute
;
right
:
14px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-text-muted
);
pointer-events
:
none
;
}
.amount-preview
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
margin-top
:
6px
;
min-height
:
18px
;
font-variant-numeric
:
tabular-nums
;
}
.amount-presets
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
margin-top
:
10px
;
}
.chip
{
height
:
30px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12.5px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
background
:
var
(
--color-primary-soft
);
border
:
1px
solid
transparent
;
border-radius
:
999px
;
cursor
:
pointer
;
transition
:
background
150ms
ease
,
border-color
150ms
ease
;
}
.chip
:hover
{
border-color
:
var
(
--color-primary
);
}
.chip.is-active
{
background
:
var
(
--color-primary
);
color
:
#fff
;
}
.chip
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
#submitBtn
{
width
:
100%
;
height
:
46px
;
margin-top
:
8px
;
background
:
var
(
--color-primary
);
color
:
#fff
;
font-family
:
inherit
;
font-size
:
15px
;
font-weight
:
600
;
border
:
none
;
border-radius
:
var
(
--radius-sm
);
cursor
:
pointer
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
8px
;
transition
:
background
150ms
ease
,
transform
150ms
ease
;
}
#submitBtn
:hover
{
background
:
var
(
--color-primary-hover
);
}
#submitBtn
:active
{
transform
:
scale
(
0.99
);
}
#submitBtn
:disabled
{
opacity
:
0.7
;
cursor
:
not-allowed
;
}
#submitBtn
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
.spinner
{
width
:
16px
;
height
:
16px
;
border
:
2px
solid
rgba
(
255
,
255
,
255
,
0.4
);
border-top-color
:
#fff
;
border-radius
:
50%
;
animation
:
spin
700ms
linear
infinite
;
display
:
none
;
}
#submitBtn
.loading
.spinner
{
display
:
inline-block
;
}
#submitBtn
.loading
.btn-label
::after
{
content
:
"Đang tạo…"
;
}
#submitBtn
.loading
.btn-label
{
font-size
:
0
;
}
#submitBtn
.loading
.btn-label
::after
{
font-size
:
15px
;
}
@keyframes
spin
{
to
{
transform
:
rotate
(
360deg
);
}
}
#result
{
margin-top
:
20px
;
}
.alert
{
display
:
flex
;
gap
:
12px
;
align-items
:
flex-start
;
padding
:
16px
;
border-radius
:
8px
;
border
:
1px
solid
;
font-size
:
14px
;
line-height
:
1.5
;
animation
:
alertIn
220ms
ease
both
;
}
@keyframes
alertIn
{
from
{
opacity
:
0
;
transform
:
translateY
(
4px
);
}
to
{
opacity
:
1
;
transform
:
translateY
(
0
);
}
}
.alert
svg
{
flex-shrink
:
0
;
margin-top
:
1px
;
}
.alert-success
{
background
:
var
(
--color-success-bg
);
border-color
:
var
(
--color-success-border
);
color
:
#14532d
;
}
.alert-error
{
background
:
var
(
--color-danger-bg
);
border-color
:
var
(
--color-danger-border
);
color
:
#7f1d1d
;
}
.alert-title
{
font-weight
:
600
;
margin
:
0
0
6px
;
}
.trx-badge
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
12px
;
font-weight
:
600
;
color
:
#14532d
;
background
:
rgba
(
21
,
128
,
61
,
0.1
);
border-radius
:
6px
;
padding
:
3px
8px
;
margin-bottom
:
10px
;
font-variant-numeric
:
tabular-nums
;
}
.link-row
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
margin-top
:
8px
;
background
:
#fff
;
border
:
1px
solid
var
(
--color-success-border
);
border-radius
:
6px
;
padding
:
8px
10px
;
}
.link-row
input
{
flex
:
1
;
height
:
auto
;
border
:
none
;
padding
:
0
;
font-size
:
13px
;
color
:
var
(
--color-text
);
background
:
transparent
;
}
.link-row
input
:focus
{
box-shadow
:
none
;
}
.copy-btn
{
flex-shrink
:
0
;
height
:
30px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12px
;
font-weight
:
600
;
color
:
var
(
--color-success
);
background
:
#fff
;
border
:
1px
solid
var
(
--color-success-border
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
background
150ms
ease
;
}
.copy-btn
:hover
{
background
:
var
(
--color-success-bg
);
}
.copy-btn
:focus-visible
{
outline
:
2px
solid
var
(
--color-primary
);
outline-offset
:
2px
;
}
.result-actions
{
margin-top
:
10px
;
}
.btn-ghost
{
height
:
34px
;
padding
:
0
12px
;
font-family
:
inherit
;
font-size
:
12.5px
;
font-weight
:
600
;
color
:
var
(
--color-primary
);
background
:
transparent
;
border
:
1px
solid
var
(
--color-border
);
border-radius
:
6px
;
cursor
:
pointer
;
transition
:
border-color
150ms
ease
,
background
150ms
ease
;
}
.btn-ghost
:hover
{
border-color
:
var
(
--color-primary
);
background
:
var
(
--color-primary-soft
);
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.spinner
{
animation
:
none
;
}
.alert
{
animation
:
none
;
}
*
{
transition
:
none
!important
;
}
}
</style>
</head>
<body>
<div
class=
"page"
>
<div
class=
"nav"
>
<span>
Xin chào,
<strong>
{{username}}
</strong></span>
{% if isAdmin %}
<a
href=
"/admin/accounts"
>
Quản lý tài khoản
</a>
{% endif
%}
<a
href=
"/admin/account/password"
>
Đổi mật khẩu
</a>
<form
method=
"POST"
action=
"/admin/logout"
>
<button
type=
"submit"
>
Đăng xuất
</button>
</form>
</div>
<a
class=
"breadcrumb"
href=
"/admin/transactions"
>
<svg
width=
"16"
height=
"16"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<path
d=
"M19 12H5M12 19l-7-7 7-7"
/>
</svg>
Lịch sử giao dịch
</a>
<div
class=
"card"
>
<h1>
Thêm mới giao dịch
</h1>
<p
class=
"subtitle"
>
Nhập thông tin khách hàng để tạo link thanh toán
</p>
<form
id=
"txForm"
novalidate
>
<div
class=
"field"
data-field=
"customerName"
>
<label
for=
"customerName"
>
Tên khách hàng
</label>
<input
type=
"text"
id=
"customerName"
name=
"customerName"
placeholder=
"Nguyễn Văn A"
autocomplete=
"name"
required
aria-describedby=
"customerName-error"
/>
<p
class=
"field-error"
id=
"customerName-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/>
</svg>
Vui lòng nhập tên khách hàng
</p>
</div>
<p
class=
"field-error"
id=
"amount-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
><circle
cx=
"12"
cy=
"12"
r=
"10"
/><line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/><line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/></svg>
Số tiền phải từ 10,000 đến 20,000,000 VNĐ
</p>
<p
class=
"amount-preview"
id=
"amount-preview"
aria-live=
"polite"
></p>
<div
class=
"amount-presets"
role=
"group"
aria-label=
"Chọn nhanh số tiền"
>
<button
type=
"button"
class=
"chip"
data-amount=
"2000000"
>
2,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"5000000"
>
5,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"10000000"
>
10,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"15000000"
>
15,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"19000000"
>
19,000,000
</button>
<div
class=
"field"
data-field=
"customerPhone"
>
<label
for=
"customerPhone"
>
Số điện thoại
</label>
<input
type=
"tel"
id=
"customerPhone"
name=
"customerPhone"
placeholder=
"09xx xxx xxx"
autocomplete=
"tel"
inputmode=
"numeric"
required
aria-describedby=
"customerPhone-error"
/>
<p
class=
"field-error"
id=
"customerPhone-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/>
</svg>
Số điện thoại không hợp lệ (VD: 0901234567)
</p>
</div>
</div>
<button
type=
"submit"
id=
"submitBtn"
>
<span
class=
"spinner"
></span>
<span
class=
"btn-label"
>
Tạo giao dịch
</span>
</button>
</form>
<div
id=
"result"
aria-live=
"polite"
></div>
<div
class=
"field"
data-field=
"customerAddress"
>
<label
for=
"customerAddress"
>
Địa chỉ
</label>
<input
type=
"text"
id=
"customerAddress"
name=
"customerAddress"
placeholder=
"Số nhà, đường, phường/xã"
autocomplete=
"street-address"
required
aria-describedby=
"customerAddress-error"
/>
<p
class=
"field-error"
id=
"customerAddress-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/>
</svg>
Vui lòng nhập địa chỉ
</p>
</div>
<div
class=
"field"
data-field=
"customerIdCard"
>
<label
for=
"customerIdCard"
>
Số CMND
</label>
<input
type=
"text"
id=
"customerIdCard"
name=
"customerIdCard"
placeholder=
"012345678901"
autocomplete=
"off"
inputmode=
"numeric"
maxlength=
"12"
minlength=
"12"
required
aria-describedby=
"customerIdCard-error"
/>
<p
class=
"field-error"
id=
"customerIdCard-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/>
</svg>
Số CMND phải đủ 12 chữ số
</p>
</div>
<div
class=
"field"
data-field=
"amount"
>
<label
for=
"amount"
>
Số tiền thanh toán
</label>
<div
class=
"amount-field"
>
<input
type=
"number"
id=
"amount"
name=
"amount"
placeholder=
"0"
min=
"10000"
max=
"20000000"
step=
"1"
inputmode=
"numeric"
required
aria-describedby=
"amount-error amount-preview"
/>
<span
class=
"amount-suffix"
>
VNĐ
</span>
</div>
<p
class=
"field-error"
id=
"amount-error"
role=
"alert"
>
<svg
width=
"13"
height=
"13"
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
stroke-width=
"2.5"
stroke-linecap=
"round"
stroke-linejoin=
"round"
>
<circle
cx=
"12"
cy=
"12"
r=
"10"
/>
<line
x1=
"12"
y1=
"8"
x2=
"12"
y2=
"12"
/>
<line
x1=
"12"
y1=
"16"
x2=
"12.01"
y2=
"16"
/>
</svg>
Số tiền phải từ 10,000 đến 20,000,000 VNĐ
</p>
<p
class=
"amount-preview"
id=
"amount-preview"
aria-live=
"polite"
></p>
<div
class=
"amount-presets"
role=
"group"
aria-label=
"Chọn nhanh số tiền"
>
<button
type=
"button"
class=
"chip"
data-amount=
"2000000"
>
2,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"5000000"
>
5,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"10000000"
>
10,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"15000000"
>
15,000,000
</button>
<button
type=
"button"
class=
"chip"
data-amount=
"19000000"
>
19,000,000
</button>
</div>
</div>
<button
type=
"submit"
id=
"submitBtn"
>
<span
class=
"spinner"
></span>
<span
class=
"btn-label"
>
Tạo giao dịch
</span>
</button>
</form>
<div
id=
"result"
aria-live=
"polite"
></div>
</div>
</div>
</div>
<script>
var
iconCheck
=
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>'
;
var
iconAlert
=
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
;
var
form
=
document
.
getElementById
(
'txForm'
);
var
amountInput
=
document
.
getElementById
(
'amount'
);
var
amountPreview
=
document
.
getElementById
(
'amount-preview'
);
var
phoneInput
=
document
.
getElementById
(
'customerPhone'
);
function
formatVnd
(
n
)
{
return
n
.
toLocaleString
(
'vi-VN'
);
}
function
updateAmountPreview
()
{
var
val
=
parseInt
(
amountInput
.
value
,
10
);
amountPreview
.
textContent
=
(
isFinite
(
val
)
&&
val
>
0
)
?
formatVnd
(
val
)
+
' ₫'
:
''
;
document
.
querySelectorAll
(
'.chip'
).
forEach
(
function
(
chip
)
{
chip
.
classList
.
toggle
(
'is-active'
,
String
(
val
)
===
chip
.
getAttribute
(
'data-amount'
));
});
}
<script>
var
iconCheck
=
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>'
;
var
iconAlert
=
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
;
var
form
=
document
.
getElementById
(
"txForm"
);
var
amountInput
=
document
.
getElementById
(
"amount"
);
var
amountPreview
=
document
.
getElementById
(
"amount-preview"
);
var
phoneInput
=
document
.
getElementById
(
"customerPhone"
);
function
formatVnd
(
n
)
{
return
n
.
toLocaleString
(
"vi-VN"
);
}
function
updateAmountPreview
()
{
var
val
=
parseInt
(
amountInput
.
value
,
10
);
amountPreview
.
textContent
=
isFinite
(
val
)
&&
val
>
0
?
formatVnd
(
val
)
+
" ₫"
:
""
;
document
.
querySelectorAll
(
".chip"
).
forEach
(
function
(
chip
)
{
chip
.
classList
.
toggle
(
"is-active"
,
String
(
val
)
===
chip
.
getAttribute
(
"data-amount"
),
);
});
}
amountInput
.
addEventListener
(
'input'
,
updateAmountPreview
);
amountInput
.
addEventListener
(
"input"
,
updateAmountPreview
);
document
.
querySelectorAll
(
'.chip'
).
forEach
(
function
(
chip
)
{
chip
.
addEventListener
(
'click'
,
function
()
{
amountInput
.
value
=
chip
.
getAttribute
(
'data-amount'
);
updateAmountPreview
();
setFieldValid
(
'amount'
);
document
.
querySelectorAll
(
".chip"
).
forEach
(
function
(
chip
)
{
chip
.
addEventListener
(
"click"
,
function
()
{
amountInput
.
value
=
chip
.
getAttribute
(
"data-amount"
);
updateAmountPreview
();
setFieldValid
(
"amount"
);
});
});
});
function
setFieldInvalid
(
name
)
{
var
field
=
form
.
querySelector
(
'[data-field="'
+
name
+
'"]'
);
if
(
field
)
field
.
classList
.
add
(
'is-invalid'
);
}
function
setFieldValid
(
name
)
{
var
field
=
form
.
querySelector
(
'[data-field="'
+
name
+
'"]'
);
if
(
field
)
field
.
classList
.
remove
(
'is-invalid'
);
}
function
validatePhone
(
value
)
{
return
/^
(
0|
\+
84
)[
0-9
]{9}
$/
.
test
(
value
.
replace
(
/
[\s
.-
]
/g
,
''
));
}
function
validateIdCard
(
value
)
{
return
/^
[
0-9
]{9,12}
$/
.
test
(
value
.
trim
());
}
function
validateForm
(
data
)
{
var
valid
=
true
;
if
(
!
data
.
customerName
.
trim
())
{
setFieldInvalid
(
'customerName'
);
valid
=
false
;
}
else
{
setFieldValid
(
'customerName'
);
}
if
(
!
validatePhone
(
data
.
customerPhone
))
{
setFieldInvalid
(
'customerPhone'
);
valid
=
false
;
}
else
{
setFieldValid
(
'customerPhone'
);
}
if
(
!
data
.
customerAddress
.
trim
())
{
setFieldInvalid
(
'customerAddress'
);
valid
=
false
;
}
else
{
setFieldValid
(
'customerAddress'
);
}
if
(
!
validateIdCard
(
data
.
customerIdCard
))
{
setFieldInvalid
(
'customerIdCard'
);
valid
=
false
;
}
else
{
setFieldValid
(
'customerIdCard'
);
}
var
amountNum
=
parseInt
(
data
.
amount
,
10
);
if
(
!
isFinite
(
amountNum
)
||
amountNum
<
10000
||
amountNum
>
20000000
)
{
setFieldInvalid
(
'amount'
);
valid
=
false
;
}
else
{
setFieldValid
(
'amount'
);
}
return
valid
;
}
[
'customerName'
,
'customerAddress'
,
'customerIdCard'
].
forEach
(
function
(
id
)
{
document
.
getElementById
(
id
).
addEventListener
(
'input'
,
function
()
{
setFieldValid
(
id
);
function
setFieldInvalid
(
name
)
{
var
field
=
form
.
querySelector
(
'[data-field="'
+
name
+
'"]'
);
if
(
field
)
field
.
classList
.
add
(
"is-invalid"
);
}
function
setFieldValid
(
name
)
{
var
field
=
form
.
querySelector
(
'[data-field="'
+
name
+
'"]'
);
if
(
field
)
field
.
classList
.
remove
(
"is-invalid"
);
}
function
validatePhone
(
value
)
{
return
/^
(
0|
\+
84
)[
0-9
]{9}
$/
.
test
(
value
.
replace
(
/
[\s
.-
]
/g
,
""
));
}
function
validateIdCard
(
value
)
{
return
/^
[
0-9
]{12}
$/
.
test
(
value
.
trim
());
}
function
validateForm
(
data
)
{
var
valid
=
true
;
if
(
!
data
.
customerName
.
trim
())
{
setFieldInvalid
(
"customerName"
);
valid
=
false
;
}
else
{
setFieldValid
(
"customerName"
);
}
if
(
!
validatePhone
(
data
.
customerPhone
))
{
setFieldInvalid
(
"customerPhone"
);
valid
=
false
;
}
else
{
setFieldValid
(
"customerPhone"
);
}
if
(
!
data
.
customerAddress
.
trim
())
{
setFieldInvalid
(
"customerAddress"
);
valid
=
false
;
}
else
{
setFieldValid
(
"customerAddress"
);
}
if
(
!
validateIdCard
(
data
.
customerIdCard
))
{
setFieldInvalid
(
"customerIdCard"
);
valid
=
false
;
}
else
{
setFieldValid
(
"customerIdCard"
);
}
var
amountNum
=
parseInt
(
data
.
amount
,
10
);
if
(
!
isFinite
(
amountNum
)
||
amountNum
<
10000
||
amountNum
>
20000000
)
{
setFieldInvalid
(
"amount"
);
valid
=
false
;
}
else
{
setFieldValid
(
"amount"
);
}
return
valid
;
}
[
"customerName"
,
"customerAddress"
,
"customerIdCard"
].
forEach
(
function
(
id
)
{
document
.
getElementById
(
id
).
addEventListener
(
"input"
,
function
()
{
setFieldValid
(
id
);
});
},
);
phoneInput
.
addEventListener
(
"input"
,
function
()
{
setFieldValid
(
"customerPhone"
);
});
});
phoneInput
.
addEventListener
(
'input'
,
function
()
{
setFieldValid
(
'customerPhone'
);
});
form
.
addEventListener
(
'submit'
,
function
(
e
)
{
e
.
preventDefault
();
var
btn
=
document
.
getElementById
(
'submitBtn'
);
var
data
=
{
customerName
:
form
.
customerName
.
value
,
customerPhone
:
form
.
customerPhone
.
value
,
customerAddress
:
form
.
customerAddress
.
value
,
customerIdCard
:
form
.
customerIdCard
.
value
,
amount
:
form
.
amount
.
value
};
if
(
!
validateForm
(
data
))
{
var
firstInvalid
=
form
.
querySelector
(
'.is-invalid input'
);
if
(
firstInvalid
)
firstInvalid
.
focus
();
return
;
}
btn
.
classList
.
add
(
'loading'
);
btn
.
disabled
=
true
;
fetch
(
'/admin/transactions'
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
},
body
:
JSON
.
stringify
(
data
)
})
.
then
(
function
(
r
)
{
return
r
.
json
();
})
.
then
(
function
(
res
)
{
if
(
res
.
data
===
'LOGIN_REQUIRED'
)
{
window
.
location
.
href
=
'/admin/login'
;
return
;
}
var
el
=
document
.
getElementById
(
'result'
);
if
(
res
.
code
===
'00'
)
{
var
trxShort
=
String
(
res
.
data
.
merTrxId
||
''
).
split
(
'_'
).
pop
();
el
.
innerHTML
=
'<div class="alert alert-success">'
+
iconCheck
+
'<div><p class="alert-title">Đã tạo giao dịch thành công</p>'
+
(
trxShort
?
'<span class="trx-badge">#'
+
trxShort
+
'</span>'
:
''
)
+
'<div class="link-row">'
+
'<input type="text" readonly value="'
+
res
.
data
.
paymentUrl
+
'" id="paymentUrlField">'
+
'<button type="button" class="copy-btn" id="copyBtn">Sao chép</button>'
+
'</div>'
+
'<div class="result-actions"><button type="button" class="btn-ghost" id="newTxBtn">Tạo giao dịch khác</button></div>'
+
'</div></div>'
;
document
.
getElementById
(
'copyBtn'
).
addEventListener
(
'click'
,
function
()
{
var
field
=
document
.
getElementById
(
'paymentUrlField'
);
field
.
select
();
navigator
.
clipboard
&&
navigator
.
clipboard
.
writeText
(
field
.
value
);
this
.
textContent
=
'Đã chép!'
;
var
self
=
this
;
setTimeout
(
function
()
{
self
.
textContent
=
'Sao chép'
;
},
1500
);
});
document
.
getElementById
(
'newTxBtn'
).
addEventListener
(
'click'
,
function
()
{
el
.
innerHTML
=
''
;
amountPreview
.
textContent
=
''
;
document
.
querySelectorAll
(
'.chip'
).
forEach
(
function
(
c
)
{
c
.
classList
.
remove
(
'is-active'
);
});
document
.
getElementById
(
'customerName'
).
focus
();
});
form
.
reset
();
}
else
{
el
.
innerHTML
=
'<div class="alert alert-error">'
+
iconAlert
+
'<div><p class="alert-title">Không tạo được giao dịch</p>'
+
res
.
data
+
'</div></div>'
;
}
})
.
catch
(
function
()
{
document
.
getElementById
(
'result'
).
innerHTML
=
'<div class="alert alert-error">'
+
iconAlert
+
'<div><p class="alert-title">Lỗi kết nối</p>Vui lòng thử lại.</div></div>'
;
form
.
addEventListener
(
"submit"
,
function
(
e
)
{
e
.
preventDefault
();
var
btn
=
document
.
getElementById
(
"submitBtn"
);
var
data
=
{
customerName
:
form
.
customerName
.
value
,
customerPhone
:
form
.
customerPhone
.
value
,
customerAddress
:
form
.
customerAddress
.
value
,
customerIdCard
:
form
.
customerIdCard
.
value
,
amount
:
form
.
amount
.
value
,
};
if
(
!
validateForm
(
data
))
{
var
firstInvalid
=
form
.
querySelector
(
".is-invalid input"
);
if
(
firstInvalid
)
firstInvalid
.
focus
();
return
;
}
btn
.
classList
.
add
(
"loading"
);
btn
.
disabled
=
true
;
fetch
(
"/admin/transactions"
,
{
method
:
"POST"
,
headers
:
{
"Content-Type"
:
"application/json"
},
body
:
JSON
.
stringify
(
data
),
})
.
finally
(
function
()
{
btn
.
classList
.
remove
(
'loading'
);
btn
.
disabled
=
false
;
});
});
</script>
</body>
.
then
(
function
(
r
)
{
return
r
.
json
();
})
.
then
(
function
(
res
)
{
if
(
res
.
data
===
"LOGIN_REQUIRED"
)
{
window
.
location
.
href
=
"/admin/login"
;
return
;
}
var
el
=
document
.
getElementById
(
"result"
);
if
(
res
.
code
===
"00"
)
{
var
trxShort
=
String
(
res
.
data
.
merTrxId
||
""
)
.
split
(
"_"
)
.
pop
();
el
.
innerHTML
=
'<div class="alert alert-success">'
+
iconCheck
+
'<div><p class="alert-title">Đã tạo giao dịch thành công</p>'
+
(
trxShort
?
'<span class="trx-badge">#'
+
trxShort
+
"</span>"
:
""
)
+
'<div class="link-row">'
+
'<input type="text" readonly value="'
+
res
.
data
.
paymentUrl
+
'" id="paymentUrlField">'
+
'<button type="button" class="copy-btn" id="copyBtn">Sao chép</button>'
+
"</div>"
+
'<div class="result-actions"><button type="button" class="btn-ghost" id="newTxBtn">Tạo giao dịch khác</button></div>'
+
"</div></div>"
;
document
.
getElementById
(
"copyBtn"
)
.
addEventListener
(
"click"
,
function
()
{
var
field
=
document
.
getElementById
(
"paymentUrlField"
);
field
.
select
();
navigator
.
clipboard
&&
navigator
.
clipboard
.
writeText
(
field
.
value
);
this
.
textContent
=
"Đã chép!"
;
var
self
=
this
;
setTimeout
(
function
()
{
self
.
textContent
=
"Sao chép"
;
},
1500
);
});
document
.
getElementById
(
"newTxBtn"
)
.
addEventListener
(
"click"
,
function
()
{
el
.
innerHTML
=
""
;
amountPreview
.
textContent
=
""
;
document
.
querySelectorAll
(
".chip"
).
forEach
(
function
(
c
)
{
c
.
classList
.
remove
(
"is-active"
);
});
document
.
getElementById
(
"customerName"
).
focus
();
});
form
.
reset
();
}
else
{
el
.
innerHTML
=
'<div class="alert alert-error">'
+
iconAlert
+
'<div><p class="alert-title">Không tạo được giao dịch</p>'
+
res
.
data
+
"</div></div>"
;
}
})
.
catch
(
function
()
{
document
.
getElementById
(
"result"
).
innerHTML
=
'<div class="alert alert-error">'
+
iconAlert
+
'<div><p class="alert-title">Lỗi kết nối</p>Vui lòng thử lại.</div></div>'
;
})
.
finally
(
function
()
{
btn
.
classList
.
remove
(
"loading"
);
btn
.
disabled
=
false
;
});
});
</script>
</body>
</html>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment