commit e2c49ea43c53a1599636d0906ea4d9521a57d2f0 Author: padreug Date: Wed Dec 31 19:04:13 2025 +0100 v12.0.0 - initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ef410a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +packages/*/node_modules +.git +.direnv +.envrc \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ed343d7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: build + +on: [ workflow_dispatch ] + +jobs: + everything: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Setup Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build packages with Turbo + run: pnpm run build + + - name: Package production build + run: | + # Create production-ready server package using pnpm deploy + pnpm deploy --filter=./packages/server --prod lamassu-server --legacy + + # Copy built admin UI to public directory + cp -r packages/admin-ui/build lamassu-server/public + + # Create tarball + tar -zcf lamassu-server.tar.gz lamassu-server/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: lamassu-server.tar.gz + path: lamassu-server.tar.gz diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..dbbef74 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,92 @@ +name: Docker Build and Publish + +on: + push: + branches: + - dev + +env: + DOCKERHUB_SERVER_REPO: lamassu/lamassu-server + DOCKERHUB_ADMIN_REPO: lamassu/lamassu-admin-server + +jobs: + build-and-publish: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build packages with Turbo + run: pnpm run build + + - name: Verify build artifacts + run: | + echo "=== Verifying typesafe-db build ===" + ls -la packages/typesafe-db/lib/ + echo "=== Verifying admin-ui build ===" + ls -la packages/admin-ui/build/ + + - name: Package production build + run: | + # Create production-ready server package using pnpm deploy + pnpm deploy --filter=./packages/server --prod lamassu-server --legacy + + # Copy built admin UI to public directory + cp -r packages/admin-ui/build lamassu-server/public + + # Copy Dockerfile to lamassu-server context + cp build/server.Dockerfile lamassu-server/ + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Build and push server image + uses: docker/build-push-action@v5 + with: + context: lamassu-server + push: true + target: l-s + file: lamassu-server/server.Dockerfile + tags: ${{ env.DOCKERHUB_SERVER_REPO }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push admin server image + uses: docker/build-push-action@v5 + with: + context: lamassu-server + push: true + target: l-a-s + file: lamassu-server/server.Dockerfile + tags: ${{ env.DOCKERHUB_ADMIN_REPO }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6a0cb4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Tests + +on: + pull_request: + branches: [ dev ] + push: + branches: [ dev ] + +jobs: + test: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Turbo cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm run test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..227a35c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +**/node_modules +**/.env +.pnpm-store/ + +.idea/ +.settings/ +.turbo/ + +packages/server/.lamassu +packages/server/certs/ +packages/server/tests/stress/machines +packages/server/tests/stress/config.json +packages/typesafe-db/lib/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2135df9 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Run linting +npx lint-staged diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..bf93fdb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "bracketSameLine": true +} \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..478a821 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +nodejs 22 +pnpm 10 +python 3 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c975923 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${workspaceRoot}/bin/lamassu-server", + "cwd": "${workspaceRoot}", + "args": [""] + }, + { + "type": "node", + "request": "attach", + "name": "Attach by Process ID", + "processId": "${command:PickProcess}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..48d232f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "vsicons.presets.angular": false +} diff --git a/CRYPTO_README.md b/CRYPTO_README.md new file mode 100644 index 0000000..d912bba --- /dev/null +++ b/CRYPTO_README.md @@ -0,0 +1,54 @@ +## Adding a new cryptocurrency to a Lamassu ATM + +### Structure + +In order to install new coins onto a Lamassu system, there are three points to pay attention: + - **The blockchain daemon:** This is a file which will need to be running on the lamassu-server and communicates with the blockchain network. This generally has an integrated wallet, but it may occur for the daemon and the wallet to be completely seperate processes (e.g. XMR). This manager is currently built into the lamassu-server project, but in the future, it will be moved to either lamassu-coins or a new library; + - **The wallet plugin:** This uses the capabilities of the RPC (Remote Procedure Call) API built into the blockchain daemon and wallet to create a linking API to standardize with the Lamassu ecosystem. It is built into the lamassu-server project; + - **The coin constants:** This has all the information about an implemented coin, including its code, unit scale, daemon RPC ports and all other information to make lamassu-server, lamassu-admin-server and lamassu-machine know about the supported coins. It is built into the lamassu-coins package; + +I'll be using XMR as example for all the steps in this guide. + +#### Blockchain + +Steps to implement a daemon: + - Create a file in `lamassu-server/lib/blockchain/.js`; + - Go to `lamassu-server/lib/blockchain/common.js` and add a new entry to the `BINARIES` object. Each entry has two mandatory fields (`url` and `dir`), and an optional one (`files`). + - To get the `url` needed to download the blockchain daemon, you need to access the releases of the daemon of the coin you're working with. For example, for XMR, the daemon can be found in their GitHub releases (https://github.com/monero-project/monero-gui/releases). Get the URL for the Linux 64-bit distribution and note the extension of the file, which will most likely be `.tar.gz` or `.tar.bz2`. For `.tar.bz2`, the coin you're working with needs to be added to the following snippet of code, responsible for the extraction of the downloaded file (`common.js`): + ``` + coinRec.cryptoCode === 'XMR' + ? es(`tar -xf ${downloadFile}`) + : es(`tar -xzf ${downloadFile}`) + ``` + - To get the `dir`, simply download the file, extract it, and take note of the folder inside the zipped file and the path towards the actual files you want. In XMR's case, `dir` = `monero-x86_64-linux-gnu-v0.17.2.0`, but for BTC it is `bitcoin-0.20.1/bin` + - Inside the directory specified in the `dir` field, there can be multiple files inside. In that case, you want to specity the `files` field. This is a multi-dimensional array, where each entry contains a pair of [, ]. + ``` + [ + ['monerod', 'monerod'], + ['monero-wallet-rpc', 'monero-wallet-rpc'] + ] + ``` + This means that the `monerod` found inside the distribution folder will be saved as `monerod` on the server. Same for the `monero-wallet-rpc`. + - Go to `lamassu-server/lib/blockchain/install.js` and add a new entry on the `PLUGINS` object. This entry must import the file created in step 1. + - Go to the file created in step one and import the object (which isn't created right now) containing all the information needed of a coin `const coinRec = utils.getCryptoCurrency('')`. + - The coin blockchain plugin contains two functions: `setup` and `buildConfig`. + - The build config has all the required flags to operate the downloaded daemon, and each coin has their particular set of flags and options, so that specification won't be covered here. + - The setup function has a similar structure in any coin, and the differences between them is generally related to how a daemon is ran. + +#### Wallet plugin + +Steps to implement a wallet plugin: + - Create a file in `lamassu-server/lib/plugins/wallet//.js` + - The wallet plugin serves as a middleware between the RPC calls supported by each daemon, and the processes ran inside the lamassu-server ecosystem. This includes address creation, balance lookup, making transactions, etc. As such, this file needs to export the following functions: + - `balance`: Responds with the amount of usable balance the operator wallet has; + - `sendCoins`: Responsible for creating a transaction and responds with an object containing the fee of the transaction and the transactionID; + - `newAddress`: Generates a new address for the operator wallet. Used for machine transactions and funding page; + - `getStatus`: Responsible for getting the status of a cash-out transaction (transaction from an operator address to a client address). + - `newFunding`: Creates the response to the funding page, with the amount of balance the operator has, the pending balance and a new funding address; + - `cryptoNetwork`: Responds with the crypto network the wallet is operating in, based on the port of the RPC calls used. + +#### Coin utils + +Steps to work on lamassu-coins: + - Create a new object on `lamassu-coins/config/consts.js` containing all the information relative to a coin. If you're using a wallet built into the daemon, use BTC as template. Otherwise, if the wallet process is separated from the daemon, use XMR as template; + - Create a new file on `lamassu-coins/plugins/.js`. This file should handle URL parsing and address validation. Despite most coins in lamassu-coins operating on base58 or bech32 validation, this validation can implemented on some variation of the existing algorithms, as is the case with XMR. When this happens, the implementation of this variation needs to be created from scratch. With the validator created, the machine should be able to recognize a valid address. To test this out, simply edit the `lamassu-machine/device-config.json` file with the new coin address to validate. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..78c1fcb --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +APPENDIX A SOFTWARE LICENSE AGREEMENT + +This Appendix A Software License Agreement (hereinafter referred to as the "Agreement") is made between Lamassu Industries AG, a Swiss corporation, (hereinafter referred to as "Lamassu") and Lamassu’s customer to whom Lamassu’s proprietary software or products containing embedded or pre-loaded proprietary software, or both is made available (hereinafter referred to as "Licensee"). Licensee and Lamassu enter into an agreement to which this appendix is attached (hereinafter referred to as the "Primary Agreement"). + +WHEREAS, Lamassu has developed and is the owner of the Lamassu software program that runs Lamassu Crypto ATMs (hereinafter referred to as the "Software"). Crypto ATMs means automated teller machines that allow a person to purchase or sell Bitcoin and other cryptocurrencies by using cash or debit card (hereinafter referred to as the "Crypto ATMs"). Software (i) means proprietary software in source code or object code format, and adaptations, translations, de-compilations, disassemblies, emulations, or derivative works of such software; (ii) means any modifications, enhancements, new versions and new releases of the software provided by Lamassu; and (iii) may contain one or more items of software owned by a third party supplier. The term "Software" does not include any third party software provided under separate license or third party software not licensable under the terms of this Agreement. + +WHEREAS, Lamassu desires to grant to Licensee, and Licensee desires to obtain from Lamassu, a limited, non-exclusive license to use the Software subject to terms and conditions set forth in this Agreement. + +NOW, THEREFORE, in consideration of the mutual promises set forth herein, Lamassu and Licensee (hereinafter referred to as the "Parties") agree as follows: + +1. Scope + +1.1. Lamassu and Licensee enter into this Agreement in connection with Lamassu's delivery of certain proprietary Software or products containing embedded or pre-loaded proprietary Software, or both. This Agreement contains the terms and conditions of the license Lamassu is providing to Licensee, and Licensee’s use of the Software and documentation. + +1.2. The license granted in this Agreement is on a per Lamassu product basis and/or per non Lamassu Crypto ATM basis according to the Primary Agreement. For example, if Licensee intends to use the Software on 10 Lamassu Crypto ATMs, Licensee must obtain and pay for 10 license seats in accordance with the Primary Agreement. If Licensee purchases only one Lamassu Crypto ATM, Licensee obtains only one license seat for the Software, Licensee is permitted to use the Software on the purchased Lamassu Crypto ATM but not on other Lamassu and/or non Lamassu Crypto ATMs owned by Licensee, unless additional license seats are obtained and paid by Licensee. The same applies if Licensee intends to use the Software on non Lamassu Crypto ATMs. Licensee must obtain and pay a separate license seat for each non Lamassu Crypto ATM in accordance with the Primary Agreement. + +2. Granting of License + +2.1. Subject to the provisions of this Agreement and the payment of applicable fees outlined in the Primary Agreement, Lamassu grants to Licensee a personal, limited, non-transferable, non-sublicensable and non-exclusive license under Lamassu copyrights embodied in the Software to use the Software, in original source code or object code form, and the documentation solely in connection with Licensee's use of the Lamassu products or to modify, adapt, create derivative works of the Software for the purpose of running it on non Lamassu Crypto ATMs (hereinafter referred to as the "Contractual Use"). + +2.2. If the Software licensed under this Agreement contains or is derived from Open Source Software, the terms and conditions governing the use of such Open Source Software are in the Open Source Software Licenses of the copyright owner and not this Agreement. If there is a conflict between the terms and conditions of this Agreement and the terms and conditions of the Open Source Software Licenses governing Licensee's use of the Open Source Software, the terms and conditions of the license grant of the applicable Open Source Software Licenses will take precedence over the license grants in this Agreement. + +2.3. Licensee may copy the Software, so far as a copy is necessary for the Contractual Use of the Software. Necessary copies are for example, the installation of the Software on a storage medium and loading the Software into the working memory. Licensee are also entitled to make a reasonable number of copies of the Software for backup purposes only. This right also includes the regular production of backup copies for the purpose of quickly restoring data stocks after a system failure and the temporary use of the Software on an alternative system. Copies of the Software made for backup purposes must be marked accordingly and may not be used for other purposes. Licensee is entitled to edit and modify the Software (including error corrections) but is not permitted to publish any version of the Software with modifications with regard to the Source Code, the attributions and/ or any other part of the Software. + +2.4. Lamassu reserves for itself all other rights and interest not explicitly granted under this Agreement. The right of Licensee to decrypt according to Art. 21 URG is reserved. + +3. Limitations on Use + +3.1. Licensee may use the Software only for (i) Licensee's internal business purposes, (ii) the Contractual Use and (iii) only in accordance with the documentation. Any other use of the Software is strictly prohibited. Without limiting the general nature of these restrictions, Licensee will not make the Software available for use by third parties on a "time sharing," "application service provider," or "service bureau" basis or for any other similar commercial rental or sharing arrangement. + +3.2. Licensee will not, and will not allow or enable any third party to: (i) reverse engineer, disassemble, peel components, decompile, reprogram or otherwise reduce the Software or any portion to a human perceptible form or otherwise attempt to recreate the source code; (ii) modify, adapt, create derivative works of, or merge the Software outside of the Contractual Use; (iii) copy, reproduce, distribute, lend, or lease the Software or documentation to any third party, grant any sublicense or other rights in the Software or documentation to any third party, or take any action that would cause the Software or documentation to be placed in the public domain; (iv) remove, or in any way alter or obscure, any copyright notice or other notice of Lamassu's proprietary rights; or (v) provide, copy, transmit, disclose, divulge or make the Software or documentation available to any third party. + +4. Delivery of the Software + +4.1. The delivery of the Software takes place with consultation between the Parties. Lamassu shall register Licensee within its system and provide it with an access code. + +4.2. Licensee shall download the Software through their own access code and install it on their own server or Crypto ATM. Licensee shall not alter the Software's functionality or provide its access code to third parties. Licensee is responsible for maintaining the confidentiality of their access data, their account information and all activities resulting from accessing the Software using their username and access data. + +4.3. Licensee is responsible for the installation and the launch of the Software. + +5. Updates & upgrades & bug fixes + +5.1. Licensee agrees and acknowledges that malfunctions of the Software cannot be completely excluded, even with the greatest care, and that the uninterrupted functionality of the Software cannot be guaranteed. + +5.2. Lamassu shall provide Licensee free of charge with all updates, upgrades, bug fixes and code corrections to correct Software malfunctions and defects in order to bring the Software into substantial conformity with its operating specifications. + +5.3. Lamassu may, but will not be required to, provide these services if Licensee has modified the Software or is in default. + +5.4. The ownership and any intellectual property rights to the work results continuously created by providing updates, upgrades and bug fixes belong fully and exclusively to Lamassu. + +5.5. If Licensee provides Lamassu with any feedback, ideas or suggestions regarding the Software, Lamassu may use all such feedback without restriction and shall not be subject to any non-disclosure or non-use obligations in respect of such feedback, and Licensee hereby grants Lamassu a worldwide, non-exclusive, perpetual, irrevocable, royalty-free, fully paid, sub-licensable and transferable license to use, edit, aggregate, reproduce, distribute, prepare derivative works of, display, perform, and otherwise fully exploit such feedback, ideas or suggestions regarding the Software, for any use or purpose whatsoever. + +6. Warranty of Title and Functionality + +6.1. Lamassu hereby represents and warrants to Licensee that Lamassu is the owner of the Software and has the right to grant to Licensee the rights set forth in this Agreement. + +6.2. If Licensee is not in breach of any of its obligations under this Agreement and the Primary Agreement, Lamassu warrants that the Software will perform the functions described in this Agreement if used in accordance with the Agreement. Failure to do so shall constitute a defect in the Software that is subject to warranty (hereinafter referred to as the "Defect"). This warranty shall not apply to the Software if modified by anyone, even if such modification is allowed under the terms of this Agreement, or if used improperly or in an operating environment not approved by Lamassu. + +6.3. Lamassu does not warrant that Licensee's use of the Software or the Lamassu products will be uninterrupted, error-free, completely free of security vulnerabilities, or that the Software or the Lamassu products will meet Licensee's particular requirements. Lamassu makes no representations or warranties with respect to any third party software included in the Software. Licensee explicitly agrees and acknowledges that Lamassu does not warrant that the Software will satisfy or fulfill any regulators, customers and/or other authorities' requirements and expectations. It's in the sole responsibility of Licensee to clarify with the competent regulators whether the Software and other implemented measures fulfil the applicable requirements. + +6.4. Licensee acknowledges, however, that malfunctions of the Software cannot be completely ruled out, even with the greatest care, and that the uninterrupted functionality of the Software cannot be guaranteed. + +6.5. Lamassu's sole obligation to Licensee and Licensee's exclusive remedy under this warranty is to use reasonable efforts to remedy any material Software defect covered by this warranty. These efforts will involve either replacing the media or attempting to correct significant, demonstrable program or documentation errors or security vulnerabilities. If Lamassu cannot correct the defect within a reasonable time, then at Lamassu's option, Lamassu will replace the defective Software with functionally-equivalent Software, license to Licensee substitute Software which will accomplish the same objective, or terminate the license and refund Licensee's paid license fee. + +6.6. Licensee must give Lamassu sufficient notice of any defect within 5 calendar days of its discovery. + +6.7. The express warranties set forth in this Section 6 are in lieu of, and Lamassu disclaims, any and all other warranties (express or implied, oral or written) with respect to the Software or documentation, including, without limitation, any and all implied warranties of condition, title, non-infringement, merchantability, or fitness for a particular purpose or use by Licensee (whether or not Lamassu knows, has reason to know, has been advised, or is otherwise aware of any such purpose or use), whether arising by law, by reason of custom or usage of trade, or by course of dealing. In addition, Lamassu disclaims any warranty to any person other than Licensee with respect to the Software or documentation. Any further warranty claims of Licensee are expressly excluded. + +7. Limitation of Liability + +7.1. Lamassu shall not be responsible for, and shall not pay, any amount of incidental, consequential or other indirect damages, whether based on lost revenue or otherwise, regardless of whether Licensee was advised of the possibility of such losses in advance by Lamassu. + +7.2. In no event shall Lamassu's liability hereunder exceed the amount paid by Licensee under the Primary Agreement, regardless of whether Licensee’s claim is based on contract, tort, strict liability, product liability or otherwise. + +7.3. This limitation of liability provision survives the expiration or termination of this Agreement and applies notwithstanding any contrary provision in this Agreement. + +8. Term and termination + +8.1. Licensee's right to use the Software and documentation will begin when the Primary Agreement is signed by both Parties and will continue (i) for the life of the Lamassu Products with which or for which the Software and documentation have been provided by Lamassu, unless Licensee breaches this Agreement, in which case this Agreement and Licensee's right to use the Software and documentation may be terminated immediately upon notice by Lamassu or (ii) for the duration of the Primary Agreement, unless Licensee breaches this Agreement or the Primary Agreement, in which case this Agreement and Licensee's right to use the Software and documentation may be terminated immediately upon notice by Lamassu. + +8.2. After termination of this Agreement, Licensee shall no longer use the Software. Upon first request by Lamassu, Licensee shall remove or delete and destroy all copies of the Software and documentation. + +8.3. Licensee acknowledges that Lamassu made a considerable investment of resources in the development, marketing, and distribution of the Software and documentation and that Licensee's breach of this Agreement will result in irreparable harm to Lamassu for which solely monetary damages would be inadequate. If Licensee breaches this Agreement, Lamassu may terminate this Agreement and be entitled to all available remedies at law or in equity (including immediate injunctive relief and repossession of all non-embedded Software and associated documentation). + +8.4. Sections 3, 6, 7, 9, 10, 11, 12, 13, 14, and 15 survive the termination of this Agreement. + +9. Intellectual Property Rights + +9.1. The Software is and includes intellectual property of Lamassu. All associated intellectual property rights, including, without limitation, worldwide patent, trademark, copyright and trade secret rights, are reserved by Lamassu. Lamassu retains all right, title and interest in and copyrights to the Software, regardless of form or media in or on which the original or other copies may subsequently exist. This Agreement does not constitute a sale of the Software and no title or proprietary rights to the Software are transferred to Licensee hereby. Licensee acknowledges that the Software is a unique, confidential and valuable asset of Lamassu. + +9.2. All intellectual property developed, originated, or prepared by Lamassu in connection with providing the Software, Lamassu products, documentation or related services, remains vested exclusively in Lamassu, and Licensee will not have any shared development or other intellectual property rights. + +10. Confidentiality + +10.1. Licensee acknowledges that the Software contains proprietary trade secrets of Lamassu and Licensee hereby agrees to maintain the confidentiality of the Software using at least as great a degree of care as it uses to maintain the confidentiality of its own confidential information. Licensee agrees to promptly communicate the terms and conditions of this Agreement to those persons employed by Licensee who come into contact with the Software. Licensee is responsible in the event of a breach of confidentiality by any of its employees or agents. Licensee shall use reasonable efforts to ensure its compliance with its confidentiality obligations under this Agreement, including, without limitation, preventing the use of any portion of the Software for the purpose of deriving the source code of the Software. + +11. Successors + +11.1. This Agreement will be binding upon and will inure to the benefit of the Parties hereto and their respective representatives, successors and assigns except as otherwise provided herein. + +12. Severability + +12.1. In the event any provision of this Agreement is determined to be invalid or unenforceable, the remainder of this Agreement shall remain in force as if such provision were not a part of it. + +13. Non-assignment + +13.1. This Agreement, any claims and the licenses granted by it may not be assigned, sublicensed, or otherwise transferred by Licensee without the prior written consent of Lamassu. + +13.2. This Agreement and any claims hereunder may not be assigned by Lamassu to any third party without the prior written consent of Licensee. + +14. Agreement + +14.1. This Agreement sets forth the entire understanding between the Parties with respect to the subject matter hereof, and merges and supersedes all prior agreements, discussions and understandings, express or implied, concerning such matters. This Agreement shall take precedence over any additional or conflicting term, which may be contained in Licensees' purchase order or Lamassu‘s order acknowledgment forms. + +15. Governing law / place of jurisdiction + +15.1. This Agreement shall be governed by and construed in accordance with the substantive laws of Switzerland under exclusion of its conflict of laws rules. The United Nations Convention on Contracts on the International Sale of Goods is expressly excluded. + +15.2. The Parties hereby irrevocably and unconditionally agree to the exclusive jurisdiction of the courts of Lucerne, Switzerland. + +IN WITNESS WHEREOF, the Parties have caused this Agreement to be executed. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47ea16b --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# lamassu-server + +Lamassu remote server. + +## Pull Requests + +We do not generally accept outside pull requests for new features. Please consult with us before putting a lot of work into a pull request. + +## Development + +### Requirements + +- Nodejs 22 +- PNPM 10+ +- Postgres Database +- Python 3 (to be deprecated, required by a single dependency installation) +- OpenSSL (for cert-gen.sh, it will set up the server self-signed certificates) + +There's a shell.nix file that you can use to set up your env in case you're a nix user. (most reliable way of installing native deps) +There's also a .tool-versions for asdf and mise users. + +This project uses Turbo for monorepo management. Install dependencies: + +```bash +pnpm install +``` + +Prepare environment files: + +```bash +bash packages/server/tools/cert-gen.sh +``` + +On packages/server/.env you can alter variables such as the postgres connection info. + +After configuring the postgres connection, run: + +```bash +node packages/server/bin/lamassu-migrate +``` + +### Start development environment: + +If you've already done the setup, you can run: + +```bash +pnpm run dev +``` + +### Creating a user + +```bash +node packages/server/bin/lamassu-register admin@example.com superuser +``` + +### Pairing a machine + +To get the pairing token from the QRCode open the browser console before picking the name of the machine, the token should appear on the terminal. +It's also possible to inspect the qrCode, the token is on the data-cy="" attr. +Lastly, you can always scan it with a phone and copy the contents over. + +Now continue with lamassu-machine instructions from the `INSTALL.md` file in [lamassu-machine repository](https://github.com/lamassu/lamassu-machine) diff --git a/build/docker-compose.yaml b/build/docker-compose.yaml new file mode 100644 index 0000000..0a5a56a --- /dev/null +++ b/build/docker-compose.yaml @@ -0,0 +1,55 @@ +version: "3.8" + +services: + lamassu-server: + image: lamassu/lamassu-server:latest + restart: on-failure + network_mode: host + volumes: + - ./lamassu-data:/lamassu-data + environment: + - NODE_ENV=production + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres123 + - POSTGRES_HOST=localhost + - POSTGRES_PORT=5432 + - POSTGRES_DB=lamassu + - CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem + - CERT_PATH=/lamassu-data/certs/Lamassu_OP.pem + - KEY_PATH=/lamassu-data/private/Lamassu_OP.key + - MNEMONIC_PATH=/lamassu-data/mnemonics/mnemonic.txt + - OFAC_DATA_DIR=/lamassu-data/ofac + - ID_PHOTO_CARD_DIR=/lamassu-data/idphotocard + - FRONT_CAMERA_DIR=/lamassu-data/frontcamera + - OPERATOR_DATA_DIR=/lamassu-data/operatordata + - COIN_ATM_RADAR_URL=https://coinatmradar.info/api/lamassu/ + - HOSTNAME=localhost + - LOG_LEVEL=info + + lamassu-admin-server: + image: lamassu/lamassu-admin-server:latest + restart: on-failure + network_mode: host + volumes: + - ./lamassu-data:/lamassu-data + environment: + - NODE_ENV=production + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres123 + - POSTGRES_HOST=localhost + - POSTGRES_PORT=5432 + - POSTGRES_DB=lamassu + - CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem + - CERT_PATH=/lamassu-data/certs/Lamassu_OP.pem + - KEY_PATH=/lamassu-data/private/Lamassu_OP.key + - MNEMONIC_PATH=/lamassu-data/mnemonics/mnemonic.txt + - OFAC_DATA_DIR=/lamassu-data/ofac + - ID_PHOTO_CARD_DIR=/lamassu-data/idphotocard + - FRONT_CAMERA_DIR=/lamassu-data/frontcamera + - OPERATOR_DATA_DIR=/lamassu-data/operatordata + - COIN_ATM_RADAR_URL=https://coinatmradar.info/api/lamassu/ + - HOSTNAME=172.29.0.3 + - LOG_LEVEL=info + depends_on: + lamassu-server: + condition: service_started \ No newline at end of file diff --git a/build/server.Dockerfile b/build/server.Dockerfile new file mode 100644 index 0000000..2348ed7 --- /dev/null +++ b/build/server.Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-bullseye AS base +RUN apt install openssl ca-certificates + +WORKDIR /lamassu-server + +# Copy the pre-built production package from CI (with node_modules) +COPY . ./ + +FROM base AS l-s +RUN chmod +x /lamassu-server/bin/lamassu-server-entrypoint.sh +EXPOSE 3000 +ENTRYPOINT [ "/lamassu-server/bin/lamassu-server-entrypoint.sh" ] + +FROM base AS l-a-s +RUN chmod +x /lamassu-server/bin/lamassu-admin-server-entrypoint.sh +EXPOSE 443 +ENTRYPOINT [ "/lamassu-server/bin/lamassu-admin-server-entrypoint.sh" ] \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b48d46d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,85 @@ +import js from '@eslint/js' +import globals from 'globals' +import pluginReact from 'eslint-plugin-react' +import json from '@eslint/json' +import { defineConfig, globalIgnores } from 'eslint/config' +import ts from 'typescript-eslint' +import reactCompiler from 'eslint-plugin-react-compiler' +import eslintConfigPrettier from 'eslint-config-prettier/flat' +import vitest from 'eslint-plugin-vitest' + +const jsConfig = defineConfig([ + globalIgnores([ + '**/.lamassu', + '**/build', + '**/package.json', + '**/package-lock.json', + '**/currencies.json', + '**/countries.json', + '**/languages.json', + 'packages/typesafe-db/lib/**/*.js' + ]), + { + files: ['**/*.{js,mjs,cjs,jsx}'], + plugins: { js }, + extends: ['js/recommended'], + }, + { + files: ['packages/admin-ui/**/*.{js,mjs,jsx}'], + languageOptions: { + sourceType: 'module', + globals: { + ...globals.browser, + process: 'readonly', + }, + }, + }, + { + files: ['packages/server/**/*.{js,cjs}'], + languageOptions: { sourceType: 'commonjs', globals: globals.node }, + }, + { + ...pluginReact.configs.flat.recommended, + settings: { react: { version: 'detect' } }, + files: ['packages/admin-ui/**/*.{jsx,js}'], + }, + { ...reactCompiler.configs.recommended }, + eslintConfigPrettier, + { + files: ['**/*.json'], + plugins: { json }, + language: 'json/json', + extends: ['json/recommended'], + }, + { + rules: { + 'react/prop-types': 'off', + 'react/display-name': 'off', + 'react/no-unescaped-entities': 'off', + 'react-compiler/react-compiler': 'warn', + }, + }, + { + // update this to match your test files + files: ['**/*.spec.js', '**/*.test.js'], + plugins: { + vitest, + }, + rules: { + ...vitest.configs.recommended.rules, // you can also use vitest.configs.all.rules to enable all rules + 'vitest/max-nested-describe': ['error', { max: 3 }], // you can also modify rules' behavior using option like this + }, + }, +]) + +export default ts.config( + jsConfig, + { + files: ['packages/typesafe-db/src/**/*.ts'], + extends: [ts.configs.recommended], + rules: { + "@typescript-eslint/no-explicit-any": "warn", // TODO: remove this line + "@typescript-eslint/consistent-type-imports": "error", + }, + }, +) diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd7167e --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "lamassu-server", + "description": "bitcoin atm client server protocol module", + "version": "12.0.0", + "license": "./LICENSE", + "author": "Lamassu (https://lamassu.is)", + "packageManager": "pnpm@10.11.0", + "repository": { + "type": "git", + "url": "https://github.com/lamassu/lamassu-server.git" + }, + "engines": { + "node": ">=22.0.0" + }, + "devDependencies": { + "@eslint/css": "^0.7.0", + "@eslint/js": "^9.26.0", + "@eslint/json": "^0.12.0", + "eslint": "^9.26.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-compiler": "^19.1.0-rc.1", + "eslint-plugin-vitest": "^0.5.4", + "globals": "^16.1.0", + "husky": "^8.0.0", + "lint-staged": "^16.0.0", + "prettier": "^3.5.3", + "turbo": "^2.5.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0" + }, + "scripts": { + "prepare": "husky install", + "build": "turbo build", + "dev": "turbo dev", + "test": "turbo test" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,css,scss,md}": [ + "prettier --write", + "eslint --fix" + ] + } +} diff --git a/packages/admin-ui/.gitignore b/packages/admin-ui/.gitignore new file mode 100644 index 0000000..e802167 --- /dev/null +++ b/packages/admin-ui/.gitignore @@ -0,0 +1,23 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.eslintcache diff --git a/packages/admin-ui/README.md b/packages/admin-ui/README.md new file mode 100644 index 0000000..1db995e --- /dev/null +++ b/packages/admin-ui/README.md @@ -0,0 +1,36 @@ +## Dev Environment + +### formatting + +You can configure a eslint plugin to format code on save. +The configuration for vscode is already on the repo, all you need to do is install the eslint plugin. + +This project has a husky pre commit hook to format the staged changes using our styleguide. +To take advantage of that make sure to run `git commit` from within this folder. + +## Available Scripts + +From the root directory (recommended with Turbo): + +- `pnpm run dev` - Start development environment +- `pnpm run build` - Build for production +- `pnpm run admin:dev` - Start only admin UI development + +In the admin-ui package directory, you can run: + +### `pnpm start` or `pnpm run dev` + +Runs the app in development mode with Vite. +Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +The page will reload if you make edits. +You will also see any lint errors in the console. + +### `pnpm test` + +Launches the test runner with vitest. + +### `pnpm run build` + +Builds the app for production to the `build` folder. +It correctly bundles React in production mode and optimizes the build for the best performance. diff --git a/packages/admin-ui/index.html b/packages/admin-ui/index.html new file mode 100644 index 0000000..c920f86 --- /dev/null +++ b/packages/admin-ui/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + Lamassu Admin + + + +
+ + + diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json new file mode 100644 index 0000000..8a2744b --- /dev/null +++ b/packages/admin-ui/package.json @@ -0,0 +1,81 @@ +{ + "name": "lamassu-admin", + "version": "12.0.0", + "license": "../LICENSE", + "type": "module", + "dependencies": { + "@apollo/client": "^3.13.8", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@lamassu/coins": "v1.6.1", + "@mui/icons-material": "^7.1.0", + "@mui/material": "^7.1.0", + "@mui/x-date-pickers": "^8.3.1", + "@simplewebauthn/browser": "^3.0.0", + "apollo-upload-client": "^18.0.0", + "bignumber.js": "9.0.0", + "classnames": "2.2.6", + "countries-and-timezones": "^2.4.0", + "d3": "^6.2.0", + "date-fns": "^2.26.0", + "date-fns-tz": "^1.1.6", + "downshift": "9.0.9", + "file-saver": "2.0.2", + "formik": "2.2.0", + "immer": "^10.1.1", + "jss-plugin-extend": "^10.0.0", + "jszip": "^3.6.0", + "libphonenumber-js": "^1.11.15", + "match-sorter": "^4.2.0", + "material-react-table": "^3.2.1", + "pretty-ms": "^2.1.0", + "qrcode.react": "4.2.0", + "ramda": "^0.30.1", + "react": "18.3.1", + "react-copy-to-clipboard": "^5.0.2", + "react-dom": "18.3.1", + "react-dropzone": "^11.4.2", + "react-number-format": "^4.4.1", + "react-otp-input": "3.1.1", + "react-virtualized": "^9.21.2", + "ua-parser-js": "1.0.40", + "uuid": "11.1.0", + "wouter": "^3.7.0", + "yup": "1.6.1", + "zustand": "^4.5.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-react-swc": "^3.7.2", + "esbuild-plugin-react-virtualized": "^1.0.4", + "globals": "^15.13.0", + "prettier": "3.4.1", + "tailwindcss": "^4.1.4", + "vite": "^6.0.1", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^3.1.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "lint-staged": { + "*.{js,jsx,md,json}": "eslint --cache --fix", + "*.{js,jsx,css,md,json}": "prettier --write" + } +} diff --git a/packages/admin-ui/public/assets/wizard/fullexample.commissions.png b/packages/admin-ui/public/assets/wizard/fullexample.commissions.png new file mode 100644 index 0000000..92fbf32 Binary files /dev/null and b/packages/admin-ui/public/assets/wizard/fullexample.commissions.png differ diff --git a/packages/admin-ui/public/assets/wizard/fullexample.locale.png b/packages/admin-ui/public/assets/wizard/fullexample.locale.png new file mode 100644 index 0000000..629c2bd Binary files /dev/null and b/packages/admin-ui/public/assets/wizard/fullexample.locale.png differ diff --git a/packages/admin-ui/public/assets/wizard/fullexample.twilio.png b/packages/admin-ui/public/assets/wizard/fullexample.twilio.png new file mode 100644 index 0000000..a8d4412 Binary files /dev/null and b/packages/admin-ui/public/assets/wizard/fullexample.twilio.png differ diff --git a/packages/admin-ui/public/assets/wizard/fullexample.wallet.png b/packages/admin-ui/public/assets/wizard/fullexample.wallet.png new file mode 100644 index 0000000..328e791 Binary files /dev/null and b/packages/admin-ui/public/assets/wizard/fullexample.wallet.png differ diff --git a/packages/admin-ui/public/favicon.ico b/packages/admin-ui/public/favicon.ico new file mode 100644 index 0000000..762aa9c Binary files /dev/null and b/packages/admin-ui/public/favicon.ico differ diff --git a/packages/admin-ui/public/fonts/BPmono/BPmono.ttf b/packages/admin-ui/public/fonts/BPmono/BPmono.ttf new file mode 100644 index 0000000..8b2ada9 Binary files /dev/null and b/packages/admin-ui/public/fonts/BPmono/BPmono.ttf differ diff --git a/packages/admin-ui/public/fonts/BPmono/BPmonoBold.ttf b/packages/admin-ui/public/fonts/BPmono/BPmonoBold.ttf new file mode 100644 index 0000000..c34b297 Binary files /dev/null and b/packages/admin-ui/public/fonts/BPmono/BPmonoBold.ttf differ diff --git a/packages/admin-ui/public/fonts/BPmono/BPmonoItalic.ttf b/packages/admin-ui/public/fonts/BPmono/BPmonoItalic.ttf new file mode 100644 index 0000000..b158e12 Binary files /dev/null and b/packages/admin-ui/public/fonts/BPmono/BPmonoItalic.ttf differ diff --git a/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff b/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff new file mode 100644 index 0000000..85e11fa Binary files /dev/null and b/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff differ diff --git a/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff2 b/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff2 new file mode 100644 index 0000000..ddf8442 Binary files /dev/null and b/packages/admin-ui/public/fonts/MontHeavy/mont-bold-webfont.woff2 differ diff --git a/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff b/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff new file mode 100644 index 0000000..f76db64 Binary files /dev/null and b/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff differ diff --git a/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff2 b/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff2 new file mode 100644 index 0000000..669b667 Binary files /dev/null and b/packages/admin-ui/public/fonts/MontHeavy/mont-heavy-webfont.woff2 differ diff --git a/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff new file mode 100644 index 0000000..02917c4 Binary files /dev/null and b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff differ diff --git a/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff2 b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff2 new file mode 100644 index 0000000..c83e38a Binary files /dev/null and b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_500-webfont.woff2 differ diff --git a/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff new file mode 100644 index 0000000..e4d2440 Binary files /dev/null and b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff differ diff --git a/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff2 b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff2 new file mode 100644 index 0000000..1fda725 Binary files /dev/null and b/packages/admin-ui/public/fonts/MuseoSans/MuseoSans_700-webfont.woff2 differ diff --git a/packages/admin-ui/public/fonts/Rubik/Rubik-Black.otf b/packages/admin-ui/public/fonts/Rubik/Rubik-Black.otf new file mode 100644 index 0000000..9ec62f2 Binary files /dev/null and b/packages/admin-ui/public/fonts/Rubik/Rubik-Black.otf differ diff --git a/packages/admin-ui/public/fonts/Rubik/Rubik-Bold.otf b/packages/admin-ui/public/fonts/Rubik/Rubik-Bold.otf new file mode 100644 index 0000000..4d7fc63 Binary files /dev/null and b/packages/admin-ui/public/fonts/Rubik/Rubik-Bold.otf differ diff --git a/packages/admin-ui/public/fonts/Rubik/Rubik-Medium.otf b/packages/admin-ui/public/fonts/Rubik/Rubik-Medium.otf new file mode 100644 index 0000000..35c50c7 Binary files /dev/null and b/packages/admin-ui/public/fonts/Rubik/Rubik-Medium.otf differ diff --git a/packages/admin-ui/public/manifest.json b/packages/admin-ui/public/manifest.json new file mode 100644 index 0000000..1f2f141 --- /dev/null +++ b/packages/admin-ui/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/admin-ui/public/robots.txt b/packages/admin-ui/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/packages/admin-ui/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/packages/admin-ui/public/wizard-background.svg b/packages/admin-ui/public/wizard-background.svg new file mode 100644 index 0000000..a783cf7 --- /dev/null +++ b/packages/admin-ui/public/wizard-background.svg @@ -0,0 +1,204 @@ + + + welcome-page-background + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/admin-ui/src/App.jsx b/packages/admin-ui/src/App.jsx new file mode 100644 index 0000000..985e61f --- /dev/null +++ b/packages/admin-ui/src/App.jsx @@ -0,0 +1,54 @@ +import CssBaseline from '@mui/material/CssBaseline' +import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles' +import React, { useState } from 'react' +import { Router } from 'wouter' +import ApolloProvider from './utils/apollo' +import { LocalizationProvider } from '@mui/x-date-pickers' +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV2' + +import AppContext from './AppContext' +import theme from './styling/theme' + +import Main from './Main' +import './styling/global/global.css' +import useLocationWithConfirmation from './routing/useLocationWithConfirmation.js' + +const App = () => { + const [wizardTested, setWizardTested] = useState(false) + const [userData, setUserData] = useState(null) + const [isDirtyForm, setDirtyForm] = useState(false) + + const setRole = role => { + if (userData && role && userData.role !== role) { + setUserData({ ...userData, role }) + } + } + + return ( + + + + + + + +
+ + + + + + + ) +} + +export default App diff --git a/packages/admin-ui/src/AppContext.js b/packages/admin-ui/src/AppContext.js new file mode 100644 index 0000000..f54c8c4 --- /dev/null +++ b/packages/admin-ui/src/AppContext.js @@ -0,0 +1,3 @@ +import React from 'react' + +export default React.createContext() diff --git a/packages/admin-ui/src/Main.jsx b/packages/admin-ui/src/Main.jsx new file mode 100644 index 0000000..e162b96 --- /dev/null +++ b/packages/admin-ui/src/Main.jsx @@ -0,0 +1,104 @@ +import { useLocation } from 'wouter' +import React, { useContext, useState } from 'react' +import { gql, useQuery } from '@apollo/client' +import Slide from '@mui/material/Slide' +import Grid from '@mui/material/Grid' + +import Header from './components/layout/Header.jsx' +import Sidebar from './components/layout/Sidebar.jsx' +import TitleSection from './components/layout/TitleSection.jsx' +import { getParent, hasSidebar, Routes, tree } from './routing/routes.jsx' +import Wizard from './pages/Wizard/Wizard.jsx' + +import AppContext from './AppContext.js' + +const GET_USER_DATA = gql` + query userData { + userData { + id + username + role + enabled + last_accessed + last_accessed_from + last_accessed_address + } + restrictionLevel + } +` + +const Main = () => { + const [location, navigate] = useLocation() + const { wizardTested, userData, setUserData } = useContext(AppContext) + const [loading, setLoading] = useState(true) + const [restrictionLevel, setRestrictionLevel] = useState(null) + + useQuery(GET_USER_DATA, { + onCompleted: userResponse => { + if (!userData && userResponse?.userData) { + setUserData(userResponse.userData) + } + if (userResponse?.restrictionLevel !== undefined) { + setRestrictionLevel(userResponse.restrictionLevel) + } + setLoading(false) + }, + }) + + const sidebar = hasSidebar(location) + const parent = sidebar ? getParent(location) : {} + + const is404 = location === '/404' + + const isSelected = it => location === it.route + + const onClick = it => navigate(it.route) + + const contentClassName = sidebar ? 'flex-1 ml-12 pt-4' : 'w-[1200px]' + + // Show loading state until userData is fetched + if (loading) { + return <> + } + + if (!wizardTested && !is404 && userData) { + return + } + + return ( +
+ {!is404 && wizardTested && ( +
+ )} +
+ {sidebar && !is404 && wizardTested && ( + +
+ +
+
+ )} + + + {sidebar && !is404 && wizardTested && ( + it.label} + onClick={onClick} + /> + )} +
+ +
+
+
+
+ ) +} + +export default Main diff --git a/packages/admin-ui/src/components/Carousel.jsx b/packages/admin-ui/src/components/Carousel.jsx new file mode 100644 index 0000000..4f1547b --- /dev/null +++ b/packages/admin-ui/src/components/Carousel.jsx @@ -0,0 +1,48 @@ +import React, { memo, useState } from 'react' +import styles from './Carousel.module.css' +import LeftArrow from '../styling/icons/arrow/carousel-left-arrow.svg?react' +import RightArrow from '../styling/icons/arrow/carousel-right-arrow.svg?react' + +export const Carousel = memo(({ photosData, slidePhoto }) => { + const [activeIndex, setActiveIndex] = useState(0) + + const handlePrev = () => { + const newIndex = activeIndex === 0 ? photosData.length - 1 : activeIndex - 1 + setActiveIndex(newIndex) + slidePhoto(newIndex) + } + + const handleNext = () => { + const newIndex = activeIndex === photosData.length - 1 ? 0 : activeIndex + 1 + setActiveIndex(newIndex) + slidePhoto(newIndex) + } + + if (!photosData || photosData.length === 0) { + return null + } + + return ( +
+ {photosData.length > 1 && ( + + )} + +
+ +
+ + {photosData.length > 1 && ( + + )} +
+ ) +}) diff --git a/packages/admin-ui/src/components/Carousel.module.css b/packages/admin-ui/src/components/Carousel.module.css new file mode 100644 index 0000000..3239bde --- /dev/null +++ b/packages/admin-ui/src/components/Carousel.module.css @@ -0,0 +1,45 @@ +.carouselContainer { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.imageContainer { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + height: 100%; + overflow: hidden; + max-width: 80%; +} + +.image { + object-fit: contain; + object-position: center; + width: 100%; + height: 100%; + margin-bottom: 40px; +} + +.navButton { + background-color: transparent; + border: none; + border-radius: 0; + color: transparent; + opacity: 1; + cursor: pointer; + padding: 8px; + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.navButton:hover { + background-color: rgba(0, 0, 0, 0.04); +} diff --git a/packages/admin-ui/src/components/CollapsibleCard.jsx b/packages/admin-ui/src/components/CollapsibleCard.jsx new file mode 100644 index 0000000..9fc095a --- /dev/null +++ b/packages/admin-ui/src/components/CollapsibleCard.jsx @@ -0,0 +1,21 @@ +import React from 'react' + +import Paper from '@mui/material/Paper' +import classnames from 'classnames' + +const cardState = Object.freeze({ + DEFAULT: 'default', + SHRUNK: 'shrunk', + EXPANDED: 'expanded', +}) + +const CollapsibleCard = ({ className, state, shrunkComponent, children }) => { + return ( + + {state === cardState.SHRUNK ? shrunkComponent : children} + + ) +} + +export default CollapsibleCard +export { cardState } diff --git a/packages/admin-ui/src/components/ConfirmDialog.jsx b/packages/admin-ui/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..62e4b11 --- /dev/null +++ b/packages/admin-ui/src/components/ConfirmDialog.jsx @@ -0,0 +1,105 @@ +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import IconButton from '@mui/material/IconButton' +import InputLabel from '@mui/material/InputLabel' +import React, { memo, useState } from 'react' +import { H4, P } from './typography' +import CloseIcon from '../styling/icons/action/close/zodiac.svg?react' + +import { Button } from './buttons' +import { TextInput } from './inputs' + +import ErrorMessage from './ErrorMessage' +import SvgIcon from '@mui/material/SvgIcon' + +export const DialogTitle = ({ children, onClose }) => { + return ( +
+ {children} + {onClose && ( + + + + + + )} +
+ ) +} + +export const ConfirmDialog = memo( + ({ + title = 'Confirm action', + errorMessage = 'This action requires confirmation', + open, + toBeConfirmed, + saveButtonAlwaysEnabled = false, + message, + confirmationMessage = `Write '${toBeConfirmed}' to confirm this action`, + onConfirmed, + onDismissed, + initialValue = '', + disabled = false, + ...props + }) => { + const [value, setValue] = useState(initialValue) + const [error, setError] = useState(false) + const handleChange = event => setValue(event.target.value) + + const innerOnClose = () => { + setValue('') + setError(false) + onDismissed() + } + + const isOnErrorState = + (!saveButtonAlwaysEnabled && toBeConfirmed !== value) || value === '' + + return ( + + +

{title}

+
+ {errorMessage && ( + + + {errorMessage.split(':').map(error => ( + <> + {error} +
+ + ))} +
+
+ )} + + {message &&

{message}

} + {confirmationMessage} + +
+ + + +
+ ) + }, +) diff --git a/packages/admin-ui/src/components/CopyToClipboard.jsx b/packages/admin-ui/src/components/CopyToClipboard.jsx new file mode 100644 index 0000000..a46b719 --- /dev/null +++ b/packages/admin-ui/src/components/CopyToClipboard.jsx @@ -0,0 +1,80 @@ +import classnames from 'classnames' +import * as R from 'ramda' +import React, { useState, useEffect } from 'react' +import { CopyToClipboard as ReactCopyToClipboard } from 'react-copy-to-clipboard' +import Popover from './Popper.jsx' +import CopyIcon from '../styling/icons/action/copy/copy.svg?react' + +import { comet } from '../styling/variables.js' + +import { Label1, Mono } from './typography/index.jsx' + +const CopyToClipboard = ({ + className, + buttonClassname, + children, + value, + wrapperClassname, + removeSpace = true, +}) => { + const [anchorEl, setAnchorEl] = useState(null) + + useEffect(() => { + if (anchorEl) setTimeout(() => setAnchorEl(null), 3000) + }, [anchorEl]) + + const handleClick = event => { + setAnchorEl(anchorEl ? null : event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + const id = open ? 'simple-popper' : undefined + + const text = value + ? value + : removeSpace + ? R.replace(/\s/g, '')(children ?? '') + : children + + return ( +
+ {children && ( + <> + + {children} + +
+ + + +
+ + + Copied to clipboard! + + + + )} +
+ ) +} + +export default CopyToClipboard diff --git a/packages/admin-ui/src/components/DeleteDialog.jsx b/packages/admin-ui/src/components/DeleteDialog.jsx new file mode 100644 index 0000000..a6a7cb9 --- /dev/null +++ b/packages/admin-ui/src/components/DeleteDialog.jsx @@ -0,0 +1,65 @@ +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import IconButton from '@mui/material/IconButton' +import React from 'react' +import { H4, P } from './typography' +import CloseIcon from '../styling/icons/action/close/zodiac.svg?react' + +import { Button } from './buttons' + +import ErrorMessage from './ErrorMessage' +import SvgIcon from '@mui/material/SvgIcon' + +export const DialogTitle = ({ children, close }) => { + return ( +
+ {children} + {close && ( + + + + + + )} +
+ ) +} + +export const DeleteDialog = ({ + title = 'Confirm Delete', + open = false, + onConfirmed, + onDismissed, + item = 'item', + confirmationMessage = `Are you sure you want to delete this ${item}?`, + extraMessage, + errorMessage = '', +}) => { + return ( + + onDismissed()}> +

{title}

+
+ {errorMessage && ( + + + {errorMessage.split(':').map(error => ( + <> + {error} +
+ + ))} +
+
+ )} + + {confirmationMessage &&

{confirmationMessage}

} + {extraMessage} +
+ + + +
+ ) +} diff --git a/packages/admin-ui/src/components/ErrorMessage.jsx b/packages/admin-ui/src/components/ErrorMessage.jsx new file mode 100644 index 0000000..09cb35d --- /dev/null +++ b/packages/admin-ui/src/components/ErrorMessage.jsx @@ -0,0 +1,18 @@ +import classnames from 'classnames' +import React from 'react' +import ErrorIcon from '../styling/icons/warning-icon/tomato.svg?react' + +import { Info3 } from './typography' + +const ErrorMessage = ({ className, children }) => { + return ( +
+ + + {children} + +
+ ) +} + +export default ErrorMessage diff --git a/packages/admin-ui/src/components/ImagePopper.jsx b/packages/admin-ui/src/components/ImagePopper.jsx new file mode 100644 index 0000000..b243920 --- /dev/null +++ b/packages/admin-ui/src/components/ImagePopper.jsx @@ -0,0 +1,56 @@ +import ClickAwayListener from '@mui/material/ClickAwayListener' +import classnames from 'classnames' +import React, { memo, useState } from 'react' +import Popper from './Popper' +import ZoomIconInverse from '../styling/icons/circle buttons/search/white.svg?react' +import ZoomIcon from '../styling/icons/circle buttons/search/zodiac.svg?react' + +import { FeatureButton } from './buttons' + +const ImagePopper = memo( + ({ className, width, height, popupWidth, popupHeight, src }) => { + const [popperAnchorEl, setPopperAnchorEl] = useState(null) + + const handleOpenPopper = event => { + setPopperAnchorEl(popperAnchorEl ? null : event.currentTarget) + } + + const handleClosePopper = () => { + setPopperAnchorEl(null) + } + + const popperOpen = Boolean(popperAnchorEl) + + const Image = ({ className, style }) => ( + + ) + + return ( + +
+ + + +
+ +
+
+
+
+ ) + }, +) + +export default ImagePopper diff --git a/packages/admin-ui/src/components/InformativeDialog.jsx b/packages/admin-ui/src/components/InformativeDialog.jsx new file mode 100644 index 0000000..09a1be8 --- /dev/null +++ b/packages/admin-ui/src/components/InformativeDialog.jsx @@ -0,0 +1,39 @@ +import Dialog from '@mui/material/Dialog' +import DialogContent from '@mui/material/DialogContent' +import SvgIcon from '@mui/material/SvgIcon' +import IconButton from '@mui/material/IconButton' +import React, { memo } from 'react' +import { H1 } from './typography' +import CloseIcon from '../styling/icons/action/close/zodiac.svg?react' + +export const InformativeDialog = memo( + ({ title = '', open, onDissmised, data, ...props }) => { + const innerOnClose = () => { + onDissmised() + } + + return ( + +
+ + + + + + +
+

{title}

+ {data} +
+ ) + }, +) diff --git a/packages/admin-ui/src/components/LogsDownloaderPopper.jsx b/packages/admin-ui/src/components/LogsDownloaderPopper.jsx new file mode 100644 index 0000000..87ce953 --- /dev/null +++ b/packages/admin-ui/src/components/LogsDownloaderPopper.jsx @@ -0,0 +1,235 @@ +import { useLazyQuery } from '@apollo/client' +import ClickAwayListener from '@mui/material/ClickAwayListener' +import classnames from 'classnames' +import { format, set } from 'date-fns/fp' +import FileSaver from 'file-saver' +import * as R from 'ramda' +import React, { useState, useCallback } from 'react' +import Arrow from '../styling/icons/arrow/download_logs.svg?react' +import DownloadInverseIcon from '../styling/icons/button/download/white.svg?react' +import Download from '../styling/icons/button/download/zodiac.svg?react' + +import { FeatureButton, Link } from './buttons' +import { formatDate } from '../utils/timezones' + +import Popper from './Popper' +import DateRangePicker from './date-range-picker/DateRangePicker' +import { RadioGroup } from './inputs' +import { H4, Info1, Label1, Label2 } from './typography/index.jsx' + +const DateContainer = ({ date, children }) => { + return ( +
+ {children} + {date && ( + <> +
+ + {format('d', date)} + +
+ {`${format( + 'MMM', + date, + )} ${format('yyyy', date)}`} + + {format('EEEE', date)} + +
+
+ + )} +
+ ) +} + +const ALL = 'all' +const RANGE = 'range' +const ADVANCED = 'advanced' +const SIMPLIFIED = 'simplified' + +const LogsDownloaderPopover = ({ + name, + query, + args, + title, + getLogs, + timezone, + simplified, + className, +}) => { + const [selectedRadio, setSelectedRadio] = useState(ALL) + const [selectedAdvancedRadio, setSelectedAdvancedRadio] = useState(ADVANCED) + + const [range, setRange] = useState({ from: null, until: null }) + const [anchorEl, setAnchorEl] = useState(null) + const [fetchLogs] = useLazyQuery(query, { + onCompleted: data => createLogsFile(getLogs(data), range), + }) + + const dateRangePickerClasses = { + 'block h-full': selectedRadio === RANGE, + hidden: selectedRadio === ALL, + } + + const handleRadioButtons = evt => { + const selectedRadio = R.path(['target', 'value'])(evt) + setSelectedRadio(selectedRadio) + if (selectedRadio === ALL) setRange({ from: null, until: null }) + } + + const handleAdvancedRadioButtons = evt => { + const selectedAdvancedRadio = R.path(['target', 'value'])(evt) + setSelectedAdvancedRadio(selectedAdvancedRadio) + } + + const handleRangeChange = useCallback( + (from, until) => { + setRange({ from, until }) + }, + [setRange], + ) + + const downloadLogs = (range, args) => { + if (selectedRadio === ALL) { + fetchLogs({ + variables: { + ...args, + simplified: selectedAdvancedRadio === SIMPLIFIED, + excludeTestingCustomers: true, + }, + }) + } + + if (!range || !range.from) return + if (range.from && !range.until) range.until = new Date() + + if (selectedRadio === RANGE) { + fetchLogs({ + variables: { + ...args, + from: range.from, + until: range.until, + simplified: selectedAdvancedRadio === SIMPLIFIED, + excludeTestingCustomers: true, + }, + }) + } + } + + const createLogsFile = (logs, range) => { + const formatDateFile = date => { + return formatDate(date, timezone, 'yyyy-MM-dd_HH-mm') + } + + const blob = new window.Blob([logs], { + type: 'text/plain;charset=utf-8', + }) + + FileSaver.saveAs( + blob, + selectedRadio === ALL + ? `${formatDateFile(new Date())}_${name}.csv` + : `${formatDateFile(range.from)}_${formatDateFile( + range.until, + )}_${name}.csv`, + ) + } + + const handleOpenRangePicker = event => { + setAnchorEl(anchorEl ? null : event.currentTarget) + } + + const handleClickAway = () => { + setAnchorEl(null) + } + + const radioButtonOptions = [ + { display: 'All logs', code: ALL }, + { display: 'Date range', code: RANGE }, + ] + + const advancedRadioButtonOptions = [ + { display: 'Advanced logs', code: ADVANCED }, + { display: 'Simplified logs', code: SIMPLIFIED }, + ] + + const open = Boolean(anchorEl) + const id = open ? 'date-range-popover' : undefined + + return ( + +
+ + +
+

+ {title} +

+
+ +
+ {selectedRadio === RANGE && ( +
+
+ {range && ( + <> + From +
+ +
+ To + + )} +
+ +
+ )} + {simplified && ( +
+ +
+ )} +
+ downloadLogs(range, args)}> + Download + +
+
+
+
+
+ ) +} + +export default LogsDownloaderPopover diff --git a/packages/admin-ui/src/components/Modal.jsx b/packages/admin-ui/src/components/Modal.jsx new file mode 100644 index 0000000..67278d9 --- /dev/null +++ b/packages/admin-ui/src/components/Modal.jsx @@ -0,0 +1,96 @@ +import MaterialModal from '@mui/material/Modal' +import IconButton from '@mui/material/IconButton' +import SvgIcon from '@mui/material/SvgIcon' +import Paper from '@mui/material/Paper' +import classnames from 'classnames' +import React from 'react' +import { H1, H4 } from './typography' +import CloseIcon from '../styling/icons/action/close/zodiac.svg?react' + +const Modal = ({ + width, + height, + minHeight = 400, + infoPanelHeight, + title, + small, + xl, + infoPanel, + handleClose, + children, + className, + closeOnEscape, + closeOnBackdropClick, + ...props +}) => { + const TitleCase = small ? H4 : H1 + + const innerClose = (evt, reason) => { + if (!closeOnBackdropClick && reason === 'backdropClick') return + if (!closeOnEscape && reason === 'escapeKeyDown') return + handleClose() + } + + const marginBySize = xl ? 0 : small ? 12 : 16 + const paddingBySize = xl ? 88 : small ? 16 : 32 + return ( + + <> + +
+ {title && ( + + {title} + + )} +
+ handleClose()}> + + + + +
+
+
+ {children} +
+
+ {infoPanel && ( + +
+ {infoPanel} +
+
+ )} + +
+ ) +} + +export default Modal diff --git a/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.jsx b/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.jsx new file mode 100644 index 0000000..a7b751c --- /dev/null +++ b/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.jsx @@ -0,0 +1,160 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import * as R from 'ramda' +import React, { useState, useEffect } from 'react' +import ActionButton from '../buttons/ActionButton' +import { H5 } from '../typography' +import NotificationIconZodiac from '../../styling/icons/menu/notification-zodiac.svg?react' +import ClearAllIconInverse from '../../styling/icons/stage/spring/empty.svg?react' +import ClearAllIcon from '../../styling/icons/stage/zodiac/empty.svg?react' +import ShowUnreadIcon from '../../styling/icons/stage/zodiac/full.svg?react' + +import NotificationRow from './NotificationRow' +import classes from './NotificationCenter.module.css' + +const GET_NOTIFICATIONS = gql` + query getNotifications { + notifications { + id + type + detail + message + created + read + valid + } + hasUnreadNotifications + machines { + deviceId + name + } + } +` + +const TOGGLE_CLEAR_NOTIFICATION = gql` + mutation toggleClearNotification($id: ID!, $read: Boolean!) { + toggleClearNotification(id: $id, read: $read) { + id + read + } + } +` + +const CLEAR_ALL_NOTIFICATIONS = gql` + mutation clearAllNotifications { + clearAllNotifications { + id + } + } +` + +const NotificationCenter = ({ + close, + hasUnreadProp, + buttonCoords, + popperRef, + refetchHasUnreadHeader, +}) => { + const { data, loading } = useQuery(GET_NOTIFICATIONS, { + pollInterval: 60000, + }) + const [xOffset, setXoffset] = useState(300) + + const [showingUnread, setShowingUnread] = useState(false) + const machines = R.compose( + R.map(R.prop('name')), + R.indexBy(R.prop('deviceId')), + )(R.path(['machines'])(data) ?? []) + const notifications = R.path(['notifications'])(data) ?? [] + const [hasUnread, setHasUnread] = useState(hasUnreadProp) + + const [toggleClearNotification] = useMutation(TOGGLE_CLEAR_NOTIFICATION, { + onError: () => console.error('Error while clearing notification'), + refetchQueries: () => ['getNotifications'], + }) + const [clearAllNotifications] = useMutation(CLEAR_ALL_NOTIFICATIONS, { + onError: () => console.error('Error while clearing all notifications'), + refetchQueries: () => ['getNotifications'], + }) + + useEffect(() => { + setXoffset(popperRef.current.getBoundingClientRect().x) + if (data && data.hasUnreadNotifications !== hasUnread) { + refetchHasUnreadHeader() + setHasUnread(!hasUnread) + } + }, [popperRef, data, hasUnread, refetchHasUnreadHeader]) + + const buildNotifications = () => { + const notificationsToShow = + !showingUnread || !hasUnread + ? notifications + : R.filter(R.propEq(false, 'read'))(notifications) + return notificationsToShow.map(n => { + return ( + + toggleClearNotification({ + variables: { id: n.id, read: !n.read }, + }) + } + /> + ) + }) + } + + return ( + <> +
+
+
Notifications
+ +
+
+ {hasUnread && ( + setShowingUnread(!showingUnread)}> + {showingUnread ? 'Show all' : 'Show unread'} + + )} + {hasUnread && ( + + Mark all as read + + )} +
+
+ {!loading && buildNotifications()} +
+
+ + ) +} + +export default NotificationCenter diff --git a/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.module.css b/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.module.css new file mode 100644 index 0000000..b10a914 --- /dev/null +++ b/packages/admin-ui/src/components/NotificationCenter/NotificationCenter.module.css @@ -0,0 +1,147 @@ +.container { + width: 40vw; + height: 110vh; + right: 0; + background-color: white; + box-shadow: 0 0 14px 0 rgba(0, 0, 0, 0.24); +} + +@media only screen and (max-width: 1920px) { + .container { + width: 30vw; + } +} + +.header { + display: flex; + justify-content: space-between; +} + +.headerText { + margin-top: 20px; + margin-left: 12px; +} + +.actionButtons { + display: flex; + margin-left: 16px; + height: 0; +} + +.notificationIcon { + position: absolute; + cursor: pointer; + background: transparent; + box-shadow: 0 0 0 transparent; + border: 0 solid transparent; + text-shadow: 0 0 0 transparent; + outline: none; +} + +.clearAllButton { + margin-top: -16px; + margin-left: 8px; + background-color: var(--zircon); +} + +.notificationsList { + height: 90vh; + max-height: 100vh; + margin-top: 24px; + margin-left: 0; + overflow-y: auto; + overflow-x: hidden; + background-color: white; + z-index: 10; +} + +.notificationRow { + display: flex; + flex-direction: row; + justify-content: flex-start; + position: relative; + margin-bottom: 16px; + padding-top: 12px; + gap: 10px; +} + +.notificationRow > *:first-child { + margin-right: 24px; +} + +.notificationContent { + display: flex; + flex-direction: column; + justify-content: center; + width: 300px; +} + +.unread { + background-color: var(--spring3); +} + +.notificationRowIcon { + align-self: center; +} + +.notificationRowIcon > * { + margin-left: 24px; +} + +.readIconWrapper { + flex-grow: 1; +} + +.unreadIcon { + margin-top: 5px; + margin-left: 8px; + width: 12px; + height: 12px; + background-color: var(--spring); + border-radius: 50%; + cursor: pointer; + z-index: 1; +} + +.readIcon { + margin-left: 8px; + margin-top: 5px; + width: 12px; + height: 12px; + border: 1px solid var(--comet); + border-radius: 50%; + cursor: pointer; + z-index: 1; +} + +.notificationTitle { + margin: 0; + color: var(--comet); +} + +.notificationBody { + margin: 0; +} + +.notificationSubtitle { + margin: 0; + margin-bottom: 8px; + color: var(--comet); +} + +.stripes { + position: absolute; + height: 100%; + top: 0; + opacity: 60%; +} + +.hasUnread { + position: absolute; + top: 0; + left: 16px; + width: 9px; + height: 9px; + background-color: var(--spring); + border-radius: 50%; +} diff --git a/packages/admin-ui/src/components/NotificationCenter/NotificationRow.jsx b/packages/admin-ui/src/components/NotificationCenter/NotificationRow.jsx new file mode 100644 index 0000000..ceb4652 --- /dev/null +++ b/packages/admin-ui/src/components/NotificationCenter/NotificationRow.jsx @@ -0,0 +1,91 @@ +import classnames from 'classnames' +import prettyMs from 'pretty-ms' +import * as R from 'ramda' +import React from 'react' +import { Label1, Label2, TL2 } from '../typography' +import Wrench from '../../styling/icons/action/wrench/zodiac.svg?react' +import Transaction from '../../styling/icons/arrow/transaction.svg?react' +import WarningIcon from '../../styling/icons/warning-icon/tomato.svg?react' + +import classes from './NotificationCenter.module.css' + +const types = { + transaction: { + display: 'Transactions', + icon: , + }, + highValueTransaction: { + display: 'Transactions', + icon: , + }, + fiatBalance: { + display: 'Maintenance', + icon: , + }, + cryptoBalance: { + display: 'Maintenance', + icon: , + }, + compliance: { + display: 'Compliance', + icon: , + }, + error: { display: 'Error', icon: }, +} + +const NotificationRow = ({ + id, + type, + message, + deviceName, + created, + read, + valid, + toggleClear, +}) => { + const typeDisplay = R.path([type, 'display'])(types) ?? null + const icon = R.path([type, 'icon'])(types) ?? ( + + ) + const age = prettyMs(new Date().getTime() - new Date(created).getTime(), { + compact: true, + verbose: true, + }) + const notificationTitle = + typeDisplay && deviceName + ? `${typeDisplay} - ${deviceName}` + : !typeDisplay && deviceName + ? `${deviceName}` + : `${typeDisplay}` + + const iconClass = { + [classes.readIcon]: read, + [classes.unreadIcon]: !read, + } + return ( +
+
+
{icon}
+
+
+ + {notificationTitle} + + {message} + {age} +
+
+
toggleClear(id)} + className={classnames(iconClass)} + /> +
+
+ ) +} + +export default NotificationRow diff --git a/packages/admin-ui/src/components/NotificationCenter/index.js b/packages/admin-ui/src/components/NotificationCenter/index.js new file mode 100644 index 0000000..fd2be25 --- /dev/null +++ b/packages/admin-ui/src/components/NotificationCenter/index.js @@ -0,0 +1,3 @@ +import NotificationCenter from './NotificationCenter' + +export default NotificationCenter diff --git a/packages/admin-ui/src/components/Popper.jsx b/packages/admin-ui/src/components/Popper.jsx new file mode 100644 index 0000000..5726b07 --- /dev/null +++ b/packages/admin-ui/src/components/Popper.jsx @@ -0,0 +1,77 @@ +import MaterialPopper from '@mui/material/Popper' +import Paper from '@mui/material/Paper' +import classnames from 'classnames' +import * as R from 'ramda' +import React, { useState } from 'react' + +import { white } from '../styling/variables' +import classes from './Popper.module.css' + +const Popover = ({ children, bgColor = white, className, ...props }) => { + const [arrowRef, setArrowRef] = useState(null) + + const flipPlacements = { + top: ['bottom'], + bottom: ['top'], + left: ['right'], + right: ['left'], + } + + const modifiers = [ + { + name: 'flip', + enabled: R.defaultTo(false, props.flip), + options: { + allowedAutoPlacements: flipPlacements[props.placement], + }, + }, + { + name: 'preventOverflow', + enabled: true, + options: { + rootBoundary: 'scrollParent', + }, + }, + { + name: 'offset', + enabled: true, + options: { + offset: [0, 10], + }, + }, + { + name: 'arrow', + enabled: R.defaultTo(true, props.showArrow), + options: { + element: arrowRef, + }, + }, + { + name: 'computeStyles', + options: { + gpuAcceleration: false, + }, + }, + ] + + return ( + <> + + + + {children} + + + + ) +} + +export default Popover diff --git a/packages/admin-ui/src/components/Popper.module.css b/packages/admin-ui/src/components/Popper.module.css new file mode 100644 index 0000000..1beb7c5 --- /dev/null +++ b/packages/admin-ui/src/components/Popper.module.css @@ -0,0 +1,33 @@ +.newArrow, +.newArrow::before { + position: absolute; + width: 8px; + height: 8px; + background: inherit; +} + +.newArrow { + visibility: hidden; +} + +.newArrow::before { + visibility: visible; + content: ''; + transform: rotate(45deg); +} + +.tooltip[data-popper-placement^='top'] > div > span { + bottom: -4px; +} + +.tooltip[data-popper-placement^='bottom'] > div > span { + top: -4px; +} + +.tooltip[data-popper-placement^='left'] > div > span { + right: -4px; +} + +.tooltip[data-popper-placement^='right'] > div > span { + left: -4px; +} diff --git a/packages/admin-ui/src/components/PromptWhenDirty.jsx b/packages/admin-ui/src/components/PromptWhenDirty.jsx new file mode 100644 index 0000000..16fc2cb --- /dev/null +++ b/packages/admin-ui/src/components/PromptWhenDirty.jsx @@ -0,0 +1,19 @@ +import { useFormikContext } from 'formik' +import React, { useEffect } from 'react' + +import useDirtyHandler from '../routing/dirtyHandler.js' + +const PromptWhenDirty = () => { + const setIsDirty = useDirtyHandler(state => state.setIsDirty) + const formik = useFormikContext() + + const hasChanges = formik.dirty && formik.submitCount === 0 + + useEffect(() => { + setIsDirty(hasChanges) + }, [hasChanges]) + + return <> +} + +export default PromptWhenDirty diff --git a/packages/admin-ui/src/components/SearchBox.jsx b/packages/admin-ui/src/components/SearchBox.jsx new file mode 100644 index 0000000..7760111 --- /dev/null +++ b/packages/admin-ui/src/components/SearchBox.jsx @@ -0,0 +1,83 @@ +import InputBase from '@mui/material/InputBase' +import Paper from '@mui/material/Paper' +import MAutocomplete from '@mui/material/Autocomplete' +import classnames from 'classnames' +import React, { memo, useState } from 'react' +import { P } from './typography' +import SearchIcon from '../styling/icons/circle buttons/search/zodiac.svg?react' + +const SearchBox = memo( + ({ + loading = false, + filters = [], + options = [], + inputPlaceholder = '', + onChange, + ...props + }) => { + const [popupOpen, setPopupOpen] = useState(false) + + const inputClasses = { + 'flex flex-1 h-8 px-2 py-2 font-md items-center rounded-2xl bg-zircon text-comet': true, + 'rounded-b-none': popupOpen, + } + + const innerOnChange = filters => onChange(filters) + + return ( + it.label || it.value} + renderOption={(props, it) => ( +
  • +
    +

    + {it.label || it.value} +

    +

    {it.type}

    +
    +
  • + )} + autoHighlight + disableClearable + clearOnEscape + multiple + filterSelectedOptions + isOptionEqualToValue={(option, value) => option.type === value.type} + renderInput={params => { + return ( + } + placeholder={inputPlaceholder} + inputProps={{ + className: 'font-bold', + ...params.inputProps, + }} + /> + ) + }} + onOpen={() => setPopupOpen(true)} + onClose={() => setPopupOpen(false)} + onChange={(_, filters) => innerOnChange(filters)} + {...props} + slots={{ + paper: ({ children }) => ( + +
    + {children} + + ), + }} + /> + ) + }, +) + +export default SearchBox diff --git a/packages/admin-ui/src/components/SearchFilter.jsx b/packages/admin-ui/src/components/SearchFilter.jsx new file mode 100644 index 0000000..bfb3b18 --- /dev/null +++ b/packages/admin-ui/src/components/SearchFilter.jsx @@ -0,0 +1,53 @@ +import Chip from '@mui/material/Chip' +import React from 'react' +import { P, Label3 } from './typography' +import CloseIcon from '../styling/icons/action/close/zodiac.svg?react' +import FilterIcon from '../styling/icons/button/filter/white.svg?react' +import ReverseFilterIcon from '../styling/icons/button/filter/zodiac.svg?react' + +import { ActionButton } from './buttons' +import { onlyFirstToUpper, singularOrPlural } from '../utils/string' + +const SearchFilter = ({ + filters, + onFilterDelete, + deleteAllFilters, + entries = 0, +}) => { + return ( + <> +

    {'Filters:'}

    +
    +
    + {filters.map((f, idx) => ( + onFilterDelete(f)} + deleteIcon={} + /> + ))} +
    +
    + { + {`${entries} ${singularOrPlural( + entries, + `entry`, + `entries`, + )}`} + } + + Delete filters + +
    +
    + + ) +} + +export default SearchFilter diff --git a/packages/admin-ui/src/components/Status.jsx b/packages/admin-ui/src/components/Status.jsx new file mode 100644 index 0000000..710cffd --- /dev/null +++ b/packages/admin-ui/src/components/Status.jsx @@ -0,0 +1,23 @@ +import Chip from '@mui/material/Chip' +import React from 'react' + +const Status = ({ status }) => { + return +} + +const MainStatus = ({ statuses }) => { + const mainStatus = + statuses.find(s => s.type === 'error') || + statuses.find(s => s.type === 'warning') || + statuses[0] + const plus = { label: `+${statuses.length - 1}`, type: mainStatus.type } + + return ( +
    + + {statuses.length > 1 && } +
    + ) +} + +export { Status, MainStatus } diff --git a/packages/admin-ui/src/components/Stepper.jsx b/packages/admin-ui/src/components/Stepper.jsx new file mode 100644 index 0000000..1e67d21 --- /dev/null +++ b/packages/admin-ui/src/components/Stepper.jsx @@ -0,0 +1,61 @@ +import classnames from 'classnames' +import * as R from 'ramda' +import React, { memo } from 'react' +import CompleteStageIconSpring from '../styling/icons/stage/spring/complete.svg?react' +import CurrentStageIconSpring from '../styling/icons/stage/spring/current.svg?react' +import EmptyStageIconSpring from '../styling/icons/stage/spring/empty.svg?react' +import CompleteStageIconZodiac from '../styling/icons/stage/zodiac/complete.svg?react' +import CurrentStageIconZodiac from '../styling/icons/stage/zodiac/current.svg?react' +import EmptyStageIconZodiac from '../styling/icons/stage/zodiac/empty.svg?react' + +import classes from './Stepper.module.css' + +const Stepper = memo(({ steps, currentStep, color = 'spring', className }) => { + if (currentStep < 1 || currentStep > steps) + throw Error('Value of currentStage is invalid') + if (steps < 1) throw Error('Value of stages is invalid') + + const separatorClasses = { + 'w-7 h-[2px] border-2 z-1': true, + 'border-spring': color === 'spring', + 'border-zodiac': color === 'zodiac', + } + + const separatorEmptyClasses = { + 'w-7 h-[2px] border-2 z-1': true, + 'border-dust': color === 'spring', + 'border-comet': color === 'zodiac', + } + + return ( +
    + {R.range(1, currentStep).map(idx => ( +
    + {idx > 1 &&
    } +
    + {color === 'spring' && } + {color === 'zodiac' && } +
    +
    + ))} +
    + {currentStep > 1 &&
    } +
    + {color === 'spring' && } + {color === 'zodiac' && } +
    +
    + {R.range(currentStep + 1, steps + 1).map(idx => ( +
    +
    +
    + {color === 'spring' && } + {color === 'zodiac' && } +
    +
    + ))} +
    + ) +}) + +export default Stepper diff --git a/packages/admin-ui/src/components/Stepper.module.css b/packages/admin-ui/src/components/Stepper.module.css new file mode 100644 index 0000000..9dccb96 --- /dev/null +++ b/packages/admin-ui/src/components/Stepper.module.css @@ -0,0 +1,12 @@ +.stage { + display: flex; + height: 28px; + width: 28px; + z-index: 2; +} + +.stage > svg { + height: 100%; + width: 100%; + overflow: visible; +} diff --git a/packages/admin-ui/src/components/Subtitle.jsx b/packages/admin-ui/src/components/Subtitle.jsx new file mode 100644 index 0000000..31f24fb --- /dev/null +++ b/packages/admin-ui/src/components/Subtitle.jsx @@ -0,0 +1,15 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import { TL1 } from './typography' + +const Subtitle = memo(({ children, className, extraMarginTop }) => { + const classNames = { + 'text-comet my-4': true, + 'mt-18': extraMarginTop, + } + + return {children} +}) + +export default Subtitle diff --git a/packages/admin-ui/src/components/TableFilters.jsx b/packages/admin-ui/src/components/TableFilters.jsx new file mode 100644 index 0000000..697788a --- /dev/null +++ b/packages/admin-ui/src/components/TableFilters.jsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect } from 'react' +import { + Autocomplete, + TextField, + Select, + MenuItem, + FormControl, +} from '@mui/material' +import { AsyncAutocomplete } from './inputs/base/AsyncAutocomplete.jsx' + +export const SelectFilter = ({ column, options = [] }) => { + const columnFilterValue = column.getFilterValue() + + return ( + + + + ) +} + +export const AutocompleteFilter = ({ + column, + options = [], + placeholder = 'Filter...', + renderOption, + getOptionLabel = option => option.label || '', +}) => { + const columnFilterValue = column.getFilterValue() + const selectedOption = + options.find(option => option.value === columnFilterValue) || null + + return ( + { + column.setFilterValue(newValue?.value || '') + }} + getOptionLabel={getOptionLabel} + isOptionEqualToValue={(option, value) => option?.value === value?.value} + renderOption={renderOption} + renderInput={params => ( + + )} + size="small" + fullWidth + slotProps={{ + listbox: { + style: { maxHeight: 200 }, + }, + popper: { + style: { width: 'auto' }, + }, + }} + /> + ) +} + +export const TextFilter = ({ column, placeholder = 'Filter...' }) => { + const columnFilterValue = column.getFilterValue() + + return ( + { + column.setFilterValue(event.target.value || undefined) + }} + placeholder={placeholder} + variant="standard" + size="small" + fullWidth + /> + ) +} + +export const AsyncAutocompleteFilter = ({ column, ...props }) => { + const [selectedOption, setSelectedOption] = useState(null) + const columnFilterValue = column.getFilterValue() + const getOptionId = props.getOptionId || (option => option.id) + + useEffect(() => { + if (!columnFilterValue) { + setSelectedOption(null) + } + }, [columnFilterValue]) + + const handleChange = (event, newValue) => { + column.setFilterValue(newValue ? getOptionId(newValue) : '') + setSelectedOption(newValue) + } + + return ( + + ) +} diff --git a/packages/admin-ui/src/components/Title.jsx b/packages/admin-ui/src/components/Title.jsx new file mode 100644 index 0000000..25c68b2 --- /dev/null +++ b/packages/admin-ui/src/components/Title.jsx @@ -0,0 +1,9 @@ +import React, { memo } from 'react' + +import { H1 } from './typography' + +const Title = memo(({ children }) => { + return

    {children}

    +}) + +export default Title diff --git a/packages/admin-ui/src/components/Tooltip.jsx b/packages/admin-ui/src/components/Tooltip.jsx new file mode 100644 index 0000000..feb85b9 --- /dev/null +++ b/packages/admin-ui/src/components/Tooltip.jsx @@ -0,0 +1,97 @@ +import ClickAwayListener from '@mui/material/ClickAwayListener' +import * as R from 'ramda' +import React, { useState, memo } from 'react' +import Popper from './Popper' +import HelpIcon from '../styling/icons/action/help/zodiac.svg?react' + +const usePopperHandler = () => { + const [helpPopperAnchorEl, setHelpPopperAnchorEl] = useState(null) + + const handleOpenHelpPopper = event => { + setHelpPopperAnchorEl(helpPopperAnchorEl ? null : event.currentTarget) + } + + const openHelpPopper = event => { + setHelpPopperAnchorEl(event.currentTarget) + } + + const handleCloseHelpPopper = () => { + setHelpPopperAnchorEl(null) + } + + const helpPopperOpen = Boolean(helpPopperAnchorEl) + + return { + helpPopperAnchorEl, + helpPopperOpen, + handleOpenHelpPopper, + openHelpPopper, + handleCloseHelpPopper, + } +} + +const HelpTooltip = memo(({ children, width }) => { + const handler = usePopperHandler(width) + + return ( + +
    + {handler.helpPopperOpen && ( +
    + )} + + +
    + {children} +
    +
    +
    +
    + ) +}) + +const HoverableTooltip = memo(({ parentElements, children, width }) => { + const handler = usePopperHandler(width) + + return ( + +
    + {!R.isNil(parentElements) && ( +
    + {parentElements} +
    + )} + {R.isNil(parentElements) && ( + + )} + +
    + {children} +
    +
    +
    +
    + ) +}) + +export { HoverableTooltip, HelpTooltip } diff --git a/packages/admin-ui/src/components/booleanPropertiesTable/BooleanPropertiesTable.jsx b/packages/admin-ui/src/components/booleanPropertiesTable/BooleanPropertiesTable.jsx new file mode 100644 index 0000000..82b2da7 --- /dev/null +++ b/packages/admin-ui/src/components/booleanPropertiesTable/BooleanPropertiesTable.jsx @@ -0,0 +1,123 @@ +import IconButton from '@mui/material/IconButton' +import { useFormikContext, Form, Formik, Field as FormikField } from 'formik' +import * as R from 'ramda' +import React, { useState, memo } from 'react' +import PromptWhenDirty from '../PromptWhenDirty' +import { H4 } from '../typography' +import EditIconDisabled from '../../styling/icons/action/edit/disabled.svg?react' +import EditIcon from '../../styling/icons/action/edit/enabled.svg?react' +import FalseIcon from '../../styling/icons/table/false.svg?react' +import TrueIcon from '../../styling/icons/table/true.svg?react' +import * as Yup from 'yup' + +import { Link } from '../buttons' +import { RadioGroup } from '../inputs/formik' +import { Table, TableBody, TableRow, TableCell } from '../table' +import SvgIcon from '@mui/material/SvgIcon' + +const BooleanCell = ({ name }) => { + const { values } = useFormikContext() + return values[name] === 'true' ? : +} + +const BooleanPropertiesTable = memo( + ({ title, disabled, data, elements, save, forcedEditing = false }) => { + const [editing, setEditing] = useState(forcedEditing) + + const initialValues = R.fromPairs( + elements.map(it => [it.name, data[it.name]?.toString() ?? 'false']), + ) + + const validationSchema = Yup.object().shape( + R.fromPairs( + elements.map(it => [ + it.name, + Yup.mixed().oneOf(['true', 'false', true, false]).required(), + ]), + ), + ) + + const innerSave = async values => { + const toBoolean = num => R.equals(num, 'true') + save(R.mapObjIndexed(toBoolean, R.filter(R.complement(R.isNil))(values))) + setEditing(false) + } + + const radioButtonOptions = [ + { display: 'Yes', code: 'true' }, + { display: 'No', code: 'false' }, + ] + return ( +
    + + {({ resetForm }) => { + return ( +
    +
    +

    {title}

    + {editing ? ( +
    + + Save + + { + resetForm() + setEditing(false) + }} + color="secondary"> + Cancel + +
    + ) : ( + setEditing(true)}> + + {disabled ? : } + + + )} +
    + + + + {elements.map((it, idx) => ( + + {it.display} + + {editing && ( + + )} + {!editing && } + + + ))} + +
    + + ) + }} +
    +
    + ) + }, +) + +export default BooleanPropertiesTable diff --git a/packages/admin-ui/src/components/booleanPropertiesTable/index.js b/packages/admin-ui/src/components/booleanPropertiesTable/index.js new file mode 100644 index 0000000..3112ae6 --- /dev/null +++ b/packages/admin-ui/src/components/booleanPropertiesTable/index.js @@ -0,0 +1,3 @@ +import BooleanPropertiesTable from './BooleanPropertiesTable' + +export { BooleanPropertiesTable } diff --git a/packages/admin-ui/src/components/buttons/ActionButton.jsx b/packages/admin-ui/src/components/buttons/ActionButton.jsx new file mode 100644 index 0000000..6c0ca25 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/ActionButton.jsx @@ -0,0 +1,49 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import moduleStyles from './ActionButton.module.css' + +const ActionButton = memo( + ({ + className, + altTextColor, + Icon, + InverseIcon, + color, + center, + children, + ...props + }) => { + const classNames = { + [moduleStyles.actionButton]: true, + [moduleStyles.altText]: altTextColor || color !== 'primary', + [moduleStyles.primary]: color === 'primary', + [moduleStyles.secondary]: color === 'secondary', + [moduleStyles.spring]: color === 'spring', + [moduleStyles.tomato]: color === 'tomato', + [moduleStyles.center]: center, + } + + return ( + + ) + }, +) + +export default ActionButton diff --git a/packages/admin-ui/src/components/buttons/ActionButton.module.css b/packages/admin-ui/src/components/buttons/ActionButton.module.css new file mode 100644 index 0000000..22f310b --- /dev/null +++ b/packages/admin-ui/src/components/buttons/ActionButton.module.css @@ -0,0 +1,145 @@ +.actionButton { + composes: p from '../typography/typography.module.css'; + cursor: pointer; + border: none; + height: 28px; + outline: 0; + border-radius: 6px; + padding: 0 8px; + display: flex; + align-items: center; +} + +.actionButton.altText { + color: white; +} + +.primary { + background-color: var(--zircon); +} + +.primary:hover { + background-color: var(--zircon2); +} + +.primary:active { + background-color: var(--comet); + color: white; +} + +.primary .actionButtonIconActive { + display: none; +} + +.primary:active .actionButtonIcon { + display: none; +} + +.primary:active .actionButtonIconActive { + display: flex; +} + +.secondary { + background-color: var(--comet); + color: white; +} + +.secondary:hover { + background-color: var(--comet2); +} + +.secondary:active { + background-color: var(--comet3); +} + +.secondary .actionButtonIcon { + display: none; +} + +.secondary .actionButtonIconActive { + display: flex; +} + +.secondary:active .actionButtonIcon { + display: flex; +} + +.secondary:active .actionButtonIconActive { + display: none; +} + +.spring { + background-color: var(--spring2); + color: white; +} + +.spring:hover { + background-color: var(--spring); +} + +.spring:active { + background-color: var(--spring4); +} + +.spring .actionButtonIcon { + display: none; +} + +.spring .actionButtonIconActive { + display: flex; +} + +.spring:active .actionButtonIcon { + display: flex; +} + +.spring:active .actionButtonIconActive { + display: none; +} + +.tomato { + background-color: var(--tomato); + color: white; +} + +.tomato:hover { + background-color: var(--tomato); +} + +.tomato:active { + background-color: var(--tomato); +} + +.tomato .actionButtonIcon { + display: none; +} + +.tomato .actionButtonIconActive { + display: flex; +} + +.tomato:active .actionButtonIcon { + display: flex; +} + +.tomato:active .actionButtonIconActive { + display: none; +} + +.actionButtonIcon { + display: flex; + padding-right: 7px; +} + +.actionButtonIcon svg { + width: 14px; + height: 14px; +} + +.center { + align-items: center; + justify-content: center; +} + +.actionButtonIconActive { +} diff --git a/packages/admin-ui/src/components/buttons/AddButton.jsx b/packages/admin-ui/src/components/buttons/AddButton.jsx new file mode 100644 index 0000000..e6cecdc --- /dev/null +++ b/packages/admin-ui/src/components/buttons/AddButton.jsx @@ -0,0 +1,16 @@ +import classnames from 'classnames' +import React, { memo } from 'react' +import AddIcon from '../../styling/icons/button/add/zodiac.svg?react' + +import classes from './AddButton.module.css' + +const SimpleButton = memo(({ className, children, ...props }) => { + return ( + + ) +}) + +export default SimpleButton diff --git a/packages/admin-ui/src/components/buttons/AddButton.module.css b/packages/admin-ui/src/components/buttons/AddButton.module.css new file mode 100644 index 0000000..a92609f --- /dev/null +++ b/packages/admin-ui/src/components/buttons/AddButton.module.css @@ -0,0 +1,30 @@ +.button { + composes: p from '../typography/typography.module.css'; + border: none; + background-color: var(--zircon); + cursor: pointer; + outline: 0; + display: flex; + justify-content: center; + align-items: center; + width: 167px; + height: 48px; + color: var(--zodiac); +} + +.button:hover { + background-color: var(--zircon2); +} + +.button:active { + background-color: var(--comet); + color: white; +} + +.button:active svg g * { + stroke: white; +} + +.button svg { + margin-right: 8px; +} diff --git a/packages/admin-ui/src/components/buttons/BaseButton.styles.js b/packages/admin-ui/src/components/buttons/BaseButton.styles.js new file mode 100644 index 0000000..e3cd451 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/BaseButton.styles.js @@ -0,0 +1,70 @@ +import { + white, + fontColor, + subheaderColor, + subheaderDarkColor, + offColor, + offDarkColor, +} from '../../styling/variables' + +const colors = (color1, color2, color3) => { + return { + backgroundColor: color1, + '&:hover': { + backgroundColor: color2, + }, + '&:active': { + backgroundColor: color3, + }, + } +} + +const buttonHeight = 32 + +export default { + baseButton: { + extend: colors(subheaderColor, subheaderDarkColor, offColor), + cursor: 'pointer', + border: 'none', + outline: 0, + height: buttonHeight, + color: fontColor, + '&:active': { + color: white, + }, + }, + primary: { + extend: colors(subheaderColor, subheaderDarkColor, offColor), + '&:active': { + color: white, + '& $buttonIcon': { + display: 'none', + }, + '& $buttonIconActive': { + display: 'block', + }, + }, + '& $buttonIconActive': { + display: 'none', + }, + }, + secondary: { + extend: colors(offColor, offDarkColor, white), + color: white, + '&:active': { + color: fontColor, + '& $buttonIcon': { + display: 'flex', + }, + '& $buttonIconActive': { + display: 'none', + }, + }, + '& $buttonIcon': { + display: 'none', + }, + '& $buttonIconActive': { + display: 'flex', + }, + }, +} diff --git a/packages/admin-ui/src/components/buttons/Button.jsx b/packages/admin-ui/src/components/buttons/Button.jsx new file mode 100644 index 0000000..95342ac --- /dev/null +++ b/packages/admin-ui/src/components/buttons/Button.jsx @@ -0,0 +1,43 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import moduleStyles from './Button.module.css' +import { spacer } from '../../styling/variables.js' + +const pickSize = size => { + switch (size) { + case 'xl': + return spacer * 7.625 + case 'sm': + return spacer * 4 + case 'lg': + default: + return spacer * 5 + } +} + +const ActionButton = memo( + ({ size = 'lg', children, className, buttonClassName, ...props }) => { + const height = pickSize(size) + + return ( +
    + +
    + ) + }, +) + +export default ActionButton diff --git a/packages/admin-ui/src/components/buttons/Button.module.css b/packages/admin-ui/src/components/buttons/Button.module.css new file mode 100644 index 0000000..c5c5c43 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/Button.module.css @@ -0,0 +1,49 @@ +.button { + composes: h3 from '../typography/typography.module.css'; + border: none; + cursor: pointer; + outline: 0; + font-weight: 900; + background-color: var(--spring); + height: 40px; + padding: 0 20px; + border-radius: 10px; + box-shadow: 0 3px var(--spring4); +} + +.buttonXl { + composes: h1 from '../typography/typography.module.css'; + height: 61px; + border-radius: 15px; +} + +.buttonSm { + height: 32px; + padding: 0 16px; + border-radius: 8px; +} + +.button:disabled { + background-color: var(--dust); + box-shadow: none; +} + +.button:disabled:hover { + background-color: var(--dust); + box-shadow: none; +} + +.button:disabled:active { + margin-top: 0; +} + +.button:hover { + background-color: var(--spring2); + box-shadow: 0 3px var(--spring4); +} + +.button:active { + margin-top: 2px; + background-color: var(--spring2); + box-shadow: 0 2px var(--spring4); +} diff --git a/packages/admin-ui/src/components/buttons/FeatureButton.jsx b/packages/admin-ui/src/components/buttons/FeatureButton.jsx new file mode 100644 index 0000000..da65a2d --- /dev/null +++ b/packages/admin-ui/src/components/buttons/FeatureButton.jsx @@ -0,0 +1,37 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import classes from './FeatureButton.module.css' + +const FeatureButton = memo( + ({ className, Icon, InverseIcon, children, ...props }) => { + return ( + + ) + }, +) + +export default FeatureButton diff --git a/packages/admin-ui/src/components/buttons/FeatureButton.module.css b/packages/admin-ui/src/components/buttons/FeatureButton.module.css new file mode 100644 index 0000000..1c3d7ba --- /dev/null +++ b/packages/admin-ui/src/components/buttons/FeatureButton.module.css @@ -0,0 +1,87 @@ +.baseButton { + cursor: pointer; + border: none; + outline: 0; + height: 32px; + color: var(--zodiac); +} + +.roundButton { + width: 32px; + border-radius: 16px; + display: flex; + padding: 0; +} + +.roundButton .buttonIcon { + margin: auto; +} + +.roundButton .buttonIcon svg { + width: 16px; + height: 16px; + overflow: visible; +} + +.roundButton .buttonIcon svg g { + stroke-width: 2px; +} + +.baseButton:active { + color: white; +} + +.primary { + background-color: var(--zircon); +} + +.primary:hover { + background-color: var(--zircon2); +} + +.primary:active { + background-color: var(--comet); + color: white; +} + +.primary .buttonIconActive { + display: none; +} + +.primary:active .buttonIcon { + display: none; +} + +.primary:active .buttonIconActive { + display: block; +} + +.secondary { + background-color: var(--comet); + color: white; +} + +.secondary:hover { + background-color: var(--comet2); +} + +.secondary:active { + background-color: white; + color: var(--zodiac); +} + +.secondary .buttonIcon { + display: none; +} + +.secondary .buttonIconActive { + display: flex; +} + +.secondary:active .buttonIcon { + display: flex; +} + +.secondary:active .buttonIconActive { + display: none; +} diff --git a/packages/admin-ui/src/components/buttons/IDButton.jsx b/packages/admin-ui/src/components/buttons/IDButton.jsx new file mode 100644 index 0000000..55f459c --- /dev/null +++ b/packages/admin-ui/src/components/buttons/IDButton.jsx @@ -0,0 +1,79 @@ +import ClickAwayListener from '@mui/material/ClickAwayListener' +import classnames from 'classnames' +import React, { useState, memo } from 'react' +import Popover from '../Popper' + +import classes from './IDButton.module.css' + +const IDButton = memo( + ({ + name, + className, + Icon, + InverseIcon, + children, + popoverClassname, + ...props + }) => { + const [anchorEl, setAnchorEl] = useState(null) + + const open = Boolean(anchorEl) + const id = open ? `simple-popper-${name}` : undefined + + const classNames = { + [classes.idButton]: true, + [classes.primary]: true, + [classes.open]: open, + [classes.closed]: !open, + } + + const iconClassNames = { + [classes.buttonIcon]: true, + } + + const handleClick = event => { + setAnchorEl(anchorEl ? null : event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + return ( + <> + + + + +
    +
    {children}
    +
    +
    + + ) + }, +) + +export default IDButton diff --git a/packages/admin-ui/src/components/buttons/IDButton.module.css b/packages/admin-ui/src/components/buttons/IDButton.module.css new file mode 100644 index 0000000..109d588 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/IDButton.module.css @@ -0,0 +1,58 @@ +.idButton { + width: 34px; + height: 28px; + display: flex; + border-radius: 4px; + padding: 0; + border: none; + cursor: pointer; +} + +.buttonIcon { + margin: auto; + line-height: 1px; +} + +.buttonIcon svg { + overflow: visible; +} + +.closed { + background-color: var(--zircon); +} + +.closed:hover { + background-color: var(--zircon2); +} + +.closed:active { + background-color: var(--comet); + color: white; +} + +.open { + background-color: var(--comet); + color: white; +} + +.open:hover { + background-color: var(--comet2); +} + +.open:active { + background-color: var(--comet3); +} + +.popoverContent { + composes: info2 from '../typography/typography.module.css'; + padding: 8px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 4px; +} + +.popoverContent img { + height: 145px; + min-width: 200px; +} diff --git a/packages/admin-ui/src/components/buttons/Link.jsx b/packages/admin-ui/src/components/buttons/Link.jsx new file mode 100644 index 0000000..39a721d --- /dev/null +++ b/packages/admin-ui/src/components/buttons/Link.jsx @@ -0,0 +1,27 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import classes from './Link.module.css' + +const Link = memo( + ({ submit, className, children, color = 'primary', ...props }) => { + const classNames = { + [classes.link]: true, + [classes.primary]: color === 'primary', + [classes.secondary]: color === 'secondary', + [classes.noColor]: color === 'noColor', + [classes.action]: color === 'action', + } + + return ( + + ) + }, +) + +export default Link diff --git a/packages/admin-ui/src/components/buttons/Link.module.css b/packages/admin-ui/src/components/buttons/Link.module.css new file mode 100644 index 0000000..ee8244f --- /dev/null +++ b/packages/admin-ui/src/components/buttons/Link.module.css @@ -0,0 +1,47 @@ +.link { + composes: h4 from '../typography/typography.module.css'; + text-decoration: none; + border: none; + background-color: transparent; + cursor: pointer; + padding: 0; + height: 100%; +} + +.primary { + box-shadow: inset 0 -4px 0 0 rgba(72, 246, 148, 0.8); +} + +.primary:hover { + box-shadow: none; + background-color: rgba(72, 246, 148, 0.8); +} + +.secondary { + box-shadow: inset 0 -4px 0 0 rgba(255, 88, 74, 0.8); +} + +.secondary:hover { + box-shadow: none; + background-color: rgba(255, 88, 74, 0.8); + color: white; +} + +.noColor { + box-shadow: inset 0 -4px 0 0 rgba(255, 255, 255, 0.8); +} + +.noColor:hover { + box-shadow: none; + background-color: rgba(255, 255, 255, 0.8); +} + +.action { + box-shadow: inset 0 -4px 0 0 rgba(72, 246, 148, 0.8); + color: var(--zircon); +} + +.action:hover { + box-shadow: none; + background-color: rgba(72, 246, 148, 0.8); +} diff --git a/packages/admin-ui/src/components/buttons/SubpageButton.jsx b/packages/admin-ui/src/components/buttons/SubpageButton.jsx new file mode 100644 index 0000000..73e38f4 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/SubpageButton.jsx @@ -0,0 +1,62 @@ +import classnames from 'classnames' +import React, { memo, useState } from 'react' +import { H4 } from '../typography' +import CancelIconInverse from '../../styling/icons/button/cancel/white.svg?react' + +import classes from './SubpageButton.module.css' + +const SubpageButton = memo( + ({ + className, + Icon, + InverseIcon, + toggle, + forceDisable = false, + children, + }) => { + const [active, setActive] = useState(false) + const isActive = forceDisable ? false : active + const classNames = { + [classes.button]: true, + [classes.normal]: !isActive, + [classes.active]: isActive, + } + + const normalButton = + + const activeButton = ( + <> + +

    {children}

    + + + ) + + const innerToggle = () => { + forceDisable = false + const newActiveState = !isActive + toggle(newActiveState) + setActive(newActiveState) + } + + return ( + + ) + }, +) + +export default SubpageButton diff --git a/packages/admin-ui/src/components/buttons/SubpageButton.module.css b/packages/admin-ui/src/components/buttons/SubpageButton.module.css new file mode 100644 index 0000000..84babdb --- /dev/null +++ b/packages/admin-ui/src/components/buttons/SubpageButton.module.css @@ -0,0 +1,51 @@ +.button { + cursor: pointer; + border: none; + outline: 0; + height: 32px; + padding: 0; + color: white; + border-radius: 16px; + background-color: var(--zircon); +} + +.button:hover { + background-color: var(--zircon2); +} + +.normal { + width: 32px; +} + +.active { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--comet); + font-weight: bold; + padding: 0 5px; +} + +.active:hover { + background-color: var(--comet); +} + +.buttonIcon { + width: 16px; + height: 16px; + overflow: visible; +} + +.buttonIcon g { + stroke-width: 1.8px; +} + +.buttonIconActiveLeft { + margin-right: 12px; + margin-left: 4px; +} + +.buttonIconActiveRight { + margin-right: 5px; + margin-left: 20px; +} diff --git a/packages/admin-ui/src/components/buttons/SubpageButton.styles.js b/packages/admin-ui/src/components/buttons/SubpageButton.styles.js new file mode 100644 index 0000000..5f51454 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/SubpageButton.styles.js @@ -0,0 +1,46 @@ +import baseButtonStyles from './BaseButton.styles' +import { offColor, white } from '../../styling/variables' + +const { baseButton } = baseButtonStyles + +export default { + button: { + extend: baseButton, + padding: 0, + color: white, + borderRadius: baseButton.height / 2, + }, + normalButton: { + width: baseButton.height, + }, + activeButton: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + backgroundColor: offColor, + fontWeight: 'bold', + padding: '0 5px', + '&:hover': { + backgroundColor: offColor, + }, + }, + buttonIcon: { + width: 16, + height: 16, + overflow: 'visible', + '& g': { + strokeWidth: 1.8, + }, + }, + buttonIconActiveLeft: { + marginRight: 12, + marginLeft: 4, + }, + buttonIconActiveRight: { + marginRight: 5, + marginLeft: 20, + }, + white: { + color: white, + }, +} diff --git a/packages/admin-ui/src/components/buttons/SupportLinkButton.jsx b/packages/admin-ui/src/components/buttons/SupportLinkButton.jsx new file mode 100644 index 0000000..630868f --- /dev/null +++ b/packages/admin-ui/src/components/buttons/SupportLinkButton.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import InverseLinkIcon from '../../styling/icons/action/external link/white.svg?react' +import LinkIcon from '../../styling/icons/action/external link/zodiac.svg?react' + +import { ActionButton } from './' + +const SupportLinkButton = ({ link, label }) => { + return ( + + + {label} + + + ) +} + +export default SupportLinkButton diff --git a/packages/admin-ui/src/components/buttons/index.js b/packages/admin-ui/src/components/buttons/index.js new file mode 100644 index 0000000..10cbe94 --- /dev/null +++ b/packages/admin-ui/src/components/buttons/index.js @@ -0,0 +1,19 @@ +import ActionButton from './ActionButton' +import AddButton from './AddButton' +import Button from './Button' +import FeatureButton from './FeatureButton' +import IDButton from './IDButton' +import Link from './Link' +import SubpageButton from './SubpageButton' +import SupportLinkButton from './SupportLinkButton' + +export { + Button, + Link, + ActionButton, + FeatureButton, + IDButton, + AddButton, + SupportLinkButton, + SubpageButton, +} diff --git a/packages/admin-ui/src/components/date-range-picker/Calendar.jsx b/packages/admin-ui/src/components/date-range-picker/Calendar.jsx new file mode 100644 index 0000000..7499670 --- /dev/null +++ b/packages/admin-ui/src/components/date-range-picker/Calendar.jsx @@ -0,0 +1,138 @@ +import { + add, + differenceInMonths, + format, + getDay, + getDaysInMonth, + isAfter, + isSameDay, + isSameMonth, + lastDayOfMonth, + startOfMonth, + startOfWeek, + sub, +} from 'date-fns/fp' +import * as R from 'ramda' +import React, { useState } from 'react' +import Arrow from '../../styling/icons/arrow/month_change.svg?react' +import RightArrow from '../../styling/icons/arrow/month_change_right.svg?react' + +import Tile from './Tile' +import classes from './Calendar.module.css' + +const Calendar = ({ minDate, maxDate, handleSelect, ...props }) => { + const [currentDisplayedMonth, setCurrentDisplayedMonth] = useState(new Date()) + + const weekdays = Array.from(Array(7)).map((_, i) => + format('EEEEE', add({ days: i }, startOfWeek(new Date()))), + ) + + const monthLength = month => getDaysInMonth(month) + + const monthdays = month => { + const lastMonth = sub({ months: 1 }, month) + const lastMonthRange = R.range(0, getDay(startOfMonth(month))).reverse() + const lastMonthDays = R.map(i => + sub({ days: i }, lastDayOfMonth(lastMonth)), + )(lastMonthRange) + + const thisMonthRange = R.range(0, monthLength(month)) + const thisMonthDays = R.map(i => add({ days: i }, startOfMonth(month)))( + thisMonthRange, + ) + + const nextMonth = add({ months: 1 }, month) + const nextMonthRange = R.range( + 0, + 42 - lastMonthDays.length - thisMonthDays.length, + ) + const nextMonthDays = R.map(i => add({ days: i }, startOfMonth(nextMonth)))( + nextMonthRange, + ) + + return R.concat(R.concat(lastMonthDays, thisMonthDays), nextMonthDays) + } + + const getRow = (month, row) => monthdays(month).slice(row * 7 - 7, row * 7) + + const handleNavPrev = currentMonth => { + const prevMonth = sub({ months: 1 }, currentMonth) + if (!minDate) setCurrentDisplayedMonth(prevMonth) + else { + setCurrentDisplayedMonth( + isSameMonth(minDate, prevMonth) || + differenceInMonths(minDate, prevMonth) > 0 + ? prevMonth + : currentDisplayedMonth, + ) + } + } + const handleNavNext = currentMonth => { + const nextMonth = add({ months: 1 }, currentMonth) + if (!maxDate) setCurrentDisplayedMonth(nextMonth) + else { + setCurrentDisplayedMonth( + isSameMonth(maxDate, nextMonth) || + differenceInMonths(nextMonth, maxDate) > 0 + ? nextMonth + : currentDisplayedMonth, + ) + } + } + + return ( +
    +
    + + + {`${format('MMMM', currentDisplayedMonth)} ${format( + 'yyyy', + currentDisplayedMonth, + )}`} + + +
    + + + + {weekdays.map((day, key) => ( + + ))} + + + + {R.range(1, 8).map((row, key) => ( + + {getRow(currentDisplayedMonth, row).map((day, key) => ( + + ))} + + ))} + +
    {day}
    handleSelect(day)}> + + {format('d', day)} + +
    +
    + ) +} + +export default Calendar diff --git a/packages/admin-ui/src/components/date-range-picker/Calendar.module.css b/packages/admin-ui/src/components/date-range-picker/Calendar.module.css new file mode 100644 index 0000000..afc5950 --- /dev/null +++ b/packages/admin-ui/src/components/date-range-picker/Calendar.module.css @@ -0,0 +1,66 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.button { + outline: none; +} + +.navbar { + font-size: 14px; + font-family: var(--museo); + font-weight: 500; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 15px 15px; + color: var(--zodiac); +} + +.navbar button { + display: flex; + align-items: center; + padding: 0; + border: none; + background-color: var(--zircon); + cursor: pointer; + border-radius: 50%; + width: 20px; + height: 20px; + position: relative; + overflow: hidden; +} + +.navbar button svg { + position: absolute; + left: 0; +} + +.table { + border-collapse: collapse; + width: 100%; + color: var(--zodiac); +} + +.table tr:first-child { + padding-left: 5px; +} + +.table tr:last-child { + padding-right: 5px; +} + +.table th, +.table td { + margin: 0; + padding: 3px 0 3px 0; +} + +.table th { + font-size: 13px; + font-family: var(--museo); + font-weight: 700; +} diff --git a/packages/admin-ui/src/components/date-range-picker/DateRangePicker.jsx b/packages/admin-ui/src/components/date-range-picker/DateRangePicker.jsx new file mode 100644 index 0000000..f90086f --- /dev/null +++ b/packages/admin-ui/src/components/date-range-picker/DateRangePicker.jsx @@ -0,0 +1,59 @@ +import classnames from 'classnames' +import { compareAsc, differenceInDays, set } from 'date-fns/fp' +import * as R from 'ramda' +import React, { useState, useEffect } from 'react' + +import Calendar from './Calendar' + +const DateRangePicker = ({ minDate, maxDate, className, onRangeChange }) => { + const [from, setFrom] = useState(null) + const [to, setTo] = useState(null) + + useEffect(() => { + onRangeChange(from, to) + }, [from, onRangeChange, to]) + + const handleSelect = day => { + if ( + (maxDate && compareAsc(maxDate, day) > 0) || + (minDate && differenceInDays(day, minDate) > 0) + ) + return + + if (from && !to) { + if (differenceInDays(from, day) >= 0) { + setTo( + set({ hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }, day), + ) + } else { + setTo( + set( + { hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }, + R.clone(from), + ), + ) + setFrom(day) + } + return + } + + setFrom(day) + setTo(null) + } + + return ( + <> +
    + +
    + + ) +} + +export default DateRangePicker diff --git a/packages/admin-ui/src/components/date-range-picker/Tile.jsx b/packages/admin-ui/src/components/date-range-picker/Tile.jsx new file mode 100644 index 0000000..71f52db --- /dev/null +++ b/packages/admin-ui/src/components/date-range-picker/Tile.jsx @@ -0,0 +1,41 @@ +import classnames from 'classnames' +import React from 'react' + +import classes from './Tile.module.css' + +const Tile = ({ + isLowerBound, + isUpperBound, + isBetween, + isDisabled, + children, +}) => { + const selected = isLowerBound || isUpperBound + + const rangeClasses = { + [classes.between]: isBetween && !(isLowerBound && isUpperBound), + [classes.lowerBound]: isLowerBound && !isUpperBound, + [classes.upperBound]: isUpperBound && !isLowerBound, + } + + const buttonWrapperClasses = { + [classes.wrapper]: true, + [classes.selected]: selected, + } + + const buttonClasses = { + [classes.button]: true, + [classes.disabled]: isDisabled, + } + + return ( +
    +
    +
    + +
    +
    + ) +} + +export default Tile diff --git a/packages/admin-ui/src/components/date-range-picker/Tile.module.css b/packages/admin-ui/src/components/date-range-picker/Tile.module.css new file mode 100644 index 0000000..557c7e6 --- /dev/null +++ b/packages/admin-ui/src/components/date-range-picker/Tile.module.css @@ -0,0 +1,53 @@ +.wrapper { + height: 26px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.button { + outline: none; + font-size: 13px; + font-family: var(--museo); + font-weight: 500; + border: none; + cursor: pointer; + background-color: transparent; + color: var(--zodiac); + z-index: 2; +} + +.lowerBound { + left: 50%; +} + +.upperBound { + right: 50%; +} + +.selected { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--spring2); + border-radius: 50%; + position: absolute; + z-index: 1; +} + +.between { + position: absolute; + width: 100%; + height: 100%; + z-index: 0; + background-color: var(--spring3); +} + +.disabled { + color: var(--dust); + cursor: default; +} diff --git a/packages/admin-ui/src/components/editableTable/Context.js b/packages/admin-ui/src/components/editableTable/Context.js new file mode 100644 index 0000000..f54c8c4 --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Context.js @@ -0,0 +1,3 @@ +import React from 'react' + +export default React.createContext() diff --git a/packages/admin-ui/src/components/editableTable/Header.jsx b/packages/admin-ui/src/components/editableTable/Header.jsx new file mode 100644 index 0000000..58f9f48 --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Header.jsx @@ -0,0 +1,127 @@ +import classnames from 'classnames' +import * as R from 'ramda' +import React, { useContext } from 'react' +import { Td, THead, TDoubleLevelHead, ThDoubleLevel } from '../fake-table/Table' + +import { sentenceCase } from '../../utils/string' + +import TableCtx from './Context' + +const groupSecondHeader = elements => { + const doubleHeader = R.prop('doubleHeader') + const sameDoubleHeader = (a, b) => doubleHeader(a) === doubleHeader(b) + const group = R.pipe( + R.groupWith(sameDoubleHeader), + R.map(group => + R.isNil(doubleHeader(group[0])) // No doubleHeader + ? group + : [ + { + width: R.sum(R.map(R.prop('width'), group)), + elements: group, + name: doubleHeader(group[0]), + }, + ], + ), + R.reduce(R.concat, []), + ) + + return R.all(R.pipe(doubleHeader, R.isNil), elements) + ? [elements, THead] + : [group(elements), TDoubleLevelHead] +} + +const Header = () => { + const { + elements, + enableEdit, + enableEditText, + editWidth, + enableDelete, + deleteWidth, + enableToggle, + toggleWidth, + orderedBy, + DEFAULT_COL_SIZE, + } = useContext(TableCtx) + + const mapElement2 = (it, idx) => { + const { width, elements, name } = it + + if (elements && elements.length) { + return ( + + {elements.map(mapElement)} + + ) + } + + return mapElement(it, idx) + } + + const mapElement = ( + { name, display, width = DEFAULT_COL_SIZE, header, textAlign }, + idx, + ) => { + const orderClasses = classnames({ + 'whitespace-nowrap': + R.isNil(header) && + !R.isNil(orderedBy) && + R.equals(name, orderedBy.code), + }) + + const attachOrderedByToComplexHeader = header => { + if (!R.isNil(orderedBy) && R.equals(name, orderedBy.code)) { + try { + const cloneHeader = R.clone(header) + const children = R.path(['props', 'children'], cloneHeader) + const spanChild = R.find(it => R.equals(it.type, 'span'), children) + spanChild.props.children = R.append(' -', spanChild.props.children) + return cloneHeader + } catch (e) { + console.error(e) + return header + } + } + return header + } + + return ( + + {!R.isNil(header) ? ( + <>{attachOrderedByToComplexHeader(header) ?? header} + ) : ( + + {!R.isNil(display) ? display : sentenceCase(name)}{' '} + {!R.isNil(orderedBy) && R.equals(name, orderedBy.code) && '-'} + + )} + + ) + } + + const [innerElements, HeaderElement] = groupSecondHeader(elements) + + return ( + + {innerElements.map(mapElement2)} + {enableEdit && ( + + {enableEditText ?? `Edit`} + + )} + {enableDelete && ( + + Delete + + )} + {enableToggle && ( + + Enable + + )} + + ) +} + +export default Header diff --git a/packages/admin-ui/src/components/editableTable/NamespacedTable.jsx b/packages/admin-ui/src/components/editableTable/NamespacedTable.jsx new file mode 100644 index 0000000..8313f7c --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/NamespacedTable.jsx @@ -0,0 +1,29 @@ +import * as R from 'ramda' +import React from 'react' + +import { fromNamespace, toNamespace } from '../../utils/config' + +import EditableTable from './Table' + +const NamespacedTable = ({ + name, + save, + data = {}, + namespaces = [], + ...props +}) => { + const innerSave = (...[, it]) => { + return save(toNamespace(it.id)(R.omit(['id2'], it))) + } + + const innerData = R.map(it => ({ + id: it, + ...fromNamespace(it)(data), + }))(namespaces) + + return ( + + ) +} + +export default NamespacedTable diff --git a/packages/admin-ui/src/components/editableTable/Row.jsx b/packages/admin-ui/src/components/editableTable/Row.jsx new file mode 100644 index 0000000..6f58c55 --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Row.jsx @@ -0,0 +1,299 @@ +import Switch from '@mui/material/Switch' +import IconButton from '@mui/material/IconButton' +import SvgIcon from '@mui/material/SvgIcon' +import classnames from 'classnames' +import { Field, useFormikContext } from 'formik' +import * as R from 'ramda' +import React, { useContext, useState } from 'react' +import { DeleteDialog } from '../DeleteDialog' +import { Td, Tr } from '../fake-table/Table' +import { Label2 } from '../typography' +import DisabledDeleteIcon from '../../styling/icons/action/delete/disabled.svg?react' +import DeleteIcon from '../../styling/icons/action/delete/enabled.svg?react' +import DisabledEditIcon from '../../styling/icons/action/edit/disabled.svg?react' +import EditIcon from '../../styling/icons/action/edit/enabled.svg?react' +import StripesSvg from '../../styling/icons/stripes.svg?react' + +import { Link } from '../buttons' + +import TableCtx from './Context' +import moduleStyles from './Row.module.css' + +const ActionCol = ({ disabled, editing }) => { + const { values, submitForm, resetForm } = useFormikContext() + const { + editWidth, + onEdit, + enableEdit, + enableDelete, + disableRowEdit, + onDelete, + deleteWidth, + enableToggle, + onToggle, + toggleWidth, + forceAdd, + clearError, + actionColSize, + error, + } = useContext(TableCtx) + + const disableEdit = disabled || (disableRowEdit && disableRowEdit(values)) + const cancel = () => { + clearError() + resetForm() + } + + const [deleteDialog, setDeleteDialog] = useState(false) + + const onConfirmed = () => { + onDelete(values.id).then(res => { + if (!R.isNil(res)) setDeleteDialog(false) + }) + } + + return ( + <> + {editing && ( + + + Save + + {!forceAdd && ( + + Cancel + + )} + + )} + {!editing && enableEdit && ( + + onEdit && onEdit(values.id)} + size="small"> + + {disableEdit ? : } + + + + )} + {!editing && enableDelete && ( + + { + setDeleteDialog(true) + }} + size="small"> + + {disabled ? : } + + + { + setDeleteDialog(false) + clearError() + }} + errorMessage={error} + /> + + )} + {!editing && enableToggle && ( + + onToggle(values.id)} + /> + + )} + + ) +} + +const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => { + const { + name, + names, + bypassField, + input, + editable = true, + size, + bold, + width, + textAlign, + editingAlign = textAlign, + prefix, + PrefixComponent = Label2, + suffix, + SuffixComponent = Label2, + textStyle = () => {}, + isHidden = () => false, + view = it => it?.toString(), + inputProps = {}, + } = config + + const fields = names ?? [name] + + const { values } = useFormikContext() + const isEditable = editable => { + if (typeof editable === 'function') return editable(values) + return editable + } + const isEditing = editing && isEditable(editable) + const isField = !bypassField + + const innerProps = { + fullWidth: true, + autoFocus: focus, + size, + bold, + textAlign: isEditing ? editingAlign : textAlign, + ...inputProps, + } + + const newAlign = isEditing ? editingAlign : textAlign + const justifyContent = newAlign === 'right' ? 'flex-end' : newAlign + const style = suffix || prefix ? { justifyContent } : {} + + return ( +
    + {fields.map((f, idx) => ( + + {prefix && !isHidden(values) && ( + + {typeof prefix === 'function' ? prefix(f) : prefix} + + )} + {isEditing && isField && !isHidden(values) && ( + + )} + {isEditing && !isField && !isHidden(values) && ( + + )} + {!isEditing && values && !isHidden(values) && ( +
    + {view(values[f], values)} +
    + )} + {suffix && !isHidden(values) && ( + + {suffix} + + )} + {isHidden(values) && } + + ))} +
    + ) +} + +const groupStriped = elements => { + const [toStripe, noStripe] = R.partition(R.propEq(true, 'stripe'))(elements) + + if (!toStripe.length) { + return elements + } + + const index = R.indexOf(toStripe[0], elements) + const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe) + + return R.insert( + index, + { width, editable: false, view: () => }, + noStripe, + ) +} + +const ERow = ({ editing, disabled, lastOfGroup, newRow }) => { + const { touched, errors, values } = useFormikContext() + const { + elements, + enableEdit, + enableDelete, + error, + enableToggle, + rowSize, + stripeWhen, + } = useContext(TableCtx) + + const shouldStripe = !editing && stripeWhen && stripeWhen(values) + + const innerElements = shouldStripe ? groupStriped(elements) : elements + const [toSHeader] = R.partition(R.has('doubleHeader'))(elements) + + const extraPaddingIndex = toSHeader?.length + ? R.indexOf(toSHeader[0], elements) + : -1 + + const extraPaddingRightIndex = toSHeader?.length + ? R.indexOf(toSHeader[toSHeader.length - 1], elements) + : -1 + + const elementToFocusIndex = innerElements.findIndex( + it => it.editable === undefined || it.editable, + ) + + const classNames = { + [moduleStyles.lastOfGroup]: lastOfGroup, + } + + const touchedErrors = R.pick(R.keys(touched), errors) + const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0 + const hasErrors = hasTouchedErrors || !!error + + const errorMessage = + error || (touchedErrors && R.values(touchedErrors).join(', ')) + + return ( + + {innerElements.map((it, idx) => { + return ( + + ) + })} + {(enableEdit || enableDelete || enableToggle) && ( + + )} + + ) +} + +export default ERow diff --git a/packages/admin-ui/src/components/editableTable/Row.module.css b/packages/admin-ui/src/components/editableTable/Row.module.css new file mode 100644 index 0000000..319a895 --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Row.module.css @@ -0,0 +1,29 @@ +.saveButton { + margin-right: 20px; +} + +.lastOfGroup { + margin-bottom: 24px; +} + +.extraPadding { + padding-left: 35px; + padding-right: 30px; +} + +.extraPaddingRight { + padding-right: 39px; +} + +.suffix { + margin: 0 0 0 7px; +} + +.prefix { + margin: 0 7px 0 0; +} + +.fields { + display: flex; + flex-direction: column; +} diff --git a/packages/admin-ui/src/components/editableTable/Table.jsx b/packages/admin-ui/src/components/editableTable/Table.jsx new file mode 100644 index 0000000..0a63fec --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Table.jsx @@ -0,0 +1,250 @@ +import { Form, Formik } from 'formik' +import * as R from 'ramda' +import React, { useState, useEffect } from 'react' +import PromptWhenDirty from '../PromptWhenDirty' +import Link from '../buttons/Link' +import { TBody, Table } from '../fake-table/Table' +import { Info2, TL1 } from '../typography' +import { v4 as uuidv4 } from 'uuid' + +import { AddButton } from '../buttons/index' + +import TableCtx from './Context' +import Header from './Header' +import ERow from './Row' +import classes from './Table.module.css' + +const ACTION_COL_SIZE = 87 +const DEFAULT_COL_SIZE = 100 + +const getWidth = R.compose( + R.reduce(R.add)(0), + R.map(it => it.width ?? DEFAULT_COL_SIZE), +) + +const ETable = ({ + name, + title, + titleLg, + elements = [], + data = [], + save, + error: externalError, + rowSize = 'md', + validationSchema, + enableCreate, + enableEdit, + enableEditText, + editWidth: outerEditWidth, + enableDelete, + deleteWidth = ACTION_COL_SIZE, + enableToggle, + toggleWidth = ACTION_COL_SIZE, + onToggle, + forceDisable, + disableAdd, + initialValues, + setEditing, + shouldOverrideEdit, + editOverride, + stripeWhen, + disableRowEdit, + groupBy, + sortBy, + createText = 'Add override', + forceAdd = false, + tbodyWrapperClass, + orderedBy = null, +}) => { + const [editingId, setEditingId] = useState(null) + const [adding, setAdding] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => setError(externalError), [externalError]) + useEffect(() => { + setError(null) + setAdding(forceAdd) + }, [forceAdd]) + + const innerSave = async value => { + if (saving) return + + setSaving(true) + + const it = validationSchema.cast(value, { assert: 'ignore-optionality' }) + const index = R.findIndex(R.propEq(it.id, 'id'))(data) + const list = index !== -1 ? R.update(index, it, data) : R.prepend(it, data) + + if (!R.equals(data[index], it)) { + try { + await save({ [name]: list }, it) + } catch (err) { + console.error(err) + setSaving(false) + return + } + } + + setAdding(false) + setEditing && setEditing(false) + setSaving(false) + } + + const onDelete = id => { + const list = R.reject(it => it.id === id, data) + return save({ [name]: list }) + } + + const onReset = () => { + setAdding(false) + setEditingId(null) + setEditing && setEditing(false) + } + + const onEdit = it => { + if (shouldOverrideEdit && shouldOverrideEdit(it)) return editOverride(it) + setEditingId(it) + setError(null) + setEditing && setEditing(it, true) + } + + const addField = () => { + setAdding(true) + setError(null) + setEditing && setEditing(true, true) + } + + const widthIfEditNull = + enableDelete || enableToggle ? ACTION_COL_SIZE : ACTION_COL_SIZE * 2 + + const editWidth = R.defaultTo(widthIfEditNull)(outerEditWidth) + + const actionColSize = + ((enableDelete && deleteWidth) ?? 0) + + ((enableEdit && editWidth) ?? 0) + + ((enableToggle && toggleWidth) ?? 0) + + const width = getWidth(elements) + actionColSize + + const showButtonOnEmpty = !data.length && enableCreate && !adding + const canAdd = !forceDisable && !editingId && !disableAdd && !adding + const showTable = adding || data.length !== 0 + + const innerData = sortBy ? R.sortWith(sortBy)(data) : data + + const ctxValue = { + elements, + enableEdit, + enableEditText, + onEdit, + clearError: () => setError(null), + error: error, + disableRowEdit, + editWidth, + enableDelete, + onDelete, + deleteWidth, + enableToggle, + rowSize, + onToggle, + toggleWidth, + actionColSize, + stripeWhen, + forceAdd, + orderedBy, + DEFAULT_COL_SIZE, + } + + return ( + +
    + {showButtonOnEmpty && canAdd && ( + {createText} + )} + {showTable && ( + <> + {(title || enableCreate) && ( +
    + {title && titleLg && ( + {title} + )} + {title && !titleLg && ( + {title} + )} + {enableCreate && canAdd && ( + + {createText} + + )} +
    + )} + +
    +
    +
    + {adding && ( + +
    + + + +
    + )} + {innerData.map((it, idx) => { + const nextElement = innerData[idx + 1] + + const canGroup = !!groupBy && nextElement + const isFunction = R.type(groupBy) === 'Function' + const groupFunction = isFunction ? groupBy : R.prop(groupBy) + + const isLastOfGroup = + canGroup && + groupFunction(it) !== groupFunction(nextElement) + + return ( + +
    + + + +
    + ) + })} + + +
    + + )} +
    +
    + ) +} + +export default ETable diff --git a/packages/admin-ui/src/components/editableTable/Table.module.css b/packages/admin-ui/src/components/editableTable/Table.module.css new file mode 100644 index 0000000..9808ccd --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/Table.module.css @@ -0,0 +1,16 @@ +.addLink { + margin-left: auto; +} + +.title { + margin: 0; + color: var(--comet); +} + +.outerHeader { + min-height: 16px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/packages/admin-ui/src/components/editableTable/index.js b/packages/admin-ui/src/components/editableTable/index.js new file mode 100644 index 0000000..b9ca2dd --- /dev/null +++ b/packages/admin-ui/src/components/editableTable/index.js @@ -0,0 +1,4 @@ +import NamespacedTable from './NamespacedTable' +import Table from './Table' + +export { Table, NamespacedTable } diff --git a/packages/admin-ui/src/components/fake-table/Table.jsx b/packages/admin-ui/src/components/fake-table/Table.jsx new file mode 100644 index 0000000..29c7904 --- /dev/null +++ b/packages/admin-ui/src/components/fake-table/Table.jsx @@ -0,0 +1,145 @@ +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import classnames from 'classnames' +import React from 'react' + +import { Link } from '../buttons' +import styles from './Table.module.css' + +const Table = ({ children, className, ...props }) => ( +
    + {children} +
    +) + +const THead = ({ children, className }) => { + return
    {children}
    +} + +const TDoubleLevelHead = ({ children, className }) => { + return ( +
    {children}
    + ) +} + +const TBody = ({ children, className }) => { + return
    {children}
    +} + +const Td = ({ + style = {}, + children, + header, + className, + width = 100, + size, + bold, + textAlign, + action, +}) => { + const inlineStyle = { + ...style, + width, + textAlign, + fontSize: size === 'sm' ? '14px' : size === 'lg' ? '24px' : '', + } + + const cssClasses = { + [styles.td]: !header, + [styles.tdHeader]: header, + 'font-bold': !header && (bold || size === 'lg'), + [styles.actionCol]: action, + } + + return ( +
    + {children} +
    + ) +} + +const Th = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +const ThDoubleLevel = ({ title, children, className, width }) => { + return ( +
    +
    {title}
    +
    {children}
    +
    + ) +} + +const Tr = ({ + onClick, + error, + errorMessage, + shouldShowError, + children, + className, + size, + newRow, +}) => { + const inlineStyle = { + minHeight: size === 'sm' ? '34px' : size === 'lg' ? '68px' : '48px', + } + const cardClasses = { + [styles.card]: true, + [styles.trError]: error, + [styles.trAdding]: newRow, + } + + const mainContentClasses = { + [styles.mainContent]: true, + [styles.sizeSm]: size === 'sm', + [styles.sizeLg]: size === 'lg', + } + + return ( + <> + + +
    + {children} +
    + {error && shouldShowError && ( +
    {errorMessage}
    + )} +
    +
    + + ) +} + +const EditCell = ({ save, cancel }) => ( + + + Cancel + + + Save + + +) + +export { + Table, + THead, + TDoubleLevelHead, + TBody, + Tr, + Td, + Th, + ThDoubleLevel, + EditCell, +} diff --git a/packages/admin-ui/src/components/fake-table/Table.module.css b/packages/admin-ui/src/components/fake-table/Table.module.css new file mode 100644 index 0000000..ea3277a --- /dev/null +++ b/packages/admin-ui/src/components/fake-table/Table.module.css @@ -0,0 +1,106 @@ +.header { + composes: tl2 from '../typography/typography.module.css'; + background-color: var(--zodiac); + height: 32px; + text-align: left; + color: white; + display: flex; + align-items: center; +} + +.doubleHeader { + composes: tl2 from '../typography/typography.module.css'; + background-color: var(--zodiac); + height: 64px; + color: white; + display: table-row; +} + +.thDoubleLevel { + display: table-cell; +} + +.thDoubleLevelFirst { + composes: label1 from '../typography/typography.module.css'; + margin: 0 10px; + font-weight: 700; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--comet); + color: white; + border-radius: 0 0 8px 8px; + height: 28px; +} + +.thDoubleLevel > :last-child { + padding: 0 11px; + display: table-cell; + vertical-align: middle; + height: 36px; +} + +.cellDoubleLevel { + display: flex; + padding: 0 16px; +} + +.td { + padding: 1px 24px 0 24px; +} + +.tdHeader { + vertical-align: middle; + display: table-cell; + padding: 0 24px; +} + +.trError { + background-color: var(--misty-rose); +} + +.trAdding { + background-color: var(--spring3); +} + +.mainContent { + display: flex; + align-items: center; +} + +.cardContentRoot { + margin: 0; + padding: 0; +} + +.cardContentRoot:last-child { + padding: 0; +} + +.card { + composes: p from '../typography/typography.module.css'; + margin: 4px 0 0 0; + width: 100%; + box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.08); +} + +.card:before { + height: 0; +} + +.actionCol { + margin-left: auto; +} + +.errorContent { + padding: 12px 0 12px 24px; + color: var(--tomato); +} + +.sizeSm { + min-height: 34px; +} + +.sizeLg { + min-height: 68px; +} diff --git a/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx b/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx new file mode 100644 index 0000000..4c37d3a --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx @@ -0,0 +1,75 @@ +import React, { useState, useRef } from 'react' +import { Autocomplete, TextField } from '@mui/material' + +export const AsyncAutocomplete = ({ + value, + onChange, + onSearch, + getOptionLabel, + getOptionId = option => option.id, + placeholder = 'Search...', + noOptionsText = 'Type to start searching...', + minSearchLength = 2, + debounceMs = 300, + variant = 'standard', + size = 'small', + fullWidth = true, + ...textFieldProps +}) => { + const [options, setOptions] = useState([]) + const timeoutRef = useRef(null) + + // Simple debounce using timeout + const debouncedSearch = searchTerm => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout(() => { + onSearch(searchTerm).then(results => { + setOptions(results) + }) + }, debounceMs) + } + + const handleInputChange = (event, newInputValue, reason) => { + // Only search when user is typing, not when selecting an option + if ( + reason === 'input' && + newInputValue && + newInputValue.length > minSearchLength + ) { + debouncedSearch(newInputValue) + } + } + + const handleBlur = () => { + setOptions([]) + } + + return ( + + getOptionId(option) === getOptionId(value) + } + noOptionsText={noOptionsText} + renderInput={params => ( + + )} + size={size} + fullWidth={fullWidth} + /> + ) +} diff --git a/packages/admin-ui/src/components/inputs/base/Autocomplete.jsx b/packages/admin-ui/src/components/inputs/base/Autocomplete.jsx new file mode 100644 index 0000000..33d1aa9 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/Autocomplete.jsx @@ -0,0 +1,127 @@ +import MAutocomplete from '@mui/material/Autocomplete' +import classnames from 'classnames' +import sort from 'match-sorter' +import * as R from 'ramda' +import React from 'react' +import { HoverableTooltip } from '../../Tooltip' +import { P } from '../../typography' + +import TextInput from './TextInput' + +const Autocomplete = ({ + limit, + options, + label, + valueProp, + multiple, + onChange, + labelProp, + value: outsideValue, + error, + fullWidth, + textAlign, + size, + autoFocus, + ...props +}) => { + const mapFromValue = options => it => R.find(R.propEq(it, valueProp))(options) + const mapToValue = R.prop(valueProp) + + const getValue = () => { + if (!valueProp) return outsideValue + + const transform = multiple + ? R.map(mapFromValue(options)) + : mapFromValue(options) + + return transform(outsideValue) + } + + const value = getValue() + + const innerOnChange = (evt, value) => { + if (!valueProp) return onChange(evt, value) + + const rValue = multiple ? R.map(mapToValue)(value) : mapToValue(value) + onChange(evt, rValue) + } + + const valueArray = () => { + if (R.isNil(value)) return [] + return multiple ? value : [value] + } + + const filter = (array, input) => { + if (!input) return array + return sort(array, input, { keys: [valueProp, labelProp] }) + } + + const filterOptions = (array, { inputValue }) => + R.union( + R.isEmpty(inputValue) ? valueArray() : [], + filter(array, inputValue), + ).slice( + 0, + R.defaultTo(undefined)(limit) && + Math.max(limit, R.isEmpty(inputValue) ? valueArray().length : 0), + ) + + return ( + ( + + )} + renderOption={(iprops, props) => { + if (!props.warning && !props.warningMessage) + return
  • {R.path([labelProp])(props)}
  • + + const className = { + 'flex w-4 h-4 rounded-md': true, + 'bg-spring4': props.warning === 'clean', + 'bg-orange-yellow': props.warning === 'partial', + 'bg-tomato': props.warning === 'important', + } + + const hoverableElement =
    + + return ( +
  • +
    +
    {R.path([labelProp])(props)}
    + +

    {props.warningMessage}

    +
    +
    +
  • + ) + }} + slotProps={{ + chip: { onDelete: null }, + }} + /> + ) +} + +export default Autocomplete diff --git a/packages/admin-ui/src/components/inputs/base/Checkbox.jsx b/packages/admin-ui/src/components/inputs/base/Checkbox.jsx new file mode 100644 index 0000000..37be94b --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/Checkbox.jsx @@ -0,0 +1,46 @@ +import Checkbox from '@mui/material/Checkbox' +import CheckBoxIcon from '@mui/icons-material/CheckBox' +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank' +import React from 'react' +import { Label2, Info3 } from '../../typography' +import WarningIcon from '../../../styling/icons/warning-icon/comet.svg?react' + +import { fontSize2, fontSize3 } from '../../../styling/variables' + +const CheckboxInput = ({ name, onChange, value, settings, ...props }) => { + const { enabled, label, disabledMessage, rightSideLabel } = settings + + return ( + <> + {enabled ? ( +
    + {!rightSideLabel && {label}} + + } + checkedIcon={} + disableRipple + {...props} + /> + {rightSideLabel && {label}} +
    + ) : ( +
    + + + {disabledMessage} + +
    + )} + + ) +} + +export default CheckboxInput diff --git a/packages/admin-ui/src/components/inputs/base/CodeInput.jsx b/packages/admin-ui/src/components/inputs/base/CodeInput.jsx new file mode 100644 index 0000000..af78dc0 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/CodeInput.jsx @@ -0,0 +1,37 @@ +import classnames from 'classnames' +import React from 'react' +import OtpInput from 'react-otp-input' + +import classes from './CodeInput.module.css' + +const CodeInput = ({ + name, + value, + onChange, + numInputs, + error, + inputStyle, + containerStyle, +}) => { + return ( + } + shouldAutoFocus + containerStyle={classnames(containerStyle, 'justify-evenly')} + inputStyle={classnames( + inputStyle, + classes.input, + 'font-museo font-black text-4xl', + error && 'border-tomato', + )} + inputType={'tel'} + renderInput={props => } + /> + ) +} + +export default CodeInput diff --git a/packages/admin-ui/src/components/inputs/base/CodeInput.module.css b/packages/admin-ui/src/components/inputs/base/CodeInput.module.css new file mode 100644 index 0000000..5d76df7 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/CodeInput.module.css @@ -0,0 +1,14 @@ +.input { + width: 3.5rem !important; + height: 5rem; + border: 2px solid; + border-color: var(--zircon); + border-radius: 4px; +} + +.input:focus { + border: 2px solid; + border-color: var(--zodiac); + border-radius: 4px; + outline: none; +} diff --git a/packages/admin-ui/src/components/inputs/base/Dropdown.jsx b/packages/admin-ui/src/components/inputs/base/Dropdown.jsx new file mode 100644 index 0000000..20acfef --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/Dropdown.jsx @@ -0,0 +1,29 @@ +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import classnames from 'classnames' +import React from 'react' + +const Dropdown = ({ label, name, options, onChange, value, className }) => { + return ( + + {label} + + + ) +} + +export default Dropdown diff --git a/packages/admin-ui/src/components/inputs/base/NumberInput.jsx b/packages/admin-ui/src/components/inputs/base/NumberInput.jsx new file mode 100644 index 0000000..16cf2b3 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/NumberInput.jsx @@ -0,0 +1,53 @@ +import React, { memo } from 'react' +import NumberFormat from 'react-number-format' + +import TextInput from './TextInput' + +const NumberInput = memo( + ({ + name, + onChange, + onBlur, + value, + error, + suffix, + textAlign, + width, + // lg or sm + size, + bold, + className, + decimalPlaces, + ...props + }) => { + return ( + { + onChange({ + target: { + id: name, + value: values.floatValue, + }, + }) + }} + {...props} + /> + ) + }, +) + +export default NumberInput diff --git a/packages/admin-ui/src/components/inputs/base/RadioGroup.jsx b/packages/admin-ui/src/components/inputs/base/RadioGroup.jsx new file mode 100644 index 0000000..d6d6bfd --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/RadioGroup.jsx @@ -0,0 +1,53 @@ +import Radio from '@mui/material/Radio' +import MRadioGroup from '@mui/material/RadioGroup' +import FormControlLabel from '@mui/material/FormControlLabel' +import classnames from 'classnames' +import React from 'react' +import { Label1 } from '../../typography' + +const RadioGroup = ({ + name, + label, + value, + options, + onChange, + className, + labelClassName, + radioClassName, +}) => { + return ( + <> + {label && ( + {label} + )} + + {options.map((option, idx) => ( + +
    + + } + label={option.display} + className={classnames(labelClassName)} + /> + {option.subtitle && ( + {option.subtitle} + )} +
    +
    + ))} +
    + + ) +} + +export default RadioGroup diff --git a/packages/admin-ui/src/components/inputs/base/SecretInput.jsx b/packages/admin-ui/src/components/inputs/base/SecretInput.jsx new file mode 100644 index 0000000..5bdb26d --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/SecretInput.jsx @@ -0,0 +1,35 @@ +import React, { memo, useState } from 'react' + +import { TextInput } from '../base' + +const SecretInput = memo( + ({ value, onFocus, isPasswordFilled, onBlur, ...props }) => { + const [focused, setFocused] = useState(false) + const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬' + const innerOnFocus = event => { + setFocused(true) + onFocus && onFocus(event) + } + + const innerOnBlur = event => { + setFocused(false) + onBlur && onBlur(event) + } + + return ( + + ) + }, +) + +export default SecretInput diff --git a/packages/admin-ui/src/components/inputs/base/Select.jsx b/packages/admin-ui/src/components/inputs/base/Select.jsx new file mode 100644 index 0000000..c78ef92 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/Select.jsx @@ -0,0 +1,52 @@ +import classnames from 'classnames' +import { useSelect } from 'downshift' +import * as R from 'ramda' +import React from 'react' +import Arrowdown from '../../../styling/icons/action/arrow/regular.svg?react' + +import styles from './Select.module.css' + +function Select({ className, label, items, ...props }) { + const { + isOpen, + selectedItem, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getItemProps, + } = useSelect({ + items, + selectedItem: props.selectedItem, + onSelectedItemChange: item => { + props.onSelectedItemChange(item.selectedItem) + }, + }) + + const selectClassNames = { + [styles.select]: true, + [styles.selectFiltered]: props.defaultAsFilter + ? true + : !R.equals(selectedItem, props.default), + [styles.open]: isOpen, + } + + return ( +
    + + +
      + {isOpen && + items.map(({ code, display }, index) => ( +
    • + {display} +
    • + ))} +
    +
    + ) +} + +export default Select diff --git a/packages/admin-ui/src/components/inputs/base/Select.module.css b/packages/admin-ui/src/components/inputs/base/Select.module.css new file mode 100644 index 0000000..102fe2c --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/Select.module.css @@ -0,0 +1,100 @@ +.selectedItem { + width: 111px; + display: block; + white-space: nowrap; + overflow: hidden; +} + +.select { + width: 152px; + z-index: 1; +} + +.select label { + font-size: 13px; + font-family: var(--museo); + font-weight: 500; + color: var(--comet); + padding-left: 10px; +} + +.select button { + font-size: 14px; + font-family: var(--museo); + font-weight: 500; + position: relative; + border: 0; + background-color: var(--zircon); + width: 152px; + padding: 6px 0 6px 12px; + border-radius: 20px; + line-height: 1.14; + text-align: left; + color: var(--comet); + cursor: pointer; + outline: 0 none; +} + +.select ul { + max-height: 200px; + width: 152px; + overflow-y: auto; + position: absolute; + margin: 0; + border-top: 0; + padding: 0; + border-radius: 0 0 8px 8px; + background-color: var(--zircon); + outline: 0 none; +} + +.select ul li { + font-size: 14px; + font-family: var(--museo); + font-weight: 500; + list-style-type: none; + padding: 6px 12px; + cursor: pointer; +} + +.select ul li span { + width: 100%; + display: block; + overflow: hidden; + white-space: nowrap; +} + +.select ul li:hover { + background-color: var(--comet); + color: white; +} + +.select svg { + position: absolute; + top: 12px; + right: 14px; + fill: var(--comet); +} + +.selectFiltered button { + background-color: var(--comet); + color: white; +} + +.selectFiltered ul li { + background-color: var(--comet); + color: white; +} + +.selectFiltered ul li:hover { + background-color: var(--zircon); + color: var(--comet); +} + +.selectFiltered svg { + fill: white !important; +} + +.open button { + border-radius: 8px 8px 0 0; +} diff --git a/packages/admin-ui/src/components/inputs/base/TextInput.jsx b/packages/admin-ui/src/components/inputs/base/TextInput.jsx new file mode 100644 index 0000000..c468023 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/TextInput.jsx @@ -0,0 +1,74 @@ +import TextField from '@mui/material/TextField' +import classnames from 'classnames' +import * as R from 'ramda' +import React, { memo } from 'react' + +import styles from './TextInput.module.css' + +const TextInput = memo( + ({ + name, + isPasswordFilled, + onChange, + onBlur, + value, + error, + textAlign, + width, + inputClasses, + // lg or sm + size, + bold, + className, + inputProps, + InputProps, + ...props + }) => { + const isTextFilled = !error && !R.isNil(value) && !R.isEmpty(value) + const filled = isPasswordFilled || isTextFilled + + const style = { + width: width, + textAlign: textAlign, + } + + const sizeClass = + size === 'sm' + ? styles.sizeSm + : size === 'lg' + ? styles.sizeLg + : styles.size + + const divClass = { + [styles.bold]: bold, + } + + return ( + + ) + }, +) + +export default TextInput diff --git a/packages/admin-ui/src/components/inputs/base/TextInput.module.css b/packages/admin-ui/src/components/inputs/base/TextInput.module.css new file mode 100644 index 0000000..12172b0 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/TextInput.module.css @@ -0,0 +1,24 @@ +.size { + font-size: 16px; +} + +.sizeSm { + font-size: 14px; +} + +.sizeLg { + font-size: 24px; + font-weight: 700; +} + +.bold { + font-weight: 700; +} + +.underline:before { + border-bottom-color: var(--spring); +} + +.underline:hover:not(.Mui-disabled)::before { + border-bottom-color: var(--spring); +} diff --git a/packages/admin-ui/src/components/inputs/base/ToggleButtonGroup.jsx b/packages/admin-ui/src/components/inputs/base/ToggleButtonGroup.jsx new file mode 100644 index 0000000..ee9a3b8 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/ToggleButtonGroup.jsx @@ -0,0 +1,45 @@ +import MUIToggleButtonGroup from '@mui/material/ToggleButtonGroup' +import ToggleButton from '@mui/material/ToggleButton' +import React from 'react' +import { H4, P } from '../../typography' + +const ToggleButtonGroup = ({ + name, + orientation = 'vertical', + value, + exclusive = true, + onChange, + size = 'small', + ...props +}) => { + return ( + + {props.options.map(option => { + return ( + +
    + +
    +

    {option.title}

    +

    {option.description}

    +
    +
    +
    + ) + })} +
    + ) +} + +export default ToggleButtonGroup diff --git a/packages/admin-ui/src/components/inputs/base/index.js b/packages/admin-ui/src/components/inputs/base/index.js new file mode 100644 index 0000000..cad7bff --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/index.js @@ -0,0 +1,21 @@ +import Autocomplete from './Autocomplete' +import Checkbox from './Checkbox' +import CodeInput from './CodeInput' +import Dropdown from './Dropdown' +import NumberInput from './NumberInput' +import RadioGroup from './RadioGroup' +import SecretInput from './SecretInput' +import TextInput from './TextInput' +import ToggleButtonGroup from './ToggleButtonGroup' + +export { + Checkbox, + CodeInput, + TextInput, + NumberInput, + SecretInput, + RadioGroup, + Autocomplete, + ToggleButtonGroup, + Dropdown, +} diff --git a/packages/admin-ui/src/components/inputs/cashbox/Cashbox.jsx b/packages/admin-ui/src/components/inputs/cashbox/Cashbox.jsx new file mode 100644 index 0000000..cfa69ec --- /dev/null +++ b/packages/admin-ui/src/components/inputs/cashbox/Cashbox.jsx @@ -0,0 +1,181 @@ +import Chip from '@mui/material/Chip' +import classnames from 'classnames' +import React from 'react' +import { Info2, Label1, Label2 } from '../../typography' + +import { numberToFiatAmount } from '../../../utils/number' + +import classes from './Cashbox.module.css' +import { primaryColor as zodiac, tomato } from '../../../styling/variables.js' + +const colors = { + cashOut: { + empty: tomato, + full: zodiac, + }, + cashIn: { + empty: zodiac, + full: tomato, + }, +} + +const Cashbox = ({ + percent = 0, + cashOut = false, + width = 80, + height = 118, + className, + emptyPartClassName, + labelClassName, + omitInnerPercentage, + isLow, +}) => { + const ltHalf = percent <= 51 + const color = + colors[cashOut ? 'cashOut' : 'cashIn'][!isLow ? 'full' : 'empty'] + + return ( +
    +
    + {!omitInnerPercentage && ltHalf && ( + + {percent.toFixed(0)}% + + )} +
    +
    + {!omitInnerPercentage && !ltHalf && ( + + {percent.toFixed(0)}% + + )} +
    +
    + ) +} + +// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte +// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box +const CashIn = ({ + capacity = 500, + currency, + notes, + className, + editingMode = false, + threshold, + width, + height, + total, + omitInnerPercentage, +}) => { + const percent = (100 * notes) / capacity + const isLow = percent < threshold + return ( + <> +
    +
    + +
    + {!editingMode && ( +
    +
    + {notes} notes +
    +
    + + {total} {currency.code} + +
    +
    + )} +
    + + ) +} + +const CashOut = ({ + capacity = 500, + denomination = 0, + currency, + notes, + className, + editingMode = false, + threshold, + width, + height, + omitInnerPercentage, +}) => { + const percent = (100 * notes) / capacity + const isLow = percent < threshold + return ( + <> +
    +
    + +
    + {!editingMode && ( +
    +
    + {notes} + +
    +
    + + {numberToFiatAmount(notes * denomination)} {currency.code} + +
    +
    + )} +
    + + ) +} + +const CashOutLite = ({ + capacity = 500, + denomination = 0, + currency, + notes, + threshold, + width, +}) => { + const percent = (100 * notes) / capacity + const isLow = percent < threshold + return ( +
    + + +
    + ) +} + +export { Cashbox, CashIn, CashOut, CashOutLite } diff --git a/packages/admin-ui/src/components/inputs/cashbox/Cashbox.module.css b/packages/admin-ui/src/components/inputs/cashbox/Cashbox.module.css new file mode 100644 index 0000000..9a05d32 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/cashbox/Cashbox.module.css @@ -0,0 +1,53 @@ +.row { + display: flex; + align-items: center; +} + +.col { + display: flex; + flex-direction: column; + align-items: center; +} + +.innerRow { + display: flex; + justify-content: flex-start; +} + +.col2 { + margin-left: 14px; +} + +.noMarginText { + margin-top: 0; + margin-bottom: 0; +} + +.link { + margin-top: 8px; +} + +/*TODO important because of tailwind integration with MUI*/ +.fullPartP { + color: white !important; + display: inline; +} + +.emptyPart { + background-color: var(--ghost); + position: relative; +} + +.emptyPartP { + display: inline-block; + position: absolute; + margin: 0; + bottom: 0; + right: 0; +} + +.cashbox { + border: 2px solid; + text-align: end; + display: inline-block; +} diff --git a/packages/admin-ui/src/components/inputs/formik/AsyncAutocomplete.jsx b/packages/admin-ui/src/components/inputs/formik/AsyncAutocomplete.jsx new file mode 100644 index 0000000..06fefac --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/AsyncAutocomplete.jsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { AsyncAutocomplete as BaseAsyncAutocomplete } from '../base/AsyncAutocomplete' + +const AsyncAutocompleteFormik = ({ field, form, ...props }) => { + const { name } = field + const { touched, errors, setFieldValue } = form + const [selectedOption, setSelectedOption] = useState(null) + + const error = touched[name] && errors[name] + const getOptionId = props.getOptionId || (opt => opt.id) + + const handleChange = (event, newValue) => { + setSelectedOption(newValue) + setFieldValue(name, newValue ? getOptionId(newValue) : '') + } + + return ( + + ) +} + +export const AsyncAutocomplete = AsyncAutocompleteFormik diff --git a/packages/admin-ui/src/components/inputs/formik/Autocomplete.jsx b/packages/admin-ui/src/components/inputs/formik/Autocomplete.jsx new file mode 100644 index 0000000..6355e10 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/Autocomplete.jsx @@ -0,0 +1,51 @@ +import { useFormikContext } from 'formik' +import * as R from 'ramda' +import React, { useState } from 'react' + +import { Autocomplete } from '../base' + +const AutocompleteFormik = ({ options, onChange, ...props }) => { + const [open, setOpen] = useState(false) + + const { name, onBlur, value } = props.field + const { touched, errors, setFieldValue, setFieldTouched } = props.form + const error = !!(touched[name] && errors[name]) + const { initialValues, values } = useFormikContext() + + const innerOptions = + R.type(options) === 'Function' ? options(initialValues, values) : options + + const innerOnBlur = event => { + name && setFieldTouched(name, true) + onBlur && onBlur(event) + } + + const onChangeHandler = value => setFieldValue(name, value) + const shouldStayOpen = !!props.shouldStayOpen + + return ( + { + if (onChange) return onChange(value, item, onChangeHandler) + setFieldValue(name, item) + }} + onBlur={innerOnBlur} + value={value} + error={error} + open={open} + options={innerOptions} + onOpen={() => { + if (!props.multiple) return setOpen(true) + setOpen(value?.length !== props.limit) + }} + onClose={(event, reason) => { + if (shouldStayOpen && reason !== 'blur') setOpen(true) + else setOpen(false) + }} + {...props} + /> + ) +} + +export default AutocompleteFormik diff --git a/packages/admin-ui/src/components/inputs/formik/CashCassetteInput.jsx b/packages/admin-ui/src/components/inputs/formik/CashCassetteInput.jsx new file mode 100644 index 0000000..543917e --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/CashCassetteInput.jsx @@ -0,0 +1,39 @@ +import classNames from 'classnames' +import React, { memo, useState } from 'react' +import { CashOut } from '../cashbox/Cashbox' + +import { NumberInput } from '../base' + +const CashCassetteInput = memo( + ({ decimalPlaces, width, threshold, inputClassName, ...props }) => { + const { name, onChange, onBlur, value } = props.field + const { touched, errors } = props.form + const [notes, setNotes] = useState(value) + const error = !!(touched[name] && errors[name]) + return ( +
    + + { + setNotes(e.target.value) + return onChange(e) + }} + onBlur={onBlur} + value={value} + error={error} + decimalPlaces={decimalPlaces} + {...props} + /> +
    + ) + }, +) + +export default CashCassetteInput diff --git a/packages/admin-ui/src/components/inputs/formik/Checkbox.jsx b/packages/admin-ui/src/components/inputs/formik/Checkbox.jsx new file mode 100644 index 0000000..0ef940a --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/Checkbox.jsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react' + +import { Checkbox } from '../base' + +const CheckboxInput = memo( + ({ label, enabled = true, disabledMessage = '', ...props }) => { + const { name, onChange, value } = props.field + + const settings = { + enabled: enabled, + label: label, + disabledMessage: disabledMessage, + } + + return ( + + ) + }, +) + +export default CheckboxInput diff --git a/packages/admin-ui/src/components/inputs/formik/Dropdown.jsx b/packages/admin-ui/src/components/inputs/formik/Dropdown.jsx new file mode 100644 index 0000000..e640da2 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/Dropdown.jsx @@ -0,0 +1,25 @@ +import React, { memo } from 'react' + +import { Dropdown } from '../base' + +const RadioGroupFormik = memo(({ label, ...props }) => { + const { name, value } = props.field + const { setFieldValue } = props.form + return ( + { + setFieldValue(name, e.target.value) + props.resetError && props.resetError() + }} + className={props.className} + {...props} + /> + ) +}) + +export default RadioGroupFormik diff --git a/packages/admin-ui/src/components/inputs/formik/NumberInput.jsx b/packages/admin-ui/src/components/inputs/formik/NumberInput.jsx new file mode 100644 index 0000000..caca139 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/NumberInput.jsx @@ -0,0 +1,24 @@ +import React, { memo } from 'react' + +import { NumberInput } from '../base' + +const NumberInputFormik = memo(({ decimalPlaces, ...props }) => { + const { name, onChange, onBlur, value } = props.field + const { touched, errors } = props.form + + const error = !!(touched[name] && errors[name]) + + return ( + + ) +}) + +export default NumberInputFormik diff --git a/packages/admin-ui/src/components/inputs/formik/RadioGroup.jsx b/packages/admin-ui/src/components/inputs/formik/RadioGroup.jsx new file mode 100644 index 0000000..130f528 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/RadioGroup.jsx @@ -0,0 +1,25 @@ +import React, { memo } from 'react' + +import { RadioGroup } from '../base' + +const RadioGroupFormik = memo(({ label, ...props }) => { + const { name, onChange, value } = props.field + + return ( + { + onChange(e) + props.resetError && props.resetError() + }} + className={props.className} + {...props} + /> + ) +}) + +export default RadioGroupFormik diff --git a/packages/admin-ui/src/components/inputs/formik/SecretInput.jsx b/packages/admin-ui/src/components/inputs/formik/SecretInput.jsx new file mode 100644 index 0000000..a647d10 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/SecretInput.jsx @@ -0,0 +1,24 @@ +import React, { memo } from 'react' + +import { SecretInput } from '../base' + +const SecretInputFormik = memo(({ isPasswordFilled, ...props }) => { + const { name, onChange, onBlur, value } = props.field + const { touched, errors } = props.form + + const error = !isPasswordFilled && !!(touched[name] && errors[name]) + + return ( + + ) +}) + +export default SecretInputFormik diff --git a/packages/admin-ui/src/components/inputs/formik/TextInput.jsx b/packages/admin-ui/src/components/inputs/formik/TextInput.jsx new file mode 100644 index 0000000..cdbb16a --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/TextInput.jsx @@ -0,0 +1,23 @@ +import React, { memo } from 'react' + +import { TextInput } from '../base' + +const TextInputFormik = memo(({ ...props }) => { + const { name, onChange, onBlur, value } = props.field + const { touched, errors } = props.form + + const error = !!(touched[name] && errors[name]) + + return ( + + ) +}) + +export default TextInputFormik diff --git a/packages/admin-ui/src/components/inputs/formik/ToggleButtonGroup.jsx b/packages/admin-ui/src/components/inputs/formik/ToggleButtonGroup.jsx new file mode 100644 index 0000000..51d5f74 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/ToggleButtonGroup.jsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react' + +import { ToggleButtonGroup } from '../base' + +const ToggleButtonGroupFormik = memo(({ enforceValueSet = true, ...props }) => { + const { name, value } = props.field + const { setFieldValue } = props.form + return ( + { + // enforceValueSet prevents you from not having any button selected + // after selecting one the first time + if (enforceValueSet && !value) return null + setFieldValue(name, value) + props.resetError && props.resetError() + }} + className={props.className} + {...props} + /> + ) +}) + +export default ToggleButtonGroupFormik diff --git a/packages/admin-ui/src/components/inputs/formik/index.js b/packages/admin-ui/src/components/inputs/formik/index.js new file mode 100644 index 0000000..303b4c3 --- /dev/null +++ b/packages/admin-ui/src/components/inputs/formik/index.js @@ -0,0 +1,21 @@ +import Autocomplete from './Autocomplete' +import { AsyncAutocomplete } from './AsyncAutocomplete' +import CashCassetteInput from './CashCassetteInput' +import Checkbox from './Checkbox' +import Dropdown from './Dropdown' +import NumberInput from './NumberInput' +import RadioGroup from './RadioGroup' +import SecretInput from './SecretInput' +import TextInput from './TextInput' + +export { + Autocomplete, + AsyncAutocomplete, + Checkbox, + TextInput, + NumberInput, + SecretInput, + RadioGroup, + CashCassetteInput, + Dropdown, +} diff --git a/packages/admin-ui/src/components/inputs/index.js b/packages/admin-ui/src/components/inputs/index.js new file mode 100644 index 0000000..6298d8c --- /dev/null +++ b/packages/admin-ui/src/components/inputs/index.js @@ -0,0 +1,18 @@ +import Autocomplete from './base/Autocomplete' +import Checkbox from './base/Checkbox' +import CodeInput from './base/CodeInput' +import RadioGroup from './base/RadioGroup' +import Select from './base/Select' +import TextInput from './base/TextInput' +import { CashIn, CashOut } from './cashbox/Cashbox' + +export { + Autocomplete, + TextInput, + Checkbox, + CodeInput, + Select, + RadioGroup, + CashIn, + CashOut, +} diff --git a/packages/admin-ui/src/components/layout/Header.jsx b/packages/admin-ui/src/components/layout/Header.jsx new file mode 100644 index 0000000..31f2a49 --- /dev/null +++ b/packages/admin-ui/src/components/layout/Header.jsx @@ -0,0 +1,250 @@ +import { useQuery, gql } from '@apollo/client' +import ClickAwayListener from '@mui/material/ClickAwayListener' +import Popper from '@mui/material/Popper' +import classnames from 'classnames' +import * as R from 'ramda' +import React, { memo, useState, useEffect, useRef } from 'react' +import { Link as WLink, useRoute, useLocation } from 'wouter' +import ActionButton from '../buttons/ActionButton' +import { H4 } from '../typography' +import AddIconReverse from '../../styling/icons/button/add/white.svg?react' +import AddIcon from '../../styling/icons/button/add/zodiac.svg?react' +import Logo from '../../styling/icons/menu/logo.svg?react' +import NotificationIcon from '../../styling/icons/menu/notification.svg?react' + +import NotificationCenter from '../NotificationCenter' +import AddMachine from '../../pages/AddMachine' + +import styles from './Header.module.css' + +const HAS_UNREAD = gql` + query getUnread { + hasUnreadNotifications + } +` + +const Link = ({ + setActive, + isParent, + className, + activeClassName, + item, + ...props +}) => { + const [location] = useLocation() + const [isActive] = useRoute(props.to) + const isParentActive = isParent && location.startsWith(props.to) + if (isActive || isParentActive) setActive(item) + + const classNames = classnames({ + [className]: true, + [activeClassName]: isActive || isParentActive, + }) + + return ( + + {props.children} + + ) +} + +const Subheader = ({ item, user }) => { + const [prev, setPrev] = useState(null) + + return ( +
    +
    + +
    +
    + ) +} + +const notNil = R.compose(R.not, R.isNil) + +const Header = memo(({ tree, user, restrictionLevel }) => { + const [open, setOpen] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) + const [notifButtonCoords, setNotifButtonCoords] = useState({ x: 0, y: 0 }) + const [active, setActive] = useState() + const [hasUnread, setHasUnread] = useState(false) + + const { data, refetch, startPolling, stopPolling } = useQuery(HAS_UNREAD) + const notifCenterButtonRef = useRef() + const popperRef = useRef() + const [, navigate] = useLocation() + + useEffect(() => { + if (data?.hasUnreadNotifications) return setHasUnread(true) + // if not true, make sure it's false and not undefined + if (notNil(data?.hasUnreadNotifications)) return setHasUnread(false) + }, [data]) + + useEffect(() => { + startPolling(60000) + return stopPolling + }) + + const bannerClassnames = classnames({ + [styles.smallBanner]: restrictionLevel === 1, + [styles.bigBanner]: restrictionLevel === 2, + ['bg-orange-400 w-full flex flex-col justify-center items-center text-white font-bold text-md']: true, + }) + + const onPaired = machine => { + setOpen(false) + navigate('/maintenance/machine-status', { state: { id: machine.deviceId } }) + } + + // these inline styles prevent scroll bubbling: when the user reaches the bottom of the notifications list and keeps scrolling, + // the body scrolls, stealing the focus from the notification center, preventing the admin from scrolling the notifications back up + // on the first scroll, needing to move the mouse to recapture the focus on the notification center + // it also disables the scrollbars caused by the notification center's background to the right of the page, but keeps the scrolling on the body enabled + const onClickAway = () => { + setAnchorEl(null) + document.querySelector('#root').classList.remove('root-notifcenter-open') + document.querySelector('body').classList.remove('body-notifcenter-open') + } + + const handleClick = event => { + const coords = notifCenterButtonRef.current.getBoundingClientRect() + setNotifButtonCoords({ x: coords.x, y: coords.y + 5 }) + + setAnchorEl(anchorEl ? null : event.currentTarget) + document.querySelector('#root').classList.add('root-notifcenter-open') + document.querySelector('body').classList.add('body-notifcenter-open') + } + + const popperOpen = Boolean(anchorEl) + const id = popperOpen ? 'notifications-popper' : undefined + return ( +
    +
    +
    +
    { + setActive(false) + navigate('/dashboard') + }} + className={classnames(styles.logo, styles.logoLink)}> + +

    Lamassu Admin

    +
    + +
    + setOpen(true)}> + Add machine + + +
    + + + + +
    +
    +
    +
    +
    + {active && active.children && } + {restrictionLevel > 0 && ( +
    +

    + The software you're running is out of license. Please contact us to + ensure your OSA payments are current. +

    +

    + Future restrictions may be applied. If this is in error, please get + in touch with support@lamassu.is. +

    +
    + )} + {open && setOpen(false)} onPaired={onPaired} />} +
    + ) +}) + +export default Header diff --git a/packages/admin-ui/src/components/layout/Header.module.css b/packages/admin-ui/src/components/layout/Header.module.css new file mode 100644 index 0000000..691e9b6 --- /dev/null +++ b/packages/admin-ui/src/components/layout/Header.module.css @@ -0,0 +1,185 @@ +.headerContainer { + position: relative; +} + +.header { + background-color: var(--zodiac); + color: white; + height: 56px; + display: flex; +} + +.content { + max-width: 1200px; + flex: 1; + display: flex; + align-items: center; + margin: 0 auto; +} + +.nav { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; +} + +.ul { + display: flex; + padding-left: 36px; + height: 56px; + margin: 0; +} + +.li { + list-style: none; + color: white; + margin: 20px 20px 0 20px; + position: relative; + line-height: 17px; +} + +.li:hover { + color: white; +} + +.li:hover::after { + width: 50%; + margin-left: -25%; +} + +.li::after { + content: ''; + display: block; + background: white; + width: 0; + height: 4px; + left: 50%; + margin-left: 0; + position: absolute; + border-radius: 1000px; + transition: all 0.2s cubic-bezier(0.95, 0.1, 0.45, 0.94); +} + +.link { + text-decoration: none; + border: none; + color: white; + background-color: transparent; +} + +.forceSize { + display: inline-block; + text-align: center; +} + +.forceSize::after { + display: block; + content: attr(data-forcesize); + font-weight: 700; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.activeLink { + color: white; +} + +.activeLink li::after { + width: 50%; + margin-left: -25%; +} + +.addMachine { + margin-left: auto; +} + +.subheader { + background-color: var(--zircon); + color: white; + height: 40px; + display: flex; +} + +.subheaderUl { + display: flex; + padding-left: 0; + margin: 0; +} + +.subheaderLi { + list-style: none; + padding: 0 20px; +} + +.subheaderLi:first-child { + padding-left: 0; +} + +.subheaderLink { + text-decoration: none; + border: none; + color: var(--comet); +} + +.activeSubheaderLink { + text-shadow: 0.2px 0 0 currentColor; + color: var(--zodiac); +} + +.logo { + display: flex; + align-items: center; +} + +.logo > svg { + margin-right: 16px; +} + +.logoLink { + cursor: pointer; +} + +.actionButtonsContainer { + z-index: 1; + position: relative; + display: flex; + justify-content: space-between; + min-width: 200px; + transform: translateZ(0); +} + +.notificationIcon { + margin-top: 4px; + cursor: pointer; + background: transparent; + box-shadow: 0px 0px 0px transparent; + border: 0px solid transparent; + text-shadow: 0px 0px 0px transparent; + outline: none; +} + +.hasUnread { + position: absolute; + top: 4px; + left: 186px; + width: 9px; + height: 9px; + background-color: var(--spring); + border-radius: 50%; +} + +.popper { + z-index: 1; +} + +.smallBanner { + font-size: 14px; + height: 5vh; +} + +.bigBanner { + font-size: 24px; + height: 40vh; +} diff --git a/packages/admin-ui/src/components/layout/Section.jsx b/packages/admin-ui/src/components/layout/Section.jsx new file mode 100644 index 0000000..d83a0b0 --- /dev/null +++ b/packages/admin-ui/src/components/layout/Section.jsx @@ -0,0 +1,19 @@ +import React from 'react' +import ErrorMessage from '../ErrorMessage' +import Subtitle from '../Subtitle' + +const Section = ({ error, children, title }) => { + return ( +
    + {(title || error) && ( +
    + {title} + {error && Failed to save changes} +
    + )} + {children} +
    + ) +} + +export default Section diff --git a/packages/admin-ui/src/components/layout/Sidebar.jsx b/packages/admin-ui/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..18376bb --- /dev/null +++ b/packages/admin-ui/src/components/layout/Sidebar.jsx @@ -0,0 +1,75 @@ +import classnames from 'classnames' +import React from 'react' +import { P } from '../typography' +import CompleteStageIconZodiac from '../../styling/icons/stage/zodiac/complete.svg?react' +import CurrentStageIconZodiac from '../../styling/icons/stage/zodiac/current.svg?react' +import EmptyStageIconZodiac from '../../styling/icons/stage/zodiac/empty.svg?react' + +import styles from './Sidebar.module.css' + +const Sidebar = ({ + data, + displayName, + isSelected, + onClick, + children, + itemRender, + loading = false, +}) => { + return ( +
    + {loading &&

    Loading...

    } + {!loading && + data?.map((it, idx) => ( +
    onClick(it)}> +
    + {itemRender ? itemRender(it, isSelected(it)) : displayName(it)} +
    +
    + ))} + {!loading && children} +
    + ) +} + +export default Sidebar + +const Stepper = ({ step, it, idx, steps }) => { + const active = step === idx + const past = idx < step + const future = idx > step + + return ( +
    + + {it.label} + + {active && } + {past && } + {future && } + {idx < steps.length - 1 && ( +
    + )} +
    + ) +} + +export { Stepper } diff --git a/packages/admin-ui/src/components/layout/Sidebar.module.css b/packages/admin-ui/src/components/layout/Sidebar.module.css new file mode 100644 index 0000000..0f0f8df --- /dev/null +++ b/packages/admin-ui/src/components/layout/Sidebar.module.css @@ -0,0 +1,106 @@ +:root { + --sidebar-color: var(--zircon); +} + +.sidebar { + display: flex; + background-color: var(--sidebar-color); + width: 520px; + margin-left: -300px; + box-shadow: -500px 0px 0px 0px var(--sidebar-color); + border-radius: 0 20px 0 0; + align-items: flex-end; + padding: 24px; + flex-direction: column; +} + +@media (max-width: 1440px) { + .sidebar { + width: auto; + margin-left: 0; + min-width: 250px; + box-shadow: -200px 0px 0px 0px var(--sidebar-color); + } +} + +.linkWrapper { + cursor: pointer; +} + +.link { + position: relative; + color: var(--comet); + margin: 12px 24px 12px 0; + cursor: pointer; +} + +.link:hover::after { + height: 140%; +} + +.link::after { + content: ''; + display: block; + background: var(--zodiac); + width: 4px; + height: 0; + left: 100%; + margin-left: 20px; + bottom: -2px; + position: absolute; + border-radius: 1000px; + transition: all 0.2s cubic-bezier(0.95, 0.1, 0.45, 0.94); +} + +.activeLink { + font-weight: 700; + color: var(--zodiac); +} + +.activeLink::after { + height: 140%; +} + +.customRenderLink:hover::after { + height: 100%; +} + +.customRenderLink::after { + bottom: 0; +} + +.customRenderActiveLink::after { + height: 100%; +} + +.item { + position: relative; + margin: 12px 0 12px 0; + display: flex; +} + +.itemText { + color: var(--comet); + margin-right: 24px; +} + +.itemTextActive { + color: var(--zodiac); +} + +.itemTextPast { + color: var(--zodiac); +} + +.stepperPath { + position: absolute; + height: 25px; + width: 1px; + border: 1px solid var(--comet); + right: 8px; + top: 18px; +} + +.stepperPast { + border: 1px solid var(--zodiac); +} diff --git a/packages/admin-ui/src/components/layout/TitleSection.jsx b/packages/admin-ui/src/components/layout/TitleSection.jsx new file mode 100644 index 0000000..319965a --- /dev/null +++ b/packages/admin-ui/src/components/layout/TitleSection.jsx @@ -0,0 +1,66 @@ +import classnames from 'classnames' +import * as R from 'ramda' +import React from 'react' +import ErrorMessage from '../ErrorMessage' +import Title from '../Title' +import { Info1, Label1 } from '../typography' + +import { SubpageButton } from '../buttons' + +const TitleSection = ({ + className, + title, + error, + labels, + buttons = [], + children, + appendix, + appendixRight, +}) => { + return ( +
    +
    + {title} + {!!appendix && appendix} + {error && Failed to save} + {buttons.length > 0 && ( + <> + {buttons.map((button, idx) => + !R.isNil(button.component) ? ( + button.component + ) : ( + + + {button.text} + + + ), + )} + + )} +
    +
    + {(labels ?? []).map(({ icon, label }, idx) => ( +
    +
    {icon}
    + {label} +
    + ))} + {appendixRight} +
    + {children} +
    + ) +} + +export default TitleSection diff --git a/packages/admin-ui/src/components/machineActions/DiagnosticsModal.jsx b/packages/admin-ui/src/components/machineActions/DiagnosticsModal.jsx new file mode 100644 index 0000000..4397c27 --- /dev/null +++ b/packages/admin-ui/src/components/machineActions/DiagnosticsModal.jsx @@ -0,0 +1,225 @@ +import { useLazyQuery, useQuery, gql } from '@apollo/client' +import { subMinutes } from 'date-fns' +import FileSaver from 'file-saver' +import React, { useState, useEffect, useRef } from 'react' +import Modal from '../Modal' +import { H3, P } from '../typography' + +import { Button } from '../buttons' + +const STATES = { + INITIAL: 'INITIAL', + EMPTY: 'EMPTY', + RUNNING: 'RUNNING', + FAILURE: 'FAILURE', + FILLED: 'FILLED', +} + +const MACHINE = gql` + query getMachine($deviceId: ID!) { + machine(deviceId: $deviceId) { + diagnostics { + timestamp + frontTimestamp + scanTimestamp + } + } + } +` + +const MACHINE_LOGS = gql` + query machineLogsCsv( + $deviceId: ID! + $limit: Int + $from: DateTimeISO + $until: DateTimeISO + $timezone: String + ) { + machineLogsCsv( + deviceId: $deviceId + limit: $limit + from: $from + until: $until + timezone: $timezone + ) + } +` + +const createCsv = async ({ machineLogsCsv }) => { + const machineLogs = new Blob([machineLogsCsv], { + type: 'text/plain;charset=utf-8', + }) + + FileSaver.saveAs(machineLogs, 'machineLogs.csv') +} + +const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => { + const [state, setState] = useState(STATES.INITIAL) + const [timestamp, setTimestamp] = useState(null) + const [diagnosticTimestamps, setDiagnosticTimestamps] = useState({}) + const timeoutRef = useRef(null) + + const [fetchSummary, { loading }] = useLazyQuery(MACHINE_LOGS, { + onCompleted: data => createCsv(data), + }) + + const { data, stopPolling, startPolling } = useQuery(MACHINE, { + variables: { deviceId }, + }) + + useEffect(() => { + if (!data) return + if (!timestamp && !data.machine.diagnostics.timestamp) { + stopPolling() + setState(STATES.EMPTY) + } + if ( + data.machine.diagnostics.timestamp && + data.machine.diagnostics.timestamp !== timestamp + ) { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + setTimestamp(data.machine.diagnostics.timestamp) + setDiagnosticTimestamps({ + front: data.machine.diagnostics.frontTimestamp, + scan: data.machine.diagnostics.scanTimestamp, + }) + setState(STATES.FILLED) + stopPolling() + } + }, [data, stopPolling, timestamp]) + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + }, []) + + const runDiagnostics = () => { + setState(STATES.RUNNING) + startPolling(2000) + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + timeoutRef.current = setTimeout(() => { + setState(STATES.FAILURE) + stopPolling() + timeoutRef.current = null + }, 60 * 1000) + + sendAction() + } + + const messageClass = 'm-auto flex flex-col items-center justify-center' + + const showPhoto = diagnosticName => { + console.log(diagnosticName, diagnosticTimestamps) + return diagnosticTimestamps[diagnosticName] ? ( + + ) : ( + <>Failed getting photo + ) + } + + const date = new Date(timestamp) + const dateString = date.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return ( + + {state === STATES.INITIAL && ( +
    +

    Loading...

    +
    + )} + + {state === STATES.EMPTY && ( +
    +

    No diagnostics available

    +

    Run diagnostics to generate a report

    +
    + )} + + {state === STATES.RUNNING && ( +
    +

    Running Diagnostics...

    +

    This page should refresh automatically

    +
    + )} + + {state === STATES.FAILURE && ( +
    +

    Failed to run diagnostics

    +

    Please try again. If the problem persists, contact support.

    +
    + )} + + {state === STATES.FILLED && ( +
    +
    +
    +

    Scan

    + {showPhoto('scan')} +
    +
    +

    Front

    + {showPhoto('front')} +
    +
    +
    +

    Diagnostics executed at: {dateString}

    +
    +
    + )} +
    + + +
    +
    + ) +} + +export default DiagnosticsModal diff --git a/packages/admin-ui/src/components/machineActions/GroupModal.jsx b/packages/admin-ui/src/components/machineActions/GroupModal.jsx new file mode 100644 index 0000000..6fc5db2 --- /dev/null +++ b/packages/admin-ui/src/components/machineActions/GroupModal.jsx @@ -0,0 +1,112 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import React, { useState } from 'react' +import { Autocomplete, TextField } from '@mui/material' +import Modal from '../Modal' +import { H3 } from '../typography' +import ErrorMessage from '../ErrorMessage' + +import { Button } from '../buttons' + +const MACHINE_GROUPS = gql` + query getMachineGroups { + machineGroups { + id + name + } + } +` + +const CHANGE_GROUP = gql` + mutation ChangeGroup($deviceIds: [ID!]!, $groupId: ID!) { + assignMachinesToGroup(deviceIds: $deviceIds, groupId: $groupId) + } +` + +const GroupModal = ({ onClose, deviceIds, onSuccess }) => { + const { data, loading } = useQuery(MACHINE_GROUPS) + const [selectedGroup, setSelectedGroup] = useState(null) + + const [changeGroup, { loading: mutationLoading, error }] = useMutation( + CHANGE_GROUP, + { + onCompleted: () => { + onSuccess && onSuccess() + onClose() + }, + refetchQueries: ['getMachines', 'getMachineGroups'], + }, + ) + + const handleSubmit = () => { + if (selectedGroup) { + changeGroup({ + variables: { + deviceIds: deviceIds, + groupId: selectedGroup.id, + }, + }) + } + } + + const messageClass = 'm-auto flex flex-col items-center justify-center' + + return ( + + {loading && ( +
    +

    Loading...

    +
    + )} + + {!loading && data && ( +
    +
    + { + setSelectedGroup(newValue) + }} + getOptionLabel={option => option.name || ''} + isOptionEqualToValue={(option, value) => option?.id === value?.id} + renderInput={params => ( + + )} + fullWidth + /> +
    +
    + {error && ( + + {error.graphQLErrors?.[0]?.extensions?.code === + 'RESOURCE_NOT_FOUND' + ? 'The selected group no longer exists' + : 'Failed to move machine to group'} + + )} + +
    +
    + )} +
    + ) +} + +export default GroupModal diff --git a/packages/admin-ui/src/components/machineActions/MachineActions.jsx b/packages/admin-ui/src/components/machineActions/MachineActions.jsx new file mode 100644 index 0000000..ebf97ee --- /dev/null +++ b/packages/admin-ui/src/components/machineActions/MachineActions.jsx @@ -0,0 +1,345 @@ +import { useMutation, useLazyQuery, gql } from '@apollo/client' +import GroupAddIcon from '@mui/icons-material/GroupAdd' +import EditIcon from '@mui/icons-material/Edit' +import RestartAltIcon from '@mui/icons-material/RestartAlt' +import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew' +import LinkOffIcon from '@mui/icons-material/LinkOff' +import BugReportIcon from '@mui/icons-material/BugReport' +import EmptyIcon from '@mui/icons-material/Remove' +import RefillIcon from '@mui/icons-material/Add' +import Visibility from '@mui/icons-material/Visibility' +import { + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Divider, +} from '@mui/material' +import React, { memo, useState } from 'react' +import { useLocation } from 'wouter' +import { ConfirmDialog } from '../ConfirmDialog' + +import DiagnosticsModal from './DiagnosticsModal' +import GroupModal from './GroupModal' + +const MACHINE_ACTION = gql` + mutation MachineAction( + $deviceId: ID! + $action: MachineAction! + $newName: String + ) { + machineAction(deviceId: $deviceId, action: $action, newName: $newName) { + deviceId + } + } +` + +const MACHINE = gql` + query getMachine($deviceId: ID!) { + machine(deviceId: $deviceId) { + latestEvent { + note + } + } + } +` + +const isStaticState = machineState => { + if (!machineState) { + return true + } + const staticStates = [ + 'chooseCoin', + 'idle', + 'pendingIdle', + 'dualIdle', + 'networkDown', + 'unpaired', + 'maintenance', + 'virgin', + 'wifiList', + ] + return staticStates.includes(machineState) +} + +const getState = machineEventsLazy => + JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}') + .state + +const MachineActions = memo( + ({ machine, onActionSuccess, anchorEl, open, onClose, showViewAction }) => { + const [, navigate] = useLocation() + const [action, setAction] = useState({ command: null }) + const [showModal, setShowModal] = useState(false) + const [showGroupModal, setShowGroupModal] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const warningMessage = ( + + A user may be in the middle of a transaction and they could lose their + funds if you continue. + + ) + + const [fetchMachineEvents, { loading: loadingEvents }] = + useLazyQuery(MACHINE) + + const [simpleMachineAction] = useMutation(MACHINE_ACTION) + + const [machineAction, { loading }] = useMutation(MACHINE_ACTION, { + onError: ({ message }) => { + const errorMessage = message ?? 'An error ocurred' + setErrorMessage(errorMessage) + }, + onCompleted: () => { + onActionSuccess && onActionSuccess() + setAction({ display: action.display, command: null }) + }, + }) + + const confirmDialogOpen = Boolean(action.command) + const disabled = !!(action?.command === 'restartServices' && loadingEvents) + + const machineStatusPreflight = actionToDo => { + fetchMachineEvents({ + variables: { deviceId: machine.deviceId }, + onCompleted: machineEventsLazy => { + const message = !isStaticState(getState(machineEventsLazy)) + ? warningMessage + : null + setAction({ ...actionToDo, message }) + }, + }) + } + + const handleMenuItemClick = actionFn => { + actionFn() + onClose() + } + + return ( + <> + + {showViewAction && ( + { + navigate(`/machines/${machine.deviceId}`) + onClose() + }}> + + + + View + + )} + {showViewAction && } + + + handleMenuItemClick(() => + setAction({ + command: 'rename', + display: 'Rename', + confirmationMessage: 'Write the new name for this machine', + }), + ) + }> + + + + Rename + + + + handleMenuItemClick(() => + setAction({ + command: 'unpair', + display: 'Unpair', + }), + ) + }> + + + + Unpair + + + + + + handleMenuItemClick(() => + setAction({ + command: 'reboot', + display: 'Reboot', + }), + ) + }> + + + + Reboot + + + + handleMenuItemClick(() => + setAction({ + command: 'shutdown', + display: 'Shutdown', + message: + 'In order to bring it back online, the machine will need to be visited and its power reset.', + }), + ) + }> + + + + Shutdown + + + + handleMenuItemClick(() => { + machineStatusPreflight({ + command: 'restartServices', + display: 'Restart services for', + }) + }) + }> + + + + Restart services + + + {machine.model === 'aveiro' && [ + , + + handleMenuItemClick(() => { + setAction({ + command: 'emptyUnit', + display: 'Empty', + message: + "Triggering this action will move all cash inside the machine towards its cashbox (if possible), allowing for the collection of cash from the machine via only its cashbox. Depending on how full the cash units are, it's possible that this action will need to be used more than once to ensure that the unit is left completely empty.", + }) + }) + }> + + + + Empty Unit + , + + handleMenuItemClick(() => { + setAction({ + command: 'refillUnit', + display: 'Refill', + message: + 'Triggering this action will refill the recyclers in this machine, by using bills present in its cassettes. This action may require manual operation of the cassettes and close attention to make sure that the denominations in the cassettes match the denominations in the recyclers.', + }) + }) + }> + + + + Refill Unit + , + ]} + + + + + handleMenuItemClick(() => { + setShowModal(true) + }) + }> + + + + Diagnostics + + + + handleMenuItemClick(() => { + setShowGroupModal(true) + }) + }> + + + + Change Group + + + {showGroupModal && ( + { + setShowGroupModal(false) + }} + onSuccess={onActionSuccess} + /> + )} + {showModal && ( + + simpleMachineAction({ + variables: { + deviceId: machine.deviceId, + action: 'diagnostics', + }, + }) + } + deviceId={machine.deviceId} + onClose={() => { + setShowModal(false) + }} + /> + )} + { + setErrorMessage(null) + machineAction({ + variables: { + deviceId: machine.deviceId, + action: `${action?.command}`, + ...(action?.command === 'rename' && { newName: value }), + }, + }) + }} + onDismissed={() => { + setAction({ display: action.display, command: null }) + setErrorMessage(null) + }} + /> + + ) + }, +) + +export default MachineActions diff --git a/packages/admin-ui/src/components/single-row-table/SingleRowTable.jsx b/packages/admin-ui/src/components/single-row-table/SingleRowTable.jsx new file mode 100644 index 0000000..e2d2561 --- /dev/null +++ b/packages/admin-ui/src/components/single-row-table/SingleRowTable.jsx @@ -0,0 +1,69 @@ +import IconButton from '@mui/material/IconButton' +import SvgIcon from '@mui/material/SvgIcon' +import React from 'react' +import { Table, THead, TBody, Td, Th, Tr } from '../fake-table/Table' +import EditIcon from '../../styling/icons/action/edit/white.svg?react' + +import { Label1, P } from '../typography/index.jsx' + +const SingleRowTable = ({ + width = 378, + height = 128, + title, + items, + onEdit, + className, +}) => { + return ( + <> + + + + + + + + + +
    + {title} + + + + + +
    + {items && ( + <> + {items[0] && ( +
    + + {items[0].label} + +

    + {items[0].value} +

    +
    + )} + {items[1] && ( +
    + + {items[1].label} + +

    + {items[1].value} +

    +
    + )} + + )} +
    + + ) +} + +export default SingleRowTable diff --git a/packages/admin-ui/src/components/table/EditCell.jsx b/packages/admin-ui/src/components/table/EditCell.jsx new file mode 100644 index 0000000..a5ef831 --- /dev/null +++ b/packages/admin-ui/src/components/table/EditCell.jsx @@ -0,0 +1,17 @@ +import React, { memo } from 'react' + +import { Link } from '../buttons' +import { TableCell as Td } from './' + +const EditCell = memo(({ save, cancel }) => ( + + + Cancel + + + Save + + +)) + +export default EditCell diff --git a/packages/admin-ui/src/components/table/EmptyTable.jsx b/packages/admin-ui/src/components/table/EmptyTable.jsx new file mode 100644 index 0000000..f559c26 --- /dev/null +++ b/packages/admin-ui/src/components/table/EmptyTable.jsx @@ -0,0 +1,19 @@ +import classNames from 'classnames' +import React, { memo } from 'react' +import { H4 } from '../typography' +import EmptyTableIcon from '../../styling/icons/table/empty-table.svg?react' + +const EmptyTable = memo(({ message, className }) => { + return ( +
    + +

    {message}

    +
    + ) +}) + +export default EmptyTable diff --git a/packages/admin-ui/src/components/table/Table.jsx b/packages/admin-ui/src/components/table/Table.jsx new file mode 100644 index 0000000..c54bedd --- /dev/null +++ b/packages/admin-ui/src/components/table/Table.jsx @@ -0,0 +1,17 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +const Table = memo(({ className, children, ...props }) => { + return ( + + {children} +
    + ) +}) + +export default Table diff --git a/packages/admin-ui/src/components/table/Table.module.css b/packages/admin-ui/src/components/table/Table.module.css new file mode 100644 index 0000000..4ba9e91 --- /dev/null +++ b/packages/admin-ui/src/components/table/Table.module.css @@ -0,0 +1,3 @@ +.tableCell { + padding: 0 6px; +} diff --git a/packages/admin-ui/src/components/table/TableBody.jsx b/packages/admin-ui/src/components/table/TableBody.jsx new file mode 100644 index 0000000..ecfcb78 --- /dev/null +++ b/packages/admin-ui/src/components/table/TableBody.jsx @@ -0,0 +1,7 @@ +import React, { memo } from 'react' + +const TableBody = memo(({ children, ...props }) => ( + {children} +)) + +export default TableBody diff --git a/packages/admin-ui/src/components/table/TableCell.jsx b/packages/admin-ui/src/components/table/TableCell.jsx new file mode 100644 index 0000000..36e8501 --- /dev/null +++ b/packages/admin-ui/src/components/table/TableCell.jsx @@ -0,0 +1,24 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +import classes from './Table.module.css' + +const TableCell = memo( + ({ colspan, rightAlign, className, children, ...props }) => { + const styles = { + [classes.tableCell]: true, + 'text-right': rightAlign, + } + + return ( + + {children} + + ) + }, +) + +export default TableCell diff --git a/packages/admin-ui/src/components/table/TableHead.jsx b/packages/admin-ui/src/components/table/TableHead.jsx new file mode 100644 index 0000000..4cdb837 --- /dev/null +++ b/packages/admin-ui/src/components/table/TableHead.jsx @@ -0,0 +1,7 @@ +import React, { memo } from 'react' + +const TableHead = memo(({ children, ...props }) => ( + {children} +)) + +export default TableHead diff --git a/packages/admin-ui/src/components/table/TableHeader.jsx b/packages/admin-ui/src/components/table/TableHeader.jsx new file mode 100644 index 0000000..9653d1b --- /dev/null +++ b/packages/admin-ui/src/components/table/TableHeader.jsx @@ -0,0 +1,19 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +const TableHeaderCell = memo( + ({ rightAlign, children, className, ...props }) => { + const styles = { + 'bg-zodiac text-white py-0 px-6 h-8 text-sm text-left': true, + 'text-right': rightAlign, + } + + return ( + + {children} + + ) + }, +) + +export default TableHeaderCell diff --git a/packages/admin-ui/src/components/table/TableRow.jsx b/packages/admin-ui/src/components/table/TableRow.jsx new file mode 100644 index 0000000..a34da45 --- /dev/null +++ b/packages/admin-ui/src/components/table/TableRow.jsx @@ -0,0 +1,23 @@ +import classnames from 'classnames' +import React, { memo } from 'react' + +const TableRow = memo( + ({ className, children, header, error, success, size = 'sm', ...props }) => { + const classnamesObj = { + 'p-1 bg-white': !header, + 'h-12': !header && size !== 'sm' && size !== 'lg', + 'h-8': !header && size === 'sm', + 'h-9 font-bold text-base ': !header && size === 'lg', + 'bg-misty-rose': error, + 'bg-spring3': success, + } + + return ( + + {children} + + ) + }, +) + +export default TableRow diff --git a/packages/admin-ui/src/components/table/index.js b/packages/admin-ui/src/components/table/index.js new file mode 100644 index 0000000..85ccc33 --- /dev/null +++ b/packages/admin-ui/src/components/table/index.js @@ -0,0 +1,19 @@ +import EditCell from './EditCell' +import EmptyTable from './EmptyTable' +import Table from './Table' +import TableBody from './TableBody' +import TableCell from './TableCell' +import TableHead from './TableHead' +import TableHeader from './TableHeader' +import TableRow from './TableRow' + +export { + EditCell, + EmptyTable, + Table, + TableCell, + TableHead, + TableHeader, + TableRow, + TableBody, +} diff --git a/packages/admin-ui/src/components/tables/DataTable.jsx b/packages/admin-ui/src/components/tables/DataTable.jsx new file mode 100644 index 0000000..bb23752 --- /dev/null +++ b/packages/admin-ui/src/components/tables/DataTable.jsx @@ -0,0 +1,211 @@ +import classnames from 'classnames' +import * as R from 'ramda' +import React, { useState, useEffect } from 'react' +import { + AutoSizer, + List, + CellMeasurer, + CellMeasurerCache, +} from 'react-virtualized' +import { Table, TBody, THead, Tr, Td, Th } from '../fake-table/Table' +import { H4 } from '../typography' +import ExpandClosedIcon from '../../styling/icons/action/expand/closed.svg?react' +import ExpandOpenIcon from '../../styling/icons/action/expand/open.svg?react' + +import { EmptyTable } from '../table' + +const Row = ({ + id, + index, + elements, + data, + width, + Details, + expanded, + expandRow, + expWidth, + expandable, + onClick, + size, + ...props +}) => { + const hasPointer = onClick || expandable + const trClasses = { + 'cursor-pointer': hasPointer, + 'border-2 border-transparent': true, + 'border-2 border-zircon shadow-md': expanded, + } + + return ( +
    +
    + { + expandable && expandRow(id, data) + onClick && onClick(data) + }} + error={data.error || data.hasError || data.batchError} + shouldShowError={false} + errorMessage={data.errorMessage || data.hasError || data.batchError}> + {elements.map(({ view = it => it?.toString(), ...props }, idx) => ( + + {view(data)} + + ))} + {expandable && ( + + + + )} + +
    + {expanded && ( +
    + + +
    + + +
    + )} +
    + ) +} + +const DataTable = ({ + elements = [], + data = [], + Details, + className, + tableClassName, + expandable, + initialExpanded, + onClick, + loading, + maxWidth = 1200, + emptyText, + rowSize, + ...props +}) => { + const [expanded, setExpanded] = useState(initialExpanded) + + useEffect(() => setExpanded(initialExpanded), [initialExpanded]) + + const coreWidth = R.compose(R.sum, R.map(R.prop('width')))(elements) + const expWidth = maxWidth - coreWidth + const width = coreWidth + (expandable ? expWidth : 0) + + const expandRow = (id, data) => { + if (data.id) { + cache.clear(data.id) + setExpanded(data.id === expanded ? null : data.id) + } else { + cache.clear(id) + setExpanded(id === expanded ? null : id) + } + } + + const cache = new CellMeasurerCache({ + defaultHeight: 58, + fixedWidth: true, + }) + + function rowRenderer({ index, key, parent, style }) { + return ( + + {({ registerChild }) => ( +
    + +
    + )} +
    + ) + } + + return ( +
    + + + {elements.map(({ width, className, textAlign, header }, idx) => ( + + ))} + {expandable && } + + + {loading &&

    Loading...

    } + {!loading && R.isEmpty(data) && } + {!loading && !R.isEmpty(data) && ( + + {({ height }) => ( + + )} + + )} + +
    + {header} +
    +
    + ) +} + +export default DataTable diff --git a/packages/admin-ui/src/components/typography/index.jsx b/packages/admin-ui/src/components/typography/index.jsx new file mode 100644 index 0000000..a0776a6 --- /dev/null +++ b/packages/admin-ui/src/components/typography/index.jsx @@ -0,0 +1,119 @@ +import classnames from 'classnames' +import React from 'react' + +import styles from './typography.module.css' + +function H1({ children, noMargin, className, ...props }) { + const classNames = { + [styles.h1]: true, + [styles.noMargin]: noMargin, + [className]: !!className, + } + + return ( +

    + {children} +

    + ) +} + +function H2({ children, noMargin, className, ...props }) { + const classNames = { + [styles.h2]: true, + [styles.noMargin]: noMargin, + [className]: !!className, + } + + return ( +

    + {children} +

    + ) +} + +function H3({ children, noMargin, className, ...props }) { + const classNames = { + [styles.h3]: true, + [styles.noMargin]: noMargin, + [className]: !!className, + } + + return ( +

    + {children} +

    + ) +} + +function H4({ children, noMargin, className, ...props }) { + const classNames = { + [styles.h4]: true, + [styles.noMargin]: noMargin, + [className]: !!className, + } + + return ( +

    + {children} +

    + ) +} + +function H5({ children, noMargin, className, ...props }) { + const classNames = { + [styles.h5]: true, + [styles.noMargin]: noMargin, + [className]: !!className, + } + + return ( +
    + {children} +
    + ) +} + +const P = pBuilder('p') +const Info1 = pBuilder('info1') +const Info2 = pBuilder('info2') +const Info3 = pBuilder('info3') +const Mono = pBuilder('mono') +const TL1 = pBuilder('tl1') +const TL2 = pBuilder('tl2') +const Label1 = pBuilder('label1') +const Label2 = pBuilder('label2') +const Label3 = pBuilder('label3') + +function pBuilder(elementClass) { + return ({ inline, noMargin, className, children, ...props }) => { + const classNames = { + [className]: !!className, + [styles[elementClass]]: elementClass, + [styles.inline]: inline, + [styles.noMargin]: noMargin, + } + return ( +

    + {children} +

    + ) + } +} + +export { + H1, + H2, + H3, + H4, + H5, + TL1, + TL2, + P, + Info1, + Info2, + Info3, + Mono, + Label1, + Label2, + Label3, +} diff --git a/packages/admin-ui/src/components/typography/styles.js b/packages/admin-ui/src/components/typography/styles.js new file mode 100644 index 0000000..dc60889 --- /dev/null +++ b/packages/admin-ui/src/components/typography/styles.js @@ -0,0 +1,136 @@ +import { + fontColor, + fontSize1, + fontSize2, + fontSize3, + fontSize4, + fontSize5, + fontPrimary, + fontSecondary, + fontMonospaced, +} from '../../styling/variables' + +const base = { + color: fontColor, +} + +export default { + base: { + color: fontColor, + }, + h1: { + extend: base, + fontSize: fontSize1, + fontFamily: fontPrimary, + fontWeight: 900, + }, + h2: { + extend: base, + fontSize: fontSize2, + fontFamily: fontPrimary, + fontWeight: 900, + }, + h3: { + extend: base, + fontSize: fontSize4, + fontFamily: fontPrimary, + fontWeight: 900, + }, + h4: { + extend: base, + fontSize: fontSize4, + fontFamily: fontPrimary, + fontWeight: 700, + }, + h5: { + extend: base, + fontSize: fontSize3, + fontFamily: fontPrimary, + fontWeight: 700, + }, + p: { + ...base, + fontSize: fontSize4, + fontFamily: fontSecondary, + fontWeight: 500, + }, + tl1: { + extend: base, + fontSize: fontSize2, + fontFamily: fontSecondary, + fontWeight: 700, + }, + tl2: { + extend: base, + fontSize: fontSize4, + fontFamily: fontSecondary, + fontWeight: 700, + }, + info1: { + extend: base, + fontSize: fontSize1, + fontFamily: fontSecondary, + fontWeight: 700, + }, + info2: { + extend: base, + fontSize: fontSize3, + fontFamily: fontSecondary, + fontWeight: 700, + }, + info3: { + extend: base, + fontSize: fontSize3, + fontFamily: fontSecondary, + fontWeight: 500, + }, + mono: { + extend: base, + fontSize: fontSize4, + fontFamily: fontMonospaced, + fontWeight: 500, + }, + monoBold: { + fontWeight: 700, + }, + monoSmall: { + fontSize: fontSize5, + }, + inputFont: { + fontSize: fontSize2, + fontFamily: fontSecondary, + fontWeight: 500, + lineHeight: '110%', + color: fontColor, + }, + regularLabel: { + fontSize: fontSize4, + fontFamily: fontSecondary, + fontWeight: 500, + lineHeight: '110%', + }, + label1: { + fontSize: fontSize5, + fontFamily: fontSecondary, + fontWeight: 500, + color: fontColor, + }, + label2: { + fontSize: fontSize5, + fontFamily: fontSecondary, + fontWeight: 700, + color: fontColor, + }, + label3: { + fontSize: fontSize4, + fontFamily: fontSecondary, + fontWeight: 500, + color: fontColor, + }, + inline: { + display: 'inline', + }, + noMargin: { + margin: 0, + }, +} diff --git a/packages/admin-ui/src/components/typography/typography.module.css b/packages/admin-ui/src/components/typography/typography.module.css new file mode 100644 index 0000000..b08fa5d --- /dev/null +++ b/packages/admin-ui/src/components/typography/typography.module.css @@ -0,0 +1,140 @@ +.base { + line-height: 120%; + color: var(--zodiac); +} + +.h1 { + composes: base; + font-size: 24px; + font-family: var(--mont); + font-weight: 900; +} + +.h2 { + composes: base; + font-size: 20px; + font-family: var(--mont); + font-weight: 900; +} + +.h3 { + composes: base; + font-size: 14px; + font-family: var(--mont); + font-weight: 900; +} + +.h4 { + composes: base; + font-size: 14px; + font-family: var(--mont); + font-weight: 700; +} + +.h5 { + composes: base; + font-size: 16px; + font-family: var(--mont); + font-weight: 700; +} + +.p { + composes: base; + font-size: 14px; + font-family: var(--museo); + font-weight: 500; +} + +.tl1 { + composes: base; + font-size: 20px; + font-family: var(--museo); + font-weight: 700; +} + +.tl2 { + composes: base; + font-size: 14px; + font-family: var(--museo); + font-weight: 700; +} + +.info1 { + composes: base; + font-size: 24px; + font-family: var(--museo); + font-weight: 700; +} + +.info2 { + composes: base; + font-size: 16px; + font-family: var(--museo); + font-weight: 700; +} + +.info3 { + composes: base; + font-size: 16px; + font-family: var(--museo); + font-weight: 500; +} + +.mono { + composes: base; + font-size: 14px; + font-family: var(--bpmono); + font-weight: 500; +} + +.monoBold { + font-weight: 700; +} + +.monoSmall { + font-size: 13px; +} + +.inputFont { + font-size: 20px; + font-family: var(--museo); + font-weight: 500; + line-height: 110%; + color: var(--zodiac); +} + +.regularLabel { + font-size: 14px; + font-family: var(--museo); + font-weight: 500; + line-height: 110%; +} + +.label1 { + font-size: 13px; + font-family: var(--museo); + font-weight: 500; + color: var(--zodiac); +} + +.label2 { + font-size: 13px; + font-family: var(--museo); + font-weight: 700; + color: var(--zodiac); +} + +.label3 { + font-size: 14px; + font-family: var(--museo); + font-weight: 500; + color: var(--zodiac); +} + +.inline { + display: inline; +} + +.noMargin { + margin: 0; +} diff --git a/packages/admin-ui/src/index.jsx b/packages/admin-ui/src/index.jsx new file mode 100644 index 0000000..a035c56 --- /dev/null +++ b/packages/admin-ui/src/index.jsx @@ -0,0 +1,8 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +import App from './App' + +const container = document.getElementById('root') +const root = createRoot(container) +root.render() diff --git a/packages/admin-ui/src/pages/AddMachine/AddMachine.jsx b/packages/admin-ui/src/pages/AddMachine/AddMachine.jsx new file mode 100644 index 0000000..b6c674a --- /dev/null +++ b/packages/admin-ui/src/pages/AddMachine/AddMachine.jsx @@ -0,0 +1,276 @@ +import { useMutation, useQuery, gql } from '@apollo/client' +import Dialog from '@mui/material/Dialog' +import DialogContent from '@mui/material/DialogContent' +import SvgIcon from '@mui/material/SvgIcon' +import IconButton from '@mui/material/IconButton' +import classnames from 'classnames' +import { Form, Formik, FastField } from 'formik' +import { QRCodeSVG as QRCode } from 'qrcode.react' +import * as R from 'ramda' +import React, { memo, useState, useEffect, useRef } from 'react' +import Title from '../../components/Title' +import Sidebar from '../../components/layout/Sidebar' +import { Info2, P } from '../../components/typography' +import CameraIcon from '../../styling/icons/ID/photo/zodiac.svg?react' +import CloseIcon from '../../styling/icons/action/close/zodiac.svg?react' +import CompleteStageIconSpring from '../../styling/icons/stage/spring/complete.svg?react' +import CompleteStageIconZodiac from '../../styling/icons/stage/zodiac/complete.svg?react' +import CurrentStageIconZodiac from '../../styling/icons/stage/zodiac/current.svg?react' +import EmptyStageIconZodiac from '../../styling/icons/stage/zodiac/empty.svg?react' +import WarningIcon from '../../styling/icons/warning-icon/comet.svg?react' +import * as Yup from 'yup' + +import { Button } from '../../components/buttons' +import { TextInput } from '../../components/inputs/formik' +import { primaryColor } from '../../styling/variables' + +const SAVE_CONFIG = gql` + mutation createPairingTotem($name: String!) { + createPairingTotem(name: $name) + } +` +const GET_MACHINES = gql` + { + machines { + name + deviceId + } + } +` + +const getSize = R.compose(R.length, R.pathOr([], ['machines'])) + +const QrCodeComponent = ({ qrCode, name, count, onPaired }) => { + const timeout = useRef(null) + const CLOSE_SCREEN_TIMEOUT = 2000 + const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 }) + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current) + } + } + }, []) + + const addedMachine = data?.machines?.find(m => m.name === name) + const hasNewMachine = getSize(data) > count && addedMachine + if (hasNewMachine) { + timeout.current = setTimeout( + () => onPaired(addedMachine), + CLOSE_SCREEN_TIMEOUT, + ) + } + + return ( + <> + Scan QR code with your new cryptomat +
    +
    + +
    + +

    + Snap a picture and scan +

    +
    +
    +
    +
    +
    + +
    +

    + To pair the machine you need scan the QR code with your machine. + To do this either snap a picture of this QR code or download it + through the button above and scan it with the scanning bay on your + machine. +

    +
    + {hasNewMachine && ( +
    +
    + +
    + + Machine has been successfully paired! + +
    + )} +
    +
    + + ) +} + +const initialValues = { + name: '', +} + +const validationSchema = Yup.object().shape({ + name: Yup.string() + .required('Machine name is required.') + .max(50) + .test( + 'unique-name', + 'Machine name is already in use.', + (value, context) => + !R.includes( + R.toLower(value), + R.map(R.toLower, context.options.context.machineNames), + ), + ), +}) + +const MachineNameComponent = ({ nextStep, setQrCode, setName }) => { + const [register] = useMutation(SAVE_CONFIG, { + onCompleted: ({ createPairingTotem }) => { + if (process.env.NODE_ENV === 'development') { + console.log(`totem: "${createPairingTotem}" `) + } + setQrCode(createPairingTotem) + nextStep() + }, + onError: e => console.log(e), + }) + + const { data } = useQuery(GET_MACHINES) + const machineNames = R.map(R.prop('name'), data?.machines || {}) + + const uniqueNameValidator = value => { + try { + validationSchema.validateSync(value, { + context: { machineNames: machineNames }, + }) + } catch (error) { + return error + } + } + + return ( + <> + Machine Name (ex: Coffee shop 01) + { + setName(name) + register({ variables: { name } }) + }}> + {({ errors }) => ( +
    +
    + +
    + {errors &&

    {errors.message}

    } +
    + +
    +
    + )} +
    + + ) +} + +const steps = [ + { + label: 'Machine name', + component: MachineNameComponent, + }, + { + label: 'Scan QR code', + component: QrCodeComponent, + }, +] + +const renderStepper = (step, it, idx) => { + const active = step === idx + const past = idx < step + const future = idx > step + + return ( +
    + + {it.label} + + {active && } + {past && } + {future && } + {idx < steps.length - 1 && ( +
    + )} +
    + ) +} + +const AddMachine = memo(({ close, onPaired }) => { + const { data } = useQuery(GET_MACHINES) + const [qrCode, setQrCode] = useState('') + const [name, setName] = useState('') + const [step, setStep] = useState(0) + const count = getSize(data) + + const Component = steps[step].component + + return ( +
    + + +
    +
    + Add Machine + + + + + +
    +
    + + {steps.map((it, idx) => renderStepper(step, it, idx))} + +
    + setStep(1)} + count={count} + onPaired={onPaired} + qrCode={qrCode} + setQrCode={setQrCode} + name={name} + setName={setName} + /> +
    +
    +
    +
    +
    +
    + ) +}) + +export default AddMachine diff --git a/packages/admin-ui/src/pages/AddMachine/index.js b/packages/admin-ui/src/pages/AddMachine/index.js new file mode 100644 index 0000000..aae56dd --- /dev/null +++ b/packages/admin-ui/src/pages/AddMachine/index.js @@ -0,0 +1,3 @@ +import AddMachine from './AddMachine' + +export default AddMachine diff --git a/packages/admin-ui/src/pages/Analytics/Analytics.jsx b/packages/admin-ui/src/pages/Analytics/Analytics.jsx new file mode 100644 index 0000000..13bb4de --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/Analytics.jsx @@ -0,0 +1,377 @@ +import { useQuery, gql } from '@apollo/client' +import classnames from 'classnames' +import { endOfToday } from 'date-fns' +import { subDays, format, add, startOfWeek } from 'date-fns/fp' +import * as R from 'ramda' +import React, { useState } from 'react' +import TitleSection from '../../components/layout/TitleSection' +import { Info2, P } from '../../components/typography' +import DownIcon from '../../styling/icons/dashboard/down.svg?react' +import EqualIcon from '../../styling/icons/dashboard/equal.svg?react' +import UpIcon from '../../styling/icons/dashboard/up.svg?react' + +import { Select } from '../../components/inputs' +import { fromNamespace } from '../../utils/config' +import { numberToFiatAmount } from '../../utils/number' +import { DAY, WEEK, MONTH } from '../../utils/time' + +import LegendEntry from './components/LegendEntry' +import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper' +import OverTimeWrapper from './components/wrappers/OverTimeWrapper' +import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper' +import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper' + +const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }] +const REPRESENTING_OPTIONS = [ + { code: 'overTime', display: 'Over time' }, + { code: 'volumeOverTime', display: 'Volume' }, + { code: 'topMachines', display: 'Top machines' }, + { code: 'hourOfTheDay', display: 'Hour of the day' }, +] +const PERIOD_OPTIONS = [ + { code: 'day', display: 'Last 24 hours' }, + { code: 'threeDays', display: 'Last 3 days' }, + { code: 'week', display: 'Last 7 days' }, + { code: 'month', display: 'Last 30 days' }, +] +const TIME_OPTIONS = { + day: DAY, + threeDays: 3 * DAY, + week: WEEK, + month: MONTH, +} + +const DAY_OPTIONS = R.map( + it => ({ + code: R.toLower(it), + display: it, + }), + Array.from(Array(7)).map((_, i) => + format('EEEE', add({ days: i }, startOfWeek(new Date()))), + ), +) + +const GET_TRANSACTIONS = gql` + query transactions( + $from: DateTimeISO + $until: DateTimeISO + $excludeTestingCustomers: Boolean + ) { + transactions( + from: $from + until: $until + excludeTestingCustomers: $excludeTestingCustomers + ) { + txClass + expired + sendConfirmed + dispense + hasError: error + deviceId + fiat + fiatCode + created + profit + } + } +` + +const GET_DATA = gql` + query getData { + config + machines { + name + deviceId + } + fiatRates { + code + name + rate + } + } +` + +const VerticalLine = () => ( +
    +) + +const OverviewEntry = ({ label, value, oldValue, currency }) => { + const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue + const growthRate = ((value - oldValue) * 100) / _oldValue + + const growthClasses = { + 'font-bold': true, + 'text-malachite': R.gt(value, oldValue), + 'text-tomato': R.gt(oldValue, value), + } + + return ( +
    +

    {label}

    + + {numberToFiatAmount(value)} + {!!currency && ` ${currency}`} + + + {R.gt(growthRate, 0) && } + {R.lt(growthRate, 0) && } + {R.equals(growthRate, 0) && } +

    + {numberToFiatAmount(growthRate)}% +

    +
    +
    + ) +} + +const Analytics = () => { + const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, { + variables: { + from: subDays(65, endOfToday()), + until: endOfToday(), + excludeTestingCustomers: true, + }, + }) + const { data: configResponse, loading: configLoading } = useQuery(GET_DATA) + + const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0]) + const [period, setPeriod] = useState(PERIOD_OPTIONS[0]) + const [machine, setMachine] = useState(MACHINE_OPTIONS[0]) + const [selectedDay, setSelectedDay] = useState( + R.equals(representing.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null, + ) + + const loading = txLoading || configLoading + + const transactions = R.path(['transactions'])(txResponse) ?? [] + const machines = R.path(['machines'])(configResponse) ?? [] + const config = R.path(['config'])(configResponse) ?? [] + const rates = R.path(['fiatRates'])(configResponse) ?? [] + const fiatLocale = fromNamespace('locale')(config).fiatCurrency + + const timezone = config?.locale_timezone + + const convertFiatToLocale = item => { + if (item.fiatCode === fiatLocale) return item + const itemRate = R.find(R.propEq(item.fiatCode, 'code'))(rates) + const localeRate = R.find(R.propEq('code', fiatLocale))(rates) + const multiplier = localeRate?.rate / itemRate?.rate + return { ...item, fiat: parseFloat(item.fiat) * multiplier } + } + + const data = + R.map(convertFiatToLocale)( + transactions?.filter( + tx => + (!tx.dispensed || !tx.expired) && + (tx.sendConfirmed || tx.dispense) && + !tx.hasError, + ), + ) ?? [] + + const machineOptions = R.clone(MACHINE_OPTIONS) + + R.forEach( + m => machineOptions.push({ code: m.deviceId, display: m.name }), + machines, + ) + + const machineTxs = R.filter( + tx => (machine.code === 'all' ? true : tx.deviceId === machine.code), + data, + ) + + const filteredData = timeInterval => ({ + current: + machineTxs.filter(d => { + const txDay = new Date(d.created) + const isSameWeekday = !R.isNil(selectedDay) + ? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code) + : true + + return isSameWeekday && txDay >= Date.now() - TIME_OPTIONS[timeInterval] + }) ?? [], + previous: + machineTxs.filter(d => { + const txDay = new Date(d.created) + const isSameWeekday = !R.isNil(selectedDay) + ? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code) + : true + + return ( + isSameWeekday && + txDay < Date.now() - TIME_OPTIONS[timeInterval] && + txDay >= Date.now() - 2 * TIME_OPTIONS[timeInterval] + ) + }) ?? [], + }) + + const txs = { + current: filteredData(period.code).current.length, + previous: filteredData(period.code).previous.length, + } + + const median = values => (values.length === 0 ? 0 : R.median(values)) + + const medianAmount = { + current: median(R.map(d => d.fiat, filteredData(period.code).current)), + previous: median(R.map(d => d.fiat, filteredData(period.code).previous)), + } + + const txVolume = { + current: R.sum(R.map(d => d.fiat, filteredData(period.code).current)), + previous: R.sum(R.map(d => d.fiat, filteredData(period.code).previous)), + } + + const commissions = { + current: R.sum(R.map(d => d.profit, filteredData(period.code).current)), + previous: R.sum(R.map(d => d.profit, filteredData(period.code).previous)), + } + + const handleRepresentationChange = newRepresentation => { + setRepresenting(newRepresentation) + setSelectedDay( + R.equals(newRepresentation.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null, + ) + } + + const getGraphInfo = representing => { + switch (representing.code) { + case 'overTime': + return ( + + ) + case 'volumeOverTime': + return ( + + ) + case 'topMachines': + return ( + + ) + case 'hourOfTheDay': + return ( + + ) + default: + throw new Error(`There's no graph info to represent ${representing}`) + } + } + + return ( + !loading && ( + <> + +
    + + + +
    +
    +
    +
    + +
    +
    + + + + + + + +
    +
    + {getGraphInfo(representing)} + + ) + ) +} + +export default Analytics diff --git a/packages/admin-ui/src/pages/Analytics/components/LegendEntry.jsx b/packages/admin-ui/src/pages/Analytics/components/LegendEntry.jsx new file mode 100644 index 0000000..009ca4d --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/components/LegendEntry.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import { P } from '../../../components/typography' + +const LegendEntry = ({ IconElement, IconComponent, label }) => { + return ( + + {!!IconComponent && } + {!!IconElement && IconElement} +

    {label}

    +
    + ) +} + +export default LegendEntry diff --git a/packages/admin-ui/src/pages/Analytics/components/tooltips/GraphTooltip.jsx b/packages/admin-ui/src/pages/Analytics/components/tooltips/GraphTooltip.jsx new file mode 100644 index 0000000..1349fcb --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/components/tooltips/GraphTooltip.jsx @@ -0,0 +1,73 @@ +import Paper from '@mui/material/Paper' +import * as R from 'ramda' +import React, { memo } from 'react' +import { Info2, Label3, P } from '../../../../components/typography' +import TxInIcon from '../../../../styling/icons/direction/cash-in.svg?react' +import TxOutIcon from '../../../../styling/icons/direction/cash-out.svg?react' + +import { numberToFiatAmount } from '../../../../utils/number' +import { singularOrPlural } from '../../../../utils/string' +import { formatDate, formatDateNonUtc } from '../../../../utils/timezones' + +const GraphTooltip = ({ + coords, + data, + dateInterval, + currency, + representing, +}) => { + const formattedDateInterval = !R.includes('hourOfDay', representing.code) + ? [ + formatDate(dateInterval[1], null, 'MMM d'), + formatDate(dateInterval[1], null, 'HH:mm'), + formatDate(dateInterval[0], null, 'HH:mm'), + ] + : [ + formatDate(dateInterval[1], null, 'MMM d'), + formatDateNonUtc(dateInterval[1], 'HH:mm'), + formatDateNonUtc(dateInterval[0], 'HH:mm'), + ] + + const transactions = R.reduce( + (acc, value) => { + acc.volume += parseInt(value.fiat) + if (value.txClass === 'cashIn') acc.cashIn++ + if (value.txClass === 'cashOut') acc.cashOut++ + return acc + }, + { volume: 0, cashIn: 0, cashOut: 0 }, + data, + ) + + return ( + + {!R.includes('hourOfDay', representing.code) && ( + {`${formattedDateInterval[0]}`} + )} + + {`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`} + +

    + {R.length(data)}{' '} + {singularOrPlural(R.length(data), 'transaction', 'transactions')} +

    +

    + {numberToFiatAmount(transactions.volume)} {currency} in volume +

    +
    + + + {transactions.cashIn} cash-in + + + + {transactions.cashOut} cash-out + +
    +
    + ) +} + +export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords) diff --git a/packages/admin-ui/src/pages/Analytics/components/wrappers/HourOfDayWrapper.jsx b/packages/admin-ui/src/pages/Analytics/components/wrappers/HourOfDayWrapper.jsx new file mode 100644 index 0000000..b0aa962 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/components/wrappers/HourOfDayWrapper.jsx @@ -0,0 +1,124 @@ +import { getTimezoneOffset } from 'date-fns-tz' +import * as R from 'ramda' +import React, { useState } from 'react' +import { H2 } from '../../../../components/typography' + +import { Select } from '../../../../components/inputs' +import { MINUTE } from '../../../../utils/time' + +import Graph from '../../graphs/Graph' +import LegendEntry from '../LegendEntry' +import classes from './wrappers.module.css' + +const options = [ + { code: 'hourOfDayTransactions', display: 'Transactions' }, + { code: 'hourOfDayVolume', display: 'Volume' }, +] + +const HourOfDayBarGraphHeader = ({ + title, + period, + data, + machines, + selectedMachine, + handleMachineChange, + selectedDay, + dayOptions, + handleDayChange, + timezone, + currency, +}) => { + const [graphType /*, setGraphType */] = useState(options[0].code) + + const legend = { + cashIn:
    , + cashOut:
    , + } + + const offset = getTimezoneOffset(timezone) + + const txsPerWeekday = R.reduce( + (acc, value) => { + const created = new Date(value.created) + created.setTime( + created.getTime() + created.getTimezoneOffset() * MINUTE + offset, + ) + switch (created.getDay()) { + case 0: + acc.sunday.push(value) + break + case 1: + acc.monday.push(value) + break + case 2: + acc.tuesday.push(value) + break + case 3: + acc.wednesday.push(value) + break + case 4: + acc.thursday.push(value) + break + case 5: + acc.friday.push(value) + break + case 6: + acc.saturday.push(value) + break + default: + throw new Error('Day of week not recognized') + } + return acc + }, + R.fromPairs(R.map(it => [it.code, []], dayOptions)), + data, + ) + + return ( + <> +
    +
    +

    {title}

    +
    + + +
    +
    +
    + {/* setGraphType(e.target.value)} + /> */} + +
    +
    + it.code === graphType)(options)} + period={period} + data={txsPerWeekday[selectedDay.code]} + timezone={timezone} + currency={currency} + selectedMachine={selectedMachine} + machines={machines} + selectedDay={selectedDay} + /> + + ) +} + +export default HourOfDayBarGraphHeader diff --git a/packages/admin-ui/src/pages/Analytics/components/wrappers/OverTimeWrapper.jsx b/packages/admin-ui/src/pages/Analytics/components/wrappers/OverTimeWrapper.jsx new file mode 100644 index 0000000..2174f7b --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/components/wrappers/OverTimeWrapper.jsx @@ -0,0 +1,89 @@ +import Switch from '@mui/material/Switch' +import React, { useState } from 'react' +import { H2, Label1 } from '../../../../components/typography' + +import { Select } from '../../../../components/inputs' +import { primaryColor } from '../../../../styling/variables' + +import Graph from '../../graphs/Graph' +import LegendEntry from '../LegendEntry' +import classes from './wrappers.module.css' + +const OverTimeDotGraphHeader = ({ + title, + representing, + period, + data, + machines, + selectedMachine, + handleMachineChange, + timezone, + currency, +}) => { + const [logarithmic, setLogarithmic] = useState() + + const legend = { + cashIn:
    , + cashOut:
    , + transaction:
    , + median: ( + + + + ), + } + + return ( + <> +
    +
    +

    {title}

    +
    + + + + +
    +
    +
    +
    + + Log. scale + + setLogarithmic(event.target.checked)} + /> +
    + +
    +
    + + + ) +} + +export default VolumeOverTimeGraphHeader diff --git a/packages/admin-ui/src/pages/Analytics/components/wrappers/wrappers.module.css b/packages/admin-ui/src/pages/Analytics/components/wrappers/wrappers.module.css new file mode 100644 index 0000000..7cd5319 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/components/wrappers/wrappers.module.css @@ -0,0 +1,56 @@ +.graphHeaderWrapper { + display: flex; + justify-content: space-between; + margin-bottom: 40px; +} + +.graphHeaderLeft { + display: flex; + flex-direction: column; +} + +.graphHeaderRight { + margin-top: 15px; + display: flex; + gap: 30px; +} + +.cashInIcon { + width: 12px; + height: 12px; + border-radius: 12px; + background-color: var(--java); +} + +.cashOutIcon { + width: 12px; + height: 12px; + border-radius: 12px; + background-color: var(--neon); +} + +.graphLegend { + display: flex; + align-items: center; + gap: 24px; +} + +.txIcon { + width: 12px; + height: 12px; + border-radius: 12px; + background-color: #000; +} + +.graphHeaderSwitchBox { + display: flex; + flex-direction: column; + /*'& > *': {*/ + /* margin: 0*/ + /*},*/ + /*'& > :first-child': {*/ + /* marginBottom: 2,*/ + /* extend: label1,*/ + /* color: offColor*/ + /*}*/ +} diff --git a/packages/admin-ui/src/pages/Analytics/graphs/Graph.jsx b/packages/admin-ui/src/pages/Analytics/graphs/Graph.jsx new file mode 100644 index 0000000..d6c85f3 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/graphs/Graph.jsx @@ -0,0 +1,135 @@ +import * as R from 'ramda' +import React, { memo, useState } from 'react' + +import GraphTooltip from '../components/tooltips/GraphTooltip' + +import HourOfDayBarGraph from './HourOfDayBarGraph' +import OverTimeDotGraph from './OverTimeDotGraph' +import OverTimeLineGraph from './OverTimeLineGraph' +import TopMachinesBarGraph from './TopMachinesBarGraph' + +const GraphWrapper = ({ + data, + representing, + period, + timezone, + currency, + selectedMachine, + machines, + selectedDay, + log, +}) => { + const [selectionCoords, setSelectionCoords] = useState(null) + const [selectionDateInterval, setSelectionDateInterval] = useState(null) + const [selectionData, setSelectionData] = useState(null) + + const getGraph = representing => { + switch (representing.code) { + case 'overTime': + return ( + + ) + case 'volumeOverTime': + return ( + + ) + case 'topMachinesVolume': + return ( + it.code !== 'all', machines)} + currency={currency} + /> + ) + case 'topMachinesTransactions': + return ( + it.code !== 'all', machines)} + currency={currency} + /> + ) + case 'hourOfDayVolume': + return ( + it.code !== 'all', machines)} + currency={currency} + selectedDay={selectedDay} + /> + ) + case 'hourOfDayTransactions': + return ( + it.code !== 'all', machines)} + currency={currency} + selectedDay={selectedDay} + /> + ) + default: + throw new Error(`There's no graph to represent ${representing}`) + } + } + + return ( +
    + {!R.isNil(selectionCoords) && ( + + )} + {getGraph(representing)} +
    + ) +} + +export default memo(GraphWrapper) diff --git a/packages/admin-ui/src/pages/Analytics/graphs/HourOfDayBarGraph.jsx b/packages/admin-ui/src/pages/Analytics/graphs/HourOfDayBarGraph.jsx new file mode 100644 index 0000000..0394e41 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/graphs/HourOfDayBarGraph.jsx @@ -0,0 +1,436 @@ +import BigNumber from 'bignumber.js' +import * as d3 from 'd3' +import { getTimezoneOffset } from 'date-fns-tz' +import { add, startOfDay } from 'date-fns/fp' +import * as R from 'ramda' +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { + java, + neon, + subheaderDarkColor, + fontColor, + fontSecondary, + subheaderColor, +} from '../../../styling/variables' +import { MINUTE } from '../../../utils/time' +import { toUtc } from '../../../utils/timezones' + +const Graph = ({ + data, + timezone, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, +}) => { + const ref = useRef(null) + + const GRAPH_POPOVER_WIDTH = 150 + const GRAPH_POPOVER_MARGIN = 25 + const BAR_MARGIN = 10 + const GRAPH_HEIGHT = 401 + const GRAPH_WIDTH = 1163 + const GRAPH_MARGIN = useMemo( + () => ({ + top: 25, + right: 0.5, + bottom: 27, + left: 36.5, + }), + [], + ) + + const offset = getTimezoneOffset(timezone) + + const getTickIntervals = (domain, interval) => { + const ticks = [] + const start = new Date(domain[0]) + const end = new Date(domain[1]) + + const step = R.clone(start) + + while (step <= end) { + ticks.push(R.clone(step)) + step.setUTCHours(step.getUTCHours() + interval) + } + + return ticks + } + + const filterByHourInterval = useCallback( + (lowerBound, upperBound) => + R.filter(it => { + const tzCreated = new Date(it.created).setTime( + new Date(it.created).getTime() + + new Date(it.created).getTimezoneOffset() * MINUTE + + offset, + ) + const created = new Date(tzCreated) + + return ( + (lowerBound.getUTCHours() < upperBound.getUTCHours() && + created.getUTCHours() >= new Date(lowerBound).getUTCHours() && + created.getUTCHours() < new Date(upperBound).getUTCHours()) || + (lowerBound.getUTCHours() > upperBound.getUTCHours() && + created.getUTCHours() <= new Date(lowerBound).getUTCHours() && + created.getUTCHours() < new Date(upperBound).getUTCHours()) + ) + }, data), + [data, offset], + ) + + const txClassByHourInterval = useCallback( + (lowerBound, upperBound) => + R.reduce( + (acc, value) => { + if (value.txClass === 'cashIn') + acc.cashIn += BigNumber(value.fiat).toNumber() + if (value.txClass === 'cashOut') + acc.cashOut += BigNumber(value.fiat).toNumber() + return acc + }, + { cashIn: 0, cashOut: 0 }, + filterByHourInterval(lowerBound, upperBound), + ), + [filterByHourInterval], + ) + + const x = d3 + .scaleUtc() + .domain([ + toUtc(startOfDay(new Date())), + toUtc(add({ days: 1 }, startOfDay(new Date()))), + ]) + .rangeRound([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right]) + + const groupedByDateInterval = R.map( + it => { + const lowerBound = R.clone(it) + it.setUTCHours(it.getUTCHours() + 2) + const upperBound = R.clone(it) + return [lowerBound, filterByHourInterval(lowerBound, upperBound)] + }, + R.init(getTickIntervals(x.domain(), 2)), + ) + + const groupedByTxClass = R.map( + it => { + const lowerBound = R.clone(it) + it.setUTCHours(it.getUTCHours() + 2) + const upperBound = R.clone(it) + return [lowerBound, txClassByHourInterval(lowerBound, upperBound)] + }, + R.init(getTickIntervals(x.domain(), 2)), + ) + + const y = d3 + .scaleLinear() + .domain([ + 0, + d3.max( + groupedByTxClass.map(it => it[1]), + d => d.cashIn + d.cashOut, + ) !== 0 + ? d3.max( + groupedByTxClass.map(it => it[1]), + d => d.cashIn + d.cashOut, + ) + : 50, + ]) + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const buildXAxis = useCallback( + g => + g + .attr( + 'transform', + `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`, + ) + .call( + d3 + .axisBottom(x) + .ticks(d3.timeHour.every(2)) + .tickFormat(d3.timeFormat('%H:%M')), + ), + [GRAPH_MARGIN, x], + ) + + const buildYAxis = useCallback( + g => + g + .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) + .call( + d3 + .axisLeft(y) + .ticks(GRAPH_HEIGHT / 100) + .tickSize(0) + .tickFormat(``), + ) + .call(g => g.select('.domain').remove()), + [GRAPH_MARGIN, y], + ) + + const buildVerticalLines = useCallback( + g => + g + .attr('stroke', subheaderDarkColor) + .append('g') + .selectAll('line') + .data(getTickIntervals(x.domain(), 2)) + .join('line') + .attr('x1', d => { + const xValue = x(d) + const intervals = getTickIntervals(x.domain(), 2) + return xValue === x(intervals[R.length(intervals) - 1]) + ? xValue - 1 + : 0.5 + xValue + }) + .attr('x2', d => { + const xValue = x(d) + const intervals = getTickIntervals(x.domain(), 2) + return xValue === x(intervals[R.length(intervals) - 1]) + ? xValue - 1 + : 0.5 + xValue + }) + .attr('y1', GRAPH_MARGIN.top) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom), + [GRAPH_MARGIN, x], + ) + + const buildHoverableEventRects = useCallback( + g => + g + .append('g') + .selectAll('line') + .data(getTickIntervals(x.domain(), 2)) + .join('rect') + .attr('x', d => x(d)) + .attr('y', GRAPH_MARGIN.top) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const ticks = getTickIntervals(x.domain(), 2).map(it => x(it)) + + const index = R.findIndex(it => it === xValue, ticks) + const width = + index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index] + + return Math.round(width * 100) / 100 + }) + .attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top) + .attr('stroke', 'transparent') + .attr('fill', 'transparent') + .on('mouseover', d => { + const date = R.clone(new Date(d.target.__data__)) + const startDate = R.clone(date) + date.setUTCHours(date.getUTCHours() + 2) + const endDate = R.clone(date) + + const filteredData = groupedByDateInterval.find(it => + R.equals(startDate, it[0]), + )[1] + + const rectXCoords = { + left: R.clone(d.target.getBoundingClientRect().x), + right: R.clone( + d.target.getBoundingClientRect().x + + d.target.getBoundingClientRect().width, + ), + } + + const xCoord = + d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH + ? rectXCoords.right + GRAPH_POPOVER_MARGIN + : rectXCoords.left - GRAPH_POPOVER_WIDTH - GRAPH_POPOVER_MARGIN + const yCoord = R.clone(d.target.getBoundingClientRect().y) + + setSelectionDateInterval([endDate, startDate]) + setSelectionData(filteredData) + setSelectionCoords({ + x: Math.round(xCoord), + y: Math.round(yCoord), + }) + + d3.select(`#event-rect-${x(d.target.__data__)}`).attr( + 'fill', + subheaderColor, + ) + }) + .on('mouseleave', d => { + d3.select(`#event-rect-${x(d.target.__data__)}`).attr( + 'fill', + 'transparent', + ) + setSelectionDateInterval(null) + setSelectionData(null) + setSelectionCoords(null) + }), + [ + GRAPH_MARGIN, + groupedByDateInterval, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, + x, + ], + ) + + const buildEventRects = useCallback( + g => + g + .append('g') + .selectAll('line') + .data(getTickIntervals(x.domain(), 2)) + .join('rect') + .attr('id', d => `event-rect-${x(d)}`) + .attr('x', d => x(d)) + .attr('y', GRAPH_MARGIN.top) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const ticks = getTickIntervals(x.domain(), 2).map(it => x(it)) + + const index = R.findIndex(it => it === xValue, ticks) + const width = + index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index] + + return Math.round(width * 100) / 100 + }) + .attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top) + .attr('stroke', 'transparent') + .attr('fill', 'transparent'), + [GRAPH_MARGIN, x], + ) + + const formatTicksText = useCallback( + () => + d3 + .selectAll('.tick text') + .style('stroke', fontColor) + .style('fill', fontColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const drawCashIn = useCallback( + g => { + g.selectAll('rect') + .data(R.init(getTickIntervals(x.domain(), 2))) + .join('rect') + .attr('stroke', java) + .attr('fill', java) + .attr('x', d => { + return x(d) + BAR_MARGIN / 2 + }) + .attr('y', d => { + const interval = R.find(it => R.equals(it[0], d), groupedByTxClass) + return y(interval[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom + }) + .attr('height', d => { + const interval = R.find(it => R.equals(it[0], d), groupedByTxClass) + return R.clamp( + 0, + GRAPH_HEIGHT, + GRAPH_HEIGHT - + y(interval[1].cashIn) - + GRAPH_MARGIN.bottom - + BAR_MARGIN / 2, + ) + }) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const ticks = getTickIntervals(x.domain(), 2).map(it => x(it)) + + const index = R.findIndex(it => it === xValue, ticks) + const width = + index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index] + return Math.round((width - BAR_MARGIN) * 100) / 100 + }) + .attr('rx', 2.5) + }, + [x, y, GRAPH_MARGIN, groupedByTxClass], + ) + + const drawCashOut = useCallback( + g => { + g.selectAll('rect') + .data(R.init(getTickIntervals(x.domain(), 2))) + .join('rect') + .attr('stroke', neon) + .attr('fill', neon) + .attr('x', d => { + return x(d) + BAR_MARGIN / 2 + }) + .attr('y', d => { + const interval = R.find(it => R.equals(it[0], d), groupedByTxClass) + return ( + y(interval[1].cashIn + interval[1].cashOut) - + GRAPH_MARGIN.top + + GRAPH_MARGIN.bottom + ) + }) + .attr('height', d => { + const interval = R.find(it => R.equals(it[0], d), groupedByTxClass) + return R.clamp( + 0, + GRAPH_HEIGHT, + GRAPH_HEIGHT - + y(interval[1].cashOut) - + GRAPH_MARGIN.bottom - + BAR_MARGIN / 2, + ) + }) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const ticks = getTickIntervals(x.domain(), 2).map(it => x(it)) + + const index = R.findIndex(it => it === xValue, ticks) + const width = + index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index] + return Math.round((width - BAR_MARGIN) * 100) / 100 + }) + .attr('rx', 2.5) + }, + [x, y, GRAPH_MARGIN, groupedByTxClass], + ) + + const drawChart = useCallback(() => { + const svg = d3 + .select(ref.current) + .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT]) + + svg.append('g').call(buildXAxis) + svg.append('g').call(buildYAxis) + svg.append('g').call(buildVerticalLines) + svg.append('g').call(buildEventRects) + svg.append('g').call(formatTicksText) + svg.append('g').call(drawCashIn) + svg.append('g').call(drawCashOut) + svg.append('g').call(buildHoverableEventRects) + + return svg.node() + }, [ + buildXAxis, + buildYAxis, + buildEventRects, + buildHoverableEventRects, + buildVerticalLines, + drawCashIn, + formatTicksText, + drawCashOut, + ]) + + useEffect(() => { + d3.select(ref.current).selectAll('*').remove() + drawChart() + }, [drawChart]) + + return +} + +export default memo( + Graph, + (prev, next) => + R.equals(prev.period, next.period) && + R.equals(prev.selectedDay, next.selectedDay) && + R.equals(prev.selectedMachine, next.selectedMachine), +) diff --git a/packages/admin-ui/src/pages/Analytics/graphs/OverTimeDotGraph.jsx b/packages/admin-ui/src/pages/Analytics/graphs/OverTimeDotGraph.jsx new file mode 100644 index 0000000..168c80b --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/graphs/OverTimeDotGraph.jsx @@ -0,0 +1,570 @@ +import BigNumber from 'bignumber.js' +import * as d3 from 'd3' +import { getTimezoneOffset } from 'date-fns-tz' +import { add, format, startOfWeek, startOfYear } from 'date-fns/fp' +import * as R from 'ramda' +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { + java, + neon, + subheaderDarkColor, + offColor, + fontColor, + primaryColor, + fontSecondary, + subheaderColor, +} from '../../../styling/variables' +import { numberToFiatAmount } from '../../../utils/number' +import { MINUTE, DAY, WEEK, MONTH } from '../../../utils/time' + +const Graph = ({ + data, + period, + timezone, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, + log = false, +}) => { + const ref = useRef(null) + + const GRAPH_POPOVER_WIDTH = 150 + const GRAPH_POPOVER_MARGIN = 25 + const GRAPH_HEIGHT = 401 + const GRAPH_WIDTH = 1163 + const GRAPH_MARGIN = useMemo( + () => ({ + top: 25, + right: 3.5, + bottom: 27, + left: 38, + }), + [], + ) + + const offset = getTimezoneOffset(timezone) + const NOW = Date.now() + offset + + const periodDomains = { + day: [NOW - DAY, NOW], + threeDays: [NOW - 3 * DAY, NOW], + week: [NOW - WEEK, NOW], + month: [NOW - MONTH, NOW], + } + + const dataPoints = useMemo( + () => ({ + day: { + freq: 24, + step: 60 * 60 * 1000, + tick: d3.utcHour.every(1), + labelFormat: '%H:%M', + }, + threeDays: { + freq: 12, + step: 6 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%a %d', + }, + week: { + freq: 7, + step: 24 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%a %d', + }, + month: { + freq: 30, + step: 24 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%d', + }, + }), + [], + ) + + const getPastAndCurrentDayLabels = useCallback(d => { + const currentDate = new Date(d) + const currentDateDay = currentDate.getUTCDate() + const currentDateWeekday = currentDate.getUTCDay() + const currentDateMonth = currentDate.getUTCMonth() + + const previousDate = new Date(currentDate.getTime()) + previousDate.setUTCDate(currentDateDay - 1) + + const previousDateDay = previousDate.getUTCDate() + const previousDateWeekday = previousDate.getUTCDay() + const previousDateMonth = previousDate.getUTCMonth() + + const daysOfWeek = Array.from(Array(7)).map((_, i) => + format('EEE', add({ days: i }, startOfWeek(new Date()))), + ) + + const months = Array.from(Array(12)).map((_, i) => + format('LLL', add({ months: i }, startOfYear(new Date()))), + ) + + return { + previous: + currentDateMonth !== previousDateMonth + ? months[previousDateMonth] + : `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`, + current: + currentDateMonth !== previousDateMonth + ? months[currentDateMonth] + : `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`, + } + }, []) + + const buildTicks = useCallback( + domain => { + const points = [] + + const roundDate = d => { + const step = dataPoints[period.code].step + return new Date(Math.ceil(d.valueOf() / step) * step) + } + + for (let i = 0; i <= dataPoints[period.code].freq; i++) { + const stepDate = new Date(NOW - i * dataPoints[period.code].step) + if (roundDate(stepDate) > domain[1]) continue + if (stepDate < domain[0]) continue + points.push(roundDate(stepDate)) + } + + return points + }, + [NOW, dataPoints, period.code], + ) + + const buildAreas = useCallback( + domain => { + const points = [] + + points.push(domain[1]) + + const roundDate = d => { + const step = dataPoints[period.code].step + return new Date(Math.ceil(d.valueOf() / step) * step) + } + + for (let i = 0; i <= dataPoints[period.code].freq; i++) { + const stepDate = new Date(NOW - i * dataPoints[period.code].step) + if (roundDate(stepDate) > new Date(domain[1])) continue + if (stepDate < new Date(domain[0])) continue + points.push(roundDate(stepDate)) + } + + points.push(domain[0]) + + return points + }, + [NOW, dataPoints, period.code], + ) + + const x = d3 + .scaleUtc() + .domain(periodDomains[period.code]) + .range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right]) + + // Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain + const x2 = d3 + .scaleUtc() + .domain(periodDomains[period.code]) + .range([GRAPH_MARGIN.left, GRAPH_WIDTH]) + + const yLin = d3 + .scaleLinear() + .domain([ + 0, + (d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.03, + ]) + .nice() + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const yLog = d3 + .scaleLog() + .domain([ + (d3.min(data, d => new BigNumber(d.fiat).toNumber()) ?? 1) * 0.9, + (d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.1, + ]) + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const y = log ? yLog : yLin + + const getAreaInterval = (breakpoints, dataLimits, graphLimits) => { + const fullBreakpoints = [ + graphLimits[1], + ...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints), + dataLimits[0], + ] + + const intervals = [] + for (let i = 0; i < fullBreakpoints.length - 1; i++) { + intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]]) + } + + return intervals + } + + const getAreaIntervalByX = (intervals, xValue) => { + return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0] + } + + const getDateIntervalByX = (areas, intervals, xValue) => { + const flattenIntervals = R.uniq(R.flatten(intervals)) + + // flattenIntervals and areas should have the same number of elements + for (let i = intervals.length - 1; i >= 0; i--) { + if (xValue < flattenIntervals[i]) { + return [areas[i], areas[i + 1]] + } + } + } + + const buildXAxis = useCallback( + g => + g + .attr( + 'transform', + `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`, + ) + .call( + d3 + .axisBottom(x) + .ticks(dataPoints[period.code].tick) + .tickFormat(d => { + return d3.timeFormat(dataPoints[period.code].labelFormat)( + d.getTime() + d.getTimezoneOffset() * MINUTE, + ) + }) + .tickSizeOuter(0), + ) + .call(g => + g + .select('.domain') + .attr('stroke', primaryColor) + .attr('stroke-width', 1), + ), + [GRAPH_MARGIN, dataPoints, period.code, x], + ) + + const buildYAxis = useCallback( + g => + g + .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) + .call( + d3 + .axisLeft(y) + .ticks(GRAPH_HEIGHT / 100) + .tickSizeOuter(0) + .tickFormat(d => { + if (log && !['1', '2', '5'].includes(d.toString()[0])) return '' + + if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k' + + return numberToFiatAmount(d) + }), + ) + .select('.domain') + .attr('stroke', primaryColor) + .attr('stroke-width', 1), + [GRAPH_MARGIN, y, log], + ) + + const buildGrid = useCallback( + g => { + g.attr('stroke', subheaderDarkColor) + .attr('fill', subheaderDarkColor) + // Vertical lines + .call(g => + g + .append('g') + .selectAll('line') + .data(buildTicks(x.domain())) + .join('line') + .attr('x1', d => 0.5 + x(d)) + .attr('x2', d => 0.5 + x(d)) + .attr('y1', GRAPH_MARGIN.top) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom), + ) + // Horizontal lines + .call(g => + g + .append('g') + .selectAll('line') + .data( + d3 + .axisLeft(y) + .scale() + .ticks(GRAPH_HEIGHT / 100), + ) + .join('line') + .attr('y1', d => 0.5 + y(d)) + .attr('y2', d => 0.5 + y(d)) + .attr('x1', GRAPH_MARGIN.left) + .attr('x2', GRAPH_WIDTH), + ) + // Vertical transparent rectangles for events + .call(g => + g + .append('g') + .selectAll('line') + .data(buildAreas(x.domain())) + .join('rect') + .attr('x', d => x(d)) + .attr('y', GRAPH_MARGIN.top) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const intervals = getAreaInterval( + buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100), + x.range(), + x2.range(), + ) + const interval = getAreaIntervalByX(intervals, xValue) + return Math.round((interval[0] - interval[1]) * 100) / 100 + }) + .attr( + 'height', + GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top, + ) + .attr('stroke', 'transparent') + .attr('fill', 'transparent') + .on('mouseover', d => { + const xValue = Math.round(d.target.x.baseVal.value * 100) / 100 + const areas = buildAreas(x.domain()) + const intervals = getAreaInterval( + buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100), + x.range(), + x2.range(), + ) + + const dateInterval = getDateIntervalByX(areas, intervals, xValue) + if (!dateInterval) return + const filteredData = data.filter(it => { + const created = new Date(it.created) + const tzCreated = created.setTime(created.getTime() + offset) + return ( + tzCreated > new Date(dateInterval[1]) && + tzCreated <= new Date(dateInterval[0]) + ) + }) + + const rectXCoords = { + left: R.clone(d.target.getBoundingClientRect().x), + right: R.clone( + d.target.getBoundingClientRect().x + + d.target.getBoundingClientRect().width, + ), + } + + const xCoord = + d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH + ? rectXCoords.right + GRAPH_POPOVER_MARGIN + : rectXCoords.left - + GRAPH_POPOVER_WIDTH - + GRAPH_POPOVER_MARGIN + const yCoord = R.clone(d.target.getBoundingClientRect().y) + + setSelectionDateInterval(dateInterval) + setSelectionData(filteredData) + setSelectionCoords({ + x: Math.round(xCoord), + y: Math.round(yCoord), + }) + + d3.select(d.target).attr('fill', subheaderColor) + }) + .on('mouseleave', d => { + d3.select(d.target).attr('fill', 'transparent') + setSelectionDateInterval(null) + setSelectionData(null) + setSelectionCoords(null) + }), + ) + // Thick vertical lines + .call(g => + g + .append('g') + .selectAll('line') + .data( + buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }), + ) + .join('line') + .attr('class', 'dateSeparator') + .attr('x1', d => 0.5 + x(d)) + .attr('x2', d => 0.5 + x(d)) + .attr('y1', GRAPH_MARGIN.top - 50) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom) + .attr('stroke-width', 5) + .join('text'), + ) + // Left side breakpoint label + .call(g => { + const separator = d3?.select('.dateSeparator')?.node()?.getBBox() + + if (!separator) return + + const breakpoint = buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }) + + const labels = getPastAndCurrentDayLabels(breakpoint) + + return g + .append('text') + .attr('x', separator.x - 10) + .attr('y', separator.y + 33) + .attr('text-anchor', 'end') + .attr('dy', '.25em') + .text(labels.previous) + }) + // Right side breakpoint label + .call(g => { + const separator = d3?.select('.dateSeparator')?.node()?.getBBox() + + if (!separator) return + + const breakpoint = buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }) + + const labels = getPastAndCurrentDayLabels(breakpoint) + + return g + .append('text') + .attr('x', separator.x + 10) + .attr('y', separator.y + 33) + .attr('text-anchor', 'start') + .attr('dy', '.25em') + .text(labels.current) + }) + }, + [ + GRAPH_MARGIN, + buildTicks, + getPastAndCurrentDayLabels, + x, + x2, + y, + period, + buildAreas, + data, + offset, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, + ], + ) + + const formatTicksText = useCallback( + () => + d3 + .selectAll('.tick text') + .style('stroke', fontColor) + .style('fill', fontColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const formatText = useCallback( + () => + d3 + .selectAll('text') + .style('stroke', offColor) + .style('fill', offColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const formatTicks = useCallback(() => { + d3.selectAll('.tick line') + .style('stroke', primaryColor) + .style('fill', primaryColor) + }, []) + + const buildAvg = useCallback( + g => { + const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0 + + if (log && median === 0) return + + g.attr('stroke', primaryColor) + .attr('stroke-width', 3) + .attr('stroke-dasharray', '10, 5') + .call(g => + g + .append('line') + .attr('y1', 0.5 + y(median)) + .attr('y2', 0.5 + y(median)) + .attr('x1', GRAPH_MARGIN.left) + .attr('x2', GRAPH_WIDTH), + ) + }, + [GRAPH_MARGIN, y, data, log], + ) + + const drawData = useCallback( + g => { + g.selectAll('circle') + .data(data) + .join('circle') + .attr('cx', d => { + const created = new Date(d.created) + return x(created.setTime(created.getTime() + offset)) + }) + .attr('cy', d => y(new BigNumber(d.fiat).toNumber())) + .attr('fill', d => (d.txClass === 'cashIn' ? java : neon)) + .attr('r', 3.5) + }, + [data, offset, x, y], + ) + + const drawChart = useCallback(() => { + const svg = d3 + .select(ref.current) + .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT]) + + svg.append('g').call(buildGrid) + svg.append('g').call(buildAvg) + svg.append('g').call(buildXAxis) + svg.append('g').call(buildYAxis) + svg.append('g').call(formatTicksText) + svg.append('g').call(formatText) + svg.append('g').call(formatTicks) + svg.append('g').call(drawData) + + return svg.node() + }, [ + buildAvg, + buildGrid, + buildXAxis, + buildYAxis, + drawData, + formatText, + formatTicks, + formatTicksText, + ]) + + useEffect(() => { + d3.select(ref.current).selectAll('*').remove() + drawChart() + }, [drawChart]) + + return +} + +export default memo( + Graph, + (prev, next) => + R.equals(prev.period, next.period) && + R.equals(prev.selectedMachine, next.selectedMachine) && + R.equals(prev.log, next.log), +) diff --git a/packages/admin-ui/src/pages/Analytics/graphs/OverTimeLineGraph.jsx b/packages/admin-ui/src/pages/Analytics/graphs/OverTimeLineGraph.jsx new file mode 100644 index 0000000..988e5ac --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/graphs/OverTimeLineGraph.jsx @@ -0,0 +1,646 @@ +import BigNumber from 'bignumber.js' +import * as d3 from 'd3' +import { getTimezoneOffset } from 'date-fns-tz' +import { + add, + addMilliseconds, + compareDesc, + differenceInMilliseconds, + format, + startOfWeek, + startOfYear, +} from 'date-fns/fp' +import * as R from 'ramda' +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { + java, + neon, + subheaderDarkColor, + offColor, + fontColor, + primaryColor, + fontSecondary, + subheaderColor, +} from '../../../styling/variables' +import { numberToFiatAmount } from '../../../utils/number' +import { MINUTE, DAY, WEEK, MONTH } from '../../../utils/time' + +const Graph = ({ + data, + period, + timezone, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, + log = false, +}) => { + const ref = useRef(null) + + const GRAPH_POPOVER_WIDTH = 150 + const GRAPH_POPOVER_MARGIN = 25 + const GRAPH_HEIGHT = 401 + const GRAPH_WIDTH = 1163 + const GRAPH_MARGIN = useMemo( + () => ({ + top: 25, + right: 3.5, + bottom: 27, + left: 38, + }), + [], + ) + + const offset = getTimezoneOffset(timezone) + const NOW = Date.now() + offset + + const periodDomains = { + day: [NOW - DAY, NOW], + threeDays: [NOW - 3 * DAY, NOW], + week: [NOW - WEEK, NOW], + month: [NOW - MONTH, NOW], + } + + const dataPoints = useMemo( + () => ({ + day: { + freq: 24, + step: 60 * 60 * 1000, + tick: d3.utcHour.every(1), + labelFormat: '%H:%M', + }, + threeDays: { + freq: 12, + step: 6 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%a %d', + }, + week: { + freq: 7, + step: 24 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%a %d', + }, + month: { + freq: 30, + step: 24 * 60 * 60 * 1000, + tick: d3.utcDay.every(1), + labelFormat: '%d', + }, + }), + [], + ) + + const getPastAndCurrentDayLabels = useCallback(d => { + const currentDate = new Date(d) + const currentDateDay = currentDate.getUTCDate() + const currentDateWeekday = currentDate.getUTCDay() + const currentDateMonth = currentDate.getUTCMonth() + + const previousDate = new Date(currentDate.getTime()) + previousDate.setUTCDate(currentDateDay - 1) + + const previousDateDay = previousDate.getUTCDate() + const previousDateWeekday = previousDate.getUTCDay() + const previousDateMonth = previousDate.getUTCMonth() + + const daysOfWeek = Array.from(Array(7)).map((_, i) => + format('EEE', add({ days: i }, startOfWeek(new Date()))), + ) + + const months = Array.from(Array(12)).map((_, i) => + format('LLL', add({ months: i }, startOfYear(new Date()))), + ) + + return { + previous: + currentDateMonth !== previousDateMonth + ? months[previousDateMonth] + : `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`, + current: + currentDateMonth !== previousDateMonth + ? months[currentDateMonth] + : `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`, + } + }, []) + + const buildTicks = useCallback( + domain => { + const points = [] + + const roundDate = d => { + const step = dataPoints[period.code].step + return new Date(Math.ceil(d.valueOf() / step) * step) + } + + for (let i = 0; i <= dataPoints[period.code].freq; i++) { + const stepDate = new Date(NOW - i * dataPoints[period.code].step) + if (roundDate(stepDate) > domain[1]) continue + if (stepDate < domain[0]) continue + points.push(roundDate(stepDate)) + } + + return points + }, + [NOW, dataPoints, period.code], + ) + + const buildAreas = useCallback( + domain => { + const points = [] + + points.push(domain[1]) + + const roundDate = d => { + const step = dataPoints[period.code].step + return new Date(Math.ceil(d.valueOf() / step) * step) + } + + for (let i = 0; i <= dataPoints[period.code].freq; i++) { + const stepDate = new Date(NOW - i * dataPoints[period.code].step) + if (roundDate(stepDate) > new Date(domain[1])) continue + if (stepDate < new Date(domain[0])) continue + points.push(roundDate(stepDate)) + } + + points.push(domain[0]) + + return points + }, + [NOW, dataPoints, period.code], + ) + + const x = d3 + .scaleUtc() + .domain(periodDomains[period.code]) + .range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right]) + + // Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain + const x2 = d3 + .scaleUtc() + .domain(periodDomains[period.code]) + .range([GRAPH_MARGIN.left, GRAPH_WIDTH]) + + const bins = buildAreas(x.domain()) + .sort((a, b) => compareDesc(a.date, b.date)) + .map(addMilliseconds(-dataPoints[period.code].step)) + .map((date, i, dates) => { + // move first and last bin in such way + // that all bin have uniform width + if (i === 0) + return addMilliseconds(dataPoints[period.code].step, dates[1]) + else if (i === dates.length - 1) + return addMilliseconds( + -dataPoints[period.code].step, + dates[dates.length - 2], + ) + else return date + }) + .map(date => { + const middleOfBin = addMilliseconds( + dataPoints[period.code].step / 2, + date, + ) + + const txs = data.filter(tx => { + const txCreated = new Date(tx.created) + const shift = new Date(txCreated.getTime() + offset) + + return ( + Math.abs(differenceInMilliseconds(shift, middleOfBin)) < + dataPoints[period.code].step / 2 + ) + }) + + const cashIn = txs + .filter(tx => tx.txClass === 'cashIn') + .reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0) + + const cashOut = txs + .filter(tx => tx.txClass === 'cashOut') + .reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0) + + return { date: middleOfBin, cashIn, cashOut } + }) + + const min = d3.min(bins, d => Math.min(d.cashIn, d.cashOut)) ?? 0 + const max = d3.max(bins, d => Math.max(d.cashIn, d.cashOut)) ?? 1000 + + const yLin = d3 + .scaleLinear() + .domain([0, (max === min ? min + 1000 : max) * 1.03]) + .nice() + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const yLog = d3 + .scaleLog() + .domain([ + min === 0 ? 0.9 : min * 0.9, + (max === min ? min + Math.pow(10, 2 * min + 1) : max) * 2, + ]) + .clamp(true) + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const y = log ? yLog : yLin + + const getAreaInterval = (breakpoints, dataLimits, graphLimits) => { + const fullBreakpoints = [ + graphLimits[1], + ...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints), + dataLimits[0], + ] + + const intervals = [] + for (let i = 0; i < fullBreakpoints.length - 1; i++) { + intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]]) + } + + return intervals + } + + const getAreaIntervalByX = (intervals, xValue) => { + return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0] + } + + const getDateIntervalByX = (areas, intervals, xValue) => { + const flattenIntervals = R.uniq(R.flatten(intervals)) + + // flattenIntervals and areas should have the same number of elements + for (let i = intervals.length - 1; i >= 0; i--) { + if (xValue < flattenIntervals[i]) { + return [areas[i], areas[i + 1]] + } + } + } + + const buildXAxis = useCallback( + g => + g + .attr( + 'transform', + `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`, + ) + .call( + d3 + .axisBottom(x) + .ticks(dataPoints[period.code].tick) + .tickFormat(d => { + return d3.timeFormat(dataPoints[period.code].labelFormat)( + d.getTime() + d.getTimezoneOffset() * MINUTE, + ) + }) + .tickSizeOuter(0), + ) + .call(g => + g + .select('.domain') + .attr('stroke', primaryColor) + .attr('stroke-width', 1), + ), + [GRAPH_MARGIN, dataPoints, period.code, x], + ) + + const buildYAxis = useCallback( + g => + g + .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) + .call( + d3 + .axisLeft(y) + .ticks(GRAPH_HEIGHT / 100) + .tickSizeOuter(0) + .tickFormat(d => { + if (log && !['1', '2', '5'].includes(d.toString()[0])) return '' + + if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k' + + return numberToFiatAmount(d) + }), + ) + .select('.domain') + .attr('stroke', primaryColor) + .attr('stroke-width', 1), + [GRAPH_MARGIN, y, log], + ) + + const buildGrid = useCallback( + g => { + g.attr('stroke', subheaderDarkColor) + .attr('fill', subheaderDarkColor) + // Vertical lines + .call(g => + g + .append('g') + .selectAll('line') + .data(buildTicks(x.domain())) + .join('line') + .attr('x1', d => 0.5 + x(d)) + .attr('x2', d => 0.5 + x(d)) + .attr('y1', GRAPH_MARGIN.top) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom), + ) + // Horizontal lines + .call(g => + g + .append('g') + .selectAll('line') + .data( + d3 + .axisLeft(y) + .scale() + .ticks(GRAPH_HEIGHT / 100), + ) + .join('line') + .attr('y1', d => 0.5 + y(d)) + .attr('y2', d => 0.5 + y(d)) + .attr('x1', GRAPH_MARGIN.left) + .attr('x2', GRAPH_WIDTH), + ) + // Vertical transparent rectangles for events + .call(g => + g + .append('g') + .selectAll('line') + .data(buildAreas(x.domain())) + .join('rect') + .attr('x', d => x(d)) + .attr('y', GRAPH_MARGIN.top) + .attr('width', d => { + const xValue = Math.round(x(d) * 100) / 100 + const intervals = getAreaInterval( + buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100), + x.range(), + x2.range(), + ) + const interval = getAreaIntervalByX(intervals, xValue) + return Math.round((interval[0] - interval[1]) * 100) / 100 + }) + .attr( + 'height', + GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top, + ) + .attr('stroke', 'transparent') + .attr('fill', 'transparent') + .on('mouseover', d => { + const xValue = Math.round(d.target.x.baseVal.value * 100) / 100 + const areas = buildAreas(x.domain()) + const intervals = getAreaInterval( + buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100), + x.range(), + x2.range(), + ) + + const dateInterval = getDateIntervalByX(areas, intervals, xValue) + if (!dateInterval) return + const filteredData = data.filter(it => { + const created = new Date(it.created) + const tzCreated = created.setTime(created.getTime() + offset) + return ( + tzCreated > new Date(dateInterval[1]) && + tzCreated <= new Date(dateInterval[0]) + ) + }) + + const rectXCoords = { + left: R.clone(d.target.getBoundingClientRect().x), + right: R.clone( + d.target.getBoundingClientRect().x + + d.target.getBoundingClientRect().width, + ), + } + + const xCoord = + d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH + ? rectXCoords.right + GRAPH_POPOVER_MARGIN + : rectXCoords.left - + GRAPH_POPOVER_WIDTH - + GRAPH_POPOVER_MARGIN + const yCoord = R.clone(d.target.getBoundingClientRect().y) + + setSelectionDateInterval(dateInterval) + setSelectionData(filteredData) + setSelectionCoords({ + x: Math.round(xCoord), + y: Math.round(yCoord), + }) + + d3.select(d.target).attr('fill', subheaderColor) + }) + .on('mouseleave', d => { + d3.select(d.target).attr('fill', 'transparent') + setSelectionDateInterval(null) + setSelectionData(null) + setSelectionCoords(null) + }), + ) + // Thick vertical lines + .call(g => + g + .append('g') + .selectAll('line') + .data( + buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }), + ) + .join('line') + .attr('class', 'dateSeparator') + .attr('x1', d => 0.5 + x(d)) + .attr('x2', d => 0.5 + x(d)) + .attr('y1', GRAPH_MARGIN.top - 50) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom) + .attr('stroke-width', 5) + .join('text'), + ) + // Left side breakpoint label + .call(g => { + const separator = d3?.select('.dateSeparator')?.node()?.getBBox() + + if (!separator) return + + const breakpoint = buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }) + + const labels = getPastAndCurrentDayLabels(breakpoint) + + return g + .append('text') + .attr('x', separator.x - 10) + .attr('y', separator.y + 33) + .attr('text-anchor', 'end') + .attr('dy', '.25em') + .text(labels.previous) + }) + // Right side breakpoint label + .call(g => { + const separator = d3?.select('.dateSeparator')?.node()?.getBBox() + + if (!separator) return + + const breakpoint = buildTicks(x.domain()).filter(x => { + if (period.code === 'day') return x.getUTCHours() === 0 + return x.getUTCDate() === 1 + }) + + const labels = getPastAndCurrentDayLabels(breakpoint) + + return g + .append('text') + .attr('x', separator.x + 10) + .attr('y', separator.y + 33) + .attr('text-anchor', 'start') + .attr('dy', '.25em') + .text(labels.current) + }) + }, + [ + GRAPH_MARGIN, + buildTicks, + getPastAndCurrentDayLabels, + x, + x2, + y, + period, + buildAreas, + data, + offset, + setSelectionCoords, + setSelectionData, + setSelectionDateInterval, + ], + ) + + const formatTicksText = useCallback( + () => + d3 + .selectAll('.tick text') + .style('stroke', fontColor) + .style('fill', fontColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const formatText = useCallback( + () => + d3 + .selectAll('text') + .style('stroke', offColor) + .style('fill', offColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const formatTicks = useCallback(() => { + d3.selectAll('.tick line') + .style('stroke', primaryColor) + .style('fill', primaryColor) + }, []) + + const drawData = useCallback( + g => { + g.append('clipPath') + .attr('id', 'clip-path') + .append('rect') + .attr('x', GRAPH_MARGIN.left) + .attr('y', GRAPH_MARGIN.top) + .attr('width', GRAPH_WIDTH) + .attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top) + .attr('fill', java) + + g.append('g') + .attr('clip-path', 'url(#clip-path)') + .selectAll('circle .cashIn') + .data(bins) + .join('circle') + .attr('cx', d => x(d.date)) + .attr('cy', d => y(d.cashIn)) + .attr('fill', java) + .attr('r', d => (d.cashIn === 0 ? 0 : 3.5)) + + g.append('path') + .datum(bins) + .attr('fill', 'none') + .attr('stroke', java) + .attr('stroke-width', 3) + .attr('clip-path', 'url(#clip-path)') + .attr( + 'd', + d3 + .line() + .curve(d3.curveMonotoneX) + .x(d => x(d.date)) + .y(d => y(d.cashIn)), + ) + + g.append('g') + .attr('clip-path', 'url(#clip-path)') + .selectAll('circle .cashIn') + .data(bins) + .join('circle') + .attr('cx', d => x(d.date)) + .attr('cy', d => y(d.cashOut)) + .attr('fill', neon) + .attr('r', d => (d.cashOut === 0 ? 0 : 3.5)) + + g.append('path') + .datum(bins) + .attr('fill', 'none') + .attr('stroke', neon) + .attr('stroke-width', 3) + .attr('clip-path', 'url(#clip-path)') + .attr( + 'd', + d3 + .line() + .curve(d3.curveMonotoneX) + .x(d => x(d.date)) + .y(d => y(d.cashOut)), + ) + }, + [x, y, bins, GRAPH_MARGIN], + ) + + const drawChart = useCallback(() => { + const svg = d3 + .select(ref.current) + .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT]) + + svg.append('g').call(buildGrid) + svg.append('g').call(drawData) + svg.append('g').call(buildXAxis) + svg.append('g').call(buildYAxis) + svg.append('g').call(formatTicksText) + svg.append('g').call(formatText) + svg.append('g').call(formatTicks) + + return svg.node() + }, [ + buildGrid, + buildXAxis, + buildYAxis, + drawData, + formatText, + formatTicks, + formatTicksText, + ]) + + useEffect(() => { + d3.select(ref.current).selectAll('*').remove() + drawChart() + }, [drawChart]) + + return +} + +export default memo( + Graph, + (prev, next) => + R.equals(prev.period, next.period) && + R.equals(prev.selectedMachine, next.selectedMachine) && + R.equals(prev.log, next.log), +) diff --git a/packages/admin-ui/src/pages/Analytics/graphs/TopMachinesBarGraph.jsx b/packages/admin-ui/src/pages/Analytics/graphs/TopMachinesBarGraph.jsx new file mode 100644 index 0000000..dc580c0 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/graphs/TopMachinesBarGraph.jsx @@ -0,0 +1,316 @@ +import BigNumber from 'bignumber.js' +import * as d3 from 'd3' +import * as R from 'ramda' +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { + java, + neon, + subheaderDarkColor, + fontColor, + fontSecondary, +} from '../../../styling/variables' + +const Graph = ({ data, machines, currency }) => { + const ref = useRef(null) + + const AMOUNT_OF_MACHINES = 5 + const BAR_PADDING = 0.15 + const BAR_MARGIN = 10 + const GRAPH_HEIGHT = 401 + const GRAPH_WIDTH = 1163 + const GRAPH_MARGIN = useMemo( + () => ({ + top: 25, + right: 0.5, + bottom: 27, + left: 36.5, + }), + [], + ) + + const machinesClone = R.clone(machines) + + // This ensures that the graph renders a minimum amount of machines + // and avoids having a single bar for cases with one machine + const filledMachines = + R.length(machines) >= AMOUNT_OF_MACHINES + ? machinesClone + : R.map( + it => { + if (!R.isNil(machinesClone[it])) return machinesClone[it] + return { code: `ghostMachine${it}`, display: `` } + }, + R.times(R.identity, AMOUNT_OF_MACHINES), + ) + + const txByDevice = R.reduce( + (acc, value) => { + acc[value.code] = R.filter(it => it.deviceId === value.code, data) + return acc + }, + {}, + filledMachines, + ) + + const getDeviceVolume = deviceId => + R.reduce( + (acc, value) => acc + BigNumber(value.fiat).toNumber(), + 0, + txByDevice[deviceId], + ) + + const getDeviceVolumeByTxClass = deviceId => + R.reduce( + (acc, value) => { + if (value.txClass === 'cashIn') + acc.cashIn += BigNumber(value.fiat).toNumber() + if (value.txClass === 'cashOut') + acc.cashOut += BigNumber(value.fiat).toNumber() + return acc + }, + { cashIn: 0, cashOut: 0 }, + txByDevice[deviceId], + ) + + const devicesByVolume = R.sort( + (a, b) => b[1] - a[1], + R.map(m => [m.code, getDeviceVolume(m.code)], filledMachines), + ) + + const topMachines = R.take(AMOUNT_OF_MACHINES, devicesByVolume) + + const txClassVolumeByDevice = R.fromPairs( + R.map(v => [v[0], getDeviceVolumeByTxClass(v[0])], topMachines), + ) + + const x = d3 + .scaleBand() + .domain(topMachines) + .range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right]) + .paddingInner(BAR_PADDING) + + const y = d3 + .scaleLinear() + .domain([ + 0, + d3.max(topMachines, d => d[1]) !== 0 + ? d3.max(topMachines, d => d[1]) + : 50, + ]) + .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) + + const buildXAxis = useCallback( + g => + g + .attr('class', 'x-axis-1') + .attr( + 'transform', + `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`, + ) + .call( + d3 + .axisBottom(x) + .tickFormat( + d => + `${ + R.find(it => it.code === d[0], filledMachines).display ?? '' + }`, + ) + .tickSize(0) + .tickPadding(10), + ), + [GRAPH_MARGIN, x, filledMachines], + ) + + const buildXAxis2 = useCallback( + g => { + g.attr('class', 'x-axis-2') + .attr( + 'transform', + `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`, + ) + .call( + d3 + .axisBottom(x) + .tickFormat(d => + R.includes(`ghostMachine`, d[0]) + ? `` + : `${d[1].toFixed(2)} ${currency}`, + ) + .tickSize(0) + .tickPadding(10), + ) + }, + [GRAPH_MARGIN, x, currency], + ) + + const positionXAxisLabels = useCallback(() => { + d3.selectAll('.x-axis-1 .tick text').attr('transform', function () { + const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES + return `translate(${-widthPerEntry / 2.25 + this.getBBox().width / 2}, 0)` + }) + }, [x]) + + const positionXAxis2Labels = useCallback(() => { + d3.selectAll('.x-axis-2 .tick text').attr('transform', function () { + const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES + return `translate(${widthPerEntry / 2.25 - this.getBBox().width / 2}, 0)` + }) + }, [x]) + + const buildYAxis = useCallback( + g => + g + .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) + .call( + d3 + .axisLeft(y) + .ticks(GRAPH_HEIGHT / 100) + .tickSize(0) + .tickFormat(``), + ) + .call(g => g.select('.domain').remove()), + [GRAPH_MARGIN, y], + ) + + const formatTicksText = useCallback( + () => + d3 + .selectAll('.tick text') + .style('stroke', fontColor) + .style('fill', fontColor) + .style('stroke-width', 0.5) + .style('font-family', fontSecondary), + [], + ) + + const buildGrid = useCallback( + g => { + g.attr('stroke', subheaderDarkColor) + .attr('fill', subheaderDarkColor) + // Vertical lines + .call(g => + g + .append('g') + .selectAll('line') + .data(R.tail(x.domain())) + .join('line') + .attr('x1', d => { + const domainIndex = R.findIndex(it => R.equals(it, d), x.domain()) + + const xValue = + x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1]) + + const paddedXValue = xValue * (BAR_PADDING / 2) + return 0.5 + x(d) - paddedXValue + }) + .attr('x2', d => { + const domainIndex = R.findIndex(it => R.equals(it, d), x.domain()) + + const xValue = + x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1]) + + const paddedXValue = xValue * (BAR_PADDING / 2) + return 0.5 + x(d) - paddedXValue + }) + .attr('y1', GRAPH_MARGIN.top) + .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom), + ) + }, + [GRAPH_MARGIN, x], + ) + + const drawCashIn = useCallback( + g => { + g.selectAll('rect') + .data(R.toPairs(txClassVolumeByDevice)) + .join('rect') + .attr('fill', java) + .attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut])) + .attr('y', d => y(d[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom) + .attr('height', d => + R.clamp( + 0, + GRAPH_HEIGHT, + GRAPH_HEIGHT - y(d[1].cashIn) - GRAPH_MARGIN.bottom - BAR_MARGIN, + ), + ) + .attr('width', x.bandwidth()) + .attr('rx', 2.5) + }, + [txClassVolumeByDevice, x, y, GRAPH_MARGIN], + ) + + const drawCashOut = useCallback( + g => { + g.selectAll('rect') + .data(R.toPairs(txClassVolumeByDevice)) + .join('rect') + .attr('fill', neon) + .attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut])) + .attr( + 'y', + d => + y(d[1].cashIn + d[1].cashOut) - + GRAPH_MARGIN.top + + GRAPH_MARGIN.bottom, + ) + .attr('height', d => { + return R.clamp( + 0, + GRAPH_HEIGHT, + GRAPH_HEIGHT - + y(d[1].cashOut) - + GRAPH_MARGIN.bottom - + BAR_MARGIN / 2, + ) + }) + .attr('width', x.bandwidth()) + .attr('rx', 2.5) + }, + [txClassVolumeByDevice, x, y, GRAPH_MARGIN], + ) + + const drawChart = useCallback(() => { + const svg = d3 + .select(ref.current) + .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT]) + + svg.append('g').call(buildXAxis) + svg.append('g').call(buildXAxis2) + svg.append('g').call(buildYAxis) + svg.append('g').call(formatTicksText) + svg.append('g').call(buildGrid) + svg.append('g').call(drawCashIn) + svg.append('g').call(drawCashOut) + svg.append('g').call(positionXAxisLabels) + svg.append('g').call(positionXAxis2Labels) + + return svg.node() + }, [ + buildXAxis, + buildXAxis2, + positionXAxisLabels, + positionXAxis2Labels, + buildYAxis, + formatTicksText, + buildGrid, + drawCashIn, + drawCashOut, + ]) + + useEffect(() => { + d3.select(ref.current).selectAll('*').remove() + drawChart() + }, [drawChart]) + + return +} + +export default memo( + Graph, + (prev, next) => + R.equals(prev.period, next.period) && + R.equals(prev.selectedMachine, next.selectedMachine), +) diff --git a/packages/admin-ui/src/pages/Analytics/index.js b/packages/admin-ui/src/pages/Analytics/index.js new file mode 100644 index 0000000..64db146 --- /dev/null +++ b/packages/admin-ui/src/pages/Analytics/index.js @@ -0,0 +1,3 @@ +import Analytics from './Analytics' + +export default Analytics diff --git a/packages/admin-ui/src/pages/Authentication/Authentication.module.css b/packages/admin-ui/src/pages/Authentication/Authentication.module.css new file mode 100644 index 0000000..57fedb4 --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Authentication.module.css @@ -0,0 +1,73 @@ +.welcomeBackground { + background: var(--ghost) url(/wizard-background.svg) no-repeat fixed center + center; + background-size: cover; + height: 100vh; + width: 100vw; + position: relative; + left: 50%; + right: 50%; + margin-left: -50vw; + margin-right: -50vw; + min-height: 100vh; +} + +.wrapper { + padding: 2.5em 4em; + width: 575px; + display: flex; + flex-direction: column; +} + +.titleWrapper { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 30px; +} + +.icon { + transform: scale(1.5); + margin-right: 25px; +} + +.title { + padding-top: 8px; +} + +.infoWrapper { + margin-bottom: 3vh; +} + +.info2 { + text-align: justify; +} + +.qrCodeWrapper { + display: flex; + justify-content: center; + margin-bottom: 3vh; +} + +.secretWrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.secretLabel { + margin-right: 15px; +} + +.secret { + margin-right: 35px; +} + +.hiddenSecret { + margin-right: 35px; + filter: blur(8px); +} + +.confirm2FAInput { + margin-top: 25px; +} diff --git a/packages/admin-ui/src/pages/Authentication/Input2FAState.jsx b/packages/admin-ui/src/pages/Authentication/Input2FAState.jsx new file mode 100644 index 0000000..1782e0f --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Input2FAState.jsx @@ -0,0 +1,125 @@ +import { useMutation, useLazyQuery, gql } from '@apollo/client' +import { Form, Formik } from 'formik' +import React, { useContext, useState } from 'react' +import { useLocation } from 'wouter' +import { TL1, P } from '../../components/typography' + +import AppContext from '../../AppContext' +import { Button } from '../../components/buttons' +import { CodeInput } from '../../components/inputs/base' + +import { STATES } from './states' + +const INPUT_2FA = gql` + mutation input2FA( + $username: String! + $password: String! + $code: String! + $rememberMe: Boolean! + ) { + input2FA( + username: $username + password: $password + code: $code + rememberMe: $rememberMe + ) + } +` + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + +const Input2FAState = ({ state, dispatch }) => { + const [, navigate] = useLocation() + const { setUserData } = useContext(AppContext) + + const [invalidToken, setInvalidToken] = useState(false) + + const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, { + onCompleted: ({ userData }) => { + setUserData(userData) + navigate('/') + }, + }) + + const [input2FA, { error: mutationError }] = useMutation(INPUT_2FA, { + onCompleted: ({ input2FA: success }) => { + if (success) { + return getUserData() + } + return setInvalidToken(true) + }, + }) + + const handle2FAChange = value => { + dispatch({ + type: STATES.INPUT_2FA, + payload: { + twoFAField: value, + }, + }) + setInvalidToken(false) + } + + const handleSubmit = () => { + if (state.twoFAField.length !== 6) { + setInvalidToken(true) + return + } + + const options = { + variables: { + username: state.clientField, + password: state.passwordField, + code: state.twoFAField, + rememberMe: state.rememberMeField, + }, + } + + input2FA(options) + } + + const getErrorMsg = () => { + if (queryError) return 'Internal server error' + if (state.twoFAField.length !== 6 && invalidToken) + return 'The code should have 6 characters!' + if (mutationError || invalidToken) + return 'Code is invalid. Please try again.' + return null + } + + const errorMessage = getErrorMsg() + + return ( + <> + Enter your two-factor authentication code + {/* TODO: refactor the 2FA CodeInput to properly use Formik */} + {}} initialValues={{}}> +
    + +
    + {errorMessage &&

    {errorMessage}

    } + +
    + +
    + + ) +} + +export default Input2FAState diff --git a/packages/admin-ui/src/pages/Authentication/InputFIDOState.jsx b/packages/admin-ui/src/pages/Authentication/InputFIDOState.jsx new file mode 100644 index 0000000..136c43a --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/InputFIDOState.jsx @@ -0,0 +1,207 @@ +import { useMutation, useLazyQuery, gql } from '@apollo/client' +import { startAssertion } from '@simplewebauthn/browser' +import { Field, Form, Formik } from 'formik' +import React, { useState, useContext } from 'react' +import { useLocation } from 'wouter' +import { H2, Label2, P } from '../../components/typography' +import * as Yup from 'yup' + +import AppContext from '../../AppContext' +import { Button } from '../../components/buttons' +import { Checkbox, TextInput } from '../../components/inputs/formik' + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + +const validationSchema = Yup.object().shape({ + localClient: Yup.string() + .required('Client field is required!') + .email('Username field should be in an email format!'), + localRememberMe: Yup.boolean(), +}) + +const initialValues = { + localClient: '', + localRememberMe: false, +} + +const InputFIDOState = ({ state, strategy }) => { + const GENERATE_ASSERTION = gql` + query generateAssertionOptions($username: String!${ + strategy === 'FIDO2FA' ? `, $password: String!` : `` + }, $domain: String!) { + generateAssertionOptions(username: $username${ + strategy === 'FIDO2FA' ? `, password: $password` : `` + }, domain: $domain) + } + ` + + const VALIDATE_ASSERTION = gql` + mutation validateAssertion( + $username: String! + ${strategy === 'FIDO2FA' ? `, $password: String!` : ``} + $rememberMe: Boolean! + $assertionResponse: JSONObject! + $domain: String! + ) { + validateAssertion( + username: $username + ${strategy === 'FIDO2FA' ? `password: $password` : ``} + rememberMe: $rememberMe + assertionResponse: $assertionResponse + domain: $domain + ) + } + ` + + const [, navigate] = useLocation() + const { setUserData } = useContext(AppContext) + + const [localClientField, setLocalClientField] = useState('') + const [localRememberMeField, setLocalRememberMeField] = useState(false) + const [invalidUsername, setInvalidUsername] = useState(false) + const [invalidToken, setInvalidToken] = useState(false) + + const [validateAssertion, { error: mutationError }] = useMutation( + VALIDATE_ASSERTION, + { + onCompleted: ({ validateAssertion: success }) => { + success ? getUserData() : setInvalidToken(true) + }, + }, + ) + + const [assertionOptions, { error: assertionQueryError }] = useLazyQuery( + GENERATE_ASSERTION, + { + variables: + strategy === 'FIDO2FA' + ? { + username: state.clientField, + password: state.passwordField, + domain: window.location.hostname, + } + : { + username: localClientField, + domain: window.location.hostname, + }, + onCompleted: ({ generateAssertionOptions: options }) => { + startAssertion(options) + .then(res => { + const variables = + strategy === 'FIDO2FA' + ? { + username: state.clientField, + password: state.passwordField, + rememberMe: state.rememberMeField, + assertionResponse: res, + domain: window.location.hostname, + } + : { + username: localClientField, + rememberMe: localRememberMeField, + assertionResponse: res, + domain: window.location.hostname, + } + validateAssertion({ + variables, + }) + }) + .catch(err => { + console.error(err) + setInvalidToken(true) + }) + }, + }, + ) + + const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, { + onCompleted: ({ userData }) => { + setUserData(userData) + navigate('/') + }, + }) + + const getErrorMsg = (formikErrors, formikTouched) => { + if (!formikErrors || !formikTouched) return null + if (assertionQueryError || queryError || mutationError) + return 'Internal server error' + if (formikErrors.client && formikTouched.client) return formikErrors.client + if (invalidUsername) return 'Invalid login.' + if (invalidToken) return 'Code is invalid. Please try again.' + return null + } + + return ( + <> + {strategy === 'FIDOPasswordless' && ( + { + setInvalidUsername(false) + setLocalClientField(values.localClient) + setLocalRememberMeField(values.localRememberMe) + assertionOptions() + }}> + {({ errors, touched }) => ( +
    + { + if (invalidUsername) setInvalidUsername(false) + }} + /> +
    + + Keep me logged in +
    +
    + {getErrorMsg(errors, touched) && ( +

    {getErrorMsg(errors, touched)}

    + )} + +
    + + )} +
    + )} + {strategy === 'FIDO2FA' && ( + <> +

    + Insert your hardware key and follow the instructions +

    + + + )} + + ) +} + +export default InputFIDOState diff --git a/packages/admin-ui/src/pages/Authentication/Login.jsx b/packages/admin-ui/src/pages/Authentication/Login.jsx new file mode 100644 index 0000000..0ea27cb --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Login.jsx @@ -0,0 +1,23 @@ +import Grid from '@mui/material/Grid' +import React from 'react' + +import LoginCard from './LoginCard' +import classes from './Authentication.module.css' + +const Login = () => { + return ( + + + + + + ) +} + +export default Login diff --git a/packages/admin-ui/src/pages/Authentication/LoginCard.jsx b/packages/admin-ui/src/pages/Authentication/LoginCard.jsx new file mode 100644 index 0000000..5d15686 --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/LoginCard.jsx @@ -0,0 +1,67 @@ +import Paper from '@mui/material/Paper' +import React, { useReducer } from 'react' +import Logo from '../../styling/icons/menu/logo.svg?react' + +import Input2FAState from './Input2FAState' +import InputFIDOState from './InputFIDOState' +import LoginState from './LoginState' +import Setup2FAState from './Setup2FAState' +import { STATES } from './states' +import classes from './Authentication.module.css' + +// FIDO2FA, FIDOPasswordless or FIDOUsernameless +const AUTHENTICATION_STRATEGY = 'FIDO2FA' + +const initialState = { + twoFAField: '', + clientField: '', + passwordField: '', + rememberMeField: false, + loginState: STATES.LOGIN, +} + +const reducer = (state, action) => { + const { type, payload } = action + return { ...state, ...payload, loginState: type } +} + +const LoginCard = () => { + const [state, dispatch] = useReducer(reducer, initialState) + + const renderState = () => { + switch (state.loginState) { + case STATES.LOGIN: + return ( + + ) + case STATES.INPUT_2FA: + return + case STATES.SETUP_2FA: + return + case STATES.FIDO: + return ( + + ) + default: + break + } + } + + return ( + +
    +
    + +

    Lamassu Admin

    +
    + {renderState()} +
    +
    + ) +} + +export default LoginCard diff --git a/packages/admin-ui/src/pages/Authentication/LoginState.jsx b/packages/admin-ui/src/pages/Authentication/LoginState.jsx new file mode 100644 index 0000000..ef7541c --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/LoginState.jsx @@ -0,0 +1,231 @@ +import { useMutation, useLazyQuery, gql } from '@apollo/client' +import { startAssertion } from '@simplewebauthn/browser' +import { Field, Form, Formik } from 'formik' +import React, { useContext } from 'react' +import { useLocation } from 'wouter' +import { Label3, P } from '../../components/typography' +import * as Yup from 'yup' + +import AppContext from '../../AppContext' +import { Button } from '../../components/buttons' +import { + Checkbox, + SecretInput, + TextInput, +} from '../../components/inputs/formik' + +const LOGIN = gql` + mutation login($username: String!, $password: String!) { + login(username: $username, password: $password) + } +` + +const GENERATE_ASSERTION = gql` + query generateAssertionOptions($domain: String!) { + generateAssertionOptions(domain: $domain) + } +` + +const VALIDATE_ASSERTION = gql` + mutation validateAssertion( + $assertionResponse: JSONObject! + $domain: String! + ) { + validateAssertion(assertionResponse: $assertionResponse, domain: $domain) + } +` + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + +const validationSchema = Yup.object().shape({ + email: Yup.string().label('Email').required().email(), + password: Yup.string().required('Password field is required'), + rememberMe: Yup.boolean(), +}) + +const initialValues = { + email: '', + password: '', + rememberMe: false, +} + +const getErrorMsg = (formikErrors, formikTouched, mutationError) => { + if (!formikErrors || !formikTouched) return null + if (mutationError) return 'Invalid email/password combination' + if (formikErrors.email && formikTouched.email) return formikErrors.email + if (formikErrors.password && formikTouched.password) + return formikErrors.password + return null +} + +const LoginState = ({ dispatch, strategy }) => { + const [, navigate] = useLocation() + const { setUserData } = useContext(AppContext) + + const [login, { error: loginMutationError }] = useMutation(LOGIN) + + const submitLogin = async (username, password, rememberMe) => { + const options = { + variables: { + username, + password, + }, + } + const { data: loginResponse } = await login(options) + + if (!loginResponse.login) return + + return dispatch({ + type: loginResponse.login, + payload: { + clientField: username, + passwordField: password, + rememberMeField: rememberMe, + }, + }) + } + + const [validateAssertion, { error: FIDOMutationError }] = useMutation( + VALIDATE_ASSERTION, + { + onCompleted: ({ validateAssertion: success }) => success && getUserData(), + }, + ) + + const [assertionOptions, { error: assertionQueryError }] = useLazyQuery( + GENERATE_ASSERTION, + { + onCompleted: ({ generateAssertionOptions: options }) => { + startAssertion(options) + .then(res => { + validateAssertion({ + variables: { + assertionResponse: res, + domain: window.location.hostname, + }, + }) + }) + .catch(err => { + console.error(err) + }) + }, + }, + ) + + const [getUserData, { error: userDataQueryError }] = useLazyQuery( + GET_USER_DATA, + { + onCompleted: ({ userData }) => { + setUserData(userData) + navigate('/') + }, + }, + ) + + return ( + + submitLogin(values.email, values.password, values.rememberMe) + }> + {({ errors, touched }) => ( +
    + + +
    + + Keep me logged in +
    +
    + {getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError, + ) && ( +

    + {getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError, + )} +

    + )} + {strategy !== 'FIDO2FA' && ( + + )} + +
    + + )} +
    + ) +} + +export default LoginState diff --git a/packages/admin-ui/src/pages/Authentication/Register.jsx b/packages/admin-ui/src/pages/Authentication/Register.jsx new file mode 100644 index 0000000..dedd30f --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Register.jsx @@ -0,0 +1,216 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import Grid from '@mui/material/Grid' +import Paper from '@mui/material/Paper' +import { Field, Form, Formik } from 'formik' +import React, { useReducer } from 'react' +import { useLocation, useSearchParams } from 'wouter' +import { H2, Label3, P } from '../../components/typography' +import Logo from '../../styling/icons/menu/logo.svg?react' +import * as Yup from 'yup' + +import { Button } from '../../components/buttons' +import { SecretInput } from '../../components/inputs/formik' +import classes from './Authentication.module.css' + +const VALIDATE_REGISTER_LINK = gql` + query validateRegisterLink($token: String!) { + validateRegisterLink(token: $token) { + username + role + } + } +` + +const REGISTER = gql` + mutation register( + $token: String! + $username: String! + $password: String! + $role: String! + ) { + register( + token: $token + username: $username + password: $password + role: $role + ) + } +` + +const PASSWORD_MIN_LENGTH = 8 +const validationSchema = Yup.object({ + password: Yup.string() + .required('A password is required') + .min( + PASSWORD_MIN_LENGTH, + `Your password must contain at least ${PASSWORD_MIN_LENGTH} characters`, + ), + confirmPassword: Yup.string() + .required('Please confirm the password') + .oneOf([Yup.ref('password')], 'Passwords must match'), +}) + +const initialValues = { + password: '', + confirmPassword: '', +} + +const initialState = { + username: null, + role: null, + result: '', +} + +const reducer = (state, action) => { + const { type, payload } = action + return { ...state, ...payload, result: type } +} + +const getErrorMsg = ( + formikErrors, + formikTouched, + queryError, + mutationError, +) => { + if (!formikErrors || !formikTouched) return null + if (queryError || mutationError) return 'Internal server error' + if (formikErrors.password && formikTouched.password) + return formikErrors.password + if (formikErrors.confirmPassword && formikTouched.confirmPassword) + return formikErrors.confirmPassword + return null +} + +const Register = () => { + const [, navigate] = useLocation() + const [searchParams] = useSearchParams() + const token = searchParams.get('t') + + const [state, dispatch] = useReducer(reducer, initialState) + + const queryOptions = { + variables: { token: token }, + onCompleted: ({ validateRegisterLink: info }) => { + if (!info) { + return dispatch({ + type: 'failure', + }) + } + dispatch({ + type: 'success', + payload: { + username: info.username, + role: info.role, + }, + }) + }, + onError: () => + dispatch({ + type: 'failure', + }), + } + + const { error: queryError, loading } = useQuery( + VALIDATE_REGISTER_LINK, + queryOptions, + ) + + const [register, { error: mutationError }] = useMutation(REGISTER, { + onCompleted: ({ register: success }) => { + if (success) navigate('/') + }, + }) + + return ( + + +
    + +
    +
    + +

    Lamassu Admin

    +
    + {!loading && state.result === 'success' && ( + { + register({ + variables: { + token: token, + username: state.username, + password: values.password, + role: state.role, + }, + }) + }}> + {({ errors, touched }) => ( +
    + + +
    + {getErrorMsg( + errors, + touched, + queryError, + mutationError, + ) && ( +

    + {getErrorMsg( + errors, + touched, + queryError, + mutationError, + )} +

    + )} + +
    + + )} +
    + )} + {!loading && state.result === 'failure' && ( + <> + Link has expired + + To obtain a new link, run the command{' '} + lamassu-register in your server’s terminal. + + + )} +
    +
    +
    +
    +
    + ) +} + +export default Register diff --git a/packages/admin-ui/src/pages/Authentication/Reset2FA.jsx b/packages/admin-ui/src/pages/Authentication/Reset2FA.jsx new file mode 100644 index 0000000..3f2652d --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Reset2FA.jsx @@ -0,0 +1,204 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import Grid from '@mui/material/Grid' +import Paper from '@mui/material/Paper' +import { Form, Formik } from 'formik' +import { QRCodeSVG as QRCode } from 'qrcode.react' +import React, { useReducer, useState } from 'react' +import { useLocation, useSearchParams } from 'wouter' +import { H2, Label2, Label3, P } from '../../components/typography' +import Logo from '../../styling/icons/menu/logo.svg?react' + +import { ActionButton, Button } from '../../components/buttons' +import { CodeInput } from '../../components/inputs/base' +import { primaryColor } from '../../styling/variables' + +import classes from './Authentication.module.css' + +const VALIDATE_RESET_2FA_LINK = gql` + query validateReset2FALink($token: String!) { + validateReset2FALink(token: $token) { + user_id + secret + otpauth + } + } +` + +const RESET_2FA = gql` + mutation reset2FA($token: String!, $userID: ID!, $code: String!) { + reset2FA(token: $token, userID: $userID, code: $code) + } +` + +const initialState = { + userID: null, + secret: null, + otpauth: null, + result: null, +} + +const reducer = (state, action) => { + const { type, payload } = action + return { ...state, ...payload, result: type } +} + +const Reset2FA = () => { + const [, navigate] = useLocation() + const [searchParams] = useSearchParams() + const token = searchParams.get('t') + + const [isShowing, setShowing] = useState(false) + const [invalidToken, setInvalidToken] = useState(false) + const [twoFAConfirmation, setTwoFAConfirmation] = useState('') + + const [state, dispatch] = useReducer(reducer, initialState) + + const handle2FAChange = value => { + setTwoFAConfirmation(value) + setInvalidToken(false) + } + + const { error: queryError, loading } = useQuery(VALIDATE_RESET_2FA_LINK, { + variables: { token: token }, + onCompleted: ({ validateReset2FALink: info }) => { + if (!info) { + dispatch({ + type: 'failure', + }) + } else { + dispatch({ + type: 'success', + payload: { + userID: info.user_id, + secret: info.secret, + otpauth: info.otpauth, + }, + }) + } + }, + onError: () => { + dispatch({ + type: 'failure', + }) + }, + }) + + const [reset2FA, { error: mutationError }] = useMutation(RESET_2FA, { + onCompleted: ({ reset2FA: success }) => { + success ? navigate('/') : setInvalidToken(true) + }, + }) + + const getErrorMsg = () => { + if (queryError) return 'Internal server error' + if (twoFAConfirmation.length !== 6 && invalidToken) + return 'The code should have 6 characters!' + if (mutationError || invalidToken) + return 'Code is invalid. Please try again.' + return null + } + + const handleSubmit = () => { + if (twoFAConfirmation.length !== 6) { + setInvalidToken(true) + return + } + reset2FA({ + variables: { + token: token, + userID: state.userID, + code: twoFAConfirmation, + }, + }) + } + + return ( + + +
    + +
    +
    + +

    Lamassu Admin

    +
    + {!loading && state.result === 'success' && ( + <> +
    + + To finish this process, please scan the following QR code + or insert the secret further below on an authentication + app of your choice, such Google Authenticator or Authy. + +
    +
    + +
    +
    + + Your secret: + + + {state.secret} + + { + setShowing(!isShowing) + }}> + {isShowing ? 'Hide' : 'Show'} + +
    +
    + {/* TODO: refactor the 2FA CodeInput to properly use Formik */} + {}} initialValues={{}}> +
    + +
    + {getErrorMsg() && ( +

    {getErrorMsg()}

    + )} + +
    + +
    +
    + + )} + {!loading && state.result === 'failure' && ( + <> + Link has expired + + )} +
    +
    +
    +
    +
    + ) +} + +export default Reset2FA diff --git a/packages/admin-ui/src/pages/Authentication/ResetPassword.jsx b/packages/admin-ui/src/pages/Authentication/ResetPassword.jsx new file mode 100644 index 0000000..c32412f --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/ResetPassword.jsx @@ -0,0 +1,167 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import Grid from '@mui/material/Grid' +import Paper from '@mui/material/Paper' +import { Field, Form, Formik } from 'formik' +import React, { useState } from 'react' +import { useLocation, useSearchParams } from 'wouter' +import { H2, Label3, P } from '../../components/typography' +import Logo from '../../styling/icons/menu/logo.svg?react' +import * as Yup from 'yup' + +import { Button } from '../../components/buttons' +import { SecretInput } from '../../components/inputs/formik' + +import classes from './Authentication.module.css' + +const VALIDATE_RESET_PASSWORD_LINK = gql` + query validateResetPasswordLink($token: String!) { + validateResetPasswordLink(token: $token) { + id + } + } +` + +const RESET_PASSWORD = gql` + mutation resetPassword($token: String!, $userID: ID!, $newPassword: String!) { + resetPassword(token: $token, userID: $userID, newPassword: $newPassword) + } +` + +const validationSchema = Yup.object().shape({ + password: Yup.string() + .required('A new password is required') + .test( + 'len', + 'New password must contain more than 8 characters', + val => val.length >= 8, + ), + confirmPassword: Yup.string().oneOf( + [Yup.ref('password'), null], + 'Passwords must match', + ), +}) + +const initialValues = { + password: '', + confirmPassword: '', +} + +const getErrorMsg = (formikErrors, formikTouched, mutationError) => { + if (!formikErrors || !formikTouched) return null + if (mutationError) return 'Internal server error' + if (formikErrors.password && formikTouched.password) + return formikErrors.password + if (formikErrors.confirmPassword && formikTouched.confirmPassword) + return formikErrors.confirmPassword + return null +} + +const ResetPassword = () => { + const [, navigate] = useLocation() + const [searchParams] = useSearchParams() + const token = searchParams.get('t') + const [userID, setUserID] = useState(null) + const [isLoading, setLoading] = useState(true) + const [wasSuccessful, setSuccess] = useState(false) + + useQuery(VALIDATE_RESET_PASSWORD_LINK, { + variables: { token: token }, + onCompleted: ({ validateResetPasswordLink: info }) => { + setLoading(false) + if (!info) { + setSuccess(false) + } else { + setSuccess(true) + setUserID(info.id) + } + }, + onError: () => { + setLoading(false) + setSuccess(false) + }, + }) + + const [resetPassword, { error }] = useMutation(RESET_PASSWORD, { + onCompleted: ({ resetPassword: success }) => { + if (success) navigate('/') + }, + }) + + return ( + + +
    + +
    +
    + +

    Lamassu Admin

    +
    + {!isLoading && wasSuccessful && ( + { + resetPassword({ + variables: { + token: token, + userID: userID, + newPassword: values.confirmPassword, + }, + }) + }}> + {({ errors, touched }) => ( +
    + + +
    + {getErrorMsg(errors, touched, error) && ( +

    + {getErrorMsg(errors, touched, error)} +

    + )} + +
    + + )} +
    + )} + {!isLoading && !wasSuccessful && ( + <> + Link has expired + + )} +
    +
    +
    +
    +
    + ) +} + +export default ResetPassword diff --git a/packages/admin-ui/src/pages/Authentication/Setup2FAState.jsx b/packages/admin-ui/src/pages/Authentication/Setup2FAState.jsx new file mode 100644 index 0000000..80f6741 --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/Setup2FAState.jsx @@ -0,0 +1,174 @@ +import { useMutation, useQuery, useLazyQuery, gql } from '@apollo/client' +import { Form, Formik } from 'formik' +import { QRCodeSVG as QRCode } from 'qrcode.react' +import React, { useContext, useState } from 'react' +import { useLocation } from 'wouter' +import { Label3, P } from '../../components/typography' + +import AppContext from '../../AppContext' +import { ActionButton, Button } from '../../components/buttons' +import { CodeInput } from '../../components/inputs/base' +import { primaryColor } from '../../styling/variables' + +import classes from './Authentication.module.css' + +const SETUP_2FA = gql` + mutation setup2FA( + $username: String! + $password: String! + $rememberMe: Boolean! + $codeConfirmation: String! + ) { + setup2FA( + username: $username + password: $password + rememberMe: $rememberMe + codeConfirmation: $codeConfirmation + ) + } +` + +const GET_2FA_SECRET = gql` + query get2FASecret($username: String!, $password: String!) { + get2FASecret(username: $username, password: $password) { + secret + otpauth + } + } +` + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + +const Setup2FAState = ({ state }) => { + const [, navigate] = useLocation() + const { setUserData } = useContext(AppContext) + + const [secret, setSecret] = useState(null) + const [otpauth, setOtpauth] = useState(null) + const [isShowing, setShowing] = useState(false) + + const [invalidToken, setInvalidToken] = useState(false) + const [twoFAConfirmation, setTwoFAConfirmation] = useState('') + + const handle2FAChange = value => { + setTwoFAConfirmation(value) + setInvalidToken(false) + } + + const queryOptions = { + variables: { username: state.clientField, password: state.passwordField }, + onCompleted: ({ get2FASecret }) => { + setSecret(get2FASecret.secret) + setOtpauth(get2FASecret.otpauth) + }, + } + + const mutationOptions = { + variables: { + username: state.clientField, + password: state.passwordField, + rememberMe: state.rememberMeField, + codeConfirmation: twoFAConfirmation, + }, + } + + const { error: queryError } = useQuery(GET_2FA_SECRET, queryOptions) + + const [getUserData] = useLazyQuery(GET_USER_DATA, { + onCompleted: ({ userData }) => { + setUserData(userData) + navigate('/') + }, + }) + + const [setup2FA, { error: mutationError }] = useMutation(SETUP_2FA, { + onCompleted: ({ setup2FA: success }) => { + success ? getUserData() : setInvalidToken(true) + }, + }) + + const getErrorMsg = () => { + if (mutationError || queryError) return 'Internal server error.' + if (twoFAConfirmation.length !== 6 && invalidToken) + return 'The code should have 6 characters!' + if (invalidToken) return 'Code is invalid. Please try again.' + return null + } + + const handleSubmit = () => { + if (twoFAConfirmation.length !== 6) { + setInvalidToken(true) + return + } + setup2FA(mutationOptions) + } + + return ( + secret && + otpauth && ( + <> +
    + + This account does not yet have two-factor authentication enabled. To + secure the admin, two-factor authentication is required. + + + To complete the registration process, scan the following QR code or + insert the secret below on a 2FA app, such as Google Authenticator + or AndOTP. + +
    +
    + +
    +
    + Your secret: + + {secret} + + { + setShowing(!isShowing) + }}> + {isShowing ? 'Hide' : 'Show'} + +
    +
    + {/* TODO: refactor the 2FA CodeInput to properly use Formik */} + {}} initialValues={{}}> +
    + +
    + {getErrorMsg() && ( +

    {getErrorMsg()}

    + )} + +
    + +
    +
    + + ) + ) +} + +export default Setup2FAState diff --git a/packages/admin-ui/src/pages/Authentication/states.js b/packages/admin-ui/src/pages/Authentication/states.js new file mode 100644 index 0000000..180ccf2 --- /dev/null +++ b/packages/admin-ui/src/pages/Authentication/states.js @@ -0,0 +1,8 @@ +const STATES = { + LOGIN: 'LOGIN', + SETUP_2FA: 'SETUP2FA', + INPUT_2FA: 'INPUT2FA', + FIDO: 'FIDO', +} + +export { STATES } diff --git a/packages/admin-ui/src/pages/Blacklist/Blacklist.jsx b/packages/admin-ui/src/pages/Blacklist/Blacklist.jsx new file mode 100644 index 0000000..06477f3 --- /dev/null +++ b/packages/admin-ui/src/pages/Blacklist/Blacklist.jsx @@ -0,0 +1,314 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import Dialog from '@mui/material/Dialog' +import DialogTitle from '@mui/material/DialogTitle' +import DialogContent from '@mui/material/DialogContent' +import Switch from '@mui/material/Switch' +import SvgIcon from '@mui/material/SvgIcon' +import IconButton from '@mui/material/IconButton' +import * as R from 'ramda' +import React, { useState } from 'react' +import { HelpTooltip } from '../../components/Tooltip' +import TitleSection from '../../components/layout/TitleSection' +import { H2, Label2, P, Info3, Info2 } from '../../components/typography' +import CloseIcon from '../../styling/icons/action/close/zodiac.svg?react' +import ReverseSettingsIcon from '../../styling/icons/circle buttons/settings/white.svg?react' +import SettingsIcon from '../../styling/icons/circle buttons/settings/zodiac.svg?react' + +import { Link, Button, SupportLinkButton } from '../../components/buttons' +import { fromNamespace, toNamespace } from '../../utils/config' + +import BlackListAdvanced from './BlacklistAdvanced' +import BlackListModal from './BlacklistModal' +import BlacklistTable from './BlacklistTable' + +const DELETE_ROW = gql` + mutation DeleteBlacklistRow($address: String!) { + deleteBlacklistRow(address: $address) { + address + } + } +` + +const GET_BLACKLIST = gql` + query getBlacklistData { + blacklist { + address + } + cryptoCurrencies { + display + code + } + } +` + +const SAVE_CONFIG = gql` + mutation Save($config: JSONObject) { + saveConfig(config: $config) + } +` + +const GET_INFO = gql` + query getData { + config + } +` + +const ADD_ROW = gql` + mutation InsertBlacklistRow($address: String!) { + insertBlacklistRow(address: $address) { + address + } + } +` + +const GET_BLACKLIST_MESSAGES = gql` + query getBlacklistMessages { + blacklistMessages { + id + label + content + allowToggle + } + } +` + +const EDIT_BLACKLIST_MESSAGE = gql` + mutation editBlacklistMessage($id: ID, $content: String) { + editBlacklistMessage(id: $id, content: $content) { + id + } + } +` + +const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => { + return ( + +
    + + + + + + +

    {'Are you sure you want to enable this?'}

    +
    + + {`This mode means that only paper wallets will be printed for users, and they won't be permitted to scan an address from their own wallet.`} + {`This mode is only useful for countries like Switzerland which mandates such a feature.\n`} + {`Don't enable this if you want users to be able to scan an address of their choosing.`} +
    + +
    +
    +
    +
    + ) +} + +const Blacklist = () => { + const { data: blacklistResponse } = useQuery(GET_BLACKLIST) + const { data: configData } = useQuery(GET_INFO) + const { data: messagesResponse, refetch } = useQuery(GET_BLACKLIST_MESSAGES) + const [showModal, setShowModal] = useState(false) + const [errorMsg, setErrorMsg] = useState(null) + const [editMessageError, setEditMessageError] = useState(null) + const [deleteDialog, setDeleteDialog] = useState(false) + const [confirmDialog, setConfirmDialog] = useState(false) + const [advancedSettings, setAdvancedSettings] = useState(false) + + const [deleteEntry] = useMutation(DELETE_ROW, { + onError: ({ message }) => { + const errorMessage = message ?? 'Error while deleting row' + setErrorMsg(errorMessage) + }, + onCompleted: () => setDeleteDialog(false), + refetchQueries: () => ['getBlacklistData'], + }) + + const [addEntry] = useMutation(ADD_ROW, { + refetchQueries: () => ['getBlacklistData'], + }) + + const [saveConfig] = useMutation(SAVE_CONFIG, { + refetchQueries: () => ['getData'], + }) + + const [editMessage] = useMutation(EDIT_BLACKLIST_MESSAGE, { + onError: e => setEditMessageError(e), + refetchQueries: () => ['getBlacklistData'], + }) + + const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? [] + + const complianceConfig = + configData?.config && fromNamespace('compliance')(configData.config) + + const rejectAddressReuse = !!complianceConfig?.rejectAddressReuse + + const enablePaperWalletOnly = !!complianceConfig?.enablePaperWalletOnly + + const addressReuseSave = rawConfig => { + const config = toNamespace('compliance')(rawConfig) + return saveConfig({ variables: { config } }) + } + + const handleDeleteEntry = address => { + deleteEntry({ variables: { address } }) + } + + const handleConfirmDialog = confirm => { + addressReuseSave({ + enablePaperWalletOnly: confirm, + }) + setConfirmDialog(false) + } + + const addToBlacklist = async address => { + setErrorMsg(null) + try { + const res = await addEntry({ variables: { address } }) + if (!res?.errors) { + return setShowModal(false) + } + const duplicateKeyError = res?.errors?.some(e => { + return e.message.includes('duplicate') + }) + if (duplicateKeyError) { + setErrorMsg('This address is already being blocked') + } else { + setErrorMsg(`Server error${': ' + res?.errors[0]?.message}`) + } + } catch (e) { + console.error(e) + setErrorMsg('Server error') + } + } + + const editBlacklistMessage = r => { + editMessage({ + variables: { + id: r.id, + content: r.content, + }, + }) + } + + return ( + <> + { + setConfirmDialog(false) + }} + /> + + {!advancedSettings && ( +
    +
    +

    Enable paper wallet (only)

    + + enablePaperWalletOnly + ? addressReuseSave({ + enablePaperWalletOnly: e.target.checked, + }) + : setConfirmDialog(true) + } + value={enablePaperWalletOnly} + /> + {enablePaperWalletOnly ? 'On' : 'Off'} + +

    + The "Enable paper wallet (only)" option means that only paper + wallets will be printed for users, and they won't be permitted + to scan an address from their own wallet. +

    +
    +
    +
    +

    Reject reused addresses

    + { + addressReuseSave({ rejectAddressReuse: event.target.checked }) + }} + value={rejectAddressReuse} + /> + {rejectAddressReuse ? 'On' : 'Off'} + +

    + For details about rejecting address reuse, please read the + relevant knowledgebase article: +

    + +
    +
    + setShowModal(true)}> + Blacklist new addresses + +
    + )} +
    + {!advancedSettings && ( +
    + +
    + )} + {advancedSettings && ( + refetch()} + /> + )} + {showModal && ( + { + setErrorMsg(null) + setShowModal(false) + }} + errorMsg={errorMsg} + addToBlacklist={addToBlacklist} + /> + )} + + ) +} + +export default Blacklist diff --git a/packages/admin-ui/src/pages/Blacklist/BlacklistAdvanced.jsx b/packages/admin-ui/src/pages/Blacklist/BlacklistAdvanced.jsx new file mode 100644 index 0000000..306dc31 --- /dev/null +++ b/packages/admin-ui/src/pages/Blacklist/BlacklistAdvanced.jsx @@ -0,0 +1,172 @@ +import IconButton from '@mui/material/IconButton' +import SvgIcon from '@mui/material/SvgIcon' +import { Form, Formik, Field } from 'formik' +import * as R from 'ramda' +import React, { useState } from 'react' +import ErrorMessage from '../../components/ErrorMessage' +import Modal from '../../components/Modal' +import DataTable from '../../components/tables/DataTable' +import DisabledDeleteIcon from '../../styling/icons/action/delete/disabled.svg?react' +import DeleteIcon from '../../styling/icons/action/delete/enabled.svg?react' +import EditIcon from '../../styling/icons/action/edit/enabled.svg?react' +import DefaultIconReverse from '../../styling/icons/button/retry/white.svg?react' +import DefaultIcon from '../../styling/icons/button/retry/zodiac.svg?react' +import * as Yup from 'yup' + +import { ActionButton, Button } from '../../components/buttons' +import { TextInput } from '../../components/inputs/formik' + +const DEFAULT_MESSAGE = `This address may be associated with a deceptive offer or a prohibited group. Please make sure you're using an address from your own wallet.` + +const getErrorMsg = (formikErrors, formikTouched, mutationError) => { + if (mutationError) return 'Internal server error' + if (!formikErrors || !formikTouched) return null + if (formikErrors.event && formikTouched.event) return formikErrors.event + if (formikErrors.message && formikTouched.message) return formikErrors.message + return null +} + +const BlacklistAdvanced = ({ + data, + editBlacklistMessage, + onClose, + mutationError, +}) => { + const [selectedMessage, setSelectedMessage] = useState(null) + + const elements = [ + { + name: 'label', + header: 'Label', + width: 250, + textAlign: 'left', + size: 'sm', + view: it => R.path(['label'], it), + }, + { + name: 'content', + header: 'Content', + width: 690, + textAlign: 'left', + size: 'sm', + view: it => R.path(['content'], it), + }, + { + name: 'edit', + header: 'Edit', + width: 130, + textAlign: 'center', + size: 'sm', + view: it => ( + setSelectedMessage(it)}> + + + + + ), + }, + { + name: 'deleteButton', + header: 'Delete', + width: 130, + textAlign: 'center', + size: 'sm', + view: it => ( + + + {R.path(['allowToggle'], it) ? ( + + ) : ( + + )} + + + ), + }, + ] + + const handleModalClose = () => { + setSelectedMessage(null) + } + + const handleSubmit = values => { + editBlacklistMessage(values) + handleModalClose() + !R.isNil(onClose) && onClose() + } + + const initialValues = { + label: !R.isNil(selectedMessage) ? selectedMessage.label : '', + content: !R.isNil(selectedMessage) ? selectedMessage.content : '', + } + + const validationSchema = Yup.object().shape({ + label: Yup.string().required('A label is required!'), + content: Yup.string().required('The message content is required!').trim(), + }) + + return ( + <> + + {selectedMessage && ( + + + handleSubmit({ id: selectedMessage.id, ...values }) + }> + {({ errors, touched, setFieldValue }) => ( +
    + setFieldValue('content', DEFAULT_MESSAGE)}> + Reset to default + + +
    + {getErrorMsg(errors, touched, mutationError) && ( + + {getErrorMsg(errors, touched, mutationError)} + + )} + +
    + + )} +
    +
    + )} + + ) +} + +export default BlacklistAdvanced diff --git a/packages/admin-ui/src/pages/Blacklist/BlacklistModal.jsx b/packages/admin-ui/src/pages/Blacklist/BlacklistModal.jsx new file mode 100644 index 0000000..5bb5c8e --- /dev/null +++ b/packages/admin-ui/src/pages/Blacklist/BlacklistModal.jsx @@ -0,0 +1,62 @@ +import { Formik, Form, Field } from 'formik' +import * as R from 'ramda' +import React from 'react' +import ErrorMessage from '../../components/ErrorMessage' +import Modal from '../../components/Modal' +import { H3 } from '../../components/typography' +import * as Yup from 'yup' + +import { Link } from '../../components/buttons' +import { TextInput } from '../../components/inputs/formik' + +const BlackListModal = ({ onClose, addToBlacklist, errorMsg }) => { + const handleAddToBlacklist = address => { + addToBlacklist(address) + } + + const placeholderAddress = '1ADwinnimZKGgQ3dpyfoUZvJh4p1UWSSpD' + + return ( + + { + handleAddToBlacklist(address.trim()) + }}> +
    +

    Blacklist new address

    + +
    + {!R.isNil(errorMsg) && {errorMsg}} +
    + + Blacklist address + +
    +
    + +
    +
    + ) +} + +export default BlackListModal diff --git a/packages/admin-ui/src/pages/Blacklist/BlacklistTable.jsx b/packages/admin-ui/src/pages/Blacklist/BlacklistTable.jsx new file mode 100644 index 0000000..4946a27 --- /dev/null +++ b/packages/admin-ui/src/pages/Blacklist/BlacklistTable.jsx @@ -0,0 +1,78 @@ +import IconButton from '@mui/material/IconButton' +import SvgIcon from '@mui/material/SvgIcon' +import * as R from 'ramda' +import React, { useState } from 'react' +import { DeleteDialog } from '../../components/DeleteDialog' +import DataTable from '../../components/tables/DataTable' +import CopyToClipboard from '../../components/CopyToClipboard.jsx' +import DeleteIcon from '../../styling/icons/action/delete/enabled.svg?react' + +const BlacklistTable = ({ + data, + handleDeleteEntry, + errorMessage, + setErrorMessage, + deleteDialog, + setDeleteDialog, +}) => { + const [toBeDeleted, setToBeDeleted] = useState() + + const elements = [ + { + name: 'address', + header: 'Address', + width: 1070, + textAlign: 'left', + size: 'sm', + view: it => ( +
    + {R.path(['address'], it)} +
    + ), + }, + { + name: 'deleteButton', + header: 'Delete', + width: 130, + textAlign: 'center', + size: 'sm', + view: it => ( + { + setDeleteDialog(true) + setToBeDeleted(it) + }}> + + + + + ), + }, + ] + + return ( + <> + + { + setDeleteDialog(false) + setErrorMessage(null) + }} + onConfirmed={() => { + setErrorMessage(null) + handleDeleteEntry(R.path(['address'], toBeDeleted)) + }} + errorMessage={errorMessage} + /> + + ) +} + +export default BlacklistTable diff --git a/packages/admin-ui/src/pages/Blacklist/index.js b/packages/admin-ui/src/pages/Blacklist/index.js new file mode 100644 index 0000000..4a7e8ce --- /dev/null +++ b/packages/admin-ui/src/pages/Blacklist/index.js @@ -0,0 +1,3 @@ +import Blacklist from './Blacklist' + +export default Blacklist diff --git a/packages/admin-ui/src/pages/Cashout/Cashout.jsx b/packages/admin-ui/src/pages/Cashout/Cashout.jsx new file mode 100644 index 0000000..8ee74e9 --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/Cashout.jsx @@ -0,0 +1,123 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import * as R from 'ramda' +import React, { useState } from 'react' +import { HelpTooltip } from '../../components/Tooltip' +import TitleSection from '../../components/layout/TitleSection' +import { P } from '../../components/typography' + +import { SupportLinkButton } from '../../components/buttons' +import { NamespacedTable as EditableTable } from '../../components/editableTable' +import { EmptyTable } from '../../components/table' +import { fromNamespace, toNamespace } from '../../utils/config' + +import Wizard from './Wizard' +import { DenominationsSchema, getElements } from './helper' + +const SAVE_CONFIG = gql` + mutation Save($config: JSONObject) { + saveConfig(config: $config) + } +` + +const GET_INFO = gql` + query getData { + machines { + name + deviceId + cashUnits { + cashbox + cassette1 + cassette2 + cassette3 + cassette4 + recycler1 + recycler2 + recycler3 + recycler4 + recycler5 + recycler6 + } + numberOfCassettes + numberOfRecyclers + } + config + } +` + +const CashOut = ({ name: SCREEN_KEY }) => { + const [wizard, setWizard] = useState(false) + const { data, loading } = useQuery(GET_INFO) + + const [saveConfig, { error }] = useMutation(SAVE_CONFIG, { + onCompleted: () => setWizard(false), + refetchQueries: () => ['getData'], + }) + + const save = rawConfig => { + const config = toNamespace(SCREEN_KEY)(rawConfig) + return saveConfig({ variables: { config } }) + } + + const config = data?.config && fromNamespace(SCREEN_KEY)(data.config) + + const locale = data?.config && fromNamespace('locale')(data.config) + const machines = data?.machines ?? [] + + const onToggle = id => { + const namespaced = fromNamespace(id)(config) + if (!DenominationsSchema.isValidSync(namespaced)) return setWizard(id) + save(toNamespace(id, { active: !namespaced?.active })) + } + + const wasNeverEnabled = it => R.compose(R.length, R.keys)(it) === 1 + + return ( + !loading && ( + <> + +

    + For details on configuring cash-out, please read the relevant + knowledgebase article: +

    + + + } + /> + + {R.isEmpty(machines) && } + {wizard && ( + setWizard(false)} + save={save} + error={error?.message} + locale={locale} + /> + )} + + ) + ) +} + +export default CashOut diff --git a/packages/admin-ui/src/pages/Cashout/Wizard.jsx b/packages/admin-ui/src/pages/Cashout/Wizard.jsx new file mode 100644 index 0000000..67a20e3 --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/Wizard.jsx @@ -0,0 +1,153 @@ +import * as R from 'ramda' +import React, { useState } from 'react' +import Modal from '../../components/Modal' +import * as Yup from 'yup' + +import { Autocomplete } from '../../components/inputs/formik' +import denominations from '../../utils/bill-denominations' +import { getBillOptions } from '../../utils/bill-options' +import { toNamespace } from '../../utils/config' +import { transformNumber } from '../../utils/number' + +import WizardSplash from './WizardSplash' +import WizardStep from './WizardStep' +import { DenominationsSchema } from './helper' + +const MODAL_WIDTH = 554 +const MODAL_HEIGHT = 520 + +const Wizard = ({ machine, locale, onClose, save, error }) => { + // Each stacker counts as two steps, one for front and another for rear + const LAST_STEP = machine.numberOfCassettes + machine.numberOfRecyclers + 1 + const [{ step, config }, setState] = useState({ + step: 0, + config: { active: true }, + }) + + const options = getBillOptions(locale, denominations) + + const title = `Enable cash-out` + const isLastStep = step === LAST_STEP + + const onContinue = async it => { + if (isLastStep) { + return save( + toNamespace( + machine.deviceId, + DenominationsSchema.cast(config, { assert: false }), + ), + ) + } + + const newConfig = R.mergeRight(config, it) + + setState({ + step: step + 1, + config: newConfig, + }) + } + + const steps = R.concat( + R.map( + it => ({ + model: 'cassette', + type: `cassette${it}`, + display: `Cassette ${it}`, + component: Autocomplete, + inputProps: { + options: options, + labelProp: 'display', + valueProp: 'code', + }, + }), + R.range(1, machine.numberOfCassettes + 1), + ), + R.map( + it => ({ + type: `recycler${it}`, + model: 'recycler', + display: `Recycler ${it}`, + component: Autocomplete, + inputProps: { + options: options, + labelProp: 'display', + valueProp: 'code', + }, + }), + R.range(1, machine.numberOfRecyclers + 1), + ), + ) + + const schema = () => + Yup.object().shape({ + cassette1: + machine.numberOfCassettes >= 1 && step >= 1 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + cassette2: + machine.numberOfCassettes >= 2 && step >= 2 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + cassette3: + machine.numberOfCassettes >= 3 && step >= 3 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + cassette4: + machine.numberOfCassettes >= 4 && step >= 4 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler1: + machine.numberOfRecyclers >= 1 && step >= machine.numberOfCassettes + 1 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler2: + machine.numberOfRecyclers >= 2 && step >= machine.numberOfCassettes + 2 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler3: + machine.numberOfRecyclers >= 3 && step >= machine.numberOfCassettes + 3 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler4: + machine.numberOfRecyclers >= 4 && step >= machine.numberOfCassettes + 4 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler5: + machine.numberOfRecyclers >= 5 && step >= machine.numberOfCassettes + 5 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + recycler6: + machine.numberOfRecyclers >= 6 && step >= machine.numberOfCassettes + 6 + ? Yup.number().required() + : Yup.number().transform(transformNumber).nullable(), + }) + + return ( + + {step === 0 && ( + onContinue()} /> + )} + {step !== 0 && ( + + )} + + ) +} + +export default Wizard diff --git a/packages/admin-ui/src/pages/Cashout/WizardSplash.jsx b/packages/admin-ui/src/pages/Cashout/WizardSplash.jsx new file mode 100644 index 0000000..546a75c --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/WizardSplash.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { H1, P, Info2 } from '../../components/typography' +import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react' + +import { Button } from '../../components/buttons' + +const WizardSplash = ({ name, onContinue }) => { + return ( +
    +
    +

    + + Enable cash-out +

    + + {name} + +

    + You are about to activate cash-out functionality on your {name}{' '} + machine which will allow your customers to sell crypto to you. +

    +

    + In order to activate cash-out for this machine, please enter the + denominations for the machine. +

    +
    + +
    + ) +} + +export default WizardSplash diff --git a/packages/admin-ui/src/pages/Cashout/WizardStep.jsx b/packages/admin-ui/src/pages/Cashout/WizardStep.jsx new file mode 100644 index 0000000..c4757f7 --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/WizardStep.jsx @@ -0,0 +1,153 @@ +import { Formik, Form, Field } from 'formik' +import React from 'react' +import ErrorMessage from '../../components/ErrorMessage' +import Stepper from '../../components/Stepper' +import { Info2, H4, P, Info1, Label1 } from '../../components/typography' +import WarningIcon from '../../styling/icons/warning-icon/comet.svg?react' + +import { Button } from '../../components/buttons' +import { NumberInput } from '../../components/inputs/formik' +import cassetteOne from '../../styling/icons/cassettes/cashout-cassette-1.svg' +import cassetteTwo from '../../styling/icons/cassettes/cashout-cassette-2.svg' +import tejo3CassetteOne from '../../styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-1-left.svg' +import tejo3CassetteTwo from '../../styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-2-left.svg' +import tejo3CassetteThree from '../../styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-3-left.svg' +import tejo4CassetteOne from '../../styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-1-left.svg' +import tejo4CassetteTwo from '../../styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-2-left.svg' +import tejo4CassetteThree from '../../styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-3-left.svg' +import tejo4CassetteFour from '../../styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-4-left.svg' + +const getCassetesArtworks = () => ({ + 1: { + 1: cassetteOne, + }, + 2: { + 1: cassetteOne, + 2: cassetteTwo, + }, + 3: { + 1: tejo3CassetteOne, + 2: tejo3CassetteTwo, + 3: tejo3CassetteThree, + }, + 4: { + 1: tejo4CassetteOne, + 2: tejo4CassetteTwo, + 3: tejo4CassetteThree, + 4: tejo4CassetteFour, + }, +}) + +const WizardStep = ({ + name, + step, + schema, + error, + isLastStep, + onContinue, + steps, + fiatCurrency, + options, + numberOfCassettes, +}) => { + const label = isLastStep ? 'Finish' : 'Next' + const cassetteIcon = getCassetesArtworks()[numberOfCassettes] + return ( + <> +
    + {name} + +
    + + {!isLastStep && ( + +
    +
    + {steps.map( + ({ type, display, component }, idx) => + 1 + idx === step && ( +
    +

    Edit {display}

    + + Choose bill denomination +
    + 0 ? component : NumberInput + } + fullWidth + decimalPlaces={0} + name={type} + options={options} + valueProp={'code'} + labelProp={'display'}> + + {fiatCurrency} + +
    +
    + ), + )} + cassette +
    + + +
    +
    + )} + + {isLastStep && ( +
    +
    + Cash Cassette Bill Count +

    + + When enabling cash-out, your bill count will be automatically set + to zero. Make sure you physically put cash inside the cash + cassettes to allow the machine to dispense it to your users. If + you already did, make sure you set the correct cash cassette bill + count for this machine on your Cash boxes & cassettes tab under + Maintenance. +

    + Default Commissions +

    + + When enabling cash-out, default commissions will be set. To change + commissions for this machine, please go to the Commissions tab + under Settings where you can set exceptions for each of the + available cryptocurrencies. +

    +
    + {error && Failed to save} + +
    + )} + + ) +} + +export default WizardStep diff --git a/packages/admin-ui/src/pages/Cashout/helper.js b/packages/admin-ui/src/pages/Cashout/helper.js new file mode 100644 index 0000000..510f896 --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/helper.js @@ -0,0 +1,196 @@ +import * as R from 'ramda' +import * as Yup from 'yup' + +import { Autocomplete, NumberInput } from '../../components/inputs/formik' +import { bold } from '../../styling/helpers' +import denominations from '../../utils/bill-denominations' +import { getBillOptions } from '../../utils/bill-options' +import { CURRENCY_MAX } from '../../utils/constants' +import { transformNumber } from '../../utils/number' + +const widthsByNumberOfUnits = { + 2: { machine: 325, cassette: 340 }, + 3: { machine: 300, cassette: 235 }, + 4: { machine: 205, cassette: 200 }, + 5: { machine: 180, cassette: 165 }, + 6: { machine: 165, cassette: 140 }, + 7: { machine: 130, cassette: 125 }, +} + +const denominationKeys = [ + 'cassette1', + 'cassette2', + 'cassette3', + 'cassette4', + 'recycler1', + 'recycler2', + 'recycler3', + 'recycler4', + 'recycler5', + 'recycler6', +] + +const DenominationsSchema = Yup.object() + .shape({ + cassette1: Yup.number() + .label('Cassette 1') + .min(1) + .nullable() + .max(CURRENCY_MAX), + cassette2: Yup.number() + .label('Cassette 2') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + cassette3: Yup.number() + .label('Cassette 3') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + cassette4: Yup.number() + .label('Cassette 4') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler1: Yup.number() + .label('Recycler 1') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler2: Yup.number() + .label('Recycler 2') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler3: Yup.number() + .label('Recycler 3') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler4: Yup.number() + .label('Recycler 4') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler5: Yup.number() + .label('Recycler 5') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + recycler6: Yup.number() + .label('Recycler 6') + .min(1) + .max(CURRENCY_MAX) + .nullable() + .transform(transformNumber), + }) + .test((values, context) => + R.any(key => !R.isNil(values[key]), denominationKeys) + ? true + : context.createError({ + path: '', + message: + 'The recyclers or at least one of the cassettes must have a value', + }), + ) + +const getElements = (machines, locale = {}) => { + const fiatCurrency = R.prop('fiatCurrency')(locale) + const maxNumberOfCassettes = Math.max( + ...R.map(it => it.numberOfCassettes, machines), + 0, + ) + const maxNumberOfRecyclers = Math.max( + ...R.map(it => it.numberOfRecyclers, machines), + 0, + ) + const numberOfCashUnits = + maxNumberOfCassettes + Math.ceil(maxNumberOfRecyclers / 2) + + const options = getBillOptions(locale, denominations) + const cassetteProps = + options?.length > 0 + ? { + options: options, + labelProp: 'display', + valueProp: 'code', + className: 'w-full', + } + : { decimalPlaces: 0 } + + const elements = [ + { + name: 'id', + header: 'Machine', + width: widthsByNumberOfUnits[numberOfCashUnits]?.machine, + view: it => machines.find(({ deviceId }) => deviceId === it).name, + size: 'sm', + editable: false, + }, + ] + + R.until( + R.gt(R.__, maxNumberOfCassettes), + it => { + elements.push({ + name: `cassette${it}`, + header: `Cassette ${it}`, + size: 'sm', + stripe: true, + textAlign: 'right', + width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette, + suffix: fiatCurrency, + bold: bold, + view: it => it, + input: options?.length > 0 ? Autocomplete : NumberInput, + inputProps: cassetteProps, + doubleHeader: 'Denominations of Cassettes & Recyclers', + isHidden: machine => + it > + machines.find(({ deviceId }) => deviceId === machine.id) + .numberOfCassettes, + }) + return R.add(1, it) + }, + 1, + ) + + R.until( + R.gt(R.__, Math.ceil(maxNumberOfRecyclers / 2)), + it => { + elements.push({ + names: [`recycler${it * 2 - 1}`, `recycler${it * 2}`], + header: `Recyclers ${it * 2 - 1} - ${it * 2}`, + size: 'sm', + stripe: true, + textAlign: 'right', + width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette, + suffix: fiatCurrency, + bold: bold, + input: options?.length > 0 ? Autocomplete : NumberInput, + inputProps: cassetteProps, + doubleHeader: 'Denominations of Cassettes & Recyclers', + isHidden: machine => + it > + Math.ceil( + machines.find(({ deviceId }) => deviceId === machine.id) + .numberOfRecyclers / 2, + ), + }) + return R.add(1, it) + }, + 1, + ) + + return elements +} + +export { DenominationsSchema, getElements } diff --git a/packages/admin-ui/src/pages/Cashout/index.js b/packages/admin-ui/src/pages/Cashout/index.js new file mode 100644 index 0000000..e625c74 --- /dev/null +++ b/packages/admin-ui/src/pages/Cashout/index.js @@ -0,0 +1,3 @@ +import Cashout from './Cashout' + +export default Cashout diff --git a/packages/admin-ui/src/pages/Commissions/Commissions.jsx b/packages/admin-ui/src/pages/Commissions/Commissions.jsx new file mode 100644 index 0000000..3537ece --- /dev/null +++ b/packages/admin-ui/src/pages/Commissions/Commissions.jsx @@ -0,0 +1,159 @@ +import { useQuery, useMutation, gql } from '@apollo/client' +import * as R from 'ramda' +import React, { useState } from 'react' +import { HelpTooltip } from '../../components/Tooltip' +import TitleSection from '../../components/layout/TitleSection' +import ReverseListingViewIcon from '../../styling/icons/circle buttons/listing-view/white.svg?react' +import ListingViewIcon from '../../styling/icons/circle buttons/listing-view/zodiac.svg?react' +import OverrideLabelIcon from '../../styling/icons/status/spring2.svg?react' + +import { SupportLinkButton } from '../../components/buttons' +import { fromNamespace, toNamespace, namespaces } from '../../utils/config' + +import { P } from '../../components/typography' + +import CommissionsDetails from './components/CommissionsDetails' +import CommissionsList from './components/CommissionsList' + +const GET_DATA = gql` + query getData { + config + cryptoCurrencies { + code + display + } + machines { + name + deviceId + } + } +` + +const SAVE_CONFIG = gql` + mutation Save($config: JSONObject) { + saveConfig(config: $config) + } +` +const removeCoinFromOverride = crypto => override => + R.mergeRight(override, { + cryptoCurrencies: R.without([crypto], override.cryptoCurrencies), + }) + +const Commissions = ({ name: SCREEN_KEY }) => { + const [showMachines, setShowMachines] = useState(false) + const [error, setError] = useState(null) + const { data, loading } = useQuery(GET_DATA) + const [saveConfig] = useMutation(SAVE_CONFIG, { + refetchQueries: () => ['getData'], + onError: error => setError(error), + }) + + const config = data?.config && fromNamespace(SCREEN_KEY)(data.config) + const localeConfig = + data?.config && fromNamespace(namespaces.LOCALE)(data.config) + + const currency = R.prop('fiatCurrency')(localeConfig) + const overrides = R.prop('overrides')(config) + + const save = it => { + const config = toNamespace(SCREEN_KEY)(it.commissions[0]) + return saveConfig({ variables: { config } }) + } + + const saveOverrides = it => { + const config = toNamespace(SCREEN_KEY)(it) + setError(null) + return saveConfig({ variables: { config } }) + } + + const saveOverridesFromList = it => (_, override) => { + const cryptoOverridden = R.path(['cryptoCurrencies', 0], override) + + const sameMachine = R.eqProps('machine', override) + const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it) + + const filterMachine = R.filter(R.both(sameMachine, notSameOverride)) + const removeCoin = removeCoinFromOverride(cryptoOverridden) + + const machineOverrides = R.map(removeCoin)(filterMachine(it)) + + const overrides = machineOverrides.concat( + R.filter(it => !sameMachine(it), it), + ) + + const config = { + commissions_overrides: R.prepend(override, overrides), + } + + return saveConfig({ variables: { config } }) + } + + const labels = showMachines + ? [ + { + label: 'Override value', + icon: , + }, + ] + : [] + + return ( + <> + +

    + For details about commissions, please read the relevant + knowledgebase articles: +

    + + + + } + /> + + {!showMachines && !loading && ( + + )} + {showMachines && !loading && ( + + )} + + ) +} + +export default Commissions diff --git a/packages/admin-ui/src/pages/Commissions/components/CommissionsDetails.jsx b/packages/admin-ui/src/pages/Commissions/components/CommissionsDetails.jsx new file mode 100644 index 0000000..9f414f4 --- /dev/null +++ b/packages/admin-ui/src/pages/Commissions/components/CommissionsDetails.jsx @@ -0,0 +1,79 @@ +import * as R from 'ramda' +import React, { useState, memo } from 'react' +import Section from '../../../components/layout/Section' +import { + mainFields, + overrides, + getSchema, + getOverridesSchema, + defaults, + overridesDefaults, + getOrder, +} from '../helper' + +import { Table as EditableTable } from '../../../components/editableTable' + +const CommissionsDetails = memo( + ({ config, locale, currency, data, error, save, saveOverrides }) => { + const [isEditingDefault, setEditingDefault] = useState(false) + const [isEditingOverrides, setEditingOverrides] = useState(false) + + const commission = config && !R.isEmpty(config) ? config : defaults + const commissionOverrides = commission?.overrides ?? [] + + const orderedCommissionsOverrides = R.sortWith([ + R.ascend(getOrder), + R.ascend(R.prop('machine')), + ])(commissionOverrides) + + const onEditingDefault = (it, editing) => setEditingDefault(editing) + const onEditingOverrides = (it, editing) => setEditingOverrides(editing) + + return ( + <> +
    + +
    +
    + +
    + + ) + }, +) + +export default CommissionsDetails diff --git a/packages/admin-ui/src/pages/Commissions/components/CommissionsList.jsx b/packages/admin-ui/src/pages/Commissions/components/CommissionsList.jsx new file mode 100644 index 0000000..55deb1b --- /dev/null +++ b/packages/admin-ui/src/pages/Commissions/components/CommissionsList.jsx @@ -0,0 +1,162 @@ +import * as R from 'ramda' +import React, { memo, useState } from 'react' +import { + overridesDefaults, + getCommissions, + getListCommissionsSchema, + commissionsList, +} from '../helper' + +import { Table as EditableTable } from '../../../components/editableTable' +import { Select } from '../../../components/inputs' + +const SHOW_ALL = { + code: 'SHOW_ALL', + display: 'Show all', +} + +const ORDER_OPTIONS = [ + { + code: 'machine', + display: 'Machine name', + }, + { + code: 'cryptoCurrencies', + display: 'Cryptocurrency', + }, + { + code: 'cashIn', + display: 'Cash-in', + }, + { + code: 'cashOut', + display: 'Cash-out', + }, + { + code: 'fixedFee', + display: 'Fixed fee', + }, + { + code: 'minimumTx', + display: 'Minimum Tx', + }, +] + +const getElement = (code, display) => ({ + code: code, + display: display || code, +}) + +const sortCommissionsBy = prop => { + switch (prop) { + case ORDER_OPTIONS[0]: + return R.sortBy(R.find(R.propEq(R.prop('machine'), 'code'))) + case ORDER_OPTIONS[1]: + return R.sortBy(R.path(['cryptoCurrencies', 0])) + default: + return R.sortBy(R.prop(prop.code)) + } +} + +const filterCommissions = (coinFilter, machineFilter) => + R.compose( + R.filter( + it => (machineFilter === SHOW_ALL) | (machineFilter.code === it.machine), + ), + R.filter( + it => + (coinFilter === SHOW_ALL) | + (coinFilter.code === it.cryptoCurrencies[0]), + ), + ) + +const CommissionsList = memo( + ({ config, localeConfig, currency, data, error, saveOverrides }) => { + const [machineFilter, setMachineFilter] = useState(SHOW_ALL) + const [coinFilter, setCoinFilter] = useState(SHOW_ALL) + const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0]) + + const coins = R.prop('cryptoCurrencies', localeConfig) ?? [] + + const getMachineCoins = deviceId => { + const override = R.prop('overrides', localeConfig)?.find( + R.propEq(deviceId, 'machine'), + ) + + const machineCoins = override + ? R.prop('cryptoCurrencies', override) + : coins + + return R.xprod([deviceId], machineCoins) + } + + const getMachineElement = it => + getElement(R.prop('deviceId', it), R.prop('name', it)) + + const cryptoData = R.map(getElement)(coins) + + const machineData = R.sortBy( + R.prop('display'), + R.map(getMachineElement)(R.prop('machines', data)), + ) + + const machinesCoinsTuples = R.unnest( + R.map(getMachineCoins)(machineData.map(R.prop('code'))), + ) + + const commissions = R.map(([deviceId, cryptoCode]) => + getCommissions(cryptoCode, deviceId, config), + )(machinesCoinsTuples) + + const tableData = R.compose( + sortCommissionsBy(orderProp), + filterCommissions(coinFilter, machineFilter), + )(commissions) + + return ( +
    +
    + + setInput(fileInput)} + onChange={event => { + // need to store it locally if we want to display it even after saving to db + const file = R.head(event.target.files) + if (!file) return + setFieldValue(R.head(fields).name, file) + }} + /> + Replace +
    + } + + )} + {fields && ( + + Save + + )} + cancel()} + type="reset"> + Cancel + + {authorize && authorized.label !== 'Accepted' && ( + authorize()}> + Authorize + + )} + {authorize && authorized.label !== 'Rejected' && ( + reject()}> + Reject + + )} + {error && ( + Failed to save changes + )} + + )} +
    + + )} + + + + ) +} + +export default EditableCard diff --git a/packages/admin-ui/src/pages/Customers/components/PhotosCard.jsx b/packages/admin-ui/src/pages/Customers/components/PhotosCard.jsx new file mode 100644 index 0000000..d513109 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/PhotosCard.jsx @@ -0,0 +1,62 @@ +import ButtonBase from '@mui/material/ButtonBase' +import Paper from '@mui/material/Card' +import * as R from 'ramda' +import React, { memo, useState } from 'react' +import { InformativeDialog } from '../../../components/InformativeDialog' +import { Info2 } from '../../../components/typography' +import CrossedCameraIcon from '../../../styling/icons/ID/photo/crossed-camera.svg?react' + +import PhotosCarousel from './PhotosCarousel' + +const PhotosCard = memo(({ photosData, timezone }) => { + const [photosDialog, setPhotosDialog] = useState(false) + + const sortedPhotosData = R.sortWith( + [(a, b) => R.has('id', a) - R.has('id', b), R.descend(R.prop('date'))], + photosData, + ) + + const singlePhoto = R.head(sortedPhotosData) + + return ( + <> + + { + setPhotosDialog(true) + }}> + {singlePhoto ? ( +
    + +
    + + {sortedPhotosData.length} + +
    + ) : ( + + )} +
    +
    + + } + onDissmised={() => { + setPhotosDialog(false) + }} + /> + + ) +}) + +export default PhotosCard diff --git a/packages/admin-ui/src/pages/Customers/components/PhotosCarousel.jsx b/packages/admin-ui/src/pages/Customers/components/PhotosCarousel.jsx new file mode 100644 index 0000000..3cae828 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/PhotosCarousel.jsx @@ -0,0 +1,62 @@ +import * as R from 'ramda' +import React, { memo, useState } from 'react' +import { Carousel } from '../../../components/Carousel' +import { Label1 } from '../../../components/typography' + +import { formatDate } from '../../../utils/timezones' + +import CopyToClipboard from '../../../components/CopyToClipboard.jsx' + +const PhotosCarousel = memo(({ photosData, timezone }) => { + const [currentIndex, setCurrentIndex] = useState(0) + + const Label = ({ children }) => { + return ( + + {children} + + ) + } + + const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex]) + + const slidePhoto = index => setCurrentIndex(index) + + // TODO hide copy to clipboard shit + return ( + <> + + {!isFaceCustomerPhoto && ( +
    + + + {photosData && photosData[currentIndex]?.id} + +
    + )} +
    +
    + <> + +
    + {photosData && + formatDate( + photosData[currentIndex]?.date, + timezone, + 'yyyy-MM-dd HH:mm', + )} +
    + +
    +
    + +
    + {!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'} +
    +
    +
    + + ) +}) + +export default PhotosCarousel diff --git a/packages/admin-ui/src/pages/Customers/components/TransactionsList.jsx b/packages/admin-ui/src/pages/Customers/components/TransactionsList.jsx new file mode 100644 index 0000000..25969e5 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/TransactionsList.jsx @@ -0,0 +1,166 @@ +import { toUnit } from '@lamassu/coins/lightUtils' +import BigNumber from 'bignumber.js' +import * as R from 'ramda' +import React from 'react' +import DataTable from '../../../components/tables/DataTable' +import { H3, H4, Label1, Label2, P } from '../../../components/typography' +import TxInIcon from '../../../styling/icons/direction/cash-in.svg?react' +import TxOutIcon from '../../../styling/icons/direction/cash-out.svg?react' + +import { ifNotNull } from '../../../utils/nullCheck' +import { formatDate } from '../../../utils/timezones' + +import CopyToClipboard from '../../../components/CopyToClipboard.jsx' + +const TransactionsList = ({ customer, data, loading }) => { + const LastTxIcon = customer.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon + const hasData = !(R.isEmpty(data) || R.isNil(data)) + const { lastUsedMachineName } = customer + + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + + const summaryElements = [ + { + header: 'Transactions', + size: 127, + value: ifNotNull( + customer.totalTxs, + `${Number.parseInt(customer.totalTxs)}`, + ), + }, + { + header: 'Transaction volume', + size: 167, + value: ifNotNull( + customer.totalSpent, + `${Number.parseFloat(customer.totalSpent)} ${customer.lastTxFiatCode}`, + ), + }, + { + header: 'Last active', + size: 142, + value: + !R.isNil(timezone) && + ((customer.lastActive && + formatDate(customer.lastActive, timezone, 'yyyy-MM-dd')) ?? + ''), + }, + { + header: 'Last transaction', + size: 198, + value: ifNotNull( + customer.lastTxFiat, + <> + + {`${Number.parseFloat(customer.lastTxFiat)} + ${customer.lastTxFiatCode}`} + , + ), + }, + { + header: 'Last used machine', + size: 198, + value: ifNotNull(lastUsedMachineName, <>{lastUsedMachineName}), + }, + ] + + const tableElements = [ + { + width: 40, + view: it => ( + <> + {it.txClass === 'cashOut' ? ( + + ) : ( + + )} + + ), + }, + { + header: 'Machine', + width: 160, + view: R.path(['machineName']), + }, + { + header: 'Transaction ID', + width: 145, + view: it => ( + + {it.id} + + ), + }, + { + header: 'Cash', + width: 155, + textAlign: 'right', + view: it => ( + <> + {`${Number.parseFloat(it.fiat)} `} + {it.fiatCode} + + ), + }, + { + header: 'Crypto', + width: 145, + textAlign: 'right', + view: it => ( + <> + {`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode).toFormat( + 5, + )} `} + {it.cryptoCode} + + ), + }, + { + header: 'Date', + width: 100, + view: it => formatDate(it.created, timezone, 'yyyy‑MM‑dd'), + }, + { + header: 'Time (h:m:s)', + width: 130, + view: it => formatDate(it.created, timezone, 'HH:mm:ss'), + }, + ] + + return ( + <> +

    Transactions

    +
    +
    + {summaryElements.map(({ size, header }, idx) => ( + + {header} + + ))} +
    +
    + {summaryElements.map(({ size, value }, idx) => ( +

    + {value} +

    + ))} +
    +
    +
    + {loading ? ( +

    Loading

    + ) : hasData ? ( + + ) : ( +

    No transactions so far

    + )} +
    + + ) +} + +export default TransactionsList diff --git a/packages/admin-ui/src/pages/Customers/components/Upload.jsx b/packages/admin-ui/src/pages/Customers/components/Upload.jsx new file mode 100644 index 0000000..7434742 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/Upload.jsx @@ -0,0 +1,65 @@ +import { useFormikContext } from 'formik' +import * as R from 'ramda' +import React, { useState, useCallback } from 'react' +import { useDropzone } from 'react-dropzone' +import { Label3, H3 } from '../../../components/typography' +import UploadPhotoIcon from '../../../styling/icons/button/photo/zodiac-resized.svg?react' +import UploadFileIcon from '../../../styling/icons/button/upload-file/zodiac-resized.svg?react' + +import classes from './Upload.module.css' + +const Upload = ({ type }) => { + const [data, setData] = useState({}) + + const { setFieldValue } = useFormikContext() + + const IMAGE = 'image' + const ID_CARD_PHOTO = 'idCardPhoto' + const FRONT_CAMERA = 'frontCamera' + + const isImage = + type === IMAGE || type === FRONT_CAMERA || type === ID_CARD_PHOTO + + const onDrop = useCallback( + acceptedData => { + setFieldValue(type, R.head(acceptedData)) + + setData({ + preview: isImage + ? URL.createObjectURL(R.head(acceptedData)) + : R.head(acceptedData).name, + }) + }, + [isImage, type, setFieldValue], + ) + + const { getRootProps, getInputProps } = useDropzone({ onDrop }) + + return ( + <> +
    + {R.isEmpty(data) && ( +
    + + {isImage ? : } + {`Drag and drop ${ + isImage ? 'an image' : 'a file' + } or click to open the explorer`} +
    + )} + {!R.isEmpty(data) && isImage && ( +
    + +
    + )} + {!R.isEmpty(data) && !isImage && ( +
    +

    {data.preview}

    +
    + )} +
    + + ) +} + +export default Upload diff --git a/packages/admin-ui/src/pages/Customers/components/Upload.module.css b/packages/admin-ui/src/pages/Customers/components/Upload.module.css new file mode 100644 index 0000000..3f7955d --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/Upload.module.css @@ -0,0 +1,14 @@ +.box { + box-sizing: border-box; + width: 450px; + height: 120px; + border-style: dashed; + border-color: var(--comet); + border-radius: 4px; + border-width: 1px; + background-color: var(--zircon); + display: flex; + justify-content: center; + align-items: center; + gap: 20px; +} diff --git a/packages/admin-ui/src/pages/Customers/components/consts.js b/packages/admin-ui/src/pages/Customers/components/consts.js new file mode 100644 index 0000000..5cec0f8 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/consts.js @@ -0,0 +1,5 @@ +const OVERRIDE_PENDING = 'automatic' +const OVERRIDE_AUTHORIZED = 'verified' +const OVERRIDE_REJECTED = 'blocked' + +export { OVERRIDE_PENDING, OVERRIDE_AUTHORIZED, OVERRIDE_REJECTED } diff --git a/packages/admin-ui/src/pages/Customers/components/index.js b/packages/admin-ui/src/pages/Customers/components/index.js new file mode 100644 index 0000000..41020d3 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/index.js @@ -0,0 +1,16 @@ +import Wizard from '../Wizard' + +import CustomerDetails from './CustomerDetails' +import CustomerSidebar from './CustomerSidebar' +import EditableCard from './EditableCard' +import TransactionsList from './TransactionsList' +import Upload from './Upload' + +export { + CustomerDetails, + TransactionsList, + CustomerSidebar, + EditableCard, + Wizard, + Upload, +} diff --git a/packages/admin-ui/src/pages/Customers/components/notes/NewNoteCard.jsx b/packages/admin-ui/src/pages/Customers/components/notes/NewNoteCard.jsx new file mode 100644 index 0000000..3cffcd7 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/notes/NewNoteCard.jsx @@ -0,0 +1,17 @@ +import Paper from '@mui/material/Paper' +import { React } from 'react' +import { P } from '../../../../components/typography' +import AddIcon from '../../../../styling/icons/button/add/zodiac.svg?react' + +const NewNoteCard = ({ setOpenModal }) => { + return ( + setOpenModal(true)}> + +

    Add new

    +
    + ) +} + +export default NewNoteCard diff --git a/packages/admin-ui/src/pages/Customers/components/notes/NewNoteModal.jsx b/packages/admin-ui/src/pages/Customers/components/notes/NewNoteModal.jsx new file mode 100644 index 0000000..e1536d6 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/notes/NewNoteModal.jsx @@ -0,0 +1,74 @@ +import { Form, Formik, Field } from 'formik' +import { React } from 'react' +import ErrorMessage from '../../../../components/ErrorMessage' +import Modal from '../../../../components/Modal' +import * as Yup from 'yup' + +import { Button } from '../../../../components/buttons' +import { TextInput } from '../../../../components/inputs/formik' + +const initialValues = { + title: '', + content: '', +} + +const validationSchema = Yup.object().shape({ + title: Yup.string().required().trim().max(25), + content: Yup.string().required(), +}) + +const NewNoteModal = ({ showModal, onClose, onSubmit, errorMsg }) => { + return ( + <> + + { + onSubmit({ title, content }) + }}> +
    + + +
    + {errorMsg && {errorMsg}} + +
    + +
    +
    + + ) +} + +export default NewNoteModal diff --git a/packages/admin-ui/src/pages/Customers/components/notes/NoteCard.jsx b/packages/admin-ui/src/pages/Customers/components/notes/NoteCard.jsx new file mode 100644 index 0000000..b2c2aab --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/notes/NoteCard.jsx @@ -0,0 +1,48 @@ +import Paper from '@mui/material/Paper' +import * as R from 'ramda' +import { React } from 'react' +import { H3, P } from '../../../../components/typography' +import DeleteIcon from '../../../../styling/icons/action/delete/enabled.svg?react' + +import { formatDate } from '../../../../utils/timezones' + +const formatContent = content => { + const fragments = R.split(/\n/)(content) + return R.map((it, idx) => { + if (idx === fragments.length) return <>{it} + return ( + <> + {it} +
    + + ) + }, fragments) +} + +const NoteCard = ({ note, deleteNote, handleClick, timezone }) => { + return ( + handleClick(note)}> +
    +
    +

    {note?.title}

    +

    {formatDate(note?.created, timezone, 'yyyy-MM-dd')}

    +
    +
    + { + e.stopPropagation() + deleteNote({ noteId: note.id }) + }} + /> +
    +
    +

    + {formatContent(note?.content)} +

    +
    + ) +} + +export default NoteCard diff --git a/packages/admin-ui/src/pages/Customers/components/notes/NoteEdit.jsx b/packages/admin-ui/src/pages/Customers/components/notes/NoteEdit.jsx new file mode 100644 index 0000000..80e352b --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/components/notes/NoteEdit.jsx @@ -0,0 +1,98 @@ +import Paper from '@mui/material/Paper' +import { formatDurationWithOptions, intervalToDuration } from 'date-fns/fp' +import { Form, Formik, Field } from 'formik' +import { React, useRef } from 'react' +import { P } from '../../../../components/typography' +import CancelIconInverse from '../../../../styling/icons/button/cancel/white.svg?react' +import CancelIcon from '../../../../styling/icons/button/cancel/zodiac.svg?react' +import SaveIconInverse from '../../../../styling/icons/circle buttons/save/white.svg?react' +import SaveIcon from '../../../../styling/icons/circle buttons/save/zodiac.svg?react' +import * as Yup from 'yup' + +import { ActionButton } from '../../../../components/buttons' +import { TextInput } from '../../../../components/inputs/formik' +import { toTimezone } from '../../../../utils/timezones' + +const NoteEdit = ({ note, cancel, edit, timezone }) => { + const formRef = useRef() + + const validationSchema = Yup.object().shape({ + content: Yup.string(), + }) + + const initialValues = { + content: note.content, + } + + return ( + +
    +

    + {`Last edited `} + {formatDurationWithOptions( + { delimited: ', ' }, + intervalToDuration({ + start: toTimezone(new Date(note.lastEditedAt), timezone), + end: toTimezone(new Date(), timezone), + }), + )} + {` ago`} +

    +
    + + {`Cancel`} + + + {`Save changes`} + + formRef.current.setFieldValue('content', '')}> + {`Clear content`} + +
    +
    + + edit({ + noteId: note.id, + newContent: content, + oldContent: note.content, + }) + } + innerRef={formRef}> +
    + + +
    +
    + ) +} + +export default NoteEdit diff --git a/packages/admin-ui/src/pages/Customers/helper.jsx b/packages/admin-ui/src/pages/Customers/helper.jsx new file mode 100644 index 0000000..f30561d --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/helper.jsx @@ -0,0 +1,533 @@ +import React from 'react' +import { parse, isValid, format } from 'date-fns/fp' +import { Field, useFormikContext } from 'formik' +import { parsePhoneNumberFromString } from 'libphonenumber-js' +import * as R from 'ramda' +import { H4 } from '../../components/typography' +import { validate as uuidValidate } from 'uuid' +import * as Yup from 'yup' + +import { + RadioGroup, + TextInput, + Autocomplete, +} from '../../components/inputs/formik' +import { MANUAL } from '../../utils/constants' + +import { Upload } from './components' + +const CUSTOMER_BLOCKED = 'blocked' +const CUSTOM = 'custom' +const REQUIREMENT = 'requirement' +const ID_CARD_DATA = 'idCardData' + +const getAuthorizedStatus = (it, triggers, customRequests) => { + const fields = R.concat( + ['frontCamera', 'idCardData', 'idCardPhoto', 'email', 'usSsn', 'sanctions'], + R.map(ite => ite.id, customRequests), + ) + const fieldsWithPathSuffix = ['frontCamera', 'idCardPhoto'] + + const isManualField = fieldName => { + const triggerName = R.equals(fieldName, 'frontCamera') + ? 'facephoto' + : fieldName + const manualOverrides = R.filter( + ite => R.equals(R.toLower(ite.automation), MANUAL), + triggers?.overrides ?? [], + ) + + return ( + !!R.find( + ite => R.equals(ite.requirement, triggerName), + manualOverrides, + ) || R.equals(R.toLower(triggers.automation ?? ''), MANUAL) + ) + } + + const getFieldsByStatus = status => + R.map(ite => { + if (isManualField(ite)) { + if (uuidValidate(ite)) { + const request = R.find( + iter => iter.infoRequestId === ite, + it.customInfoRequests, + ) + return !R.isNil(request) && R.equals(request.override, status) + } + + const regularFieldValue = R.includes(ite, fieldsWithPathSuffix) + ? it[`${ite}Path`] + : it[`${ite}`] + if (R.isNil(regularFieldValue)) return false + return R.equals(it[`${ite}Override`], status) + } + return false + }, fields) + + const pendingFieldStatus = getFieldsByStatus('automatic') + const rejectedFieldStatus = getFieldsByStatus('blocked') + + if (it.authorizedOverride === CUSTOMER_BLOCKED) + return { label: 'Blocked', type: 'error' } + if (it.isSuspended) + return it.daysSuspended > 0 + ? { label: `${it.daysSuspended} day suspension`, type: 'warning' } + : { label: `< 1 day suspension`, type: 'warning' } + if (R.any(ite => ite === true, rejectedFieldStatus)) + return { label: 'Rejected', type: 'error' } + if (R.any(ite => ite === true, pendingFieldStatus)) + return { label: 'Pending', type: 'warning' } + return { label: 'Authorized', type: 'success' } +} + +const getFormattedPhone = (phone, country) => { + const phoneNumber = + phone && country ? parsePhoneNumberFromString(phone, country) : null + + return phoneNumber ? phoneNumber.formatInternational() : phone +} + +const getName = it => { + const idData = R.path(['idCardData'])(it) + + return `${R.path(['firstName'])(idData) ?? ''} ${ + R.path(['lastName'])(idData) ?? '' + }`.trim() +} + +// Manual Entry Wizard + +const entryOptions = [ + { display: 'Custom entry', code: 'custom' }, + { display: 'Populate existing requirement', code: 'requirement' }, +] + +const dataOptions = [ + { display: 'Text', code: 'text' }, + // TODO: Requires backend modifications to support File and Image + // { display: 'File', code: 'file' }, + // { display: 'Image', code: 'image' } +] + +const requirementOptions = [ + { display: 'ID card image', code: 'idCardPhoto' }, + { display: 'ID data', code: 'idCardData' }, + { display: 'US SSN', code: 'usSsn' }, + { display: 'Email', code: 'email' }, + { display: 'Customer camera', code: 'frontCamera' }, +] + +const customTextOptions = [ + { label: 'Data entry title', name: 'title' }, + { label: 'Data entry', name: 'data' }, +] + +const customUploadOptions = [{ label: 'Data entry title', name: 'title' }] + +const entryTypeSchema = Yup.lazy(values => { + if (values.entryType === 'custom') { + return Yup.object().shape({ + entryType: Yup.string().required(), + dataType: Yup.string().required(), + }) + } else if (values.entryType === 'requirement') { + return Yup.object().shape({ + entryType: Yup.string().required(), + requirement: Yup.string().required(), + }) + } +}) + +const customFileSchema = Yup.object().shape({ + title: Yup.string().required(), + file: Yup.mixed().required(), +}) + +const customImageSchema = Yup.object().shape({ + title: Yup.string().required(), + image: Yup.mixed().required(), +}) + +const customTextSchema = Yup.object().shape({ + title: Yup.string().required(), + data: Yup.string().required(), +}) + +const updateRequirementOptions = it => [ + { + display: 'Custom information requirement', + code: 'custom', + }, + ...it, +] + +const EntryType = () => { + const { values } = useFormikContext() + + const displayCustomOptions = values.entryType === CUSTOM + const displayRequirementOptions = values.entryType === REQUIREMENT + + const Entry = ({ title, name, options, className }) => ( +
    +
    +

    {title}

    +
    + +
    + ) + + return ( + <> + + {displayCustomOptions && ( + + )} + {displayRequirementOptions && ( + + )} + + ) +} + +const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => { + const typeOfEntrySelected = selectedValues?.entryType + const dataTypeSelected = selectedValues?.dataType + const requirementSelected = selectedValues?.requirement + + const displayRequirements = typeOfEntrySelected === 'requirement' + + const isCustomInfoRequirement = requirementSelected === CUSTOM + + const updatedRequirementOptions = !R.isEmpty(customInfoRequirementOptions) + ? updateRequirementOptions(requirementOptions) + : requirementOptions + + const requirementName = displayRequirements + ? R.find(R.propEq(requirementSelected, 'code'))(updatedRequirementOptions) + .display + : '' + + const title = displayRequirements + ? `Requirement ${requirementName}` + : `Custom ${dataTypeSelected} entry` + + const elements = displayRequirements + ? requirementElements[requirementSelected] + : customElements[dataTypeSelected] + + const upload = displayRequirements + ? requirementSelected === 'idCardPhoto' || + requirementSelected === 'frontCamera' + : dataTypeSelected === 'file' || dataTypeSelected === 'image' + + return ( + <> +
    +

    {title}

    +
    + {isCustomInfoRequirement && ( + {}} + /> + )} +
    + {!upload && + !isCustomInfoRequirement && + elements.options.map(({ label, name }, idx) => ( + + ))} +
    + {upload && ( + + )} + + ) +} + +const customElements = { + text: { + schema: customTextSchema, + options: customTextOptions, + Component: ManualDataEntry, + initialValues: { data: '', title: '' }, + saveType: 'customEntry', + }, + file: { + schema: customFileSchema, + options: customUploadOptions, + Component: ManualDataEntry, + initialValues: { file: null, title: '' }, + saveType: 'customEntryUpload', + }, + image: { + schema: customImageSchema, + options: customUploadOptions, + Component: ManualDataEntry, + initialValues: { image: null, title: '' }, + saveType: 'customEntryUpload', + }, +} + +const entryType = { + schema: entryTypeSchema, + options: entryOptions, + Component: EntryType, + initialValues: { entryType: '' }, +} + +// Customer data + +const customerDataElements = { + idCardData: [ + { + name: 'firstName', + label: 'First name', + component: TextInput, + editable: true, + }, + { + name: 'documentNumber', + label: 'ID number', + component: TextInput, + editable: true, + }, + { + name: 'dateOfBirth', + label: 'Birthdate', + component: TextInput, + editable: true, + }, + { + name: 'gender', + label: 'Gender', + component: TextInput, + editable: true, + }, + { + name: 'lastName', + label: 'Last name', + component: TextInput, + editable: true, + }, + { + name: 'expirationDate', + label: 'Expiration date', + component: TextInput, + editable: true, + }, + { + name: 'country', + label: 'Country', + component: TextInput, + editable: true, + }, + ], + usSsn: [ + { + name: 'usSsn', + label: 'US SSN', + component: TextInput, + size: 190, + editable: true, + }, + ], + email: [ + { + name: 'email', + label: 'Email', + component: TextInput, + size: 190, + editable: false, + }, + ], + idCardPhoto: [{ name: 'idCardPhoto' }], + frontCamera: [{ name: 'frontCamera' }], +} + +const customerDataSchemas = { + idCardData: Yup.object().shape({ + firstName: Yup.string().required(), + lastName: Yup.string().required(), + documentNumber: Yup.string().required(), + dateOfBirth: Yup.string() + .test({ + test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)), + message: 'Date must be in format YYYY-MM-DD', + }) + .required(), + gender: Yup.string().required(), + country: Yup.string().required(), + expirationDate: Yup.string() + .test({ + test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)), + message: 'Date must be in format YYYY-MM-DD', + }) + .required(), + }), + usSsn: Yup.object().shape({ + usSsn: Yup.string().required(), + }), + idCardPhoto: Yup.object().shape({ + idCardPhoto: Yup.mixed().required(), + }), + frontCamera: Yup.object().shape({ + frontCamera: Yup.mixed().required(), + }), + email: Yup.object().shape({ + email: Yup.string().required(), + }), +} + +const requirementElements = { + idCardData: { + schema: customerDataSchemas.idCardData, + options: customerDataElements.idCardData, + Component: ManualDataEntry, + initialValues: { + firstName: '', + lastName: '', + documentNumber: '', + dateOfBirth: '', + gender: '', + country: '', + expirationDate: '', + }, + saveType: 'customerData', + }, + usSsn: { + schema: customerDataSchemas.usSsn, + options: customerDataElements.usSsn, + Component: ManualDataEntry, + initialValues: { usSsn: '' }, + saveType: 'customerData', + }, + email: { + schema: customerDataSchemas.email, + options: customerDataElements.email, + Component: ManualDataEntry, + initialValues: { email: '' }, + saveType: 'customerData', + }, + idCardPhoto: { + schema: customerDataSchemas.idCardPhoto, + options: customerDataElements.idCardPhoto, + Component: ManualDataEntry, + initialValues: { idCardPhoto: null }, + saveType: 'customerDataUpload', + }, + frontCamera: { + schema: customerDataSchemas.frontCamera, + options: customerDataElements.frontCamera, + Component: ManualDataEntry, + initialValues: { frontCamera: null }, + saveType: 'customerDataUpload', + }, + custom: { + // schema: customerDataSchemas.customInfoRequirement, + Component: ManualDataEntry, + initialValues: { customInfoRequirement: null }, + saveType: 'customInfoRequirement', + }, +} + +const tryFormatDate = rawDate => { + try { + return ( + (rawDate && + format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDate))) ?? + '' + ) + } catch (err) { + console.error(err) + return '' + } +} + +const formatDates = values => { + R.map( + elem => + (values[elem] = format('yyyyMMdd')( + parse(new Date(), 'yyyy-MM-dd', values[elem]), + )), + )(['dateOfBirth', 'expirationDate']) + return values +} + +const mapKeys = pair => { + const [key, value] = pair + if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') { + return ['path', value] + } + if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') { + return ['date', value] + } + return pair +} + +const addPhotoDir = R.map(it => { + const hasFrontCameraData = R.has('id')(it) + return hasFrontCameraData + ? { ...it, photoDir: 'operator-data/customersphotos' } + : { ...it, photoDir: 'front-camera-photo' } +}) + +const standardizeKeys = R.map(R.compose(R.fromPairs, R.map(mapKeys), R.toPairs)) + +const filterByPhotoAvailable = R.filter( + tx => !R.isNil(tx.date) && !R.isNil(tx.path), +) + +const formatPhotosData = R.compose( + filterByPhotoAvailable, + addPhotoDir, + standardizeKeys, +) + +export { + getAuthorizedStatus, + getFormattedPhone, + getName, + entryType, + customElements, + requirementElements, + formatPhotosData, + customerDataElements, + customerDataSchemas, + formatDates, + tryFormatDate, + REQUIREMENT, + CUSTOM, + ID_CARD_DATA, +} diff --git a/packages/admin-ui/src/pages/Customers/helper.test.js b/packages/admin-ui/src/pages/Customers/helper.test.js new file mode 100644 index 0000000..b8836d7 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/helper.test.js @@ -0,0 +1,323 @@ +import { describe, it, expect } from 'vitest' +import { getAuthorizedStatus } from './helper' + +describe('getAuthorizedStatus', () => { + const mockTriggers = { + automation: 'automatic', + overrides: [], + } + + const mockCustomRequests = [{ id: 'custom-req-1' }, { id: 'custom-req-2' }] + + it('should return blocked status when authorizedOverride is blocked', () => { + const customer = { + authorizedOverride: 'blocked', + } + + const result = getAuthorizedStatus( + customer, + mockTriggers, + mockCustomRequests, + ) + + expect(result).toEqual({ + label: 'Blocked', + type: 'error', + }) + }) + + it('should return suspension status when customer is suspended with days > 0', () => { + const customer = { + authorizedOverride: null, + isSuspended: true, + daysSuspended: 5, + } + + const result = getAuthorizedStatus( + customer, + mockTriggers, + mockCustomRequests, + ) + + expect(result).toEqual({ + label: '5 day suspension', + type: 'warning', + }) + }) + + it('should return short suspension status when customer is suspended with days <= 0', () => { + const customer = { + authorizedOverride: null, + isSuspended: true, + daysSuspended: 0, + } + + const result = getAuthorizedStatus( + customer, + mockTriggers, + mockCustomRequests, + ) + + expect(result).toEqual({ + label: '< 1 day suspension', + type: 'warning', + }) + }) + + it('should return rejected status when any field has blocked override', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + emailOverride: 'blocked', + email: 'test@example.com', + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Rejected', + type: 'error', + }) + }) + + it('should return pending status when any field has automatic override', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + emailOverride: 'automatic', + email: 'test@example.com', + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Pending', + type: 'warning', + }) + }) + + it('should return authorized status when no blocking conditions exist', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + } + + const result = getAuthorizedStatus( + customer, + mockTriggers, + mockCustomRequests, + ) + + expect(result).toEqual({ + label: 'Authorized', + type: 'success', + }) + }) + + it('should handle customers with idCardData', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + idCardData: { firstName: 'John', lastName: 'Doe' }, + idCardDataOverride: 'automatic', + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Pending', + type: 'warning', + }) + }) + + it('should handle customers with photo fields using path suffix', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + frontCameraPath: '/path/to/photo.jpg', + frontCameraOverride: 'blocked', + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Rejected', + type: 'error', + }) + }) + + it('should handle custom info requests with UUID validation', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + customInfoRequests: [ + { + infoRequestId: '550e8400-e29b-41d4-a716-446655440000', + override: 'automatic', + }, + ], + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const customRequests = [{ id: '550e8400-e29b-41d4-a716-446655440000' }] + + const result = getAuthorizedStatus(customer, triggers, customRequests) + + expect(result).toEqual({ + label: 'Pending', + type: 'warning', + }) + }) + + it('should handle manual overrides for specific requirements', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + frontCameraPath: '/path/to/photo.jpg', + frontCameraOverride: 'automatic', + } + + const triggers = { + automation: 'automatic', + overrides: [ + { + requirement: 'facephoto', + automation: 'manual', + }, + ], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Pending', + type: 'warning', + }) + }) + + it('should handle null or undefined triggers gracefully', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + } + + const triggers = { + automation: 'automatic', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Authorized', + type: 'success', + }) + }) + + it('should handle empty custom requests array', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + } + + const result = getAuthorizedStatus(customer, mockTriggers, []) + + expect(result).toEqual({ + label: 'Authorized', + type: 'success', + }) + }) + + it('should prioritize blocked status over suspension', () => { + const customer = { + authorizedOverride: 'blocked', + isSuspended: true, + daysSuspended: 5, + } + + const result = getAuthorizedStatus( + customer, + mockTriggers, + mockCustomRequests, + ) + + expect(result).toEqual({ + label: 'Blocked', + type: 'error', + }) + }) + + it('should prioritize rejection over pending status', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + emailOverride: 'blocked', + email: 'test@example.com', + usSsnOverride: 'automatic', + usSsn: '123-45-6789', + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const result = getAuthorizedStatus(customer, triggers, mockCustomRequests) + + expect(result).toEqual({ + label: 'Rejected', + type: 'error', + }) + }) + + it('should return rejected status for blocked custom info request', () => { + const customer = { + authorizedOverride: null, + isSuspended: false, + customInfoRequests: [ + { + infoRequestId: '550e8400-e29b-41d4-a716-446655440000', + override: 'blocked', + }, + ], + } + + const triggers = { + automation: 'manual', + overrides: [], + } + + const customRequests = [{ id: '550e8400-e29b-41d4-a716-446655440000' }] + + const result = getAuthorizedStatus(customer, triggers, customRequests) + + expect(result).toEqual({ + label: 'Rejected', + type: 'error', + }) + }) +}) diff --git a/packages/admin-ui/src/pages/Customers/index.js b/packages/admin-ui/src/pages/Customers/index.js new file mode 100644 index 0000000..0ef3b32 --- /dev/null +++ b/packages/admin-ui/src/pages/Customers/index.js @@ -0,0 +1,4 @@ +import CustomerProfile from './CustomerProfile' +import Customers from './Customers' + +export { Customers, CustomerProfile } diff --git a/packages/admin-ui/src/pages/Dashboard/Alerts/Alerts.jsx b/packages/admin-ui/src/pages/Dashboard/Alerts/Alerts.jsx new file mode 100644 index 0000000..d252a8c --- /dev/null +++ b/packages/admin-ui/src/pages/Dashboard/Alerts/Alerts.jsx @@ -0,0 +1,90 @@ +import { useQuery, gql } from '@apollo/client' +import Button from '@mui/material/Button' +import classnames from 'classnames' +import * as R from 'ramda' +import React from 'react' +import { cardState } from '../../../components/CollapsibleCard' +import { Label1, H4 } from '../../../components/typography' + +import AlertsTable from './AlertsTable' + +const NUM_TO_RENDER = 3 + +const GET_ALERTS = gql` + query getAlerts { + alerts { + id + type + detail + message + created + read + valid + } + machines { + deviceId + name + } + } +` + +const Alerts = ({ onReset, onExpand, size }) => { + const showAllItems = size === cardState.EXPANDED + const { data } = useQuery(GET_ALERTS) + const alerts = R.path(['alerts'])(data) ?? [] + const machines = R.compose( + R.map(R.prop('name')), + R.indexBy(R.prop('deviceId')), + )(data?.machines ?? []) + const alertsLength = alerts.length + + return ( + <> +
    +

    {`Alerts (${alertsLength})`}

    + {showAllItems && ( + + + + )} +
    +
    +
    + {!alerts.length && ( + + No new alerts. Your system is running smoothly. + + )} + +
    +
    + {!showAllItems && alertsLength > NUM_TO_RENDER && ( +
    + + + +
    + )} + + ) +} +export default Alerts diff --git a/packages/admin-ui/src/pages/Dashboard/Alerts/AlertsTable.jsx b/packages/admin-ui/src/pages/Dashboard/Alerts/AlertsTable.jsx new file mode 100644 index 0000000..504a044 --- /dev/null +++ b/packages/admin-ui/src/pages/Dashboard/Alerts/AlertsTable.jsx @@ -0,0 +1,57 @@ +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import * as R from 'ramda' +import React from 'react' +import { useLocation } from 'wouter' +import { P } from '../../../components/typography/index' +import Wrench from '../../../styling/icons/action/wrench/zodiac.svg?react' +import CashBoxEmpty from '../../../styling/icons/cassettes/cashbox-empty.svg?react' +import AlertLinkIcon from '../../../styling/icons/month arrows/right.svg?react' +import WarningIcon from '../../../styling/icons/warning-icon/tomato.svg?react' + +const icons = { + error: , + fiatBalance: ( + + ), +} + +const links = { + error: '/maintenance/machine-status', + fiatBalance: '/maintenance/cash-units', + cryptoBalance: '/maintenance/funding', +} + +const AlertsTable = ({ numToRender, alerts, machines }) => { + const [, navigate] = useLocation() + const alertsToRender = R.slice(0, numToRender, alerts) + + const alertMessage = alert => { + const deviceId = alert.detail.deviceId + if (!deviceId) return `${alert.message}` + + const deviceName = R.defaultTo('Unpaired device', machines[deviceId]) + return `${alert.message} - ${deviceName}` + } + + return ( + + {alertsToRender.map((alert, idx) => { + return ( + + {icons[alert.type] || ( + + )} +

    {alertMessage(alert)}

    + navigate(links[alert.type] || '/dashboard')} + /> +
    + ) + })} +
    + ) +} + +export default AlertsTable diff --git a/packages/admin-ui/src/pages/Dashboard/Alerts/index.js b/packages/admin-ui/src/pages/Dashboard/Alerts/index.js new file mode 100644 index 0000000..6284408 --- /dev/null +++ b/packages/admin-ui/src/pages/Dashboard/Alerts/index.js @@ -0,0 +1,3 @@ +import Alerts from './Alerts' + +export default Alerts diff --git a/packages/admin-ui/src/pages/Dashboard/Dashboard.jsx b/packages/admin-ui/src/pages/Dashboard/Dashboard.jsx new file mode 100644 index 0000000..f27f3b0 --- /dev/null +++ b/packages/admin-ui/src/pages/Dashboard/Dashboard.jsx @@ -0,0 +1,103 @@ +import { useQuery, gql } from '@apollo/client' +import * as R from 'ramda' +import React, { useState } from 'react' +import { useLocation } from 'wouter' +import TitleSection from '../../components/layout/TitleSection' +import { H1, Info2, TL2, Label1 } from '../../components/typography' +import TxInIcon from '../../styling/icons/direction/cash-in.svg?react' +import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react' + +import { Button } from '../../components/buttons' +import AddMachine from '../AddMachine' +import { errorColor } from '../../styling/variables' + +import Footer from './Footer' +import RightSide from './RightSide' +import Paper from '@mui/material/Paper' +import SystemPerformance from './SystemPerformance/index.js' + +const GET_DATA = gql` + query getData { + machines { + name + } + serverVersion + } +` + +const Dashboard = () => { + const [, navigate] = useLocation() + const [open, setOpen] = useState(false) + + const { data, loading } = useQuery(GET_DATA) + + const onPaired = machine => { + setOpen(false) + navigate('/maintenance/machine-status', { state: { id: machine.deviceId } }) + } + + return !loading ? ( + !R.isEmpty(data.machines) ? ( + <> + +
    +
    + + Cash-in +
    +
    + + Cash-out +
    +
    + + + + Action Required +
    +
    +
    +
    +
    + + + +
    +
    + +
    +
    +