mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
365
package-lock.json
generated
365
package-lock.json
generated
@@ -2320,9 +2320,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
|
||||
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2334,9 +2334,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
|
||||
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2348,9 +2348,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
|
||||
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2362,9 +2362,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
|
||||
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2376,9 +2376,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
|
||||
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2390,9 +2390,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
|
||||
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2404,9 +2404,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
|
||||
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2418,9 +2418,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
|
||||
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2432,9 +2432,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2446,9 +2446,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
|
||||
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2460,9 +2460,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2474,9 +2474,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
|
||||
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2488,9 +2488,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2502,9 +2502,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
|
||||
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2516,9 +2516,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2530,9 +2530,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
|
||||
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2544,9 +2544,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2558,9 +2558,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2572,9 +2572,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
|
||||
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2586,9 +2586,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
|
||||
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2600,9 +2600,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
|
||||
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2614,9 +2614,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
|
||||
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2628,9 +2628,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
|
||||
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2642,9 +2642,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
|
||||
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2656,9 +2656,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
|
||||
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3501,9 +3501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3776,9 +3776,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3786,13 +3786,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3900,10 +3900,22 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4051,14 +4063,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -4100,9 +4113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4585,7 +4598,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -4651,9 +4663,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
|
||||
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5178,16 +5190,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5534,6 +5546,19 @@
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
@@ -6072,9 +6097,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6109,7 +6134,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@@ -6138,9 +6162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6317,9 +6341,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6363,9 +6387,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6382,7 +6406,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -6824,10 +6848,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -7049,9 +7076,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -7250,13 +7277,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
@@ -7266,31 +7293,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.61.1",
|
||||
"@rollup/rollup-android-arm64": "4.61.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.61.1",
|
||||
"@rollup/rollup-darwin-x64": "4.61.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.61.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.61.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.61.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.61.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.61.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.61.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.61.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.61.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.61.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.61.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -7665,9 +7692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7925,9 +7952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8018,9 +8045,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8128,9 +8155,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
@@ -148,14 +148,27 @@
|
||||
const manualBtn = document.getElementById('manualBtn');
|
||||
manualBtn.href = url;
|
||||
|
||||
// Try to open the URL scheme directly
|
||||
// This page is opened in external browser, so custom schemes work
|
||||
window.location.href = url;
|
||||
// Attempt to launch the app WITHOUT a top-level navigation. A direct
|
||||
// `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME
|
||||
// inside Android in-app browsers (Telegram/Yandex/…) and silently fails on
|
||||
// iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is
|
||||
// contained: the app opens if installed, otherwise the failure stays inside
|
||||
// the (invisible) frame and the button below stays usable.
|
||||
try {
|
||||
var frame = document.createElement('iframe');
|
||||
frame.style.display = 'none';
|
||||
frame.src = url;
|
||||
document.body.appendChild(frame);
|
||||
setTimeout(function() {
|
||||
try { frame.parentNode.removeChild(frame); } catch (e) {}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Show manual button after a delay in case redirect didn't work
|
||||
setTimeout(function() {
|
||||
document.getElementById('errorBlock').classList.add('show');
|
||||
}, 2000);
|
||||
// Programmatic opening is unreliable in in-app browsers, so surface the
|
||||
// manual "Open app" button immediately — a user tap is the reliable trigger.
|
||||
document.getElementById('errorBlock').classList.add('show');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
48
src/App.tsx
48
src/App.tsx
@@ -135,7 +135,6 @@ const AdminBroadcastDetail = lazyWithRetry(() => import('./pages/AdminBroadcastD
|
||||
const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages'));
|
||||
const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate'));
|
||||
const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions'));
|
||||
const AdminEmailTemplatePreview = lazyWithRetry(() => import('./pages/AdminEmailTemplatePreview'));
|
||||
const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles'));
|
||||
const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit'));
|
||||
const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign'));
|
||||
@@ -156,6 +155,7 @@ const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
|
||||
const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView'));
|
||||
const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages'));
|
||||
const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor'));
|
||||
const AdminLegalPages = lazyWithRetry(() => import('./pages/AdminLegalPages'));
|
||||
|
||||
function ProtectedRoute({
|
||||
children,
|
||||
@@ -408,6 +408,18 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Path-based method variant: some providers (Lava) reject return URLs that carry a
|
||||
query string, so the method is encoded in the path instead of ?method=. */}
|
||||
<Route
|
||||
path="/balance/top-up/result/:method"
|
||||
element={
|
||||
<ProtectedRoute withLayout={false}>
|
||||
<LazyPage>
|
||||
<TopUpResult />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance/top-up/:methodId"
|
||||
element={
|
||||
@@ -620,6 +632,19 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
{/* Deep-link target for admin ticket notification buttons (bot issue #2988):
|
||||
opens a specific ticket directly. Static "/settings" above out-ranks
|
||||
this dynamic segment in react-router, so there is no conflict. */}
|
||||
<Route
|
||||
path="/admin/tickets/:ticketId"
|
||||
element={
|
||||
<PermissionRoute permission="tickets:read">
|
||||
<LazyPage>
|
||||
<AdminTickets />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
@@ -1200,17 +1225,6 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates/preview/:type/:lang"
|
||||
element={
|
||||
<PermissionRoute permission="email_templates:read">
|
||||
<LazyPage>
|
||||
<AdminEmailTemplatePreview />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* RBAC routes */}
|
||||
<Route
|
||||
path="/admin/roles"
|
||||
@@ -1345,6 +1359,16 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/legal-pages"
|
||||
element={
|
||||
<PermissionRoute permission="info_pages:read">
|
||||
<LazyPage>
|
||||
<AdminLegalPages />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/audit-log"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hideBackButton,
|
||||
onBackButtonClick,
|
||||
offBackButtonClick,
|
||||
retrieveLaunchParams,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Twemoji from 'react-twemoji';
|
||||
@@ -172,12 +173,51 @@ function TelegramBackButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `admin_ticket_<id>` startapp param → /admin/tickets/<id>. */
|
||||
const ADMIN_TICKET_START_PARAM_RE = /^admin_ticket_(\d+)$/;
|
||||
|
||||
/**
|
||||
* Routes a Telegram Mini App start param to an in-app destination on launch.
|
||||
*
|
||||
* Admin ticket notification buttons in GROUP/channel chats open the cabinet via
|
||||
* a `t.me/<bot>/<app>?startapp=admin_ticket_<id>` deep link (bot issue #2988) —
|
||||
* `web_app` buttons don't work in group chats, so the startapp param is the only
|
||||
* way in. Telegram delivers it as `tgWebAppStartParam`; we map it to the admin
|
||||
* ticket route once on mount. Access is still gated by the route's
|
||||
* `PermissionRoute permission="tickets:read"`.
|
||||
*/
|
||||
function StartParamNavigator() {
|
||||
const navigate = useNavigate();
|
||||
const handled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return;
|
||||
handled.current = true;
|
||||
|
||||
let startParam: string | undefined;
|
||||
try {
|
||||
startParam = retrieveLaunchParams().tgWebAppStartParam;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!startParam) return;
|
||||
|
||||
const match = ADMIN_TICKET_START_PARAM_RE.exec(startParam);
|
||||
if (match) {
|
||||
navigate(`/admin/tickets/${match[1]}`, { replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AppWithNavigator() {
|
||||
const isTelegram = isInTelegramWebApp();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{isTelegram && <TelegramBackButton />}
|
||||
{isTelegram && <StartParamNavigator />}
|
||||
<ErrorBoundary level="page">
|
||||
<PlatformProvider>
|
||||
<ThemeColorsProvider>
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface EmailTemplateType {
|
||||
export interface EmailTemplateListResponse {
|
||||
items: EmailTemplateType[];
|
||||
available_languages: string[];
|
||||
/** Placeholders available in every template regardless of type */
|
||||
common_context_vars: string[];
|
||||
}
|
||||
|
||||
export interface EmailTemplateLanguageData {
|
||||
@@ -30,6 +32,8 @@ export interface EmailTemplateDetail {
|
||||
label: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
context_vars: string[];
|
||||
/** Placeholders available in every template regardless of type */
|
||||
common_context_vars?: string[];
|
||||
languages: Record<string, EmailTemplateLanguageData>;
|
||||
}
|
||||
|
||||
@@ -52,6 +56,9 @@ export interface EmailTemplatePreviewResponse {
|
||||
export interface EmailTemplateSendTestRequest {
|
||||
language: string;
|
||||
email?: string;
|
||||
/** Current editor content — when set, the test sends it instead of the saved template */
|
||||
subject?: string;
|
||||
body_html?: string;
|
||||
}
|
||||
|
||||
export const adminEmailTemplatesApi = {
|
||||
|
||||
156
src/api/adminLegalPages.ts
Normal file
156
src/api/adminLegalPages.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import apiClient from './client';
|
||||
import type { InfoPageDisplayMode } from './infoPages';
|
||||
|
||||
export type LegalDisplayMode = InfoPageDisplayMode;
|
||||
|
||||
export interface LegalDocumentItem {
|
||||
language: string;
|
||||
content: string;
|
||||
is_enabled: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface LegalDocumentResponse {
|
||||
display_mode: LegalDisplayMode;
|
||||
display_mode_env_locked: boolean;
|
||||
items: LegalDocumentItem[];
|
||||
}
|
||||
|
||||
export interface LegalDocumentUpdateRequest {
|
||||
display_mode?: LegalDisplayMode;
|
||||
items?: Array<{ language: string; content: string; is_enabled: boolean }>;
|
||||
}
|
||||
|
||||
export interface RulesItem {
|
||||
language: string;
|
||||
content: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRulesResponse {
|
||||
display_mode: LegalDisplayMode;
|
||||
display_mode_env_locked: boolean;
|
||||
items: RulesItem[];
|
||||
}
|
||||
|
||||
export interface RulesUpdateRequest {
|
||||
display_mode?: LegalDisplayMode;
|
||||
items?: Array<{ language: string; content: string }>;
|
||||
}
|
||||
|
||||
export interface FaqSettingItem {
|
||||
language: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FaqPageItem {
|
||||
id: number;
|
||||
language: string;
|
||||
title: string;
|
||||
content: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface FaqResponse {
|
||||
display_mode: LegalDisplayMode;
|
||||
display_mode_env_locked: boolean;
|
||||
settings: FaqSettingItem[];
|
||||
pages: FaqPageItem[];
|
||||
}
|
||||
|
||||
export interface FaqUpdateRequest {
|
||||
display_mode?: LegalDisplayMode;
|
||||
settings?: FaqSettingItem[];
|
||||
}
|
||||
|
||||
export interface FaqPageCreateRequest {
|
||||
language: string;
|
||||
title: string;
|
||||
content: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface FaqPageUpdateRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const adminLegalPagesApi = {
|
||||
getPrivacyPolicy: async (): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.get<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/privacy-policy',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePrivacyPolicy: async (data: LegalDocumentUpdateRequest): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.put<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/privacy-policy',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPublicOffer: async (): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.get<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/public-offer',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePublicOffer: async (data: LegalDocumentUpdateRequest): Promise<LegalDocumentResponse> => {
|
||||
const response = await apiClient.put<LegalDocumentResponse>(
|
||||
'/cabinet/admin/legal-pages/public-offer',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRules: async (): Promise<AdminRulesResponse> => {
|
||||
const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateRules: async (data: RulesUpdateRequest): Promise<AdminRulesResponse> => {
|
||||
const response = await apiClient.put<AdminRulesResponse>(
|
||||
'/cabinet/admin/legal-pages/rules',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getFaq: async (): Promise<FaqResponse> => {
|
||||
const response = await apiClient.get<FaqResponse>('/cabinet/admin/legal-pages/faq');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateFaq: async (data: FaqUpdateRequest): Promise<FaqResponse> => {
|
||||
const response = await apiClient.put<FaqResponse>('/cabinet/admin/legal-pages/faq', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createFaqPage: async (data: FaqPageCreateRequest): Promise<FaqPageItem> => {
|
||||
const response = await apiClient.post<FaqPageItem>(
|
||||
'/cabinet/admin/legal-pages/faq/pages',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateFaqPage: async (id: number, data: FaqPageUpdateRequest): Promise<FaqPageItem> => {
|
||||
const response = await apiClient.put<FaqPageItem>(
|
||||
`/cabinet/admin/legal-pages/faq/pages/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteFaqPage: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/legal-pages/faq/pages/${id}`);
|
||||
},
|
||||
};
|
||||
48
src/api/adminOverpayCertificate.ts
Normal file
48
src/api/adminOverpayCertificate.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const OVERPAY_CERT_MAX_SIZE = 1024 * 1024;
|
||||
|
||||
export interface OverpayCertificateStatus {
|
||||
uploaded: boolean;
|
||||
valid: boolean;
|
||||
path: string;
|
||||
subject: string | null;
|
||||
not_valid_after: string | null;
|
||||
has_chain: boolean | null;
|
||||
env_locked_path: boolean;
|
||||
env_locked_passphrase: boolean;
|
||||
}
|
||||
|
||||
export interface OverpayCertificateUploadResponse {
|
||||
subject: string;
|
||||
not_valid_after: string;
|
||||
has_chain: boolean;
|
||||
path: string;
|
||||
env_locked_path: boolean;
|
||||
env_locked_passphrase: boolean;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export const adminOverpayCertificateApi = {
|
||||
getStatus: async (): Promise<OverpayCertificateStatus> => {
|
||||
const response = await apiClient.get<OverpayCertificateStatus>(
|
||||
'/cabinet/admin/overpay/certificate',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
upload: async (file: File, passphrase: string): Promise<OverpayCertificateUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('passphrase', passphrase);
|
||||
const response = await apiClient.post<OverpayCertificateUploadResponse>(
|
||||
'/cabinet/admin/overpay/certificate',
|
||||
formData,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
remove: async (): Promise<void> => {
|
||||
await apiClient.delete('/cabinet/admin/overpay/certificate');
|
||||
},
|
||||
};
|
||||
@@ -35,6 +35,11 @@ export interface SettingDefinition {
|
||||
original: unknown;
|
||||
has_override: boolean;
|
||||
read_only: boolean;
|
||||
// Secret-bearing key (token/secret/password/key). `current`/`original` come back masked
|
||||
// (••••••••); the UI renders a password input and only sends a value when actually changed.
|
||||
is_secret?: boolean;
|
||||
// Pinned in .env: value shadows the DB and can't be edited here (only viewed).
|
||||
env_locked: boolean;
|
||||
choices: SettingChoice[];
|
||||
hint?: SettingHint | null;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/password/forgot',
|
||||
'/cabinet/auth/password/reset',
|
||||
'/cabinet/auth/oauth/',
|
||||
'/cabinet/auth/merge/',
|
||||
'/cabinet/auth/account/link/server-complete',
|
||||
'/cabinet/auth/deeplink/',
|
||||
'/cabinet/auth/login/auto',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
import { HEALTH } from '../config/constants';
|
||||
|
||||
/**
|
||||
* Backend liveness probe + "service unavailable" detection.
|
||||
@@ -44,9 +45,9 @@ const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
|
||||
* proxy is up but the API upstream is dead, so it counts as NOT reachable.
|
||||
* Resolves false on a transport-level failure with no response.
|
||||
*/
|
||||
export async function pingBackend(): Promise<boolean> {
|
||||
export async function pingBackend(timeoutMs: number = HEALTH.PROBE_TIMEOUT_MS): Promise<boolean> {
|
||||
try {
|
||||
await axios.get(HEALTH_URL, { timeout: 5000 });
|
||||
await axios.get(HEALTH_URL, { timeout: timeoutMs });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
@@ -56,6 +57,22 @@ export async function pingBackend(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a *real* outage before blanking the app. Probes with the tolerant timeout and
|
||||
* retries once: returns true only when the backend is unreachable across all attempts. A
|
||||
* single slow/cold-connection blip on a working backend (common on mobile / in-app webviews)
|
||||
* must not flip the full-screen ServiceUnavailableScreen.
|
||||
*/
|
||||
async function confirmBackendDown(): Promise<boolean> {
|
||||
for (let attempt = 0; attempt <= HEALTH.CONFIRM_RETRIES; attempt++) {
|
||||
if (await pingBackend()) return false;
|
||||
if (attempt < HEALTH.CONFIRM_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, HEALTH.CONFIRM_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let everReachedBackend = false;
|
||||
|
||||
/** Called on the first successful API response: the app reached the backend at
|
||||
@@ -99,9 +116,13 @@ export async function reportPossibleBackendDown(): Promise<void> {
|
||||
if (confirmInFlight) return;
|
||||
confirmInFlight = true;
|
||||
try {
|
||||
const reachable = await pingBackend();
|
||||
if (!reachable) {
|
||||
useBlockingStore.getState().setBackendUnavailable();
|
||||
// Confirm with a tolerant, retried probe so a single cold-connection blip on a working
|
||||
// backend can't blank an already-loaded session.
|
||||
if (await confirmBackendDown()) {
|
||||
// Re-check: a concurrent request may have reached the backend during the probe.
|
||||
if (useBlockingStore.getState().blockingType !== 'backend_unavailable') {
|
||||
useBlockingStore.getState().setBackendUnavailable();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
confirmInFlight = false;
|
||||
@@ -110,17 +131,22 @@ export async function reportPossibleBackendDown(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Eager liveness check fired once at app launch (from main.tsx), in parallel
|
||||
* with auth bootstrap. If the backend is already down at launch this paints the
|
||||
* ServiceUnavailableScreen immediately — before the auth flow can flash the
|
||||
* /login page — even on the no-stored-token path that makes no early request.
|
||||
* No-op if the app has already reached the backend or another blocking screen
|
||||
* is showing.
|
||||
* with auth bootstrap. If the backend is genuinely down at launch this paints the
|
||||
* ServiceUnavailableScreen — before the auth flow can flash the /login page — even
|
||||
* on the no-stored-token path that makes no early request.
|
||||
*
|
||||
* Uses the tolerant, retried confirm probe (NOT a single short-timeout ping): the old
|
||||
* hardcoded 5s probe falsely flagged slow devices / cold mobile connections as
|
||||
* "service unavailable" while the real 30s API requests racing alongside it would have
|
||||
* succeeded — the exact "works on one device, not another on the same Wi-Fi" report.
|
||||
* No-op if the app has already reached the backend or another blocking screen is showing.
|
||||
*/
|
||||
export async function checkBackendOnStartup(): Promise<void> {
|
||||
if (everReachedBackend) return;
|
||||
if (useBlockingStore.getState().blockingType !== null) return;
|
||||
const reachable = await pingBackend();
|
||||
if (!reachable && !everReachedBackend && useBlockingStore.getState().blockingType === null) {
|
||||
useBlockingStore.getState().setBackendUnavailable();
|
||||
if (await confirmBackendDown()) {
|
||||
if (!everReachedBackend && useBlockingStore.getState().blockingType === null) {
|
||||
useBlockingStore.getState().setBackendUnavailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ export interface LanguageInfo {
|
||||
flag: string;
|
||||
}
|
||||
|
||||
export interface InfoVisibility {
|
||||
faq: boolean;
|
||||
rules: boolean;
|
||||
privacy: boolean;
|
||||
offer: boolean;
|
||||
}
|
||||
|
||||
export const infoApi = {
|
||||
// Get FAQ pages list
|
||||
getFaqPages: async (): Promise<FaqPage[]> => {
|
||||
@@ -99,4 +106,9 @@ export const infoApi = {
|
||||
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getVisibility: async (): Promise<InfoVisibility> => {
|
||||
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ export type InfoPageType = 'page' | 'faq';
|
||||
|
||||
export type ReplacesTab = 'faq' | 'rules' | 'privacy' | 'offer';
|
||||
|
||||
export type InfoPageDisplayMode = 'bot' | 'web' | 'both';
|
||||
|
||||
export interface InfoPage {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -14,6 +16,7 @@ export interface InfoPage {
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
replaces_tab: ReplacesTab | null;
|
||||
display_mode: InfoPageDisplayMode;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -27,6 +30,7 @@ export interface InfoPageListItem {
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
replaces_tab: ReplacesTab | null;
|
||||
display_mode: InfoPageDisplayMode;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
@@ -39,6 +43,7 @@ export interface InfoPageCreateRequest {
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
replaces_tab: ReplacesTab | null;
|
||||
display_mode: InfoPageDisplayMode;
|
||||
}
|
||||
|
||||
export interface InfoPageUpdateRequest {
|
||||
@@ -50,6 +55,7 @@ export interface InfoPageUpdateRequest {
|
||||
sort_order?: number;
|
||||
icon?: string | null;
|
||||
replaces_tab?: ReplacesTab | null;
|
||||
display_mode?: InfoPageDisplayMode;
|
||||
}
|
||||
|
||||
export type TabReplacements = Record<ReplacesTab, string | null>;
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{isChunk ? 'Reload' : 'Try again'}
|
||||
</button>
|
||||
|
||||
@@ -118,9 +118,21 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
},
|
||||
});
|
||||
|
||||
// Deactivate discount mutation
|
||||
// Deactivate discount mutation.
|
||||
// A discount granted by a PROMOCODE must be turned off via the dedicated route
|
||||
// (POST /cabinet/promocode/deactivate-discount), which also rolls back the promocode
|
||||
// usage (deletes the PromoCodeUse, decrements current_uses, strips the promo group) so
|
||||
// the user can re-activate it later. The plain clearActiveDiscount route only zeroes
|
||||
// the discount fields and is correct for admin/offer-sourced discounts.
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: promoApi.clearActiveDiscount,
|
||||
mutationFn: async () => {
|
||||
const source = activeDiscount?.source ?? '';
|
||||
if (source.startsWith('promocode:')) {
|
||||
await promoApi.deactivateDiscount();
|
||||
} else {
|
||||
await promoApi.clearActiveDiscount();
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||
@@ -131,8 +143,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
|
||||
// detail may be a plain string (clearActiveDiscount route) or a structured
|
||||
// { code, message } object (deactivate-discount route). Handle both.
|
||||
const axiosErr = error as {
|
||||
response?: { data?: { detail?: string | { message?: string } } };
|
||||
};
|
||||
const detail = axiosErr.response?.data?.detail;
|
||||
const message = typeof detail === 'string' ? detail : detail?.message;
|
||||
setErrorMessage(message || t('promo.deactivate.error'));
|
||||
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ export default function SuccessNotificationModal() {
|
||||
{isSubscription && (
|
||||
<button
|
||||
onClick={handleGoToSubscription}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-on-accent shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
|
||||
>
|
||||
<RocketIcon className="h-8 w-8" />
|
||||
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
|
||||
@@ -301,7 +301,7 @@ export default function SuccessNotificationModal() {
|
||||
{isDevicesPurchased && (
|
||||
<button
|
||||
onClick={handleGoToSubscription}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-on-accent shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600"
|
||||
>
|
||||
<DevicesIcon className="h-8 w-8" />
|
||||
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AnalyticsTab() {
|
||||
<button
|
||||
onClick={handleSaveYandex}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
@@ -253,7 +253,7 @@ export function AnalyticsTab() {
|
||||
<button
|
||||
onClick={handleSaveGoogleId}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
@@ -307,7 +307,7 @@ export function AnalyticsTab() {
|
||||
<button
|
||||
onClick={handleSaveGoogleLabel}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function BackgroundEditor() {
|
||||
'w-full rounded-xl py-3 text-sm font-medium transition-colors',
|
||||
saveStatus === 'saved'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50',
|
||||
: 'bg-accent-500 text-on-accent hover:bg-accent-600 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{saveStatus === 'saving'
|
||||
|
||||
@@ -168,7 +168,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => updateBrandingMutation.mutate(newName)}
|
||||
disabled={updateBrandingMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
|
||||
@@ -228,7 +228,7 @@ export function ButtonsTab() {
|
||||
? 'bg-success-500 text-white'
|
||||
: cfg.style === 'danger'
|
||||
? 'bg-error-500 text-white'
|
||||
: 'bg-accent-500 text-white'
|
||||
: 'bg-accent-500 text-on-accent'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.buttons.styles.${cfg.style}`)}
|
||||
@@ -321,7 +321,7 @@ export function ButtonsTab() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -346,7 +346,7 @@ export function ColoredItemCombobox({
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
|
||||
@@ -72,7 +72,7 @@ function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
|
||||
onClick={() => onChange(n)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-lg text-xs font-semibold transition-all ${
|
||||
value === n
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-600 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
@@ -788,7 +788,7 @@ export function MenuEditorTab() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,9 @@ interface QuickTogglesProps {
|
||||
export function QuickToggles({ settings, onUpdate, disabled, className }: QuickTogglesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const booleanSettings = settings.filter((s) => s.type === 'bool' && !s.read_only);
|
||||
const booleanSettings = settings.filter(
|
||||
(s) => s.type === 'bool' && !s.read_only && !s.env_locked,
|
||||
);
|
||||
|
||||
if (booleanSettings.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -40,7 +40,10 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentValue = String(setting.current ?? '');
|
||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
|
||||
// Secrets are always edited via the single-line (password) input — never a textarea — and
|
||||
// never pre-filled with the masked value, so leaving the field empty means "keep current".
|
||||
const needsTextarea =
|
||||
!setting.is_secret && (isLongValue(currentValue) || isListOrJsonKey(setting.key));
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
@@ -51,11 +54,19 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
}, [value, isEditing]);
|
||||
|
||||
const handleStart = () => {
|
||||
setValue(currentValue);
|
||||
// For secrets, start from an empty field (the displayed value is just the mask) so the
|
||||
// admin types a brand-new value; leaving it empty is treated as "no change".
|
||||
setValue(setting.is_secret ? '' : currentValue);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Empty secret field = the admin opened edit but didn't change anything → keep the stored
|
||||
// secret instead of overwriting it with an empty value.
|
||||
if (setting.is_secret && value === '') {
|
||||
handleCancel();
|
||||
return;
|
||||
}
|
||||
onUpdate(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
@@ -111,7 +122,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('admin.settings.saveButton')}
|
||||
@@ -128,7 +139,14 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={setting.type === 'int' || setting.type === 'float' ? 'number' : 'text'}
|
||||
type={
|
||||
setting.is_secret
|
||||
? 'password'
|
||||
: setting.type === 'int' || setting.type === 'float'
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
autoComplete={setting.is_secret ? 'new-password' : undefined}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -141,7 +159,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-lg bg-accent-500 p-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
title={t('admin.settings.saveHint')}
|
||||
>
|
||||
<CheckIcon />
|
||||
|
||||
@@ -30,6 +30,10 @@ export function SettingRow({
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
|
||||
|
||||
// env-locked keys behave like read-only here: the .env value shadows the DB,
|
||||
// so editing would be silently discarded by the bot. Show the value, no input.
|
||||
const locked = setting.read_only || setting.env_locked;
|
||||
|
||||
// Check if this is a long/complex value
|
||||
const isLongValue = (() => {
|
||||
const val = String(setting.current ?? '');
|
||||
@@ -65,6 +69,15 @@ export function SettingRow({
|
||||
{t('admin.settings.readOnly')}
|
||||
</span>
|
||||
)}
|
||||
{setting.env_locked && !setting.read_only && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400"
|
||||
title={t('admin.settings.envLockedHint')}
|
||||
>
|
||||
<LockIcon />
|
||||
{t('admin.settings.envLocked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
|
||||
@@ -100,10 +113,17 @@ export function SettingRow({
|
||||
<div
|
||||
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
|
||||
>
|
||||
{setting.read_only ? (
|
||||
// Read-only display
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||
{locked ? (
|
||||
// Read-only / env-locked display
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||
</div>
|
||||
{setting.env_locked && !setting.read_only && (
|
||||
<p className="text-xs leading-relaxed text-dark-500">
|
||||
{t('admin.settings.envLockedHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : setting.type === 'bool' ? (
|
||||
// Boolean toggle
|
||||
@@ -158,7 +178,7 @@ export function SettingRow({
|
||||
</div>
|
||||
|
||||
{/* Reset button for long values - shown below */}
|
||||
{isLongValue && setting.has_override && !setting.read_only && setting.type !== 'bool' && (
|
||||
{isLongValue && setting.has_override && !locked && setting.type !== 'bool' && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={onReset}
|
||||
|
||||
@@ -37,6 +37,8 @@ export function SettingsTableRow({
|
||||
const isModified = setting.has_override;
|
||||
const isBool = setting.type === 'bool';
|
||||
const boolChecked = setting.current === true || setting.current === 'true';
|
||||
// env-locked keys are pinned in .env and shadow the DB — show value, no input.
|
||||
const locked = setting.read_only || setting.env_locked;
|
||||
|
||||
const isLongValue = (() => {
|
||||
const val = String(setting.current ?? '');
|
||||
@@ -84,14 +86,17 @@ export function SettingsTableRow({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{setting.has_override && !setting.read_only && (
|
||||
{setting.has_override && !locked && (
|
||||
<span className="rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-sky-400">
|
||||
{t('admin.settings.badgeDb')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{setting.read_only && (
|
||||
<span className="flex items-center gap-0.5 rounded-full bg-warning-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400">
|
||||
{setting.env_locked && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full bg-warning-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400"
|
||||
title={t('admin.settings.envLockedHint')}
|
||||
>
|
||||
{t('admin.settings.badgeEnv')}
|
||||
<LockIcon className="h-3 w-3" />
|
||||
</span>
|
||||
@@ -116,7 +121,7 @@ export function SettingsTableRow({
|
||||
isLongValue ? 'w-full' : 'max-lg:self-end lg:flex-shrink-0',
|
||||
)}
|
||||
>
|
||||
{setting.read_only ? (
|
||||
{locked ? (
|
||||
<span className="max-w-[240px] truncate rounded bg-dark-700/30 px-3 py-1.5 font-mono text-xs text-dark-400">
|
||||
{isBool
|
||||
? boolChecked
|
||||
@@ -138,7 +143,7 @@ export function SettingsTableRow({
|
||||
)}
|
||||
|
||||
{/* Reset button -- hover-reveal when has_override */}
|
||||
{isModified && !setting.read_only && (
|
||||
{isModified && !locked && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function SortableSelectedMethodCard({
|
||||
className={cn(
|
||||
'flex h-3.5 w-3.5 items-center justify-center rounded',
|
||||
enabled
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'border border-dark-600 bg-dark-700',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -428,7 +428,7 @@ export function ThemeTab() {
|
||||
<button
|
||||
onClick={() => updateColorsMutation.mutate(draftColors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateColorsMutation.isPending
|
||||
? t('common.saving', t('common.save'))
|
||||
|
||||
@@ -621,7 +621,7 @@ export function ActionModal({
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
|
||||
className="w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
@@ -647,7 +647,7 @@ export function ActionModal({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isConfirmDisabled}
|
||||
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.bulkActions.confirm')}
|
||||
</button>
|
||||
|
||||
@@ -208,7 +208,7 @@ export function FloatingActionBar({
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 rounded-xl bg-accent-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center gap-2 rounded-xl bg-accent-500 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.actions')}
|
||||
<ChevronDownIcon />
|
||||
|
||||
@@ -52,7 +52,7 @@ export function CountryFilter({
|
||||
>
|
||||
<GlobeIcon className="h-4 w-4" />
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-on-accent">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function NodeFilter({
|
||||
<ServerIcon className="h-4 w-4" />
|
||||
{t('admin.trafficUsage.nodes')}
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-on-accent">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function PeriodSelector({
|
||||
onClick={() => onChange(p)}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
value === p
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function StatusFilter({
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
{t('admin.trafficUsage.status')}
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-on-accent">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TariffFilter({
|
||||
<FilterIcon className="h-4 w-4" />
|
||||
{t('admin.trafficUsage.tariff')}
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-on-accent">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SendIcon } from '@/components/icons';
|
||||
import { GiftIcon, SendIcon } from '@/components/icons';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import { useCurrency } from '../../../hooks/useCurrency';
|
||||
import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers';
|
||||
|
||||
@@ -208,18 +209,18 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }:
|
||||
<div className="space-y-6">
|
||||
{/* Summary counters */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.gifts.totalSent')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{giftsData.sent_total}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.gifts.totalReceived')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-success-400">{giftsData.received_total}</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.users.detail.gifts.totalSent')}
|
||||
value={giftsData.sent_total}
|
||||
icon={<GiftIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.users.detail.gifts.totalReceived')}
|
||||
value={giftsData.received_total}
|
||||
icon={<GiftIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sent Gifts */}
|
||||
|
||||
@@ -378,7 +378,7 @@ export function InfoTab(props: InfoTabProps) {
|
||||
<button
|
||||
onClick={onUpdateReferralCommission}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-accent-500 px-2 py-1 text-xs text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="w-full rounded-lg bg-accent-500 px-2 py-1 text-xs text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
@@ -475,7 +475,7 @@ export function InfoTab(props: InfoTabProps) {
|
||||
disabled={actionLoading}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
confirmingAction === 'resetTrial'
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-accent-500/15 text-accent-400 hover:bg-accent-500/25'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useNavigate } from 'react-router';
|
||||
import { useCurrency } from '../../../hooks/useCurrency';
|
||||
import { useNotify } from '../../../platform/hooks/useNotify';
|
||||
import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers';
|
||||
import { XIcon } from '@/components/icons';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import { BanknotesIcon, PercentIcon, TagIcon, UsersIcon, XIcon } from '@/components/icons';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Referrals tab — top-of-graph referrer + stats + referrals list,
|
||||
@@ -327,40 +328,35 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
|
||||
|
||||
{/* Section 2: Referral stats */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referrals.totalReferrals')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referrals.totalEarnings')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referrals.commission')}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-bold text-dark-100">
|
||||
{user.referral.commission_percent != null
|
||||
<StatCard
|
||||
label={t('admin.users.detail.referrals.totalReferrals')}
|
||||
value={user.referral.referrals_count}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.users.detail.referrals.totalEarnings')}
|
||||
value={formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.users.detail.referrals.commission')}
|
||||
value={
|
||||
user.referral.commission_percent != null
|
||||
? `${user.referral.commission_percent}%`
|
||||
: t('admin.users.detail.referrals.default')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/40 p-4">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referrals.referralCode')}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-sm text-dark-100">
|
||||
{user.referral.referral_code}
|
||||
</div>
|
||||
</div>
|
||||
: t('admin.users.detail.referrals.default')
|
||||
}
|
||||
icon={<PercentIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.users.detail.referrals.referralCode')}
|
||||
value={user.referral.referral_code}
|
||||
icon={<TagIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
valueClassName="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Referrals list */}
|
||||
|
||||
@@ -464,7 +464,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
<button
|
||||
onClick={() => selectedTrafficGb && onAddTraffic(Number(selectedTrafficGb))}
|
||||
disabled={actionLoading || !selectedTrafficGb}
|
||||
className="shrink-0 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="shrink-0 rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.users.detail.subscription.addButton')}
|
||||
</button>
|
||||
|
||||
@@ -348,7 +348,7 @@ function ChatView({
|
||||
onClick={onReply}
|
||||
disabled={!replyText.trim() || replySending}
|
||||
aria-label={t('admin.tickets.sendReply', 'Send reply')}
|
||||
className="min-h-[44px] min-w-[44px] shrink-0 self-end rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
className="min-h-[44px] min-w-[44px] shrink-0 self-end rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50 sm:min-h-0 sm:min-w-0"
|
||||
>
|
||||
{replySending ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
|
||||
@@ -20,6 +20,10 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
|
||||
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
|
||||
if (typeof reduced.particleDensity === 'number')
|
||||
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
||||
if (typeof reduced.density === 'number')
|
||||
reduced.density = Math.max(20, Math.floor(reduced.density / 2));
|
||||
if (typeof reduced.starCount === 'number')
|
||||
reduced.starCount = Math.max(50, Math.floor(reduced.starCount / 4));
|
||||
if (typeof reduced.number === 'number')
|
||||
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
||||
if ('interactive' in reduced) reduced.interactive = false;
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import { pingBackend, hasEverReachedBackend } from '../../api/health';
|
||||
import { HEALTH } from '../../config/constants';
|
||||
import { CloudWarningIcon, RestartIcon } from '@/components/icons';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { cn } from '@/lib/utils';
|
||||
import BlockingShell from './BlockingShell';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Full-screen state shown when the backend is unreachable (transport-level
|
||||
* failure), replacing the blank loader the app used to get stuck on. Polls the
|
||||
@@ -54,20 +53,24 @@ export default function ServiceUnavailableScreen() {
|
||||
}
|
||||
}, [recover]);
|
||||
|
||||
// Auto-recovery: probe immediately on mount, then every POLL_INTERVAL_MS.
|
||||
// Auto-recovery: probe on mount, then re-schedule after each result resolves. Self-chaining
|
||||
// (rather than a fixed setInterval) prevents the now-longer tolerant probe from overlapping
|
||||
// with itself.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
if (await pingBackend()) {
|
||||
if (!cancelled) recover();
|
||||
return;
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(tick, HEALTH.RECOVERY_POLL_INTERVAL_MS);
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [recover]);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function PendingGiftCard({ gifts, className }: PendingGiftCardPro
|
||||
{/* Activate button */}
|
||||
<Link
|
||||
to={`/gift?tab=activate&code=${gift.token}`}
|
||||
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-400"
|
||||
>
|
||||
{t('gift.pending.activate')}
|
||||
</Link>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PiCaretRight } from 'react-icons/pi';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import { CardIcon, ChevronRightIcon, UsersIcon } from '@/components/icons';
|
||||
|
||||
interface StatsGridProps {
|
||||
balanceRubles: number;
|
||||
@@ -12,10 +11,6 @@ interface StatsGridProps {
|
||||
refLoading: boolean;
|
||||
}
|
||||
|
||||
const ChevronIcon = ({ color }: { color: string }) => (
|
||||
<PiCaretRight width={16} height={16} style={{ flexShrink: 0, color }} aria-hidden="true" />
|
||||
);
|
||||
|
||||
export default function StatsGrid({
|
||||
balanceRubles,
|
||||
referralCount,
|
||||
@@ -24,122 +19,31 @@ export default function StatsGrid({
|
||||
}: StatsGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
const accentColor = 'rgb(var(--color-accent-400))';
|
||||
const accentBg = 'rgba(var(--color-accent-400), 0.07)';
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: t('dashboard.stats.balance'),
|
||||
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
|
||||
valueColor: accentColor,
|
||||
to: '/balance',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
<path d="M6 14h.01M10 14h.01" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: accentBg,
|
||||
iconColor: accentColor,
|
||||
loading: false,
|
||||
onboarding: 'balance',
|
||||
},
|
||||
{
|
||||
label: t('dashboard.stats.referrals'),
|
||||
value: `${referralCount}`,
|
||||
valueColor: g.text,
|
||||
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
|
||||
subtitleColor: accentColor,
|
||||
to: '/referral',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: g.trackBg,
|
||||
iconColor: g.textSecondary,
|
||||
loading: refLoading,
|
||||
},
|
||||
];
|
||||
const chevron = <ChevronRightIcon className="h-4 w-4 shrink-0 text-dark-500" />;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{cards.map((card, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={card.to}
|
||||
className="group relative overflow-hidden rounded-[18px] transition-all duration-200"
|
||||
style={{
|
||||
background: g.cardBg,
|
||||
border: `1px solid ${g.cardBorder}`,
|
||||
boxShadow: g.shadow,
|
||||
padding: '18px 20px 20px',
|
||||
}}
|
||||
data-onboarding={card.onboarding}
|
||||
>
|
||||
{/* Top row: icon + label + arrow */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[9px] transition-colors duration-500"
|
||||
style={{ background: card.iconBg }}
|
||||
>
|
||||
{card.icon(card.iconColor)}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-dark-50/45">{card.label}</span>
|
||||
</div>
|
||||
<ChevronIcon color={g.textFaint} />
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
{card.loading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="text-[28px] font-bold leading-tight tracking-tight transition-colors duration-500"
|
||||
style={{ color: card.valueColor }}
|
||||
>
|
||||
{card.value}
|
||||
</div>
|
||||
{card.subtitle && (
|
||||
<div
|
||||
className="mt-0.5 text-[13px] font-semibold"
|
||||
style={{ color: card.subtitleColor }}
|
||||
>
|
||||
{card.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/balance" className="block h-full" data-onboarding="balance">
|
||||
<StatCard
|
||||
label={t('dashboard.stats.balance')}
|
||||
value={`${formatAmount(balanceRubles)} ${currencySymbol}`}
|
||||
icon={<CardIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
trailing={chevron}
|
||||
/>
|
||||
</Link>
|
||||
<Link to="/referral" className="block h-full">
|
||||
<StatCard
|
||||
label={t('dashboard.stats.referrals')}
|
||||
value={`${referralCount}`}
|
||||
subValue={`+${formatAmount(earningsRubles)} ${currencySymbol}`}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
loading={refLoading}
|
||||
trailing={chevron}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function MobileBottomNav({
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'relative flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 transition-all duration-200',
|
||||
isActive(item.path) ? 'text-accent-400' : 'text-dark-500 hover:text-dark-300',
|
||||
isActive(item.path) ? 'text-accent-400' : 'text-dark-400 hover:text-dark-200',
|
||||
)}
|
||||
>
|
||||
{isActive(item.path) && (
|
||||
|
||||
@@ -13,7 +13,7 @@ export const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
'bg-accent-500 text-white',
|
||||
'bg-accent-500 text-on-accent',
|
||||
'hover:bg-accent-600',
|
||||
'active:bg-accent-700',
|
||||
'shadow-linear-sm hover:shadow-linear',
|
||||
|
||||
@@ -24,6 +24,12 @@ interface StatCardProps {
|
||||
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
|
||||
tone?: keyof typeof TONE;
|
||||
valueClassName?: string;
|
||||
/** Optional secondary line shown under the value (e.g. a subtitle or context). */
|
||||
subValue?: string;
|
||||
/** When true, shows a skeleton placeholder instead of the value. */
|
||||
loading?: boolean;
|
||||
/** Optional node rendered at the right edge of the label row (e.g. a chevron for nav cards). */
|
||||
trailing?: ReactNode;
|
||||
/** Optional period-over-period change shown under the value. */
|
||||
delta?: StatCardDelta | null;
|
||||
}
|
||||
@@ -34,6 +40,9 @@ export function StatCard({
|
||||
icon,
|
||||
tone = 'neutral',
|
||||
valueClassName,
|
||||
subValue,
|
||||
loading,
|
||||
trailing,
|
||||
delta,
|
||||
}: StatCardProps) {
|
||||
const toneStyle = TONE[tone];
|
||||
@@ -41,20 +50,33 @@ export function StatCard({
|
||||
const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
|
||||
<div className="truncate text-xs text-dark-500 sm:text-sm">{label}</div>
|
||||
<div className="h-full rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-2 text-xs leading-tight text-dark-500 sm:text-sm">{label}</span>
|
||||
{trailing}
|
||||
</div>
|
||||
{/* Chip is centred against the value line only (delta sits below the whole
|
||||
row), so the icon lands in the same spot on every card. */}
|
||||
row), so the icon lands in the same spot on every card. The forced svg
|
||||
size normalises every icon regardless of what the call site passes. */}
|
||||
<div className="mt-1.5 flex items-center gap-2.5">
|
||||
{icon && (
|
||||
<span
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneStyle.chip}`}
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&>svg]:h-5 [&>svg]:w-5 ${toneStyle.chip}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className={`min-w-0 flex-1 truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
|
||||
{value}
|
||||
<div className="min-w-0 flex-1">
|
||||
{loading ? (
|
||||
<div className="skeleton h-7 w-20 rounded" />
|
||||
) : (
|
||||
<>
|
||||
<div className={`truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
{subValue && <div className="truncate text-xs text-dark-500">{subValue}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{trendStyle && (
|
||||
|
||||
@@ -107,7 +107,7 @@ export function TariffPickerGrid({
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/subscriptions')}
|
||||
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('subscription.backToList', 'Мои подписки')}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeBoolean } from './types';
|
||||
import { sanitizeColor, safeBoolean, safeSelect } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
@@ -8,7 +8,17 @@ interface Props {
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
const SPEED_DURATIONS: Record<string, string> = {
|
||||
slow: '90s',
|
||||
normal: '60s',
|
||||
fast: '30s',
|
||||
};
|
||||
|
||||
export default function AuroraBackground({ settings }: Props) {
|
||||
const firstColor = sanitizeColor(settings.firstColor, '#3b82f6');
|
||||
const secondColor = sanitizeColor(settings.secondColor, '#a5b4fc');
|
||||
const thirdColor = sanitizeColor(settings.thirdColor, '#93c5fd');
|
||||
const speed = safeSelect(settings.speed, ['slow', 'normal', 'fast'] as const, 'normal');
|
||||
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
@@ -22,9 +32,9 @@ export default function AuroraBackground({ settings }: Props) {
|
||||
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, #3b82f6 10%, #a5b4fc 15%, #93c5fd 20%, #ddd6fe 25%, #60a5fa 30%)',
|
||||
backgroundImage: `repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, ${firstColor} 10%, ${secondColor} 15%, ${thirdColor} 20%, ${secondColor} 25%, ${firstColor} 30%)`,
|
||||
backgroundSize: isMobile ? '100%, 100%' : '300%, 200%',
|
||||
animationDuration: SPEED_DURATIONS[speed],
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeColor } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +27,11 @@ const BEAMS: BeamOptions[] = [
|
||||
{ initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' },
|
||||
];
|
||||
|
||||
function Explosion(props: React.HTMLProps<HTMLDivElement>) {
|
||||
function Explosion({
|
||||
beamColor,
|
||||
explosionColor,
|
||||
...props
|
||||
}: React.HTMLProps<HTMLDivElement> & { beamColor: string; explosionColor: string }) {
|
||||
const spans = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
directionX: Math.floor(Math.random() * 80 - 40),
|
||||
@@ -40,7 +45,10 @@ function Explosion(props: React.HTMLProps<HTMLDivElement>) {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.5, ease: 'easeOut' }}
|
||||
className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full bg-gradient-to-r from-transparent via-indigo-500 to-transparent blur-sm"
|
||||
className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full blur-sm"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${explosionColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
{spans.map((span) => (
|
||||
<motion.span
|
||||
@@ -48,7 +56,10 @@ function Explosion(props: React.HTMLProps<HTMLDivElement>) {
|
||||
initial={{ x: 0, y: 0, opacity: 1 }}
|
||||
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
|
||||
transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }}
|
||||
className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-indigo-500 to-purple-500"
|
||||
className="absolute h-1 w-1 rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${beamColor}, ${explosionColor})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -59,10 +70,14 @@ function CollisionMechanism({
|
||||
containerRef,
|
||||
parentRef,
|
||||
beamOptions,
|
||||
beamColor,
|
||||
explosionColor,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
parentRef: React.RefObject<HTMLDivElement | null>;
|
||||
beamOptions: BeamOptions;
|
||||
beamColor: string;
|
||||
explosionColor: string;
|
||||
}) {
|
||||
const beamRef = useRef<HTMLDivElement>(null);
|
||||
const [collision, setCollision] = useState<{
|
||||
@@ -92,8 +107,6 @@ function CollisionMechanism({
|
||||
}
|
||||
}, [containerRef, parentRef]);
|
||||
|
||||
// Throttled collision detection loop.
|
||||
// Parent unmounts this component when paused, so no visibility handling needed here.
|
||||
useEffect(() => {
|
||||
let animId = 0;
|
||||
let lastCheck = 0;
|
||||
@@ -114,7 +127,6 @@ function CollisionMechanism({
|
||||
};
|
||||
}, [checkCollision]);
|
||||
|
||||
// Collision reset with proper timeout cleanup
|
||||
useEffect(() => {
|
||||
if (!collision.detected || !collision.coordinates) return;
|
||||
|
||||
@@ -154,14 +166,19 @@ function CollisionMechanism({
|
||||
repeatDelay: beamOptions.repeatDelay,
|
||||
}}
|
||||
className={cn(
|
||||
'absolute left-0 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-indigo-500 via-purple-500 to-transparent',
|
||||
'absolute left-0 top-20 m-auto h-14 w-px rounded-full',
|
||||
beamOptions.className,
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${beamColor}, ${explosionColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{collision.detected && collision.coordinates && (
|
||||
<Explosion
|
||||
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
||||
beamColor={beamColor}
|
||||
explosionColor={explosionColor}
|
||||
style={{
|
||||
left: `${collision.coordinates.x}px`,
|
||||
top: `${collision.coordinates.y}px`,
|
||||
@@ -174,11 +191,14 @@ function CollisionMechanism({
|
||||
);
|
||||
}
|
||||
|
||||
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||
export default function BackgroundBeamsCollision({ settings }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const beamColor = sanitizeColor(settings.beamColor, '#6366f1');
|
||||
const explosionColor = sanitizeColor(settings.explosionColor, '#a855f7');
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||
{!paused &&
|
||||
@@ -188,9 +208,10 @@ export default function BackgroundBeamsCollision({ settings: _settings }: Props)
|
||||
beamOptions={beam}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
beamColor={beamColor}
|
||||
explosionColor={explosionColor}
|
||||
/>
|
||||
))}
|
||||
{/* Bottom collision line */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 w-full"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { sanitizeColor } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
@@ -99,10 +100,17 @@ function ensureStyles() {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||
export default React.memo(function BackgroundBeams({ settings }: Props) {
|
||||
const paused = useAnimationPause();
|
||||
const uid = React.useId();
|
||||
const beamGradientId = `beamGradient-${uid}`;
|
||||
const radialGradientId = `beamsRadial-${uid}`;
|
||||
|
||||
const gradientStart = sanitizeColor(settings.gradientStart, '#18CCFC');
|
||||
const gradientMid = sanitizeColor(settings.gradientMid, '#6344F5');
|
||||
const gradientEnd = sanitizeColor(settings.gradientEnd, '#AE48FF');
|
||||
const staticColor = sanitizeColor(settings.staticColor, '#d4d4d4');
|
||||
|
||||
// Inject CSS once on first render
|
||||
React.useEffect(() => {
|
||||
ensureStyles();
|
||||
}, []);
|
||||
@@ -117,20 +125,18 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Static background — all paths at very low opacity */}
|
||||
<path
|
||||
d={bgPath}
|
||||
stroke="url(#paint0_radial_beams)"
|
||||
stroke={`url(#${radialGradientId})`}
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */}
|
||||
{animatedPaths.map(({ path, paramIndex }) => (
|
||||
<path
|
||||
key={paramIndex}
|
||||
d={path}
|
||||
stroke="url(#beamGradient)"
|
||||
stroke={`url(#${beamGradientId})`}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
pathLength={1}
|
||||
@@ -144,26 +150,24 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
||||
))}
|
||||
|
||||
<defs>
|
||||
{/* Single shared gradient for all beams (cyan → purple → magenta) */}
|
||||
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop stopColor="#18CCFC" stopOpacity="0" />
|
||||
<stop offset="10%" stopColor="#18CCFC" />
|
||||
<stop offset="50%" stopColor="#6344F5" />
|
||||
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
|
||||
<linearGradient id={beamGradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop stopColor={gradientStart} stopOpacity="0" />
|
||||
<stop offset="10%" stopColor={gradientStart} />
|
||||
<stop offset="50%" stopColor={gradientMid} />
|
||||
<stop offset="100%" stopColor={gradientEnd} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Radial gradient for static background */}
|
||||
<radialGradient
|
||||
id="paint0_radial_beams"
|
||||
id={radialGradientId}
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
|
||||
>
|
||||
<stop offset="0.0666667" stopColor="#d4d4d4" />
|
||||
<stop offset="0.243243" stopColor="#d4d4d4" />
|
||||
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="0.0666667" stopColor={staticColor} />
|
||||
<stop offset="0.243243" stopColor={staticColor} />
|
||||
<stop offset="0.43594" stopColor={staticColor} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { sanitizeColor, clampNumber, safeBoolean } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
@@ -18,7 +18,14 @@ const COLORS = [
|
||||
];
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const v = parseInt(hex.slice(1), 16);
|
||||
let value = hex.replace('#', '');
|
||||
if (value.length === 3)
|
||||
value = value
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(value)) return [129, 140, 248];
|
||||
const v = parseInt(value, 16);
|
||||
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
|
||||
}
|
||||
|
||||
@@ -44,16 +51,19 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||
const lineColor = sanitizeColor(settings.lineColor, '#334155');
|
||||
const multicolor =
|
||||
settings.multicolor === undefined
|
||||
? boxColor === '#818cf8'
|
||||
: safeBoolean(settings.multicolor, true);
|
||||
|
||||
const cells = useMemo((): CellData[] => {
|
||||
return Array.from({ length: rows * cols }, () => ({
|
||||
rgb: hexToRgb(
|
||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||
),
|
||||
rgb: hexToRgb(multicolor ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor),
|
||||
phase: Math.random() * 8,
|
||||
period: 3 + Math.random() * 4,
|
||||
}));
|
||||
}, [rows, cols, boxColor]);
|
||||
}, [rows, cols, boxColor, multicolor]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -124,7 +134,8 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -142,7 +153,7 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
[cells, rows, cols],
|
||||
[cells, rows, cols, lineColor],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||
const pointerColor = hexToRgbString(sanitizeColor(settings.pointerColor, '#8C64FF'));
|
||||
const interactive = safeBoolean(settings.interactive, true);
|
||||
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
|
||||
|
||||
@@ -172,7 +173,7 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
ref={interactiveRef}
|
||||
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, rgba(140, 100, 255, 0.8) 0, rgba(140, 100, 255, 0) 50%) no-repeat`,
|
||||
background: `radial-gradient(circle at center, rgba(${pointerColor}, 0.8) 0, rgba(${pointerColor}, 0) 50%) no-repeat`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
146
src/components/ui/backgrounds/constellation.tsx
Normal file
146
src/components/ui/backgrounds/constellation.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface ConstellationState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
particles: Particle[];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function ConstellationBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<ConstellationState | null>(null);
|
||||
|
||||
const particleColor = sanitizeColor(settings.particleColor, '#818cf8');
|
||||
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||
const count = clampNumber(settings.count, 10, 200, 60);
|
||||
const linkDistance = clampNumber(settings.linkDistance, 40, 300, 120);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const particles: Particle[] = Array.from({ length: Math.floor(count) }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
}));
|
||||
|
||||
stateRef.current = { ctx, particles, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
const state = stateRef.current;
|
||||
if (state) {
|
||||
state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (state.w > 0 && state.h > 0) {
|
||||
for (const p of state.particles) {
|
||||
p.x *= nw / state.w;
|
||||
p.y *= nh / state.h;
|
||||
}
|
||||
}
|
||||
state.w = nw;
|
||||
state.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [count]);
|
||||
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, particles, w, h } = state;
|
||||
const linkDistanceSq = linkDistance * linkDistance;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
for (const p of particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
if (p.x < 0) {
|
||||
p.x = 0;
|
||||
p.vx = Math.abs(p.vx);
|
||||
} else if (p.x > w) {
|
||||
p.x = w;
|
||||
p.vx = -Math.abs(p.vx);
|
||||
}
|
||||
if (p.y < 0) {
|
||||
p.y = 0;
|
||||
p.vy = Math.abs(p.vy);
|
||||
} else if (p.y > h) {
|
||||
p.y = h;
|
||||
p.vy = -Math.abs(p.vy);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = 0.8;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq < linkDistanceSq) {
|
||||
const dist = Math.sqrt(distSq);
|
||||
ctx.globalAlpha = (1 - dist / linkDistance) * 0.35;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = particleColor;
|
||||
|
||||
for (const p of particles) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}, [particleColor, lineColor, count, linkDistance]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
122
src/components/ui/backgrounds/fireflies.tsx
Normal file
122
src/components/ui/backgrounds/fireflies.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Firefly {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
phase: number;
|
||||
pulseSpeed: number;
|
||||
}
|
||||
|
||||
interface FirefliesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
fireflies: Firefly[];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function FirefliesBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<FirefliesState | null>(null);
|
||||
|
||||
const color = sanitizeColor(settings.color, '#ffd166');
|
||||
const count = clampNumber(settings.count, 5, 200, 40);
|
||||
const speed = clampNumber(settings.speed, 0.1, 3, 1);
|
||||
const size = clampNumber(settings.size, 0.5, 6, 2);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const fireflies: Firefly[] = Array.from({ length: Math.floor(count) }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.6,
|
||||
vy: (Math.random() - 0.5) * 0.6,
|
||||
radius: 0.5 + Math.random() * 0.5,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.5 + Math.random() * 1.5,
|
||||
}));
|
||||
|
||||
stateRef.current = { ctx, fireflies, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [count]);
|
||||
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, fireflies, w, h } = state;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = color;
|
||||
|
||||
for (const f of fireflies) {
|
||||
f.x += f.vx * speed + Math.sin(time / 2000 + f.phase) * 0.3 * speed;
|
||||
f.y += f.vy * speed + Math.cos(time / 2400 + f.phase) * 0.2 * speed;
|
||||
|
||||
if (f.x < -20) f.x = w + 20;
|
||||
if (f.x > w + 20) f.x = -20;
|
||||
if (f.y < -20) f.y = h + 20;
|
||||
if (f.y > h + 20) f.y = -20;
|
||||
|
||||
const pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin((time / 1000) * f.pulseSpeed + f.phase));
|
||||
const radius = f.radius * size;
|
||||
|
||||
ctx.globalAlpha = pulse * 0.25;
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, radius * 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = pulse;
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[color, count, speed, size],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
59
src/components/ui/backgrounds/liquid-gradient.tsx
Normal file
59
src/components/ui/backgrounds/liquid-gradient.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
const BLOBS = [
|
||||
{ anim: 'animate-move-vertical', baseDuration: 30, top: '5%', left: '10%' },
|
||||
{ anim: 'animate-move-in-circle', baseDuration: 20, top: '35%', left: '50%' },
|
||||
{ anim: 'animate-move-horizontal', baseDuration: 40, top: '55%', left: '15%' },
|
||||
{ anim: 'animate-move-in-circle-slow', baseDuration: 40, top: '15%', left: '55%' },
|
||||
];
|
||||
|
||||
const SPEED_MULTIPLIERS: Record<string, number> = {
|
||||
slow: 1.6,
|
||||
normal: 1,
|
||||
fast: 0.5,
|
||||
};
|
||||
|
||||
export default function LiquidGradientBackground({ settings }: Props) {
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const colors = [
|
||||
sanitizeColor(settings.color1, '#6366f1'),
|
||||
sanitizeColor(settings.color2, '#ec4899'),
|
||||
sanitizeColor(settings.color3, '#22d3ee'),
|
||||
sanitizeColor(settings.color4, '#a855f7'),
|
||||
];
|
||||
const speed = safeSelect(settings.speed, ['slow', 'normal', 'fast'] as const, 'normal');
|
||||
const blurAmount = clampNumber(settings.blurAmount, 10, 120, 60);
|
||||
|
||||
const multiplier = SPEED_MULTIPLIERS[speed];
|
||||
const effectiveBlur = isMobile ? Math.min(blurAmount, 30) : blurAmount;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ filter: `blur(${effectiveBlur}px)` }}>
|
||||
{BLOBS.map((blob, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('absolute h-[55%] w-[55%] rounded-full', blob.anim)}
|
||||
style={{
|
||||
top: blob.top,
|
||||
left: blob.left,
|
||||
opacity: 0.7,
|
||||
background: `radial-gradient(circle at center, ${colors[i]} 0%, transparent 65%)`,
|
||||
animationDuration: `${blob.baseDuration * multiplier}s`,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/ui/backgrounds/matrix-rain.tsx
Normal file
128
src/components/ui/backgrounds/matrix-rain.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const FONT_SIZE = 16;
|
||||
const BASE_STEP_MS = 80;
|
||||
|
||||
const CHARSETS: Record<string, string> = {
|
||||
katakana:
|
||||
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンガギグゲゴザジズゼゾダヂヅデドバビブベボ',
|
||||
latin: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
||||
binary: '01',
|
||||
};
|
||||
|
||||
interface MatrixState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
drops: number[];
|
||||
active: boolean[];
|
||||
acc: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function MatrixRainBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<MatrixState | null>(null);
|
||||
|
||||
const color = sanitizeColor(settings.color, '#00ff41');
|
||||
const density = clampNumber(settings.density, 10, 100, 70);
|
||||
const speed = clampNumber(settings.speed, 0.2, 3, 1);
|
||||
const charset = safeSelect(
|
||||
settings.charset,
|
||||
['katakana', 'latin', 'binary'] as const,
|
||||
'katakana',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const initColumns = (width: number, height: number) => {
|
||||
const cols = Math.max(1, Math.floor(width / FONT_SIZE));
|
||||
return {
|
||||
drops: Array.from({ length: cols }, () => Math.floor(Math.random() * (height / FONT_SIZE))),
|
||||
active: Array.from({ length: cols }, () => Math.random() * 100 < density),
|
||||
};
|
||||
};
|
||||
|
||||
const { drops, active } = initColumns(w, h);
|
||||
stateRef.current = { ctx, drops, active, acc: 0, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const next = initColumns(nw, nh);
|
||||
stateRef.current.drops = next.drops;
|
||||
stateRef.current.active = next.active;
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [density]);
|
||||
|
||||
useAnimationLoop(
|
||||
(_time, delta) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, drops, active, w, h } = state;
|
||||
const chars = CHARSETS[charset];
|
||||
|
||||
state.acc += delta;
|
||||
const stepMs = BASE_STEP_MS / speed;
|
||||
if (state.acc < stepMs) return;
|
||||
state.acc %= stepMs;
|
||||
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.12)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${FONT_SIZE}px monospace`;
|
||||
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
if (!active[i]) continue;
|
||||
const char = chars[Math.floor(Math.random() * chars.length)];
|
||||
ctx.fillText(char, i * FONT_SIZE, drops[i] * FONT_SIZE);
|
||||
|
||||
if (drops[i] * FONT_SIZE > h && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
active[i] = Math.random() * 100 < density;
|
||||
}
|
||||
drops[i]++;
|
||||
}
|
||||
},
|
||||
[color, density, speed, charset],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default function Meteors({ settings }: Props) {
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
width: meteor.size,
|
||||
height: meteor.size,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
|
||||
boxShadow: `0 0 0 1px ${meteorColor}0d, 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
|
||||
background: meteorColor,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,12 @@ const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<u
|
||||
dots: () => import('./grid-background'),
|
||||
spotlight: () => import('./spotlight-bg'),
|
||||
ripple: () => import('./background-ripple'),
|
||||
fireflies: () => import('./fireflies'),
|
||||
snowfall: () => import('./snowfall'),
|
||||
starfield: () => import('./starfield'),
|
||||
'matrix-rain': () => import('./matrix-rain'),
|
||||
'liquid-gradient': () => import('./liquid-gradient'),
|
||||
constellation: () => import('./constellation'),
|
||||
};
|
||||
|
||||
/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */
|
||||
@@ -47,6 +53,12 @@ export const backgroundComponents: Record<
|
||||
dots: lazy(() => import('./grid-background')),
|
||||
spotlight: lazy(() => import('./spotlight-bg')),
|
||||
ripple: lazy(() => import('./background-ripple')),
|
||||
fireflies: lazy(() => import('./fireflies')),
|
||||
snowfall: lazy(() => import('./snowfall')),
|
||||
starfield: lazy(() => import('./starfield')),
|
||||
'matrix-rain': lazy(() => import('./matrix-rain')),
|
||||
'liquid-gradient': lazy(() => import('./liquid-gradient')),
|
||||
constellation: lazy(() => import('./constellation')),
|
||||
};
|
||||
|
||||
// Registry of all background definitions with settings for the editor
|
||||
@@ -59,9 +71,15 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
descriptionKey: 'admin.backgrounds.auroraDesc',
|
||||
category: 'css',
|
||||
settings: [
|
||||
{ key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#00d2ff' },
|
||||
{ key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#7928ca' },
|
||||
{ key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#ff0080' },
|
||||
{ key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#3b82f6' },
|
||||
{ key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#a5b4fc' },
|
||||
{ key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#93c5fd' },
|
||||
{
|
||||
key: 'showRadialGradient',
|
||||
label: 'admin.backgrounds.radialGradient',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
@@ -188,6 +206,12 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
type: 'color',
|
||||
default: '#2EB9DF',
|
||||
},
|
||||
{
|
||||
key: 'bgStarColor',
|
||||
label: 'admin.backgrounds.bgStarColor',
|
||||
type: 'color',
|
||||
default: '#ffffff',
|
||||
},
|
||||
{
|
||||
key: 'starDensity',
|
||||
label: 'admin.backgrounds.density',
|
||||
@@ -222,14 +246,37 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
labelKey: 'admin.backgrounds.beams',
|
||||
descriptionKey: 'admin.backgrounds.beamsDesc',
|
||||
category: 'svg',
|
||||
settings: [],
|
||||
settings: [
|
||||
{
|
||||
key: 'gradientStart',
|
||||
label: 'admin.backgrounds.color1',
|
||||
type: 'color',
|
||||
default: '#18CCFC',
|
||||
},
|
||||
{ key: 'gradientMid', label: 'admin.backgrounds.color2', type: 'color', default: '#6344F5' },
|
||||
{ key: 'gradientEnd', label: 'admin.backgrounds.color3', type: 'color', default: '#AE48FF' },
|
||||
{
|
||||
key: 'staticColor',
|
||||
label: 'admin.backgrounds.fillColor',
|
||||
type: 'color',
|
||||
default: '#d4d4d4',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'background-beams-collision',
|
||||
labelKey: 'admin.backgrounds.beamsCollision',
|
||||
descriptionKey: 'admin.backgrounds.beamsCollisionDesc',
|
||||
category: 'svg',
|
||||
settings: [],
|
||||
settings: [
|
||||
{ key: 'beamColor', label: 'admin.backgrounds.beamColor', type: 'color', default: '#6366f1' },
|
||||
{
|
||||
key: 'explosionColor',
|
||||
label: 'admin.backgrounds.explosionColor',
|
||||
type: 'color',
|
||||
default: '#a855f7',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gradient-animation',
|
||||
@@ -242,6 +289,12 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
{ key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#64DCFF' },
|
||||
{ key: 'fourthColor', label: 'admin.backgrounds.color4', type: 'color', default: '#C83232' },
|
||||
{ key: 'fifthColor', label: 'admin.backgrounds.color5', type: 'color', default: '#B4B432' },
|
||||
{
|
||||
key: 'pointerColor',
|
||||
label: 'admin.backgrounds.pointerColor',
|
||||
type: 'color',
|
||||
default: '#8C64FF',
|
||||
},
|
||||
{
|
||||
key: 'interactive',
|
||||
label: 'admin.backgrounds.interactive',
|
||||
@@ -310,6 +363,11 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
type: 'color',
|
||||
default: '#000000',
|
||||
},
|
||||
{ key: 'waveColor1', label: 'admin.backgrounds.color1', type: 'color', default: '#38bdf8' },
|
||||
{ key: 'waveColor2', label: 'admin.backgrounds.color2', type: 'color', default: '#818cf8' },
|
||||
{ key: 'waveColor3', label: 'admin.backgrounds.color3', type: 'color', default: '#c084fc' },
|
||||
{ key: 'waveColor4', label: 'admin.backgrounds.color4', type: 'color', default: '#e879f9' },
|
||||
{ key: 'waveColor5', label: 'admin.backgrounds.color5', type: 'color', default: '#22d3ee' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -378,6 +436,18 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
default: 12,
|
||||
},
|
||||
{ key: 'boxColor', label: 'admin.backgrounds.fillColor', type: 'color', default: '#818cf8' },
|
||||
{
|
||||
key: 'multicolor',
|
||||
label: 'admin.backgrounds.multicolor',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: 'lineColor',
|
||||
label: 'admin.backgrounds.lineColor',
|
||||
type: 'color',
|
||||
default: '#334155',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -520,4 +590,206 @@ export const backgroundRegistry: BackgroundDefinition[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'fireflies',
|
||||
labelKey: 'admin.backgrounds.fireflies',
|
||||
descriptionKey: 'admin.backgrounds.firefliesDesc',
|
||||
category: 'canvas',
|
||||
settings: [
|
||||
{ key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffd166' },
|
||||
{
|
||||
key: 'count',
|
||||
label: 'admin.backgrounds.count',
|
||||
type: 'number',
|
||||
min: 5,
|
||||
max: 200,
|
||||
step: 5,
|
||||
default: 40,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
type: 'number',
|
||||
min: 0.1,
|
||||
max: 3,
|
||||
step: 0.1,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: 'admin.backgrounds.size',
|
||||
type: 'number',
|
||||
min: 0.5,
|
||||
max: 6,
|
||||
step: 0.5,
|
||||
default: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'snowfall',
|
||||
labelKey: 'admin.backgrounds.snowfall',
|
||||
descriptionKey: 'admin.backgrounds.snowfallDesc',
|
||||
category: 'canvas',
|
||||
settings: [
|
||||
{ key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffffff' },
|
||||
{
|
||||
key: 'density',
|
||||
label: 'admin.backgrounds.density',
|
||||
type: 'number',
|
||||
min: 20,
|
||||
max: 400,
|
||||
step: 10,
|
||||
default: 150,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
type: 'number',
|
||||
min: 0.1,
|
||||
max: 3,
|
||||
step: 0.1,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: 'wind',
|
||||
label: 'admin.backgrounds.wind',
|
||||
type: 'number',
|
||||
min: -3,
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
default: 0.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'starfield',
|
||||
labelKey: 'admin.backgrounds.starfield',
|
||||
descriptionKey: 'admin.backgrounds.starfieldDesc',
|
||||
category: 'canvas',
|
||||
settings: [
|
||||
{ key: 'color', label: 'admin.backgrounds.starColor', type: 'color', default: '#ffffff' },
|
||||
{
|
||||
key: 'starCount',
|
||||
label: 'admin.backgrounds.particles',
|
||||
type: 'number',
|
||||
min: 50,
|
||||
max: 800,
|
||||
step: 25,
|
||||
default: 200,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
type: 'number',
|
||||
min: 0.1,
|
||||
max: 5,
|
||||
step: 0.1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'matrix-rain',
|
||||
labelKey: 'admin.backgrounds.matrixRain',
|
||||
descriptionKey: 'admin.backgrounds.matrixRainDesc',
|
||||
category: 'canvas',
|
||||
settings: [
|
||||
{ key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#00ff41' },
|
||||
{
|
||||
key: 'density',
|
||||
label: 'admin.backgrounds.density',
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 70,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
type: 'number',
|
||||
min: 0.2,
|
||||
max: 3,
|
||||
step: 0.1,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: 'charset',
|
||||
label: 'admin.backgrounds.charset',
|
||||
type: 'select',
|
||||
default: 'katakana',
|
||||
options: [
|
||||
{ label: 'Katakana', value: 'katakana' },
|
||||
{ label: 'Latin', value: 'latin' },
|
||||
{ label: 'Binary', value: 'binary' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'liquid-gradient',
|
||||
labelKey: 'admin.backgrounds.liquidGradient',
|
||||
descriptionKey: 'admin.backgrounds.liquidGradientDesc',
|
||||
category: 'css',
|
||||
settings: [
|
||||
{ key: 'color1', label: 'admin.backgrounds.color1', type: 'color', default: '#6366f1' },
|
||||
{ key: 'color2', label: 'admin.backgrounds.color2', type: 'color', default: '#ec4899' },
|
||||
{ key: 'color3', label: 'admin.backgrounds.color3', type: 'color', default: '#22d3ee' },
|
||||
{ key: 'color4', label: 'admin.backgrounds.color4', type: 'color', default: '#a855f7' },
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'admin.backgrounds.speed',
|
||||
type: 'select',
|
||||
default: 'normal',
|
||||
options: [
|
||||
{ label: 'admin.backgrounds.slow', value: 'slow' },
|
||||
{ label: 'admin.backgrounds.normal', value: 'normal' },
|
||||
{ label: 'admin.backgrounds.fast', value: 'fast' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'blurAmount',
|
||||
label: 'admin.backgrounds.blurAmount',
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 120,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'constellation',
|
||||
labelKey: 'admin.backgrounds.constellation',
|
||||
descriptionKey: 'admin.backgrounds.constellationDesc',
|
||||
category: 'canvas',
|
||||
settings: [
|
||||
{
|
||||
key: 'particleColor',
|
||||
label: 'admin.backgrounds.particleColor',
|
||||
type: 'color',
|
||||
default: '#818cf8',
|
||||
},
|
||||
{ key: 'lineColor', label: 'admin.backgrounds.lineColor', type: 'color', default: '#818cf8' },
|
||||
{
|
||||
key: 'count',
|
||||
label: 'admin.backgrounds.count',
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 200,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
{
|
||||
key: 'linkDistance',
|
||||
label: 'admin.backgrounds.linkDistance',
|
||||
type: 'number',
|
||||
min: 40,
|
||||
max: 300,
|
||||
step: 10,
|
||||
default: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
|
||||
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||
const bgStarColor = sanitizeColor(settings.bgStarColor, '#ffffff');
|
||||
const starDensity = clampNumber(settings.starDensity, 0.00001, 0.001, 0.00015);
|
||||
const minSpeed = clampNumber(settings.minSpeed, 1, 50, 10);
|
||||
const maxSpeed = clampNumber(settings.maxSpeed, 5, 100, 30);
|
||||
@@ -117,9 +118,11 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity})`;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = bgStarColor;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (time - state.lastShootingTime > state.nextShootingDelay) {
|
||||
state.shootingStars.push({
|
||||
@@ -166,7 +169,7 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
return true;
|
||||
});
|
||||
},
|
||||
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
|
||||
[starColor, trailColor, bgStarColor, starDensity, minSpeed, maxSpeed],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
|
||||
114
src/components/ui/backgrounds/snowfall.tsx
Normal file
114
src/components/ui/backgrounds/snowfall.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Flake {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
fallSpeed: number;
|
||||
phase: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface SnowfallState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
flakes: Flake[];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function SnowfallBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<SnowfallState | null>(null);
|
||||
|
||||
const color = sanitizeColor(settings.color, '#ffffff');
|
||||
const density = clampNumber(settings.density, 20, 400, 150);
|
||||
const speed = clampNumber(settings.speed, 0.1, 3, 1);
|
||||
const wind = clampNumber(settings.wind, -3, 3, 0.5);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const flakes: Flake[] = Array.from({ length: Math.floor(density) }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
radius: 0.8 + Math.random() * 2.2,
|
||||
fallSpeed: 0.4 + Math.random() * 1.2,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
opacity: 0.3 + Math.random() * 0.7,
|
||||
}));
|
||||
|
||||
stateRef.current = { ctx, flakes, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [density]);
|
||||
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, flakes, w, h } = state;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = color;
|
||||
|
||||
for (const f of flakes) {
|
||||
f.y += f.fallSpeed * speed;
|
||||
f.x += wind * f.fallSpeed * 0.6 + Math.sin(time / 1800 + f.phase) * 0.4;
|
||||
|
||||
if (f.y > h + 5) {
|
||||
f.y = -5;
|
||||
f.x = Math.random() * w;
|
||||
}
|
||||
if (f.x > w + 5) f.x = -5;
|
||||
if (f.x < -5) f.x = w + 5;
|
||||
|
||||
ctx.globalAlpha = f.opacity;
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[color, density, speed, wind],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
123
src/components/ui/backgrounds/starfield.tsx
Normal file
123
src/components/ui/backgrounds/starfield.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 1000;
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface StarfieldState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
stars: Star[];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
function spawnStar(w: number, h: number, randomDepth: boolean): Star {
|
||||
return {
|
||||
x: (Math.random() - 0.5) * w * 2,
|
||||
y: (Math.random() - 0.5) * h * 2,
|
||||
z: randomDepth ? Math.random() * MAX_DEPTH : MAX_DEPTH,
|
||||
};
|
||||
}
|
||||
|
||||
export default function StarfieldBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<StarfieldState | null>(null);
|
||||
|
||||
const color = sanitizeColor(settings.color, '#ffffff');
|
||||
const starCount = clampNumber(settings.starCount, 50, 800, 200);
|
||||
const speed = clampNumber(settings.speed, 0.1, 5, 1);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const stars = Array.from({ length: Math.floor(starCount) }, () => spawnStar(w, h, true));
|
||||
|
||||
stateRef.current = { ctx, stars, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [starCount]);
|
||||
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, stars, w, h } = state;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const fov = Math.min(w, h);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = color;
|
||||
|
||||
for (let i = 0; i < stars.length; i++) {
|
||||
const s = stars[i];
|
||||
s.z -= speed * 4;
|
||||
|
||||
if (s.z <= 1) {
|
||||
stars[i] = spawnStar(w, h, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
const k = fov / s.z;
|
||||
const sx = cx + s.x * k;
|
||||
const sy = cy + s.y * k;
|
||||
|
||||
if (sx < 0 || sx > w || sy < 0 || sy > h) {
|
||||
stars[i] = spawnStar(w, h, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
const depth = 1 - s.z / MAX_DEPTH;
|
||||
const radius = Math.max(0.3, depth * 2.2);
|
||||
|
||||
ctx.globalAlpha = 0.2 + depth * 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}, [color, starCount, speed]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export type BackgroundType =
|
||||
| 'dots'
|
||||
| 'spotlight'
|
||||
| 'ripple'
|
||||
| 'fireflies'
|
||||
| 'snowfall'
|
||||
| 'starfield'
|
||||
| 'matrix-rain'
|
||||
| 'liquid-gradient'
|
||||
| 'constellation'
|
||||
| 'none';
|
||||
|
||||
export interface AnimationConfig {
|
||||
|
||||
@@ -27,7 +27,13 @@ export default function WavyBackground({ settings }: Props) {
|
||||
const blur = clampNumber(settings.blur, 0, 50, 10);
|
||||
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
||||
const backgroundFill = sanitizeColor(settings.backgroundFill, '#000000');
|
||||
const colors = ['#38bdf8', '#818cf8', '#c084fc', '#e879f9', '#22d3ee'];
|
||||
const colors = [
|
||||
sanitizeColor(settings.waveColor1, '#38bdf8'),
|
||||
sanitizeColor(settings.waveColor2, '#818cf8'),
|
||||
sanitizeColor(settings.waveColor3, '#c084fc'),
|
||||
sanitizeColor(settings.waveColor4, '#e879f9'),
|
||||
sanitizeColor(settings.waveColor5, '#22d3ee'),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -100,7 +106,7 @@ export default function WavyBackground({ settings }: Props) {
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||
}, [speed, waveWidth, blur, waveOpacity, backgroundFill, ...colors]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -29,3 +29,17 @@ export const API = {
|
||||
TRAFFIC_WARN_PERCENT: 70,
|
||||
TRAFFIC_CRITICAL_PERCENT: 90,
|
||||
} as const;
|
||||
|
||||
// Backend liveness probe (ServiceUnavailableScreen detection/recovery).
|
||||
export const HEALTH = {
|
||||
// Tolerant of cold mobile connections (radio wake + DNS + TLS + first byte). A hardcoded
|
||||
// 5s here used to falsely flag slow devices as "service unavailable" while the real 30s
|
||||
// API requests would have succeeded.
|
||||
PROBE_TIMEOUT_MS: 12000,
|
||||
// Re-probe before declaring the backend down so a single cold-connection blip can't blank
|
||||
// the app. Total worst case to show the screen on a genuine outage stays under the API timeout.
|
||||
CONFIRM_RETRIES: 1,
|
||||
CONFIRM_RETRY_DELAY_MS: 1500,
|
||||
// Recovery poll while the screen is shown — quick feedback once the backend answers again.
|
||||
RECOVERY_POLL_INTERVAL_MS: 5000,
|
||||
} as const;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { brandingApi } from '@/api/branding';
|
||||
@@ -6,8 +7,31 @@ import { wheelApi } from '@/api/wheel';
|
||||
import { contestsApi } from '@/api/contests';
|
||||
import { pollsApi } from '@/api/polls';
|
||||
|
||||
// Последние известные значения флагов. Пока запросы в полёте, флаги были
|
||||
// undefined -> табы «Рефералы»/«Колесо» появлялись с задержкой и нижняя
|
||||
// навигация прыгала на каждом холодном старте. Кэш убирает layout shift;
|
||||
// при изменении флага на бэке UI догонит после ответа запроса.
|
||||
const FLAGS_CACHE_KEY = 'cabinet-feature-flags';
|
||||
|
||||
type CachedFlags = {
|
||||
referralEnabled?: boolean;
|
||||
wheelEnabled?: boolean;
|
||||
hasContests?: boolean;
|
||||
hasPolls?: boolean;
|
||||
giftEnabled?: boolean;
|
||||
};
|
||||
|
||||
function readFlagsCache(): CachedFlags {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FLAGS_CACHE_KEY) || '{}') as CachedFlags;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const cached = readFlagsCache();
|
||||
|
||||
const { data: referralTerms } = useQuery({
|
||||
queryKey: ['referral-terms'],
|
||||
@@ -49,11 +73,29 @@ export function useFeatureFlags() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
referralEnabled: referralTerms?.is_enabled,
|
||||
wheelEnabled: wheelConfig?.is_enabled,
|
||||
hasContests: (contestsCount?.count ?? 0) > 0,
|
||||
hasPolls: (pollsCount?.count ?? 0) > 0,
|
||||
giftEnabled: giftConfig?.enabled,
|
||||
const flags = {
|
||||
referralEnabled: referralTerms ? referralTerms.is_enabled : cached.referralEnabled,
|
||||
wheelEnabled: wheelConfig ? wheelConfig.is_enabled : cached.wheelEnabled,
|
||||
hasContests: contestsCount ? contestsCount.count > 0 : cached.hasContests,
|
||||
hasPolls: pollsCount ? pollsCount.count > 0 : cached.hasPolls,
|
||||
giftEnabled: giftConfig ? giftConfig.enabled : cached.giftEnabled,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!referralTerms && !wheelConfig && !contestsCount && !pollsCount && !giftConfig) return;
|
||||
try {
|
||||
localStorage.setItem(FLAGS_CACHE_KEY, JSON.stringify(flags));
|
||||
} catch {
|
||||
// квоты/приватный режим — некритично
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
flags.referralEnabled,
|
||||
flags.wheelEnabled,
|
||||
flags.hasContests,
|
||||
flags.hasPolls,
|
||||
flags.giftEnabled,
|
||||
]);
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,66 @@ function interpolateRgb(
|
||||
);
|
||||
}
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
function mixRgb(rgb1: Rgb, rgb2: Rgb, factor: number): Rgb {
|
||||
return {
|
||||
r: Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||
g: Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||
b: Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
|
||||
};
|
||||
}
|
||||
|
||||
// WCAG relative luminance
|
||||
function relativeLuminance({ r, g, b }: Rgb): number {
|
||||
const srgb = (v: number) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
}
|
||||
|
||||
// WCAG contrast ratio between two colors
|
||||
function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
const l1 = relativeLuminance(a);
|
||||
const l2 = relativeLuminance(b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee a minimum contrast for a TEXT color against its background.
|
||||
*
|
||||
* Operators pick arbitrary palette colors in the theme editor, and several
|
||||
* tokens are additionally blended toward the surface (dark-500 = secondary
|
||||
* text mixed 40% into the card color). Without a floor, hint/meta text
|
||||
* regularly lands at a 1.5-2.5 contrast ratio and becomes unreadable.
|
||||
* When the color already passes, it is returned untouched, so well-tuned
|
||||
* palettes render byte-for-byte the same as before.
|
||||
*/
|
||||
function ensureReadable(fg: Rgb, towards: Rgb, bg: Rgb, minRatio: number): Rgb {
|
||||
if (contrastRatio(fg, bg) >= minRatio) return fg;
|
||||
for (let t = 0.1; t <= 1; t += 0.1) {
|
||||
const mixed = mixRgb(fg, towards, t);
|
||||
if (contrastRatio(mixed, bg) >= minRatio) return mixed;
|
||||
}
|
||||
return towards;
|
||||
}
|
||||
|
||||
// Black-or-white text for a given button/badge background: pick whichever
|
||||
// side actually reads on top of it (operators may choose a light accent).
|
||||
function onColorFor(bgTriplet: string): string {
|
||||
const [r, g, b] = bgTriplet.split(',').map((x) => Number(x.trim()));
|
||||
const bg = { r, g, b };
|
||||
const white = { r: 255, g: 255, b: 255 };
|
||||
const ink = { r: 15, g: 23, b: 42 };
|
||||
return contrastRatio(white, bg) >= contrastRatio(ink, bg) ? '255, 255, 255' : '15, 23, 42';
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
export function applyThemeColors(themeColors: ThemeColors): void {
|
||||
// Частичный/битый ответ /branding/colors раньше ронял ВСЁ приложение в
|
||||
// ErrorBoundary (hexToRgb(undefined)). Недостающие поля добиваем дефолтами.
|
||||
const colors: ThemeColors = { ...DEFAULT_THEME_COLORS, ...themeColors };
|
||||
const root = document.documentElement;
|
||||
|
||||
// Generate palettes from status colors
|
||||
@@ -70,6 +128,17 @@ export function applyThemeColors(colors: ThemeColors): void {
|
||||
const darkTextRgb = hexToRgb(colors.darkText);
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
|
||||
|
||||
// Contrast floors: secondary text must stay readable on the card surface
|
||||
// regardless of the operator-chosen palette (AA 4.5 for dark-400, a softer
|
||||
// 3.5 floor for the blended hint token dark-500).
|
||||
const darkTextSecReadable = ensureReadable(darkTextSecRgb, darkTextRgb, darkSurfaceRgb, 5.0);
|
||||
const darkHintReadable = ensureReadable(
|
||||
mixRgb(darkTextSecRgb, darkSurfaceRgb, 0.4),
|
||||
darkTextRgb,
|
||||
darkSurfaceRgb,
|
||||
3.8,
|
||||
);
|
||||
|
||||
// Apply dark palette with actual user colors:
|
||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||
root.style.setProperty(
|
||||
@@ -84,11 +153,14 @@ export function applyThemeColors(colors: ThemeColors): void {
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
|
||||
root.style.setProperty(
|
||||
'--color-dark-400',
|
||||
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
|
||||
rgbToString(darkTextSecReadable.r, darkTextSecReadable.g, darkTextSecReadable.b),
|
||||
);
|
||||
|
||||
// Transition colors (500-700): interpolate between secondary text and surface
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
|
||||
root.style.setProperty(
|
||||
'--color-dark-500',
|
||||
rgbToString(darkHintReadable.r, darkHintReadable.g, darkHintReadable.b),
|
||||
);
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
|
||||
|
||||
@@ -122,11 +194,23 @@ export function applyThemeColors(colors: ThemeColors): void {
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
|
||||
|
||||
// Transition colors (500-600): between bg and text
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
|
||||
// Transition colors (500-600): between bg and text.
|
||||
// Same contrast floors as the dark palette: champagne-600 backs dark-400
|
||||
// (secondary text) in the light theme, champagne-500 backs dark-500 (hints).
|
||||
const lightHintReadable = ensureReadable(
|
||||
mixRgb(lightBgRgb, lightTextSecRgb, 0.6),
|
||||
lightTextRgb,
|
||||
lightSurfaceRgb,
|
||||
3.8,
|
||||
);
|
||||
const lightTextSecReadable = ensureReadable(lightTextSecRgb, lightTextRgb, lightSurfaceRgb, 5.0);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-500',
|
||||
rgbToString(lightHintReadable.r, lightHintReadable.g, lightHintReadable.b),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-600',
|
||||
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
|
||||
rgbToString(lightTextSecReadable.r, lightTextSecReadable.g, lightTextSecReadable.b),
|
||||
);
|
||||
|
||||
// Text colors (700-950): secondary to primary text
|
||||
@@ -154,6 +238,13 @@ export function applyThemeColors(colors: ThemeColors): void {
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
|
||||
}
|
||||
|
||||
// Readable text color on top of each status color (buttons, filled badges).
|
||||
// Hardcoded white breaks the moment an operator picks a light accent.
|
||||
root.style.setProperty('--color-on-accent', onColorFor(accentPalette[500]));
|
||||
root.style.setProperty('--color-on-success', onColorFor(successPalette[500]));
|
||||
root.style.setProperty('--color-on-warning', onColorFor(warningPalette[500]));
|
||||
root.style.setProperty('--color-on-error', onColorFor(errorPalette[500]));
|
||||
|
||||
// Apply semantic colors (hex for direct use)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground);
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface);
|
||||
|
||||
@@ -25,10 +25,9 @@
|
||||
"copy": "Copy",
|
||||
"all": "All",
|
||||
"units": {
|
||||
"tb": "TB",
|
||||
"gb": "GB",
|
||||
"perGb": "/GB",
|
||||
"days": "days",
|
||||
"mo": "mo"
|
||||
"mb": "MB"
|
||||
},
|
||||
"retry": "Retry",
|
||||
"saving": "Saving...",
|
||||
@@ -787,6 +786,13 @@
|
||||
"not_yet_valid": "Promo code is not yet active",
|
||||
"used": "Promo code has already been used",
|
||||
"already_used_by_user": "You have already used this promo code",
|
||||
"active_discount_exists": "You already have an active discount. Deactivate it first.",
|
||||
"no_subscription_for_days": "This promo code requires an active or expired subscription",
|
||||
"subscription_not_found": "Subscription not found",
|
||||
"not_first_purchase": "This promo code is only available for your first purchase",
|
||||
"daily_limit": "Too many promo code activations today",
|
||||
"trial_subscription_exists": "You already have a subscription, so this trial code can't be applied",
|
||||
"trial_provisioning_failed": "Couldn't provision the trial right now, please try again later",
|
||||
"user_not_found": "User not found",
|
||||
"server_error": "Server error"
|
||||
},
|
||||
@@ -1039,7 +1045,8 @@
|
||||
"openSupport": "Open Support",
|
||||
"contactSupport": "Please contact {{username}} for support",
|
||||
"contactUs": "Contact Support",
|
||||
"create_ticket": "Create ticket"
|
||||
"create_ticket": "Create ticket",
|
||||
"writeButton": "Message"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "Fortune Wheel",
|
||||
@@ -1112,7 +1119,7 @@
|
||||
"globalOpacity": "Opacity",
|
||||
"globalBlur": "Blur",
|
||||
"categoryCSS": "CSS",
|
||||
"categoryCanvas": "Canvas",
|
||||
"categoryCANVAS": "Canvas",
|
||||
"categorySVG": "SVG",
|
||||
"aurora": "Aurora",
|
||||
"auroraDesc": "Northern lights with smooth gradients",
|
||||
@@ -1146,6 +1153,18 @@
|
||||
"noiseDesc": "Noise gradients with motion",
|
||||
"ripple": "Ripple",
|
||||
"rippleDesc": "Ripple wave effect",
|
||||
"fireflies": "Fireflies",
|
||||
"firefliesDesc": "Drifting glowing dots with pulsation",
|
||||
"snowfall": "Snowfall",
|
||||
"snowfallDesc": "Falling snow with wind drift",
|
||||
"starfield": "Starfield",
|
||||
"starfieldDesc": "Stars flying toward the viewer",
|
||||
"matrixRain": "Matrix Rain",
|
||||
"matrixRainDesc": "Falling columns of glyphs",
|
||||
"liquidGradient": "Liquid Gradient",
|
||||
"liquidGradientDesc": "Blurred mesh gradient blobs",
|
||||
"constellation": "Constellation",
|
||||
"constellationDesc": "Particles linked by lines",
|
||||
"geminiEffect": "Gemini Effect",
|
||||
"geminiEffectDesc": "SVG effect like Google Gemini",
|
||||
"speed": "Speed",
|
||||
@@ -1161,6 +1180,15 @@
|
||||
"particleColor": "Particle Color",
|
||||
"starColor": "Star Color",
|
||||
"trailColor": "Trail Color",
|
||||
"bgStarColor": "Background Stars Color",
|
||||
"pointerColor": "Pointer Color",
|
||||
"beamColor": "Beam Color",
|
||||
"explosionColor": "Explosion Color",
|
||||
"multicolor": "Multicolor",
|
||||
"lineColor": "Line Color",
|
||||
"wind": "Wind",
|
||||
"charset": "Charset",
|
||||
"linkDistance": "Link Distance",
|
||||
"bgColor": "Background Color",
|
||||
"fillColor": "Fill Color",
|
||||
"color1": "Color 1",
|
||||
@@ -1229,7 +1257,8 @@
|
||||
"referralNetwork": "Referral Network",
|
||||
"news": "News",
|
||||
"bulkActions": "Bulk Actions",
|
||||
"infoPages": "Info Pages"
|
||||
"infoPages": "Info Pages",
|
||||
"legalPages": "System pages"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1534,9 +1563,14 @@
|
||||
"resetted": "Template reset to default",
|
||||
"testSent": "Test email sent",
|
||||
"variables": "Available Variables",
|
||||
"clickToCopy": "Click to copy",
|
||||
"previewDesktopOnly": "Preview is only available in the desktop web version",
|
||||
"previewNotAvailable": "Preview not available"
|
||||
"editorTab": "Editor",
|
||||
"previewHint": "The preview shows the email with sample values in place of variables.",
|
||||
"clickToInsert": "Click to insert into the text",
|
||||
"insertDefault": "Insert default template",
|
||||
"insertDefaultConfirm": "Replace the current content with the default template?",
|
||||
"testRecipientPlaceholder": "Recipient email (defaults to yours)",
|
||||
"testHint": "Sends the current editor content with sample values in place of variables.",
|
||||
"variablesCommon": "Common variables (work in all templates)"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Payments",
|
||||
@@ -1589,12 +1623,36 @@
|
||||
"methodEnabled": "Method enabled",
|
||||
"providerNotConfigured": "Provider not configured in env",
|
||||
"openUrlDirect": "Open payment page directly",
|
||||
"openUrlDirectHint": "Skip the link panel: the provider opens inside the MiniApp/tab right after the click. After payment the user returns to /balance/top-up/result. Ignored for Stars/CryptoBot and other t.me/ links.",
|
||||
"openUrlDirectHint": "ON — the payment link opens immediately, without the intermediate button panel. OFF — an \"Open payment\" button is shown. Inside Telegram the link opens in the external browser either way (otherwise paying via a bank app/SBP fails); after payment the user returns to the result page. Stars/CryptoBot (t.me/ links) always open natively and ignore this flag.",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "Leave empty for default name",
|
||||
"subOptions": "Payment options",
|
||||
"minAmount": "Min amount (kopeks)",
|
||||
"maxAmount": "Max amount (kopeks)",
|
||||
"quickAmounts": "Quick amounts (₽)",
|
||||
"quickAmountsHint": "Quick top-up amount buttons. Empty — defaults are used: {{defaults}} ₽",
|
||||
"quickAmountsPlaceholder": "Amount in rubles",
|
||||
"quickAmountsAdd": "Add",
|
||||
"quickAmountsInvalid": "Enter a positive amount in rubles",
|
||||
"quickAmountsLimit": "No more than 10 amounts",
|
||||
"quickAmountsRemove": "Remove amount {{value}} ₽",
|
||||
"overpayCertTitle": "mTLS certificate (Overpay)",
|
||||
"overpayCertValid": "✅ Valid until {{date}}",
|
||||
"overpayCertExpired": "⚠️ Expired {{date}}",
|
||||
"overpayCertUnreadable": "⚠️ Certificate cannot be read — wrong passphrase or corrupted file",
|
||||
"overpayCertMissing": "❌ Certificate not uploaded",
|
||||
"overpayCertEnvLockedPath": "Path is set via the OVERPAY_P12_PATH environment variable — the database value is not applied",
|
||||
"overpayCertEnvLockedPassphrase": "Passphrase is set via the OVERPAY_P12_PASSPHRASE environment variable — the database value is not applied",
|
||||
"overpayCertFile": "Certificate file (.p12 / .pfx)",
|
||||
"overpayCertChooseFile": "Choose file",
|
||||
"overpayCertPassphrase": "Certificate passphrase",
|
||||
"overpayCertPassphrasePlaceholder": "Leave empty if none",
|
||||
"overpayCertUpload": "Upload",
|
||||
"overpayCertDelete": "Delete",
|
||||
"overpayCertConfirmDelete": "Delete the Overpay certificate?",
|
||||
"overpayCertFileTooLarge": "File exceeds 1 MB",
|
||||
"overpayCertUploadError": "Failed to upload certificate",
|
||||
"overpayCertDeleteError": "Failed to delete certificate",
|
||||
"conditions": "Display conditions",
|
||||
"userTypeFilter": "User type",
|
||||
"userTypeAll": "All",
|
||||
@@ -1993,6 +2051,8 @@
|
||||
"quickToggles": "Quick Toggles",
|
||||
"modified": "Modified",
|
||||
"readOnly": "Read only",
|
||||
"envLocked": "Set in .env",
|
||||
"envLockedHint": "This value is set in the .env file and overrides the database, so it can't be changed here. To manage it from the cabinet, remove the key from .env and restart the bot.",
|
||||
"badgeDb": "DB",
|
||||
"badgeEnv": "ENV",
|
||||
"reset": "Reset",
|
||||
@@ -4062,7 +4122,8 @@
|
||||
"sortOrder": "Sort Order",
|
||||
"icon": "Icon (emoji)",
|
||||
"pageType": "Page Type",
|
||||
"replacesTab": "Replaces Tab"
|
||||
"replacesTab": "Replaces Tab",
|
||||
"displayMode": "Visibility"
|
||||
},
|
||||
"replacesTabNone": "None",
|
||||
"replacesTabOptions": {
|
||||
@@ -4071,6 +4132,11 @@
|
||||
"privacy": "Privacy",
|
||||
"offer": "Offer"
|
||||
},
|
||||
"displayModes": {
|
||||
"bot": "Bot only",
|
||||
"web": "Web only",
|
||||
"both": "Bot and web"
|
||||
},
|
||||
"replacesTabConflict": "already assigned",
|
||||
"replacesTabWarning": "Another page already replaces this tab. It will be unassigned on save.",
|
||||
"filter": {
|
||||
@@ -4105,6 +4171,43 @@
|
||||
"zh": "Chinese",
|
||||
"fa": "Persian"
|
||||
}
|
||||
},
|
||||
"legalPages": {
|
||||
"title": "System pages",
|
||||
"subtitle": "Rules, privacy policy, public offer and FAQ",
|
||||
"open": "System pages",
|
||||
"tabs": {
|
||||
"privacy": "Privacy",
|
||||
"offer": "Offer",
|
||||
"rules": "Rules",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"displayMode": "Visibility",
|
||||
"displayModes": {
|
||||
"bot": "Bot only",
|
||||
"web": "Web only",
|
||||
"both": "Bot and web"
|
||||
},
|
||||
"displayModeLocked": "Set via environment variable",
|
||||
"enabled": "Visible to users",
|
||||
"language": "Content language",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "HTML page content...",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saveError": "Failed to save. Please try again.",
|
||||
"unsavedWarning": "Unsaved changes will be lost. Continue?",
|
||||
"faqPages": "Questions and answers",
|
||||
"addQuestion": "Add question",
|
||||
"newQuestion": "New question",
|
||||
"questionTitle": "Question",
|
||||
"questionContent": "Answer (HTML supported)",
|
||||
"confirmDeleteQuestion": "Delete this question?",
|
||||
"noQuestions": "No questions yet",
|
||||
"active": "Active",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"delete": "Delete"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -5157,7 +5260,7 @@
|
||||
},
|
||||
"subscriptions": {
|
||||
"buy": "Buy subscription",
|
||||
"buyAnother": "Buy another subscription",
|
||||
"buyAnother": "Buy another",
|
||||
"browsePlans": "View plans and subscribe",
|
||||
"empty": "You have no subscriptions yet",
|
||||
"emptyDesc": "Choose a tariff to get started",
|
||||
|
||||
@@ -22,10 +22,9 @@
|
||||
"currency": "تومان",
|
||||
"copy": "کپی",
|
||||
"units": {
|
||||
"tb": "TB",
|
||||
"gb": "GB",
|
||||
"perGb": "/GB",
|
||||
"days": "روز",
|
||||
"mo": "ماه"
|
||||
"mb": "MB"
|
||||
},
|
||||
"add": "افزودن",
|
||||
"refresh": "بازخوانی",
|
||||
@@ -674,6 +673,13 @@
|
||||
"not_yet_valid": "کد تخفیف هنوز فعال نشده",
|
||||
"used": "کد تخفیف قبلاً استفاده شده",
|
||||
"already_used_by_user": "شما قبلاً از این کد استفاده کردهاید",
|
||||
"active_discount_exists": "شما در حال حاضر یک تخفیف فعال دارید. ابتدا آن را غیرفعال کنید.",
|
||||
"no_subscription_for_days": "این کد تخفیف به اشتراک فعال یا منقضیشده نیاز دارد",
|
||||
"subscription_not_found": "اشتراک یافت نشد",
|
||||
"not_first_purchase": "این کد تخفیف فقط برای اولین خرید در دسترس است",
|
||||
"daily_limit": "تعداد فعالسازی کد تخفیف امروز بیش از حد است",
|
||||
"trial_subscription_exists": "شما در حال حاضر اشتراک دارید، بنابراین این کد آزمایشی قابل اعمال نیست",
|
||||
"trial_provisioning_failed": "در حال حاضر امکان فعالسازی نسخه آزمایشی نیست، لطفاً بعداً تلاش کنید",
|
||||
"user_not_found": "کاربر یافت نشد",
|
||||
"server_error": "خطای سرور"
|
||||
},
|
||||
@@ -922,7 +928,8 @@
|
||||
"openSupport": "باز کردن پشتیبانی",
|
||||
"ticketsDisabled": "تیکتها غیرفعال است",
|
||||
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
|
||||
"create_ticket": "ایجاد تیکت"
|
||||
"create_ticket": "ایجاد تیکت",
|
||||
"writeButton": "پیام"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "چرخ شانس",
|
||||
@@ -994,7 +1001,7 @@
|
||||
"globalOpacity": "شفافیت",
|
||||
"globalBlur": "تاری",
|
||||
"categoryCSS": "CSS",
|
||||
"categoryCanvas": "Canvas",
|
||||
"categoryCANVAS": "Canvas",
|
||||
"categorySVG": "SVG",
|
||||
"aurora": "شفق قطبی",
|
||||
"auroraDesc": "شفق شمالی با گرادیانهای نرم",
|
||||
@@ -1028,6 +1035,18 @@
|
||||
"noiseDesc": "گرادیانهای نویزی با حرکت",
|
||||
"ripple": "موج",
|
||||
"rippleDesc": "اثر موج دایرهای",
|
||||
"fireflies": "کرمهای شبتاب",
|
||||
"firefliesDesc": "نقاط درخشان شناور با تپش",
|
||||
"snowfall": "بارش برف",
|
||||
"snowfallDesc": "برف در حال بارش با وزش باد",
|
||||
"starfield": "میدان ستارگان",
|
||||
"starfieldDesc": "پرواز در میان ستارهها",
|
||||
"matrixRain": "باران ماتریکس",
|
||||
"matrixRainDesc": "ستونهای نمادهای در حال سقوط",
|
||||
"liquidGradient": "گرادیان مایع",
|
||||
"liquidGradientDesc": "حبابهای گرادیان مات و روان",
|
||||
"constellation": "صورت فلکی",
|
||||
"constellationDesc": "ذرات متصل با خطوط",
|
||||
"geminiEffect": "افکت Gemini",
|
||||
"geminiEffectDesc": "افکت SVG مشابه Google Gemini",
|
||||
"speed": "سرعت",
|
||||
@@ -1043,6 +1062,15 @@
|
||||
"particleColor": "رنگ ذرات",
|
||||
"starColor": "رنگ ستاره",
|
||||
"trailColor": "رنگ دنباله",
|
||||
"bgStarColor": "رنگ ستارههای پسزمینه",
|
||||
"pointerColor": "رنگ اشارهگر",
|
||||
"beamColor": "رنگ پرتو",
|
||||
"explosionColor": "رنگ انفجار",
|
||||
"multicolor": "حالت چندرنگ",
|
||||
"lineColor": "رنگ خطوط",
|
||||
"wind": "باد",
|
||||
"charset": "مجموعه نویسهها",
|
||||
"linkDistance": "فاصله اتصال",
|
||||
"bgColor": "رنگ پسزمینه",
|
||||
"fillColor": "رنگ پر کردن",
|
||||
"color1": "رنگ ۱",
|
||||
@@ -1102,7 +1130,8 @@
|
||||
"landings": "صفحات فرود",
|
||||
"referralNetwork": "شبکه ارجاع",
|
||||
"news": "اخبار",
|
||||
"infoPages": "صفحات اطلاعات"
|
||||
"infoPages": "صفحات اطلاعات",
|
||||
"legalPages": "صفحات سیستمی"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -1407,9 +1436,14 @@
|
||||
"resetted": "قالب به پیشفرض بازگردانده شد",
|
||||
"testSent": "ایمیل آزمایشی ارسال شد",
|
||||
"variables": "متغیرهای موجود",
|
||||
"clickToCopy": "برای کپی کلیک کنید",
|
||||
"previewDesktopOnly": "پیشنمایش فقط در نسخه وب دسکتاپ در دسترس است",
|
||||
"previewNotAvailable": "پیشنمایش در دسترس نیست"
|
||||
"editorTab": "ویرایشگر",
|
||||
"previewHint": "پیشنمایش ایمیل را با مقادیر نمونه به جای متغیرها نشان میدهد.",
|
||||
"clickToInsert": "برای درج در متن کلیک کنید",
|
||||
"insertDefault": "درج قالب پیشفرض",
|
||||
"insertDefaultConfirm": "محتوای فعلی با قالب پیشفرض جایگزین شود؟",
|
||||
"testRecipientPlaceholder": "ایمیل گیرنده (پیشفرض: ایمیل شما)",
|
||||
"testHint": "محتوای فعلی ویرایشگر با مقادیر نمونه به جای متغیرها ارسال میشود.",
|
||||
"variablesCommon": "متغیرهای عمومی (در همه قالبها کار میکنند)"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "تنظیمات چرخ شانس",
|
||||
@@ -1697,6 +1731,8 @@
|
||||
"quickToggles": "تغییر سریع",
|
||||
"modified": "تغییر یافته",
|
||||
"readOnly": "فقط خواندنی",
|
||||
"envLocked": "تنظیمشده در .env",
|
||||
"envLockedHint": "این مقدار در فایل .env تنظیم شده و پایگاهداده را بازنویسی میکند، بنابراین اینجا قابل تغییر نیست. برای مدیریت آن از پنل، کلید را از .env حذف کنید و ربات را ریاستارت کنید.",
|
||||
"reset": "بازنشانی",
|
||||
"categoriesCount": "دسته",
|
||||
"settingsCount": "تنظیم",
|
||||
@@ -2245,7 +2281,7 @@
|
||||
"methodEnabled": "روش فعال است",
|
||||
"providerNotConfigured": "ارائهدهنده در env تنظیم نشده",
|
||||
"openUrlDirect": "صفحه پرداخت را مستقیماً باز کن",
|
||||
"openUrlDirectHint": "بدون پنل لینک: ارائهدهنده داخل MiniApp/تب بلافاصله پس از کلیک باز میشود. پس از پرداخت کاربر به /balance/top-up/result بازمیگردد. برای Stars/CryptoBot و سایر لینکهای t.me/ این پرچم نادیده گرفته میشود.",
|
||||
"openUrlDirectHint": "روشن — لینک پرداخت بلافاصله باز میشود، بدون پنل دکمهٔ میانی. خاموش — دکمهٔ «باز کردن پرداخت» نمایش داده میشود. داخل تلگرام لینک در هر دو حالت در مرورگر خارجی باز میشود (در غیر این صورت پرداخت از طریق اپ بانکی/SBP کار نمیکند)؛ پس از پرداخت کاربر به صفحهٔ نتیجه بازمیگردد. Stars/CryptoBot (لینکهای t.me/) همیشه بهصورت نیتیو باز میشوند و این پرچم روی آنها اعمال نمیشود.",
|
||||
"displayName": "نام نمایشی",
|
||||
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
||||
"subOptions": "گزینههای پرداخت",
|
||||
@@ -2269,7 +2305,31 @@
|
||||
"noPromoGroups": "هیچ گروه تبلیغاتی نیست",
|
||||
"cancelButton": "لغو",
|
||||
"saveButton": "ذخیره",
|
||||
"notFound": "روش پرداخت یافت نشد"
|
||||
"notFound": "روش پرداخت یافت نشد",
|
||||
"quickAmounts": "مبالغ سریع (₽)",
|
||||
"quickAmountsHint": "دکمههای مبلغ شارژ سریع. خالی — مقادیر پیشفرض استفاده میشوند: {{defaults}} ₽",
|
||||
"quickAmountsPlaceholder": "مبلغ به روبل",
|
||||
"quickAmountsAdd": "افزودن",
|
||||
"quickAmountsInvalid": "یک مبلغ مثبت به روبل وارد کنید",
|
||||
"quickAmountsLimit": "حداکثر ۱۰ مبلغ مجاز است",
|
||||
"quickAmountsRemove": "حذف مبلغ {{value}} ₽",
|
||||
"overpayCertTitle": "گواهی mTLS (Overpay)",
|
||||
"overpayCertValid": "✅ معتبر تا {{date}}",
|
||||
"overpayCertExpired": "⚠️ در تاریخ {{date}} منقضی شده است",
|
||||
"overpayCertUnreadable": "⚠️ گواهی قابل خواندن نیست — رمز نادرست یا فایل خراب است",
|
||||
"overpayCertMissing": "❌ گواهی بارگذاری نشده است",
|
||||
"overpayCertEnvLockedPath": "مسیر از طریق متغیر محیطی OVERPAY_P12_PATH تنظیم شده است — مقدار پایگاه داده اعمال نمیشود",
|
||||
"overpayCertEnvLockedPassphrase": "رمز از طریق متغیر محیطی OVERPAY_P12_PASSPHRASE تنظیم شده است — مقدار پایگاه داده اعمال نمیشود",
|
||||
"overpayCertFile": "فایل گواهی (.p12 / .pfx)",
|
||||
"overpayCertChooseFile": "انتخاب فایل",
|
||||
"overpayCertPassphrase": "رمز گواهی",
|
||||
"overpayCertPassphrasePlaceholder": "اگر رمزی ندارد خالی بگذارید",
|
||||
"overpayCertUpload": "بارگذاری",
|
||||
"overpayCertDelete": "حذف",
|
||||
"overpayCertConfirmDelete": "گواهی Overpay حذف شود؟",
|
||||
"overpayCertFileTooLarge": "حجم فایل بیش از ۱ مگابایت است",
|
||||
"overpayCertUploadError": "بارگذاری گواهی ناموفق بود",
|
||||
"overpayCertDeleteError": "حذف گواهی ناموفق بود"
|
||||
},
|
||||
"campaigns": {
|
||||
"title": "کمپینهای تبلیغاتی",
|
||||
@@ -3778,7 +3838,8 @@
|
||||
"sortOrder": "ترتیب",
|
||||
"icon": "آیکون (ایموجی)",
|
||||
"pageType": "نوع صفحه",
|
||||
"replacesTab": "جایگزینی تب"
|
||||
"replacesTab": "جایگزینی تب",
|
||||
"displayMode": "نمایش"
|
||||
},
|
||||
"replacesTabNone": "بدون جایگزینی",
|
||||
"replacesTabOptions": {
|
||||
@@ -3820,6 +3881,11 @@
|
||||
"en": "انگلیسی",
|
||||
"zh": "چینی",
|
||||
"fa": "فارسی"
|
||||
},
|
||||
"displayModes": {
|
||||
"bot": "فقط ربات",
|
||||
"web": "فقط وب",
|
||||
"both": "ربات و وب"
|
||||
}
|
||||
},
|
||||
"bulkActions": {
|
||||
@@ -3930,6 +3996,43 @@
|
||||
"trafficGbUnit": "GB",
|
||||
"selectAllSubs": "انتخاب همه اشتراکها",
|
||||
"deselectAllSubs": "لغو انتخاب همه اشتراکها"
|
||||
},
|
||||
"legalPages": {
|
||||
"title": "صفحات سیستمی",
|
||||
"subtitle": "قوانین، سیاست حریم خصوصی، پیشنهاد عمومی و FAQ",
|
||||
"open": "صفحات سیستمی",
|
||||
"tabs": {
|
||||
"privacy": "حریم خصوصی",
|
||||
"offer": "پیشنهاد",
|
||||
"rules": "قوانین",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"displayMode": "نمایش",
|
||||
"displayModes": {
|
||||
"bot": "فقط ربات",
|
||||
"web": "فقط وب",
|
||||
"both": "ربات و وب"
|
||||
},
|
||||
"displayModeLocked": "از طریق متغیر محیطی تنظیم شده",
|
||||
"enabled": "نمایش به کاربران",
|
||||
"language": "زبان محتوا",
|
||||
"content": "متن",
|
||||
"contentPlaceholder": "متن HTML صفحه...",
|
||||
"save": "ذخیره",
|
||||
"saving": "در حال ذخیره...",
|
||||
"saveError": "ذخیره ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"unsavedWarning": "تغییرات ذخیرهنشده از بین میرود. ادامه میدهید؟",
|
||||
"faqPages": "سوالات و پاسخها",
|
||||
"addQuestion": "افزودن سوال",
|
||||
"newQuestion": "سوال جدید",
|
||||
"questionTitle": "سوال",
|
||||
"questionContent": "پاسخ (HTML پشتیبانی میشود)",
|
||||
"confirmDeleteQuestion": "این سوال حذف شود؟",
|
||||
"noQuestions": "هنوز سوالی وجود ندارد",
|
||||
"active": "فعال",
|
||||
"moveUp": "انتقال به بالا",
|
||||
"moveDown": "انتقال به پایین",
|
||||
"delete": "حذف"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -4791,7 +4894,7 @@
|
||||
},
|
||||
"subscriptions": {
|
||||
"buy": "خرید اشتراک",
|
||||
"buyAnother": "خرید اشتراک دیگر",
|
||||
"buyAnother": "خرید دیگر",
|
||||
"browsePlans": "مشاهده تعرفهها و خرید اشتراک",
|
||||
"empty": "هنوز اشتراکی ندارید",
|
||||
"emptyDesc": "برای شروع تعرفهای انتخاب کنید",
|
||||
|
||||
@@ -28,10 +28,9 @@
|
||||
"saving": "Сохранение...",
|
||||
"understand": "Понятно",
|
||||
"units": {
|
||||
"tb": "ТБ",
|
||||
"gb": "ГБ",
|
||||
"perGb": "/ГБ",
|
||||
"days": "дн",
|
||||
"mo": "мес"
|
||||
"mb": "МБ"
|
||||
},
|
||||
"balance": "Баланс",
|
||||
"copied": "Скопировано",
|
||||
@@ -613,8 +612,8 @@
|
||||
"unlimited": "Безлимит",
|
||||
"buyTrafficGb": "Купить {{gb}} ГБ",
|
||||
"buyUnlimited": "Купить Безлимит",
|
||||
"manageServers": "Управление сквадами",
|
||||
"manageServersTitle": "Управление сквадами",
|
||||
"manageServers": "Управление локациями",
|
||||
"manageServersTitle": "Управление локациями",
|
||||
"connectedDevices": "Подключённые устройства",
|
||||
"currentDeviceLimit": "Текущий лимит устройств",
|
||||
"decrementDevices": "Уменьшить кол-во устройств",
|
||||
@@ -816,6 +815,13 @@
|
||||
"not_yet_valid": "Промокод ещё не начал действовать",
|
||||
"used": "Промокод уже использован",
|
||||
"already_used_by_user": "Вы уже использовали этот промокод",
|
||||
"active_discount_exists": "У вас уже есть активная скидка. Сначала отключите её.",
|
||||
"no_subscription_for_days": "Для этого промокода нужна активная или истёкшая подписка",
|
||||
"subscription_not_found": "Подписка не найдена",
|
||||
"not_first_purchase": "Промокод доступен только для первой покупки",
|
||||
"daily_limit": "Слишком много активаций промокодов за сегодня",
|
||||
"trial_subscription_exists": "У вас уже есть подписка, поэтому триал-промокод применить нельзя",
|
||||
"trial_provisioning_failed": "Не удалось выдать триал прямо сейчас, попробуйте позже",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"server_error": "Ошибка сервера"
|
||||
},
|
||||
@@ -1068,7 +1074,8 @@
|
||||
"openSupport": "Открыть поддержку",
|
||||
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
|
||||
"contactUs": "Связаться с поддержкой",
|
||||
"create_ticket": "Создать тикет"
|
||||
"create_ticket": "Создать тикет",
|
||||
"writeButton": "Написать"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "Колесо удачи",
|
||||
@@ -1142,7 +1149,7 @@
|
||||
"globalOpacity": "Прозрачность",
|
||||
"globalBlur": "Размытие",
|
||||
"categoryCSS": "CSS",
|
||||
"categoryCanvas": "Canvas",
|
||||
"categoryCANVAS": "Canvas",
|
||||
"categorySVG": "SVG",
|
||||
"aurora": "Aurora",
|
||||
"auroraDesc": "Северное сияние с плавными переливами",
|
||||
@@ -1176,6 +1183,18 @@
|
||||
"noiseDesc": "Шумовые градиенты с движением",
|
||||
"ripple": "Ripple",
|
||||
"rippleDesc": "Волновой эффект при клике",
|
||||
"fireflies": "Fireflies",
|
||||
"firefliesDesc": "Дрейфующие светящиеся точки с пульсацией",
|
||||
"snowfall": "Snowfall",
|
||||
"snowfallDesc": "Падающий снег с ветром",
|
||||
"starfield": "Starfield",
|
||||
"starfieldDesc": "Полёт сквозь звёздное небо",
|
||||
"matrixRain": "Matrix Rain",
|
||||
"matrixRainDesc": "Падающие колонки символов",
|
||||
"liquidGradient": "Liquid Gradient",
|
||||
"liquidGradientDesc": "Жидкий mesh-градиент из размытых блобов",
|
||||
"constellation": "Constellation",
|
||||
"constellationDesc": "Частицы, связанные линиями",
|
||||
"geminiEffect": "Gemini Effect",
|
||||
"geminiEffectDesc": "SVG-эффект как на Google Gemini",
|
||||
"speed": "Скорость",
|
||||
@@ -1191,6 +1210,15 @@
|
||||
"particleColor": "Цвет частиц",
|
||||
"starColor": "Цвет звёзд",
|
||||
"trailColor": "Цвет следа",
|
||||
"bgStarColor": "Цвет фоновых звёзд",
|
||||
"pointerColor": "Цвет курсорного блоба",
|
||||
"beamColor": "Цвет лучей",
|
||||
"explosionColor": "Цвет взрыва",
|
||||
"multicolor": "Разноцветный режим",
|
||||
"lineColor": "Цвет линий",
|
||||
"wind": "Ветер",
|
||||
"charset": "Набор символов",
|
||||
"linkDistance": "Дистанция связи",
|
||||
"bgColor": "Цвет фона",
|
||||
"fillColor": "Цвет заливки",
|
||||
"color1": "Цвет 1",
|
||||
@@ -1251,7 +1279,8 @@
|
||||
"referralNetwork": "Реферальная сеть",
|
||||
"news": "Новости",
|
||||
"bulkActions": "Массовые действия",
|
||||
"infoPages": "Инфо-страницы"
|
||||
"infoPages": "Инфо-страницы",
|
||||
"legalPages": "Системные страницы"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -1556,9 +1585,14 @@
|
||||
"resetted": "Шаблон сброшен к значению по умолчанию",
|
||||
"testSent": "Тестовое письмо отправлено",
|
||||
"variables": "Доступные переменные",
|
||||
"clickToCopy": "Нажмите для копирования",
|
||||
"previewDesktopOnly": "Предпросмотр доступен только в веб-версии на десктопе",
|
||||
"previewNotAvailable": "Предпросмотр недоступен"
|
||||
"editorTab": "Редактор",
|
||||
"previewHint": "Превью показывает письмо с примерами значений вместо переменных.",
|
||||
"clickToInsert": "Нажмите, чтобы вставить в текст",
|
||||
"insertDefault": "Вставить шаблон по умолчанию",
|
||||
"insertDefaultConfirm": "Заменить текущее содержимое шаблоном по умолчанию?",
|
||||
"testRecipientPlaceholder": "Email получателя (по умолчанию — ваш)",
|
||||
"testHint": "Отправит текущее содержимое редактора с примерами значений вместо переменных.",
|
||||
"variablesCommon": "Общие переменные (работают во всех шаблонах)"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Платежи",
|
||||
@@ -1611,12 +1645,36 @@
|
||||
"methodEnabled": "Метод включён",
|
||||
"providerNotConfigured": "Провайдер не настроен в env",
|
||||
"openUrlDirect": "Открывать страницу оплаты сразу",
|
||||
"openUrlDirectHint": "Без панели со ссылкой: провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
|
||||
"openUrlDirectHint": "ВКЛ — ссылка оплаты открывается сразу, без промежуточной панели с кнопкой. ВЫКЛ — показывается кнопка «Открыть оплату». В Telegram ссылка в обоих случаях открывается во внешнем браузере (иначе оплата через банковское приложение/СБП не срабатывает), после оплаты — возврат на страницу результата. Stars/CryptoBot (t.me/-ссылки) всегда открываются нативно, флаг к ним не применяется.",
|
||||
"displayName": "Отображаемое имя",
|
||||
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
||||
"subOptions": "Варианты оплаты",
|
||||
"minAmount": "Мин. сумма (коп.)",
|
||||
"maxAmount": "Макс. сумма (коп.)",
|
||||
"quickAmounts": "Быстрые суммы (₽)",
|
||||
"quickAmountsHint": "Кнопки быстрого выбора суммы пополнения. Пусто — используются значения по умолчанию: {{defaults}} ₽",
|
||||
"quickAmountsPlaceholder": "Сумма в рублях",
|
||||
"quickAmountsAdd": "Добавить",
|
||||
"quickAmountsInvalid": "Введите положительную сумму в рублях",
|
||||
"quickAmountsLimit": "Не более 10 сумм",
|
||||
"quickAmountsRemove": "Удалить сумму {{value}} ₽",
|
||||
"overpayCertTitle": "Сертификат mTLS (Overpay)",
|
||||
"overpayCertValid": "✅ Действует до {{date}}",
|
||||
"overpayCertExpired": "⚠️ Срок действия истёк {{date}}",
|
||||
"overpayCertUnreadable": "⚠️ Сертификат не читается — неверный пароль или повреждённый файл",
|
||||
"overpayCertMissing": "❌ Сертификат не загружен",
|
||||
"overpayCertEnvLockedPath": "Путь задан через переменную окружения OVERPAY_P12_PATH — значение из БД не применяется",
|
||||
"overpayCertEnvLockedPassphrase": "Пароль задан через переменную окружения OVERPAY_P12_PASSPHRASE — значение из БД не применяется",
|
||||
"overpayCertFile": "Файл сертификата (.p12 / .pfx)",
|
||||
"overpayCertChooseFile": "Выбрать файл",
|
||||
"overpayCertPassphrase": "Пароль сертификата",
|
||||
"overpayCertPassphrasePlaceholder": "Пусто, если без пароля",
|
||||
"overpayCertUpload": "Загрузить",
|
||||
"overpayCertDelete": "Удалить",
|
||||
"overpayCertConfirmDelete": "Удалить сертификат Overpay?",
|
||||
"overpayCertFileTooLarge": "Файл больше 1 МБ",
|
||||
"overpayCertUploadError": "Не удалось загрузить сертификат",
|
||||
"overpayCertDeleteError": "Не удалось удалить сертификат",
|
||||
"conditions": "Условия отображения",
|
||||
"userTypeFilter": "Тип пользователя",
|
||||
"userTypeAll": "Все",
|
||||
@@ -2020,6 +2078,8 @@
|
||||
"quickToggles": "Быстрые переключатели",
|
||||
"modified": "Изменено",
|
||||
"readOnly": "Только чтение",
|
||||
"envLocked": "Задано в .env",
|
||||
"envLockedHint": "Значение задано в файле .env и перекрывает базу данных — здесь его изменить нельзя. Чтобы управлять настройкой из кабинета, удалите ключ из .env и перезапустите бота.",
|
||||
"badgeDb": "БД",
|
||||
"badgeEnv": "ENV",
|
||||
"reset": "Сбросить",
|
||||
@@ -4607,7 +4667,8 @@
|
||||
"sortOrder": "Порядок сортировки",
|
||||
"icon": "Иконка (эмодзи)",
|
||||
"pageType": "Тип страницы",
|
||||
"replacesTab": "Заменяет таб"
|
||||
"replacesTab": "Заменяет таб",
|
||||
"displayMode": "Отображение"
|
||||
},
|
||||
"replacesTabNone": "Не заменяет",
|
||||
"replacesTabOptions": {
|
||||
@@ -4616,6 +4677,11 @@
|
||||
"privacy": "Конфиденциальность",
|
||||
"offer": "Оферта"
|
||||
},
|
||||
"displayModes": {
|
||||
"bot": "Только бот",
|
||||
"web": "Только веб",
|
||||
"both": "Бот и веб"
|
||||
},
|
||||
"replacesTabConflict": "уже занят",
|
||||
"replacesTabWarning": "Другая страница уже заменяет этот таб. При сохранении она будет снята.",
|
||||
"filter": {
|
||||
@@ -4650,6 +4716,43 @@
|
||||
"zh": "Китайский",
|
||||
"fa": "Персидский"
|
||||
}
|
||||
},
|
||||
"legalPages": {
|
||||
"title": "Системные страницы",
|
||||
"subtitle": "Правила, политика конфиденциальности, оферта и FAQ",
|
||||
"open": "Системные страницы",
|
||||
"tabs": {
|
||||
"privacy": "Политика конф.",
|
||||
"offer": "Оферта",
|
||||
"rules": "Правила",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"displayMode": "Отображение",
|
||||
"displayModes": {
|
||||
"bot": "Только бот",
|
||||
"web": "Только веб",
|
||||
"both": "Бот и веб"
|
||||
},
|
||||
"displayModeLocked": "Задано через переменную окружения",
|
||||
"enabled": "Показывать пользователям",
|
||||
"language": "Язык контента",
|
||||
"content": "Текст",
|
||||
"contentPlaceholder": "HTML-текст страницы...",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение...",
|
||||
"saveError": "Не удалось сохранить. Попробуйте ещё раз.",
|
||||
"unsavedWarning": "Несохранённые изменения будут потеряны. Продолжить?",
|
||||
"faqPages": "Вопросы и ответы",
|
||||
"addQuestion": "Добавить вопрос",
|
||||
"newQuestion": "Новый вопрос",
|
||||
"questionTitle": "Вопрос",
|
||||
"questionContent": "Ответ (поддерживается HTML)",
|
||||
"confirmDeleteQuestion": "Удалить этот вопрос?",
|
||||
"noQuestions": "Вопросов пока нет",
|
||||
"active": "Активен",
|
||||
"moveUp": "Переместить вверх",
|
||||
"moveDown": "Переместить вниз",
|
||||
"delete": "Удалить"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -5719,7 +5822,7 @@
|
||||
},
|
||||
"subscriptions": {
|
||||
"buy": "Купить подписку",
|
||||
"buyAnother": "Купить ещё подписку",
|
||||
"buyAnother": "Купить ещё",
|
||||
"browsePlans": "Посмотреть тарифы и купить подписку",
|
||||
"empty": "У вас пока нет подписок",
|
||||
"emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом",
|
||||
|
||||
@@ -28,10 +28,9 @@
|
||||
"understand": "明白了",
|
||||
"collapse": "收起",
|
||||
"units": {
|
||||
"tb": "TB",
|
||||
"gb": "GB",
|
||||
"perGb": "/GB",
|
||||
"days": "天",
|
||||
"mo": "月"
|
||||
"mb": "MB"
|
||||
},
|
||||
"balance": "余额",
|
||||
"copied": "已复制",
|
||||
@@ -674,6 +673,13 @@
|
||||
"not_yet_valid": "优惠码尚未生效",
|
||||
"used": "优惠码已被使用",
|
||||
"already_used_by_user": "您已使用过此优惠码",
|
||||
"active_discount_exists": "您已有一个生效中的折扣,请先停用它。",
|
||||
"no_subscription_for_days": "此优惠码需要有效或已过期的订阅",
|
||||
"subscription_not_found": "未找到订阅",
|
||||
"not_first_purchase": "此优惠码仅限首次购买使用",
|
||||
"daily_limit": "今日优惠码激活次数过多",
|
||||
"trial_subscription_exists": "您已有订阅,因此无法使用此试用优惠码",
|
||||
"trial_provisioning_failed": "暂时无法开通试用,请稍后再试",
|
||||
"user_not_found": "用户不存在",
|
||||
"server_error": "服务器错误"
|
||||
},
|
||||
@@ -922,7 +928,8 @@
|
||||
"openSupport": "打开支持",
|
||||
"ticketsDisabled": "工单已禁用",
|
||||
"useExternalLink": "请使用外部链接获取支持",
|
||||
"create_ticket": "创建工单"
|
||||
"create_ticket": "创建工单",
|
||||
"writeButton": "发消息"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "幸运转盘",
|
||||
@@ -994,7 +1001,7 @@
|
||||
"globalOpacity": "透明度",
|
||||
"globalBlur": "模糊",
|
||||
"categoryCSS": "CSS",
|
||||
"categoryCanvas": "Canvas",
|
||||
"categoryCANVAS": "Canvas",
|
||||
"categorySVG": "SVG",
|
||||
"aurora": "极光",
|
||||
"auroraDesc": "柔和渐变的北极光",
|
||||
@@ -1028,6 +1035,18 @@
|
||||
"noiseDesc": "带运动的噪声渐变",
|
||||
"ripple": "涟漪",
|
||||
"rippleDesc": "涟漪波纹效果",
|
||||
"fireflies": "萤火虫",
|
||||
"firefliesDesc": "漂浮闪烁的发光点",
|
||||
"snowfall": "降雪",
|
||||
"snowfallDesc": "随风飘落的雪花",
|
||||
"starfield": "星空",
|
||||
"starfieldDesc": "向观察者飞来的星星",
|
||||
"matrixRain": "黑客帝国雨",
|
||||
"matrixRainDesc": "下落的字符列",
|
||||
"liquidGradient": "流体渐变",
|
||||
"liquidGradientDesc": "模糊的网格渐变色块",
|
||||
"constellation": "星座",
|
||||
"constellationDesc": "由线条连接的粒子",
|
||||
"geminiEffect": "Gemini效果",
|
||||
"geminiEffectDesc": "类似Google Gemini的SVG效果",
|
||||
"speed": "速度",
|
||||
@@ -1043,6 +1062,15 @@
|
||||
"particleColor": "粒子颜色",
|
||||
"starColor": "星星颜色",
|
||||
"trailColor": "尾迹颜色",
|
||||
"bgStarColor": "背景星星颜色",
|
||||
"pointerColor": "指针颜色",
|
||||
"beamColor": "光束颜色",
|
||||
"explosionColor": "爆炸颜色",
|
||||
"multicolor": "多彩模式",
|
||||
"lineColor": "线条颜色",
|
||||
"wind": "风力",
|
||||
"charset": "字符集",
|
||||
"linkDistance": "连接距离",
|
||||
"bgColor": "背景颜色",
|
||||
"fillColor": "填充颜色",
|
||||
"color1": "颜色1",
|
||||
@@ -1102,7 +1130,8 @@
|
||||
"landings": "落地页",
|
||||
"referralNetwork": "推荐网络",
|
||||
"news": "新闻",
|
||||
"infoPages": "信息页面"
|
||||
"infoPages": "信息页面",
|
||||
"legalPages": "系统页面"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -1404,7 +1433,7 @@
|
||||
"methodEnabled": "方法已启用",
|
||||
"providerNotConfigured": "提供商未在env中配置",
|
||||
"openUrlDirect": "直接打开支付页面",
|
||||
"openUrlDirectHint": "跳过链接面板:点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
|
||||
"openUrlDirectHint": "开启 — 支付链接立即打开,不显示中间的按钮面板。关闭 — 显示「打开支付」按钮。在 Telegram 内两种情况都会在外部浏览器中打开链接(否则通过银行 App/SBP 付款会失败);支付完成后返回结果页。Stars/CryptoBot(t.me/ 链接)始终原生打开,不受此开关影响。",
|
||||
"displayName": "显示名称",
|
||||
"displayNameHint": "留空以使用默认名称",
|
||||
"subOptions": "支付选项",
|
||||
@@ -1428,7 +1457,31 @@
|
||||
"noPromoGroups": "没有促销组",
|
||||
"cancelButton": "取消",
|
||||
"saveButton": "保存",
|
||||
"notFound": "未找到支付方式"
|
||||
"notFound": "未找到支付方式",
|
||||
"quickAmounts": "快捷金额(₽)",
|
||||
"quickAmountsHint": "快捷充值金额按钮。留空 — 使用默认值:{{defaults}} ₽",
|
||||
"quickAmountsPlaceholder": "金额(卢布)",
|
||||
"quickAmountsAdd": "添加",
|
||||
"quickAmountsInvalid": "请输入正的卢布金额",
|
||||
"quickAmountsLimit": "最多 10 个金额",
|
||||
"quickAmountsRemove": "删除金额 {{value}} ₽",
|
||||
"overpayCertTitle": "mTLS 证书(Overpay)",
|
||||
"overpayCertValid": "✅ 有效期至 {{date}}",
|
||||
"overpayCertExpired": "⚠️ 已于 {{date}} 过期",
|
||||
"overpayCertUnreadable": "⚠️ 证书无法读取——密码错误或文件损坏",
|
||||
"overpayCertMissing": "❌ 未上传证书",
|
||||
"overpayCertEnvLockedPath": "路径已通过环境变量 OVERPAY_P12_PATH 设置——数据库中的值不会生效",
|
||||
"overpayCertEnvLockedPassphrase": "密码已通过环境变量 OVERPAY_P12_PASSPHRASE 设置——数据库中的值不会生效",
|
||||
"overpayCertFile": "证书文件(.p12 / .pfx)",
|
||||
"overpayCertChooseFile": "选择文件",
|
||||
"overpayCertPassphrase": "证书密码",
|
||||
"overpayCertPassphrasePlaceholder": "无密码请留空",
|
||||
"overpayCertUpload": "上传",
|
||||
"overpayCertDelete": "删除",
|
||||
"overpayCertConfirmDelete": "确定删除 Overpay 证书?",
|
||||
"overpayCertFileTooLarge": "文件超过 1 MB",
|
||||
"overpayCertUploadError": "证书上传失败",
|
||||
"overpayCertDeleteError": "证书删除失败"
|
||||
},
|
||||
"emailTemplates": {
|
||||
"title": "邮件模板",
|
||||
@@ -1448,9 +1501,14 @@
|
||||
"resetted": "模板已恢复默认",
|
||||
"testSent": "测试邮件已发送",
|
||||
"variables": "可用变量",
|
||||
"clickToCopy": "点击复制",
|
||||
"previewDesktopOnly": "预览仅在桌面网页版可用",
|
||||
"previewNotAvailable": "预览不可用"
|
||||
"editorTab": "编辑器",
|
||||
"previewHint": "预览以示例值代替变量显示邮件。",
|
||||
"clickToInsert": "点击插入到文本中",
|
||||
"insertDefault": "插入默认模板",
|
||||
"insertDefaultConfirm": "用默认模板替换当前内容?",
|
||||
"testRecipientPlaceholder": "收件人邮箱(默认为您的邮箱)",
|
||||
"testHint": "发送当前编辑器内容,变量将替换为示例值。",
|
||||
"variablesCommon": "通用变量(适用于所有模板)"
|
||||
},
|
||||
"payments": {
|
||||
"title": "支付",
|
||||
@@ -1738,6 +1796,8 @@
|
||||
"quickToggles": "快速开关",
|
||||
"modified": "已修改",
|
||||
"readOnly": "只读",
|
||||
"envLocked": "在 .env 中设置",
|
||||
"envLockedHint": "该值在 .env 文件中设置并覆盖数据库,因此无法在此处更改。若要从面板管理此设置,请从 .env 中删除该键并重启机器人。",
|
||||
"reset": "重置",
|
||||
"categoriesCount": "个分类",
|
||||
"settingsCount": "个设置",
|
||||
@@ -3777,7 +3837,8 @@
|
||||
"sortOrder": "排序",
|
||||
"icon": "图标(表情)",
|
||||
"pageType": "页面类型",
|
||||
"replacesTab": "替换标签"
|
||||
"replacesTab": "替换标签",
|
||||
"displayMode": "显示"
|
||||
},
|
||||
"replacesTabNone": "不替换",
|
||||
"replacesTabOptions": {
|
||||
@@ -3819,6 +3880,11 @@
|
||||
"en": "英语",
|
||||
"zh": "中文",
|
||||
"fa": "波斯语"
|
||||
},
|
||||
"displayModes": {
|
||||
"bot": "仅机器人",
|
||||
"web": "仅网页",
|
||||
"both": "机器人和网页"
|
||||
}
|
||||
},
|
||||
"bulkActions": {
|
||||
@@ -3929,6 +3995,43 @@
|
||||
"trafficGbUnit": "GB",
|
||||
"selectAllSubs": "选择所有订阅",
|
||||
"deselectAllSubs": "取消选择所有订阅"
|
||||
},
|
||||
"legalPages": {
|
||||
"title": "系统页面",
|
||||
"subtitle": "规则、隐私政策、公开要约和 FAQ",
|
||||
"open": "系统页面",
|
||||
"tabs": {
|
||||
"privacy": "隐私",
|
||||
"offer": "条款",
|
||||
"rules": "规则",
|
||||
"faq": "FAQ"
|
||||
},
|
||||
"displayMode": "显示",
|
||||
"displayModes": {
|
||||
"bot": "仅机器人",
|
||||
"web": "仅网页",
|
||||
"both": "机器人和网页"
|
||||
},
|
||||
"displayModeLocked": "通过环境变量设置",
|
||||
"enabled": "对用户可见",
|
||||
"language": "内容语言",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "HTML 页面内容...",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"saveError": "保存失败,请重试。",
|
||||
"unsavedWarning": "未保存的更改将丢失。是否继续?",
|
||||
"faqPages": "问答",
|
||||
"addQuestion": "添加问题",
|
||||
"newQuestion": "新问题",
|
||||
"questionTitle": "问题",
|
||||
"questionContent": "答案(支持 HTML)",
|
||||
"confirmDeleteQuestion": "确定删除此问题?",
|
||||
"noQuestions": "暂无问题",
|
||||
"active": "已激活",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"delete": "删除"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -4790,7 +4893,7 @@
|
||||
},
|
||||
"subscriptions": {
|
||||
"buy": "购买订阅",
|
||||
"buyAnother": "再购买一个订阅",
|
||||
"buyAnother": "再购买",
|
||||
"browsePlans": "查看套餐并订阅",
|
||||
"empty": "您还没有订阅",
|
||||
"emptyDesc": "选择套餐以开始使用",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isFullscreen,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { clearStaleSessionIfNeeded } from './utils/token';
|
||||
import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import { AppWithNavigator } from './AppWithNavigator';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
@@ -29,6 +30,12 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra
|
||||
import { applyTelegramLanguage } from './i18n';
|
||||
import './styles/globals.css';
|
||||
|
||||
// Harden the global encoders against lone UTF-16 surrogates (truncated emoji in
|
||||
// backend names/remarks) BEFORE anything renders or fetches — otherwise such a
|
||||
// string crashes any encodeURI/encodeURIComponent/btoa path on iOS WebKit,
|
||||
// including qrcode.react's internal encodeURI. See installEncodingSurrogateGuard.
|
||||
installEncodingSurrogateGuard();
|
||||
|
||||
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
||||
// @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally.
|
||||
// Without this, init() throws LaunchParamsRetrieveError on affected devices.
|
||||
|
||||
@@ -545,7 +545,7 @@ export default function AdminAuditLog() {
|
||||
handleExport();
|
||||
}}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span className="hidden sm:inline">
|
||||
@@ -614,7 +614,7 @@ export default function AdminAuditLog() {
|
||||
<span
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
|
||||
isSelected
|
||||
? 'border-accent-500 bg-accent-500 text-white'
|
||||
? 'border-accent-500 bg-accent-500 text-on-accent'
|
||||
: 'border-dark-500 bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
@@ -734,7 +734,7 @@ export default function AdminAuditLog() {
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('admin.auditLog.filters.apply')}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
banSystemApi,
|
||||
type BanSystemStatus,
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
ExclamationIcon,
|
||||
BackIcon,
|
||||
XIcon,
|
||||
ClockIcon,
|
||||
StatusIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
type TabType =
|
||||
@@ -51,35 +54,6 @@ type TabType =
|
||||
| 'reports'
|
||||
| 'health';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
color: 'accent' | 'success' | 'warning' | 'error' | 'info';
|
||||
}
|
||||
|
||||
function StatCard({ title, value, subtitle, icon, color }: StatCardProps) {
|
||||
const colorClasses = {
|
||||
accent: 'bg-accent-500/20 text-accent-400',
|
||||
success: 'bg-success-500/20 text-success-400',
|
||||
warning: 'bg-warning-500/20 text-warning-400',
|
||||
error: 'bg-error-500/20 text-error-400',
|
||||
info: 'bg-info-500/20 text-info-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 backdrop-blur transition-colors hover:border-dark-600">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>{icon}</div>
|
||||
</div>
|
||||
<div className="mb-1 text-2xl font-bold text-dark-100">{value}</div>
|
||||
<div className="text-sm text-dark-400">{title}</div>
|
||||
{subtitle && <div className="mt-1 text-xs text-dark-500">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBanSystem() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('dashboard');
|
||||
@@ -504,58 +478,58 @@ export default function AdminBanSystem() {
|
||||
{activeTab === 'dashboard' && stats && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t('banSystem.stats.activeUsers')}
|
||||
label={t('banSystem.stats.activeUsers')}
|
||||
value={stats.active_users}
|
||||
subtitle={`${t('banSystem.stats.total')}: ${stats.total_users}`}
|
||||
icon={<UsersIcon />}
|
||||
color="success"
|
||||
subValue={`${t('banSystem.stats.total')}: ${stats.total_users}`}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.usersOverLimit')}
|
||||
label={t('banSystem.stats.usersOverLimit')}
|
||||
value={stats.users_over_limit}
|
||||
icon={<WarningIcon />}
|
||||
color="warning"
|
||||
icon={<WarningIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.activeBans')}
|
||||
label={t('banSystem.stats.activeBans')}
|
||||
value={stats.active_punishments}
|
||||
subtitle={`${t('banSystem.stats.total')}: ${stats.total_punishments}`}
|
||||
icon={<BanIcon />}
|
||||
color="error"
|
||||
subValue={`${t('banSystem.stats.total')}: ${stats.total_punishments}`}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.nodesOnline')}
|
||||
label={t('banSystem.stats.nodesOnline')}
|
||||
value={`${stats.nodes_online}/${stats.nodes_total}`}
|
||||
icon={<ServerIcon />}
|
||||
color="accent"
|
||||
icon={<ServerIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.agentsOnline')}
|
||||
label={t('banSystem.stats.agentsOnline')}
|
||||
value={`${stats.agents_online}/${stats.agents_total}`}
|
||||
icon={<AgentIcon />}
|
||||
color="info"
|
||||
icon={<AgentIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.totalRequests')}
|
||||
label={t('banSystem.stats.totalRequests')}
|
||||
value={stats.total_requests.toLocaleString()}
|
||||
icon={<ChartIcon className="h-5 w-5" />}
|
||||
color="accent"
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.panelStatus')}
|
||||
label={t('banSystem.stats.panelStatus')}
|
||||
value={
|
||||
stats.panel_connected
|
||||
? t('banSystem.stats.connected')
|
||||
: t('banSystem.stats.disconnected')
|
||||
}
|
||||
icon={<ServerIcon />}
|
||||
color={stats.panel_connected ? 'success' : 'error'}
|
||||
icon={<StatusIcon className="h-5 w-5" />}
|
||||
tone={stats.panel_connected ? 'success' : 'error'}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.stats.uptime')}
|
||||
label={t('banSystem.stats.uptime')}
|
||||
value={formatUptime(stats.uptime_seconds)}
|
||||
icon={<ChartIcon className="h-5 w-5" />}
|
||||
color="info"
|
||||
icon={<ClockIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -777,29 +751,29 @@ export default function AdminBanSystem() {
|
||||
{agents?.summary && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t('banSystem.agents.online')}
|
||||
label={t('banSystem.agents.online')}
|
||||
value={`${agents.summary.online_agents}/${agents.summary.total_agents}`}
|
||||
icon={<AgentIcon />}
|
||||
color="success"
|
||||
icon={<AgentIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.agents.totalSent')}
|
||||
label={t('banSystem.agents.totalSent')}
|
||||
value={agents.summary.total_sent.toLocaleString()}
|
||||
icon={<ChartIcon className="h-5 w-5" />}
|
||||
color="accent"
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.agents.totalDropped')}
|
||||
label={t('banSystem.agents.totalDropped')}
|
||||
value={agents.summary.total_dropped.toLocaleString()}
|
||||
icon={<WarningIcon />}
|
||||
color="warning"
|
||||
icon={<WarningIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.agents.healthy')}
|
||||
label={t('banSystem.agents.healthy')}
|
||||
value={agents.summary.healthy_count}
|
||||
subtitle={`${t('banSystem.agents.warning')}: ${agents.summary.warning_count}, ${t('banSystem.agents.critical')}: ${agents.summary.critical_count}`}
|
||||
icon={<AgentIcon />}
|
||||
color="info"
|
||||
subValue={`${t('banSystem.agents.warning')}: ${agents.summary.warning_count}, ${t('banSystem.agents.critical')}: ${agents.summary.critical_count}`}
|
||||
icon={<AgentIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -952,10 +926,10 @@ export default function AdminBanSystem() {
|
||||
{/* Traffic Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t('banSystem.traffic.enabled')}
|
||||
label={t('banSystem.traffic.enabled')}
|
||||
value={traffic.enabled ? t('common.yes') : t('common.no')}
|
||||
icon={<TrafficIcon />}
|
||||
color={traffic.enabled ? 'success' : 'warning'}
|
||||
icon={<TrafficIcon className="h-5 w-5" />}
|
||||
tone={traffic.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1086,16 +1060,16 @@ export default function AdminBanSystem() {
|
||||
{/* Report Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t('banSystem.reports.currentUsers')}
|
||||
label={t('banSystem.reports.currentUsers')}
|
||||
value={report.current_users}
|
||||
icon={<UsersIcon />}
|
||||
color="accent"
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('banSystem.reports.currentIps')}
|
||||
label={t('banSystem.reports.currentIps')}
|
||||
value={report.current_ips}
|
||||
icon={<ServerIcon />}
|
||||
color="info"
|
||||
icon={<ServerIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -702,7 +702,7 @@ export default function AdminBroadcastCreate() {
|
||||
onClick={() => toggleButton(button.key)}
|
||||
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
selectedButtons.includes(button.key)
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
@@ -770,7 +770,7 @@ export default function AdminBroadcastCreate() {
|
||||
onClick={() => setNewButtonActionType('callback')}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
newButtonActionType === 'callback'
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
@@ -781,7 +781,7 @@ export default function AdminBroadcastCreate() {
|
||||
onClick={() => setNewButtonActionType('url')}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
newButtonActionType === 'url'
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -3,14 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
BanIcon,
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
PhotoIcon,
|
||||
RefreshIcon,
|
||||
SendIcon,
|
||||
StopIcon,
|
||||
TelegramIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
XCircleIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
// Channel badge component
|
||||
@@ -150,7 +155,7 @@ export default function AdminBroadcastDetail() {
|
||||
<p className="text-dark-400">{t('admin.broadcasts.notFound')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/broadcasts')}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
@@ -205,22 +210,30 @@ export default function AdminBroadcastDetail() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-success-400">{broadcast.sent_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-warning-400">{broadcast.blocked_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.blocked')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-error-400">{broadcast.failed_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.total')}
|
||||
value={broadcast.total_count}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.sent')}
|
||||
value={broadcast.sent_count}
|
||||
icon={<SendIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.blocked')}
|
||||
value={broadcast.blocked_count}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.failed')}
|
||||
value={broadcast.failed_count}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function AdminBroadcasts() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/broadcasts/create')}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
|
||||
|
||||
@@ -79,7 +79,9 @@ function ServerSelector({
|
||||
>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
selected.includes(server.squad_uuid) ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||
selected.includes(server.squad_uuid)
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
{selected.includes(server.squad_uuid) && <CheckIcon />}
|
||||
|
||||
@@ -79,7 +79,9 @@ function ServerSelector({
|
||||
>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
selected.includes(server.squad_uuid) ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||
selected.includes(server.squad_uuid)
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
{selected.includes(server.squad_uuid) && <CheckIcon />}
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
XIcon,
|
||||
ChartIcon,
|
||||
BackIcon,
|
||||
CampaignIcon,
|
||||
BoltIcon,
|
||||
UserPlusIcon,
|
||||
BanknotesIcon,
|
||||
} from '../components/icons';
|
||||
import { StatCard } from '../components/stats';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
|
||||
@@ -131,7 +136,7 @@ export default function AdminCampaigns() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/admin/campaigns/create')}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.campaigns.createButton')}
|
||||
@@ -141,30 +146,30 @@ export default function AdminCampaigns() {
|
||||
{/* Overview */}
|
||||
{overview && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{overview.total}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.totalCampaigns')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">{overview.active}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.campaigns.overview.active')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-accent-400">{overview.total_registrations}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.registrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(overview.total_balance_issued_kopeks)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.bonusesIssued')}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.overview.totalCampaigns')}
|
||||
value={overview.total}
|
||||
icon={<CampaignIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.overview.active')}
|
||||
value={overview.active}
|
||||
icon={<BoltIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.overview.registrations')}
|
||||
value={overview.total_registrations}
|
||||
icon={<UserPlusIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.overview.bonusesIssued')}
|
||||
value={formatRubles(overview.total_balance_issued_kopeks)}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -424,7 +424,7 @@ function AddChannelForm({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!channelId.trim() || isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('admin.channelSubscriptions.form.submit')}
|
||||
@@ -499,7 +499,7 @@ function EditChannelForm({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('admin.channelSubscriptions.form.save')}
|
||||
@@ -641,7 +641,7 @@ export default function AdminChannelSubscriptions() {
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
aria-label={t('admin.channelSubscriptions.addChannel')}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.channelSubscriptions.addChannel')}</span>
|
||||
|
||||
@@ -9,11 +9,17 @@ const CABINET_VERSION = __APP_VERSION__;
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
BackIcon,
|
||||
BanknotesIcon,
|
||||
CalendarBlankIcon,
|
||||
CalendarIcon,
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationIcon,
|
||||
MegaphoneIcon,
|
||||
PowerIcon,
|
||||
@@ -21,53 +27,14 @@ import {
|
||||
RestartIcon,
|
||||
ServerIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
UsersOnlineIcon,
|
||||
WalletIcon,
|
||||
XCircleIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
color: 'accent' | 'success' | 'warning' | 'error' | 'info';
|
||||
trend?: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
function StatCard({ title, value, subtitle, icon, color, trend }: StatCardProps) {
|
||||
const colorClasses = {
|
||||
accent: 'bg-accent-500/20 text-accent-400',
|
||||
success: 'bg-success-500/20 text-success-400',
|
||||
warning: 'bg-warning-500/20 text-warning-400',
|
||||
error: 'bg-error-500/20 text-error-400',
|
||||
info: 'bg-info-500/20 text-info-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-5 transition-colors hover:border-dark-600">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className={`rounded-lg p-2.5 ${colorClasses[color]}`}>{icon}</div>
|
||||
{trend && (
|
||||
<div
|
||||
className={`rounded-full px-2 py-1 text-xs ${trend.value >= 0 ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'}`}
|
||||
>
|
||||
{trend.value >= 0 ? '+' : ''}
|
||||
{trend.value}% {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 text-2xl font-bold text-dark-100">{value}</div>
|
||||
<div className="text-sm text-dark-400">{title}</div>
|
||||
{subtitle && <div className="mt-1 text-xs text-dark-500">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NodeCardProps {
|
||||
node: NodeStatus;
|
||||
onRestart: (uuid: string) => void;
|
||||
@@ -343,29 +310,29 @@ export default function AdminDashboard() {
|
||||
{/* Main Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.usersOnline')}
|
||||
label={t('adminDashboard.stats.usersOnline')}
|
||||
value={stats?.nodes.total_users_online || 0}
|
||||
icon={<UsersOnlineIcon />}
|
||||
color="success"
|
||||
icon={<UsersOnlineIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.activeSubscriptions')}
|
||||
label={t('adminDashboard.stats.activeSubscriptions')}
|
||||
value={stats?.subscriptions.active || 0}
|
||||
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
|
||||
icon={<SparklesIcon />}
|
||||
color="accent"
|
||||
subValue={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
|
||||
icon={<SparklesIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.incomeToday')}
|
||||
label={t('adminDashboard.stats.incomeToday')}
|
||||
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<WalletIcon />}
|
||||
color="warning"
|
||||
icon={<WalletIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.incomeMonth')}
|
||||
label={t('adminDashboard.stats.incomeMonth')}
|
||||
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<ChartBarIcon />}
|
||||
color="info"
|
||||
icon={<ChartBarIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -453,22 +420,18 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<RevenueChart data={stats?.revenue_chart || []} />
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 border-t border-dark-700 pt-4">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.stats.incomeTotal')}
|
||||
</div>
|
||||
<div className="text-xl font-bold text-dark-100">
|
||||
{formatAmount(stats?.financial.income_total_rubles || 0)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.stats.subscriptionIncome')}
|
||||
</div>
|
||||
<div className="text-xl font-bold text-accent-400">
|
||||
{formatAmount(stats?.financial.subscription_income_rubles || 0)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('adminDashboard.stats.incomeTotal')}
|
||||
value={`${formatAmount(stats?.financial.income_total_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.stats.subscriptionIncome')}
|
||||
value={`${formatAmount(stats?.financial.subscription_income_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<SparklesIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -488,38 +451,30 @@ export default function AdminDashboard() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.active')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{stats?.subscriptions.active || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.trial')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-warning-400">
|
||||
{stats?.subscriptions.trial || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.paid')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{stats?.subscriptions.paid || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.expired')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-error-400">
|
||||
{stats?.subscriptions.expired || 0}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.active')}
|
||||
value={stats?.subscriptions.active || 0}
|
||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.trial')}
|
||||
value={stats?.subscriptions.trial || 0}
|
||||
icon={<StarIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.paid')}
|
||||
value={stats?.subscriptions.paid || 0}
|
||||
icon={<CreditCardIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.expired')}
|
||||
value={stats?.subscriptions.expired || 0}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-700 pt-4">
|
||||
@@ -527,30 +482,24 @@ export default function AdminDashboard() {
|
||||
{t('adminDashboard.subscriptions.newSubscriptions')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-dark-100">
|
||||
{stats?.subscriptions.purchased_today || 0}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.today')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-dark-100">
|
||||
{stats?.subscriptions.purchased_week || 0}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.week')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-dark-100">
|
||||
{stats?.subscriptions.purchased_month || 0}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('adminDashboard.subscriptions.month')}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.today')}
|
||||
value={stats?.subscriptions.purchased_today || 0}
|
||||
icon={<ClockIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.week')}
|
||||
value={stats?.subscriptions.purchased_week || 0}
|
||||
icon={<CalendarBlankIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.subscriptions.month')}
|
||||
value={stats?.subscriptions.purchased_month || 0}
|
||||
icon={<CalendarIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -741,48 +690,39 @@ export default function AdminDashboard() {
|
||||
|
||||
{/* Period Stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 border-t border-dark-700 pt-4 sm:gap-3">
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.today')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100,
|
||||
)}{' '}
|
||||
{currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.week')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100,
|
||||
)}{' '}
|
||||
{currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.month')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100,
|
||||
)}{' '}
|
||||
{currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('adminDashboard.period.today')}
|
||||
value={`${formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100,
|
||||
)} ${currencySymbol}`}
|
||||
icon={<ClockIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.period.week')}
|
||||
value={`${formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100,
|
||||
)} ${currencySymbol}`}
|
||||
icon={<CalendarBlankIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('adminDashboard.period.month')}
|
||||
value={`${formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
? referrers.by_earnings
|
||||
: referrers.by_invited
|
||||
).reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100,
|
||||
)} ${currencySymbol}`}
|
||||
icon={<CalendarIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
|
||||
import { BackIcon } from '../components/admin';
|
||||
|
||||
interface PreviewState {
|
||||
subject: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export default function AdminEmailTemplatePreview() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { type, lang } = useParams<{ type: string; lang: string }>();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
// Get data from navigation state
|
||||
const state = location.state as PreviewState | null;
|
||||
|
||||
// Preview mutation
|
||||
const {
|
||||
mutate: loadPreview,
|
||||
isPending: previewPending,
|
||||
isError: previewError,
|
||||
} = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!type || !lang || !state) {
|
||||
throw new Error('Missing required data');
|
||||
}
|
||||
return adminEmailTemplatesApi.previewTemplate(type, {
|
||||
language: lang,
|
||||
subject: state.subject,
|
||||
body_html: state.body_html,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
},
|
||||
});
|
||||
|
||||
// Load preview on mount
|
||||
useEffect(() => {
|
||||
if (!type || !lang || !state) {
|
||||
navigate('/admin/email-templates');
|
||||
return;
|
||||
}
|
||||
loadPreview();
|
||||
}, [type, lang, state, navigate, loadPreview]);
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (previewHtml && iframeRef.current) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [previewHtml]);
|
||||
|
||||
if (!type || !lang || !state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-120px)] flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.emailTemplates.preview')}</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{type} / {lang.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview content */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
|
||||
{previewPending ? (
|
||||
<div className="flex h-full items-center justify-center bg-dark-800">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : previewError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
|
||||
<p className="text-dark-400">{t('common.error')}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full w-full"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,33 +8,17 @@ import {
|
||||
EmailTemplateLanguageData,
|
||||
} from '../api/adminEmailTemplates';
|
||||
import { AdminBackButton, BackIcon } from '../components/admin';
|
||||
import { useIsTelegram } from '../platform/hooks/usePlatform';
|
||||
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
|
||||
import { useNotify } from '@/platform';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon } from '@/components/icons';
|
||||
|
||||
// Hook to check if on mobile
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 1024;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 1024);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
import { getApiErrorMessage } from '@/utils/api-error';
|
||||
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon, EditIcon } from '@/components/icons';
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
zh: 'ZH',
|
||||
ua: 'UA',
|
||||
fa: 'FA',
|
||||
};
|
||||
|
||||
const LANG_FULL_LABELS: Record<string, string> = {
|
||||
@@ -43,6 +26,7 @@ const LANG_FULL_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
zh: '中文',
|
||||
ua: 'Українська',
|
||||
fa: 'فارسی',
|
||||
};
|
||||
|
||||
// ============ Template List View ============
|
||||
@@ -102,6 +86,17 @@ function TemplateCard({
|
||||
|
||||
// ============ Template Editor ============
|
||||
|
||||
// Extract body content from full HTML (strip base template wrapper)
|
||||
function extractBodyContent(html: string): string {
|
||||
const contentMatch = html.match(
|
||||
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
|
||||
);
|
||||
if (contentMatch) {
|
||||
return contentMatch[1].trim();
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function TemplateEditor({
|
||||
detail,
|
||||
onClose,
|
||||
@@ -112,56 +107,40 @@ function TemplateEditor({
|
||||
currentLang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
const isTelegram = useIsTelegram();
|
||||
const { confirm: confirmDialog } = useNativeDialog();
|
||||
const isMobile = useIsMobile();
|
||||
const isPreviewDisabled = isTelegram || isMobile;
|
||||
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'preview'>('editor');
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [previewSubject, setPreviewSubject] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||
|
||||
// Load data for current language
|
||||
// Load data for current language (defaults arrive with {placeholders} intact)
|
||||
useEffect(() => {
|
||||
if (langData) {
|
||||
setEditSubject(langData.subject);
|
||||
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
|
||||
setEditBody(
|
||||
langData.is_default ? extractBodyContent(langData.body_html) : langData.body_html,
|
||||
);
|
||||
setIsDirty(false);
|
||||
setActiveTab('editor');
|
||||
}
|
||||
}, [activeLang, langData]);
|
||||
|
||||
// Extract body content from full HTML (strip base template wrapper)
|
||||
const extractBodyContent = useCallback((html: string): string => {
|
||||
// If it's wrapped in the base template, extract just the content div
|
||||
const contentMatch = html.match(
|
||||
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
|
||||
);
|
||||
if (contentMatch) {
|
||||
return contentMatch[1].trim();
|
||||
}
|
||||
return html;
|
||||
}, []);
|
||||
|
||||
// When langData changes (e.g., after refetch), update the body content
|
||||
useEffect(() => {
|
||||
if (langData) {
|
||||
if (langData.is_default) {
|
||||
// For default templates, extract just the content portion
|
||||
setEditBody(extractBodyContent(langData.body_html));
|
||||
} else {
|
||||
setEditBody(langData.body_html);
|
||||
}
|
||||
setEditSubject(langData.subject);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [activeLang, langData, extractBodyContent]);
|
||||
const notifyError = useCallback(
|
||||
(error: unknown) => {
|
||||
notify.error(getApiErrorMessage(error, t('common.error')));
|
||||
},
|
||||
[notify, t],
|
||||
);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
@@ -178,9 +157,7 @@ function TemplateEditor({
|
||||
setIsDirty(false);
|
||||
notify.success(t('admin.emailTemplates.saved'));
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
// Reset mutation
|
||||
@@ -194,33 +171,70 @@ function TemplateEditor({
|
||||
setIsDirty(false);
|
||||
notify.success(t('admin.emailTemplates.resetted'));
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
// Send test mutation
|
||||
// Send test mutation — sends the CURRENT editor content (even unsaved)
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
||||
language: activeLang,
|
||||
email: testEmail.trim(),
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
notify.success(`${t('admin.emailTemplates.testSent')} → ${data.sent_to}`);
|
||||
},
|
||||
onError: () => {
|
||||
notify.error(t('common.error'));
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
const handleSubjectChange = (value: string) => {
|
||||
setEditSubject(value);
|
||||
setIsDirty(true);
|
||||
// Preview mutation — renders current content with sample values
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||
language: activeLang,
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
setPreviewSubject(data.subject);
|
||||
},
|
||||
onError: notifyError,
|
||||
});
|
||||
|
||||
const openPreview = () => {
|
||||
setActiveTab('preview');
|
||||
previewMutation.mutate();
|
||||
};
|
||||
|
||||
const handleBodyChange = (value: string) => {
|
||||
setEditBody(value);
|
||||
const insertVariable = (variable: string) => {
|
||||
const token = `{${variable}}`;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setEditBody(editBody + token);
|
||||
setIsDirty(true);
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? editBody.length;
|
||||
const end = textarea.selectionEnd ?? editBody.length;
|
||||
const next = editBody.slice(0, start) + token + editBody.slice(end);
|
||||
setEditBody(next);
|
||||
setIsDirty(true);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
};
|
||||
|
||||
const insertDefaultTemplate = async () => {
|
||||
if (!langData) return;
|
||||
if (!(await confirmDialog(t('admin.emailTemplates.insertDefaultConfirm')))) return;
|
||||
setEditSubject(langData.default_subject);
|
||||
setEditBody(extractBodyContent(langData.default_body_html));
|
||||
setIsDirty(true);
|
||||
setActiveTab('editor');
|
||||
};
|
||||
|
||||
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
|
||||
@@ -279,60 +293,148 @@ function TemplateEditor({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => handleSubjectChange(e.target.value)}
|
||||
className="input"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
|
||||
/>
|
||||
{/* Editor / Preview tabs */}
|
||||
<div className="flex items-center gap-1 rounded-lg bg-dark-900 p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition-all duration-150 sm:text-sm ${
|
||||
activeTab === 'editor'
|
||||
? 'bg-dark-700 text-dark-100 shadow-sm'
|
||||
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<EditIcon className="h-4 w-4" />
|
||||
{t('admin.emailTemplates.editorTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={openPreview}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition-all duration-150 sm:text-sm ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-dark-700 text-dark-100 shadow-sm'
|
||||
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context variables hint */}
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
|
||||
title={t('admin.emailTemplates.clickToCopy')}
|
||||
onClick={() => {
|
||||
void copyToClipboard(`{${v}}`);
|
||||
notify.success(`Copied {${v}}`);
|
||||
}}
|
||||
>
|
||||
{`{${v}}`}
|
||||
</code>
|
||||
))}
|
||||
{activeTab === 'preview' ? (
|
||||
<div className="space-y-2">
|
||||
{previewSubject && (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 px-3 py-2">
|
||||
<span className="text-xs text-dark-400">{t('admin.emailTemplates.subject')}: </span>
|
||||
<span className="text-sm text-dark-100">{previewSubject}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl border border-dark-700 bg-white">
|
||||
{previewMutation.isPending ? (
|
||||
<div className="flex h-[420px] items-center justify-center bg-dark-800">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
sandbox=""
|
||||
className="h-[420px] w-full sm:h-[560px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xs text-dark-500">{t('admin.emailTemplates.previewHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => {
|
||||
setEditSubject(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="input"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.body')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editBody}
|
||||
onChange={(e) => handleBodyChange(e.target.value)}
|
||||
rows={12}
|
||||
className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm"
|
||||
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
|
||||
</div>
|
||||
{/* Context variables hint: type-specific + common (available in all templates) */}
|
||||
{(detail.context_vars.length > 0 || (detail.common_context_vars?.length ?? 0) > 0) && (
|
||||
<div className="space-y-2.5 rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
|
||||
title={t('admin.emailTemplates.clickToInsert')}
|
||||
onClick={() => insertVariable(v)}
|
||||
>
|
||||
{`{${v}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(detail.common_context_vars?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-400">
|
||||
{t('admin.emailTemplates.variablesCommon')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.common_context_vars!.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className="cursor-pointer rounded bg-dark-800 px-2 py-0.5 font-mono text-xs text-dark-300 ring-1 ring-dark-600 transition-colors hover:bg-dark-700 hover:text-accent-400"
|
||||
title={t('admin.emailTemplates.clickToInsert')}
|
||||
onClick={() => insertVariable(v)}
|
||||
>
|
||||
{`{${v}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.body')}
|
||||
</label>
|
||||
<button
|
||||
onClick={insertDefaultTemplate}
|
||||
className="text-xs text-dark-400 underline-offset-2 transition-colors hover:text-accent-400 hover:underline"
|
||||
>
|
||||
{t('admin.emailTemplates.insertDefault')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editBody}
|
||||
onChange={(e) => {
|
||||
setEditBody(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
rows={12}
|
||||
className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm"
|
||||
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
@@ -340,45 +442,12 @@ function TemplateEditor({
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{saveMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isPreviewDisabled) {
|
||||
notify.warning(
|
||||
t('admin.emailTemplates.previewDesktopOnly'),
|
||||
t('admin.emailTemplates.previewNotAvailable'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(`/admin/email-templates/preview/${detail.notification_type}/${activeLang}`, {
|
||||
state: {
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 sm:px-4 sm:py-2"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
|
||||
</button>
|
||||
|
||||
{langData && !langData.is_default && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -387,7 +456,7 @@ function TemplateEditor({
|
||||
}
|
||||
}}
|
||||
disabled={resetMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<ResetIcon className="h-4 w-4" />
|
||||
<span className="truncate">{t('admin.emailTemplates.resetDefault')}</span>
|
||||
@@ -395,6 +464,31 @@ function TemplateEditor({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test email */}
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-2 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.sendTest')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder={t('admin.emailTemplates.testRecipientPlaceholder')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-2xs text-dark-500">{t('admin.emailTemplates.testHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
TrashSmallIcon,
|
||||
PlusSmallIcon,
|
||||
} from '@/components/icons';
|
||||
import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages';
|
||||
import type { InfoPageType, FaqItem, ReplacesTab, InfoPageDisplayMode } from '../api/infoPages';
|
||||
|
||||
const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
|
||||
type LocaleCode = (typeof AVAILABLE_LOCALES)[number];
|
||||
@@ -643,6 +643,7 @@ export default function AdminInfoPageEditor() {
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [pageType, setPageType] = useState<InfoPageType>(initialPageType);
|
||||
const [replacesTab, setReplacesTab] = useState<ReplacesTab | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<InfoPageDisplayMode>('both');
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// FAQ Q&A state per locale
|
||||
@@ -857,6 +858,7 @@ export default function AdminInfoPageEditor() {
|
||||
setSortOrder(pageData.sort_order);
|
||||
setPageType(pageData.page_type ?? 'page');
|
||||
setReplacesTab(pageData.replaces_tab ?? null);
|
||||
setDisplayMode(pageData.display_mode ?? 'both');
|
||||
setTitles(pageData.title);
|
||||
|
||||
if (pageData.page_type === 'faq') {
|
||||
@@ -927,6 +929,7 @@ export default function AdminInfoPageEditor() {
|
||||
sort_order: number;
|
||||
icon: string | null;
|
||||
replaces_tab: ReplacesTab | null;
|
||||
display_mode: InfoPageDisplayMode;
|
||||
}) => {
|
||||
if (isEdit && pageId != null) {
|
||||
return infoPagesApi.updatePage(pageId, data);
|
||||
@@ -976,6 +979,7 @@ export default function AdminInfoPageEditor() {
|
||||
sort_order: sortOrder,
|
||||
icon: icon.trim() || null,
|
||||
replaces_tab: replacesTab,
|
||||
display_mode: displayMode,
|
||||
};
|
||||
|
||||
haptic.buttonPress();
|
||||
@@ -1023,7 +1027,7 @@ export default function AdminInfoPageEditor() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !slug.trim()}
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('admin.infoPages.saving') : t('admin.infoPages.save')}
|
||||
</button>
|
||||
@@ -1089,6 +1093,31 @@ export default function AdminInfoPageEditor() {
|
||||
<span className="text-sm text-dark-300">{t('admin.infoPages.fields.isActive')}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label id="ip-displaymode-label" className="label">
|
||||
{t('admin.infoPages.fields.displayMode')}
|
||||
</label>
|
||||
<div className="flex gap-1" role="radiogroup" aria-labelledby="ip-displaymode-label">
|
||||
{(['bot', 'web', 'both'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={displayMode === mode}
|
||||
onClick={() => setDisplayMode(mode)}
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
displayMode === mode
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
{t(`admin.infoPages.displayModes.${mode}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page type selector */}
|
||||
<div>
|
||||
<label id="ip-pagetype-label" className="label">
|
||||
@@ -1107,7 +1136,7 @@ export default function AdminInfoPageEditor() {
|
||||
pageType === pt
|
||||
? pt === 'faq'
|
||||
? 'bg-warning-500 text-white'
|
||||
: 'bg-accent-500 text-white'
|
||||
: 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
@@ -1165,7 +1194,7 @@ export default function AdminInfoPageEditor() {
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
activeLocale === loc
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
@@ -1386,7 +1415,7 @@ export default function AdminInfoPageEditor() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !slug.trim()}
|
||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('admin.infoPages.saving') : t('admin.infoPages.save')}
|
||||
</button>
|
||||
|
||||
@@ -215,6 +215,17 @@ export default function AdminInfoPages() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
haptic.buttonPress();
|
||||
navigate('/admin/legal-pages');
|
||||
}}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-dark-800 px-4 py-2.5 text-dark-200 transition-colors hover:bg-dark-700"
|
||||
aria-label={t('admin.legalPages.open')}
|
||||
>
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('admin.legalPages.open')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"
|
||||
@@ -227,7 +238,7 @@ export default function AdminInfoPages() {
|
||||
haptic.buttonPress();
|
||||
navigate('/admin/info-pages/create?type=faq');
|
||||
}}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-warning-500/80 px-4 py-2.5 text-white transition-colors hover:bg-warning-500"
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-warning-500 px-4 py-2.5 text-on-warning transition-colors hover:bg-warning-400"
|
||||
aria-label={t('admin.infoPages.createFaq')}
|
||||
>
|
||||
<PlusIcon />
|
||||
@@ -238,7 +249,7 @@ export default function AdminInfoPages() {
|
||||
haptic.buttonPress();
|
||||
navigate('/admin/info-pages/create');
|
||||
}}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-on-accent transition-colors hover:bg-accent-600"
|
||||
aria-label={t('admin.infoPages.create')}
|
||||
>
|
||||
<PlusIcon />
|
||||
@@ -257,7 +268,7 @@ export default function AdminInfoPages() {
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
activeFilter === tab
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -588,7 +588,7 @@ export default function AdminLandingEditor() {
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !slug || !Object.values(title).some((v) => v.trim())}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
|
||||
@@ -423,7 +423,7 @@ export default function AdminLandings() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/admin/landings/create')}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.landings.create')}
|
||||
|
||||
691
src/pages/AdminLegalPages.tsx
Normal file
691
src/pages/AdminLegalPages.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
adminLegalPagesApi,
|
||||
type FaqPageItem,
|
||||
type FaqSettingItem,
|
||||
type LegalDisplayMode,
|
||||
} from '../api/adminLegalPages';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { Toggle } from '../components/admin/Toggle';
|
||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons';
|
||||
|
||||
type LegalTab = 'privacy' | 'offer' | 'rules' | 'faq';
|
||||
|
||||
const DISPLAY_MODES: LegalDisplayMode[] = ['bot', 'web', 'both'];
|
||||
|
||||
function extractErrorDetail(err: unknown): string | null {
|
||||
const error = err as { response?: { data?: { detail?: unknown } } };
|
||||
const detail = error.response?.data?.detail;
|
||||
return typeof detail === 'string' ? detail : null;
|
||||
}
|
||||
|
||||
function DisplayModeSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: LegalDisplayMode;
|
||||
onChange: (mode: LegalDisplayMode) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{t('admin.legalPages.displayMode')}</label>
|
||||
<div className="flex gap-1" role="radiogroup">
|
||||
{DISPLAY_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === mode}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(mode)}
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
value === mode
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
{t(`admin.legalPages.displayModes.${mode}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{disabled && (
|
||||
<p className="mt-1.5 text-xs text-dark-500">{t('admin.legalPages.displayModeLocked')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageTabs({
|
||||
languages,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
languages: string[];
|
||||
active: string;
|
||||
onChange: (lang: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{t('admin.legalPages.language')}</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => onChange(lang)}
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium uppercase transition-colors',
|
||||
active === lang
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentEditor({
|
||||
kind,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
kind: 'privacy-policy' | 'public-offer';
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
|
||||
const [contents, setContents] = useState<Record<string, string>>({});
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [populated, setPopulated] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'legal-pages', kind],
|
||||
queryFn: () =>
|
||||
kind === 'privacy-policy'
|
||||
? adminLegalPagesApi.getPrivacyPolicy()
|
||||
: adminLegalPagesApi.getPublicOffer(),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || isFetching || populated) return;
|
||||
setDisplayMode(data.display_mode);
|
||||
const nextContents: Record<string, string> = {};
|
||||
const nextEnabled: Record<string, boolean> = {};
|
||||
for (const item of data.items) {
|
||||
nextContents[item.language] = item.content;
|
||||
nextEnabled[item.language] = item.is_enabled;
|
||||
}
|
||||
setContents(nextContents);
|
||||
setEnabled(nextEnabled);
|
||||
if (data.items.length > 0 && !data.items.some((item) => item.language === 'ru')) {
|
||||
setActiveLang(data.items[0].language);
|
||||
}
|
||||
setPopulated(true);
|
||||
}, [data, isFetching, populated]);
|
||||
|
||||
const isDirty =
|
||||
populated &&
|
||||
!!data &&
|
||||
(displayMode !== data.display_mode ||
|
||||
data.items.some(
|
||||
(item) =>
|
||||
(contents[item.language] ?? '') !== item.content ||
|
||||
(enabled[item.language] ?? false) !== item.is_enabled,
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload = {
|
||||
...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }),
|
||||
items: Object.keys(contents).map((language) => ({
|
||||
language,
|
||||
content: contents[language] ?? '',
|
||||
is_enabled: enabled[language] ?? false,
|
||||
})),
|
||||
};
|
||||
return kind === 'privacy-policy'
|
||||
? adminLegalPagesApi.updatePrivacyPolicy(payload)
|
||||
: adminLegalPagesApi.updatePublicOffer(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', kind] });
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="skeleton h-64 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
const languages = data.items.map((item) => item.language);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DisplayModeSelector
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
disabled={data.display_mode_env_locked}
|
||||
/>
|
||||
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle
|
||||
checked={enabled[activeLang] ?? false}
|
||||
onChange={() => setEnabled((prev) => ({ ...prev, [activeLang]: !prev[activeLang] }))}
|
||||
aria-label={t('admin.legalPages.enabled')}
|
||||
/>
|
||||
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('admin.legalPages.content')}</label>
|
||||
<textarea
|
||||
value={contents[activeLang] ?? ''}
|
||||
onChange={(e) => {
|
||||
setSaveError(null);
|
||||
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
|
||||
}}
|
||||
rows={16}
|
||||
className="input min-h-[320px] w-full font-mono text-sm"
|
||||
placeholder={t('admin.legalPages.contentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
|
||||
const [contents, setContents] = useState<Record<string, string>>({});
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [populated, setPopulated] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'legal-pages', 'rules'],
|
||||
queryFn: adminLegalPagesApi.getRules,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || isFetching || populated) return;
|
||||
setDisplayMode(data.display_mode);
|
||||
const nextContents: Record<string, string> = {};
|
||||
for (const item of data.items) {
|
||||
nextContents[item.language] = item.content;
|
||||
}
|
||||
setContents(nextContents);
|
||||
if (data.items.length > 0 && !data.items.some((item) => item.language === 'ru')) {
|
||||
setActiveLang(data.items[0].language);
|
||||
}
|
||||
setPopulated(true);
|
||||
}, [data, isFetching, populated]);
|
||||
|
||||
const isDirty =
|
||||
populated &&
|
||||
!!data &&
|
||||
(displayMode !== data.display_mode ||
|
||||
data.items.some((item) => (contents[item.language] ?? '') !== item.content));
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminLegalPagesApi.updateRules({
|
||||
...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }),
|
||||
items: Object.keys(contents).map((language) => ({
|
||||
language,
|
||||
content: contents[language] ?? '',
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'rules'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="skeleton h-64 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
const languages = data.items.map((item) => item.language);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DisplayModeSelector
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
disabled={data.display_mode_env_locked}
|
||||
/>
|
||||
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
|
||||
<div>
|
||||
<label className="label">{t('admin.legalPages.content')}</label>
|
||||
<textarea
|
||||
value={contents[activeLang] ?? ''}
|
||||
onChange={(e) => {
|
||||
setSaveError(null);
|
||||
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
|
||||
}}
|
||||
rows={16}
|
||||
className="input min-h-[320px] w-full font-mono text-sm"
|
||||
placeholder={t('admin.legalPages.contentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqQuestionRow({
|
||||
page,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDelete,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
page: FaqPageItem;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDelete: () => void;
|
||||
onSaved: () => void;
|
||||
onDirtyChange: (id: number, dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const haptic = useHapticFeedback();
|
||||
const [title, setTitle] = useState(page.title);
|
||||
const [content, setContent] = useState(page.content);
|
||||
const [isActive, setIsActive] = useState(page.is_active);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = title !== page.title || content !== page.content || isActive !== page.is_active;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(page.id, isDirty);
|
||||
return () => onDirtyChange(page.id, false);
|
||||
}, [page.id, isDirty, onDirtyChange]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }),
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
onSaved();
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setSaveError(null);
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
className="input flex-1"
|
||||
placeholder={t('admin.legalPages.questionTitle')}
|
||||
/>
|
||||
<Toggle
|
||||
checked={isActive}
|
||||
onChange={() => setIsActive((v) => !v)}
|
||||
aria-label={t('admin.legalPages.active')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
|
||||
aria-label={t('admin.legalPages.moveUp')}
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
|
||||
aria-label={t('admin.legalPages.moveDown')}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||
aria-label={t('admin.legalPages.delete')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setSaveError(null);
|
||||
setContent(e.target.value);
|
||||
}}
|
||||
rows={4}
|
||||
className="input w-full font-mono text-sm"
|
||||
placeholder={t('admin.legalPages.questionContent')}
|
||||
/>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="min-h-[44px] rounded-lg bg-dark-700 px-5 py-2 text-sm font-medium text-dark-100 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const confirm = useDestructiveConfirm();
|
||||
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [populated, setPopulated] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [dirtyRows, setDirtyRows] = useState<number[]>([]);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['admin', 'legal-pages', 'faq'],
|
||||
queryFn: adminLegalPagesApi.getFaq,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || isFetching || populated) return;
|
||||
setDisplayMode(data.display_mode);
|
||||
if (data.settings.length > 0 && !data.settings.some((s) => s.language === 'ru')) {
|
||||
setActiveLang(data.settings[0].language);
|
||||
}
|
||||
setPopulated(true);
|
||||
}, [data, isFetching, populated]);
|
||||
|
||||
const handleRowDirtyChange = useCallback((id: number, dirty: boolean) => {
|
||||
setDirtyRows((prev) => {
|
||||
if (dirty) return prev.includes(id) ? prev : [...prev, id];
|
||||
return prev.includes(id) ? prev.filter((rowId) => rowId !== id) : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasDirtyRows = dirtyRows.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange(hasDirtyRows);
|
||||
}, [hasDirtyRows, onDirtyChange]);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] });
|
||||
|
||||
const settingsMutation = useMutation({
|
||||
mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) =>
|
||||
adminLegalPagesApi.updateFaq(payload),
|
||||
onSuccess: (resp) => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
queryClient.setQueryData(['admin', 'legal-pages', 'faq'], resp);
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
if (data) setDisplayMode(data.display_mode);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminLegalPagesApi.createFaqPage({
|
||||
language: activeLang,
|
||||
title: t('admin.legalPages.newQuestion'),
|
||||
content: '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: async ({ a, b }: { a: FaqPageItem; b: FaqPageItem }) => {
|
||||
await adminLegalPagesApi.updateFaqPage(a.id, { display_order: b.display_order });
|
||||
try {
|
||||
await adminLegalPagesApi.updateFaqPage(b.id, { display_order: a.display_order });
|
||||
} catch (err) {
|
||||
await adminLegalPagesApi
|
||||
.updateFaqPage(a.id, { display_order: a.display_order })
|
||||
.catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id),
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setSaveError(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <div className="skeleton h-64 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
const languages = data.settings.map((s) => s.language);
|
||||
const langEnabled = data.settings.find((s) => s.language === activeLang)?.is_enabled ?? false;
|
||||
const pages = data.pages.filter((p) => p.language === activeLang);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<DisplayModeSelector
|
||||
value={displayMode}
|
||||
onChange={(mode) => {
|
||||
setDisplayMode(mode);
|
||||
settingsMutation.mutate({ display_mode: mode });
|
||||
}}
|
||||
disabled={data.display_mode_env_locked}
|
||||
/>
|
||||
<LanguageTabs
|
||||
languages={languages}
|
||||
active={activeLang}
|
||||
onChange={async (lang) => {
|
||||
if (lang === activeLang) return;
|
||||
if (hasDirtyRows && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
|
||||
setActiveLang(lang);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle
|
||||
checked={langEnabled}
|
||||
onChange={() =>
|
||||
settingsMutation.mutate({
|
||||
settings: [{ language: activeLang, is_enabled: !langEnabled }],
|
||||
})
|
||||
}
|
||||
disabled={settingsMutation.isPending}
|
||||
aria-label={t('admin.legalPages.enabled')}
|
||||
/>
|
||||
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
|
||||
</div>
|
||||
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-dark-200">{t('admin.legalPages.faqPages')}</h2>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.legalPages.addQuestion')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{pages.length === 0 ? (
|
||||
<p className="rounded-xl border border-dark-700 bg-dark-800/50 p-6 text-center text-sm text-dark-400">
|
||||
{t('admin.legalPages.noQuestions')}
|
||||
</p>
|
||||
) : (
|
||||
pages.map((page, index) => (
|
||||
<FaqQuestionRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
canMoveUp={index > 0 && !reorderMutation.isPending}
|
||||
canMoveDown={index < pages.length - 1 && !reorderMutation.isPending}
|
||||
onMoveUp={() => reorderMutation.mutate({ a: page, b: pages[index - 1] })}
|
||||
onMoveDown={() => reorderMutation.mutate({ a: page, b: pages[index + 1] })}
|
||||
onDelete={async () => {
|
||||
const confirmed = await confirm(t('admin.legalPages.confirmDeleteQuestion'));
|
||||
if (confirmed) deleteMutation.mutate(page.id);
|
||||
}}
|
||||
onSaved={invalidate}
|
||||
onDirtyChange={handleRowDirtyChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLegalPages() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useDestructiveConfirm();
|
||||
const [activeTab, setActiveTab] = useState<LegalTab>('privacy');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const handleTabChange = async (tab: LegalTab) => {
|
||||
if (tab === activeTab) return;
|
||||
if (dirty && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
|
||||
setDirty(false);
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/info-pages" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.legalPages.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.legalPages.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={cn(
|
||||
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
activeTab === tab
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
{t(`admin.legalPages.tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<DocumentEditor key="privacy" kind="privacy-policy" onDirtyChange={setDirty} />
|
||||
)}
|
||||
{activeTab === 'offer' && (
|
||||
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
|
||||
)}
|
||||
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
|
||||
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,7 +273,7 @@ export default function AdminNews() {
|
||||
haptic.buttonPress();
|
||||
navigate('/admin/news/create');
|
||||
}}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-on-accent transition-colors hover:bg-accent-600"
|
||||
aria-label={t('news.admin.create')}
|
||||
>
|
||||
<PlusIcon />
|
||||
|
||||
@@ -558,7 +558,7 @@ export default function AdminNewsCreate() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !selectedCategory}
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||
</button>
|
||||
@@ -912,7 +912,7 @@ export default function AdminNewsCreate() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !title.trim() || !slug.trim() || !selectedCategory}
|
||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-[44px] w-full rounded-lg bg-accent-500 py-3 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? t('news.admin.saving') : t('news.admin.save')}
|
||||
</button>
|
||||
|
||||
@@ -303,6 +303,12 @@ const sections: AdminSection[] = [
|
||||
to: '/admin/info-pages',
|
||||
permission: 'info_pages:read',
|
||||
},
|
||||
{
|
||||
name: 'admin.nav.legalPages',
|
||||
icon: 'file-text',
|
||||
to: '/admin/legal-pages',
|
||||
permission: 'info_pages:read',
|
||||
},
|
||||
{
|
||||
name: 'admin.nav.updates',
|
||||
icon: 'refresh',
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function AdminPartnerCampaignAssign() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/campaigns/create?partnerId=${userId}`)}
|
||||
className="mt-2 w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600"
|
||||
className="mt-2 w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('admin.partnerDetail.campaigns.createNew')}
|
||||
</button>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function AdminPartnerCommission() {
|
||||
Number(commissionValue) < 1 ||
|
||||
Number(commissionValue) > 100
|
||||
}
|
||||
className="flex-1 rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
className="flex-1 rounded-lg bg-accent-500 px-4 py-3 font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,18 @@ import { useTranslation } from 'react-i18next';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { XIcon } from '@/components/icons';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
XIcon,
|
||||
UsersIcon,
|
||||
CheckCircleIcon,
|
||||
UsersOnlineIcon,
|
||||
PercentIcon,
|
||||
BanknotesIcon,
|
||||
CalendarIcon,
|
||||
CalendarBlankIcon,
|
||||
CalendarStarIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
// Status badge config — keys must match backend PartnerStatus enum values
|
||||
const statusConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
|
||||
@@ -112,30 +123,30 @@ export default function AdminPartnerDetail() {
|
||||
<div className="space-y-6">
|
||||
{/* Referral Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{partner.total_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.totalReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-success-400">{partner.paid_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.paidReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{partner.active_referrals}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.activeReferrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{partner.conversion_to_paid}%</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.partnerDetail.stats.conversionRate')}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.stats.totalReferrals')}
|
||||
value={partner.total_referrals}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.stats.paidReferrals')}
|
||||
value={partner.paid_referrals}
|
||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.stats.activeReferrals')}
|
||||
value={partner.active_referrals}
|
||||
icon={<UsersOnlineIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.stats.conversionRate')}
|
||||
value={`${partner.conversion_to_paid}%`}
|
||||
icon={<PercentIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Earnings */}
|
||||
@@ -144,38 +155,30 @@ export default function AdminPartnerDetail() {
|
||||
{t('admin.partnerDetail.earnings.title')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.allTime')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatWithCurrency(partner.earnings_all_time / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.today')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_today / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.week')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_week / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.partnerDetail.earnings.month')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatWithCurrency(partner.earnings_month / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.earnings.allTime')}
|
||||
value={formatWithCurrency(partner.earnings_all_time / 100)}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.earnings.today')}
|
||||
value={formatWithCurrency(partner.earnings_today / 100)}
|
||||
icon={<CalendarIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.earnings.week')}
|
||||
value={formatWithCurrency(partner.earnings_week / 100)}
|
||||
icon={<CalendarBlankIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partnerDetail.earnings.month')}
|
||||
value={formatWithCurrency(partner.earnings_month / 100)}
|
||||
icon={<CalendarStarIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,15 @@ import {
|
||||
} from '../api/partners';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { ChevronRightIcon, SettingsIcon } from '@/components/icons';
|
||||
import {
|
||||
BanknotesIcon,
|
||||
ChevronRightIcon,
|
||||
InboxIcon,
|
||||
PartnerIcon,
|
||||
SettingsIcon,
|
||||
UserPlusIcon,
|
||||
} from '@/components/icons';
|
||||
import { StatCard } from '@/components/stats';
|
||||
|
||||
export default function AdminPartners() {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,24 +66,30 @@ export default function AdminPartners() {
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.total_partners}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalPartners')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.pending_applications}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.pendingApplications')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.total_referrals}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalReferrals')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatWithCurrency(stats.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.partners.totalEarnings')}</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.partners.totalPartners')}
|
||||
value={stats.total_partners}
|
||||
icon={<PartnerIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partners.pendingApplications')}
|
||||
value={stats.pending_applications}
|
||||
icon={<InboxIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partners.totalReferrals')}
|
||||
value={stats.total_referrals}
|
||||
icon={<UserPlusIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.partners.totalEarnings')}
|
||||
value={formatWithCurrency(stats.total_earnings_kopeks / 100)}
|
||||
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,14 +1,213 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, type ChangeEvent } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import { adminOverpayCertificateApi, OVERPAY_CERT_MAX_SIZE } from '../api/adminOverpayCertificate';
|
||||
import { METHOD_LABELS } from '../constants/paymentMethods';
|
||||
import type { PromoGroupSimple } from '../types';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
import { localeMap } from '../utils/withdrawalUtils';
|
||||
import { PermissionGate } from '@/components/auth/PermissionGate';
|
||||
import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons';
|
||||
|
||||
function extractErrorDetail(err: unknown): string | null {
|
||||
const error = err as { response?: { data?: { detail?: unknown } } };
|
||||
const detail = error.response?.data?.detail;
|
||||
return typeof detail === 'string' ? detail : null;
|
||||
}
|
||||
|
||||
function OverpayCertificateSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const haptic = useHapticFeedback();
|
||||
const confirm = useDestructiveConfirm();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [certError, setCertError] = useState<string | null>(null);
|
||||
const [certWarning, setCertWarning] = useState<string | null>(null);
|
||||
|
||||
const { data: certStatus, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'overpay-certificate'],
|
||||
queryFn: adminOverpayCertificateApi.getStatus,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (payload: { file: File; passphrase: string }) =>
|
||||
adminOverpayCertificateApi.upload(payload.file, payload.passphrase),
|
||||
onSuccess: (resp) => {
|
||||
haptic.success();
|
||||
setCertError(null);
|
||||
setCertWarning(resp.warning ?? null);
|
||||
setCertFile(null);
|
||||
setPassphrase('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setCertWarning(null);
|
||||
setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertUploadError'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: adminOverpayCertificateApi.remove,
|
||||
onSuccess: () => {
|
||||
haptic.success();
|
||||
setCertError(null);
|
||||
setCertWarning(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'overpay-certificate'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
haptic.error();
|
||||
setCertError(extractErrorDetail(err) ?? t('admin.paymentMethods.overpayCertDeleteError'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCertError(null);
|
||||
setCertWarning(null);
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
if (selected && selected.size > OVERPAY_CERT_MAX_SIZE) {
|
||||
setCertFile(null);
|
||||
e.target.value = '';
|
||||
setCertError(t('admin.paymentMethods.overpayCertFileTooLarge'));
|
||||
return;
|
||||
}
|
||||
setCertFile(selected);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm(t('admin.paymentMethods.overpayCertConfirmDelete'));
|
||||
if (confirmed) deleteMutation.mutate();
|
||||
};
|
||||
|
||||
const expiryDate = certStatus?.not_valid_after
|
||||
? new Date(certStatus.not_valid_after).toLocaleDateString(localeMap[i18n.language] || 'ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
const isExpired = certStatus?.not_valid_after
|
||||
? new Date(certStatus.not_valid_after).getTime() < Date.now()
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="card space-y-4">
|
||||
<h3 className="text-sm font-semibold text-dark-200">
|
||||
{t('admin.paymentMethods.overpayCertTitle')}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
) : certStatus ? (
|
||||
<div>
|
||||
{certStatus.valid ? (
|
||||
<>
|
||||
{isExpired ? (
|
||||
<p className="text-sm text-warning-400">
|
||||
{t('admin.paymentMethods.overpayCertExpired', { date: expiryDate })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-success-400">
|
||||
{t('admin.paymentMethods.overpayCertValid', { date: expiryDate })}
|
||||
</p>
|
||||
)}
|
||||
{certStatus.subject && (
|
||||
<p className="mt-1 break-all text-xs text-dark-500">{certStatus.subject}</p>
|
||||
)}
|
||||
</>
|
||||
) : certStatus.uploaded ? (
|
||||
<p className="text-sm text-warning-400">
|
||||
{t('admin.paymentMethods.overpayCertUnreadable')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.overpayCertMissing')}</p>
|
||||
)}
|
||||
{certStatus.env_locked_path && (
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.overpayCertEnvLockedPath')}
|
||||
</p>
|
||||
)}
|
||||
{certStatus.env_locked_passphrase && (
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.overpayCertEnvLockedPassphrase')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.paymentMethods.overpayCertFile')}
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".p12,.pfx"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/50 p-4 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
|
||||
>
|
||||
{certFile ? certFile.name : t('admin.paymentMethods.overpayCertChooseFile')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.paymentMethods.overpayCertPassphrase')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t('admin.paymentMethods.overpayCertPassphrasePlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{certError && <p className="text-sm text-error-400">{certError}</p>}
|
||||
{certWarning && <p className="text-sm text-warning-400">{certWarning}</p>}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => certFile && uploadMutation.mutate({ file: certFile, passphrase })}
|
||||
disabled={!certFile || uploadMutation.isPending}
|
||||
className="btn-primary flex flex-1 items-center justify-center gap-2"
|
||||
>
|
||||
{uploadMutation.isPending && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
)}
|
||||
{t('admin.paymentMethods.overpayCertUpload')}
|
||||
</button>
|
||||
{certStatus?.uploaded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="btn-danger"
|
||||
>
|
||||
{t('admin.paymentMethods.overpayCertDelete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPaymentMethodEdit() {
|
||||
const { t } = useTranslation();
|
||||
const { methodId } = useParams<{ methodId: string }>();
|
||||
@@ -41,6 +240,9 @@ export default function AdminPaymentMethodEdit() {
|
||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
|
||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
|
||||
const [openUrlDirect, setOpenUrlDirect] = useState(false);
|
||||
const [quickAmounts, setQuickAmounts] = useState<number[]>([]);
|
||||
const [quickAmountInput, setQuickAmountInput] = useState('');
|
||||
const [quickAmountsError, setQuickAmountsError] = useState<string | null>(null);
|
||||
|
||||
// Initialize state when config loads
|
||||
useEffect(() => {
|
||||
@@ -56,6 +258,7 @@ export default function AdminPaymentMethodEdit() {
|
||||
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||||
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
|
||||
setOpenUrlDirect(config.open_url_direct ?? false);
|
||||
setQuickAmounts((config.quick_amounts ?? []).map((kopeks) => kopeks / 100));
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -104,6 +307,14 @@ export default function AdminPaymentMethodEdit() {
|
||||
data.reset_max_amount = true;
|
||||
}
|
||||
|
||||
if (quickAmounts.length > 0) {
|
||||
data.quick_amounts = [...quickAmounts]
|
||||
.sort((a, b) => a - b)
|
||||
.map((rubles) => Math.round(rubles * 100));
|
||||
} else {
|
||||
data.reset_quick_amounts = true;
|
||||
}
|
||||
|
||||
updateMethodMutation.mutate(data);
|
||||
};
|
||||
|
||||
@@ -113,6 +324,30 @@ export default function AdminPaymentMethodEdit() {
|
||||
);
|
||||
};
|
||||
|
||||
const addQuickAmount = () => {
|
||||
setQuickAmountsError(null);
|
||||
const parsed = parseFloat(quickAmountInput.replace(',', '.'));
|
||||
const value = Math.round(parsed);
|
||||
if (isNaN(value) || value <= 0) {
|
||||
setQuickAmountsError(t('admin.paymentMethods.quickAmountsInvalid'));
|
||||
return;
|
||||
}
|
||||
if (quickAmounts.includes(value)) {
|
||||
setQuickAmountInput('');
|
||||
return;
|
||||
}
|
||||
if (quickAmounts.length >= 10) {
|
||||
setQuickAmountsError(t('admin.paymentMethods.quickAmountsLimit'));
|
||||
return;
|
||||
}
|
||||
setQuickAmounts((prev) => [...prev, value].sort((a, b) => a - b));
|
||||
setQuickAmountInput('');
|
||||
};
|
||||
|
||||
const removeQuickAmount = (value: number) => {
|
||||
setQuickAmounts((prev) => prev.filter((amount) => amount !== value));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-viewport flex items-center justify-center">
|
||||
@@ -265,7 +500,9 @@ export default function AdminPaymentMethodEdit() {
|
||||
<span className="text-sm">{opt.name}</span>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
enabled ? 'bg-accent-500 text-white' : 'border border-dark-600 bg-dark-700'
|
||||
enabled
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'border border-dark-600 bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{enabled && <CheckIcon />}
|
||||
@@ -305,6 +542,55 @@ export default function AdminPaymentMethodEdit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.paymentMethods.quickAmounts')}
|
||||
</label>
|
||||
{quickAmounts.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{quickAmounts.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => removeQuickAmount(value)}
|
||||
aria-label={t('admin.paymentMethods.quickAmountsRemove', { value })}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-accent-500/30 bg-accent-500/10 px-3 py-1.5 text-sm font-medium text-accent-300 transition-colors hover:border-error-500/40 hover:bg-error-500/10 hover:text-error-400"
|
||||
>
|
||||
<span>{value} ₽</span>
|
||||
<span className="text-base leading-none">×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quickAmountInput}
|
||||
onChange={(e) => setQuickAmountInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addQuickAmount();
|
||||
}
|
||||
}}
|
||||
placeholder={t('admin.paymentMethods.quickAmountsPlaceholder')}
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button type="button" onClick={addQuickAmount} className="btn-secondary shrink-0">
|
||||
{t('admin.paymentMethods.quickAmountsAdd')}
|
||||
</button>
|
||||
</div>
|
||||
{quickAmountsError && <p className="mt-1 text-xs text-error-400">{quickAmountsError}</p>}
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.quickAmountsHint', {
|
||||
defaults: (config.default_quick_amounts ?? [])
|
||||
.map((kopeks) => kopeks / 100)
|
||||
.join(', '),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Display conditions */}
|
||||
<div className="border-t border-dark-700 pt-3">
|
||||
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||
@@ -408,7 +694,7 @@ export default function AdminPaymentMethodEdit() {
|
||||
<span>{group.name}</span>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||
selected ? 'bg-accent-500 text-on-accent' : 'border border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{selected && <CheckIcon />}
|
||||
@@ -423,6 +709,12 @@ export default function AdminPaymentMethodEdit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.method_id === 'overpay' && (
|
||||
<PermissionGate permission="settings:read">
|
||||
<OverpayCertificateSection />
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/admin/payment-methods')} className="btn-secondary flex-1">
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function AdminPaymentMethods() {
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.paymentMethods.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,16 @@ import { DateField } from '../components/DateField';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
BackIcon,
|
||||
SearchIcon,
|
||||
CalendarIcon,
|
||||
RefreshIcon,
|
||||
CheckCircleIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
@@ -38,38 +42,6 @@ function StatusBadge({ status }: StatusBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
color: 'blue' | 'amber' | 'green' | 'red';
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color, isActive, onClick }: StatCardProps) {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'border-accent-500/30 bg-accent-500/20 text-accent-400',
|
||||
amber: 'border-warning-500/30 bg-warning-500/20 text-warning-400',
|
||||
green: 'border-success-500/30 bg-success-500/20 text-success-400',
|
||||
red: 'border-error-500/30 bg-error-500/20 text-error-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-xl border p-4 text-left transition-all ${
|
||||
isActive
|
||||
? colors[color]
|
||||
: 'border-dark-700/50 bg-dark-800/50 text-dark-300 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-2xl font-bold ${isActive ? '' : 'text-dark-50'}`}>{value}</div>
|
||||
<div className="text-sm opacity-80">{label}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPayments() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -212,7 +184,7 @@ export default function AdminPayments() {
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.payments.title')}</h1>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.payments.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +239,7 @@ export default function AdminPayments() {
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
statusFilter === option.value
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
@@ -284,7 +256,7 @@ export default function AdminPayments() {
|
||||
onClick={() => handlePeriodChange(option.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
periodFilter === option.value
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
@@ -295,7 +267,7 @@ export default function AdminPayments() {
|
||||
onClick={() => handlePeriodChange('custom')}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
periodFilter === 'custom'
|
||||
? 'bg-accent-500 text-white'
|
||||
? 'bg-accent-500 text-on-accent'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
@@ -353,34 +325,62 @@ export default function AdminPayments() {
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.payments.totalCount')}
|
||||
value={stats.total}
|
||||
color="blue"
|
||||
isActive={statusFilter === 'all'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusCardClick('all')}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.payments.pendingCount')}
|
||||
value={stats.pending}
|
||||
color="amber"
|
||||
isActive={statusFilter === 'pending'}
|
||||
className={`rounded-xl text-left transition-all ${
|
||||
statusFilter === 'all' ? 'ring-2 ring-accent-500' : ''
|
||||
}`}
|
||||
>
|
||||
<StatCard
|
||||
label={t('admin.payments.totalCount')}
|
||||
value={stats.total}
|
||||
icon={<ChartBarIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusCardClick('pending')}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.payments.paidCount')}
|
||||
value={stats.paid}
|
||||
color="green"
|
||||
isActive={statusFilter === 'paid'}
|
||||
className={`rounded-xl text-left transition-all ${
|
||||
statusFilter === 'pending' ? 'ring-2 ring-warning-500' : ''
|
||||
}`}
|
||||
>
|
||||
<StatCard
|
||||
label={t('admin.payments.pendingCount')}
|
||||
value={stats.pending}
|
||||
icon={<ClockIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusCardClick('paid')}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.payments.cancelledCount')}
|
||||
value={stats.cancelled}
|
||||
color="red"
|
||||
isActive={statusFilter === 'cancelled'}
|
||||
className={`rounded-xl text-left transition-all ${
|
||||
statusFilter === 'paid' ? 'ring-2 ring-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
<StatCard
|
||||
label={t('admin.payments.paidCount')}
|
||||
value={stats.paid}
|
||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusCardClick('cancelled')}
|
||||
/>
|
||||
className={`rounded-xl text-left transition-all ${
|
||||
statusFilter === 'cancelled' ? 'ring-2 ring-error-500' : ''
|
||||
}`}
|
||||
>
|
||||
<StatCard
|
||||
label={t('admin.payments.cancelledCount')}
|
||||
value={stats.cancelled}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function AdminPinnedMessages() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/pinned-messages/create')}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.pinnedMessages.create')}</span>
|
||||
|
||||
@@ -6,16 +6,19 @@ import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac';
|
||||
import { PermissionGate } from '@/components/auth/PermissionGate';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
BackIcon,
|
||||
BoltIcon,
|
||||
CalendarIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
EditIcon,
|
||||
GlobeIcon,
|
||||
PlusIcon,
|
||||
ShieldIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
interface PolicyConditions {
|
||||
@@ -225,7 +228,7 @@ export default function AdminPolicies() {
|
||||
<PermissionGate permission="roles:create">
|
||||
<button
|
||||
onClick={() => navigate('/admin/policies/create')}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.policies.createPolicy')}
|
||||
@@ -243,28 +246,30 @@ export default function AdminPolicies() {
|
||||
{/* Stats Overview */}
|
||||
{sortedPolicies.length > 0 && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{sortedPolicies.length}</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.total')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{sortedPolicies.filter((p) => p.effect === 'allow').length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.allow')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-error-400">
|
||||
{sortedPolicies.filter((p) => p.effect === 'deny').length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.deny')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{sortedPolicies.filter((p) => p.is_active).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.active')}</div>
|
||||
</div>
|
||||
<StatCard
|
||||
label={t('admin.policies.stats.total')}
|
||||
value={sortedPolicies.length}
|
||||
icon={<ShieldIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.policies.stats.allow')}
|
||||
value={sortedPolicies.filter((p) => p.effect === 'allow').length}
|
||||
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.policies.stats.deny')}
|
||||
value={sortedPolicies.filter((p) => p.effect === 'deny').length}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.policies.stats.active')}
|
||||
value={sortedPolicies.filter((p) => p.is_active).length}
|
||||
icon={<BoltIcon className="h-5 w-5" />}
|
||||
tone="accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user