diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6d0e622407..d1bf523d3b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -151,11 +151,6 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v2
- - name: build launchpad gui-vue
- run: |
- cd applications/launchpad/gui-vue
- npm ci
- npm run build
- name: build collectibles web-app
run: |
cd applications/tari_collectibles/web-app
diff --git a/Cargo.lock b/Cargo.lock
index 79abbecab3..5c318d1c73 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -246,6 +246,15 @@ dependencies = [
"system-deps 6.0.2",
]
+[[package]]
+name = "atoi"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "attohttpc"
version = "0.19.1"
@@ -1127,6 +1136,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba"
+[[package]]
+name = "crc"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403"
+
[[package]]
name = "crc24"
version = "0.1.6"
@@ -1871,6 +1895,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
+[[package]]
+name = "dotenv"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+
[[package]]
name = "dtoa"
version = "0.4.8"
@@ -1915,6 +1945,19 @@ version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
+[[package]]
+name = "embed-resource"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc24ff8d764818e9ab17963b0593c535f077a513f565e75e4352d758bc4d8c0"
+dependencies = [
+ "cc",
+ "rustc_version 0.4.0",
+ "toml",
+ "vswhom",
+ "winreg",
+]
+
[[package]]
name = "embed_plist"
version = "1.2.2"
@@ -2237,6 +2280,17 @@ dependencies = [
"futures-util",
]
+[[package]]
+name = "futures-intrusive"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e"
+dependencies = [
+ "futures-core",
+ "lock_api 0.4.7",
+ "parking_lot 0.11.2",
+]
+
[[package]]
name = "futures-io"
version = "0.3.21"
@@ -2674,11 +2728,20 @@ version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
+[[package]]
+name = "hashbrown"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
+
[[package]]
name = "hashbrown"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
+dependencies = [
+ "ahash",
+]
[[package]]
name = "hashbrown"
@@ -2689,6 +2752,15 @@ dependencies = [
"ahash",
]
+[[package]]
+name = "hashlink"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf"
+dependencies = [
+ "hashbrown 0.11.2",
+]
+
[[package]]
name = "hdrhistogram"
version = "7.5.0"
@@ -2961,18 +3033,31 @@ dependencies = [
"byteorder",
"color_quant",
"num-iter",
- "num-rational",
+ "num-rational 0.3.2",
+ "num-traits",
+]
+
+[[package]]
+name = "image"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e30ca2ecf7666107ff827a8e481de6a132a9b687ed3bb20bb1c144a36c00964"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "color_quant",
+ "num-rational 0.4.1",
"num-traits",
]
[[package]]
name = "indexmap"
-version = "1.8.2"
+version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a"
+checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3"
dependencies = [
"autocfg",
- "hashbrown 0.11.2",
+ "hashbrown 0.9.1",
]
[[package]]
@@ -3354,6 +3439,15 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "line-wrap"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
+dependencies = [
+ "safemem",
+]
+
[[package]]
name = "linked-hash-map"
version = "0.5.4"
@@ -3982,6 +4076,17 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-rational"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
[[package]]
name = "num-traits"
version = "0.2.15"
@@ -4499,9 +4604,9 @@ dependencies = [
[[package]]
name = "petgraph"
-version = "0.6.2"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143"
+checksum = "4a13a2fa9d0b63e5f22328828741e523766fff0ee9e779316902290dff3f824f"
dependencies = [
"fixedbitset 0.4.1",
"indexmap",
@@ -4739,6 +4844,20 @@ version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
+[[package]]
+name = "plist"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225"
+dependencies = [
+ "base64 0.13.0",
+ "indexmap",
+ "line-wrap",
+ "serde",
+ "time 0.3.9",
+ "xml-rs",
+]
+
[[package]]
name = "plotters"
version = "0.3.1"
@@ -4923,7 +5042,7 @@ dependencies = [
"lazy_static",
"log",
"multimap",
- "petgraph 0.6.2",
+ "petgraph 0.6.0",
"prost",
"prost-types",
"regex",
@@ -4967,7 +5086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d2f1455f3630c6e5107b4f2b94e74d76dea80736de0981fd27644216cff57f"
dependencies = [
"checked_int_cast",
- "image",
+ "image 0.23.14",
]
[[package]]
@@ -5278,12 +5397,13 @@ dependencies = [
[[package]]
name = "rfd"
-version = "0.8.4"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f756b55bff8f256a1a8c24dbabb1430ac8110628e418a02e4a1c5ff67179f56"
+checksum = "f121348fd3b9035ed11be1f028e8944263c30641f8c5deacf57a4320782fb402"
dependencies = [
"block",
"dispatch",
+ "embed-resource",
"glib-sys",
"gobject-sys",
"gtk-sys",
@@ -5421,6 +5541,19 @@ dependencies = [
"winapi 0.3.9",
]
+[[package]]
+name = "rustls"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7"
+dependencies = [
+ "base64 0.13.0",
+ "log",
+ "ring",
+ "sct 0.6.1",
+ "webpki 0.21.4",
+]
+
[[package]]
name = "rustls"
version = "0.20.6"
@@ -5429,7 +5562,7 @@ checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033"
dependencies = [
"log",
"ring",
- "sct",
+ "sct 0.7.0",
"webpki 0.22.0",
]
@@ -5534,6 +5667,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+[[package]]
+name = "sct"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "sct"
version = "0.7.0"
@@ -5669,6 +5812,7 @@ version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
dependencies = [
+ "indexmap",
"itoa 1.0.2",
"ryu",
"serde",
@@ -6003,6 +6147,105 @@ dependencies = [
"der",
]
+[[package]]
+name = "sqlformat"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4"
+dependencies = [
+ "itertools 0.10.3",
+ "nom 7.1.1",
+ "unicode_categories",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e4b94ab0f8c21ee4899b93b06451ef5d965f1a355982ee73684338228498440"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec28b91a01e1fe286d6ba66f68289a2286df023fc97444e1fd86c2fd6d5dc026"
+dependencies = [
+ "ahash",
+ "atoi",
+ "bitflags 1.3.2",
+ "byteorder",
+ "bytes 1.1.0",
+ "crc",
+ "crossbeam-channel 0.5.4",
+ "crossbeam-queue",
+ "crossbeam-utils 0.8.8",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-intrusive",
+ "futures-util",
+ "hashlink",
+ "hex",
+ "itoa 0.4.8",
+ "libc",
+ "libsqlite3-sys",
+ "log",
+ "memchr",
+ "once_cell",
+ "parking_lot 0.11.2",
+ "percent-encoding 2.1.0",
+ "rustls 0.19.1",
+ "serde",
+ "serde_json",
+ "sha2 0.9.9",
+ "smallvec",
+ "sqlformat",
+ "sqlx-rt",
+ "stringprep",
+ "thiserror",
+ "tokio-stream",
+ "url 2.2.2",
+ "webpki 0.21.4",
+ "webpki-roots",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dc33c35d54774eed73d54568d47a6ac099aed8af5e1556a017c131be88217d5"
+dependencies = [
+ "dotenv",
+ "either",
+ "futures 0.3.21",
+ "heck 0.3.3",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde_json",
+ "sha2 0.9.9",
+ "sqlx-core",
+ "sqlx-rt",
+ "syn",
+ "url 2.2.2",
+]
+
+[[package]]
+name = "sqlx-rt"
+version = "0.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4db708cd3e459078f85f39f96a00960bd841f66ee2a669e90bf36907f5a79aae"
+dependencies = [
+ "once_cell",
+ "tokio 1.19.2",
+ "tokio-rustls 0.22.0",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -6062,6 +6305,16 @@ dependencies = [
"quote",
]
+[[package]]
+name = "stringprep"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
[[package]]
name = "strsim"
version = "0.8.0"
@@ -6213,9 +6466,9 @@ dependencies = [
[[package]]
name = "tao"
-version = "0.10.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2497feadd60f2a5a7f124572d7a44b2aba589a0ad2a65d3aaf2d073c327c3b8"
+checksum = "a71c32c2fa7bba46b01becf9cf470f6a781573af7e376c5e317a313ecce27545"
dependencies = [
"bitflags 1.3.2",
"cairo-rs",
@@ -6233,6 +6486,7 @@ dependencies = [
"glib",
"glib-sys",
"gtk",
+ "image 0.24.3",
"instant",
"jni 0.19.0",
"lazy_static",
@@ -6245,28 +6499,17 @@ dependencies = [
"once_cell",
"parking_lot 0.11.2",
"paste",
+ "png 0.17.5",
"raw-window-handle",
"scopeguard",
"serde",
- "tao-core-video-sys",
"unicode-segmentation",
+ "uuid 0.8.2",
"windows 0.37.0",
"windows-implement",
"x11-dl",
]
-[[package]]
-name = "tao-core-video-sys"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6"
-dependencies = [
- "cfg-if 1.0.0",
- "core-foundation-sys",
- "libc",
- "objc",
-]
-
[[package]]
name = "tar"
version = "0.4.38"
@@ -6821,20 +7064,28 @@ dependencies = [
"derivative",
"env_logger 0.9.0",
"futures 0.3.21",
+ "hex",
+ "indexmap",
+ "lazy_static",
"log",
"rand 0.8.5",
"regex",
+ "reqwest",
"serde",
"serde_json",
"strum 0.23.0",
"strum_macros 0.23.1",
+ "tari_app_grpc",
"tari_app_utilities",
"tari_common",
+ "tari_common_types",
"tari_comms",
"tauri",
"tauri-build",
+ "tauri-plugin-sql",
"thiserror",
"tokio 1.19.2",
+ "tonic",
"tor-hash-passwd",
]
@@ -6995,7 +7246,7 @@ dependencies = [
"prost",
"rand 0.8.5",
"reqwest",
- "rustls",
+ "rustls 0.20.6",
"semver 1.0.10",
"serde",
"serde_derive",
@@ -7245,9 +7496,9 @@ dependencies = [
[[package]]
name = "tauri"
-version = "1.0.0-rc.15"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb533e95e09fd191ef8e0a0ee6b61701b5f32175e48f82854a71a8f8367bdb41"
+checksum = "421641ec549d34935530886151a42ce5ecbbb57beb30e5eec1b22f8e08e10ee9"
dependencies = [
"anyhow",
"attohttpc",
@@ -7299,13 +7550,14 @@ dependencies = [
[[package]]
name = "tauri-build"
-version = "1.0.0-rc.13"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58f9a1c87ad53f584f970b06c9243b39d1c2aeca6116dd04c641f406133053b0"
+checksum = "acafb1c515c5d14234a294461bd43c723639a84891a45f6a250fd3441ad2e8ed"
dependencies = [
"anyhow",
"cargo_toml",
"heck 0.4.0",
+ "json-patch",
"semver 1.0.10",
"serde_json",
"tauri-utils",
@@ -7314,13 +7566,14 @@ dependencies = [
[[package]]
name = "tauri-codegen"
-version = "1.0.0-rc.9"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ceb3b7cb66f1a6ca30f601cccfa01820477881c27412909a3e6f80b6a2f73815"
+checksum = "048a7b404b92c86e7dc32458fd0963f042a76d520681e6f598d73a97c2feeeef"
dependencies = [
"base64 0.13.0",
"brotli",
"ico",
+ "plist",
"png 0.17.5",
"proc-macro2",
"quote",
@@ -7331,15 +7584,16 @@ dependencies = [
"sha2 0.10.2",
"tauri-utils",
"thiserror",
+ "time 0.3.9",
"uuid 1.1.2",
"walkdir",
]
[[package]]
name = "tauri-macros"
-version = "1.0.0-rc.9"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07883238ade4c96be38a6a0025f15cbb5e0539fe99ba92d444af9cdbc656b613"
+checksum = "aaf70098bfab21efde9b2c089008b319ba333f4ee6e55c38bdea188dea86497f"
dependencies = [
"heck 0.4.0",
"proc-macro2",
@@ -7349,16 +7603,31 @@ dependencies = [
"tauri-utils",
]
+[[package]]
+name = "tauri-plugin-sql"
+version = "0.0.1"
+source = "git+https://github.com/tauri-apps/tauri-plugin-sql?branch=release#b8586fbbf8bb259170c588cb992b5dc5bd9c704e"
+dependencies = [
+ "futures 0.3.21",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tauri",
+ "thiserror",
+ "tokio 1.19.2",
+]
+
[[package]]
name = "tauri-runtime"
-version = "0.7.0"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bad3a8ce06d4e71a52efef175446c8eb7e9109b6f988782fdc6a234526f226a"
+checksum = "4e4cff3b4d9469727fa2107c4b3d2eda110df1ba45103fb420178e536362fae4"
dependencies = [
"gtk",
"http",
"http-range",
"infer",
+ "raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
@@ -7370,14 +7639,15 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
-version = "0.7.0"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c2cbc41ea88305f3e5dc133cc5c717c6913a15f121f99e7a238a0135dac083e"
+checksum = "3fa8c4edaf01d8b556e7172c844b1b4dd3399adcd1a606bd520fc3e65f698546"
dependencies = [
"cocoa",
"gtk",
"percent-encoding 2.1.0",
"rand 0.8.5",
+ "raw-window-handle",
"tauri-runtime",
"tauri-utils",
"uuid 1.1.2",
@@ -7389,9 +7659,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
-version = "1.0.0-rc.9"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b09e7ec7933a833f3b64e932a9d32d94705687aa5caa3cacf43222876a6d7e24"
+checksum = "12ff4b68d9faeb57c9c727bf58c9c9768d2b67d8e84e62ce6146e7859a2e9c6b"
dependencies = [
"brotli",
"ctor",
@@ -7411,6 +7681,7 @@ dependencies = [
"thiserror",
"url 2.2.2",
"walkdir",
+ "windows 0.37.0",
]
[[package]]
@@ -7562,6 +7833,7 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd"
dependencies = [
+ "itoa 1.0.2",
"libc",
"num_threads",
]
@@ -7664,13 +7936,24 @@ dependencies = [
"tokio 1.19.2",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6"
+dependencies = [
+ "rustls 0.19.1",
+ "tokio 1.19.2",
+ "webpki 0.21.4",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [
- "rustls",
+ "rustls 0.20.6",
"tokio 1.19.2",
"webpki 0.22.0",
]
@@ -7991,7 +8274,7 @@ dependencies = [
"radix_trie",
"rand 0.8.5",
"ring",
- "rustls",
+ "rustls 0.20.6",
"thiserror",
"time 0.3.9",
"tokio 1.19.2",
@@ -8018,13 +8301,13 @@ dependencies = [
"log",
"rand 0.8.5",
"ring",
- "rustls",
+ "rustls 0.20.6",
"rustls-pemfile",
"smallvec",
"thiserror",
"tinyvec",
"tokio 1.19.2",
- "tokio-rustls",
+ "tokio-rustls 0.23.4",
"url 2.2.2",
"webpki 0.22.0",
]
@@ -8140,6 +8423,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "universal-hash"
version = "0.4.1"
@@ -8268,6 +8557,26 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22025f6d8eb903ebf920ea6933b70b1e495be37e2cb4099e62c80454aaf57c39"
+dependencies = [
+ "cc",
+ "libc",
+]
+
[[package]]
name = "waker-fn"
version = "1.1.0"
@@ -8510,6 +8819,15 @@ dependencies = [
"untrusted",
]
+[[package]]
+name = "webpki-roots"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940"
+dependencies = [
+ "webpki 0.21.4",
+]
+
[[package]]
name = "webview2-com"
version = "0.16.0"
@@ -8558,6 +8876,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "whoami"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524b58fa5a20a2fb3014dd6358b70e6579692a56ef6fce928834e488f42f65e8"
+dependencies = [
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "wildmatch"
version = "2.1.0"
@@ -8836,9 +9164,9 @@ dependencies = [
[[package]]
name = "wry"
-version = "0.18.2"
+version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "138e84a6f7f0ef90004a244a6dd4125b5fb78074b48c4369ab52b3cac68a863e"
+checksum = "ce19dddbd3ce01dc8f14eb6d4c8f914123bf8379aaa838f6da4f981ff7104a3f"
dependencies = [
"block",
"cocoa",
diff --git a/applications/launchpad/.gitignore b/applications/launchpad/.gitignore
index b1d86c6ef2..157cc52b1f 100644
--- a/applications/launchpad/.gitignore
+++ b/applications/launchpad/.gitignore
@@ -21,3 +21,6 @@ gui/build
gui/npm-debug.log*
gui/yarn-debug.log*
gui/yarn-error.log*
+
+docker_rig/logs/data
+docker_rig/logs/positions.yml
diff --git a/applications/launchpad/3rdparty.json b/applications/launchpad/3rdparty.json
new file mode 100644
index 0000000000..50a57cf42c
--- /dev/null
+++ b/applications/launchpad/3rdparty.json
@@ -0,0 +1,11 @@
+[
+ {
+ "image_name": "monerod"
+ },
+ {
+ "image_name": "tor"
+ },
+ {
+ "image_name": "xmrig"
+ }
+]
diff --git a/applications/launchpad/README.md b/applications/launchpad/README.md
new file mode 100644
index 0000000000..608708d984
--- /dev/null
+++ b/applications/launchpad/README.md
@@ -0,0 +1,131 @@
+# Tari Launchpad - Tauri edition
+
+a.k.a. _Tari one-click miner_.
+
+## Prerequisites
+
+1. Rust and cargo (https://www.rust-lang.org/tools/install).
+2. NodeJs (v16.0 or higher) and Yarn (v 1.22 or higher).
+3. Tauri CLI (`cargo install tauri-cli`). _Optional_.
+4. [Docker](https://docs.docker.com/get-docker/) is not _strictly_ a pre-requisite, since the launchpad on-boarding
+ flow will install it for you, but you will need docker eventually, so putting it here.
+5. Install the front-end dependencies
+ ```text
+ $ cd applications/launchpad
+ $ yarn
+ $ cd gui-react
+ $ yarn
+ ```
+
+## Running a development version of launchpad
+
+These commands
+* build the launchpad ReactJs front-end and launch it in development mode.
+* build the backend in debug mode
+* launch the application
+
+```
+$ cd applications/launchpad
+$ yarn run tauri dev
+```
+
+### Debugging
+The console relays debug messages from the launchpad backend (a Rust application).
+The front-end is a standard ReactJs application wrapped inside a [Tauri](https://tauri.studio) desktop application.
+You can open a standard browser console in the front-end to debug front-end issues.
+
+
+**Tip:** If you receive the following error
+`Unable to create base node...` there was a problem packaging the assets for the app.
+
+## Building a production release
+To build a production release, which also includes a bundled installer (.dmg on mac, .deb on linux, .msi on windows),
+you can execute:
+
+```
+$ cd applications/launchpad
+$ yarn run tauri build
+```
+
+
+
+## Viewing logs and configuration files
+
+You can use the bundled Grafana environment that is packaged with launchpad to view log files. Or you can use your
+favorite text editor instead.
+
+Logs and configuration files are stored in
+* MacOs: `~/Library/Caches/tari/tmp/dibbler/{app}/log`,
+* Linux: `~/.cache/tari/tmp/dibbler/{app}/log`,
+* Windows: `???`
+
+You can edit the log configuration, `dibbler/config/log4rs.yml` to change the log level, output etc. Changes are
+picked up on the fly and take effect within 30s.
+
+## Miscellaneous notes
+
+* The blockchain data is stores in docker volumes, and not on the host machine directly. This is due to crippling performance
+limitations one suffers when mounting host file system from Windows or MacOS into docker containers.
+This isn't a big drawback, since you seldom want or need to access the raw blockchain database files anyway. Are they're
+[still accessible](#accessing-blockchain-data). But **ensure that you reserve enough space to store the Tari, and optionally,
+Monero blockchains inside the Docker VM**.
+
+### Accessing blockchain data
+
+The blockchain data is stored in a docker volume for performance reasons. If you need to back up or access the LMDB
+a blockchain data, you can use something like this to extract it to the host filesystem:
+
+`docker run --rm -v $(pwd):/backup -v blockchain:/blockchain ubuntu tar czvf /backup/backup.tar.gz /blockchain`
+
+
+## Layout
+
+ +-----------------------+
+ | |
+ +---->| Console Wallet +------------------+
+ | | | |
+ | +----------+------------+ |
+ | | |
+ | | gRPC |
+ | | |
+ | | |
+ | +----------v------------+ +------v-----+
+ | | | Socks5 | |
+ | | Base Node +---------->| Tor |----> Network
+ | | | | |
+ | +----------^------------+ +------------+
+ | |
+ | |
+ | |
+ | +----------+------------+
+ | | |
+ +-----+ SHA3-Miner |
+ | | |
+ | +-----------------------+
+ |
+ |
+ |
+ | +-----------------------+
+ | | |
+ +-----+ XMRRig etc |
+ | |
+ +-----------------------+
+
+## Building custom docker images
+
+The docker images for the base node, wallet etc. are designed to handle the broadest set of chipsets and
+architectures. For this reason, they not be optimal for _your_ system. You can build custom images for launchpad
+using the `build_images.sh` script in this folder.
+
+Refer to that script for further details and build options.
+
+There are a set of files in this folder that offer a convenient way of setting the environment up for some common
+configurations.
+
+run `source {env_config}.env` to set up the environment. Currently, the presets are:
+
+* `local-performance-amd64.env`: For building local images with Intel/AMD and AVX-2 chipset support (about 2x
+ speedup on crypto operations)
+* `local-performance-arm64.env`: For building local images for Apple M-series CPUs.
+* `hosted-dual.env`: Replicates the CI enviroment. Builds safe multi-arch images and pushes them to the docker repo
+ (requires a write access token).
\ No newline at end of file
diff --git a/applications/launchpad/backend/Cargo.toml b/applications/launchpad/backend/Cargo.toml
index 724370f14e..417d43936b 100644
--- a/applications/launchpad/backend/Cargo.toml
+++ b/applications/launchpad/backend/Cargo.toml
@@ -11,29 +11,43 @@ build = "src/build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
-tauri-build = { version = "1.0.0-rc.5", features = [] }
+tauri-build = { version = "1.0.1", features = [] }
[dependencies]
-tari_app_utilities = { version = "^0.32", path = "../../tari_app_utilities" }
+tari_common_types = { path = "../../../base_layer/common_types"}
+tari_app_utilities = { path = "../../tari_app_utilities" }
tari_comms = { version = "^0.32", path = "../../../comms/core" }
+tari_app_grpc = { path = "../../tari_app_grpc" }
tari_common = { path="../../../common"}
bollard = "0.11.1"
config = "0.13.0"
env_logger = "0.9.0"
+lazy_static = "1.3.0"
log = "0.4.14"
rand = "0.8.4"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
strum = "0.23.0"
strum_macros = "0.23.0"
-tauri = { version = "1.0.0-rc.6", features = ["api-all", "cli"] }
+tauri = { version = "1.0.1", features = ["api-all", "cli", "macos-private-api"] }
tor-hash-passwd = "1.0.1"
thiserror = "1.0.30"
tokio = { version = "1.9", features= ["sync"] }
futures = "0.3"
regex= "1.5.4"
derivative = "2.2.0"
+tonic = "0.6.2"
+# need to force this version to avoid circular dependency in tauri-plugin-sql deps
+# https://github.com/tkaitchuck/aHash/issues/95#issuecomment-881152315
+indexmap = "~1.6.2"
+hex = "0.4.3"
+reqwest = {version = "0.11", features= ["json"] }
+
+[dependencies.tauri-plugin-sql]
+git = "https://github.com/tauri-apps/tauri-plugin-sql"
+features = ["sqlite"] # or "postgres", or "mysql"
+branch = "release"
[features]
default = [ "custom-protocol" ]
diff --git a/applications/launchpad/backend/assets/defaults.ini b/applications/launchpad/backend/assets/defaults.ini
new file mode 100644
index 0000000000..2c33905d13
--- /dev/null
+++ b/applications/launchpad/backend/assets/defaults.ini
@@ -0,0 +1,1241 @@
+##################### Grafana Configuration Defaults #####################
+#
+# Do not modify this file in grafana installs
+#
+
+# possible values : production, development
+app_mode = production
+
+# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty
+instance_name = ${HOSTNAME}
+
+# force migration will run migrations that might cause dataloss
+force_migration = false
+
+#################################### Paths ###############################
+[paths]
+# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
+data = /var/grafana/data
+
+# Temporary files in `data` directory older than given duration will be removed
+temp_data_lifetime = 24h
+
+# Directory where grafana can store logs
+logs = /var/tari/grafana/logs
+
+# Directory where grafana will automatically scan and look for plugins
+plugins = data/plugins
+
+# folder that contains provisioning config files that grafana will apply on startup and while running.
+provisioning = conf/provisioning
+
+#################################### Server ##############################
+[server]
+# Protocol (http, https, h2, socket)
+protocol = http
+
+# The ip address to bind to, empty will bind to all interfaces
+http_addr =
+
+# The http port to use
+http_port = 18300
+
+# The public facing domain name used to access grafana from a browser
+domain = localhost
+
+# Redirect to correct domain if host header does not match domain
+# Prevents DNS rebinding attacks
+enforce_domain = false
+
+# The full public facing url
+root_url = %(protocol)s://%(domain)s:%(http_port)s/
+
+# Serve Grafana from subpath specified in `root_url` setting. By default it is set to `false` for compatibility reasons.
+serve_from_sub_path = false
+
+# Log web requests
+router_logging = false
+
+# the path relative working path
+static_root_path = public
+
+# enable gzip
+enable_gzip = false
+
+# https certs & key file
+cert_file =
+cert_key =
+
+# Unix socket path
+socket = /tmp/grafana.sock
+
+# CDN Url
+cdn_url =
+
+# Sets the maximum time in minutes before timing out read of an incoming request and closing idle connections.
+# `0` means there is no timeout for reading the request.
+read_timeout = 0
+
+#################################### Database ############################
+[database]
+# You can configure the database connection by specifying type, host, name, user and password
+# as separate properties or as on string using the url property.
+
+# Either "mysql", "postgres" or "sqlite3", it's your choice
+type = sqlite3
+host = 127.0.0.1:3306
+name = grafana
+user = root
+# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
+password =
+# Use either URL or the previous fields to configure the database
+# Example: mysql://user:secret@host:port/database
+url =
+
+# Max idle conn setting default is 2
+max_idle_conn = 2
+
+# Max conn setting default is 0 (mean not set)
+max_open_conn =
+
+# Connection Max Lifetime default is 14400 (means 14400 seconds or 4 hours)
+conn_max_lifetime = 14400
+
+# Set to true to log the sql calls and execution times.
+log_queries =
+
+# For "postgres", use either "disable", "require" or "verify-full"
+# For "mysql", use either "true", "false", or "skip-verify".
+ssl_mode = disable
+
+# Database drivers may support different transaction isolation levels.
+# Currently, only "mysql" driver supports isolation levels.
+# If the value is empty - driver's default isolation level is applied.
+# For "mysql" use "READ-UNCOMMITTED", "READ-COMMITTED", "REPEATABLE-READ" or "SERIALIZABLE".
+isolation_level =
+
+ca_cert_path =
+client_key_path =
+client_cert_path =
+server_cert_name =
+
+# For "sqlite3" only, path relative to data_path setting
+path = grafana.db
+
+# For "sqlite3" only. cache mode setting used for connecting to the database
+cache_mode = private
+
+# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
+locking_attempt_timeout_sec = 0
+
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`. Only addr is required. ssl may be 'true', 'false', or 'insecure'.
+# memcache: 127.0.0.1:11211
+connstr =
+
+#################################### Data proxy ###########################
+[dataproxy]
+
+# This enables data proxy logging, default is false
+logging = false
+
+# How long the data proxy waits to read the headers of the response before timing out, default is 30 seconds.
+# This setting also applies to core backend HTTP data sources where query requests use an HTTP client with timeout set.
+timeout = 30
+
+# How long the data proxy waits to establish a TCP connection before timing out, default is 10 seconds.
+dialTimeout = 10
+
+# How many seconds the data proxy waits before sending a keepalive request.
+keep_alive_seconds = 30
+
+# How many seconds the data proxy waits for a successful TLS Handshake before timing out.
+tls_handshake_timeout_seconds = 10
+
+# How many seconds the data proxy will wait for a server's first response headers after
+# fully writing the request headers if the request has an "Expect: 100-continue"
+# header. A value of 0 will result in the body being sent immediately, without
+# waiting for the server to approve.
+expect_continue_timeout_seconds = 1
+
+# Optionally limits the total number of connections per host, including connections in the dialing,
+# active, and idle states. On limit violation, dials will block.
+# A value of zero (0) means no limit.
+max_conns_per_host = 0
+
+# The maximum number of idle connections that Grafana will keep alive.
+max_idle_connections = 100
+
+# How many seconds the data proxy keeps an idle connection open before timing out.
+idle_conn_timeout_seconds = 90
+
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request.
+send_user_header = false
+
+# Limit the amount of bytes that will be read/accepted from responses of outgoing HTTP requests.
+response_limit = 0
+
+# Limits the number of rows that Grafana will process from SQL data sources.
+row_limit = 1000000
+
+#################################### Analytics ###########################
+[analytics]
+# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
+# No ip addresses are being tracked, only simple counters to track
+# running instances, dashboard and error counts. It is very helpful to us.
+# Change this option to false to disable reporting.
+reporting_enabled = false
+
+# The name of the distributor of the Grafana instance. Ex hosted-grafana, grafana-labs
+reporting_distributor = grafana-labs
+
+# Set to false to disable all checks to https://grafana.com
+# for new versions of grafana. The check is used
+# in some UI views to notify that a grafana update exists.
+# This option does not cause any auto updates, nor send any information
+# only a GET request to https://raw.githubusercontent.com/grafana/grafana/main/latest.json to get the latest version.
+check_for_updates = false
+
+# Set to false to disable all checks to https://grafana.com
+# for new versions of plugins. The check is used
+# in some UI views to notify that a plugin update exists.
+# This option does not cause any auto updates, nor send any information
+# only a GET request to https://grafana.com to get the latest versions.
+check_for_plugin_updates = false
+
+# Google Analytics universal tracking code, only enabled if you specify an id here
+google_analytics_ua_id =
+
+# Google Tag Manager ID, only enabled if you specify an id here
+google_tag_manager_id =
+
+# Rudderstack write key, enabled only if rudderstack_data_plane_url is also set
+rudderstack_write_key =
+
+# Rudderstack data plane url, enabled only if rudderstack_write_key is also set
+rudderstack_data_plane_url =
+
+# Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set
+rudderstack_sdk_url =
+
+# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
+rudderstack_config_url =
+
+# Application Insights connection string. Specify an URL string to enable this feature.
+application_insights_connection_string =
+
+# Optional. Specifies an Application Insights endpoint URL where the endpoint string is wrapped in backticks ``.
+application_insights_endpoint_url =
+
+# Controls if the UI contains any links to user feedback forms
+feedback_links_enabled = true
+
+#################################### Security ############################
+[security]
+# disable creation of admin user on first start of grafana
+disable_initial_admin_creation = false
+
+# default admin user, created on startup
+admin_user = admin
+
+# default admin password, can be changed before first start of grafana, or in profile settings
+admin_password = admin
+
+# used for signing
+secret_key = SW2YcwTIb9zpOOhoPsMm
+
+# current key provider used for envelope encryption, default to static value specified by secret_key
+encryption_provider = secretKey.v1
+
+# list of configured key providers, space separated (Enterprise only): e.g., awskms.v1 azurekv.v1
+available_encryption_providers =
+
+# disable gravatar profile images
+disable_gravatar = false
+
+# data source proxy whitelist (ip_or_domain:port separated by spaces)
+data_source_proxy_whitelist =
+
+# disable protection against brute force login attempts
+disable_brute_force_login_protection = false
+
+# set to true if you host Grafana behind HTTPS. default is false.
+cookie_secure = false
+
+# set cookie SameSite attribute. defaults to `lax`. can be set to "lax", "strict", "none" and "disabled"
+cookie_samesite = lax
+
+# set to true if you want to allow browsers to render Grafana in a ,
} />
+ ,
+ )
+
+ const closeButton = screen.getByText('Close')
+ fireEvent.click(closeButton)
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render title when provided', () => {
+ render(
+
+ null}
+ content={alert content
}
+ title='alert title'
+ />
+ ,
+ )
+
+ const title = screen.getByText('alert title')
+
+ expect(title).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Alert/index.tsx b/applications/launchpad/gui-react/src/components/Alert/index.tsx
new file mode 100644
index 0000000000..e5046a7043
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Alert/index.tsx
@@ -0,0 +1,73 @@
+import { ReactNode } from 'react'
+import { useTheme } from 'styled-components'
+
+import Modal from '../Modal'
+import Box from '../Box'
+import Button from '../Button'
+import Text from '../Text'
+import t from '../../locales'
+
+/**
+ * @name Alert
+ * @description A simple modal component showing a message with optional title
+ *
+ * @prop {ReactNode} content - content shown in the alert
+ * @prop {boolean} open - indicates whether alert should be shown or not
+ * @prop {() => void} onClose - callback on close action of the alert (Close button and backdrop)
+ * @prop {string} [title] - optional title of the alert
+ *
+ * @example
+ * setIsOpen(false)}
+ * />
+ */
+const Alert = ({
+ content,
+ open,
+ onClose,
+ title,
+}: {
+ content: ReactNode
+ open: boolean
+ onClose: () => void
+ title?: string
+}) => {
+ const theme = useTheme()
+
+ return (
+
+
+ {Boolean(title) && (
+
+ {title}
+
+ )}
+ {content}
+
+
+
+
+
+ )
+}
+
+export default Alert
diff --git a/applications/launchpad/gui-react/src/components/Backdrop/index.tsx b/applications/launchpad/gui-react/src/components/Backdrop/index.tsx
new file mode 100644
index 0000000000..50aece748d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Backdrop/index.tsx
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+
+const Backdrop = styled.div<{ $opacity?: number; $borderRadius?: string }>`
+ background: ${({ theme }) => theme.modalBackdrop};
+ opacity: ${({ $opacity }) => $opacity};
+ position: absolute;
+ border-radius: ${({ $borderRadius }) => $borderRadius};
+ top: 0;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ z-index: 1;
+`
+Backdrop.defaultProps = {
+ $opacity: 0.1,
+ $borderRadius: '0',
+}
+
+export default Backdrop
diff --git a/applications/launchpad/gui-react/src/components/Box/Box.test.tsx b/applications/launchpad/gui-react/src/components/Box/Box.test.tsx
new file mode 100644
index 0000000000..31d49f250d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Box/Box.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import Box from '.'
+
+describe('Box', () => {
+ it('should render box and children without crash', () => {
+ render(
+
+
+ box children
+
+ ,
+ )
+
+ const el = screen.getByText('box children')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Box/index.tsx b/applications/launchpad/gui-react/src/components/Box/index.tsx
new file mode 100644
index 0000000000..3692d53686
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Box/index.tsx
@@ -0,0 +1,45 @@
+import { StyledBox } from './styles'
+
+import { BoxProps } from './types'
+
+/**
+ * A box with standardized border radius, padding etc.
+ *
+ * @prop {ReactNode} children - elements to render inside the box
+ * @prop {Gradient} gradient - optional gradient definition for box background
+ * @prop {boolean} border - whether to show box border or not
+ * @prop {CSSProperties} style - prop allowing to override all styles of the box
+ *
+ * @typedef Gradient
+ * @prop {string} start - color of gradient start
+ * @prop {string} end - color on gradient end
+ * @prop {number} rotation - gradient rotation in degress (45 by default)
+ */
+const Box = ({
+ children,
+ gradient,
+ border,
+ style: inlineStyle,
+ testId = 'box-cmp',
+}: BoxProps) => {
+ const style = {
+ border: border === false ? 'none' : undefined,
+ background:
+ gradient &&
+ `
+ linear-gradient(
+ ${gradient.rotation || 45}deg,
+ ${gradient.start} 0%,
+ ${gradient.end} 100%
+ )`,
+ ...inlineStyle,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default Box
diff --git a/applications/launchpad/gui-react/src/components/Box/styles.ts b/applications/launchpad/gui-react/src/components/Box/styles.ts
new file mode 100644
index 0000000000..bf35658e76
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Box/styles.ts
@@ -0,0 +1,13 @@
+import styled from 'styled-components'
+
+export const StyledBox = styled.div`
+ color: ${({ theme }) => theme.primary};
+ background: ${({ theme }) => theme.background};
+ padding: ${({ theme }) => theme.spacing()};
+ margin: ${({ theme }) => theme.spacing()} 0;
+ border-radius: ${({ theme }) => theme.borderRadius()};
+ border: 1px solid ${({ theme }) => theme.borderColor};
+ min-width: 416px;
+ width: 416px;
+ box-sizing: border-box;
+`
diff --git a/applications/launchpad/gui-react/src/components/Box/types.ts b/applications/launchpad/gui-react/src/components/Box/types.ts
new file mode 100644
index 0000000000..97256051ee
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Box/types.ts
@@ -0,0 +1,15 @@
+import { ReactNode, CSSProperties } from 'react'
+
+type Gradient = {
+ start: string
+ end: string
+ rotation?: number
+}
+
+export type BoxProps = {
+ children: ReactNode
+ border?: boolean
+ style?: CSSProperties
+ gradient?: Gradient
+ testId?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/Button/Button.test.tsx b/applications/launchpad/gui-react/src/components/Button/Button.test.tsx
new file mode 100644
index 0000000000..cfa8f501e1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Button/Button.test.tsx
@@ -0,0 +1,168 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Button from '.'
+import SvgAdd from '../../styles/Icons/Add'
+import themes from '../../styles/themes'
+import styles from '../../styles/styles'
+
+import Text from '../Text'
+
+describe('Button', () => {
+ it('should render children wrapped with Text component when children is string', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-text-wrapper')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should not wrap children when children is ReactNode', () => {
+ const testText = The callout test text
+ render(
+
+
+ ,
+ )
+
+ const el = screen.queryByTestId('button-text-wrapper')
+ expect(el).not.toBeInTheDocument()
+ })
+
+ it('should render icons when icons provided', () => {
+ const testText = 'The callout test text'
+ render(
+
+ } rightIcon={}>
+ {testText}
+
+ ,
+ )
+
+ const elLeft = screen.getByTestId('button-left-icon')
+ expect(elLeft).toBeInTheDocument()
+
+ const elRight = screen.getByTestId('button-right-icon')
+ expect(elRight).toBeInTheDocument()
+ })
+
+ it('should render loading icon when flag loading is set', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-loading-icon')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render small Text when size is set to small', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-text-wrapper')
+ expect(el).toHaveStyle(
+ `font-size: ${styles.typography.smallMedium.fontSize}px;`,
+ )
+ })
+
+ it('should render button in text without crashing', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-in-text')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render button as a link when href prop provided', () => {
+ const testText = 'The callout test text'
+ const testHref = 'test-href-attr'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el.nodeName.toLowerCase()).toBe('a')
+ expect(el).toHaveAttribute('href', testHref)
+ })
+
+ it('should render secondary variant without crashing', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render text variant without crashing', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render warning variant without crashing', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render disabled without crashing', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render disabled without crashing when button is an button-in-text', () => {
+ const testText = 'The callout test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('button-cmp')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Button/index.tsx b/applications/launchpad/gui-react/src/components/Button/index.tsx
new file mode 100644
index 0000000000..160f754664
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Button/index.tsx
@@ -0,0 +1,173 @@
+import Loading from '../Loading'
+import Text from '../Text'
+
+import {
+ ButtonContentWrapper,
+ IconWrapper,
+ LoadingIconWrapper,
+ StyledButton,
+ StyledButtonText,
+ StyledLink,
+ StyledLinkLikeButton,
+} from './styles'
+import { ButtonProps } from './types'
+
+/**
+ * Button component
+ *
+ * @param {ReactNode | string} children - the button content. String is wrapped with the component.
+ * @param {ButtonVariantType} [variant='primary'] - ie. 'primary', 'secondary', 'button-in-text'
+ * @param {CSSProperties} [style] - the style applied to the outter element.
+ * @param {ButtonType} [type='button'] - the HTML button type, ie. 'submit'
+ * @param {ButtonSizeType} [size='medium'] - the size of the button
+ * @param {string} [href] - if applied, it renders element with a given href
+ * @param {ReactNode} [leftIcon] - element rendered on left side of the button
+ * @param {string} [leftIconColor] - custom icon color
+ * @param {ReactNode} [rightIcon] - element rendered on right side of the button
+ * @param {boolean} [autosizeIcons='true'] - by default, it resizes any svg element set as leftIcon or rightIcon to a given dimensions (16x16px)
+ * @param {boolean} [loading] - displays the loader
+ * @param {boolean} [fullWidth] - render with width = 100%
+ * @param {() => void} [onClick] - on button click
+ * @param {string} [testId] - react test id
+ *
+ * @example
+ * }
+ * leftIcon={}
+ * >
+ * String or {ReactNode}
+ *
+ */
+
+const Button = ({
+ children,
+ disabled,
+ style,
+ variant,
+ type = 'button',
+ size = 'medium',
+ href,
+ leftIcon,
+ leftIconColor,
+ rightIcon,
+ autosizeIcons = true,
+ onClick,
+ loading,
+ fullWidth,
+ testId = 'button-cmp',
+}: ButtonProps) => {
+ let btnText = children
+
+ if (typeof children === 'string') {
+ btnText = (
+
+
+ {children}
+
+
+ )
+ }
+
+ const btnContent = (
+ <>
+ {leftIcon ? (
+
+ {leftIcon}
+
+ ) : null}
+ {btnText}
+ {rightIcon ? (
+
+ {rightIcon}
+
+ ) : null}
+ {loading ? (
+
+
+
+ ) : null}
+ >
+ )
+
+ if (variant === 'button-in-text') {
+ return (
+
+ {btnContent}
+
+ )
+ }
+
+ if (type === 'link' || href) {
+ if (variant && variant !== 'text') {
+ return (
+
+ {btnContent}
+
+ )
+ }
+
+ return (
+
+ {btnContent}
+
+ )
+ }
+
+ return (
+
+ {btnContent}
+
+ )
+}
+
+export default Button
diff --git a/applications/launchpad/gui-react/src/components/Button/styles.ts b/applications/launchpad/gui-react/src/components/Button/styles.ts
new file mode 100644
index 0000000000..c1fa4c7a00
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Button/styles.ts
@@ -0,0 +1,212 @@
+/* eslint-disable indent */
+import styled, { DefaultTheme, css } from 'styled-components'
+
+import { ButtonProps, ButtonVariantType } from './types'
+
+const getButtonBackgroundColor = ({
+ disabled,
+ variant,
+ theme,
+}: Pick & { theme: DefaultTheme }) => {
+ if ((disabled || variant === 'secondary') && variant !== 'text') {
+ return theme.disabledPrimaryButton
+ }
+
+ switch (variant) {
+ case 'text':
+ return 'transparent'
+ case 'warning':
+ return theme.warningGradient
+ default:
+ return theme.tariGradient
+ }
+}
+
+const ButtonCSS = css<
+ { $fullWidth?: boolean } & Pick
+>`
+display: flex;
+position: relative;
+${({ $fullWidth }) => $fullWidth && 'width: 100%;'}
+justify-content: ${({ $fullWidth }) =>
+ $fullWidth ? 'center' : 'space-between'};
+align-items: center;
+column-gap: 0.25em;
+margin: 0;
+border-radius: ${({ theme }) => theme.tightBorderRadius()};
+border: ${({ disabled, theme, variant }) => {
+ if (variant === 'text') {
+ return 'none'
+ }
+
+ if (disabled) {
+ return `1px solid ${getButtonBackgroundColor({
+ disabled,
+ theme,
+ variant,
+ })}`
+ }
+
+ if (variant === 'secondary') {
+ return `1px solid ${theme.borderColor}`
+ }
+
+ if (variant === 'warning') {
+ return `1px solid ${theme.warning}`
+ }
+
+ return `1px solid ${theme.accent}`
+}};
+box-shadow: none;
+padding: ${({ theme }) => theme.spacingVertical(0.5)}
+ ${({ theme }) => theme.spacingHorizontal()};
+cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
+background: ${getButtonBackgroundColor};
+color: ${({ disabled, variant, theme }) => {
+ if (disabled) {
+ return theme.disabledText
+ }
+
+ if (variant === 'secondary') {
+ return theme.primary
+ }
+
+ return variant === 'text' ? theme.secondary : theme.inverted.primary
+}};
+outline: none;
+text-decoration: none;
+
+& * {
+ color: inherit;
+}
+
+&:hover {
+ background: ${({ disabled, variant, theme }) => {
+ if (disabled || variant === 'text') {
+ return 'auto'
+ }
+
+ if (variant === 'secondary') {
+ return theme.backgroundSecondary
+ }
+
+ if (variant === 'warning') {
+ return theme.warningDark
+ }
+
+ return theme.accent
+ }};
+
+ ${({ variant, disabled }) =>
+ variant === 'text' && !disabled ? 'opacity: 0.7;' : ''}
+`
+
+export const StyledButton = styled.button<
+ { $fullWidth?: boolean } & Pick
+>`
+ ${ButtonCSS}
+`
+
+export const StyledLinkLikeButton = styled.a<
+ { $fullWidth?: boolean } & Pick
+>`
+ ${ButtonCSS}
+`
+
+export const StyledLink = styled.a>`
+ display: inline-flex;
+ align-items: center;
+ background: ${({ variant, theme }) =>
+ variant === 'text' ? 'transparent' : theme.tariGradient};
+ color: ${({ variant, theme }) =>
+ variant === 'text' ? theme.secondary : theme.primary};
+ cursor: pointer;
+ margin: 0;
+ padding: 0;
+ text-decoration: underline;
+ box-sizing: border-box;
+ border-width: 0;
+ box-shadow: none;
+ font-size: inherit;
+ color: inherit;
+ line-height: inherit;
+ font-family: inherit;
+ font-weight: inherit;
+
+ ${({ disabled }) => {
+ if (disabled) {
+ return `
+ opacity: 0.5;
+ `
+ }
+
+ return ''
+ }}
+
+ &:hover {
+ opacity: ${({ disabled }) => (disabled ? '0.5' : '0.7')};
+ }
+`
+
+export const StyledButtonText = styled.span>`
+ display: flex;
+ padding-top: ${({ theme, size }) =>
+ theme.spacingVertical(size === 'small' ? 0.1 : 0.2)};
+`
+
+export const IconWrapper = styled.span<{
+ $spacing?: 'left' | 'right'
+ $autosizeIcon?: boolean
+ $variant?: ButtonVariantType
+ $disabled?: boolean
+ $leftIconColor?: string
+}>`
+ display: inline-flex;
+ ${({ $spacing, theme }) => {
+ if ($spacing) {
+ return `margin-${$spacing}: ${theme.spacingHorizontal(0.25)};`
+ }
+
+ return ''
+ }}
+
+ color: ${({ $disabled, theme, $leftIconColor }) => {
+ if ($disabled) {
+ return theme.disabledPrimaryButtonText
+ } else if ($leftIconColor) {
+ return $leftIconColor
+ }
+
+ return 'inherit'
+ }};
+
+ ${({ $autosizeIcon }) => {
+ if ($autosizeIcon) {
+ return `
+ & > svg {
+ width: 16px;
+ height: 16px;
+ }
+ `
+ }
+ return ''
+ }}
+`
+
+export const ButtonContentWrapper = styled.span<{
+ disabled?: boolean
+}>`
+ display: inline-flex;
+ color: ${({ disabled, theme }) => {
+ if (disabled) {
+ return theme.disabledPrimaryButtonText
+ }
+
+ return 'inherit'
+ }};
+`
+
+export const LoadingIconWrapper = styled.span`
+ display: inline-flex;
+ margin-left: ${({ theme }) => theme.spacingHorizontal(0.2)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Button/types.ts b/applications/launchpad/gui-react/src/components/Button/types.ts
new file mode 100644
index 0000000000..a5b894352c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Button/types.ts
@@ -0,0 +1,30 @@
+import { ReactNode, CSSProperties } from 'react'
+
+export type ButtonVariantType =
+ | 'primary'
+ | 'secondary'
+ | 'warning'
+ | 'text'
+ | 'button-in-text'
+
+export type ButtonSizeType = 'medium' | 'small'
+
+export type ButtonType = 'link' | 'button' | 'submit'
+
+export interface ButtonProps {
+ disabled?: boolean
+ children?: ReactNode
+ style?: CSSProperties
+ type?: ButtonType
+ size?: ButtonSizeType
+ href?: string
+ variant?: ButtonVariantType
+ leftIcon?: ReactNode
+ leftIconColor?: string
+ rightIcon?: ReactNode
+ autosizeIcons?: boolean
+ onClick?: () => void
+ loading?: boolean
+ fullWidth?: boolean
+ testId?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/ButtonRadio/index.test.tsx b/applications/launchpad/gui-react/src/components/ButtonRadio/index.test.tsx
new file mode 100644
index 0000000000..490a2d3416
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ButtonRadio/index.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+
+import ButtonRadio from '.'
+
+const defaultOptions = [
+ { option: 'option1', label: 'Option 1' },
+ { option: 'option2', label: 'Option 2' },
+ { option: 'option3', label: 'Option 3' },
+]
+
+describe('ButtonRadio', () => {
+ it('should render all the options', () => {
+ render(
+
+ null}
+ />
+ ,
+ )
+
+ defaultOptions.forEach(({ label }) => {
+ expect(screen.queryByText(label)).toBeInTheDocument()
+ })
+ })
+
+ it('should call onChange with correct value', () => {
+ const onChange = jest.fn()
+ render(
+
+
+ ,
+ )
+
+ const option2Button = screen.getByText('Option 2')
+ fireEvent.click(option2Button)
+
+ expect(onChange).toHaveBeenCalledWith('option2')
+ })
+
+ it('should not render the component when option list is empty', () => {
+ const { container } = render(
+
+ null} />
+ ,
+ )
+
+ expect(container.childElementCount).toBe(0)
+ })
+
+ it('should not allow clicking disabled option', () => {
+ const onChange = jest.fn()
+ render(
+
+
+ ,
+ )
+
+ const option2Button = screen.getByText('Disabled option')
+ fireEvent.click(option2Button)
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/ButtonRadio/index.tsx b/applications/launchpad/gui-react/src/components/ButtonRadio/index.tsx
new file mode 100644
index 0000000000..29b268d92f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ButtonRadio/index.tsx
@@ -0,0 +1,55 @@
+import { useTheme } from 'styled-components'
+
+import Button from '../Button'
+
+/**
+ * @name ButtonRadio
+ * @description controlled presentation component that shows a row of buttons and allows to select only one of them at a time
+ *
+ * @prop {string} value - currently selected value
+ * @prop {{ option: string; label: string; disabled?: boolean }[]} options - options to be rendered as buttons
+ * @prop {(option: string) => void} onChange - value change callback
+ */
+const ButtonRadio = ({
+ value,
+ options,
+ onChange,
+}: {
+ value: string
+ options: { option: string; label: string; disabled?: boolean }[]
+ onChange: (option: string) => void
+}) => {
+ const theme = useTheme()
+
+ if (options.length === 0) {
+ return null
+ }
+
+ return (
+
+ {options.map(({ option, label, disabled }) => (
+
+ ))}
+
+ )
+}
+
+export default ButtonRadio
diff --git a/applications/launchpad/gui-react/src/components/Callout/Callout.test.tsx b/applications/launchpad/gui-react/src/components/Callout/Callout.test.tsx
new file mode 100644
index 0000000000..90bb684400
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Callout/Callout.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Callout from '.'
+import themes from '../../styles/themes'
+
+describe('Callout', () => {
+ it('should render without crashing when children is a string', () => {
+ const testText = 'The callout test text'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const el = screen.getByText(testText)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render without crashing when children is a React Node', () => {
+ const testId = 'the-callout-test-id'
+ const testText = 'The callout test text'
+ const testCmp = {testText}
+ render(
+
+ {testCmp}
+ ,
+ )
+
+ const elText = screen.getByText(testText)
+ expect(elText).toBeInTheDocument()
+
+ const elCmp = screen.getByTestId(testId)
+ expect(elCmp).toBeInTheDocument()
+ })
+
+ it('should render without crashing when inverted prop is used', () => {
+ const testText = 'The callout test text'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const el = screen.getByText(testText)
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Callout/index.tsx b/applications/launchpad/gui-react/src/components/Callout/index.tsx
new file mode 100644
index 0000000000..07895a297e
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Callout/index.tsx
@@ -0,0 +1,38 @@
+import Text from '../Text'
+import { CalloutIcon, StyledCallout } from './styles'
+
+import { CalloutProps } from './types'
+
+/**
+ * Callout component that renders styled box with proper icon.
+ * NOTE: It supports only the `warning` type for now.
+ *
+ * @param {CalloutType} [type='warning'] - the callout type/style.
+ * @param {ReactNode} [icon] - override the icon.
+ * @param {string | ReactNode} children - the callout content (text or ReactNode).
+ */
+const Callout = ({
+ type = 'warning',
+ icon = '⚠️',
+ inverted,
+ children,
+}: CalloutProps) => {
+ let content = children
+
+ if (typeof children === 'string') {
+ content = (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {icon}
+ {content}
+
+ )
+}
+
+export default Callout
diff --git a/applications/launchpad/gui-react/src/components/Callout/styles.ts b/applications/launchpad/gui-react/src/components/Callout/styles.ts
new file mode 100644
index 0000000000..62582439f9
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Callout/styles.ts
@@ -0,0 +1,27 @@
+import styled from 'styled-components'
+import { CalloutType } from './types'
+
+export const StyledCallout = styled.div<{
+ $type: CalloutType
+ $inverted?: boolean
+}>`
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.62)} ${theme.spacingHorizontal(0.5)}`};
+ background: ${({ theme, $inverted }) => {
+ return $inverted
+ ? theme.inverted.backgroundSecondary
+ : theme.calloutBackground
+ }}};
+ color: ${({ theme, $inverted }) => {
+ return $inverted ? theme.inverted.warningText : theme.warningText
+ }};
+ border-radius: ${({ theme }) => theme.borderRadius(0.5)};
+ font-size: 0.9em;
+ line-height: 150%;
+`
+
+export const CalloutIcon = styled.span`
+ display: inline-block;
+ font-size: 12px;
+ margin-right: ${({ theme }) => theme.spacingHorizontal(0.15)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Callout/types.ts b/applications/launchpad/gui-react/src/components/Callout/types.ts
new file mode 100644
index 0000000000..4e1bdd1186
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Callout/types.ts
@@ -0,0 +1,10 @@
+import { ReactNode } from 'react'
+
+export type CalloutType = 'warning'
+
+export interface CalloutProps {
+ type?: CalloutType
+ icon?: string | ReactNode
+ inverted?: boolean
+ children: string | ReactNode
+}
diff --git a/applications/launchpad/gui-react/src/components/CenteredLayout.tsx b/applications/launchpad/gui-react/src/components/CenteredLayout.tsx
new file mode 100644
index 0000000000..0d3a691dce
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CenteredLayout.tsx
@@ -0,0 +1,15 @@
+import styled from 'styled-components'
+
+const CenteredLayout = styled.div<{
+ horizontally?: boolean
+ vertically?: boolean
+}>`
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: ${({ horizontally }) => (horizontally ? 'center' : 'left')};
+ align-items: ${({ vertically }) => (vertically ? 'center' : 'flex-start')};
+ min-height: ${({ vertically }) => (vertically ? '100%' : '0')};
+ column-gap: ${({ theme }) => theme.spacing()};
+ position: relative;
+`
+export default CenteredLayout
diff --git a/applications/launchpad/gui-react/src/components/Charts/Bar/index.tsx b/applications/launchpad/gui-react/src/components/Charts/Bar/index.tsx
new file mode 100644
index 0000000000..59f13dd462
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Charts/Bar/index.tsx
@@ -0,0 +1,55 @@
+import { CSSProperties } from 'react'
+import { ResponsiveBarCanvas } from '@nivo/bar'
+import { useTheme } from 'styled-components'
+
+const BarChart = ({
+ data,
+ indexBy,
+ keys,
+ style,
+}: {
+ data: Record[]
+ yAxisGridResolution?: number
+ indexBy: string
+ keys: string[]
+ style: CSSProperties
+}) => {
+ const theme = useTheme()
+
+ return (
+
+ `${v / 1000}k`,
+ }}
+ />
+
+ )
+}
+
+export default BarChart
diff --git a/applications/launchpad/gui-react/src/components/Checkbox/Checkbox.test.tsx b/applications/launchpad/gui-react/src/components/Checkbox/Checkbox.test.tsx
new file mode 100644
index 0000000000..da918829cf
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Checkbox/Checkbox.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Checkbox from './'
+
+import themes from '../../styles/themes'
+
+const TEST_LABEL = 'test label'
+
+describe('Checkbox', () => {
+ it('should render label', () => {
+ render(
+
+ null}>
+ {TEST_LABEL}
+
+ ,
+ )
+
+ expect(screen.getByText(TEST_LABEL)).toBeInTheDocument()
+ })
+
+ it('should render svg icon when checked', () => {
+ const { container } = render(
+
+ null}>
+ {TEST_LABEL}
+
+ ,
+ )
+
+ const icon = container.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should call onChange correctly when label is clicked', () => {
+ const onChange = jest.fn()
+ render(
+
+
+ {TEST_LABEL}
+
+ ,
+ )
+
+ const label = screen.getByText(TEST_LABEL)
+ fireEvent.click(label)
+ expect(onChange).toHaveBeenCalledWith(true)
+ })
+
+ it('should call onChange correctly when tick is clicked', () => {
+ const onChange = jest.fn()
+ const { container } = render(
+
+
+ {TEST_LABEL}
+
+ ,
+ )
+
+ const tickContainer = container.querySelector('svg')?.parentElement
+ expect(tickContainer).toBeInTheDocument()
+ fireEvent.click(tickContainer as unknown as Element)
+ expect(onChange).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Checkbox/index.tsx b/applications/launchpad/gui-react/src/components/Checkbox/index.tsx
new file mode 100644
index 0000000000..96655ee9ea
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Checkbox/index.tsx
@@ -0,0 +1,66 @@
+import { CSSProperties } from 'react'
+import { useTheme } from 'styled-components'
+
+import TickIcon from '../../styles/Icons/Tick'
+import Text from '../Text'
+
+import { Wrapper, CheckWrapper } from './styles'
+
+/**
+ * @name Checkbox
+ * @description renders a controlled checkbox component with a label
+ *
+ * @prop {boolean} checked - whether to show checked or not checked ui state
+ * @prop {(v: boolean) => void} onChange - when state changes, this callback is called with new value
+ * @prop {string} children - label shown next to the tick box
+ * @prop {CSSProperties} [style] - allows to extend main wrapper element styles
+ * @prop {boolean} disabled - indicates whether to render in disabled UI state
+ *
+ * @example
+ *
+ * enabled
+ *
+ */
+const Checkbox = ({
+ checked,
+ onChange,
+ children,
+ style,
+ disabled,
+}: {
+ checked: boolean
+ onChange: (v: boolean) => void
+ children: string
+ style?: CSSProperties
+ disabled?: boolean
+}) => {
+ const theme = useTheme()
+
+ const color = disabled
+ ? theme.placeholderText
+ : checked
+ ? theme.primary
+ : theme.nodeWarningText
+
+ return (
+ onChange(!checked)}
+ >
+
+ {checked && (
+
+ )}
+
+
+ {children}
+
+
+ )
+}
+
+export default Checkbox
diff --git a/applications/launchpad/gui-react/src/components/Checkbox/styles.ts b/applications/launchpad/gui-react/src/components/Checkbox/styles.ts
new file mode 100644
index 0000000000..95f92f883a
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Checkbox/styles.ts
@@ -0,0 +1,27 @@
+import styled from 'styled-components'
+
+export const Wrapper = styled.div<{ disabled?: boolean }>`
+ display: flex;
+ pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
+`
+
+export const CheckWrapper = styled.div<{
+ checked: boolean
+ disabled?: boolean
+}>`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 1em;
+ height: 1em;
+ border: 2px solid
+ ${({ disabled, checked, theme }) => {
+ if (disabled) {
+ return theme.placeholderText
+ }
+ return checked ? theme.accent : theme.nodeWarningText
+ }};
+ border-radius: 3px;
+ margin-right: ${({ theme }) => theme.spacing(0.5)};
+ cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
+`
diff --git a/applications/launchpad/gui-react/src/components/CoinsList/index.tsx b/applications/launchpad/gui-react/src/components/CoinsList/index.tsx
new file mode 100644
index 0000000000..03309d563d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CoinsList/index.tsx
@@ -0,0 +1,75 @@
+import { formatAmount } from '../../utils/Format'
+import Loading from '../Loading'
+import Text from '../Text'
+import { TextType } from '../Text/types'
+
+import { CoinsListItem, IconWrapper, StyledCoinsList } from './styles'
+import { CoinsListProps } from './types'
+
+/**
+ * Render the list of coins with amount.
+ * @param {CoinProps[]} coins - the list of coins
+ * @param {string} [color = 'inherit'] - the main text color
+ * @param {string} [unitsColor] - color of units text
+ * @param {boolean} [inline] - if true, renders as inline block
+ * @param {boolean} [small] - if true, renders smaller font sizes
+ *
+ * @typedef {CoinProps}
+ * @param {string | number} amount - the amount
+ * @param {string} unit - the unit, ie. xtr
+ * @param {string} [suffixText] - the latter text after the amount and unit
+ * @param {boolean} [loading] - is value being loaded
+ *
+ * @example
+ *
+ */
+const CoinsList = ({
+ coins,
+ color,
+ unitsColor,
+ showSymbols,
+ inline,
+ small,
+}: CoinsListProps) => {
+ const textSize: { amount: TextType; suffix: TextType } = small
+ ? { amount: 'defaultHeavy', suffix: 'microRegular' }
+ : { amount: 'subheader', suffix: 'smallMedium' }
+
+ return (
+
+ {coins.map((c, idx) => (
+
+ {c.loading ? (
+
+ ) : c.icon && showSymbols ? (
+ {c.icon}
+ ) : null}
+
+ {formatAmount(c.amount)}
+
+ {c.unit}
+
+ {c.suffixText ? (
+
+ {c.suffixText}
+
+ ) : null}
+
+ ))}
+
+ )
+}
+
+export default CoinsList
diff --git a/applications/launchpad/gui-react/src/components/CoinsList/styles.ts b/applications/launchpad/gui-react/src/components/CoinsList/styles.ts
new file mode 100644
index 0000000000..877cb3e6f4
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CoinsList/styles.ts
@@ -0,0 +1,26 @@
+import styled from 'styled-components'
+
+export const StyledCoinsList = styled.ul<{ color?: string; inline?: boolean }>`
+ color: ${({ color }) => (color ? color : 'inherit')};
+ list-style: none;
+ padding-left: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+ display: ${({ inline }) => (inline ? 'inline-block' : '')};
+`
+
+export const CoinsListItem = styled.li<{ $loading?: boolean }>`
+ opacity: ${({ $loading }) => ($loading ? 0.64 : 1)};
+ display: flex;
+ align-items: baseline;
+`
+
+export const IconWrapper = styled.span`
+ margin-right: 8px;
+ margin-top: -4px;
+ & > svg {
+ width: 24px;
+ height: 24px;
+ }
+ color: ${({ theme }) => theme.inverted.secondary};
+`
diff --git a/applications/launchpad/gui-react/src/components/CoinsList/types.ts b/applications/launchpad/gui-react/src/components/CoinsList/types.ts
new file mode 100644
index 0000000000..f876a6c33c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CoinsList/types.ts
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react'
+
+export interface CoinProps {
+ amount: string | number
+ unit: string
+ suffixText?: string
+ loading?: boolean
+ icon?: ReactNode
+}
+
+export interface CoinsListProps {
+ coins: CoinProps[]
+ inline?: boolean
+ small?: boolean
+ color?: string
+ unitsColor?: string
+ showSymbols?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/CopyBox/index.tsx b/applications/launchpad/gui-react/src/components/CopyBox/index.tsx
new file mode 100644
index 0000000000..f935d5b26b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CopyBox/index.tsx
@@ -0,0 +1,91 @@
+import { CSSProperties, useState, useEffect, useRef } from 'react'
+import { clipboard } from '@tauri-apps/api'
+import { useSpring, animated } from 'react-spring'
+
+import Button from '../Button'
+import Text from '../Text'
+import Tag from '../Tag'
+import CopyIcon from '../../styles/Icons/Copy'
+import t from '../../locales'
+
+import { ValueContainer, StyledBox, FeedbackContainer } from './styles'
+
+/**
+ * @name CopyBox
+ * @description Renders a box with value with a button that allows to copy it
+ *
+ *
+ * @prop {string} [label] - label describing the value
+ * @prop {string} value - value that can be copied to clipboard
+ */
+const CopyBox = ({
+ label,
+ labelColor,
+ value,
+ style,
+ valueTransform,
+}: {
+ label?: string
+ labelColor?: string
+ value: string
+ style?: CSSProperties
+ valueTransform?: (s: string) => string
+}) => {
+ const [copied, setCopied] = useState(false)
+ const styles = useSpring({ opacity: copied ? 1 : 0 })
+ const timeout = useRef | undefined>(undefined)
+
+ const copy = async () => {
+ const transformed = valueTransform ? valueTransform(value) : value
+ await clipboard.writeText(transformed)
+
+ setCopied(true)
+ if (timeout.current) {
+ clearTimeout(timeout.current)
+ }
+
+ timeout.current = setTimeout(() => {
+ setCopied(false)
+ timeout.current = undefined
+ }, 2000)
+ }
+
+ useEffect(() => {
+ return () => {
+ if (timeout.current) {
+ clearTimeout(timeout.current)
+ }
+ }
+ }, [])
+
+ return (
+ <>
+ {label && {label}}
+
+ {value}
+
+
+ >
+ )
+}
+
+export default CopyBox
diff --git a/applications/launchpad/gui-react/src/components/CopyBox/styles.ts b/applications/launchpad/gui-react/src/components/CopyBox/styles.ts
new file mode 100644
index 0000000000..a7fa46b57d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/CopyBox/styles.ts
@@ -0,0 +1,31 @@
+import styled from 'styled-components'
+
+export const StyledBox = styled.div`
+ background: ${({ theme }) => theme.backgroundImage};
+ border: 1px solid ${({ theme }) => theme.borderColor};
+ border-radius: ${({ theme }) => theme.tightBorderRadius()};
+ color: ${({ theme }) => theme.secondary};
+ padding: ${({ theme }) => theme.spacingVertical()}
+ ${({ theme }) => theme.spacingHorizontal()};
+ margin: ${({ theme }) => theme.spacingVertical(0.6)} 0;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ column-gap: 0.25em;
+`
+
+export const FeedbackContainer = styled.div`
+ position: absolute;
+ left: 50%;
+ bottom: 120%;
+ transform: translateX(-50%);
+`
+
+export const ValueContainer = styled.div`
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ word-break: keep-all;
+ -webkit-user-select: none;
+ cursor: default;
+ font-family: 'AvenirMedium';
+`
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/DatePicker.tsx b/applications/launchpad/gui-react/src/components/DatePicker/DatePicker.tsx
new file mode 100644
index 0000000000..96405a2b88
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/DatePicker.tsx
@@ -0,0 +1,139 @@
+import { Fragment } from 'react'
+import { useTheme } from 'styled-components'
+import { useLilius } from 'use-lilius'
+
+import ArrowLeft from '../../styles/Icons/ArrowLeft2'
+import ArrowRight from '../../styles/Icons/ArrowRight2'
+import t from '../../locales'
+import { month } from '../../utils/Format'
+import {
+ startOfDay,
+ endOfMonth,
+ startOfMonth,
+ isCurrentMonth,
+} from '../../utils/Date'
+import Button from '../Button'
+import Text from '../Text'
+
+import Day from './Day'
+import DatePickerWrapper from './DatePickerWrapper'
+import { MonthContainer } from './styles'
+import { DatePickerProps } from './types'
+
+const allowPast = false
+
+/**
+ * @name DatePickerComponent
+ * @description date picker component that renders calendar and returns selected date
+ *
+ * @prop {Date} [value] - selected value
+ * @prop {(d: Date) => void} onChange - callback called when user selects a date
+ * style {CSSProperties} [style] - optional styles to main container of the date picker
+ */
+const DatePickerComponent = ({
+ value,
+ onChange,
+ style,
+}: Omit) => {
+ const theme = useTheme()
+ const valueWithoutTime = value && startOfDay(value)
+
+ const {
+ calendar,
+ inRange,
+ isSelected,
+ toggle,
+ viewing,
+ viewNextMonth,
+ viewPreviousMonth,
+ } = useLilius({
+ viewing: valueWithoutTime,
+ selected: valueWithoutTime ? [valueWithoutTime] : [],
+ })
+
+ return (
+
+
+
+ {month(viewing)}
+
+
+ {Object.values(t.common.weekdayShort).map(weekDay => (
+
+ {weekDay}
+
+ ))}
+ {calendar[0].map((week, weekId) => (
+
+ {week.map(day => {
+ const isInMonth = inRange(
+ day,
+ startOfMonth(viewing),
+ endOfMonth(viewing),
+ )
+ const labelColor = isInMonth
+ ? isSelected(day)
+ ? theme.on
+ : undefined
+ : theme.disabledPrimaryButtonText
+ const disabled =
+ !allowPast && startOfDay(day) < startOfDay(new Date())
+
+ return (
+ {
+ toggle(day, true)
+ onChange(
+ new Date(
+ Date.UTC(
+ day.getFullYear(),
+ day.getMonth(),
+ day.getDate(),
+ ),
+ ),
+ )
+ }}
+ variant='text'
+ selected={isSelected(day)}
+ >
+ {day.getDate().toString()}
+
+ )
+ })}
+
+ ))}
+
+ )
+}
+
+export default DatePickerComponent
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/DatePickerWrapper.tsx b/applications/launchpad/gui-react/src/components/DatePicker/DatePickerWrapper.tsx
new file mode 100644
index 0000000000..2143456bb0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/DatePickerWrapper.tsx
@@ -0,0 +1,29 @@
+import { useTheme } from 'styled-components'
+
+import Box from '../Box'
+import { BoxProps } from '../Box/types'
+
+const DatePickerWrapper = ({ style, ...props }: BoxProps) => {
+ const theme = useTheme()
+
+ return (
+
+ )
+}
+
+export default DatePickerWrapper
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/Day.tsx b/applications/launchpad/gui-react/src/components/DatePicker/Day.tsx
new file mode 100644
index 0000000000..3cecc2f6ea
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/Day.tsx
@@ -0,0 +1,27 @@
+import { useTheme } from 'styled-components'
+
+import Button from '../Button'
+import { ButtonProps } from '../Button/types'
+
+const Day = ({ selected, ...props }: ButtonProps & { selected: boolean }) => {
+ const theme = useTheme()
+
+ return (
+
+ )
+}
+
+export default Day
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/index.test.tsx b/applications/launchpad/gui-react/src/components/DatePicker/index.test.tsx
new file mode 100644
index 0000000000..fc4d7517d0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/index.test.tsx
@@ -0,0 +1,94 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+
+import DatePicker from '.'
+
+describe('Modal', () => {
+ const someDateInMay2022 = new Date('2022-05-14T07:00:10.010Z')
+ jest.useFakeTimers().setSystemTime(someDateInMay2022)
+
+ it('should not render the component when open is false', () => {
+ const { container } = render(
+
+ null} />
+ ,
+ )
+
+ expect(container.childElementCount).toBe(0)
+ })
+
+ it('should render calendar component when open', () => {
+ render(
+
+ null} />
+ ,
+ )
+
+ expect(screen.getByText('Sun')).toBeInTheDocument()
+ expect(screen.getByText('Mon')).toBeInTheDocument()
+ expect(screen.getByText('Tue')).toBeInTheDocument()
+ expect(screen.getByText('Wed')).toBeInTheDocument()
+ expect(screen.getByText('Thu')).toBeInTheDocument()
+ expect(screen.getByText('Fri')).toBeInTheDocument()
+ expect(screen.getByText('Sat')).toBeInTheDocument()
+ })
+
+ it('should render month of selected date', () => {
+ render(
+
+ null}
+ value={new Date('2022-05-10')}
+ />
+ ,
+ )
+
+ expect(screen.getByText('May 2022')).toBeInTheDocument()
+ })
+
+ it('should call onChange on click', () => {
+ const onChange = jest.fn()
+ render(
+
+
+ ,
+ )
+
+ const lastOfMay = screen.getByText('31')
+ fireEvent.click(lastOfMay)
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ const selectedDate = onChange.mock.calls[0][0]
+
+ expect(selectedDate.toISOString()).toBe('2022-05-31T00:00:00.000Z')
+ })
+
+ it('should not allow selecting dates in the past', () => {
+ jest.useFakeTimers().setSystemTime(new Date('2022-05-08'))
+
+ const onChange = jest.fn()
+ render(
+
+
+ ,
+ )
+
+ const inThePast = screen.getByText('7')
+ const button = inThePast.closest('button')
+ expect(button).toBeDisabled()
+
+ fireEvent.click(inThePast)
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/index.tsx b/applications/launchpad/gui-react/src/components/DatePicker/index.tsx
new file mode 100644
index 0000000000..32c824ec77
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/index.tsx
@@ -0,0 +1,22 @@
+import DatePickerComponent from './DatePicker'
+
+import { DatePickerProps } from './types'
+
+/**
+ * @name DatePicker
+ * @description DatePicker container that renders DatePicker according to `open` state and passes props
+ *
+ * @prop {boolean} open - whether calendar should be open
+ * @prop {Date} [value] - selected value
+ * @prop {(d: Date) => void} onChange - callback called when user selects a date
+ * style {CSSProperties} [style] - optional styles to main container of the date picker
+ */
+const DatePicker = ({ open, ...props }: DatePickerProps) => {
+ if (!open) {
+ return null
+ }
+
+ return
+}
+
+export default DatePicker
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/styles.ts b/applications/launchpad/gui-react/src/components/DatePicker/styles.ts
new file mode 100644
index 0000000000..ed7266517e
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/styles.ts
@@ -0,0 +1,8 @@
+import styled from 'styled-components'
+
+export const MonthContainer = styled.div`
+ grid-area: month;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`
diff --git a/applications/launchpad/gui-react/src/components/DatePicker/types.ts b/applications/launchpad/gui-react/src/components/DatePicker/types.ts
new file mode 100644
index 0000000000..92fc6ee08d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DatePicker/types.ts
@@ -0,0 +1,8 @@
+import { CSSProperties } from 'react'
+
+export type DatePickerProps = {
+ value?: Date
+ open: boolean
+ onChange: (d: Date) => void
+ style?: CSSProperties
+}
diff --git a/applications/launchpad/gui-react/src/components/DockerImagesList/index.tsx b/applications/launchpad/gui-react/src/components/DockerImagesList/index.tsx
new file mode 100644
index 0000000000..ac25c9ff75
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DockerImagesList/index.tsx
@@ -0,0 +1,201 @@
+import { useEffect, CSSProperties, useState } from 'react'
+import { useTheme } from 'styled-components'
+
+import { useAppSelector, useAppDispatch } from '../../store/hooks'
+import { actions } from '../../store/dockerImages'
+import {
+ selectDockerImages,
+ selectDockerImagesLoading,
+} from '../../store/dockerImages/selectors'
+import Text from '../../components/Text'
+import Loading from '../../components/Loading'
+import LoadingOverlay from '../../components/LoadingOverlay'
+import Tag from '../../components/Tag'
+import Button from '../../components/Button'
+import CheckIcon from '../../styles/Icons/CheckRound'
+import t from '../../locales'
+
+import {
+ DockerRow,
+ DockerList,
+ DockerStatusWrapper,
+ ErrorWrapper,
+ TextProgessContainer,
+ ProgressContainer,
+} from './styles'
+import Alert from '../Alert'
+
+const DockerImagesList = ({
+ inverted,
+ header,
+ disableIcons,
+ style,
+}: {
+ inverted?: boolean
+ header?: boolean
+ disableIcons?: boolean
+ style?: CSSProperties
+}) => {
+ const theme = useTheme()
+ const dispatch = useAppDispatch()
+ useEffect(() => {
+ dispatch(actions.getDockerImageList())
+ }, [dispatch])
+
+ const [errorInAlert, setErrorInAlert] = useState(
+ undefined,
+ )
+
+ const dockerImages = useAppSelector(selectDockerImages)
+ const dockerImagesLoading = useAppSelector(selectDockerImagesLoading)
+
+ return (
+
+ {dockerImagesLoading && }
+ {header && (
+
+
+ {t.docker.header.image}
+
+
+ {t.docker.header.status}
+
+
+ )}
+ {dockerImages.map(dockerImage => {
+ return (
+
+
+ {dockerImage?.displayName?.toLowerCase() || ''}
+
+ {dockerImage.updated && (
+
+ {!disableIcons && (
+
+ )}
+
+ {t.docker.imageUpToDate}{' '}
+
+ {dockerImage.dockerImage}
+
+
+
+ )}
+ {!dockerImage.updated && !dockerImage.pending && (
+
+ {dockerImage.error ? (
+ setErrorInAlert(dockerImage.error)}
+ >
+
+ {dockerImage.error}
+
+
+ ) : (
+ {t.docker.newerVersion}
+ )}
+
+
+ )}
+ {!dockerImage.updated && dockerImage.pending && (
+
+
+
+ {dockerImage.status && (
+
+
+ {dockerImage.status}
+
+
+ )}
+ {dockerImage.progress !== undefined && (
+
+
+ {dockerImage.progress?.split(']').join(']\n')}
+
+
+ )}
+
+
+ )}
+
+ )
+ })}
+ setErrorInAlert(undefined)}
+ content={errorInAlert}
+ />
+
+ )
+}
+
+export default DockerImagesList
diff --git a/applications/launchpad/gui-react/src/components/DockerImagesList/styles.ts b/applications/launchpad/gui-react/src/components/DockerImagesList/styles.ts
new file mode 100644
index 0000000000..3eb1291ab4
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/DockerImagesList/styles.ts
@@ -0,0 +1,54 @@
+import styled from 'styled-components'
+
+export const DockerRow = styled.div<{ $inverted?: boolean }>`
+ display: flex;
+ align-items: center;
+ height: 2em;
+ padding: ${({ theme }) => theme.spacingVertical(1.25)};
+ &:not(:last-of-type) {
+ border-bottom: 1px solid
+ ${({ theme, $inverted }) =>
+ $inverted ? theme.inverted.resetBackground : theme.selectBorderColor};
+ }
+`
+
+export const DockerList = styled.div`
+ position: relative;
+`
+
+export const DockerStatusWrapper = styled.div`
+ flex-grow: 1;
+ display: flex;
+ width: 70%;
+ align-items: center;
+ justify-content: flex-end;
+ column-gap: ${({ theme }) => theme.spacingHorizontal(0.5)};
+`
+
+export const ErrorWrapper = styled.span`
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 60%;
+ overflow: hidden;
+ cursor: pointer;
+ color: ${({ theme }) => theme.error};
+
+ &:hover {
+ text-decoration: underline;
+ }
+`
+
+export const ProgressContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 80%;
+`
+
+export const TextProgessContainer = styled.span`
+ font-size: 12px;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ overflow: hidden;
+`
diff --git a/applications/launchpad/gui-react/src/components/Footer/Footer.test.tsx b/applications/launchpad/gui-react/src/components/Footer/Footer.test.tsx
new file mode 100644
index 0000000000..280db5f898
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Footer/Footer.test.tsx
@@ -0,0 +1,67 @@
+import { clearMocks } from '@tauri-apps/api/mocks'
+import { act, render, screen } from '@testing-library/react'
+import { randomFillSync } from 'crypto'
+
+import Footer from '.'
+import { tauriIPCMock } from '../../../__tests__/mocks/mockTauriIPC'
+
+beforeAll(() => {
+ window.crypto = {
+ // @ts-expect-error: ignore this
+ getRandomValues: function (buffer) {
+ // @ts-expect-error: ignore this
+ return randomFillSync(buffer)
+ },
+ }
+})
+
+afterEach(() => {
+ clearMocks()
+})
+
+describe('Footer', () => {
+ it('should render without crashing', async () => {
+ tauriIPCMock()
+
+ await act(async () => {
+ render()
+ })
+
+ const el = screen.getByTestId('footer-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render text for supported OS type', async () => {
+ tauriIPCMock({
+ os: {
+ arch: 'x86_64',
+ platform: 'darwin',
+ ostype: 'Darwin',
+ },
+ })
+
+ await act(async () => {
+ render()
+ })
+
+ const el = screen.getByTestId('terminal-instructions-in-footer')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should NOT render any instructions if met unsupported OS type', async () => {
+ tauriIPCMock({
+ os: {
+ arch: 'x86_64',
+ platform: 'darwin',
+ ostype: 'unsupported',
+ },
+ })
+
+ await act(async () => {
+ render()
+ })
+
+ const el = screen.queryByTestId('terminal-instructions-in-footer')
+ expect(el).not.toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Footer/index.tsx b/applications/launchpad/gui-react/src/components/Footer/index.tsx
new file mode 100644
index 0000000000..cccc4d447b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Footer/index.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'react'
+import { useSpring } from 'react-spring'
+import { os } from '@tauri-apps/api'
+
+import KeyboardKeys from '../KeyboardKeys'
+import Text from '../Text'
+
+import t from '../../locales'
+
+import { FooterTextWrapper, StyledFooter } from './styles'
+
+const TerminalInstructions = {
+ linux: {
+ text: t.footer.toOpenTerminal,
+ keysImage: ,
+ },
+ darwin: {
+ text: t.footer.toOpenTerminal,
+ keysImage: ,
+ },
+ windows_nt: {
+ text: t.footer.toOpenTerminal,
+ keysImage: ,
+ },
+}
+
+/**
+ * Footer component.
+ *
+ * The component render instructions how to open terminal on the host machine.
+ * It supports only 'linux', 'windows (win32)' and 'macos (darwin)'.
+ * If any other platform detected, then the text is not displayed.
+ */
+const Footer = () => {
+ const [osType, setOSType] = useState<
+ 'linux' | 'windows_nt' | 'darwin' | null | undefined
+ >(undefined)
+
+ const textAnim = useSpring({
+ opacity: osType === undefined ? 0 : 1,
+ })
+
+ useEffect(() => {
+ checkPlatform()
+ }, [])
+
+ const checkPlatform = async () => {
+ try {
+ const detectedPlatform = await os.type()
+
+ if (
+ ['linux', 'windows_nt', 'darwin'].includes(
+ detectedPlatform.toLowerCase(),
+ )
+ ) {
+ setOSType(
+ detectedPlatform.toLowerCase() as 'linux' | 'windows_nt' | 'darwin',
+ )
+ return
+ }
+
+ setOSType(null)
+ } catch (_err) {
+ setOSType(null)
+ }
+ }
+
+ return (
+
+
+ {osType ? (
+
+ {t.footer.press} {TerminalInstructions[osType]?.keysImage}{' '}
+ {TerminalInstructions[osType]?.text}
+
+ ) : null}
+
+
+ )
+}
+
+export default Footer
diff --git a/applications/launchpad/gui-react/src/components/Footer/styles.ts b/applications/launchpad/gui-react/src/components/Footer/styles.ts
new file mode 100644
index 0000000000..f214ddd4ec
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Footer/styles.ts
@@ -0,0 +1,15 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const StyledFooter = styled.footer`
+ height: 60px;
+ min-height: 60px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`
+
+export const FooterTextWrapper = styled(animated.div)`
+ text-align: center;
+ color: ${({ theme }) => theme.tertiary};
+`
diff --git a/applications/launchpad/gui-react/src/components/HelpTip/index.tsx b/applications/launchpad/gui-react/src/components/HelpTip/index.tsx
new file mode 100644
index 0000000000..a9c3fe7608
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/HelpTip/index.tsx
@@ -0,0 +1,44 @@
+import Button from '../../components/Button'
+import Text from '../../components/Text'
+import SvgStar from '../../styles/Icons/Star'
+import SvgInfo1 from '../../styles/Icons/Info1'
+
+import { StyledHelpTipWrapper } from './styles'
+import { HelpTipProps } from './types'
+
+/**
+ * @name HelpTip
+ * @description renders help tip with call to action button to open help
+ *
+ * @prop {string} text - text introducing help
+ * @prop {string} cta - call to action text inside button
+ * @prop {() => void} onHelp - callback called when user interacts with cta
+ * @prop {CSSProperties} [style] - styles to apply to main wrapper element
+ * @prop {boolean} [header] - whether the help tip should be rendered with additional top/bottom margin suitable for headers
+ */
+const HelpTip = ({ text, cta, onHelp, style, header }: HelpTipProps) => {
+ return (
+
+
+
+ {text}{' '}
+
+ }
+ autosizeIcons={false}
+ onClick={onHelp}
+ >
+ {cta}
+
+
+
+
+ )
+}
+
+export default HelpTip
diff --git a/applications/launchpad/gui-react/src/components/HelpTip/styles.ts b/applications/launchpad/gui-react/src/components/HelpTip/styles.ts
new file mode 100644
index 0000000000..2f9c8088c1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/HelpTip/styles.ts
@@ -0,0 +1,10 @@
+import styled from 'styled-components'
+
+import { HelpTipProps } from './types'
+
+export const StyledHelpTipWrapper = styled.div>`
+ display: flex;
+ ${({ theme, header }) =>
+ header ? `margin-top: ${theme.spacingVertical(3)};` : ''}
+ color: ${({ theme }) => theme.helpTipText}
+`
diff --git a/applications/launchpad/gui-react/src/components/HelpTip/types.ts b/applications/launchpad/gui-react/src/components/HelpTip/types.ts
new file mode 100644
index 0000000000..3ef8794d3f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/HelpTip/types.ts
@@ -0,0 +1,9 @@
+import { CSSProperties } from 'react'
+
+export type HelpTipProps = {
+ text: string
+ cta: string
+ onHelp: () => void
+ style?: CSSProperties
+ header?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/IconButton/index.tsx b/applications/launchpad/gui-react/src/components/IconButton/index.tsx
new file mode 100644
index 0000000000..7589442a2c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/IconButton/index.tsx
@@ -0,0 +1,24 @@
+import Button from '../Button'
+import { ButtonProps } from '../Button/types'
+
+const IconButton = ({
+ style,
+ children,
+ ...props
+}: Omit) => {
+ return (
+
+ )
+}
+
+export default IconButton
diff --git a/applications/launchpad/gui-react/src/components/Inputs/AmountInput/AmountInput.test.tsx b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/AmountInput.test.tsx
new file mode 100644
index 0000000000..80eeab5abe
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/AmountInput.test.tsx
@@ -0,0 +1,92 @@
+import { configureStore } from '@reduxjs/toolkit'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { ThemeProvider } from 'styled-components'
+
+import AmountInput from '.'
+import { rootReducer, store } from '../../../store'
+
+import themes from '../../../styles/themes'
+
+afterEach(cleanup)
+
+const onChangeTextMock = jest.fn()
+
+describe('AmountInput', () => {
+ it('should render the AmountInput without crashing', () => {
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('amount-input-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should update input value on change', () => {
+ const newInput = 123
+
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('amount-input-cmp')
+
+ fireEvent.change(el, { target: { value: newInput } })
+ expect(onChangeTextMock).toHaveBeenCalledWith(newInput)
+ })
+
+ it('should render error container if error exists', () => {
+ const errorMessage = 'test error'
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByText(errorMessage)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render transaction fee if fee is provided', () => {
+ const fee = 1.2
+
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('fee-help-button')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Inputs/AmountInput/index.tsx b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/index.tsx
new file mode 100644
index 0000000000..2714720cf1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/index.tsx
@@ -0,0 +1,144 @@
+import { ChangeEvent, useState } from 'react'
+import { useDispatch } from 'react-redux'
+
+import SvgQuestion from '../../../styles/Icons/Question'
+import Button from '../../Button'
+import Text from '../../Text'
+
+import t from '../../../locales'
+import MessagesConfig from '../../../config/helpMessagesConfig'
+import { tbotactions } from '../../../store/tbot'
+
+import {
+ IconWrapper,
+ InputContainer,
+ InputWrapper,
+ StyledAmountInput,
+ StyledInput,
+ Currency,
+ TransactionFee,
+ ErrorContainer,
+} from './styles'
+import { AmountInputProps } from './types'
+import { useTheme } from 'styled-components'
+
+const whatIsDecimalSeparator = () => {
+ const n = 1.1
+ return n.toLocaleString().substring(1, 2)
+}
+
+const ds = whatIsDecimalSeparator()
+
+const AmountInput = ({
+ value = 0,
+ disabled,
+ onChange,
+ icon,
+ error,
+ withError,
+ maxDecimals,
+ currency,
+ autoFocus,
+ withFee,
+ fee,
+ testId,
+}: AmountInputProps) => {
+ const dispatch = useDispatch()
+ const theme = useTheme()
+
+ const [valStr, setValStr] = useState(value.toString())
+
+ const onChangeLocal = (e: ChangeEvent) => {
+ // Remove all non-digits and delimiter characters:
+ let newVal = e.target.value.replace(new RegExp(`[^0-9${ds}]`, 'g'), '')
+ // Remove all decimal delimiters expect the last one:
+ newVal = newVal.replace(new RegExp(`[${ds}](?=${ds}*[${ds}])`, 'g'), '')
+ // Remove leading zeros:
+ newVal = newVal.replace(/^0+(\d)/, '$1')
+
+ // Limit number of decimals (optionally)
+ if (maxDecimals && newVal.includes(ds)) {
+ const splitted = newVal.split('.')
+ if (splitted.length > 1) {
+ newVal = splitted[0] + '.' + splitted[1].substring(0, maxDecimals)
+ }
+ }
+
+ newVal = newVal === '' ? '0' : newVal
+ setValStr(newVal)
+ onChange(Number(newVal))
+ }
+
+ return (
+
+
+ {icon && {icon}}
+
+
+
+ {currency && (
+
+
+ {currency}
+
+
+ )}
+
+
+ {withFee && (
+
+ {value > 0 ? (
+ <>
+
+ {t.wallet.transaction.transactionFee}
+
+
+ +{fee}
+
+ }
+ onClick={() =>
+ dispatch(tbotactions.push(MessagesConfig.TransactionFee))
+ }
+ style={{ marginTop: -2, color: theme.primary }}
+ testId='fee-help-button'
+ />
+ >
+ ) : null}
+
+ )}
+
+ {withError && (
+
+ {Boolean(error) && (
+
+ {error}
+
+ )}
+
+ )}
+
+ )
+}
+
+export default AmountInput
diff --git a/applications/launchpad/gui-react/src/components/Inputs/AmountInput/styles.ts b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/styles.ts
new file mode 100644
index 0000000000..d0d967b7a0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/styles.ts
@@ -0,0 +1,64 @@
+import styled from 'styled-components'
+
+export const InputContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`
+
+export const IconWrapper = styled.div`
+ margin-right: ${({ theme }) => theme.spacingHorizontal(0.42)};
+`
+
+export const InputWrapper = styled.span`
+ overflow: hidden;
+ padding: 0 4px 0 6px;
+ box-sizing: border-box;
+ max-width: 50%;
+ margin-top: 6px;
+`
+
+export const StyledAmountInput = styled.div``
+
+export const StyledInput = styled.input`
+ padding: 0px 6px;
+ font-family: 'AvenirMedium';
+ font-size: 48px;
+ line-height: 66px;
+ font-weight: bold;
+ flex: 1;
+ width: 100%;
+ border: none;
+ text-align: right;
+ box-sizing: border-box;
+ color: ${({ theme }) => theme.primary};
+
+ :focus-within {
+ outline: none;
+ border-color: ${({ theme }) => theme.accent};
+ }
+`
+
+export const Currency = styled.span`
+ margin-top: 20px;
+`
+
+export const TransactionFee = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: ${({ theme }) => theme.spacing(0.25)};
+ column-gap: ${({ theme }) => theme.spacing(0.25)};
+ min-height: 30px;
+ box-sizing: border-box;
+`
+
+export const ErrorContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ min-height: 25px;
+ padding-top: ${({ theme }) => theme.spacing(0.075)};
+ padding-bottom: ${({ theme }) => theme.spacing(0.125)};
+ padding-left: ${({ theme }) => theme.spacing(0.25)};
+ padding-right: ${({ theme }) => theme.spacing(0.25)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Inputs/AmountInput/types.ts b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/types.ts
new file mode 100644
index 0000000000..547c4d4582
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/AmountInput/types.ts
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react'
+
+export interface AmountInputProps {
+ value?: number
+ onChange: (val: number) => void
+ disabled?: boolean
+ icon?: ReactNode
+ error?: string
+ withError?: boolean
+ fee?: number
+ withFee?: boolean
+ feeHelp?: boolean
+ testId?: string
+ maxDecimals?: number
+ currency?: string
+ autoFocus?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Input/Input.test.tsx b/applications/launchpad/gui-react/src/components/Inputs/Input/Input.test.tsx
new file mode 100644
index 0000000000..b67b19d357
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Input/Input.test.tsx
@@ -0,0 +1,111 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Input from '.'
+import SvgCopy from '../../../styles/Icons/Copy'
+
+import themes from '../../../styles/themes'
+import lightTheme from '../../../styles/themes/light'
+
+afterEach(cleanup)
+
+describe('Input', () => {
+ it('should render the Text Input without crashing', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('input-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render the optional icon component', () => {
+ render(
+
+ } />
+ ,
+ )
+
+ const el = screen.getByTestId('icon-test')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render the optional units text', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('text-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should update input value on change', () => {
+ const onChangeTextMock = jest.fn()
+ const newInputText = 'test content'
+
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('input-cmp')
+
+ fireEvent.change(el, { target: { value: newInputText } })
+ expect(onChangeTextMock).toHaveBeenCalledWith(newInputText)
+ })
+
+ it('should render correct styling when input is disabled', () => {
+ render(
+
+
+ ,
+ )
+
+ const disabledStyle = lightTheme.placeholderText
+
+ const el = screen.getByTestId('input-cmp')
+ expect(el).toHaveStyle(`color: ${disabledStyle}`)
+ })
+
+ it('should not call icon click handler when disabled', () => {
+ const onIconClick = jest.fn()
+ render(
+
+ } />
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('icon-test'))
+
+ expect(onIconClick).not.toHaveBeenCalled()
+ })
+
+ it('should render error when given', () => {
+ const errorText = 'This field is invalid - test text'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByText(errorText)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render label when given', () => {
+ const labelText = 'This is text label'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByText(labelText)
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Input/index.tsx b/applications/launchpad/gui-react/src/components/Inputs/Input/index.tsx
new file mode 100644
index 0000000000..fb9641daca
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Input/index.tsx
@@ -0,0 +1,139 @@
+import { InputProps } from './types'
+
+import {
+ StyledInput,
+ IconUnitsContainer,
+ InputContainer,
+ UnitsText,
+ IconWrapper,
+ Label,
+} from './styles'
+import { ChangeEvent, forwardRef, useEffect, useRef, useState } from 'react'
+import Text from '../../Text'
+import { useTheme } from 'styled-components'
+
+/**
+ * @name Input component
+ * @typedef InputProps
+ *
+ * @prop {boolean} [disabled] - whether component is disabled or not
+ * @prop {string} [type] - input type
+ * @prop {string} [value] - input text value
+ * @prop {string} [id] - the input id (recommended to use when label is set)
+ * @prop {ReactNode} [label] - the input label
+ * @prop {string} [placeholder] - placeholder text
+ * @prop {string} [inputUnits] - optional units text, e.g. 'MB' on right-hand side of input field
+ * @prop {ReactNode} [inputIcon] - optional icon rendered inside input field
+ * @prop {() => void} [onIconClick] - icon click event
+ * @prop {(value: string) => void} [onChange] - text change event handler
+ * @prop {string} [testId] - for testing purposes
+ * @prop {CSSProperties} [style] - styles for actual input element
+ * @prop {CSSProperties} [containerStyle] - styles for input container
+ * @prop {boolean} [inverted] - use inverted styling
+ * @prop {boolean} [withError=true] - does the input uses the error props? 'true' value will preserve
+ * the bottom spacing so the layout will not flicker when error message appears and disappears.
+ */
+
+const Input = (
+ {
+ autoFocus,
+ type = 'text',
+ value,
+ id,
+ label,
+ disabled,
+ error,
+ placeholder,
+ inputIcon,
+ inputUnits,
+ onIconClick,
+ onChange,
+ testId,
+ style,
+ containerStyle,
+ inverted,
+ withError = true,
+ onClick,
+ }: InputProps,
+ ref?: React.ForwardedRef,
+) => {
+ const theme = useTheme()
+
+ const iconsRef = useRef(null)
+ const [iconWrapperWidth, setIconWrapperWidth] = useState(22)
+
+ useEffect(() => {
+ if (iconsRef.current) {
+ setIconWrapperWidth((iconsRef.current as HTMLDivElement).offsetWidth)
+ }
+ }, [inputIcon])
+
+ const onChangeTextLocal = (event: ChangeEvent) => {
+ if (onChange) {
+ onChange(event.target.value)
+ }
+ }
+
+ return (
+ <>
+ {label && (
+
+ )}
+
+ onChangeTextLocal(val)}
+ value={value}
+ spellCheck={false}
+ data-testid={testId || 'input-cmp'}
+ style={style}
+ ref={ref}
+ />
+ {(inputIcon || inputUnits) && (
+
+ {inputIcon && (
+
+ {inputIcon}
+
+ )}{' '}
+ {inputUnits && (
+
+ {inputUnits}
+
+ )}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+ >
+ )
+}
+
+export default forwardRef(Input)
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Input/styles.ts b/applications/launchpad/gui-react/src/components/Inputs/Input/styles.ts
new file mode 100644
index 0000000000..638606cf81
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Input/styles.ts
@@ -0,0 +1,89 @@
+/* eslint-disable indent */
+import { InputHTMLAttributes } from 'react'
+import styled from 'styled-components'
+
+import Text from '../../Text'
+
+export const StyledInput = styled.input>`
+ height: 100%;
+ width: 100%;
+ padding: 0px 16px;
+ font-family: 'AvenirMedium';
+ font-size: 14px;
+ line-height: inherit;
+ color: ${({ theme, disabled }) => {
+ if (disabled) {
+ return theme.placeholderText
+ } else {
+ return theme.primary
+ }
+ }};
+ background-color: ${({ theme, disabled }) =>
+ disabled ? theme.backgroundImage : theme.background};
+ border: none;
+ border-radius: 8px;
+ ::placeholder {
+ color: ${({ theme }) => theme.inputPlaceholder};
+ }
+ &:focus {
+ outline: none;
+ color: ${({ theme }) => {
+ return theme.primary
+ }};
+ }
+`
+
+export const InputContainer = styled.div<{
+ disabled?: boolean
+ $error: boolean
+ $withError?: boolean
+}>`
+ height: 42px;
+ width: 369px;
+ line-height: 42px;
+ display: flex;
+ align-items: center;
+ background-color: ${({ theme, disabled }) =>
+ disabled ? theme.backgroundImage : theme.background};
+ border: 1px solid;
+ border-color: ${({ theme }) => theme.borderColor};
+ border-radius: 8px;
+ font-family: 'AvenirMedium';
+ margin-bottom: ${({ $withError, $error, theme }) =>
+ $error || !$withError ? '0' : theme.spacingVertical(1.6)};
+ :focus-within {
+ outline: none;
+ border-color: ${({ theme }) => theme.accent};
+ }
+`
+
+export const IconUnitsContainer = styled.div<{ $iconWrapperWidth: number }>`
+ width: ${({ $iconWrapperWidth }) => $iconWrapperWidth}px;
+ height: auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 10px;
+`
+
+export const IconWrapper = styled.div<{ onClick?: () => void }>`
+ display: flex;
+ cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
+ font-size: 20px;
+ color: ${({ theme }) => theme.secondary};
+`
+
+export const UnitsText = styled(Text)`
+ color: ${({ theme }) => theme.placeholderText};
+ text-transform: uppercase;
+`
+
+export const Label = styled.label<{ $inverted?: boolean; $noMargin?: boolean }>`
+ font-size: 0.88em;
+ display: inline-block;
+ margin-bottom: ${({ theme, $noMargin }) =>
+ $noMargin ? '0px' : theme.spacingVertical()};
+ color: ${({ theme, $inverted }) =>
+ $inverted ? theme.inverted.primary : theme.primary};
+ font-family: 'AvenirMedium';
+`
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Input/types.ts b/applications/launchpad/gui-react/src/components/Inputs/Input/types.ts
new file mode 100644
index 0000000000..8d09ca2ba3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Input/types.ts
@@ -0,0 +1,22 @@
+import { ReactNode, CSSProperties, InputHTMLAttributes } from 'react'
+
+export interface InputProps
+ extends Omit, 'onChange'> {
+ type?: string
+ disabled?: boolean
+ value?: string
+ id?: string
+ label?: ReactNode
+ error?: string
+ placeholder?: string
+ inputUnits?: string
+ inputIcon?: ReactNode
+ onIconClick?: () => void
+ onChange?: (value: string) => void
+ testId?: string
+ style?: CSSProperties
+ containerStyle?: CSSProperties
+ inverted?: boolean
+ withError?: boolean
+ onClick?: () => void
+}
diff --git a/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/PasswordInput.test.tsx b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/PasswordInput.test.tsx
new file mode 100644
index 0000000000..12e1df3e40
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/PasswordInput.test.tsx
@@ -0,0 +1,98 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import PasswordInput from '.'
+
+import themes from '../../../styles/themes'
+
+afterEach(cleanup)
+
+describe('PasswordInput', () => {
+ it('should render input as password initially', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByDisplayValue('text for testing')
+ expect(el.getAttribute('type')).toEqual('password')
+ })
+
+ it('should show password after show password icon is clicked', () => {
+ render(
+
+
+ ,
+ )
+
+ const passwordIcon = screen.getByTestId('reveal-icon-test')
+ expect(passwordIcon).toBeInTheDocument()
+
+ fireEvent.click(passwordIcon)
+
+ const afterClick = screen.getByDisplayValue('password for testing')
+ expect(afterClick.getAttribute('type')).toEqual('text')
+ })
+
+ it('should render password strength meter for weak passwords', () => {
+ render(
+
+
+ ,
+ )
+
+ const meter = screen.getByTestId('strength-meter')
+ expect(meter).toBeInTheDocument()
+
+ expect(Number(meter.getAttribute('data-strength'))).toBeLessThanOrEqual(
+ 0.25,
+ )
+ })
+
+ it('should render password strength meter for medium passwords', () => {
+ render(
+
+
+ ,
+ )
+
+ const meter = screen.getByTestId('strength-meter')
+ expect(meter).toBeInTheDocument()
+
+ expect(Number(meter.getAttribute('data-strength'))).toBeGreaterThanOrEqual(
+ 0.5,
+ )
+ expect(Number(meter.getAttribute('data-strength'))).toBeLessThanOrEqual(
+ 0.75,
+ )
+ })
+
+ it('should render password strength meter for strong passwords', () => {
+ render(
+
+
+ ,
+ )
+
+ const meter = screen.getByTestId('strength-meter')
+ expect(meter).toBeInTheDocument()
+
+ expect(Number(meter.getAttribute('data-strength'))).toBeGreaterThanOrEqual(
+ 0.75,
+ )
+ })
+
+ it('should render empty password strength meter if value is not set', () => {
+ render(
+
+
+ ,
+ )
+
+ const meter = screen.getByTestId('strength-meter')
+ expect(meter).toBeInTheDocument()
+
+ expect(Number(meter.getAttribute('data-strength'))).toBe(0)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/StrengthMeter.tsx b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/StrengthMeter.tsx
new file mode 100644
index 0000000000..c6497a1c50
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/StrengthMeter.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useRef, useState } from 'react'
+import { useSpring, animated } from 'react-spring'
+import { useTheme } from 'styled-components'
+import zxcvbn from 'zxcvbn'
+import { StyledStrengthMeter } from './styles'
+
+/**
+ * Calculate the password strength with zxcvbn and render circle meter.
+ * @param {string} [password]
+ */
+const StrengthMeter = ({ password }: { password?: string }) => {
+ const theme = useTheme()
+ const pathRef = useRef(null)
+ const [offset, setOffset] = useState(0)
+ const [strength, setStrength] = useState(1)
+
+ useEffect(() => {
+ if (pathRef.current?.getTotalLength) {
+ setOffset((pathRef.current as SVGCircleElement).getTotalLength())
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!password) {
+ setStrength(0)
+ } else {
+ const { score } = zxcvbn(password)
+ setStrength((score + 1) / 4)
+ }
+ }, [password])
+
+ const getColor = () => {
+ if (strength <= 0.25) {
+ return theme.moneroDark
+ } else if (strength > 0.25 && strength <= 0.5) {
+ return theme.warningText
+ } else if (strength > 0.5 && strength <= 0.75) {
+ return theme.infoText
+ } else {
+ return theme.onTextLight
+ }
+ }
+
+ const { progress, color } = useSpring({
+ progress:
+ strength >= 1
+ ? `${Math.round(0.99 * offset)} ${offset - Math.round(0.99 * offset)}`
+ : `${Math.round(strength * offset)} ${
+ offset - Math.round(strength * offset)
+ }`,
+ color: getColor(),
+ tension: 4,
+ friction: 0.5,
+ precision: 0.1,
+ })
+
+ return (
+
+
+
+ )
+}
+
+export default StrengthMeter
diff --git a/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/index.tsx b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/index.tsx
new file mode 100644
index 0000000000..d9151343f2
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/index.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react'
+
+import Input from '../Input'
+import Eye from '../../../styles/Icons/Eye'
+import EyeSlash from '../../../styles/Icons/EyeSlash'
+
+import { PasswordInputProps } from './types'
+import { ClickableInputIcon, InputIcons } from './styles'
+import StrengthMeter from './StrengthMeter'
+
+/**
+ * @name PasswordInput component
+ * @typedef PasswordInputProps
+ *
+ * @prop {boolean} [disabled] - whether it is disabled or not
+ * @prop {string} [value] - input text value
+ * @prop {boolean} [hideText] - show/hide input text
+ * @prop {string} [placeholder] - placeholder text
+ * @prop {(value: string) => void} [onChange] - text change event handler
+ * @prop {string} [testId] - for testing purposes
+ */
+
+const PasswordInput = ({ ...props }: PasswordInputProps) => {
+ const [showPassword, setShowPassword] = useState(false)
+
+ const { useReveal, useStrengthMeter } = props
+
+ const inputIcons = (
+
+ {useStrengthMeter ? : null}
+ {useReveal ? (
+
+ {showPassword ? (
+ setShowPassword(false)} />
+ ) : (
+ setShowPassword(true)}
+ data-testid='reveal-icon-test'
+ />
+ )}
+
+ ) : null}
+
+ )
+
+ return (
+
+ )
+}
+
+export default PasswordInput
diff --git a/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/styles.ts b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/styles.ts
new file mode 100644
index 0000000000..7fbf5f1f77
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/styles.ts
@@ -0,0 +1,22 @@
+import styled from 'styled-components'
+
+export const InputIcons = styled.div`
+ display: flex;
+ align-items: center;
+ column-grid-gap: 8px;
+
+ & > svg {
+ margin-left: 2px;
+ margin-right: 2px;
+ }
+`
+
+export const ClickableInputIcon = styled.div`
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+`
+
+export const StyledStrengthMeter = styled.span`
+ transform: rotate(-90deg);
+`
diff --git a/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/types.ts b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/types.ts
new file mode 100644
index 0000000000..ebd3e8b0d3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/PasswordInput/types.ts
@@ -0,0 +1,9 @@
+import { InputProps } from '../Input/types'
+
+export type PasswordInputProps = Omit<
+ InputProps,
+ 'type' | 'inputIcon' | 'onIconClick' | 'inputUnits'
+> & {
+ useReveal?: boolean
+ useStrengthMeter?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Inputs/TextInput/TextInput.test.tsx b/applications/launchpad/gui-react/src/components/Inputs/TextInput/TextInput.test.tsx
new file mode 100644
index 0000000000..c4d261f572
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/TextInput/TextInput.test.tsx
@@ -0,0 +1,21 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import TextInput from '.'
+
+import themes from '../../../styles/themes'
+
+afterEach(cleanup)
+
+describe('TextInput', () => {
+ it('should hide the input text when hideText prop is set to true', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.queryByText('text for testing')
+ expect(el).not.toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Inputs/TextInput/index.tsx b/applications/launchpad/gui-react/src/components/Inputs/TextInput/index.tsx
new file mode 100644
index 0000000000..b2f6aef677
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/TextInput/index.tsx
@@ -0,0 +1,24 @@
+import Input from '../Input'
+
+import { TextInputProps } from './types'
+
+/**
+ * @name TextInput component
+ * @typedef TextInputProps
+ *
+ * @prop {boolean} [disabled] - whether it is disabled or not
+ * @prop {string} [value] - input text value
+ * @prop {boolean} [hideText] - show/hide input text
+ * @prop {string} [placeholder] - placeholder text
+ * @prop {ReactNode} [inputIcon] - optional icon rendered inside input field
+ * @prop {string} [inputUnits] - optional units text, e.g. 'MB' on right-hand side of input field
+ * @prop {() => void} [onIconClick] - icon click event
+ * @prop {(value: string) => void} [onChange] - text change event handler
+ * @prop {string} [testId] - for testing purposes
+ */
+
+const TextInput = ({ hideText = false, value, ...props }: TextInputProps) => {
+ return
+}
+
+export default TextInput
diff --git a/applications/launchpad/gui-react/src/components/Inputs/TextInput/types.ts b/applications/launchpad/gui-react/src/components/Inputs/TextInput/types.ts
new file mode 100644
index 0000000000..c4dd95077a
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/TextInput/types.ts
@@ -0,0 +1,5 @@
+import { InputProps } from '../Input/types'
+
+export interface TextInputProps extends Omit {
+ hideText?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Textarea/Textarea.test.tsx b/applications/launchpad/gui-react/src/components/Inputs/Textarea/Textarea.test.tsx
new file mode 100644
index 0000000000..8b050952b7
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Textarea/Textarea.test.tsx
@@ -0,0 +1,50 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Textarea from '.'
+
+import themes from '../../../styles/themes'
+
+afterEach(cleanup)
+
+const onChangeTextMock = jest.fn()
+
+describe('Textarea', () => {
+ it('should render the Text Area without crashing', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('textarea-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should update textarea value on change', () => {
+ const newInput = 'test text'
+
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('textarea-cmp')
+
+ fireEvent.change(el, { target: { value: newInput } })
+ expect(onChangeTextMock).toHaveBeenCalledWith(newInput)
+ })
+
+ it('should render error container if error exists', () => {
+ const errorMessage = 'test error'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByText(errorMessage)
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Textarea/index.tsx b/applications/launchpad/gui-react/src/components/Inputs/Textarea/index.tsx
new file mode 100644
index 0000000000..5387349813
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Textarea/index.tsx
@@ -0,0 +1,71 @@
+import { ChangeEvent } from 'react'
+import { Label } from '../Input/styles'
+import Text from '../../Text'
+import { InputContainer, StyledTextarea, ErrorContainer } from './styles'
+import { TextareaProps } from './types'
+import { useTheme } from 'styled-components'
+
+const Textarea = ({
+ id,
+ value,
+ rows,
+ cols,
+ label,
+ placeholder,
+ style,
+ onChange,
+ disabled,
+ withError,
+ error,
+ inverted,
+ testId,
+}: TextareaProps) => {
+ const theme = useTheme()
+
+ const onChangeTextLocal = (event: ChangeEvent) => {
+ if (onChange) {
+ onChange(event.target.value)
+ }
+ }
+
+ return (
+ <>
+ {label && (
+
+ )}
+
+
+
+ {withError && (
+
+ {Boolean(error) && (
+
+ {error}
+
+ )}
+
+ )}
+ >
+ )
+}
+
+export default Textarea
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Textarea/styles.ts b/applications/launchpad/gui-react/src/components/Inputs/Textarea/styles.ts
new file mode 100644
index 0000000000..d3c0cde00a
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Textarea/styles.ts
@@ -0,0 +1,75 @@
+import { TextareaHTMLAttributes } from 'react'
+import styled from 'styled-components'
+
+export const InputContainer = styled.div`
+ padding: 2px 6px;
+ border: 1px solid;
+ border-color: ${({ theme }) => theme.borderColor};
+ border-radius: 8px;
+ width: 100%;
+ display: flex;
+ box-sizing: border-box;
+`
+
+export const StyledTextarea = styled.textarea<
+ TextareaHTMLAttributes
+>`
+ width: 100%;
+ padding: 10px;
+ box-sizing: border-box;
+ font-family: 'AvenirMedium';
+ font-size: 14px;
+ line-height: inherit;
+ color: ${({ theme, disabled }) => {
+ if (disabled) {
+ return theme.placeholderText
+ } else {
+ return theme.primary
+ }
+ }};
+ background-color: ${({ theme, disabled }) =>
+ disabled ? theme.backgroundImage : theme.background};
+ border: none;
+ border-radius: 8px;
+ resize: vertical;
+
+ ::placeholder {
+ color: ${({ theme }) => theme.placeholderText};
+ }
+
+ &:focus {
+ outline: none;
+ color: ${({ theme }) => {
+ return theme.primary
+ }};
+ }
+
+ ::-webkit-scrollbar {
+ width: 4px;
+ }
+
+ /* Track */
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ /* Handle */
+ ::-webkit-scrollbar-thumb {
+ background: ${({ theme }) => theme.borderColor};
+ border-radius: 3px;
+ }
+
+ /* Handle on hover */
+ ::-webkit-scrollbar-thumb:hover {
+ background: #555;
+ }
+`
+
+export const ErrorContainer = styled.div`
+ display: flex;
+ min-height: 25px;
+ padding-top: ${({ theme }) => theme.spacing(0.075)};
+ padding-bottom: ${({ theme }) => theme.spacing(0.125)};
+ padding-left: ${({ theme }) => theme.spacing(0.25)};
+ padding-right: ${({ theme }) => theme.spacing(0.25)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Inputs/Textarea/types.ts b/applications/launchpad/gui-react/src/components/Inputs/Textarea/types.ts
new file mode 100644
index 0000000000..2223368509
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Inputs/Textarea/types.ts
@@ -0,0 +1,11 @@
+import { TextareaHTMLAttributes } from 'react'
+
+export interface TextareaProps
+ extends Omit, 'onChange'> {
+ label?: string
+ onChange?: (value: string) => void
+ inverted?: boolean
+ testId?: string
+ error?: string
+ withError?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Iterator/index.test.tsx b/applications/launchpad/gui-react/src/components/Iterator/index.test.tsx
new file mode 100644
index 0000000000..aa3f8acc47
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Iterator/index.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+
+import Iterator from '.'
+
+describe('Iterator', () => {
+ it('should render current value', () => {
+ const currentValue = 'current value'
+ render(
+
+ null}
+ previous={() => null}
+ />
+ ,
+ )
+
+ expect(screen.getByText(currentValue)).toBeInTheDocument()
+ })
+
+ it('should call callbacks when next/prev buttons clicked', () => {
+ const next = jest.fn()
+ const previous = jest.fn()
+
+ render(
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('iterator-btn-prev'))
+ fireEvent.click(screen.getByTestId('iterator-btn-next'))
+
+ expect(next).toHaveBeenCalledTimes(1)
+ expect(previous).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not render next/prev buttons if no more values available', () => {
+ const next = jest.fn()
+ const previous = jest.fn()
+
+ render(
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('iterator-btn-prev'))
+ fireEvent.click(screen.getByTestId('iterator-btn-next'))
+
+ expect(next).toHaveBeenCalledTimes(0)
+ expect(previous).toHaveBeenCalledTimes(0)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Iterator/index.tsx b/applications/launchpad/gui-react/src/components/Iterator/index.tsx
new file mode 100644
index 0000000000..1a297a1646
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Iterator/index.tsx
@@ -0,0 +1,73 @@
+import { CSSProperties } from 'react'
+import { useTheme } from 'styled-components'
+
+import IconButton from '../IconButton'
+import Text from '../Text'
+import ArrowLeft from '../../styles/Icons/ArrowLeft2'
+import ArrowRight from '../../styles/Icons/ArrowRight2'
+
+import { Wrapper } from './styles'
+
+/**
+ * @name Iterator
+ * @description controlled presentation component for iterating over any value with next/previous buttons for the user
+ *
+ * @prop {string} value - current value
+ * @prop {() => void} next - callback for going to next value
+ * @prop {() => void} previous - callback for going to previous value
+ * @prop {CSSProperties} style - wrapper style overrides
+ */
+const Iterator = ({
+ value,
+ next,
+ previous,
+ hasNext,
+ hasPrevious,
+ style,
+}: {
+ value: string
+ next: () => void
+ previous: () => void
+ hasNext?: boolean
+ hasPrevious?: boolean
+ style?: CSSProperties
+}) => {
+ const theme = useTheme()
+
+ const disableNextButton = hasNext !== undefined && !hasNext
+ const disablePreviousButton = hasPrevious !== undefined && !hasPrevious
+
+ return (
+
+
+
+
+
+ {value}
+
+
+
+
+
+ )
+}
+
+export default Iterator
diff --git a/applications/launchpad/gui-react/src/components/Iterator/styles.ts b/applications/launchpad/gui-react/src/components/Iterator/styles.ts
new file mode 100644
index 0000000000..ada16d40f6
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Iterator/styles.ts
@@ -0,0 +1,11 @@
+import styled from 'styled-components'
+
+export const Wrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border: 1px solid ${({ theme }) => theme.buttonRadioBorder};
+ border-radius: ${({ theme }) => theme.tightBorderRadius()};
+ padding: ${({ theme }) => theme.spacingVertical(0.5)};
+ ${({ theme }) => theme.spacingHorizontal()};
+`
diff --git a/applications/launchpad/gui-react/src/components/KeyboardKeys/KeyboardKeys.test.tsx b/applications/launchpad/gui-react/src/components/KeyboardKeys/KeyboardKeys.test.tsx
new file mode 100644
index 0000000000..3c8e46d356
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/KeyboardKeys/KeyboardKeys.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react'
+
+import KeyboardKeys from '.'
+
+describe('KeyboardKeys', () => {
+ it('should render given keys', async () => {
+ render()
+
+ const ctrlTile = screen.getByText('Ctrl')
+ expect(ctrlTile).toBeInTheDocument()
+
+ const rTile = screen.getByText('R')
+ expect(rTile).toBeInTheDocument()
+
+ const winTile = screen.getByTestId('svg-winkey')
+ expect(winTile).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/KeyboardKeys/index.tsx b/applications/launchpad/gui-react/src/components/KeyboardKeys/index.tsx
new file mode 100644
index 0000000000..4acd724634
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/KeyboardKeys/index.tsx
@@ -0,0 +1,43 @@
+import { ReactNode } from 'react'
+
+import { IconsWrapper, KeyTile, LetterKey } from './styles'
+import { KeyboardKeysProps } from './types'
+
+import SvgCmdKey from '../../styles/Icons/CmdKey'
+import SvgWinKey from '../../styles/Icons/WinKey'
+
+/**
+ * Renders keyboard keys as set of tiles.
+ * Use whenever you need to show the keyboard shortcuts, ie: "Ctrl + Alt + T"
+ *
+ * Use 'win' and 'cmd' to render Windows and Command keys.
+ *
+ * @param {string[]} keys - the set of keyboard keys
+ *
+ * @example
+ *
+ */
+const KeyboardKeys = ({ keys }: KeyboardKeysProps) => {
+ const result: ReactNode[] = []
+
+ keys.forEach((key, idx) => {
+ let symbol
+ switch (key) {
+ case 'win':
+ symbol =
+ break
+ case 'cmd':
+ symbol =
+ break
+ default:
+ symbol = {key}
+ break
+ }
+
+ result.push({symbol})
+ })
+
+ return {result}
+}
+
+export default KeyboardKeys
diff --git a/applications/launchpad/gui-react/src/components/KeyboardKeys/styles.ts b/applications/launchpad/gui-react/src/components/KeyboardKeys/styles.ts
new file mode 100644
index 0000000000..677dacceba
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/KeyboardKeys/styles.ts
@@ -0,0 +1,31 @@
+import styled from 'styled-components'
+
+export const IconsWrapper = styled.div`
+ display: inline-block;
+ vertical-align: baseline;
+`
+
+export const KeyTile = styled.span`
+ display: inline-block;
+ vertical-align: middle;
+ text-align: center;
+ font-size: 10px;
+ line-height: 10px;
+ padding: 2px;
+ background: transparent;
+ border: 1px solid ${({ theme }) => theme.borderColorLight};
+ border-radius: 4px;
+ min-width: 16px;
+ height: 16px;
+ box-sizing: border-box;
+ margin-left: 1px;
+ margin-right: 1px;
+ margin-top: -4%;
+`
+
+export const LetterKey = styled.span`
+ text-align: center;
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: 500;
+`
diff --git a/applications/launchpad/gui-react/src/components/KeyboardKeys/types.ts b/applications/launchpad/gui-react/src/components/KeyboardKeys/types.ts
new file mode 100644
index 0000000000..36a7eb52f1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/KeyboardKeys/types.ts
@@ -0,0 +1,3 @@
+export interface KeyboardKeysProps {
+ keys: string[]
+}
diff --git a/applications/launchpad/gui-react/src/components/Loading/index.tsx b/applications/launchpad/gui-react/src/components/Loading/index.tsx
new file mode 100644
index 0000000000..0b1673c4e5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Loading/index.tsx
@@ -0,0 +1,34 @@
+import { CSSProperties } from 'react'
+
+import LoadingIcon from '../../styles/Icons/Loading'
+
+import { StyledSpan } from './styles'
+
+/**
+ * Loading
+ * renders a spinning loading indicator
+ *
+ * @prop {boolean} loading - controls whether the indicator should be shown or not
+ * @prop {string} [testId] - optional testId to assign for testing purposes
+ */
+
+const Loading = ({
+ loading,
+ size = '20px',
+ color,
+ testId,
+ style,
+}: {
+ loading?: boolean
+ size?: string
+ testId?: string
+ color?: string
+ style?: CSSProperties
+}) =>
+ loading ? (
+
+
+
+ ) : null
+
+export default Loading
diff --git a/applications/launchpad/gui-react/src/components/Loading/styles.ts b/applications/launchpad/gui-react/src/components/Loading/styles.ts
new file mode 100644
index 0000000000..4296c5a3d3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Loading/styles.ts
@@ -0,0 +1,17 @@
+import styled, { keyframes } from 'styled-components'
+
+const spinKeyframes = keyframes`
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+`
+
+export const StyledSpan = styled.span`
+ line-height: 0;
+ & > svg {
+ animation: ${spinKeyframes} infinite 2s linear;
+ }
+`
diff --git a/applications/launchpad/gui-react/src/components/Loading/test.tsx b/applications/launchpad/gui-react/src/components/Loading/test.tsx
new file mode 100644
index 0000000000..34e845913c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Loading/test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import Loading from '.'
+
+describe('Loading', () => {
+ it('should render loading indicator when loading=true', () => {
+ const testId = 'loading=true'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId(testId)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should NOT render loading indicator when loading=false', () => {
+ const testId = 'loading=false'
+ render(
+
+
+ ,
+ )
+
+ const el = screen.queryByTestId(testId)
+ expect(el).not.toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/LoadingOverlay/index.tsx b/applications/launchpad/gui-react/src/components/LoadingOverlay/index.tsx
new file mode 100644
index 0000000000..fe5db0fc47
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/LoadingOverlay/index.tsx
@@ -0,0 +1,38 @@
+import styled, { useTheme } from 'styled-components'
+
+import Loading from '../Loading'
+
+const Overlay = styled.div`
+ position: absolute;
+ padding: ${({ theme }) => theme.spacing(2)};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ z-index: 1;
+ backdrop-filter: grayscale(90%);
+`
+
+const LoadingOverlay = ({ inverted }: { inverted?: boolean }) => {
+ const theme = useTheme()
+
+ return (
+
+
+
+ )
+}
+
+export default LoadingOverlay
diff --git a/applications/launchpad/gui-react/src/components/Logo/Logo.test.tsx b/applications/launchpad/gui-react/src/components/Logo/Logo.test.tsx
new file mode 100644
index 0000000000..9d3be19889
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Logo/Logo.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react'
+
+import Logo from '.'
+
+describe('Logo', () => {
+ it('should render the signet variant', () => {
+ render()
+
+ const svgEl = screen.getByTestId('svg-tarisignet')
+ expect(svgEl).toBeInTheDocument()
+ })
+
+ it('should render the logo variant', () => {
+ render()
+
+ const svgEl = screen.getByTestId('svg-tarilogo')
+ expect(svgEl).toBeInTheDocument()
+ })
+
+ it('should render the full variant', () => {
+ render()
+
+ const svgEl = screen.getByTestId('svg-tarilaunchpadlogo')
+ expect(svgEl).toBeInTheDocument()
+ })
+
+ it('should render the default variant', () => {
+ render()
+
+ const svgEl = screen.getByTestId('svg-tarilogo')
+ expect(svgEl).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Logo/index.tsx b/applications/launchpad/gui-react/src/components/Logo/index.tsx
new file mode 100644
index 0000000000..0458a5bf54
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Logo/index.tsx
@@ -0,0 +1,26 @@
+import SvgTariLaunchpadLogo from '../../styles/Icons/TariLaunchpadLogo'
+import SvgTariLogo from '../../styles/Icons/TariLogo'
+import SvgTariSignet from '../../styles/Icons/TariSignet'
+
+import { LogoProps } from './types'
+
+/**
+ * Render given Logo variant.
+ * @param {'logo' | 'signet' | 'full'} [variant = 'logo'] - selected variant
+ *
+ * - signet - only signet
+ * - logo - signet with 'Tari'
+ * - full - signet with 'Tari Launchpad'
+ */
+const Logo = ({ variant = 'logo' }: LogoProps) => {
+ switch (variant) {
+ case 'signet':
+ return
+ case 'full':
+ return
+ default:
+ return
+ }
+}
+
+export default Logo
diff --git a/applications/launchpad/gui-react/src/components/Logo/types.ts b/applications/launchpad/gui-react/src/components/Logo/types.ts
new file mode 100644
index 0000000000..2105509ce6
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Logo/types.ts
@@ -0,0 +1,3 @@
+export interface LogoProps {
+ variant?: 'signet' | 'logo' | 'full'
+}
diff --git a/applications/launchpad/gui-react/src/components/Modal/index.test.tsx b/applications/launchpad/gui-react/src/components/Modal/index.test.tsx
new file mode 100644
index 0000000000..42078e422c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Modal/index.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+
+import Modal from '.'
+
+describe('Modal', () => {
+ it('should not render children when modal is not open', () => {
+ render(
+
+ null}>
+ child element
+
+ ,
+ )
+
+ const el = screen.queryByText('child element')
+ expect(el).not.toBeInTheDocument()
+ })
+
+ it('should render children when modal is not open', () => {
+ render(
+
+ null}>
+ child element
+
+ ,
+ )
+
+ const el = screen.queryByText('child element')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should close modal on backdrop click', () => {
+ const onClose = jest.fn()
+ render(
+
+
+ child element
+
+ ,
+ )
+
+ const backdrop = screen.getByTestId('modal-backdrop')
+ fireEvent.click(backdrop)
+
+ expect(onClose).toHaveBeenCalled()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Modal/index.tsx b/applications/launchpad/gui-react/src/components/Modal/index.tsx
new file mode 100644
index 0000000000..677618a38b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Modal/index.tsx
@@ -0,0 +1,32 @@
+import { useTheme } from 'styled-components'
+import Backdrop from '../Backdrop'
+
+import { ModalContainer, ModalContent } from './styles'
+import type { ModalProps } from './types'
+
+const Modal = ({ open, children, onClose, size, local, style }: ModalProps) => {
+ if (!open) {
+ return null
+ }
+ const theme = useTheme()
+
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+
+Modal.defaultProps = {
+ size: 'large',
+}
+
+export default Modal
diff --git a/applications/launchpad/gui-react/src/components/Modal/styles.ts b/applications/launchpad/gui-react/src/components/Modal/styles.ts
new file mode 100644
index 0000000000..8a8ba498d9
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Modal/styles.ts
@@ -0,0 +1,39 @@
+import styled from 'styled-components'
+
+import type { ModalProps } from './types'
+
+export const ModalContainer = styled.div>`
+ position: ${({ local }) => (local ? 'absolute' : 'fixed')};
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100% !important;
+ z-index: 100;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`
+
+export const ModalContent = styled.div>`
+ position: relative;
+ width: ${({ size }) => {
+ if (size === 'large') {
+ return '880px'
+ }
+
+ if (size === 'small') {
+ return '449px'
+ }
+
+ return 'auto'
+ }};
+ height: ${({ size }) => (size === 'auto' ? 'auto' : '642px')};
+ max-width: 80vw;
+ max-height: 80vh;
+ background: ${({ theme }) => theme.nodeBackground};
+ border-radius: ${({ theme }) => theme.borderRadius()};
+ z-index: 2;
+ box-sizing: border-box;
+ box-shadow: ${({ theme }) => theme.shadow40};
+`
diff --git a/applications/launchpad/gui-react/src/components/Modal/types.ts b/applications/launchpad/gui-react/src/components/Modal/types.ts
new file mode 100644
index 0000000000..c8aa64f0a3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Modal/types.ts
@@ -0,0 +1,10 @@
+import { CSSProperties, ReactNode } from 'react'
+
+export interface ModalProps {
+ open?: boolean
+ children: ReactNode
+ onClose?: () => void
+ size?: 'large' | 'small' | 'auto'
+ local?: boolean
+ style?: CSSProperties
+}
diff --git a/applications/launchpad/gui-react/src/components/NodeBox/NodeBox.test.tsx b/applications/launchpad/gui-react/src/components/NodeBox/NodeBox.test.tsx
new file mode 100644
index 0000000000..491b1915a8
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/NodeBox/NodeBox.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+
+import NodeBox, { NodeBoxContentPlaceholder } from '.'
+
+describe('NodeBox', () => {
+ it('should render without crashing', async () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('node-box-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render placeholder without crashing', async () => {
+ const testText = 'Test text in placeholder'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const el = screen.getByText(testText)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render placeholder without crashing', async () => {
+ const testText = 'Test text in placeholder'
+ const testCmp = {testText}
+ render(
+
+ {testCmp}
+ ,
+ )
+
+ const el = screen.getByText(testText)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render the correct help icon colour', () => {
+ const mock = jest.fn()
+ render(
+
+
+ ,
+ )
+
+ const textColour = themes.light.inverted.secondary
+
+ const el = screen.getByTestId('help-icon-cmp')
+ expect(el).toHaveStyle(`color: ${textColour}`)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/NodeBox/index.tsx b/applications/launchpad/gui-react/src/components/NodeBox/index.tsx
new file mode 100644
index 0000000000..0ef142618f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/NodeBox/index.tsx
@@ -0,0 +1,97 @@
+import { useTheme } from 'styled-components'
+import SvgQuestion from '../../styles/Icons/Question'
+import Box from '../Box'
+import Tag from '../Tag'
+import Text from '../Text'
+
+import {
+ BoxHeader,
+ BoxContent,
+ NodeBoxPlacholder,
+ TitleRow,
+ SvgContainer,
+} from './styles'
+import { NodeBoxContentPlaceholderProps, NodeBoxProps } from './types'
+
+/**
+ * The advanced Box component handling:
+ * - custom title
+ * - header tag
+ * - background depending on the status prop
+ *
+ * Used for the UI representation of the Node (Docker container) as a Box component.
+ *
+ * @param {string} [title] - the box heading
+ * @param {{ text: string; type?: TagType }} [tag = 'inactive'] - the status of the box/node
+ * @param {CSSWithSpring} [style] - the box style
+ * @param {CSSWithSpring} [titleStyle] - the title style
+ * @param {CSSWithSpring} [contentStyle] - the content style
+ * @param {() => void} [onHelpPromptClick] - onClick handler for help icon
+ * @param {ReactNode} [children] - the box heading
+ * @param {string} [testId] - react test id
+ */
+
+const NodeBox = ({
+ title,
+ tag,
+ style,
+ titleStyle,
+ contentStyle,
+ onHelpPromptClick,
+ helpSvgGradient,
+ children,
+ testId = 'node-box-cmp',
+}: NodeBoxProps) => {
+ const theme = useTheme()
+
+ return (
+
+
+ {tag ? (
+
+ {tag.content}
+
+ ) : null}
+
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {onHelpPromptClick && (
+
+
+
+ )}
+
+ {children}
+
+ )
+}
+
+/**
+ * Simple placholder container for the node box that provides default spacing and layout.
+ * @param {string | ReactNode} children - the content
+ */
+export const NodeBoxContentPlaceholder = ({
+ children,
+ testId = 'node-box-content-placeholder',
+}: NodeBoxContentPlaceholderProps) => {
+ let content = children
+
+ if (typeof children === 'string') {
+ content = {children}
+ }
+
+ return {content}
+}
+
+export default NodeBox
diff --git a/applications/launchpad/gui-react/src/components/NodeBox/styles.ts b/applications/launchpad/gui-react/src/components/NodeBox/styles.ts
new file mode 100644
index 0000000000..cd4eb9cbcc
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/NodeBox/styles.ts
@@ -0,0 +1,34 @@
+import styled from 'styled-components'
+
+export const BoxHeader = styled.div`
+ height: 36px;
+`
+
+export const TitleRow = styled.div`
+ display: flex;
+ align-items: center;
+`
+
+export const SvgContainer = styled.div<{ running?: boolean }>`
+ display: flex;
+ justify-content: center;
+ font-size: 20px;
+ margin-left: ${({ theme }) => theme.spacingHorizontal(0.333)};
+ cursor: pointer;
+ color: ${({ theme, running }) => (running ? theme.textSecondary : null)};
+`
+
+export const BoxContent = styled.div`
+ padding-top: ${({ theme }) => theme.spacingVertical(1)};
+ padding-bottom: ${({ theme }) => theme.spacingVertical(1)};
+ min-height: 136px;
+ display: flex;
+ flex-direction: column;
+`
+
+export const NodeBoxPlacholder = styled.div`
+ display: flex;
+ flex: 1;
+ padding-top: ${({ theme }) => theme.spacingVertical(1)};
+ padding-bottom: ${({ theme }) => theme.spacingVertical(1)};
+`
diff --git a/applications/launchpad/gui-react/src/components/NodeBox/types.ts b/applications/launchpad/gui-react/src/components/NodeBox/types.ts
new file mode 100644
index 0000000000..bb4413ea78
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/NodeBox/types.ts
@@ -0,0 +1,23 @@
+import { ReactNode } from 'react'
+import { CSSWithSpring } from '../../types/general'
+import { TagType } from '../Tag/types'
+
+export interface NodeBoxProps {
+ title?: string
+ tag?: {
+ content: string | ReactNode
+ type?: TagType
+ }
+ style?: CSSWithSpring
+ titleStyle?: CSSWithSpring
+ contentStyle?: CSSWithSpring
+ children?: ReactNode
+ onHelpPromptClick?: () => void
+ helpSvgGradient?: boolean
+ testId?: string
+}
+
+export interface NodeBoxContentPlaceholderProps {
+ children: string | ReactNode
+ testId?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerImagesMessages.tsx b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerImagesMessages.tsx
new file mode 100644
index 0000000000..b3e4afd464
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerImagesMessages.tsx
@@ -0,0 +1,223 @@
+/* eslint-disable react/jsx-key */
+import { useEffect, useState } from 'react'
+import Text from '../../Text'
+import t from '../../../locales'
+import Button from '../../Button'
+import { useAppDispatch, useAppSelector } from '../../../store/hooks'
+import { setExpertView } from '../../../store/app'
+import { setExpertSwitchDisabled } from '../../../store/app'
+import { actions as dockerImagesActions } from '../../../store/dockerImages'
+import { ActionStatusContainer, CtaButtonContainer, StatusRow } from './styles'
+import Loading from '../../Loading'
+import { selectDockerImages } from '../../../store/dockerImages/selectors'
+
+type StatusType =
+ | 'not_started'
+ | 'in_progress'
+ | 'no_space_error'
+ | 'server_error'
+ | 'success'
+
+const Processing = () => (
+
+
+ {t.onboarding.status.processing}
+
+)
+
+const Done = () => (
+
+
+ ✅
+ {t.onboarding.status.done}
+
+
+)
+
+const Fail = () => (
+
+
+ ❌
+ {t.onboarding.status.fail}
+
+
+)
+
+const Status = ({ status }: { status: StatusType }) => {
+ switch (status) {
+ case 'in_progress':
+ return
+ case 'no_space_error':
+ case 'server_error':
+ return
+ case 'success':
+ return
+ default:
+ return null
+ }
+}
+
+export const DownloadImagesMessage = ({
+ onError,
+ onSuccess,
+}: {
+ onError: (type: 'no_space_error' | 'server_error') => void
+ onSuccess: () => void
+}) => {
+ const dispatch = useAppDispatch()
+
+ const dockerImages = useAppSelector(selectDockerImages)
+
+ const [status, setStatus] = useState('in_progress')
+ const [fetching, setFetching] = useState(false)
+ const [accomplished, setAccomplished] = useState(false)
+
+ useEffect(() => {
+ dispatch(setExpertSwitchDisabled(false))
+ setFetching(true)
+ dispatch(dockerImagesActions.pullImages())
+ }, [])
+
+ useEffect(() => {
+ const anyNotUpToDate = dockerImages.find(f => !f.updated)
+
+ const anyError = dockerImages.find(f => Boolean(f.error))
+ const anyInProgess = dockerImages.find(f => !f.updated && f.pending)
+
+ if (fetching && !accomplished) {
+ if (anyError) {
+ if (anyError.error?.toLowerCase().includes('no space left')) {
+ setStatus('no_space_error')
+ } else {
+ setStatus('server_error')
+ }
+ setFetching(false)
+ } else if (!anyInProgess) {
+ setStatus('success')
+ setAccomplished(true)
+ onSuccess()
+ setFetching(false)
+ }
+ }
+
+ if (!anyNotUpToDate && !accomplished) {
+ setStatus('success')
+ setAccomplished(true)
+ onSuccess()
+ setFetching(false)
+ return
+ }
+ }, [dockerImages, fetching])
+
+ useEffect(() => {
+ if (['no_space_error', 'server_error'].includes(status)) {
+ onError(status as 'no_space_error' | 'server_error')
+ }
+ }, [status])
+
+ return (
+ <>
+
+ {t.onboarding.dockerImages.message1.part1}
+
+
+
+
+ >
+ )
+}
+
+export const DownloadImagesErrorMessage = ({
+ errorType,
+ onError,
+ onSuccess,
+}: {
+ errorType: 'no_space_error' | 'server_error'
+ onError: (type: 'no_space_error' | 'server_error') => void
+ onSuccess: () => void
+}) => {
+ const dispatch = useAppDispatch()
+
+ const dockerImages = useAppSelector(selectDockerImages)
+
+ const [status, setStatus] = useState('not_started')
+ const [fetching, setFetching] = useState(false)
+ const [accomplished, setAccomplished] = useState(false)
+
+ useEffect(() => {
+ if (['no_space_error', 'server_error'].includes(status)) {
+ onError(status as 'no_space_error' | 'server_error')
+ }
+
+ if (status === 'in_progress' && !fetching) {
+ dispatch(setExpertSwitchDisabled(false))
+ setFetching(true)
+ dispatch(dockerImagesActions.pullImages())
+ }
+ }, [status])
+
+ useEffect(() => {
+ if (fetching && !accomplished) {
+ const anyError = dockerImages.find(f => Boolean(f.error))
+ const anyInProgess = dockerImages.find(f => !f.updated && f.pending)
+
+ if (anyError) {
+ if (anyError.error?.toLowerCase().includes('no space left')) {
+ setStatus('no_space_error')
+ } else {
+ setStatus('server_error')
+ }
+ setAccomplished(true)
+ setFetching(false)
+ } else if (!anyInProgess) {
+ setStatus('success')
+ setAccomplished(true)
+ onSuccess()
+ setFetching(false)
+ }
+ }
+ }, [dockerImages, fetching])
+
+ const text =
+ errorType === 'no_space_error' ? (
+ {t.onboarding.dockerImages.errors.noSpace}
+ ) : (
+ <>
+
+ {t.onboarding.dockerImages.errors.serverError.part1}
+ {' '}
+
+ {t.onboarding.dockerImages.errors.serverError.part2}
+ {' '}
+
+ {t.onboarding.dockerImages.errors.serverError.part3}
+ {' '}
+
+ {t.onboarding.dockerImages.errors.serverError.part4}
+
+ >
+ )
+
+ return (
+ <>
+ {text}
+
+ {status === 'not_started' && (
+
+
+
+ )}
+
+
+ >
+ )
+}
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerInstallMessages.tsx b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerInstallMessages.tsx
new file mode 100644
index 0000000000..60bc5e9d66
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/DockerInstallMessages.tsx
@@ -0,0 +1,126 @@
+/* eslint-disable react/jsx-key */
+import { useEffect, useRef, useState } from 'react'
+import { type } from '@tauri-apps/api/os'
+
+import Text from '../../Text'
+import t from '../../../locales'
+import Button from '../../Button'
+
+import LinksConfig from '../../../config/links'
+import { CtaButtonContainer } from './styles'
+import { isDockerInstalled } from '../../../commands'
+import { useAppDispatch } from '../../../store/hooks'
+import { setOnboardingCheckpoint } from '../../../store/app'
+import { OnboardingCheckpoints } from '../../../store/app/types'
+import SvgDocker from '../../../styles/Icons/Docker'
+
+const OS_NAMES = {
+ Darwin: 'macOS',
+ Windows_NT: 'Windows',
+ Linux: 'Linux',
+}
+
+const DOCKER_DOCS_URLS = {
+ Darwin: 'https://docs.docker.com/desktop/mac/install/',
+ Windows_NT: 'https://docs.docker.com/desktop/windows/install/',
+ Linux: 'https://docs.docker.com/engine/install/ubuntu/',
+}
+
+type OsType = keyof typeof OS_NAMES
+
+const messages = [
+
+ {t.onboarding.dockerInstall.message1.part1}{' '}
+
+ {t.onboarding.dockerInstall.message1.part2}
+ {' '}
+ {t.onboarding.dockerInstall.message1.part3}
+ ,
+
+ {t.onboarding.dockerInstall.message2}
+ ,
+ () => {
+ const [osName, setOsName] = useState('')
+
+ useEffect(() => {
+ const checkOs = async () => {
+ const osType = await type()
+ if (Object.keys(OS_NAMES).includes(osType)) {
+ setOsName(OS_NAMES[osType as OsType])
+ }
+ }
+
+ checkOs()
+ }, [])
+
+ return (
+
+ {t.onboarding.dockerInstall.message3.part1} {osName}{' '}
+ {t.onboarding.dockerInstall.message3.part2}{' '}
+
+ {t.onboarding.dockerInstall.message3.part3}
+ {' '}
+ {t.onboarding.dockerInstall.message3.part4}
+ 🐶
+
+ )
+ },
+ <>
+
+ {t.onboarding.dockerInstall.message4.part1}
+
+
+ >,
+
+ {t.onboarding.dockerInstall.afterInstall}
+ ,
+]
+
+export const DockerInstallDocs = ({ onDone }: { onDone: () => void }) => {
+ const dispatch = useAppDispatch()
+ const intervalRef = useRef | undefined>()
+
+ const [docsUrl, setDocsUrl] = useState(DOCKER_DOCS_URLS.Linux)
+
+ useEffect(() => {
+ const checkOs = async () => {
+ const osType = await type()
+ if (Object.keys(DOCKER_DOCS_URLS).includes(osType)) {
+ setDocsUrl(DOCKER_DOCS_URLS[osType as OsType])
+ }
+ }
+
+ dispatch(setOnboardingCheckpoint(OnboardingCheckpoints.DOCKER_INSTALL))
+
+ checkOs()
+ }, [])
+
+ useEffect(() => {
+ // Wait until Docker is installed...
+ intervalRef.current = setInterval(async () => {
+ const isInstalled = await isDockerInstalled()
+ if (isInstalled) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ clearInterval(intervalRef.current!)
+ onDone()
+ }
+ }, 5000)
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return () => clearInterval(intervalRef.current!)
+ }, [])
+
+ return (
+ <>
+
+ }>
+ {t.onboarding.dockerInstall.message5.link}
+
+
+ >
+ )
+}
+
+export default messages
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/IntroMessages.tsx b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/IntroMessages.tsx
new file mode 100644
index 0000000000..de5ef0ef1c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/IntroMessages.tsx
@@ -0,0 +1,34 @@
+/* eslint-disable react/jsx-key */
+import { useEffect } from 'react'
+import Text from '../../Text'
+import t from '../../../locales'
+import { useAppDispatch } from '../../../store/hooks'
+import { setExpertSwitchDisabled } from '../../../store/app'
+
+const messages = [
+ () => {
+ const dispatch = useAppDispatch()
+ useEffect(() => {
+ dispatch(setExpertSwitchDisabled(true))
+ })
+ return (
+
+ {t.onboarding.intro.message1.part1}{' '}
+
+ {t.onboarding.intro.message1.part2}
+
+
+ )
+ },
+
+ {t.onboarding.intro.message2}
+ ,
+
+ {t.onboarding.intro.message3}
+ ,
+
+ {t.onboarding.intro.message4}
+ ,
+]
+
+export default messages
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/LastStepsMessages.tsx b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/LastStepsMessages.tsx
new file mode 100644
index 0000000000..31f763dbdf
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/LastStepsMessages.tsx
@@ -0,0 +1,194 @@
+/* eslint-disable react/jsx-key */
+import { useEffect, useRef, useState } from 'react'
+import { appWindow } from '@tauri-apps/api/window'
+
+import Text from '../../Text'
+import t from '../../../locales'
+import Button from '../../Button'
+
+import { useAppDispatch } from '../../../store/hooks'
+import { setOnboardingComplete } from '../../../store/app'
+
+import { actions as containersActions } from '../../../store/containers'
+import {
+ CalcRemainTimeCont,
+ CalcRemainTimeContLoader,
+ CtaButtonContainer,
+ FlexContent,
+ ProgressContainer,
+ RemainingTime,
+} from './styles'
+import ProgressBar from '../../ProgressBar'
+import { TBotMessage, TBotMessageHOCProps } from '../../TBot/TBotPrompt/types'
+import { useTheme } from 'styled-components'
+import { SyncType, useBaseNodeSync } from '../../../useBaseNodeSync'
+import Loading from '../../Loading'
+import { humanizeEstimatedTime } from '../../../utils/Format'
+
+/**
+ * Renders the progress bar and remaining time
+ */
+const Progress = ({
+ progress,
+ time,
+ type,
+}: {
+ progress?: number
+ time?: number
+ type?: SyncType
+}) => {
+ const theme = useTheme()
+
+ return (
+
+
+ {t.onboarding.lastSteps.blockchainIsSyncing}{' '}
+ {type && type === 'Header' && '(1/2)'}
+ {type && type === 'Block' && '(2/2)'}
+
+
+
+
+ {time === undefined || type === 'Startup' ? (
+
+
+
+
+
+ {t.common.phrases.calculatingTheRemainingTime}
+ ...
+
+
+ ) : (
+ <>
+ {humanizeEstimatedTime(time)}{' '}
+ {t.common.adjectives.remaining.toLowerCase()}
+ >
+ )}
+
+
+
+ )
+}
+
+/**
+ * Renders the onboarding message running the blockchain sync
+ */
+export const BlockchainSyncStep = ({
+ pushMessages,
+ updateMessageBoxSize,
+}: {
+ pushMessages: (msgs: TBotMessage[]) => void
+} & TBotMessageHOCProps) => {
+ const dispatch = useAppDispatch()
+
+ const contentRef = useRef(null)
+
+ const [syncStarting, setSyncStarting] = useState(false)
+ const [syncStarted, setSyncStarted] = useState(false)
+
+ const pushErrorMessage = () => {
+ pushMessages([
+ {
+ content: (
+ <>
+ {t.onboarding.lastSteps.syncError}
+
+
+
+ >
+ ),
+ barFill: 0.875,
+ noSkip: true,
+ wait: 200,
+ },
+ ])
+ }
+
+ const startSync = async () => {
+ setSyncStarting(true)
+ pushMessages([
+ {
+ content: {t.onboarding.lastSteps.message2},
+ barFill: 0.875,
+ noSkip: true,
+ },
+ ])
+ try {
+ await dispatch(
+ containersActions.startRecipe({ containerName: 'base_node' }),
+ ).unwrap()
+ setSyncStarted(true)
+ } catch (err) {
+ try {
+ await dispatch(
+ containersActions.startRecipe({ containerName: 'base_node' }),
+ ).unwrap()
+ setSyncStarted(true)
+ } catch (err2) {
+ pushErrorMessage()
+ }
+ }
+ }
+
+ const baseNodeSyncProgress = useBaseNodeSync(syncStarted)
+
+ useEffect(() => {
+ if (syncStarted && updateMessageBoxSize && contentRef.current) {
+ updateMessageBoxSize()
+ }
+ }, [syncStarted])
+
+ const finishSyncing = () => {
+ dispatch(setOnboardingComplete(true))
+ }
+
+ useEffect(() => {
+ if (baseNodeSyncProgress.finished) {
+ finishSyncing()
+ }
+ }, [baseNodeSyncProgress.finished])
+
+ return (
+
+
+ {t.onboarding.lastSteps.message1} ✨💪
+
+
+ {!syncStarting && (
+
+
+
+ )}
+
+ {syncStarting && (
+ <>
+
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/styles.ts b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/styles.ts
new file mode 100644
index 0000000000..ba6ef83da5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/OnboardingMessages/styles.ts
@@ -0,0 +1,56 @@
+import styled from 'styled-components'
+
+export const CtaButtonContainer = styled.div<{ $noMargin?: boolean }>`
+ display: inline-flex;
+ ${({ theme, $noMargin }) =>
+ !$noMargin ? `margin-top: ${theme.spacingVertical(1)};` : ''}
+`
+
+export const ActionStatusContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ column-gap: ${({ theme }) => theme.spacing(0.5)};
+ margin-top: ${({ theme }) => theme.spacing()};
+`
+
+export const StatusRow = styled.div`
+ display: flex;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.spacing(0.2)};
+
+ & > p:first-child {
+ display: flex;
+ margin-bottom: 2px;
+ }
+`
+
+export const FlexContent = styled.div`
+ display: flex;
+ flex-direction: column;
+`
+
+export const ProgressContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: ${({ theme }) => theme.spacingVertical(4)};
+ margin-left: auto;
+ margin-right: auto;
+ width: 100%;
+ max-width: 450px;
+`
+
+export const RemainingTime = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(1.5)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const CalcRemainTimeCont = styled.div`
+ display: flex;
+`
+
+export const CalcRemainTimeContLoader = styled.div`
+ padding-top: ${({ theme }) => theme.spacingVertical(0.18)};
+ margin-right: ${({ theme }) => theme.spacingHorizontal(0.4)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/BarSegment.tsx b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/BarSegment.tsx
new file mode 100644
index 0000000000..e31eb2b74f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/BarSegment.tsx
@@ -0,0 +1,20 @@
+import { config, useSpring } from 'react-spring'
+import { BarSegmentContainer, AnimatedSegment } from './styles'
+
+const BarSegment = ({ fill }: { fill: number | undefined }) => {
+ let progressBarWidth
+ if (fill) {
+ progressBarWidth = 92 * fill
+ }
+ const progressAnim = useSpring({
+ width: progressBarWidth,
+ config: config.stiff,
+ })
+ return (
+
+
+
+ )
+}
+
+export default BarSegment
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/index.tsx b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/index.tsx
new file mode 100644
index 0000000000..46ae43545c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/index.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react'
+import BarSegment from './BarSegment'
+import { StyledContainer } from './styles'
+import { ProgressIndicatorProps } from './types'
+
+const NON_ZERO_VALUE_ALLOWING_ANIMATION = 0.001
+
+const ProgressIndicator = ({ overallFill }: ProgressIndicatorProps) => {
+ const [fillOne, setFillOne] = useState(
+ NON_ZERO_VALUE_ALLOWING_ANIMATION,
+ )
+ const [fillTwo, setFillTwo] = useState(
+ NON_ZERO_VALUE_ALLOWING_ANIMATION,
+ )
+ const [fillThree, setFillThree] = useState(
+ NON_ZERO_VALUE_ALLOWING_ANIMATION,
+ )
+ const [fillFour, setFillFour] = useState(
+ NON_ZERO_VALUE_ALLOWING_ANIMATION,
+ )
+
+ // Logic for animation progress
+ useEffect(() => {
+ if (overallFill) {
+ if (overallFill <= 0.25) {
+ setFillOne(overallFill * 4)
+ }
+ if (overallFill >= 0.25) {
+ setFillOne(1)
+ }
+ if (overallFill > 0.25 && overallFill <= 0.5) {
+ setTimeout(() => {
+ setFillTwo((overallFill - 0.25) * 4)
+ }, 300)
+ }
+ if (overallFill >= 0.5) {
+ setFillTwo(1)
+ }
+ if (overallFill > 0.5 && overallFill <= 0.75) {
+ setTimeout(() => {
+ setFillThree((overallFill - 0.5) * 4)
+ }, 300)
+ }
+ if (overallFill >= 0.75) {
+ setFillThree(1)
+ setTimeout(() => {
+ setFillFour((overallFill - 0.75) * 4)
+ }, 300)
+ }
+ }
+ }, [overallFill])
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default ProgressIndicator
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/styles.ts b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/styles.ts
new file mode 100644
index 0000000000..9475618e61
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/styles.ts
@@ -0,0 +1,30 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const StyledContainer = styled.div`
+ width: 404px;
+ display: flex;
+ justify-content: space-evenly;
+ margin-top: ${({ theme }) => theme.spacingVertical(4)};
+`
+
+export const BarSegmentContainer = styled(animated.div)<{ fill?: number }>`
+ width: 92px;
+ height: 5px;
+ border-radius: ${({ theme }) => theme.borderRadius(4)};
+ background-color: ${({ theme }) => theme.placeholderText};
+ display: inline-block;
+ position: relative;
+`
+
+export const AnimatedSegment = styled(animated.span)<{ $fill?: number }>`
+ background-image: ${({ theme }) => theme.tariGradient};
+ border-top-left-radius: ${({ theme }) => theme.borderRadius(4)};
+ border-bottom-left-radius: ${({ theme }) => theme.borderRadius(4)};
+ border-top-right-radius: ${({ theme, $fill }) =>
+ $fill !== 1 ? theme.borderRadius(1) : theme.borderRadius(4)};
+ border-bottom-right-radius: ${({ theme, $fill }) =>
+ $fill !== 1 ? theme.borderRadius(1) : theme.borderRadius(4)};
+ height: 100%;
+ position: absolute;
+`
diff --git a/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/types.ts b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/types.ts
new file mode 100644
index 0000000000..29428ce4ac
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Onboarding/ProgressIndicator/types.ts
@@ -0,0 +1,3 @@
+export interface ProgressIndicatorProps {
+ overallFill: number | undefined
+}
diff --git a/applications/launchpad/gui-react/src/components/Pagination/index.tsx b/applications/launchpad/gui-react/src/components/Pagination/index.tsx
new file mode 100644
index 0000000000..71b47c59e3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Pagination/index.tsx
@@ -0,0 +1,87 @@
+import ReactPaginate from 'react-paginate'
+import { useTheme } from 'styled-components'
+
+import Text from '../Text'
+
+import SvgArrowLeft2 from '../../styles/Icons/ArrowLeft2'
+import SvgArrowRight2 from '../../styles/Icons/ArrowRight2'
+
+import t from '../../locales'
+
+import {
+ StyledPagination,
+ PagesContainer,
+ PaginationStatsContainer,
+ SelectContainer,
+} from './styles'
+import { PaginationProps } from './types'
+import Select from '../Select'
+import { useMemo } from 'react'
+
+/**
+ * The pagination component
+ * @param {number} currentPage - current active page
+ * @param {number} perPage - records per page
+ * @param {number} total - total number of records
+ * @param {(val: number) => void} onPageChange - on page change
+ */
+const Pagination = ({
+ currentPage,
+ perPage,
+ total,
+ onPageChange,
+}: PaginationProps) => {
+ const theme = useTheme()
+
+ const numberOfPages = Math.ceil(total / perPage)
+
+ const options = useMemo(() => {
+ return [...Array(numberOfPages).keys()].map(v => ({
+ value: v,
+ label: (v + 1).toString(),
+ key: v.toString(),
+ }))
+ }, [perPage, total])
+
+ const firstVisibleRecordNumber = currentPage * perPage + 1
+ let lastVisibleRecordNumber = currentPage * perPage + perPage
+ if (lastVisibleRecordNumber > total) {
+ lastVisibleRecordNumber = total
+ }
+
+ return (
+
+
+
+ onPageChange(selected)
+ }
+ previousLabel={}
+ nextLabel={}
+ />
+
+
+
+ {t.common.nouns.results}: {firstVisibleRecordNumber}-
+ {lastVisibleRecordNumber} {t.common.conjunctions.of} {total}
+
+
+
+
+
+ )
+}
+
+export default Pagination
diff --git a/applications/launchpad/gui-react/src/components/Pagination/styles.ts b/applications/launchpad/gui-react/src/components/Pagination/styles.ts
new file mode 100644
index 0000000000..e3dcfdcc76
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Pagination/styles.ts
@@ -0,0 +1,75 @@
+import styled from 'styled-components'
+
+export const StyledPagination = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+`
+
+export const PagesContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & > ul {
+ list-style: none;
+ margin: 0;
+ padding-left: 0;
+ display: flex;
+ cursor: pointer;
+
+ li {
+ a {
+ display: block;
+ box-sizing: border-box;
+ border: 1px solid transparent;
+ border-radius: ${({ theme }) => theme.borderRadius(0.5)};
+ color: ${({ theme }) => theme.primary};
+ font-family: 'AvenirMedium';
+ text-align: center;
+
+ padding-top: ${({ theme }) => theme.spacingVertical(0.8)};
+ padding-bottom: ${({ theme }) => theme.spacingVertical(0.5)};
+ padding-left: ${({ theme }) => theme.spacingHorizontal(0.1)};
+ padding-right: ${({ theme }) => theme.spacingHorizontal(0.1)};
+
+ min-width: ${({ theme }) => theme.spacingHorizontal(1.6)};
+
+ &:focus {
+ outline: none;
+ }
+
+ &:hover {
+ background: ${({ theme }) => theme.borderColor};
+ }
+ }
+
+ &.selected a {
+ background: ${({ theme }) => theme.accent};
+ color: #fff;
+ border-color: ${({ theme }) => theme.borderColor};
+ }
+
+ &.next {
+ margin-left: ${({ theme }) => theme.spacingHorizontal(1)};
+ }
+
+ &.previous {
+ margin-right: ${({ theme }) => theme.spacingHorizontal(1)};
+ }
+ }
+ }
+`
+
+export const PaginationStatsContainer = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(4)};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ column-gap: ${({ theme }) => theme.spacingHorizontal(2)};
+`
+
+export const SelectContainer = styled.div`
+ min-width: ${({ theme }) => theme.spacingHorizontal(4)};
+`
diff --git a/applications/launchpad/gui-react/src/components/Pagination/types.ts b/applications/launchpad/gui-react/src/components/Pagination/types.ts
new file mode 100644
index 0000000000..c95a27a76d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Pagination/types.ts
@@ -0,0 +1,6 @@
+export interface PaginationProps {
+ currentPage: number
+ perPage: number
+ total: number
+ onPageChange: (page: number) => void
+}
diff --git a/applications/launchpad/gui-react/src/components/ProgressBar/ProgressBar.test.tsx b/applications/launchpad/gui-react/src/components/ProgressBar/ProgressBar.test.tsx
new file mode 100644
index 0000000000..d3d4ca4940
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ProgressBar/ProgressBar.test.tsx
@@ -0,0 +1,46 @@
+import { act, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import ProgressBar from '.'
+
+import themes from '../../styles/themes'
+
+describe('ProgressBar', () => {
+ it('should render given value', async () => {
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const tipTextEl = screen.getByText('50%')
+ expect(tipTextEl).toBeInTheDocument()
+ })
+
+ it('should render negative values as positive values', async () => {
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const tipTextEl = screen.getByText('50%')
+ expect(tipTextEl).toBeInTheDocument()
+ })
+
+ it('should render 100% for values greater than 100', async () => {
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const tipTextEl = screen.getByText('100%')
+ expect(tipTextEl).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/ProgressBar/index.tsx b/applications/launchpad/gui-react/src/components/ProgressBar/index.tsx
new file mode 100644
index 0000000000..71eb6effef
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ProgressBar/index.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useRef, useState } from 'react'
+import { config, useSpring } from 'react-spring'
+import Text from '../Text'
+import { Track, Fill, StyledProgressBar, Tip } from './styles'
+
+import { ProgressBarProps } from './types'
+
+const limitValue = (value: number) => {
+ return value > 100 ? 100 : Math.abs(value)
+}
+
+/**
+ * Linear progress bar with a tip.
+ * @param {number} value - number from 0-100 range
+ */
+const ProgressBar = ({ value }: ProgressBarProps) => {
+ const trackRef = useRef(null)
+
+ const [width, setWidth] = useState(value)
+
+ useEffect(() => {
+ if (trackRef.current) {
+ const trackWidth = trackRef.current.clientWidth
+ setWidth(Math.round((trackWidth * limitValue(value)) / 100))
+ }
+ }, [value])
+
+ const progressAnim = useSpring({
+ width: width,
+ config: config.gentle,
+ })
+
+ const tipAnim = useSpring({
+ left: width,
+ config: config.gentle,
+ })
+
+ return (
+
+
+
+ )
+}
+
+export default ProgressBar
diff --git a/applications/launchpad/gui-react/src/components/ProgressBar/styles.ts b/applications/launchpad/gui-react/src/components/ProgressBar/styles.ts
new file mode 100644
index 0000000000..3a615354ef
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ProgressBar/styles.ts
@@ -0,0 +1,61 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const StyledProgressBar = styled.div`
+ width: 100%;
+ &:hover .progressbar-tip {
+ opacity: 1;
+ }
+`
+
+export const Track = styled.div`
+ width: 100%;
+ height: 8px;
+ border-radius: ${({ theme }) => theme.borderRadius(4)};
+ background-color: ${({ theme }) => theme.placeholderText};
+ display: inline-block;
+ position: relative;
+`
+
+export const Fill = styled(animated.div)<{ $filled?: boolean }>`
+ background-image: ${({ theme }) => theme.tariGradient};
+ border-top-left-radius: ${({ theme }) => theme.borderRadius(4)};
+ border-bottom-left-radius: ${({ theme }) => theme.borderRadius(4)};
+ border-top-right-radius: ${({ theme, $filled }) =>
+ $filled ? theme.borderRadius(1) : theme.borderRadius(4)};
+ border-bottom-right-radius: ${({ theme, $filled }) =>
+ $filled ? theme.borderRadius(1) : theme.borderRadius(4)};
+ height: 100%;
+ position: absolute;
+`
+
+export const Tip = styled(animated.div)`
+ opacity: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ background: ${({ theme }) => theme.accent};
+ color: #fff;
+ width: 64px;
+ height: 34px;
+ top: -50px;
+ margin-left: -32px;
+ border-radius: ${({ theme }) => theme.borderRadius(0.5)};
+ box-shadow: 0px 6.00823px 6.00823px rgba(50, 50, 71, 0.08),
+ 0px 6.00823px 12.0165px rgba(50, 50, 71, 0.06);
+
+ &:after {
+ content: '';
+ border-right: 12px solid transparent;
+ border-left: 12px solid transparent;
+ position: absolute;
+ border-top: 12px solid ${({ theme }) => theme.accent};
+ width: 0;
+ height: 0;
+ left: 50%;
+ margin-left: -12px;
+ bottom: -8px;
+ border-radius: 2px;
+ }
+`
diff --git a/applications/launchpad/gui-react/src/components/ProgressBar/types.ts b/applications/launchpad/gui-react/src/components/ProgressBar/types.ts
new file mode 100644
index 0000000000..6a1282d6a1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/ProgressBar/types.ts
@@ -0,0 +1,3 @@
+export interface ProgressBarProps {
+ value: number
+}
diff --git a/applications/launchpad/gui-react/src/components/RunningButton/RunningButton.test.tsx b/applications/launchpad/gui-react/src/components/RunningButton/RunningButton.test.tsx
new file mode 100644
index 0000000000..6c4fe159bf
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/RunningButton/RunningButton.test.tsx
@@ -0,0 +1,82 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import RunningButton from '.'
+import themes from '../../styles/themes'
+
+describe('RunningButton', () => {
+ let raf: jest.SpyInstance
+ let count = 0
+
+ beforeEach(() => {
+ // Set up timers and mock requestAnimationFrame
+ jest.useFakeTimers()
+ raf = jest.spyOn(window, 'requestAnimationFrame')
+
+ raf.mockImplementation((cb: FrameRequestCallback): number => {
+ setTimeout(c => cb(c + 1), 100)
+ count = count + 1
+ return count
+ })
+ })
+
+ afterEach(() => {
+ // Clear mocks and timers
+ raf.mockRestore()
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+ })
+
+ it('should count time when is active', async () => {
+ const cbMock = jest.fn()
+
+ // Render the Timer
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ // Check that the timer has zeros only at the beginning
+ let timerEl = screen.getByTestId('timer-test-id')
+ expect(timerEl.textContent).toBe('0:00:00')
+
+ // move the clock at least by 1 sec
+ await act(async () => {
+ jest.advanceTimersByTime(1200)
+ })
+
+ jest.clearAllTimers()
+
+ // Now the timer should be changed
+ timerEl = screen.getByTestId('timer-test-id')
+ expect(timerEl.textContent).toBe('0:00:01')
+ })
+
+ it('should call onClick when clicked', async () => {
+ const cbMock = jest.fn()
+
+ render(
+
+
+ ,
+ )
+
+ const elBtn = screen.getByTestId('running-button-cmp')
+ await act(async () => {
+ fireEvent.click(elBtn)
+ })
+
+ expect(cbMock).toBeCalled()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/RunningButton/index.tsx b/applications/launchpad/gui-react/src/components/RunningButton/index.tsx
new file mode 100644
index 0000000000..34f3eaa0d4
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/RunningButton/index.tsx
@@ -0,0 +1,70 @@
+import { useState } from 'react'
+import { useTheme } from 'styled-components'
+
+import useAnimationFrame from '../../hooks/useAnimationFrame'
+import { humanizeTime } from '../../utils/Format'
+import t from '../../locales'
+
+import Text from '../Text'
+
+import { StyledRunningButton, TextWrapper, TimeWrapper } from './styles'
+import { RunningButtonProps } from './types'
+
+const Time = ({
+ startedAt,
+ active,
+}: {
+ startedAt: number
+ active?: boolean
+}) => {
+ const [time, setTime] = useState(humanizeTime(0))
+
+ useAnimationFrame(() => {
+ setTime(() => {
+ return humanizeTime(Math.abs(Date.now() - startedAt))
+ })
+ }, active)
+
+ return <>{time}>
+}
+
+/**
+ * Button with timer.
+ *
+ * @param {number} startedAt - timestamp in milliseconds
+ * @param {boolean} [active] - should the timer be active
+ * @param {() => void} onClick - on button click
+ */
+const RunningButton = ({
+ startedAt,
+ active,
+ onClick,
+ testId,
+}: RunningButtonProps) => {
+ const theme = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+ {t.common.verbs.pause}
+
+
+
+ )
+}
+
+export default RunningButton
diff --git a/applications/launchpad/gui-react/src/components/RunningButton/styles.ts b/applications/launchpad/gui-react/src/components/RunningButton/styles.ts
new file mode 100644
index 0000000000..3a92e80c11
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/RunningButton/styles.ts
@@ -0,0 +1,33 @@
+import styled from 'styled-components'
+
+export const StyledRunningButton = styled.button`
+ background: ${({ theme }) => theme.resetBackground};
+ border-width: 0;
+ box-shadow: none;
+ padding-top: ${({ theme }) => theme.spacingVertical(0.6)};
+ padding-bottom: ${({ theme }) => theme.spacingVertical(0.6)};
+ border-radius: ${({ theme }) => theme.borderRadius(0.667)};
+ display: inline-flex;
+ cursor: pointer;
+
+ &:hover {
+ background: ${({ theme }) => theme.resetBackgroundHover};
+ }
+`
+
+export const TimeWrapper = styled.span`
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.2)} ${theme.spacingHorizontal(
+ 0.83,
+ )} ${theme.spacingVertical(0)} ${theme.spacingHorizontal(0.83)}`};
+ border-right: 1px solid ${({ theme }) => theme.resetBackground};
+ min-width: 61px;
+`
+
+export const TextWrapper = styled.span`
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.2)} ${theme.spacingHorizontal(
+ 0.83,
+ )} ${theme.spacingVertical(0.0)} ${theme.spacingHorizontal(0.83)}`};
+ min-width: 61px;
+`
diff --git a/applications/launchpad/gui-react/src/components/RunningButton/types.ts b/applications/launchpad/gui-react/src/components/RunningButton/types.ts
new file mode 100644
index 0000000000..f322bdaa01
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/RunningButton/types.ts
@@ -0,0 +1,6 @@
+export interface RunningButtonProps {
+ startedAt: number
+ active?: boolean
+ onClick: () => void
+ testId?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/ConfirmPhrasePage.tsx b/applications/launchpad/gui-react/src/components/SeedPhraseModal/ConfirmPhrasePage.tsx
new file mode 100644
index 0000000000..f1fb0bef78
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/ConfirmPhrasePage.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useState } from 'react'
+
+import Button from '../Button'
+import Input from '../Inputs/Input'
+import Text from '../Text'
+
+import t from '../../locales'
+
+import { useAppDispatch } from '../../store/hooks'
+import { actions as walletActions } from '../../store/wallet'
+
+import {
+ BottomBar,
+ Content,
+ ErrorContainer,
+ ErrorText,
+ ModalContent,
+ NumWrapper,
+ TextSection,
+ WordInputRow,
+ WordsContainer,
+} from './styles'
+import { invoke } from '@tauri-apps/api'
+
+const ConfirmPhrasePage = ({
+ phrase,
+ onSuccess,
+ onBack,
+ onError,
+}: {
+ phrase: string[]
+ onSuccess: () => void
+ onBack: () => void
+ onError: (err: string) => void
+}) => {
+ const dispatch = useAppDispatch()
+
+ const [checkWords, setCheckWords] = useState([3, 10, 14, 21])
+ const [enteredWords, setEnteredWords] = useState>({})
+ const [error, setError] = useState(false)
+
+ const drawWords = (ws: number[]) => {
+ const rand = Math.floor(Math.random() * phrase.length)
+ if (ws.length === 4) {
+ setCheckWords(ws)
+ return
+ }
+
+ if (ws.includes(rand)) {
+ drawWords(ws)
+ } else {
+ drawWords(ws.concat([rand]))
+ }
+ }
+
+ useEffect(() => {
+ setError(false)
+ drawWords([])
+ }, [phrase])
+
+ const onSubmit = async () => {
+ const anyInvalid = checkWords.find(
+ i => !enteredWords[i] || phrase[i] !== enteredWords[i],
+ )
+
+ if (anyInvalid) {
+ setError(true)
+ } else {
+ setError(false)
+ dispatch(walletActions.setRecoveryPhraseAsCreated())
+ onSuccess()
+
+ try {
+ await invoke('delete_seed_words')
+ } catch (err) {
+ onError((err as Error).toString())
+ }
+ }
+ }
+
+ return (
+
+
+
+ {t.settings.security.backupRecoveryPhrase}
+
+
+
+ {t.settings.security.confirmPhraseDesc}
+
+
+
+ {checkWords.map((w, idx) => (
+
+
+ {w + 1}.
+
+ {
+ setEnteredWords(st => ({ ...st, [w]: val }))
+ }}
+ withError={false}
+ />
+
+ ))}
+
+
+
+
+
+
+ {error && (
+
+
+
+ {t.settings.security.phraseConfirmError}
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default ConfirmPhrasePage
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/IntroPage.tsx b/applications/launchpad/gui-react/src/components/SeedPhraseModal/IntroPage.tsx
new file mode 100644
index 0000000000..849a4d5e42
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/IntroPage.tsx
@@ -0,0 +1,65 @@
+import Button from '../Button'
+import Text from '../Text'
+
+import t from '../../locales'
+
+import {
+ BottomBar,
+ Content,
+ ModalContent,
+ PrintButtonWrapper,
+ TextSection,
+} from './styles'
+import PrintSheet from './PrintSheet'
+
+export const IntroPage = ({
+ phrase,
+ onSubmit,
+ onCancel,
+}: {
+ phrase: string[]
+ onSubmit: () => void
+ onCancel: () => void
+}) => {
+ return (
+ <>
+
+
+
+ {t.settings.security.backupRecoveryPhrase}
+
+
+
+ 📌 {t.settings.security.backupRecoveryPhraseExplanation.part1}
+
+
+
+
+ 📌 {t.settings.security.backupRecoveryPhraseExplanation.part2}
+
+
+
+
+ 📌 {t.settings.security.backupRecoveryPhraseExplanation.part3}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default IntroPage
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/PrintSheet.tsx b/applications/launchpad/gui-react/src/components/SeedPhraseModal/PrintSheet.tsx
new file mode 100644
index 0000000000..9216f4036d
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/PrintSheet.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+
+import Text from '../Text'
+import Button from '../Button'
+
+import t from '../../locales'
+
+import SvgPrinter from '../../styles/Icons/Printer'
+import { PrintView, PrintPhrase } from './styles'
+import { listen } from '@tauri-apps/api/event'
+
+const PrintSheet = ({ phrase }: { phrase: string[] }) => {
+ const [printView, setPrintView] = useState(false)
+
+ useEffect(() => {
+ if (printView) {
+ window.print()
+
+ listen('tauri://focus', () => {
+ setPrintView(false)
+ })
+ }
+ }, [printView])
+
+ if (!printView) {
+ return (
+ }
+ onClick={() => setPrintView(true)}
+ >
+ {t.settings.security.printRecoverySheet}
+
+ )
+ }
+
+ return (
+
+
+ {t.settings.security.backupRecoveryPhrase}
+
+
+
+ {phrase.map((p, idx) => (
+ -
+ {p}
+
+ ))}
+
+
+
+ )
+}
+
+export default PrintSheet
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/WordsPage.tsx b/applications/launchpad/gui-react/src/components/SeedPhraseModal/WordsPage.tsx
new file mode 100644
index 0000000000..ffbe888389
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/WordsPage.tsx
@@ -0,0 +1,59 @@
+import Button from '../Button'
+import Text from '../Text'
+
+import t from '../../locales'
+
+import {
+ BottomBar,
+ Content,
+ ModalContent,
+ TextSection,
+ WordDisplay,
+ WordsContainer,
+} from './styles'
+
+const WordsPage = ({
+ words,
+ startingNumber,
+ onPrevPage,
+ onNextPage,
+}: {
+ words: string[]
+ startingNumber: number
+ onPrevPage: () => void
+ onNextPage: () => void
+}) => {
+ return (
+
+
+
+ {t.settings.security.backupRecoveryPhrase}
+
+
+
+ 🖌 {t.settings.security.writeDownRecoveryPhraseInstructions}
+
+
+
+ {words.map((word, idx) => (
+
+
+ {(startingNumber - 1) * 4 + idx + 1}. {word}
+
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
+
+export default WordsPage
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/index.tsx b/applications/launchpad/gui-react/src/components/SeedPhraseModal/index.tsx
new file mode 100644
index 0000000000..4175e3b3b5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/index.tsx
@@ -0,0 +1,141 @@
+import { invoke } from '@tauri-apps/api'
+import { useEffect, useState } from 'react'
+import Alert from '../Alert'
+import Modal from '../Modal'
+import t from '../../locales'
+import ConfirmPhrasePage from './ConfirmPhrasePage'
+import IntroPage from './IntroPage'
+import WordsPage from './WordsPage'
+import Text from '../Text'
+import { BottomBar, Content, CenteredContent, ModalContent } from './styles'
+import Button from '../Button'
+
+const SeedPhraseModal = ({
+ open,
+ setOpen,
+}: {
+ open: boolean
+ setOpen: (status: boolean) => void
+}) => {
+ const [page, setPage] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [phrase, setPhrase] = useState()
+ const [error, setError] = useState(undefined)
+
+ const onCancel = () => {
+ setOpen(false)
+ }
+
+ useEffect(() => {
+ const getSeedWords = async () => {
+ try {
+ const seedWords: string[] = await invoke('get_seed_words')
+ setPhrase(seedWords)
+ } catch (err) {
+ setError((err as Error).toString())
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (open && (!phrase || phrase.length === 0)) {
+ getSeedWords()
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (error) {
+ setOpen(false)
+ }
+ }, [error])
+
+ const renderPage = () => {
+ if (loading) {
+ return (
+
+
+
+ {t.common.adjectives.loading}...
+
+
+
+
+
+
+ )
+ }
+ if (!phrase) {
+ return (
+
+
+
+ {t.settings.security.couldNotGetSeedWords}
+
+
+
+
+
+
+ )
+ }
+
+ switch (page) {
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ return (
+ setPage(p => p - 1)}
+ onNextPage={() => setPage(p => p + 1)}
+ />
+ )
+
+ case 7:
+ return (
+ setPage(p => p - 1)}
+ onSuccess={() => setOpen(false)}
+ onError={err => setError(err)}
+ />
+ )
+
+ default:
+ return (
+ setPage(1)}
+ />
+ )
+ }
+ }
+
+ return (
+ <>
+
+ {renderPage()}
+
+ setError(undefined)}
+ />
+ >
+ )
+}
+
+export default SeedPhraseModal
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/styles.ts b/applications/launchpad/gui-react/src/components/SeedPhraseModal/styles.ts
new file mode 100644
index 0000000000..d72d99530c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/styles.ts
@@ -0,0 +1,125 @@
+import styled from 'styled-components'
+
+export const ModalContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative;
+ box-sizing: border-box;
+`
+
+export const Content = styled.div`
+ flex: 1;
+ overflow: auto;
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(1.8)} ${theme.spacingHorizontal(1.65)}`};
+ padding-top: ${({ theme }) => theme.spacingVertical(3.5)};
+ color: ${({ theme }) => theme.primary};
+`
+
+export const BottomBar = styled.div`
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(1.8)} ${theme.spacingHorizontal(1.65)}`};
+ border-top: 1px solid ${({ theme }) => theme.balanceBoxBorder};
+ box-sizing: border-box;
+ display: flex;
+ column-gap: ${({ theme }) => theme.spacingHorizontal(1)};
+ background: ${({ theme }) => theme.nodeBackground};
+ justify-content: space-between;
+ border-bottom-left-radius: ${({ theme }) => theme.borderRadius(1)};
+ border-bottom-right-radius: ${({ theme }) => theme.borderRadius(1)};
+`
+
+export const ErrorContainer = styled.div`
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ box-sizing: border-box;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ background: rgba(196, 196, 196, 0.3);
+ border-radius: ${({ theme }) => theme.borderRadius(1)};
+`
+
+export const ErrorText = styled.div`
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(1.8)} ${theme.spacingHorizontal(1.65)}`};
+ background: ${({ theme }) => theme.nodeBackground};
+ color: ${({ theme }) => theme.warningDark};
+`
+
+export const TextSection = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(2)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const PrintButtonWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: ${({ theme }) => theme.spacingVertical(4)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(1)};
+ color: ${({ theme }) => theme.onTextLight};
+`
+
+export const PrintView = styled.div`
+ position: fixed;
+ background: #fff;
+ width: 100vw;
+ height: 100vh;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ z-index: 10000;
+ display: flex;
+ flex-direction: column;
+ padding: 40px;
+ color: #000;
+ align-items: flex-start;
+ justify-content: flex-start;
+`
+
+export const PrintPhrase = styled.div`
+ margin-top: 30px;
+`
+
+export const WordsContainer = styled.div`
+ padding-left: ${({ theme }) => theme.spacingHorizontal(1)};
+ padding-right: ${({ theme }) => theme.spacingHorizontal(1)};
+`
+
+export const WordDisplay = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(1)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(1)};
+ padding: ${({ theme }) => theme.spacing(0.8)};
+ padding-bottom: ${({ theme }) => theme.spacing(0.6)};
+ background: ${({ theme }) => theme.backgroundSecondary};
+ border-radius: ${({ theme }) => theme.borderRadius()};
+`
+
+export const WordInputRow = styled.div`
+ display: flex;
+ align-items: center;
+ margin-top: ${({ theme }) => theme.spacingVertical(2)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const NumWrapper = styled.div`
+ min-width: 40px;
+`
+
+export const CenteredContent = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ height: 100%;
+ box-sizing: border-box;
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(1.8)} ${theme.spacingHorizontal(1.65)}`};
+`
diff --git a/applications/launchpad/gui-react/src/components/SeedPhraseModal/types.ts b/applications/launchpad/gui-react/src/components/SeedPhraseModal/types.ts
new file mode 100644
index 0000000000..92b5f7ae82
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SeedPhraseModal/types.ts
@@ -0,0 +1,4 @@
+export interface SeedPhraseModalProps {
+ open: boolean
+ phrase: string[]
+}
diff --git a/applications/launchpad/gui-react/src/components/Select/Select.test.tsx b/applications/launchpad/gui-react/src/components/Select/Select.test.tsx
new file mode 100644
index 0000000000..563938be44
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Select/Select.test.tsx
@@ -0,0 +1,65 @@
+import { act, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import Select from './'
+
+describe('Select', () => {
+ it('should render label with select', async () => {
+ // given
+ const options = [
+ {
+ value: 'Test value',
+ key: 'test',
+ label: 'Test label',
+ },
+ ]
+
+ // when
+ await act(async () => {
+ render(
+
+ ,
+ )
+ })
+
+ // then
+ const label = screen.getByText(/Test select label/i)
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should render selected option', async () => {
+ // given
+ const options = [
+ {
+ value: 'Test value',
+ key: 'test',
+ label: 'Test label',
+ },
+ ]
+
+ // when
+ await act(async () => {
+ render(
+
+ ,
+ )
+ })
+
+ // then
+ const label = screen.getByText(/Test label/i)
+ expect(label).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Select/index.tsx b/applications/launchpad/gui-react/src/components/Select/index.tsx
new file mode 100644
index 0000000000..f28c8ca4d5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Select/index.tsx
@@ -0,0 +1,102 @@
+import { Fragment, ReactNode } from 'react'
+import { Listbox } from '@headlessui/react'
+
+import Text from '../Text'
+
+import ArrowBottom from '../../styles/Icons/ArrowBottom1'
+
+import {
+ Label,
+ SelectButton,
+ SelectorIcon,
+ OptionsContainer,
+ Option,
+} from './styles'
+import { Option as OptionProp, SelectStylesOverrideProps } from './types'
+
+/**
+ * @name Select
+ *
+ * Renders a tari-styled single select
+ *
+ * @prop {boolean?} inverted - whether component should display inverted styles on dark background
+ * @prop {string} [label] - optional label used for component
+ * @prop {Option[]} options - options shown in the select dropdown
+ * @prop {Option} value - selected value
+ * @prop {function} onChange - called when selected value changes
+ * @prop {boolean?} disabled - disables the the control
+ * @prop {ReactNode} [icon] - icon to show left to the selected value
+ * @prop {SelectStylesOverrideProps} [styles] - optional style overrides for Select
+ * @prop {boolean} [fullWidth] - default: true, with this select renders as 100% of container width
+ */
+const Select = ({
+ value,
+ options,
+ onChange,
+ inverted,
+ label,
+ disabled,
+ styles,
+ icon,
+ fullWidth = true,
+}: {
+ disabled?: boolean
+ inverted?: boolean
+ label?: string
+ value?: OptionProp
+ options: OptionProp[]
+ onChange: (option: OptionProp) => void
+ styles?: SelectStylesOverrideProps
+ icon?: ReactNode
+ fullWidth?: boolean
+}) => {
+ return (
+
+ {({ open }: { open: boolean }) => (
+
+ {label && (
+
+ )}
+
+ {icon}
+
+ {(value || {}).label || ''}
+
+ {!disabled && (
+
+
+
+ )}
+
+
+ {options.map(option => (
+
+ {({ active, selected }) => (
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export default Select
diff --git a/applications/launchpad/gui-react/src/components/Select/styles.tsx b/applications/launchpad/gui-react/src/components/Select/styles.tsx
new file mode 100644
index 0000000000..0890eb38c0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Select/styles.tsx
@@ -0,0 +1,120 @@
+import { CSSProperties } from 'react'
+import styled from 'styled-components'
+import { Listbox } from '@headlessui/react'
+
+import { SelectInternalProps } from './types'
+
+export const SelectorIcon = styled.div`
+ position: absolute;
+ top: 0;
+ right: ${({ theme }) => theme.spacingHorizontal(0.5)};
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ font-size: 1.5em;
+ color: ${({ inverted, theme }) =>
+ inverted ? theme.inverted.primary : theme.primary};
+`
+
+export const SelectButton = styled(Listbox.Button)<
+ SelectInternalProps & {
+ style?: { borderColor?: (open?: boolean) => string } & Omit<
+ CSSProperties,
+ 'borderColor'
+ >
+ }
+>`
+ display: flex;
+ align-items: center;
+ column-gap: 0.3em;
+ cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
+ font-size: 1em;
+ color: ${({ theme, inverted }) =>
+ inverted ? theme.inverted.primary : theme.secondary};
+ position: relative;
+ appearance: none;
+ background-color: ${({ theme, inverted }) =>
+ inverted ? theme.inverted.controlBackground : theme.controlBackground};
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.78)} ${theme.spacingHorizontal(0.67)}`};
+ padding-right: ${({ theme }) => theme.spacingHorizontal(1.5)};
+ width: ${({ fullWidth }) => (fullWidth ? '100%' : '')};
+ margin: 0;
+ outline: none;
+ border: none;
+ border: 1px solid;
+ border-radius: ${({ theme }) => theme.tightBorderRadius()};
+ border-color: ${({ style, theme, inverted, open }) => {
+ if (style?.borderColor) {
+ return style.borderColor(open)
+ }
+
+ return open
+ ? inverted
+ ? theme.inverted.accent
+ : theme.accent
+ : theme.selectBorderColor
+ }};
+ text-align: left;
+`
+
+const FloatingOptions = styled.ul`
+ color: ${({ theme }) => theme.secondary};
+ position: absolute;
+ margin: 0;
+ margin-top: ${({ theme }) => theme.spacingVertical(0.5)};
+ padding: 0;
+ padding-top: ${({ theme }) => theme.spacingVertical(0.4)};
+ padding-bottom: ${({ theme }) => theme.spacingVertical(0.4)};
+ width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
+ border: 1px solid;
+ border-radius: ${({ theme }) => theme.tightBorderRadius()};
+ border-color: ${({ theme, open }) =>
+ open ? theme.accent : theme.selectBorderColor};
+ background-color: ${({ theme }) => theme.nodeBackground};
+ z-index: 9001;
+`
+
+const Options = styled(Listbox.Options)`
+ position: relative;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ outline: none;
+`
+
+export const OptionsContainer = (props: SelectInternalProps) => (
+
+
+
+)
+
+export const Option = styled.li<
+ SelectInternalProps & { selected?: boolean; active?: boolean }
+>`
+ list-style-type: none;
+ position: relative;
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.4)} ${theme.spacingHorizontal(0.4)}`};
+ margin: ${({ theme }) =>
+ `${theme.spacingVertical(0.4)} ${theme.spacingHorizontal(0.4)}`};
+ border-radius: ${({ theme }) => theme.borderRadius(0.5)};
+
+ outline: none;
+ cursor: pointer;
+
+ &:hover {
+ background-color: ${({ theme }) => theme.selectOptionHover};
+ }
+`
+
+export const Label = styled(Listbox.Label)<
+ SelectInternalProps & { style?: { color?: string } }
+>`
+ font-size: 1em;
+ display: inline-block;
+ margin-bottom: ${({ theme }) => theme.spacingVertical()};
+ color: ${({ style }) => style?.color};
+ font-family: 'AvenirMedium';
+`
diff --git a/applications/launchpad/gui-react/src/components/Select/types.ts b/applications/launchpad/gui-react/src/components/Select/types.ts
new file mode 100644
index 0000000000..5513414056
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Select/types.ts
@@ -0,0 +1,46 @@
+import { ReactNode } from 'react'
+
+export interface SelectInternalProps {
+ disabled?: boolean
+ inverted?: boolean
+ children?: ReactNode
+ open?: boolean
+ fullWidth?: boolean
+}
+
+/**
+ * @typedef {Object} Option
+ * @property {string | number} value - value of the option
+ * @property {string} label - label shown in option
+ * @property {string} key - key to be used in react map
+ */
+export interface Option {
+ value: string | number
+ label: string
+ key: string
+}
+
+/**
+ * @typedef {Object} SelectStylesOverrideProps
+ * @property {Object} [icon] - down arrow styles override
+ * @property {string} [color] - color of the icon
+ * @property {Object} [value] - overrides of the box showing selected value
+ * @property {string} [color] - text color of the value
+ * @property {string} [backgroundColor] - background color
+ * @property {(open?: boolean) => string} [borderColor] - allows to set different border color for opened and closed state
+ * @property {Object} [label] - label styles override
+ * @property {string} [color] - color of the label
+ */
+export type SelectStylesOverrideProps = {
+ icon?: {
+ color?: string
+ }
+ value?: {
+ color?: string
+ backgroundColor?: string
+ borderColor?: (open?: boolean) => string
+ }
+ label?: {
+ color?: string
+ }
+}
diff --git a/applications/launchpad/gui-react/src/components/SettingsSectionHeader/SettingsSectionHeader.test.tsx b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/SettingsSectionHeader.test.tsx
new file mode 100644
index 0000000000..0bde6aa228
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/SettingsSectionHeader.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import SettingsSectionHeader from './'
+
+describe('SettingsSectionHeader', () => {
+ it('should render text when is given', async () => {
+ const testText = 'Expert view'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const label = screen.getByText(testText)
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should render without crash when children is not set', async () => {
+ render(
+
+
+ ,
+ )
+
+ const label = screen.getByTestId('settings-section-header-cmp')
+ expect(label).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/SettingsSectionHeader/index.tsx b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/index.tsx
new file mode 100644
index 0000000000..e30a9837d8
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/index.tsx
@@ -0,0 +1,30 @@
+import Text from '../Text'
+
+import { HeaderContainer, HeaderLine } from './styles'
+import { SettingsSectionHeaderProps } from './types'
+
+/**
+ * Settings header containing a text and line.
+ */
+const SettingsSectionHeader = ({
+ children,
+ noBottomMargin,
+ noTopMargin,
+}: SettingsSectionHeaderProps) => {
+ return (
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+ )
+}
+
+export default SettingsSectionHeader
diff --git a/applications/launchpad/gui-react/src/components/SettingsSectionHeader/styles.ts b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/styles.ts
new file mode 100644
index 0000000000..58b58f8f48
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/styles.ts
@@ -0,0 +1,24 @@
+import styled from 'styled-components'
+
+export const HeaderContainer = styled.div<{
+ $noBottomMargin?: boolean
+ $noTopMargin?: boolean
+}>`
+ display: flex;
+ align-items: center;
+ margin-top: ${({ theme, $noTopMargin }) =>
+ $noTopMargin ? theme.spacingVertical(1.5) : theme.spacingVertical(2.7)};
+ margin-bottom: ${({ theme, $noBottomMargin }) =>
+ $noBottomMargin ? theme.spacingVertical(1.5) : theme.spacingVertical(2.7)};
+
+ & > h2 {
+ ${({ theme }) => theme.tariTextGradient};
+ }
+`
+
+export const HeaderLine = styled.div`
+ flex: 1;
+ height: 1px;
+ background: ${({ theme }) => theme.selectBorderColor};
+ margin-left: ${({ theme }) => theme.spacingHorizontal(0.5)};
+`
diff --git a/applications/launchpad/gui-react/src/components/SettingsSectionHeader/types.ts b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/types.ts
new file mode 100644
index 0000000000..45bc5236f5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/SettingsSectionHeader/types.ts
@@ -0,0 +1,7 @@
+import { ReactNode } from 'react'
+
+export interface SettingsSectionHeaderProps {
+ children?: string | ReactNode
+ noBottomMargin?: boolean
+ noTopMargin?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Switch/Switch.test.tsx b/applications/launchpad/gui-react/src/components/Switch/Switch.test.tsx
new file mode 100644
index 0000000000..3f31c5c952
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Switch/Switch.test.tsx
@@ -0,0 +1,32 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Switch from '.'
+import themes from '../../styles/themes'
+
+describe('Switch', () => {
+ it('should render without crash', () => {
+ const onClick = jest.fn()
+ const testLabel = 'Test label for the switch component'
+ const anotherTestLabel = 'Test label for the switch component'
+ const val = false
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('switch-input-cmp')
+ expect(el).toBeInTheDocument()
+
+ fireEvent.click(el)
+
+ expect(onClick).toHaveBeenCalled()
+ expect(onClick).toHaveBeenCalledWith(!val)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Switch/index.tsx b/applications/launchpad/gui-react/src/components/Switch/index.tsx
new file mode 100644
index 0000000000..c4176b707a
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Switch/index.tsx
@@ -0,0 +1,125 @@
+import { animated, useSpring } from 'react-spring'
+import { useTheme } from 'styled-components'
+
+import Text from '../Text'
+
+import {
+ LabelText,
+ SwitchContainer,
+ SwitchController,
+ SwitchCircle,
+} from './styles'
+import { SwitchProps } from './types'
+
+/**
+ * Switch input controller
+ *
+ * @param {boolean} value - the input value
+ * @param {string | ReactNode} [leftLabel] - the text or ReactNode element on the left side of the switch.
+ * @param {string | ReactNode} [rightLabel] - the text or ReactNode element on the right side of the switch.
+ * @param {(val: boolean) => void} onClick - when the switch is clicked. Returns the new value.
+ * @param {boolean} [inverted] - use inverted styles
+ * @param {boolean} [disable] - disable switch interaction
+ * @param {string} [testId] - the test ID (react-testing/jest)
+ *
+ * @example
+ * }
+ * rightLabel={'The label text'}
+ * value={currentTheme === 'dark'}
+ * onClick={v => dispatch(setTheme(v ? 'dark' : 'light'))}
+ * />
+ */
+const Switch = ({
+ value,
+ leftLabel,
+ rightLabel,
+ onClick,
+ inverted,
+ disable,
+ testId,
+}: SwitchProps) => {
+ const theme = useTheme()
+
+ const themeStyle = inverted ? theme.inverted : theme
+
+ const circleAnim = useSpring({
+ left: value ? 10 : -1,
+ background: value ? themeStyle.primary : themeStyle.switchController,
+ })
+
+ const leftLabelColorAnim = useSpring({
+ color: disable ? themeStyle.disabledText : themeStyle.primary,
+ opacity: value && leftLabel && rightLabel ? 0.5 : 1,
+ })
+
+ const rightLabelColorAnim = useSpring({
+ color: disable ? themeStyle.disabledText : themeStyle.primary,
+ opacity: value || !leftLabel || !rightLabel ? 1 : 0.5,
+ })
+
+ const controllerAnim = useSpring({
+ background: value ? themeStyle.accent : themeStyle.switchController,
+ })
+
+ const leftLabelEl =
+ leftLabel && typeof leftLabel === 'string' ? (
+
+ {leftLabel}
+
+ ) : (
+ leftLabel
+ )
+ const rightLabelEl =
+ rightLabel && typeof rightLabel === 'string' ? (
+
+ {rightLabel}
+
+ ) : (
+ rightLabel
+ )
+
+ return (
+ onClick && !disable && onClick(!value)}
+ disable={disable}
+ data-testid={testId || 'switch-input-cmp'}
+ >
+ {leftLabelEl ? (
+
+ {leftLabelEl}
+
+ ) : null}
+
+
+
+
+
+ {rightLabelEl ? (
+
+ {rightLabelEl}
+
+ ) : null}
+
+ )
+}
+
+export default Switch
diff --git a/applications/launchpad/gui-react/src/components/Switch/styles.ts b/applications/launchpad/gui-react/src/components/Switch/styles.ts
new file mode 100644
index 0000000000..e69b5ec096
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Switch/styles.ts
@@ -0,0 +1,52 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+import colors from '../../styles/styles/colors'
+
+export const SwitchContainer = styled.label<{ disable?: boolean }>`
+ display: flex;
+ align-items: center;
+ cursor: ${({ disable }) => (disable ? '' : 'pointer')};
+`
+
+export const SwitchController = styled(animated.div)<{ disable?: boolean }>`
+ height: 14px;
+ width: 24px;
+ border: 1.5px solid
+ ${({ theme, disable }) =>
+ disable ? theme.disabledText : theme.switchBorder};
+ border-radius: 6px;
+ position: relative;
+ box-sizing: border-box;
+ box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.08);
+ cursor: ${({ disable }) => (disable ? '' : 'pointer')};
+ -webkit-box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+ -moz-box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+ box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+`
+
+export const SwitchCircle = styled(animated.div)<{ disable?: boolean }>`
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ top: 0;
+ margin-top: -1.5px;
+ margin-left: -0.5px;
+ border-radius: 6px;
+ box-sizing: border-box;
+ background: ${({ theme }) => theme.accent};
+ border: 1.5px solid
+ ${({ theme, disable }) =>
+ disable ? theme.disabledText : theme.switchBorder};
+ -webkit-box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+ -moz-box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+ box-shadow: 0px 0px 2px -1px ${colors.dark.primary};
+`
+
+export const LabelText = styled(animated.span)`
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 160%;
+ display: flex;
+ align-items: center;
+ margin: 0;
+`
diff --git a/applications/launchpad/gui-react/src/components/Switch/types.ts b/applications/launchpad/gui-react/src/components/Switch/types.ts
new file mode 100644
index 0000000000..9115387939
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Switch/types.ts
@@ -0,0 +1,11 @@
+import { ReactNode } from 'react'
+
+export interface SwitchProps {
+ value: boolean
+ leftLabel?: string | ReactNode
+ rightLabel?: string | ReactNode
+ onClick: (val: boolean) => void
+ inverted?: boolean
+ disable?: boolean
+ testId?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/TBot/DotsComponent/index.tsx b/applications/launchpad/gui-react/src/components/TBot/DotsComponent/index.tsx
new file mode 100644
index 0000000000..fec9ff1f28
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/DotsComponent/index.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useRef } from 'react'
+import lottie from 'lottie-web'
+import dotsChatLottieLight from '../../../styles/lotties/tbot-dots-animation-light.json'
+import dotsChatLottieDark from '../../../styles/lotties/tbot-dots-animation-dark.json'
+import { DotsContainer, StyledRow } from './styles'
+
+/**
+ * @name ChatDots light version
+ */
+
+const ChatDotsLight = () => {
+ const animation = useRef(null)
+
+ useEffect(() => {
+ if (animation.current) {
+ lottie.loadAnimation({
+ name: 'dotsAnimation',
+ container: animation.current,
+ renderer: 'svg',
+ loop: true,
+ autoplay: true,
+ animationData: dotsChatLottieLight,
+ })
+ }
+
+ return () => {
+ try {
+ lottie.destroy()
+ } catch (_) {
+ // Do not propagate it further
+ }
+ }
+ }, [animation])
+
+ return (
+
+
+
+ )
+}
+
+/**
+ * @name ChatDots dark version
+ */
+
+const ChatDotsDark = () => {
+ const animation = useRef(null)
+ useEffect(() => {
+ if (animation.current) {
+ lottie.loadAnimation({
+ name: 'dotsAnimation',
+ container: animation.current,
+ renderer: 'svg',
+ loop: true,
+ autoplay: true,
+ animationData: dotsChatLottieDark,
+ })
+ }
+
+ return () => {
+ try {
+ lottie.destroy()
+ } catch (_) {
+ // Do not propagate it further
+ }
+ }
+ }, [animation])
+
+ return (
+
+
+
+ )
+}
+
+export { ChatDotsLight, ChatDotsDark }
diff --git a/applications/launchpad/gui-react/src/components/TBot/DotsComponent/styles.ts b/applications/launchpad/gui-react/src/components/TBot/DotsComponent/styles.ts
new file mode 100644
index 0000000000..4c0207ddf8
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/DotsComponent/styles.ts
@@ -0,0 +1,16 @@
+import styled from 'styled-components'
+
+export const StyledRow = styled.div`
+ width: 100%;
+ height: 40px;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+`
+
+export const DotsContainer = styled.div`
+ width: 99px;
+ margin-right: 10px;
+`
+
+export const StyledDots = styled.div``
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/BaseNode/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/BaseNode/index.tsx
new file mode 100644
index 0000000000..dac36db2cc
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/BaseNode/index.tsx
@@ -0,0 +1,45 @@
+import t from '../../../../locales'
+import Text from '../../../Text'
+import GotItButton from '../GotItButton'
+import { StyledTextContainer } from '../styles'
+
+export const WhatIsBaseNode = () => {
+ return (
+ <>
+
+
+ {t.baseNode.helpMessages.howItWorks.allowsYou}
+
+ {t.baseNode.helpMessages.howItWorks.affordances.map(a => (
+ - {a}
+ ))}
+
+
+
+
+
+
+ {t.baseNode.helpMessages.howItWorks.thankYou}
+ {' '}
+
+ {t.baseNode.helpMessages.howItWorks.yourContribution}
+
+
+
+
+ >
+ )
+}
+
+export const ConnectAurora = () => {
+ return (
+ <>
+
+
+ {t.baseNode.helpMessages.aurora}
+
+
+
+ >
+ )
+}
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/CryptoMining.test.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/CryptoMining.test.tsx
new file mode 100644
index 0000000000..fd8617b0fb
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/CryptoMining.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import themes from '../../../../styles/themes'
+import { Provider } from 'react-redux'
+import { store } from '../../../../store'
+
+import { Message1 } from '.'
+
+describe('CryptoMiningMessages', () => {
+ it('should render the message component without crashing when set to open', () => {
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('message-cmp')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/index.tsx
new file mode 100644
index 0000000000..1b6316eb4b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/CryptoMining/index.tsx
@@ -0,0 +1,19 @@
+import GotItButton from '../GotItButton'
+import t from '../../../../locales'
+import { StyledTextContainer } from '../styles'
+import Text from '../../../Text'
+
+const Message1 = () => {
+ return (
+ <>
+
+
+ {t.cryptoMiningHelp.message1}
+
+
+
+ >
+ )
+}
+
+export { Message1 }
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/index.tsx
new file mode 100644
index 0000000000..7d4066a1ba
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/index.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useMemo, useState } from 'react'
+
+import Text from '../../../Text'
+
+import MessagesConfig from '../../../../config/helpMessagesConfig'
+import t from '../../../../locales'
+
+import {
+ selectDockerImages,
+ selectDockerTBotQueue,
+} from '../../../../store/dockerImages/selectors'
+import { useAppDispatch, useAppSelector } from '../../../../store/hooks'
+import { tbotactions } from '../../../../store/tbot'
+import { actions as dockerImagesActions } from '../../../../store/dockerImages'
+
+import {
+ ButtonsContainer,
+ DockerDwnlTag,
+ DockerDwnlTagContainer,
+ DockerDwnlInnerTag,
+ ProgressContainer,
+} from './styles'
+import Button from '../../../Button'
+import { selectExpertView, selectTheme } from '../../../../store/app/selectors'
+
+export const NewDockerImageToDownload = () => {
+ const dockerImages = useAppSelector(selectDockerTBotQueue)
+ const expertView = useAppSelector(selectExpertView)
+ const currentTheme = useAppSelector(selectTheme)
+
+ const dockerImage = useMemo(() => dockerImages[0], [])
+
+ return (
+
+
+
+
+ {dockerImage?.displayName || ''}
+
+
+
+ {t.docker.newerVersion}
+
+
+
+
+
+
+ {t.docker.tBot.newVersionAvailable.part1}
+ {' '}
+ {t.docker.tBot.newVersionAvailable.part2}
+
+
+ )
+}
+
+export const DownloadDockerImage = () => {
+ const dispatch = useAppDispatch()
+
+ const dockerImagesQueue = useAppSelector(selectDockerTBotQueue)
+ const dockerImages = useAppSelector(selectDockerImages)
+
+ const [status, setStatus] = useState<
+ 'not_started' | 'processing' | 'done' | 'error'
+ >('not_started')
+ const [progress, setProgress] = useState('')
+
+ const dockerImage = useMemo(() => dockerImagesQueue[0], [])
+
+ useEffect(() => {
+ const foundImage = dockerImages.find(
+ i => i.containerName === dockerImage.containerName,
+ )
+
+ if (foundImage) {
+ setProgress(foundImage.status || '')
+
+ if (!foundImage.pending && foundImage.updated) {
+ setStatus('done')
+ dispatch(tbotactions.push(MessagesConfig.DockerImageDownloadSuccess))
+ } else if (foundImage.error) {
+ setStatus('error')
+ dispatch(tbotactions.push(MessagesConfig.DockerImageDownloadError))
+ }
+ }
+ }, [dockerImages])
+
+ const dismiss = () => {
+ dispatch(tbotactions.close())
+ dispatch(
+ dockerImagesActions.removeFromTBotQueue({
+ image: dockerImage,
+ dismiss: true,
+ }),
+ )
+ }
+
+ const download = () => {
+ setStatus('processing')
+
+ dispatch(
+ dockerImagesActions.pullImage({
+ dockerImage: dockerImage.containerName,
+ }),
+ )
+ }
+
+ return (
+
+
{t.docker.tBot.downloadStepMessage}
+ {status === 'not_started' && (
+
+
+
+
+ )}
+
+ {status === 'processing' && (
+
+
+ {progress}
+
+
+ )}
+
+ {status === 'done' && (
+
+ ✅
+
+ )}
+
+ {status === 'error' && (
+
+ ❌
+
+ )}
+
+ )
+}
+
+export const DockerImageDownloadSuccess = () => {
+ const dispatch = useAppDispatch()
+
+ const dockerImagesQueue = useAppSelector(selectDockerTBotQueue)
+
+ const dockerImage = useMemo(() => dockerImagesQueue[0], [])
+
+ useEffect(() => {
+ dispatch(
+ dockerImagesActions.removeFromTBotQueue({
+ image: dockerImage,
+ dismiss: false,
+ }),
+ )
+ }, [dockerImage])
+
+ return (
+
+
+
+ ✅{' '}
+
+ {t.docker.tBot.downloadSuccess.part1}
+ {' '}
+ {t.docker.tBot.downloadSuccess.part2}
+
+
+
+ )
+}
+
+export const DockerImageDownloadError = () => {
+ const dispatch = useAppDispatch()
+
+ const dockerImagesQueue = useAppSelector(selectDockerTBotQueue)
+
+ const dockerImage = useMemo(() => dockerImagesQueue[0], [])
+
+ useEffect(() => {
+ dispatch(
+ dockerImagesActions.removeFromTBotQueue({
+ image: dockerImage,
+ dismiss: false,
+ }),
+ )
+ }, [dockerImage])
+
+ return (
+
+
+
+ ❌{' '}
+
+ {t.docker.tBot.downloadError.part1}
+ {' '}
+ {t.docker.tBot.downloadError.part2}
+
+
+
+ )
+}
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/styles.ts b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/styles.ts
new file mode 100644
index 0000000000..c8ff673db0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/DockerComponents/styles.ts
@@ -0,0 +1,47 @@
+import styled from 'styled-components'
+
+export const DockerDwnlTagContainer = styled.div`
+ display: flex;
+ margin-bottom: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const DockerDwnlTag = styled.div<{ $dark: boolean }>`
+ background: ${({ theme, $dark }) =>
+ $dark ? theme.inverted.backgroundSecondary : theme.backgroundSecondary};
+ color: ${({ theme, $dark }) => ($dark ? '#fff' : theme.accentDark)};
+ border-radius: ${({ theme }) => theme.borderRadius(1)};
+ width: 100%;
+ display: flex;
+ flex: 1;
+ text-align: center;
+ align-items: center;
+ padding-left: ${({ theme }) => theme.spacingHorizontal(0.5)};
+`
+
+export const DockerDwnlInnerTag = styled.span`
+ background: ${({ theme }) => theme.warning};
+ color: ${({ theme }) => theme.warningText};
+ border-radius: ${({ theme }) => theme.borderRadius(1)};
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(0.23)} ${theme.spacingHorizontal(0.5)}`};
+`
+
+export const ButtonsContainer = styled.div`
+ display: flex;
+ width: 100%;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.spacingHorizontal(1)};
+ margin-top: ${({ theme }) => theme.spacingVertical(2)};
+ height: 38px;
+`
+
+export const ProgressContainer = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin-top: ${({ theme }) => theme.spacingVertical(2)};
+ height: 38px;
+ color: ${({ theme }) => theme.onTextLight};
+`
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/GotItButton.test.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/GotItButton.test.tsx
new file mode 100644
index 0000000000..74272237f9
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/GotItButton.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import { Provider } from 'react-redux'
+
+import { store } from '../../../../store'
+import themes from '../../../../styles/themes'
+import GotItButton from '.'
+
+describe('GotItButton', () => {
+ it('should render the button without crashing', () => {
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('gotitbutton-cmp')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/index.tsx
new file mode 100644
index 0000000000..79ad852c4f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/GotItButton/index.tsx
@@ -0,0 +1,28 @@
+import { useAppDispatch } from '../../../../store/hooks'
+import { tbotactions } from '../../../../store/tbot'
+import t from '../../../../locales'
+import Button from '../../../Button'
+
+const GotItButton = () => {
+ const dispatch = useAppDispatch()
+
+ const close = () => {
+ return dispatch(tbotactions.close())
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default GotItButton
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/MergedMining.test.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/MergedMining.test.tsx
new file mode 100644
index 0000000000..c2eefb4490
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/MergedMining.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import { Provider } from 'react-redux'
+
+import themes from '../../../../styles/themes'
+import { store } from '../../../../store'
+
+import { Message2 } from '.'
+
+describe('MergedMiningMessages', () => {
+ it('should render the second message component without crashing when set to open', () => {
+ render(
+
+
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('message2-cmp')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/index.tsx
new file mode 100644
index 0000000000..c947c76a4c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/MergedMining/index.tsx
@@ -0,0 +1,27 @@
+import t from '../../../../locales'
+import { StyledTextContainer } from '../styles'
+import Text from '../../../Text'
+import GotItButton from '../GotItButton'
+
+const Message1 = (
+ <>
+
+ {t.mergedMiningHelp.message1}
+
+ >
+)
+
+const Message2 = () => {
+ return (
+ <>
+
+
+ {t.mergedMiningHelp.message2}
+
+
+
+ >
+ )
+}
+
+export { Message1, Message2 }
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/OnlineCheck/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/OnlineCheck/index.tsx
new file mode 100644
index 0000000000..f85879be89
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/OnlineCheck/index.tsx
@@ -0,0 +1,29 @@
+import t from '../../../../locales'
+import { StyledTextContainer, ListItem, ListGroup } from '../styles'
+import Text from '../../../Text'
+import GotItButton from '../GotItButton'
+
+export const LooksLikeYoureOffline = () => (
+
+
+ {t.online.youreOffline}
+
+ {t.online.noInternet}
+
+)
+
+export const ReconnectToInternet = () => (
+ <>
+
+
+ {t.online.tryThose}
+
+
+ {t.online.checkRouter}
+ {t.online.resetRouter}
+ {t.online.reconnectWifi}
+
+
+
+ >
+)
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/Wallet/index.tsx b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/Wallet/index.tsx
new file mode 100644
index 0000000000..6fd4a1aa08
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/Wallet/index.tsx
@@ -0,0 +1,63 @@
+import t from '../../../../locales'
+import Text from '../../../Text'
+import GotItButton from '../GotItButton'
+import { StyledTextContainer } from '../styles'
+
+export const HowWalletWorks = (
+ <>
+
+
+ {t.wallet.helpMessages.howItWorks.title}{' '}
+ {t.wallet.helpMessages.howItWorks.message}
+
+
+
+ >
+)
+
+export const WhyBalanceDiffers = (
+ <>
+
+ {t.wallet.helpMessages.whyBalanceDiffers.title}
+
+ {t.wallet.helpMessages.whyBalanceDiffers.message}
+ >
+)
+
+export const NoteAboutVerificationPeriod = () => {
+ return (
+ <>
+
+
+ {t.wallet.helpMessages.noteAboutVerificationPeriod.message}
+
+
+
+ >
+ )
+}
+
+export const TariWalletIdHelp = (
+ <>
+
+
+ {t.wallet.helpMessages.walletIdHelp.bold}{' '}
+ {t.wallet.helpMessages.walletIdHelp.regular}
+
+
+
+ >
+)
+
+export const TransactionFee = () => {
+ return (
+ <>
+
+
+ {t.wallet.helpMessages.transactionFee.message}
+
+
+
+ >
+ )
+}
diff --git a/applications/launchpad/gui-react/src/components/TBot/HelpComponents/styles.ts b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/styles.ts
new file mode 100644
index 0000000000..eb33ce11e2
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/HelpComponents/styles.ts
@@ -0,0 +1,16 @@
+import styled from 'styled-components'
+
+export const StyledTextContainer = styled.div`
+ display: flex;
+ margin-bottom: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const ListItem = styled.li`
+ font-family: 'AvenirMedium';
+ margin-bottom: ${({ theme }) => theme.spacingVertical(0.5)};
+`
+
+export const ListGroup = styled.ul`
+ margin-top: ${({ theme }) => theme.spacingVertical(0.5)};
+ margin-left: ${({ theme }) => theme.spacingHorizontal(-0.5)};
+`
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBot.test.tsx b/applications/launchpad/gui-react/src/components/TBot/TBot.test.tsx
new file mode 100644
index 0000000000..4b227e19e0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBot.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import TBot from '.'
+
+describe('TBot', () => {
+ it('should render the TBot component without crashing', () => {
+ render(
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('tbot-cmp')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/MessageBox.tsx b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/MessageBox.tsx
new file mode 100644
index 0000000000..7a25d7b2b5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/MessageBox.tsx
@@ -0,0 +1,119 @@
+import { forwardRef, ReactNode, ForwardedRef, useState, useRef } from 'react'
+import { useSpring } from 'react-spring'
+import { useTheme } from 'styled-components'
+import SvgArrowRight from '../../../styles/Icons/ArrowRight'
+import Button from '../../Button'
+import t from '../../../locales'
+import {
+ MessageSpaceContainer,
+ StyledMessage,
+ StyledMessageBox,
+ MessageSlideIn,
+ SkipButtonContainer,
+} from './styles'
+import React from 'react'
+
+/**
+ * Component renders the message wrapped with elements allowing to perform
+ * fade-in animation.
+ */
+const MessageBox = (
+ {
+ animate,
+ children,
+ skipButton,
+ onSkip,
+ floating,
+ $onDarkBg,
+ }: {
+ animate: boolean
+ children: ReactNode
+ skipButton?: boolean
+ onSkip?: () => void
+ floating?: boolean
+ $onDarkBg?: boolean
+ },
+ ref?: ForwardedRef,
+) => {
+ const messageBoxRef = useRef(null)
+ const [heightCtrl, setHeightCtrl] = useState()
+
+ const useOpacityAnim = useSpring({
+ from: { opacity: animate ? 0 : 1 },
+ to: { opacity: 1 },
+ delay: 900,
+ })
+
+ const useSlideInAnim = useSpring({
+ from: { top: animate ? 40 : 0 },
+ to: { top: 0 },
+ delay: 800,
+ })
+
+ const theme = useTheme()
+
+ const updateMessageBoxSize = () => {
+ if (messageBoxRef.current) {
+ setHeightCtrl(messageBoxRef.current.clientHeight)
+ }
+ }
+
+ return (
+
+
+ {children}
+
+
+
+
+ {React.Children.map(children, child => {
+ if (React.isValidElement(child)) {
+ /**
+ * TBot Prompt adds each message twice to the DOM:
+ * first message is hidden, and its role is to create a space in the layout for the second message.
+ * The second message is animated and absolutely positioned.
+ * In some cases, the content of the visible message can be changed,
+ * and the size may change. So it may result in covering next message.
+ * To solve this issue, the children should fire `updateMessageBoxSize()`
+ * whenever the content changes the box sizes. The `updateMessageBoxSize`
+ * updates the size of hidden message, so the visible message fits the layout.
+ */
+ return React.cloneElement(child, { updateMessageBoxSize })
+ }
+ return child
+ })}
+ {skipButton && (
+
+ }
+ autosizeIcons={false}
+ onClick={onSkip}
+ >
+ {t.onboarding.actions.skipChatting}
+
+
+ )}
+
+
+
+
+ )
+}
+
+export default forwardRef(MessageBox)
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/TBotPrompt.test.tsx b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/TBotPrompt.test.tsx
new file mode 100644
index 0000000000..b31a5008d0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/TBotPrompt.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import { Provider } from 'react-redux'
+import { randomFillSync } from 'crypto'
+
+import { store } from '../../../store'
+import themes from '../../../styles/themes'
+import TBotPrompt from '.'
+import { tauriIPCMock } from '../../../../__tests__/mocks/mockTauriIPC'
+import { clearMocks } from '@tauri-apps/api/mocks'
+
+beforeAll(() => {
+ window.crypto = {
+ // @ts-expect-error: ignore this
+ getRandomValues: function (buffer) {
+ // @ts-expect-error: ignore this
+ return randomFillSync(buffer)
+ },
+ }
+})
+
+afterEach(() => {
+ clearMocks()
+})
+
+describe('TBot', () => {
+ it('should render the TBotPrompt component without crashing when set to open', () => {
+ tauriIPCMock()
+
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.getByTestId('tbotprompt-cmp')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should not render the component when open prop is false', () => {
+ tauriIPCMock()
+
+ render(
+
+
+
+
+ ,
+ ,
+ )
+
+ const el = screen.queryByTestId('tbotprompt-cmp')
+ expect(el).not.toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/index.tsx b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/index.tsx
new file mode 100644
index 0000000000..011dae6b90
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/index.tsx
@@ -0,0 +1,354 @@
+import { useState, useEffect, useRef, ReactNode, useMemo } from 'react'
+import { config, useSpring } from 'react-spring'
+import { appWindow } from '@tauri-apps/api/window'
+
+import SvgClose from '../../../styles/Icons/Close'
+import TBot from '..'
+
+import { useAppDispatch, useAppSelector } from '../../../store/hooks'
+import { tbotactions } from '../../../store/tbot'
+import { TBotMessage, TBotPromptProps } from './types'
+
+import {
+ ContentRow,
+ PromptContainer,
+ StyledCloseIcon,
+ TBotContainer,
+ MessageContainer,
+ StyledCloseContainer,
+ ContentContainer,
+ FadeOutSection,
+ MessageWrapper,
+ ScrollWrapper,
+ HeightAnimationWrapper,
+ TBotProgressContainer,
+ PROMPT_HEIGHT_SPACING,
+ TBotContainerSizes,
+} from './styles'
+
+import { ChatDotsLight, ChatDotsDark } from '../DotsComponent'
+import MessageBox from './MessageBox'
+import ProgressIndicator from '../../Onboarding/ProgressIndicator'
+import { selectTheme } from '../../../store/app/selectors'
+
+// The default time between rendering messages
+const WAIT_TIME = 2800
+
+/**
+ * @name TBotPrompt
+ *
+ * @prop {boolean} open - controls rendering of prompt component
+ * @prop {() => void} [onClose] - callback on close action of prompt
+ * @prop {ReactNode} [children] - content rendered inside prompt component
+ * @prop {string} [testid] - for testing
+ * @prop {number} [currentIndex] -
+ * @prop {boolean} [closeIcon] - controls rendering of close button
+ * @prop {'help' | 'onboarding'} [mode] - usage mode for TBotPrompt
+ * @prop {boolean} [onDarkBg=false] - is TBot rendered over the dark background?
+ * @prop {'no' | 'yes'| 'dynamic'} [withFadeOutSection='dynamic'] - controls whether the top fading effect is rendered.
+ * - 'no' - do not render at all
+ * - 'yes' - render always
+ * - 'dynamic' - will render if the prompt height is large enough
+ * @prop {(index: number) => void} [onMessageRender] - callback triggered after rendering of each message
+ * @prop {() => void} [onSkip] - on skip chatting button click
+ */
+
+const TBotPrompt = ({
+ open,
+ floating,
+ testid,
+ messages,
+ currentIndex = 1,
+ closeIcon = true,
+ mode = 'help',
+ onDarkBg = false,
+ withFadeOutSection = 'dynamic',
+ onMessageRender,
+ onSkip,
+}: TBotPromptProps) => {
+ const dispatch = useAppDispatch()
+ const currentTheme = useAppSelector(selectTheme)
+
+ const lastMsgRef = useRef(null)
+ const scrollRef = useRef(null)
+ const messageWrapperRef = useRef(null)
+ const currentIndexRef = useRef(currentIndex)
+
+ const [messageLoading, setMessageLoading] = useState(false)
+ const [count, setCount] = useState(currentIndex || 0)
+ const [height, setHeight] = useState(100)
+ const [tickle, setTickle] = useState(true)
+ const [showFadeOut, setShowFadeOut] = useState(withFadeOutSection === 'yes')
+ const [progressFill, setProgressFill] = useState(0)
+ const [forceHeightCalculations, setForceHeightCalculations] = useState(false)
+
+ const promptAnim = useSpring({
+ from: {
+ opacity: floating ? 1 : 0,
+ },
+ opacity: 1,
+ config: config.wobbly,
+ })
+
+ const heightAnim = useSpring({
+ maxHeight: height,
+ duration: 50,
+ })
+
+ const fadeOutSectionAnim = useSpring({
+ opacity:
+ withFadeOutSection === 'yes' ||
+ (withFadeOutSection === 'dynamic' && showFadeOut)
+ ? 1
+ : 0,
+ })
+
+ const scrollToBottom = () => {
+ if (scrollRef.current !== null) {
+ scrollRef.current.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: 'smooth',
+ })
+ }
+ }
+
+ const close = () => {
+ return dispatch(tbotactions.close())
+ }
+
+ const needToShowFadeOutSection = async () => {
+ const size = await appWindow.innerSize()
+ const fadeHeight =
+ mode === 'help'
+ ? TBotContainerSizes.sm.fadeOutHeight
+ : TBotContainerSizes.md.fadeOutHeight
+ setShowFadeOut(
+ size.height * 0.9 - PROMPT_HEIGHT_SPACING - fadeHeight < height,
+ )
+ }
+
+ const getChatDotsVersion = () => {
+ if (currentTheme === 'light' && !onDarkBg) {
+ return
+ } else {
+ return
+ }
+ }
+
+ useEffect(() => {
+ if (withFadeOutSection === 'dynamic') {
+ needToShowFadeOutSection()
+ }
+ }, [height])
+
+ useEffect(() => {
+ // Update internal 'count' if parent changes the currentIndex
+ if (currentIndex || currentIndex === 0) {
+ setCount(currentIndex)
+ }
+
+ // If new currentIndex value is different, it means that we need to 'skip' next messages
+ // and scroll to the bottom.
+ if (currentIndexRef?.current && currentIndexRef?.current !== count) {
+ setTimeout(() => scrollToBottom(), 800)
+ }
+ }, [currentIndex])
+
+ // The following timer increases the 'count' - the messages indexer.
+ // This way, tbot goes through the array of messages.
+ useEffect(() => {
+ let counter = count
+ let timeout: NodeJS.Timeout
+
+ if (messages && (messages.length === 1 || counter >= messages.length)) {
+ setForceHeightCalculations(true)
+ } else if (messages && counter >= messages.length) {
+ setMessageLoading(false)
+ } else if (messages && messages.length > 0) {
+ setMessageLoading(true)
+ // use custom waiting time, if previous message has 'wait' field.
+ const lastMsg = messages[counter]
+ let wait = WAIT_TIME
+ if (
+ lastMsg &&
+ typeof lastMsg !== 'string' &&
+ typeof lastMsg !== 'number' &&
+ typeof lastMsg !== 'boolean' &&
+ 'wait' in lastMsg &&
+ lastMsg.wait
+ ) {
+ wait = lastMsg.wait
+ }
+
+ // show loading dots, and then increase count which results in rendering next message.
+ timeout = setTimeout(() => {
+ setMessageLoading(false)
+ counter++
+ setCount(count => count + 1)
+ }, wait)
+ }
+
+ return () => {
+ clearTimeout(timeout)
+ setMessageLoading(false)
+ }
+ }, [messages, count])
+ // It will animate the list max-height. The timeout is needed, bc app has to render new content first,
+ // so then we can learn what is the current list height, and animate the max-height of wrapping component.
+ useEffect(() => {
+ setTimeout(
+ () => setHeight(messageWrapperRef?.current?.offsetHeight || 100),
+ 200,
+ )
+ }, [messageLoading, count])
+
+ useEffect(() => {
+ if (forceHeightCalculations) {
+ setTimeout(
+ () => setHeight(messageWrapperRef?.current?.offsetHeight || 100),
+ 200,
+ )
+ setForceHeightCalculations(false)
+ }
+ }, [forceHeightCalculations])
+
+ // Tickle tbot whenever the app shows new message
+ useEffect(() => {
+ if (messageLoading) {
+ setTimeout(() => {
+ scrollToBottom()
+ }, 400)
+ } else {
+ setTickle(true)
+ setTimeout(() => {
+ setTickle(false)
+ }, 100)
+ }
+ }, [messageLoading])
+
+ // Automatically scroll to the new message. Timeout is used to allow make some animations meanwhile.
+ useEffect(() => {
+ setTimeout(() => {
+ if (lastMsgRef?.current) {
+ lastMsgRef?.current.scrollIntoView({ block: 'start' })
+ if (onMessageRender) {
+ onMessageRender(count)
+ }
+ }
+ }, 500)
+ }, [lastMsgRef, lastMsgRef?.current])
+
+ // Build messages list
+ const renderedMessages = useMemo(() => {
+ return messages?.slice(0, count).map((msg, idx) => {
+ const counter = count < messages.length ? count - 1 : messages.length - 1
+ const progressBarFill = (messages[counter] as TBotMessage).barFill
+ setProgressFill(progressBarFill)
+ let skipButtonCheck
+ const msgTypeCheck =
+ typeof msg !== 'string' &&
+ typeof msg !== 'number' &&
+ typeof msg !== 'boolean' &&
+ msg
+ if (msgTypeCheck && 'noSkip' in msg) {
+ skipButtonCheck = false
+ } else if (count === idx + 1) {
+ skipButtonCheck = true
+ }
+ if (msgTypeCheck) {
+ if ('content' in msg && typeof msg.content === 'function') {
+ const FuncComponentMsg = msg.content
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {'content' in msg ? (msg.content as ReactNode | string) : msg}
+
+ )
+ }
+
+ return (
+
+ {msg}
+
+ )
+ })
+ }, [messages, count]) as ReactNode
+
+ if (!open) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+ {renderedMessages}
+
+
+
+ {messageLoading && getChatDotsVersion()}
+
+
+
+ {closeIcon && (
+
+
+
+
+
+ )}
+
+
+ {mode === 'onboarding' && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default TBotPrompt
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/styles.ts b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/styles.ts
new file mode 100644
index 0000000000..63208f27e6
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/styles.ts
@@ -0,0 +1,234 @@
+import styled from 'styled-components'
+import { animated } from 'react-spring'
+import { TITLE_BAR_HEIGHT } from '../../TitleBar/styles'
+import colors from '../../../styles/styles/colors'
+
+export const PROMPT_HEIGHT_SPACING = 250
+export const CLOSE_BTN_HEIGHT = 72
+export const TBOT_CONTAINER_TOP_PADDING = 20
+
+export const TBotContainerSizes = {
+ sm: {
+ containerWidth: 476,
+ messageWidth: 426,
+ fadeOutHeight: 80,
+ },
+ md: {
+ containerWidth: 692,
+ messageWidth: 622,
+ fadeOutHeight: 220,
+ },
+}
+
+const addPx = (val: number) => {
+ return `${val}px`
+}
+
+export const PromptContainer = styled(animated.div)<{ $floating?: boolean }>`
+ position: ${({ $floating }) => ($floating ? 'fixed' : 'static')};
+ right: 40px;
+ bottom: 40px;
+ z-index: ${({ $floating }) => ($floating ? '200' : '1')};
+ width: ${({ $floating }) =>
+ $floating
+ ? addPx(TBotContainerSizes.sm.containerWidth)
+ : addPx(TBotContainerSizes.md.containerWidth)};
+ max-width: 100%;
+ height: ${({ $floating }) =>
+ $floating ? 'fit-content' : `calc(100vh - ${PROMPT_HEIGHT_SPACING}px)`};
+ max-width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: ${({ $floating }) => ($floating ? 'center' : 'flex-end')};
+`
+
+export const ContentRow = styled(animated.div)<{ $floating?: boolean }>`
+ position: relative;
+ width: ${({ $floating }) =>
+ $floating
+ ? addPx(TBotContainerSizes.sm.containerWidth)
+ : addPx(TBotContainerSizes.md.containerWidth)};
+ max-width: 100%;
+ ${({ $floating }) => ($floating ? '' : 'height: 100%;')}
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: flex-end;
+ background-blend-mode: screen;
+ height: fit-content;
+`
+
+export const ContentContainer = styled(animated.div)<{ $floating?: boolean }>`
+ display: flex;
+ position: relative;
+ justify-content: center;
+ height: fit-content;
+ max-width: 100%;
+ margin-right: 30px;
+ border-radius: ${({ theme }) => theme.borderRadius(2)};
+ background-color: ${({ $floating, theme }) =>
+ $floating ? theme.tbotContentBackground : ''};
+ backdrop-filter: blur(9px);
+ padding-bottom: 12px;
+ overflow: hidden;
+ padding-top: ${CLOSE_BTN_HEIGHT}px;
+`
+
+export const FadeOutSection = styled(animated.div)<{
+ $floating?: boolean
+ $onDarkBg?: boolean
+}>`
+ pointer-events: none;
+ position: absolute;
+ height: ${({ $floating }) =>
+ $floating
+ ? addPx(TBotContainerSizes.sm.fadeOutHeight)
+ : addPx(TBotContainerSizes.md.fadeOutHeight)};
+ width: ${({ $floating }) =>
+ $floating
+ ? addPx(TBotContainerSizes.sm.containerWidth - 12)
+ : addPx(TBotContainerSizes.md.containerWidth)};
+ max-width: 100%;
+ top: ${({ $floating }) =>
+ $floating
+ ? `${CLOSE_BTN_HEIGHT - 1}px`
+ : `${
+ TITLE_BAR_HEIGHT + CLOSE_BTN_HEIGHT - TBOT_CONTAINER_TOP_PADDING - 50
+ }px`};
+ left: 0;
+ z-index: 2;
+ background-image: ${({ $onDarkBg, $floating }) => {
+ const bgBase = $onDarkBg ? '0, 0, 0' : '250, 250, 250'
+ const firstStop = $floating ? '10%' : '20%'
+
+ return `linear-gradient(to bottom, rgba(${bgBase}, 1) ${firstStop}, rgba(${bgBase}, 0) 100%)`
+ }};
+`
+
+export const MessageContainer = styled(animated.div)<{ $floating?: boolean }>`
+ padding-left: ${({ $floating }) => ($floating ? '0px' : '10px')};
+ padding-right: ${({ $floating }) => ($floating ? '0px' : '10px')};
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+`
+
+export const ScrollWrapper = styled.div`
+ max-height: calc(90vh - ${PROMPT_HEIGHT_SPACING}px);
+ min-height: 50px;
+ max-width: 100%;
+ overflow-y: scroll;
+ transition: max-height 200ms;
+ z-index: 1;
+ position: relative;
+ padding-bottom: 20px;
+ padding-top: ${TBOT_CONTAINER_TOP_PADDING}px;
+ padding-right: 8px;
+
+ ::-webkit-scrollbar {
+ width: 4px;
+ }
+
+ /* Track */
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ /* Handle */
+ ::-webkit-scrollbar-thumb {
+ background: ${({ theme }) => theme.background};
+ border-radius: 3px;
+ }
+
+ /* Handle on hover */
+ ::-webkit-scrollbar-thumb:hover {
+ background: #555;
+ }
+`
+
+export const MessageWrapper = styled.div``
+
+export const HeightAnimationWrapper = styled(animated.div)`
+ max-height: 200px;
+ min-height: 30px;
+`
+
+export const TBotContainer = styled(animated.div)`
+ display: flex;
+ justify-content: center;
+ width: 80px;
+`
+
+export const StyledCloseContainer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ height: ${CLOSE_BTN_HEIGHT}px;
+ top: 0;
+ position: absolute;
+ right: 48px;
+ z-index: 3;
+`
+
+export const StyledCloseIcon = styled.div`
+ color: ${({ theme }) => theme.nodeWarningText};
+ cursor: pointer;
+`
+
+export const StyledMessageBox = styled.div`
+ position: relative;
+`
+
+export const StyledMessage = styled(animated.div)<{
+ $floating?: boolean
+ $skipButton?: boolean
+ $onDarkBg?: boolean
+}>`
+ display: flex;
+ flex-direction: column;
+ width: ${({ $floating }) =>
+ $floating
+ ? addPx(TBotContainerSizes.sm.messageWidth)
+ : addPx(TBotContainerSizes.md.messageWidth)};
+ max-width: 100%;
+ box-sizing: border-box;
+ height: fit-content;
+ margin-bottom: ${({ theme, $skipButton }) =>
+ $skipButton ? theme.spacingVertical(5) : theme.spacingVertical(0.6)};
+ background-color: ${({ theme, $onDarkBg }) =>
+ $onDarkBg ? colors.darkMode.message : theme.tbotMessage};
+ border-radius: ${({ theme }) => theme.borderRadius(2)};
+ box-shadow: ${({ theme }) => theme.shadow24};
+ padding: 40px;
+ color: ${({ theme, $onDarkBg }) =>
+ $onDarkBg ? colors.light.primary : theme.primary};
+ &:last-child {
+ margin-bottom: 0;
+ }
+`
+
+export const MessageSpaceContainer = styled.div`
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ position: absolute;
+`
+
+export const MessageSlideIn = styled(animated.div)`
+ position: absolute;
+ left: 0;
+ right: 0;
+`
+
+export const SkipButtonContainer = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(2)};
+`
+
+export const TBotProgressContainer = styled.div<{ mode?: string }>`
+ display: flex;
+ width: 100%;
+ justify-content: ${({ mode }) =>
+ mode === 'onboarding' ? 'space-between' : 'flex-end'};
+ height: 80px;
+`
diff --git a/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/types.ts b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/types.ts
new file mode 100644
index 0000000000..0a3e5209e6
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/TBotPrompt/types.ts
@@ -0,0 +1,27 @@
+import { ReactNode } from 'react'
+
+export type TBotMessage = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ content: string | ReactNode | ((props?: any) => JSX.Element)
+ wait?: number
+ barFill?: number
+ noSkip?: boolean
+}
+
+export interface TBotPromptProps {
+ open: boolean
+ floating?: boolean
+ testid?: string
+ messages?: (string | ReactNode | TBotMessage)[]
+ currentIndex?: number
+ closeIcon?: boolean
+ mode?: 'onboarding' | 'help'
+ onDarkBg?: boolean
+ withFadeOutSection?: 'no' | 'dynamic' | 'yes'
+ onMessageRender?: (index: number) => void
+ onSkip?: () => void
+}
+
+export interface TBotMessageHOCProps {
+ updateMessageBoxSize?: () => void
+}
diff --git a/applications/launchpad/gui-react/src/components/TBot/index.tsx b/applications/launchpad/gui-react/src/components/TBot/index.tsx
new file mode 100644
index 0000000000..740e0fd42b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/index.tsx
@@ -0,0 +1,98 @@
+import { useSpring } from 'react-spring'
+import { useTheme } from 'styled-components'
+
+import SvgTBotBase from '../../styles/Icons/TBotBase'
+import SvgTBotHeartsMonero from '../../styles/Icons/TBotHeartsMonero'
+import SvgTBotLoading from '../../styles/Icons/TBotLoading'
+import SvgTBotRadar from '../../styles/Icons/TBotSearch'
+import SvgTBotHearts from '../../styles/Icons/TBotHearts'
+
+import { TBotContainer, TBotScaleContainer, TBotShadow } from './styles'
+import { TBotProps, CSSShadowDefinition } from './types'
+
+/**
+ * TBot component
+ *
+ * @prop {TBotType} [type] - TBot variant to render
+ * @prop {CSSProperties} [style] - optional TBot additional styling
+ * @prop {boolean} [animate] - optional prop to trigger the new message T-Bot animation, set to true to trigger animation
+ * @prop {boolean | ShadowDefinition} [shadow] - optional prop to define shadow dropped around TBot, use true for defaults (color: theme.accent, spread: 10, blur: 100)
+ * @prop {boolean} [disableEnterAnimation] - optional prop to disable enter animation
+ *
+ * @example
+ *
+ */
+const TBot = ({
+ type = 'base',
+ style,
+ animate,
+ shadow,
+ disableEnterAnimation,
+}: TBotProps) => {
+ const theme = useTheme()
+ const { fontSize } = { fontSize: 74, ...style }
+ const defaultShadow: CSSShadowDefinition = {
+ color: theme.accent,
+ spread: 10,
+ blur: 100,
+ size: parseInt(fontSize.toString()),
+ }
+
+ const botVariants = {
+ base: SvgTBotBase,
+ hearts: SvgTBotHearts,
+ heartsMonero: SvgTBotHeartsMonero,
+ loading: SvgTBotLoading,
+ search: SvgTBotRadar,
+ }
+
+ const enterAnim = disableEnterAnimation
+ ? undefined
+ : useSpring({
+ from: { width: '0px', height: '0px' },
+ to: {
+ width: '100%',
+ height: '100%',
+ },
+ config: {
+ duration: 100,
+ },
+ })
+
+ const newTBotMessageAnimation = useSpring({
+ from: { transform: 'scale(1)' },
+ to: {
+ transform: animate ? 'scale(1.5)' : 'scale(1)',
+ transition: 'all ease-in-out',
+ },
+ config: {
+ duration: 300,
+ },
+ loop: {
+ transform: 'scale(1)',
+ },
+ })
+
+ const shadowDefinition: CSSShadowDefinition =
+ !shadow || shadow === true ? defaultShadow : { ...defaultShadow, ...shadow }
+
+ const TBotComponent = botVariants[type]
+
+ return (
+
+
+ {shadow && }
+
+
+
+ )
+}
+
+export default TBot
diff --git a/applications/launchpad/gui-react/src/components/TBot/styles.ts b/applications/launchpad/gui-react/src/components/TBot/styles.ts
new file mode 100644
index 0000000000..004d712066
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/styles.ts
@@ -0,0 +1,40 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+import { CSSShadowDefinition } from './types'
+
+const SHADOW_SIZE_SCALE = 0.6
+
+export const TBotContainer = styled(animated.div)<{
+ shadow?: CSSShadowDefinition
+}>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ ${({ shadow }) =>
+ shadow
+ ? `margin: ${
+ (shadow.spread +
+ shadow.blur -
+ shadow.size * (1 - SHADOW_SIZE_SCALE)) /
+ 2
+ }px 0;`
+ : ''}
+`
+
+export const TBotScaleContainer = styled(animated.div)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+`
+
+export const TBotShadow = styled.div<{ shadow: CSSShadowDefinition }>`
+ position: absolute;
+ width: ${({ shadow }) => shadow.size * SHADOW_SIZE_SCALE}px;
+ height: ${({ shadow }) => shadow.size * SHADOW_SIZE_SCALE}px;
+ box-shadow: ${({ shadow }) =>
+ `0px 0px ${shadow.blur}px ${shadow.spread}px ${shadow.color}`};
+ border-radius: 50%;
+ z-index: 0;
+`
diff --git a/applications/launchpad/gui-react/src/components/TBot/types.ts b/applications/launchpad/gui-react/src/components/TBot/types.ts
new file mode 100644
index 0000000000..2344e912d8
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TBot/types.ts
@@ -0,0 +1,29 @@
+import { CSSProperties } from 'styled-components'
+export type TBotType = 'base' | 'hearts' | 'heartsMonero' | 'loading' | 'search'
+
+/**
+ * @typedef ShadowDefinition
+ * @prop {string} [color] - color of the shadow
+ * @prop {number} [spread] - box-shadow spread value
+ * @prop {number} [blur] - box-shadow blur value
+ */
+export interface ShadowDefinition {
+ color?: string
+ spread?: number
+ blur?: number
+}
+
+export interface CSSShadowDefinition {
+ color: string
+ spread: number
+ blur: number
+ size: number
+}
+
+export interface TBotProps {
+ type?: TBotType
+ style?: CSSProperties
+ animate?: boolean
+ shadow?: boolean | ShadowDefinition
+ disableEnterAnimation?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/TabContent/TabContent.test.tsx b/applications/launchpad/gui-react/src/components/TabContent/TabContent.test.tsx
new file mode 100644
index 0000000000..4e13cc3882
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TabContent/TabContent.test.tsx
@@ -0,0 +1,20 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+import themes from '../../styles/themes'
+
+import TabContent from '../TabContent'
+
+afterEach(cleanup)
+
+describe('TabContent', () => {
+ it('should render the component without crashing', () => {
+ const testText = 'testing'
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText(testText)).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TabContent/index.tsx b/applications/launchpad/gui-react/src/components/TabContent/index.tsx
new file mode 100644
index 0000000000..10e80b7044
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TabContent/index.tsx
@@ -0,0 +1,35 @@
+import t from '../../locales'
+import Loading from '../Loading'
+import Tag from '../Tag'
+
+import { StyledTabContent, TabMainText, LoadingWrapper } from './styles'
+
+const TabContent = ({
+ text,
+ running,
+ pending,
+ tagSubText,
+}: {
+ text: string
+ running?: boolean
+ pending?: boolean
+ tagSubText?: string
+}) => {
+ return (
+
+ {text}
+ {running && !pending ? (
+
+ {t.common.adjectives.running}
+
+ ) : null}
+ {pending ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+export default TabContent
diff --git a/applications/launchpad/gui-react/src/components/TabContent/styles.ts b/applications/launchpad/gui-react/src/components/TabContent/styles.ts
new file mode 100644
index 0000000000..5b7b5e9dbe
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TabContent/styles.ts
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+
+export const StyledTabContent = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 4px;
+ column-gap: 8px;
+`
+
+export const TabMainText = styled.div`
+ margin-top: 4px;
+`
+
+export const LoadingWrapper = styled.div`
+ display: flex;
+ opacity: 0.5;
+ line-height: 16px;
+`
diff --git a/applications/launchpad/gui-react/src/components/Tabs/Tabs.test.tsx b/applications/launchpad/gui-react/src/components/Tabs/Tabs.test.tsx
new file mode 100644
index 0000000000..7583c74a98
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tabs/Tabs.test.tsx
@@ -0,0 +1,34 @@
+import { act, render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import themes from '../../styles/themes'
+import Tabs from './'
+
+const tabs = [
+ {
+ id: 'first-tab',
+ content: First tab,
+ },
+ {
+ id: 'second-tab',
+ content: Second tab,
+ },
+]
+
+describe('Tabs', () => {
+ it('should render without crashing', async () => {
+ const selected = 'second-tab'
+ const onSelect = jest.fn()
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const firstTabText = screen.queryAllByText('First tab')
+ expect(firstTabText.length).toBeGreaterThan(0)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Tabs/index.tsx b/applications/launchpad/gui-react/src/components/Tabs/index.tsx
new file mode 100644
index 0000000000..b11a591fd1
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tabs/index.tsx
@@ -0,0 +1,129 @@
+import { useEffect, useRef, useState } from 'react'
+import { useSpring } from 'react-spring'
+import { useTheme } from 'styled-components'
+
+import Text from '../Text'
+
+import {
+ TabsContainer,
+ Tab,
+ TabOptions,
+ TabContent,
+ TabSelectedBorder,
+ FontWeightCompensation,
+} from './styles'
+
+import { TabsProps } from './types'
+
+/**
+ * Tabs component renders the set of tab header tiles.
+ *
+ * @param {TabsProps} props - props of the Tabs component
+ *
+ * @typedef TabsProps
+ * @param {TabProp[]} tabs - the list of tabs.
+ * @param {string} selected - the id of the selected tab. It has to match the `id` prop of the tab.
+ * @param {(val: string) => void} onSelect - on tab click.
+ *
+ * @typedef TabProp
+ * @param {string} id - unique identifier of the tab
+ * @param {ReactNode} content - the tab header content
+ */
+const Tabs = ({ tabs, selected, onSelect, inverted }: TabsProps) => {
+ const tabsRefs = useRef<(HTMLButtonElement | null)[]>([])
+
+ // The animation of the bottom 'border' that indicates the selected tab,
+ // is based on sizes of the rendered tabs. It means, that the componenets
+ // have to be rendered first, then the parent component can read widths,
+ // and finally the size and shift can ba calculated.
+ // Also, the Tabs component needs to re-render tabs twice on the initial mount,
+ // because the selected tab uses bold font, which changes tabs widths.
+ const [initialized, setInitialzed] = useState(0)
+ const theme = useTheme()
+
+ useEffect(() => {
+ tabsRefs.current = tabsRefs.current.slice(0, tabs.length)
+ setInitialzed(1)
+ }, [tabs])
+
+ useEffect(() => {
+ if (initialized < 2) {
+ setInitialzed(initialized + 1)
+ }
+ }, [initialized])
+
+ const selectedIndex = tabs.findIndex(t => t.id === selected)
+ let width = 0
+ let left = 0
+ let totalWidth = 0
+ const tabMargin = theme.tabsMarginRight
+
+ if (selectedIndex > -1) {
+ if (
+ tabsRefs &&
+ tabsRefs.current &&
+ tabsRefs.current.length > selectedIndex
+ ) {
+ tabsRefs.current.forEach((el, index) => {
+ if (el) {
+ if (index < selectedIndex) {
+ left = left + el.offsetWidth + tabMargin
+ } else if (index === selectedIndex) {
+ width = el.offsetWidth
+ }
+ totalWidth = totalWidth + el.offsetWidth
+ }
+ })
+ }
+ }
+
+ const activeBorder = useSpring({
+ to: { left: left, width: width },
+ config: { duration: 100 },
+ })
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+ (tabsRefs.current[index] = el)}
+ onClick={() => onSelect(tab.id)}
+ selected={selected}
+ tab={tab}
+ $inverted={inverted}
+ >
+
+
+ {tab.content}
+
+
+
+
+ {tab.content}
+
+
+
+ ))}
+
+
+
+ )
+}
+
+export default Tabs
diff --git a/applications/launchpad/gui-react/src/components/Tabs/styles.ts b/applications/launchpad/gui-react/src/components/Tabs/styles.ts
new file mode 100644
index 0000000000..806e8e150f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tabs/styles.ts
@@ -0,0 +1,85 @@
+/* eslint-disable indent */
+import { TabProp } from './types'
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const TabsContainer = styled.div`
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ position: relative;
+ white-space: no-wrap;
+`
+
+export const TabOptions = styled.div`
+ display: flex;
+ align-items: center;
+`
+
+export const Tab = styled.button<{
+ $inverted?: boolean
+ selected?: string
+ tab?: TabProp
+}>`
+ display: flex;
+ padding: 8px 12px;
+ box-shadow: none;
+ border-width: 0px;
+ border-bottom: 4px solid transparent;
+ border-radius: ${({ theme }) => theme.tightBorderRadius(1.5)};
+ border-bottom-left-radius: ${({ theme, selected, tab }) =>
+ selected === tab?.id ? 0 : theme.tightBorderRadius(1.5)};
+ border-bottom-right-radius: ${({ theme, selected, tab }) =>
+ selected === tab?.id ? 0 : theme.tightBorderRadius(1.5)};
+ background: transparent;
+ box-sizing: border-box;
+ margin: 0px;
+ margin-right: ${({ theme }) => `${theme.tabsMarginRight}`}px;
+ position: relative;
+ cursor: pointer;
+ align-items: center;
+ transition: ease-in-out 300ms;
+ color: ${({ theme }) => theme.primary};
+ &:hover {
+ background-color: ${({ theme, $inverted }) =>
+ $inverted
+ ? theme.inverted.backgroundSecondary
+ : theme.backgroundSecondary};
+ }
+ &:last-child {
+ margin-right: 0;
+ }
+`
+
+export const TabSelectedBorder = styled(animated.div)<{ $inverted?: boolean }>`
+ position: absolute;
+ height: 4px;
+ border-radius: 2px;
+ background: ${({ theme, $inverted }) =>
+ $inverted ? theme.inverted.accentSecondary : theme.accent};
+ bottom: 0;
+`
+
+export const FontWeightCompensation = styled.div`
+ visibility: hidden;
+
+ & > p {
+ margin: 0;
+ }
+`
+
+export const TabContent = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ padding: 12px;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+
+ & > p {
+ margin: 0;
+ }
+`
diff --git a/applications/launchpad/gui-react/src/components/Tabs/types.ts b/applications/launchpad/gui-react/src/components/Tabs/types.ts
new file mode 100644
index 0000000000..607c239dfd
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tabs/types.ts
@@ -0,0 +1,13 @@
+import { ReactNode } from 'react'
+
+export interface TabProp {
+ id: string
+ content: ReactNode
+}
+
+export interface TabsProps {
+ tabs: TabProp[]
+ selected: string
+ onSelect: (id: string) => void
+ inverted?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Tag/Tag.test.tsx b/applications/launchpad/gui-react/src/components/Tag/Tag.test.tsx
new file mode 100644
index 0000000000..0fe0501dfc
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tag/Tag.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Tag from './'
+import SVGCheck from '../../styles/Icons/Check'
+
+import themes from '../../styles/themes'
+import styles from '../../styles/styles'
+import lightTheme from '../../styles/themes/light'
+
+describe('Tag', () => {
+ it('should render Tag component without crashing', () => {
+ render(
+
+ }>Testing
+ ,
+ )
+
+ const el = screen.getByText('Testing')
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render the correct tag variant', () => {
+ render(
+
+ } variant='large'>
+ Testing
+
+ ,
+ )
+
+ const largeFontSize = styles.typography.smallHeavy.fontSize
+
+ const el = screen.getByTestId('tag-component')
+
+ expect(el).toHaveStyle(`fontSize: ${largeFontSize}`)
+ })
+
+ it('should render the info tag type with style read from the theme', () => {
+ render(
+
+ } type='warning'>
+ Testing
+
+ ,
+ )
+
+ const warningStyle = lightTheme.warning
+
+ const el = screen.getByTestId('tag-component')
+ expect(el).toHaveStyle(`backgroundColor: ${warningStyle}`)
+ })
+
+ it('should render the running tag type with style read from the theme', () => {
+ render(
+
+ } type='running'>
+ Testing
+
+ ,
+ )
+
+ const runningStyle = lightTheme.on
+
+ const el = screen.getByTestId('tag-component')
+ expect(el).toHaveStyle(`backgroundColor: ${runningStyle}`)
+ })
+
+ it('should render the expert tag type with style read from the theme', () => {
+ render(
+
+ } type='expert'>
+ Testing
+
+ ,
+ )
+
+ const expertStyle = lightTheme.expert
+
+ const el = screen.getByTestId('tag-component')
+ expect(el).toHaveStyle(`backgroundColor: ${expertStyle}`)
+ })
+
+ it('should render optional subtext', () => {
+ const text = 'hello world'
+ render(
+
+ Testing
+ ,
+ )
+
+ const el = screen.getByText(text)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render optional additional styles from props', () => {
+ render(
+
+ } style={{ backgroundColor: 'red' }}>
+ Testing
+
+ ,
+ )
+
+ const el = screen.getByTestId('tag-component')
+ expect(el).toHaveStyle('backgroundColor: red')
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Tag/index.tsx b/applications/launchpad/gui-react/src/components/Tag/index.tsx
new file mode 100644
index 0000000000..65391c1780
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tag/index.tsx
@@ -0,0 +1,151 @@
+import { CSSProperties, useTheme } from 'styled-components'
+
+import Text from '../Text'
+
+import { TagProps } from './types'
+
+import { TagContainer, IconWrapper } from './styles'
+
+/**
+ * Tag component
+ *
+ * @prop {ReactNode} [children] - text content to display
+ * @prop {CSSProperties} [style] - optional component styles
+ * @prop {'info' | 'running' | 'warning' | 'expert' | 'light'} [type] - tag types to determine color settings
+ * @prop {boolean} [expertSec] - specific usage of expert tag type
+ * @prop {ReactNode} [icon] - optional SVG icon
+ * @prop {ReactNode} [subText] - optional additional tag text
+ * @prop {boolean} [inverted] - optional prop indicating whether tag should be rendered in inverted coloring
+ * @prop {boolean} [dark] - special style case, e.g. dashboard running
+ * @prop {boolean} [darkAlt] - special style case, e.g. base node running
+ * @prop {boolean} [expertSec] - special style case for expert tag type
+ *
+ * @example
+ * } subText='Mainnet'>
+ * Running
+ * {
+ const theme = useTheme()
+
+ let baseStyle: CSSProperties = {}
+ let textStyle: CSSProperties = {}
+
+ let runningTagBackgroundColor
+ let runningTagTextColor
+
+ if (dark) {
+ runningTagBackgroundColor = theme.dashboardRunningTagBackground
+ runningTagTextColor = theme.dashboardRunningTagText
+ } else if (darkAlt) {
+ runningTagBackgroundColor = theme.baseNodeRunningTagBackground
+ runningTagTextColor = theme.baseNodeRunningTagText
+ } else {
+ runningTagBackgroundColor = theme.runningTagBackground
+ runningTagTextColor = theme.runningTagText
+ }
+
+ switch (type) {
+ case 'running':
+ baseStyle = {
+ backgroundColor: inverted
+ ? theme.transparent(theme.onText, 40)
+ : runningTagBackgroundColor,
+ }
+ textStyle = {
+ color: inverted ? theme.onTextLight : runningTagTextColor,
+ }
+ break
+ case 'warning':
+ baseStyle = {
+ backgroundColor: theme.warningTag,
+ }
+ textStyle = {
+ color: theme.warningText,
+ }
+ break
+ case 'expert':
+ baseStyle = {
+ backgroundColor: theme.expert,
+ }
+ if (expertSec) {
+ textStyle = {
+ color: theme.expertSecText,
+ }
+ } else {
+ textStyle = {
+ backgroundImage: theme.expertText,
+ WebkitBackgroundClip: 'text',
+ color: 'transparent',
+ }
+ }
+ break
+ case 'light':
+ baseStyle = {
+ backgroundColor: theme.lightTag,
+ }
+ textStyle = {
+ color: theme.lightTagText,
+ }
+ break
+ // info tag type is default
+ default:
+ baseStyle = {
+ backgroundColor: theme.infoTag,
+ }
+ textStyle = {
+ color: theme.infoText,
+ }
+ break
+ }
+
+ if (style) {
+ baseStyle = { ...baseStyle, ...style }
+ }
+
+ const tagContent = (
+ <>
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ {children}
+
+
+ {subText && (
+
+ {subText}
+
+ )}
+ >
+ )
+ return (
+
+ {tagContent}
+
+ )
+}
+
+export default Tag
diff --git a/applications/launchpad/gui-react/src/components/Tag/styles.ts b/applications/launchpad/gui-react/src/components/Tag/styles.ts
new file mode 100644
index 0000000000..06b712e63b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tag/styles.ts
@@ -0,0 +1,27 @@
+import styled, { CSSProperties } from 'styled-components'
+
+export const TagContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ border-radius: 64px;
+ height: 26px;
+ border: 0;
+ width: fit-content;
+ padding-left: 12px;
+ padding-right: 12px;
+`
+
+export const IconWrapper = styled.div<{
+ type?: string
+ textStyle?: CSSProperties
+}>`
+ display: flex;
+ align-items: center;
+ color: white;
+ height: 100%;
+ margin-right: 7.5px;
+ color: ${({ theme, type, textStyle }) =>
+ type === 'expert' ? theme.accent : textStyle?.color};
+`
diff --git a/applications/launchpad/gui-react/src/components/Tag/types.ts b/applications/launchpad/gui-react/src/components/Tag/types.ts
new file mode 100644
index 0000000000..fe009919a9
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Tag/types.ts
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react'
+import { CSSProperties } from 'styled-components'
+
+export type TagVariantType = 'small' | 'large'
+export type TagType = 'info' | 'running' | 'warning' | 'expert' | 'light'
+
+export interface TagProps {
+ children?: ReactNode
+ style?: CSSProperties
+ type?: TagType
+ variant?: TagVariantType
+ icon?: ReactNode
+ subText?: ReactNode
+ inverted?: boolean
+ dark?: boolean
+ darkAlt?: boolean
+ expertSec?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/Text/Text.test.tsx b/applications/launchpad/gui-react/src/components/Text/Text.test.tsx
new file mode 100644
index 0000000000..d956dfa336
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Text/Text.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react'
+import { ThemeProvider } from 'styled-components'
+
+import Text from './'
+
+import themes from '../../styles/themes'
+
+describe('Text', () => {
+ it('should render Text component without crashing', () => {
+ const testText = 'The test text'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const el = screen.getByText(testText)
+ expect(el).toBeInTheDocument()
+ })
+
+ it('should render DOM element of the given type', () => {
+ const testText = 'The test text'
+ const elType = 'span'
+ render(
+
+ {testText}
+ ,
+ )
+
+ const el = screen.getByTestId('text-cmp')
+ expect(el.tagName.toLowerCase()).toBe(elType)
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/Text/index.tsx b/applications/launchpad/gui-react/src/components/Text/index.tsx
new file mode 100644
index 0000000000..0fa77a642a
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Text/index.tsx
@@ -0,0 +1,46 @@
+import styles from '../../styles/styles'
+
+import { StyledText } from './styles'
+import { TextProps } from './types'
+
+/**
+ * @name Text
+ *
+ * @typedef TextProps
+ * @prop {'header' | 'subheader' | 'defaultHeavy' | 'defaultMedium' | 'defaultUnder' | 'smallHeavy' | 'smallMedium' | 'smallUnder' | 'microHeavy' | 'microRegular' | 'microOblique' } type - text styles
+ * @prop {ReactNode} children - text content to display
+ * @prop {string} [color] - font color
+ * @prop {CSSProperties} [style] - styles that will override default styling
+ *
+ * @example
+ * ...text goes here...
+ */
+
+const Text = ({
+ type = 'defaultMedium',
+ as = 'p',
+ color,
+ children,
+ style,
+ testId,
+ className,
+}: TextProps) => {
+ const textStyles = {
+ color,
+ ...styles.typography[type],
+ ...style,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default Text
diff --git a/applications/launchpad/gui-react/src/components/Text/styles.ts b/applications/launchpad/gui-react/src/components/Text/styles.ts
new file mode 100644
index 0000000000..24ee84ea9c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Text/styles.ts
@@ -0,0 +1,6 @@
+import styled from 'styled-components'
+
+export const StyledText = styled.p`
+ color: inherit;
+ margin: 0;
+`
diff --git a/applications/launchpad/gui-react/src/components/Text/types.ts b/applications/launchpad/gui-react/src/components/Text/types.ts
new file mode 100644
index 0000000000..7b09e41a15
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/Text/types.ts
@@ -0,0 +1,50 @@
+import { ReactNode, CSSProperties } from 'react'
+import { AnimatedComponent, SpringValue } from 'react-spring'
+
+/**
+ * @typedef TextProps
+ * @prop {'header' | 'subheader' | 'defaultHeavy' | 'defaultMedium' | 'defaultUnder' | 'smallHeavy' | 'smallMedium' | 'smallUnder' | 'microHeavy' | 'microRegular' | 'microOblique' } [type] - text styles
+ * @prop {ReactNode} children - text content to display
+ * @prop {string} [color] - font color
+ * @prop {CSSProperties} [style] - optional component styles
+ * @prop {'h1' | 'h2' | 'h3' | 'h4' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'label' | AnimatedComponent<'h1' | 'h2' | 'h3' | 'h4' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'label'> } [as] - prop controlling what component to use for text
+ */
+
+export type TextType =
+ | 'header'
+ | 'subheader'
+ | 'defaultHeavy'
+ | 'defaultMedium'
+ | 'defaultUnder'
+ | 'smallHeavy'
+ | 'smallMedium'
+ | 'smallUnder'
+ | 'microHeavy'
+ | 'microMedium'
+ | 'microRegular'
+ | 'microOblique'
+
+export interface TextProps {
+ type?: TextType
+ children: ReactNode
+ color?: string
+ style?:
+ | CSSProperties
+ | Record | SpringValue>
+ as?:
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'h4'
+ | 'h4'
+ | 'h5'
+ | 'h6'
+ | 'p'
+ | 'span'
+ | 'label'
+ | AnimatedComponent<
+ 'h1' | 'h2' | 'h3' | 'h4' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'label'
+ >
+ testId?: string
+ className?: string
+}
diff --git a/applications/launchpad/gui-react/src/components/TitleBar/TitleBar.test.tsx b/applications/launchpad/gui-react/src/components/TitleBar/TitleBar.test.tsx
new file mode 100644
index 0000000000..a7e202a827
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TitleBar/TitleBar.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { randomFillSync } from 'crypto'
+import { mockIPC, clearMocks } from '@tauri-apps/api/mocks'
+
+import TitleBar from '.'
+
+import { store } from '../../store'
+import { ThemeProvider } from 'styled-components'
+import themes from '../../styles/themes'
+
+beforeAll(() => {
+ window.crypto = {
+ // @ts-expect-error: ignore this
+ getRandomValues: function (buffer) {
+ // @ts-expect-error: ignore this
+ return randomFillSync(buffer)
+ },
+ }
+})
+
+afterEach(() => {
+ clearMocks()
+})
+
+describe('TitleBar', () => {
+ it('should render all required components', () => {
+ render(
+
+
+
+
+ ,
+ )
+
+ mockIPC(cmd => {
+ switch (cmd) {
+ case 'tauri':
+ return true
+ default:
+ break
+ }
+ return false
+ })
+
+ const closeWindowBtn = screen.getByTestId('close-window-btn')
+ expect(closeWindowBtn).toBeInTheDocument()
+
+ const minWindowBtn = screen.getByTestId('minimize-window-btn')
+ expect(minWindowBtn).toBeInTheDocument()
+
+ const maxWindowBtn = screen.getByTestId('maximize-window-btn')
+ expect(maxWindowBtn).toBeInTheDocument()
+
+ const expertViewBtn = screen.getByTestId('titlebar-expert-view-btn')
+ expect(expertViewBtn).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/components/TitleBar/index.tsx b/applications/launchpad/gui-react/src/components/TitleBar/index.tsx
new file mode 100644
index 0000000000..94bb82d222
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TitleBar/index.tsx
@@ -0,0 +1,245 @@
+import { useTheme } from 'styled-components'
+import { animated, useSpring } from 'react-spring'
+import { appWindow } from '@tauri-apps/api/window'
+
+import Button from '../Button'
+import Logo from '../Logo'
+import Switch from '../Switch'
+
+import { useAppSelector, useAppDispatch } from '../../store/hooks'
+import {
+ selectExpertSwitchDisabled,
+ selectExpertView,
+} from '../../store/app/selectors'
+import { setExpertView } from '../../store/app'
+import { actions as settingsActions } from '../../store/settings'
+
+import SvgCloseCross from '../../styles/Icons/CloseCross'
+import SvgSetting from '../../styles/Icons/Setting2'
+
+import ExpertViewUtils from '../../utils/ExpertViewUtils'
+import t from '../../locales'
+
+import {
+ LeftCol,
+ LogoContainer,
+ TitleBar as StyledTitleBar,
+ TitleBarButton,
+ WindowButtons,
+} from './styles'
+import { TitleBarProps } from './types'
+import { useMemo } from 'react'
+
+const TitleBar = ({
+ drawerViewWidth = '50%',
+ hideSettingsButton = false,
+}: TitleBarProps) => {
+ const dispatch = useAppDispatch()
+
+ const expertView = useAppSelector(selectExpertView)
+ const expertSwitchDisabled = useAppSelector(selectExpertSwitchDisabled)
+ const theme = useTheme()
+
+ const [expertViewSize] = ExpertViewUtils.convertExpertViewModeToValue(
+ expertView,
+ drawerViewWidth,
+ )
+
+ const drawerContainerStyle = ExpertViewUtils.useDrawerAnim(expertViewSize)
+
+ const logoColorAnim = useSpring({
+ color: expertView === 'fullscreen' ? theme.background : theme.primary,
+ })
+
+ const buttonIconStyle = { width: 12, height: 12 }
+
+ const titleBarBgAnim = useSpring({
+ background: theme.backgroundSecondary,
+ })
+
+ const onMinimize = () => {
+ appWindow.minimize()
+ }
+
+ const onMaximize = () => {
+ appWindow.maximize()
+ }
+
+ const onClose = () => {
+ appWindow.close()
+ }
+
+ const onExpertViewClick = () => {
+ if (expertSwitchDisabled) {
+ return
+ } else {
+ if (expertView !== 'hidden') {
+ dispatch(setExpertView('hidden'))
+ } else {
+ dispatch(setExpertView('open'))
+ }
+ }
+ }
+
+ const settingsIconColor = useMemo(() => {
+ if (expertView !== 'hidden') {
+ return theme.textSecondary
+ } else {
+ return theme.helpTipText
+ }
+ }, [theme, expertView])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!hideSettingsButton && (
+ }
+ leftIconColor={settingsIconColor}
+ onClick={() => dispatch(settingsActions.open({}))}
+ style={{
+ color:
+ expertView === 'hidden'
+ ? theme.primary
+ : theme.inverted.primary,
+ }}
+ >
+ {t.common.nouns.settings}
+
+ )}
+
+
+
+
+ )
+}
+
+export default TitleBar
diff --git a/applications/launchpad/gui-react/src/components/TitleBar/styles.ts b/applications/launchpad/gui-react/src/components/TitleBar/styles.ts
new file mode 100644
index 0000000000..91b6f2280b
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TitleBar/styles.ts
@@ -0,0 +1,73 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const TITLE_BAR_HEIGHT = 60
+
+export const TitleBar = styled(animated.header)`
+ height: ${TITLE_BAR_HEIGHT}px;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: fixed;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+`
+
+export const LeftCol = styled.div`
+ flex: 1;
+ display: flex;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ justifycontent: space-between;
+ align-items: center;
+ padding-left: 16px;
+ padding-right: 16px;
+`
+
+export const RightCol = styled.div``
+
+export const WindowButtons = styled.div`
+ display: flex;
+ align-items: center;
+`
+
+export const TitleBarButton = styled.button<{
+ borderColor: string
+ background: string
+}>`
+ margin: 0px;
+ padding: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ box-shadow: none;
+ border-width: 1px;
+ border-style: solid;
+ background: ${({ background }) => background};
+ border-color: ${({ borderColor }) => borderColor};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 4px;
+ margin-left: 4px;
+ cursor: pointer;
+
+ ${WindowButtons}:hover & {
+ svg {
+ opacity: 1 !important;
+ }
+ }
+`
+
+export const LogoContainer = styled(animated.div)`
+ padding: 4px 32px;
+`
diff --git a/applications/launchpad/gui-react/src/components/TitleBar/types.ts b/applications/launchpad/gui-react/src/components/TitleBar/types.ts
new file mode 100644
index 0000000000..f0ec85df02
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TitleBar/types.ts
@@ -0,0 +1,4 @@
+export interface TitleBarProps {
+ drawerViewWidth?: string
+ hideSettingsButton?: boolean
+}
diff --git a/applications/launchpad/gui-react/src/components/TransactionsList/index.tsx b/applications/launchpad/gui-react/src/components/TransactionsList/index.tsx
new file mode 100644
index 0000000000..d76909c9ef
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TransactionsList/index.tsx
@@ -0,0 +1,201 @@
+import Text from '../Text'
+
+import t from '../../locales'
+
+import {
+ DirectionTag,
+ StyledAddress,
+ StyledTable,
+ AmountTd,
+ DateTd,
+ DirectionTd,
+ EventTd,
+ StatusTd,
+ EmojiWrapper,
+} from './styles'
+import { TransactionsListProps } from './types'
+import { TransactionDBRecord } from '../../persistence/transactionsRepository'
+import SvgArrowLeft from '../../styles/Icons/ArrowLeft'
+import Tag from '../Tag'
+import { convertU8ToString, toT } from '../../utils/Format'
+
+const trimAddress = (address: string, start = 4, end = 4) => {
+ return (
+ address.substring(0, start) +
+ '...' +
+ address.substring(address.length - end, address.length)
+ )
+}
+
+const renderStatus = (record: TransactionDBRecord) => {
+ if (record.event === 'cancelled') {
+ return {t.common.adjectives.cancelled}
+ }
+
+ if (record.event !== 'mined') {
+ return {t.common.adjectives.processing}
+ }
+
+ return null
+}
+
+const addNth = (day: number) => {
+ const dString = String(day)
+ const last = +dString.slice(-2)
+ if (last > 3 && last < 21) return 'th'
+ switch (last % 10) {
+ case 1:
+ return 'st'
+ case 2:
+ return 'nd'
+ case 3:
+ return 'rd'
+ default:
+ return 'th'
+ }
+}
+
+const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr)
+ const options = { day: 'numeric', month: 'short' } as const
+ const localeDate = date.toLocaleDateString(undefined, options)
+ const splt = localeDate.split(' ')
+ return splt[0] + addNth(date.getDate()) + ' ' + splt[1]
+}
+
+const InboundTxRow = ({
+ record,
+ inverted,
+}: {
+ record: TransactionDBRecord
+ inverted: boolean
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ {t.wallet.transactions.youReceivedTariFrom}{' '}
+
+ {trimAddress(convertU8ToString(record.source))}
+
+
+
+ {renderStatus(record)}
+
+
+ {formatDate(record.receivedAt.toString())}
+
+
+
+
+ {parseFloat(toT(record.amount).toString()).toFixed(2)}{' '}
+
+
+ XTR
+
+
+
+ )
+}
+
+const OutboundTxRow = ({
+ record,
+ inverted,
+}: {
+ record: TransactionDBRecord
+ inverted: boolean
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ {t.wallet.transactions.youSentTariTo}{' '}
+
+ {trimAddress(convertU8ToString(record.destination))}
+
+
+
+ {renderStatus(record)}
+
+
+ {formatDate(record.receivedAt.toString())}
+
+
+
+
+ -{parseFloat(toT(record.amount).toString()).toFixed(2)}{' '}
+
+
+ XTR
+
+
+
+ )
+}
+
+const MiningTxRow = ({
+ record,
+ inverted,
+}: {
+ record: TransactionDBRecord
+ inverted: boolean
+}) => {
+ return (
+
+
+
+ {'\u26CF'}
+
+
+
+ {t.wallet.transactions.youEarnedTari}
+
+ {renderStatus(record)}
+
+
+ {formatDate(record.receivedAt.toString())}
+
+
+
+
+ {parseFloat(toT(record.amount).toString()).toFixed(2)}{' '}
+
+
+ XTR
+
+
+
+ )
+}
+
+const TransactionsList = ({ records, inverted }: TransactionsListProps) => {
+ return (
+
+
+ {records.map((row, idx) => {
+ if (row.isCoinbase === 'true') {
+ return
+ }
+
+ if (row.direction === 'Outbound') {
+ return
+ }
+
+ return
+ })}
+
+
+ )
+}
+
+export default TransactionsList
diff --git a/applications/launchpad/gui-react/src/components/TransactionsList/styles.ts b/applications/launchpad/gui-react/src/components/TransactionsList/styles.ts
new file mode 100644
index 0000000000..4176a3addb
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TransactionsList/styles.ts
@@ -0,0 +1,93 @@
+import styled from 'styled-components'
+
+export const DirectionTag = styled.span<{ $variant: 'earned' | 'out' }>`
+ background: ${({ $variant, theme }) =>
+ $variant === 'earned' ? theme.on : theme.warning};
+ color: ${({ $variant, theme }) =>
+ $variant === 'earned' ? theme.onText : theme.warningText};
+ border-radius: ${({ theme }) => theme.borderRadius(0.5)};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: ${({ theme }) => theme.spacing(1)};
+ width: ${({ theme }) => theme.spacing(1)};
+`
+
+export const EmojiWrapper = styled.span`
+ font-size: 12px;
+`
+
+export const StyledAddress = styled.span`
+ text-decoration: underline;
+ color: ${({ theme }) => theme.accentDark};
+`
+
+export const StyledTable = styled.table`
+ width: 100%;
+ border-collapse: collapse;
+
+ & tr {
+ border-bottom: 1px solid ${({ theme }) => theme.borderColor};
+
+ &:first-child {
+ border-top: 1px solid ${({ theme }) => theme.borderColor};
+ }
+
+ & td {
+ padding: ${({ theme }) =>
+ `${theme.spacingVertical(1.23)} ${theme.spacingHorizontal(0.333)}`};
+ }
+ }
+`
+
+export const AmountTd = styled.td<{
+ $variant: 'earned' | 'out'
+ $inverted: boolean
+}>`
+ text-align: right;
+ max-width: ${({ theme }) => theme.spacingHorizontal(4)};
+ box-sizing: border-box;
+
+ & > * {
+ color: ${({ $variant, theme, $inverted }) =>
+ $variant === 'earned'
+ ? $inverted
+ ? theme.accent
+ : theme.onTextLight
+ : $inverted
+ ? theme.primary
+ : theme.secondary};
+ }
+`
+
+export const DateTd = styled.td`
+ max-width: ${({ theme }) => theme.spacingHorizontal(3)};
+ box-sizing: border-box;
+ text-align: center;
+
+ & > span {
+ color: ${({ theme }) => theme.secondary};
+ }
+`
+
+export const DirectionTd = styled.td`
+ max-width: ${({ theme }) => theme.spacingHorizontal()};
+ box-sizing: border-box;
+`
+
+export const EventTd = styled.td<{ $inverted: boolean }>`
+ padding-top: ${({ theme }) => theme.spacingVertical(1.6)} !important;
+ & > span {
+ line-height: 20px !important;
+ }
+ color: ${({ theme, $inverted }) => ($inverted ? theme.secondary : '')};
+`
+
+export const StatusTd = styled.td`
+ max-width: ${({ theme }) => theme.spacingHorizontal(3)};
+ box-sizing: border-box;
+
+ & > div {
+ margin: auto;
+ }
+`
diff --git a/applications/launchpad/gui-react/src/components/TransactionsList/types.ts b/applications/launchpad/gui-react/src/components/TransactionsList/types.ts
new file mode 100644
index 0000000000..b1aa364726
--- /dev/null
+++ b/applications/launchpad/gui-react/src/components/TransactionsList/types.ts
@@ -0,0 +1,6 @@
+import { TransactionDBRecord } from '../../persistence/transactionsRepository'
+
+export interface TransactionsListProps {
+ records: TransactionDBRecord[]
+ inverted: boolean
+}
diff --git a/applications/launchpad/gui-react/src/config/app.ts b/applications/launchpad/gui-react/src/config/app.ts
new file mode 100644
index 0000000000..ce03c2815f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/app.ts
@@ -0,0 +1,6 @@
+export default {
+ // If user dismisses downloading latest Docker image via TBot,
+ // after what time we can ask user again? [ms]
+ dockerDownloadDismissValidFor: 30 * 1000, // 30 sec
+ dockerNewImagesCheckInterval: 600000, // [ms] // @TODO - change to 60000 - using larger until updated field is not fixed
+}
diff --git a/applications/launchpad/gui-react/src/config/helpMessagesConfig.ts b/applications/launchpad/gui-react/src/config/helpMessagesConfig.ts
new file mode 100644
index 0000000000..0e177b03c0
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/helpMessagesConfig.ts
@@ -0,0 +1,98 @@
+import { ReactNode } from 'react'
+
+import { Message1 } from '../components/TBot/HelpComponents/CryptoMining'
+import {
+ Message1 as Merged1,
+ Message2 as Merged2,
+} from '../components/TBot/HelpComponents/MergedMining'
+import {
+ HowWalletWorks,
+ WhyBalanceDiffers,
+ NoteAboutVerificationPeriod,
+ TariWalletIdHelp,
+ TransactionFee,
+} from '../components/TBot/HelpComponents/Wallet'
+import {
+ WhatIsBaseNode,
+ ConnectAurora,
+} from '../components/TBot/HelpComponents/BaseNode'
+import { TBotMessage } from '../components/TBot/TBotPrompt/types'
+import { TBotMessages } from '../store/tbot/types'
+import {
+ DockerImageDownloadSuccess,
+ DockerImageDownloadError,
+ DownloadDockerImage,
+ NewDockerImageToDownload,
+} from '../components/TBot/HelpComponents/DockerComponents'
+import {
+ LooksLikeYoureOffline,
+ ReconnectToInternet,
+} from '../components/TBot/HelpComponents/OnlineCheck'
+
+const MessagesConfig = {
+ [TBotMessages.CryptoMiningHelp]: ['cryptoHelpMessage1'],
+ [TBotMessages.MergedMiningHelp]: ['mergedHelpMessage1', 'mergedHelpMessage2'],
+ [TBotMessages.WalletHelp]: ['walletHelpMessage'],
+ [TBotMessages.WalletIdHelp]: ['walletIdHelpMessage'],
+ [TBotMessages.WalletBalanceHelp]: [
+ 'whyBalanceDiffers',
+ 'noteAboutVerificationPeriod',
+ ],
+ [TBotMessages.BaseNodeHelp]: ['whatIsBaseNode'],
+ [TBotMessages.ConnectAurora]: ['connectAurora'],
+ [TBotMessages.TransactionFee]: ['transactionFee'],
+ [TBotMessages.NewDockerImageToDownload]: [
+ 'newDockerImageToDownload',
+ 'downloadDockerImage',
+ ],
+ [TBotMessages.DockerImageDownloadSuccess]: ['dockerImageDownloadSuccess'],
+ [TBotMessages.DockerImageDownloadError]: ['dockerImageDownloadError'],
+ [TBotMessages.OnlineCheck]: ['youreOffline', 'reconnect'],
+}
+
+export const HelpMessagesMap: {
+ [key: string]: string | ReactNode | TBotMessage
+} = {
+ cryptoHelpMessage1: {
+ content: Message1,
+ },
+ mergedHelpMessage1: Merged1,
+ mergedHelpMessage2: {
+ content: Merged2,
+ },
+ walletHelpMessage: HowWalletWorks,
+ walletIdHelpMessage: TariWalletIdHelp,
+ whyBalanceDiffers: WhyBalanceDiffers,
+ noteAboutVerificationPeriod: {
+ content: NoteAboutVerificationPeriod,
+ },
+ whatIsBaseNode: {
+ content: WhatIsBaseNode,
+ },
+ connectAurora: {
+ content: ConnectAurora,
+ },
+ transactionFee: {
+ content: TransactionFee,
+ },
+ newDockerImageToDownload: {
+ content: NewDockerImageToDownload,
+ },
+ downloadDockerImage: {
+ content: DownloadDockerImage,
+ },
+ dockerImageDownloadSuccess: {
+ content: DockerImageDownloadSuccess,
+ },
+ dockerImageDownloadError: {
+ content: DockerImageDownloadError,
+ },
+ youreOffline: {
+ content: LooksLikeYoureOffline,
+ },
+ reconnect: {
+ content: ReconnectToInternet,
+ },
+}
+
+export default MessagesConfig
diff --git a/applications/launchpad/gui-react/src/config/links.ts b/applications/launchpad/gui-react/src/config/links.ts
new file mode 100644
index 0000000000..a50f20fd1e
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/links.ts
@@ -0,0 +1,3 @@
+export default {
+ discord: 'https://discord.gg/q3Sfzb8S2V',
+}
diff --git a/applications/launchpad/gui-react/src/config/mining.ts b/applications/launchpad/gui-react/src/config/mining.ts
new file mode 100644
index 0000000000..895d9c0211
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/mining.ts
@@ -0,0 +1,8 @@
+export default {
+ maxThreads: 16,
+ maxMoneroUrls: 12,
+ defaultMoneroUrls: [
+ 'http://stagenet.community.xmr.to:38081',
+ 'http://monero-stagenet.exan.tech:38081',
+ ],
+}
diff --git a/applications/launchpad/gui-react/src/config/onboardingMessagesConfig.tsx b/applications/launchpad/gui-react/src/config/onboardingMessagesConfig.tsx
new file mode 100644
index 0000000000..9809a7b630
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/onboardingMessagesConfig.tsx
@@ -0,0 +1,77 @@
+import { TBotMessage } from '../components/TBot/TBotPrompt/types'
+import introMessages from '../components/Onboarding/OnboardingMessages/IntroMessages'
+import dockerInstallMessages, {
+ DockerInstallDocs,
+} from '../components/Onboarding/OnboardingMessages/DockerInstallMessages'
+import {
+ DownloadImagesMessage,
+ DownloadImagesErrorMessage,
+} from '../components/Onboarding/OnboardingMessages/DockerImagesMessages'
+import { BlockchainSyncStep } from '../components/Onboarding/OnboardingMessages/LastStepsMessages'
+
+export const OnboardingMessagesIntro: TBotMessage[] = [
+ {
+ content: introMessages[0],
+ barFill: 0.0625,
+ wait: 3000,
+ },
+ {
+ content: introMessages[1],
+ barFill: 0.125,
+ wait: 5000,
+ },
+ {
+ content: introMessages[2],
+ barFill: 0.188,
+ wait: 12000,
+ },
+ {
+ content: introMessages[3],
+ barFill: 0.25,
+ wait: 6000,
+ noSkip: true,
+ },
+]
+
+export const OnboardingMessagesDockerInstall: (
+ onDone: () => void,
+) => TBotMessage[] = (onDone: () => void) => [
+ {
+ content: dockerInstallMessages[0],
+ barFill: 0.3,
+ wait: 6000,
+ },
+ {
+ content: dockerInstallMessages[1],
+ barFill: 0.35,
+ wait: 7000,
+ },
+ {
+ content: dockerInstallMessages[2],
+ barFill: 0.4,
+ wait: 8000,
+ },
+ {
+ content: dockerInstallMessages[3],
+ barFill: 0.45,
+ wait: 10000,
+ },
+ {
+ content: ,
+ barFill: 0.5,
+ wait: 3000,
+ noSkip: true,
+ },
+]
+
+export const OnboardingMessagesDockerInstallAfter: TBotMessage[] = [
+ {
+ content: dockerInstallMessages[4],
+ barFill: 0.5,
+ wait: 3000,
+ noSkip: true,
+ },
+]
+
+export { BlockchainSyncStep }
+export { DownloadImagesMessage, DownloadImagesErrorMessage }
diff --git a/applications/launchpad/gui-react/src/config/wallet.ts b/applications/launchpad/gui-react/src/config/wallet.ts
new file mode 100644
index 0000000000..4e765f9b49
--- /dev/null
+++ b/applications/launchpad/gui-react/src/config/wallet.ts
@@ -0,0 +1,4 @@
+export default {
+ defaultFeePerGram: 1,
+ defaultFee: 1800,
+}
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNode.tsx b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNode.tsx
new file mode 100644
index 0000000000..1570ac9277
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNode.tsx
@@ -0,0 +1,178 @@
+import { useTheme } from 'styled-components'
+
+import Select from '../../components/Select'
+import Text from '../../components/Text'
+import Box from '../../components/Box'
+import Button from '../../components/Button'
+import Tag from '../../components/Tag'
+import t from '../../locales'
+
+import { BaseNodeProps, Network } from './types'
+import { networkOptions } from './constants'
+import SvgSetting2 from '../../styles/Icons/Setting2'
+import { useAppDispatch } from '../../store/hooks'
+import { actions as settingsActions } from '../../store/settings'
+import { Settings } from '../../store/settings/types'
+import BaseNodeQRModal from '../BaseNodeQRModal'
+import { useMemo, useState } from 'react'
+
+const BaseNode = ({
+ running,
+ pending,
+ startNode,
+ stopNode,
+ tariNetwork,
+ setTariNetwork,
+}: BaseNodeProps) => {
+ const theme = useTheme()
+ const dispatch = useAppDispatch()
+
+ const [openQRModal, setOpenQRModal] = useState(false)
+
+ const selectPausedStyleOverrides = useMemo(
+ () => ({
+ value: {
+ borderColor: () => theme.selectBorderColor,
+ },
+ label: {
+ color: theme.nodeSubHeading,
+ },
+ }),
+ [theme],
+ )
+
+ const selectRunningStyleOverrides = useMemo(
+ () => ({
+ value: {
+ color: theme.baseNodeRunningLabel,
+ borderColor: () => theme.textSecondary,
+ },
+ label: {
+ color: theme.baseNodeRunningLabel,
+ },
+ }),
+ [theme],
+ )
+
+ return (
+ <>
+
+
+ {t.baseNode.title}
+ {running && (
+
+ {t.common.adjectives.running}
+
+ )}
+
+
+
+ {!running && (
+
+ )}
+ {running && (
+
+ )}
+
+
+
+ {t.common.adjectives.recommended}
+
+ {' '}
+ {t.baseNode.aurora.withBaseNode}
+
+
+ {t.baseNode.aurora.description}
+
+
+ setOpenQRModal(false)}
+ />
+
+ }
+ style={{
+ paddingLeft: 0,
+ color: theme.helpTipText,
+ }}
+ onClick={() =>
+ dispatch(settingsActions.open({ toOpen: Settings.BaseNode }))
+ }
+ >
+ {t.baseNode.viewActions.baseNodeSettings}
+
+
+ >
+ )
+}
+
+export default BaseNode
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNodeHelp.tsx b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNodeHelp.tsx
new file mode 100644
index 0000000000..22a75b9adb
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/BaseNodeHelp.tsx
@@ -0,0 +1,19 @@
+import HelpTip from '../../components/HelpTip'
+import t from '../../locales'
+import { useAppDispatch } from '../../store/hooks'
+import { tbotactions } from '../../store/tbot'
+import MessagesConfig from '../../config/helpMessagesConfig'
+
+const BaseNodeHelp = () => {
+ const dispatch = useAppDispatch()
+
+ return (
+ dispatch(tbotactions.push(MessagesConfig.BaseNodeHelp))}
+ header
+ />
+ )
+}
+
+export default BaseNodeHelp
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeContainer/constants.ts b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/constants.ts
new file mode 100644
index 0000000000..610e06d23f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/constants.ts
@@ -0,0 +1,7 @@
+export const networks = ['dibbler', 'testnet']
+
+export const networkOptions = networks.map(network => ({
+ label: network,
+ value: network,
+ key: network,
+}))
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeContainer/index.tsx b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/index.tsx
new file mode 100644
index 0000000000..05bb727af3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/index.tsx
@@ -0,0 +1,69 @@
+import { useState } from 'react'
+
+import { useAppSelector, useAppDispatch } from '../../store/hooks'
+import {
+ selectPending,
+ selectRunning,
+ selectNetwork,
+} from '../../store/baseNode/selectors'
+import { actions } from '../../store/baseNode'
+import Alert from '../../components/Alert'
+import CenteredLayout from '../../components/CenteredLayout'
+import t from '../../locales'
+
+import BaseNode from './BaseNode'
+import BaseNodeHelp from './BaseNodeHelp'
+import { Network } from './types'
+
+const BaseNodeContainer = () => {
+ const [error, setError] = useState('')
+
+ const network = useAppSelector(selectNetwork) as Network
+ const pending = useAppSelector(selectPending)
+ const running = useAppSelector(selectRunning)
+ const dispatch = useAppDispatch()
+
+ const startNode = async () => {
+ try {
+ await dispatch(actions.startNode()).unwrap()
+ } catch (e) {
+ setError(t.baseNode.errors.start)
+ }
+ }
+
+ const stopNode = async () => {
+ try {
+ await dispatch(actions.stopNode()).unwrap()
+ } catch (e) {
+ setError(t.baseNode.errors.stop)
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+ dispatch(actions.setTariNetwork(network))
+ }
+ />
+
+
+ setError('')}
+ content={error}
+ />
+ >
+ )
+}
+
+export default BaseNodeContainer
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeContainer/types.ts b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/types.ts
new file mode 100644
index 0000000000..fec1612680
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeContainer/types.ts
@@ -0,0 +1,10 @@
+export type Network = 'dibbler' | 'testnet'
+
+export interface BaseNodeProps {
+ startNode: () => void
+ stopNode: () => void
+ tariNetwork: Network
+ setTariNetwork: (selected: Network) => void
+ running: boolean
+ pending: boolean
+}
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/BaseNodeQRModal.test.tsx b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/BaseNodeQRModal.test.tsx
new file mode 100644
index 0000000000..dff265ee66
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/BaseNodeQRModal.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { configureStore } from '@reduxjs/toolkit'
+import { ThemeProvider } from 'styled-components'
+
+import { baseNodeAllState } from '../../../__tests__/mocks/states'
+
+import themes from '../../styles/themes'
+
+import { rootReducer } from '../../store'
+
+import BaseNodeQRModal from '.'
+
+describe('BaseNodeQRModal', () => {
+ it('should render modal with the QR Code without crashing', () => {
+ const onCloseFn = jest.fn()
+ const store = configureStore({
+ reducer: rootReducer,
+ preloadedState: {
+ baseNode: baseNodeAllState,
+ },
+ })
+
+ render(
+
+
+
+
+ ,
+ )
+
+ const el = screen.getByTestId('base-node-qr-code')
+ expect(el).toBeInTheDocument()
+ })
+})
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/index.tsx b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/index.tsx
new file mode 100644
index 0000000000..8a109cef55
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/index.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react'
+import QRCode from 'react-qr-code'
+import { useTheme } from 'styled-components'
+import Button from '../../components/Button'
+
+import Modal from '../../components/Modal'
+import Text from '../../components/Text'
+
+import t from '../../locales'
+import {
+ selectBaseNodeIdentity,
+ selectNetwork,
+ selectRunning,
+} from '../../store/baseNode/selectors'
+import { useAppDispatch, useAppSelector } from '../../store/hooks'
+import { actions as baseNodeActions } from '../../store/baseNode'
+import {
+ ModalContainer,
+ Content,
+ CtaButton,
+ Steps,
+ Instructions,
+ QRContainer,
+} from './styles'
+
+import { BaseNodeQRModalProps } from './types'
+
+/**
+ * The modal rendering the Base Node address as QR code.
+ * @param {boolean} open - show modal
+ * @param {() => void} onClose - on modal close
+ */
+const BaseNodeQRModal = ({ open, onClose }: BaseNodeQRModalProps) => {
+ const theme = useTheme()
+ const network = useAppSelector(selectNetwork)
+ const baseNodeIdentity = useAppSelector(selectBaseNodeIdentity)
+ const isBaseNodeRunning = useAppSelector(selectRunning)
+
+ const dispatch = useAppDispatch()
+
+ const [qrUrl, setQrUrl] = useState('')
+
+ useEffect(() => {
+ if (baseNodeIdentity) {
+ setQrUrl(
+ `tari://${network}/base_nodes/add?name=${baseNodeIdentity.nodeId}&peer=${baseNodeIdentity.publicKey}::${baseNodeIdentity.publicAddress}`,
+ )
+ }
+ }, [baseNodeIdentity, network])
+
+ useEffect(() => {
+ if (isBaseNodeRunning && open) {
+ dispatch(baseNodeActions.getBaseNodeIdentity())
+ }
+ }, [isBaseNodeRunning, open])
+
+ return (
+
+
+
+
+ {t.baseNode.qrModal.heading}
+
+
+ {t.baseNode.qrModal.description}
+
+
+
+ {t.baseNode.qrModal.step1}
+
+
+
+
+ {t.baseNode.qrModal.step2}
+
+
+
+
+ {t.baseNode.qrModal.step3}
+
+
+
+
+ {t.baseNode.qrModal.step4}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default BaseNodeQRModal
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/styles.ts b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/styles.ts
new file mode 100644
index 0000000000..90fb5cf266
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/styles.ts
@@ -0,0 +1,37 @@
+import styled from 'styled-components'
+
+export const ModalContainer = styled.div`
+ padding: ${({ theme }) => theme.spacing(1.7)};
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ box-sizing: border-box;
+ overflow: auto;
+`
+
+export const Content = styled.div`
+ flex: 1;
+`
+
+export const CtaButton = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(3)};
+`
+
+export const Instructions = styled.div`
+ margin-top: ${({ theme }) => theme.spacingVertical(2.2)};
+ margin-bottom: ${({ theme }) => theme.spacingVertical(3)};
+ color: ${({ theme }) => theme.primary};
+`
+
+export const Steps = styled.ol`
+ margin-top: ${({ theme }) => theme.spacingVertical(1.6)};
+ padding-left: ${({ theme }) => theme.spacingVertical(2)};
+ font-size: 14px;
+`
+
+export const QRContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 20px;
+`
diff --git a/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/types.ts b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/types.ts
new file mode 100644
index 0000000000..82586759cb
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/BaseNodeQRModal/types.ts
@@ -0,0 +1,4 @@
+export interface BaseNodeQRModalProps {
+ open: boolean
+ onClose: () => void
+}
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/components/DashboardTabs/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/components/DashboardTabs/index.tsx
new file mode 100644
index 0000000000..cae50485a5
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/components/DashboardTabs/index.tsx
@@ -0,0 +1,136 @@
+import { useMemo } from 'react'
+import { useDispatch } from 'react-redux'
+
+import Tabs from '../../../../../components/Tabs'
+import TabContent from '../../../../../components/TabContent'
+
+import { setPage } from '../../../../../store/app'
+import { ViewType } from '../../../../../store/app/types'
+import { selectView } from '../../../../../store/app/selectors'
+import {
+ selectNetwork,
+ selectPending as selectBaseNodePending,
+ selectRunning as selectBaseNodeRunning,
+} from '../../../../../store/baseNode/selectors'
+import {
+ selectIsRunning as selectWalletIsRunning,
+ selectIsPending as selectWalletIsPending,
+} from '../../../../../store/wallet/selectors'
+import t from '../../../../../locales'
+import {
+ selectIsMiningPending,
+ selectIsMiningRunning,
+} from '../../../../../store/mining/selectors'
+import { useAppSelector } from '../../../../../store/hooks'
+
+/**
+ * Helper composing all dashboard tabs.
+ */
+const composeNodeTabs = ({
+ miningState,
+ baseNodeState,
+ walletState,
+}: {
+ miningState: { pending: boolean; running: boolean }
+ baseNodeState: { pending: boolean; running: boolean; network?: string }
+ walletState: { pending: boolean; running: boolean }
+}) => {
+ const miningContent = (
+
+ )
+
+ const baseNodeContent = (
+
+ )
+
+ const walletContent = (
+
+ )
+
+ return [
+ {
+ id: 'MINING',
+ content: miningContent,
+ },
+ {
+ id: 'BASE_NODE',
+ content: baseNodeContent,
+ },
+ {
+ id: 'WALLET',
+ content: walletContent,
+ },
+ ]
+}
+
+/**
+ * Renders Dasboard tabs
+ */
+const DashboardTabs = () => {
+ const dispatch = useDispatch()
+
+ const currentPage = useAppSelector(selectView)
+ const baseNodePending = useAppSelector(selectBaseNodePending)
+ const baseNodeRunning = useAppSelector(selectBaseNodeRunning)
+ const baseNodeNetwork = useAppSelector(selectNetwork)
+ const walletPending = useAppSelector(selectWalletIsPending)
+ const walletRunning = useAppSelector(selectWalletIsRunning)
+ const miningRunning = useAppSelector(selectIsMiningRunning)
+ const miningPending = useAppSelector(selectIsMiningPending)
+
+ const tabs = useMemo(
+ () =>
+ composeNodeTabs({
+ miningState: { pending: miningPending, running: miningRunning },
+ baseNodeState: {
+ pending: baseNodePending,
+ running: baseNodeRunning,
+ network: baseNodeNetwork,
+ },
+ walletState: {
+ pending: walletPending,
+ running: walletRunning,
+ },
+ }),
+ [
+ walletPending,
+ walletRunning,
+ baseNodePending,
+ baseNodeRunning,
+ baseNodeNetwork,
+ miningPending,
+ miningRunning,
+ ],
+ )
+
+ const setPageTab = (tabId: string) => {
+ dispatch(setPage(tabId as ViewType))
+ }
+
+ return (
+
+ )
+}
+
+export default DashboardTabs
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/index.tsx
new file mode 100644
index 0000000000..9aeb99a2f3
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/index.tsx
@@ -0,0 +1,54 @@
+import { useSelector } from 'react-redux'
+import { CSSProperties } from 'styled-components'
+import { SpringValue } from 'react-spring'
+
+import { DashboardContent, DashboardLayout } from './styles'
+
+import MiningContainer from '../../MiningContainer'
+import BaseNodeContainer from '../../BaseNodeContainer'
+import WalletContainer from '../../WalletContainer'
+
+import DashboardTabs from './components/DashboardTabs'
+import Footer from '../../../components/Footer'
+
+import { selectView } from '../../../store/app/selectors'
+
+/**
+ * Dashboard view containing three main tabs: Mining, Wallet and BaseNode
+ */
+const DashboardContainer = ({
+ style,
+}: {
+ style?:
+ | CSSProperties
+ | Record>
+ | Record>
+}) => {
+ const currentPage = useSelector(selectView)
+
+ const renderPage = () => {
+ switch (currentPage) {
+ case 'MINING':
+ return
+ case 'BASE_NODE':
+ return
+ case 'WALLET':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+
+ {renderPage()}
+
+
+
+
+ )
+}
+
+export default DashboardContainer
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/styles.ts b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/styles.ts
new file mode 100644
index 0000000000..191450e267
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/DashboardContainer/styles.ts
@@ -0,0 +1,39 @@
+import { animated } from 'react-spring'
+import styled from 'styled-components'
+
+export const DashboardLayout = styled(animated.div)`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: auto;
+ ::-webkit-scrollbar {
+ width: 15px;
+ }
+
+ /* Track */
+ ::-webkit-scrollbar-track {
+ background: ${({ theme }) => theme.scrollBarTrack};
+ }
+
+ /* Handle */
+ ::-webkit-scrollbar-thumb {
+ background: ${({ theme }) => theme.scrollBarThumb};
+ border-radius: 6px;
+ border: 3px solid transparent;
+ background-clip: padding-box;
+ }
+
+ /* Handle on hover */
+ ::-webkit-scrollbar-thumb:hover {
+ background: ${({ theme }) => theme.scrollBarHover};
+ border-radius: 6px;
+ border: 3px solid transparent;
+ background-clip: padding-box;
+ }
+`
+
+export const DashboardContent = styled.div`
+ flex: 1;
+ padding-top: 60px;
+ padding-bottom: 60px;
+`
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/Containers.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/Containers.tsx
new file mode 100644
index 0000000000..94b67d392c
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/Containers.tsx
@@ -0,0 +1,140 @@
+import { useState } from 'react'
+import { useTheme } from 'styled-components'
+
+import Button from '../../../../components/Button'
+import Text from '../../../../components/Text'
+import Tag from '../../../../components/Tag'
+import Alert from '../../../../components/Alert'
+import StopIcon from '../../../../styles/Icons/TurnOff'
+import StartIcon from '../../../../styles/Icons/Play'
+import t from '../../../../locales'
+
+import { ContainersProps } from './types'
+import { ContainersTable, TdRight } from './styles'
+
+/**
+ * @name Containers
+ * @description Presentation component showing containers state
+ *
+ * @prop {ServiceDto[]} containers - containers which status should be displayed
+ * @prop {(container: Container) => void} start - callback for starting a container
+ * @prop {(containerId: ContainerId) => void} stop - callback for stopping a container
+ *
+ * @typedef ContainerDto
+ * @prop {ContainerId} id - id of the container (if it is known)
+ * @prop {Container} container - container which is described by this Dto
+ * @prop {any} error - container or container type error
+ * @prop {number} cpu - % cpu usage of the container
+ * @prop {number} memory - memory in MB of the container
+ * @prop {boolean} running - indicates if container is running
+ * @prop {boolean} pending - indicates if container "running" state is about to change
+ */
+const Containers = ({ containers, stop, start }: ContainersProps) => {
+ const theme = useTheme()
+ const [error, setError] = useState('')
+
+ return (
+ <>
+
+
+ {containers.map(container => (
+
+
+
+ {container.displayName}
+
+ |
+
+
+ {container.cpu.toFixed(2)}%
+ {' '}
+
+ {t.common.nouns.cpu}
+
+
+
+
+ {container.memory.toFixed(2)} MB
+ {' '}
+
+ {t.common.nouns.memory}
+
+
+
+ {container.running && (
+
+ {t.common.adjectives.running}
+
+ )}
+ {container.error && (
+
+ )}
+ |
+
+ {!container.running && (
+
+ }
+ style={{
+ paddingRight: 0,
+ paddingLeft: 0,
+ color: theme.inverted.accentSecondary,
+ }}
+ onClick={() => start(container.container)}
+ >
+ {t.common.verbs.start}
+
+ )}
+ {container.running && (
+
+ }
+ style={{
+ paddingRight: 0,
+ paddingLeft: 0,
+ color: theme.placeholderText,
+ }}
+ onClick={() => stop(container.id)}
+ >
+ {t.common.verbs.stop}
+
+ )}
+ |
+
+ ))}
+
+
+ setError('')}
+ />
+ >
+ )
+}
+
+export default Containers
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/index.tsx
new file mode 100644
index 0000000000..6a30f92bbb
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/index.tsx
@@ -0,0 +1,64 @@
+import { useState, useMemo } from 'react'
+
+import { useAppSelector, useAppDispatch } from '../../../../store/hooks'
+import { selectContainersStatusesWithStats } from '../../../../store/containers/selectors'
+import { Container, ContainerId } from '../../../../store/containers/types'
+import { actions } from '../../../../store/containers'
+import Alert from '../../../../components/Alert'
+
+import Containers from './Containers'
+
+const ContainersContainer = () => {
+ const [error, setError] = useState('')
+
+ const dispatch = useAppDispatch()
+ const containerStatuses = useAppSelector(selectContainersStatusesWithStats)
+ const containers = useMemo(
+ () =>
+ containerStatuses.map(
+ ({ container, imageName, displayName, status }) => ({
+ id: status.id,
+ container: container as Container,
+ imageName,
+ displayName,
+ error: status.error,
+ cpu: status.stats.cpu,
+ memory: status.stats.memory,
+ pending: status.pending,
+ running: status.running,
+ }),
+ ),
+ [containerStatuses],
+ )
+
+ const start = async (container: Container) => {
+ try {
+ await dispatch(actions.start({ container: container })).unwrap()
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ setError(e.toString())
+ }
+ }
+ const stop = async (containerId: ContainerId) => {
+ try {
+ await dispatch(actions.stop(containerId)).unwrap()
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ setError(e.toString())
+ }
+ }
+
+ return (
+ <>
+
+ setError('')}
+ content={error}
+ />
+ >
+ )
+}
+
+export default ContainersContainer
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/styles.ts b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/styles.ts
new file mode 100644
index 0000000000..72117a613f
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/styles.ts
@@ -0,0 +1,11 @@
+import styled from 'styled-components'
+
+export const ContainersTable = styled.table`
+ width: 100%;
+ max-width: 50vw;
+ margin-top: ${({ theme }) => theme.spacing()};
+`
+
+export const TdRight = styled.td`
+ text-align: right;
+`
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/types.ts b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/types.ts
new file mode 100644
index 0000000000..3063250b37
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Containers/types.ts
@@ -0,0 +1,19 @@
+import { Container, ContainerId } from '../../../../store/containers/types'
+import { DockerImage } from '../../../../types/general'
+
+type ContainerDto = {
+ id: ContainerId
+ container: Container
+ cpu: number
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ error?: any
+ memory: number
+ pending: boolean
+ running: boolean
+} & Pick
+
+export type ContainersProps = {
+ containers: ContainerDto[]
+ start: (container: Container) => void
+ stop: (container: ContainerId) => void
+}
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Logs/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Logs/index.tsx
new file mode 100644
index 0000000000..8f51deea18
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Logs/index.tsx
@@ -0,0 +1,13 @@
+const Logs = () => {
+ return (
+
+ )
+}
+
+export default Logs
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/Tooltip.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/Tooltip.tsx
new file mode 100644
index 0000000000..ab8d68bc43
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/Tooltip.tsx
@@ -0,0 +1,61 @@
+import { useTheme } from 'styled-components'
+
+import Text from '../../../../../components/Text'
+import * as Format from '../../../../../utils/Format'
+import t from '../../../../../locales'
+
+import { TooltipWrapper, SeriesColorIndicator } from './styles'
+
+export type TooltipProps = {
+ display?: boolean
+ left?: number
+ top?: number
+ x?: Date
+ values?: {
+ service: string
+ unit: string
+ value: number | null
+ color: string
+ }[]
+}
+
+const Tooltip = ({ display, left, top, values, x }: TooltipProps) => {
+ const theme = useTheme()
+
+ return (
+
+ {Boolean(values) && (
+
+ {(values || [])
+ .filter(v => Boolean(v.value))
+ .map(v => (
+ -
+
+
+ {t.common.containers[v.service]}{' '}
+
+ {v.value}
+ {v.unit}
+
+
+
+ ))}
+
+ )}
+ {Boolean(x) && (
+
+ {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
+ {Format.dateTime(x!)}
+
+ )}
+
+ )
+}
+
+export default Tooltip
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/index.tsx
new file mode 100644
index 0000000000..7634af5b11
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/index.tsx
@@ -0,0 +1,357 @@
+import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
+import groupby from 'lodash.groupby'
+import { useTheme } from 'styled-components'
+import UplotReact from 'uplot-react'
+
+import { chartColors } from '../../../../../styles/styles/colors'
+import IconButton from '../../../../../components/IconButton'
+import Loading from '../../../../../components/Loading'
+import { Dictionary } from '../../../../../types/general'
+import VisibleIcon from '../../../../../styles/Icons/Eye'
+import HiddenIcon from '../../../../../styles/Icons/EyeSlash'
+import useIntersectionObserver from '../../../../../utils/useIntersectionObserver'
+import * as Format from '../../../../../utils/Format'
+import Text from '../../../../../components/Text'
+import t from '../../../../../locales'
+import { MinimalStatsEntry } from '../types'
+
+import Tooltip, { TooltipProps } from './Tooltip'
+import {
+ ChartContainer,
+ Legend,
+ LegendItem,
+ SeriesColorIndicator,
+ TitleContainer,
+} from './styles'
+
+const getTimestampInResolution = (timestampS: number, resolutionS: number) =>
+ Math.floor(timestampS / resolutionS) * resolutionS
+
+const PerformanceChart = ({
+ since,
+ now,
+ data,
+ getter,
+ title,
+ width,
+ percentage,
+ unit,
+ onFreeze,
+ loading,
+ resolution = 1,
+}: {
+ since: Date
+ now: Date
+ data: MinimalStatsEntry[]
+ getter: (se: MinimalStatsEntry) => number | null
+ title: string
+ width: number
+ percentage?: boolean
+ unit?: string
+ onFreeze: (frozen: boolean) => void
+ loading?: boolean
+ resolution?: number
+}) => {
+ const theme = useTheme()
+ const unitToDisplay = percentage ? '%' : unit || ''
+ const chartContainerRef = useRef()
+ const observerEntry = useIntersectionObserver(chartContainerRef, {})
+ const inView = Boolean(observerEntry?.isIntersecting)
+
+ const [latchedSinceS, setLatchedSinceS] = useState(since.getTime() / 1000)
+ const [latchedNowS, setLatchedNowS] = useState(now.getTime() / 1000)
+ const [frozen, setFrozen] = useState(false)
+
+ useEffect(() => {
+ if (frozen) {
+ return
+ }
+
+ setLatchedSinceS(since.getTime() / 1000)
+ }, [frozen, since])
+
+ useEffect(() => {
+ if (frozen) {
+ return
+ }
+
+ setLatchedNowS(now.getTime() / 1000)
+ }, [frozen, now])
+
+ const xValues = useMemo(() => {
+ const x = []
+ const latchedSinceInResolution = getTimestampInResolution(
+ latchedSinceS,
+ resolution,
+ )
+ const latchedNowInResolution = getTimestampInResolution(
+ latchedNowS,
+ resolution,
+ )
+ for (
+ let i = 0;
+ i < latchedNowInResolution - latchedSinceInResolution;
+ i += resolution
+ ) {
+ x.push(latchedSinceInResolution + i)
+ }
+
+ return x
+ }, [latchedNowS, latchedSinceS])
+ const chartData = useMemo(() => {
+ const grouped = groupby(data, 'service')
+ const seriesData: Dictionary = {}
+ const sinceS = xValues[0]
+ let min = 0
+ let max = 0
+ Object.keys(grouped)
+ .sort()
+ .forEach(key => {
+ const yValues = new Array(xValues.length).fill(null)
+ if (resolution === 1) {
+ grouped[key].forEach(v => {
+ const idx = v.timestampS - sinceS
+ if (idx < yValues.length) {
+ yValues[idx] = getter(v)
+ min = Math.min(min, yValues[idx])
+ max = Math.max(max, yValues[idx])
+ }
+ })
+ } else {
+ const groupedForResolution = groupby(grouped[key], v =>
+ getTimestampInResolution(v.timestampS, resolution),
+ )
+
+ Object.entries(groupedForResolution).forEach(
+ ([resolutionTimestamp, current]) => {
+ const sum = current.reduce((a, c) => a + (getter(c) || 0), 0)
+ const idx = (Number(resolutionTimestamp) - sinceS) / resolution
+
+ if (idx < yValues.length) {
+ yValues[idx] = sum / current.length
+ min = Math.min(min, yValues[idx])
+ max = Math.max(max, yValues[idx])
+ }
+ },
+ )
+ }
+
+ seriesData[key] = yValues
+ })
+ return {
+ seriesData,
+ min,
+ max,
+ }
+ }, [xValues, getter, resolution])
+ const [tooltipState, setTooltipState] = useState(null)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const setTooltipValues = useCallback((u: any) => {
+ const { left, top, idx } = u.cursor
+ const x = u.data[0][idx]
+ const chartingAreaRect = u.root.getBoundingClientRect()
+ const values: TooltipProps['values'] = []
+ for (let i = 1; i < u.data.length; i++) {
+ values.push({
+ service: u.series[i].label,
+ unit: u.series[i].unit,
+ value: u.data[i][idx]?.toFixed(2),
+ color: chartColors[i - 1],
+ })
+ }
+
+ setTooltipState(st => ({
+ ...st,
+ left: left + chartingAreaRect.left,
+ top: top + chartingAreaRect.top,
+ x: new Date(x * 1000),
+ values,
+ }))
+ }, [])
+
+ // keeping stable reference to onFreezeCallback to avoid changing
+ // mouseEnter and mouseLeave references
+ // if new references are passed to uPloat - it is rerendered
+ // and cursor disappears
+ const freezeCallback = useRef<((frozen: boolean) => void) | null>(null)
+ useEffect(() => {
+ freezeCallback.current = onFreeze
+ }, [onFreeze])
+ const mouseLeave = useCallback((_e: MouseEvent) => {
+ setFrozen(false)
+ if (freezeCallback.current) {
+ freezeCallback.current(false)
+ }
+ setTooltipState(st => ({ ...st, display: false }))
+
+ return null
+ }, [])
+ const mouseEnter = useCallback((_e: MouseEvent) => {
+ setFrozen(true)
+ if (freezeCallback.current) {
+ freezeCallback.current(true)
+ }
+ setTooltipState(st => ({ ...st, display: true }))
+
+ return null
+ }, [])
+
+ const [hiddenSeries, setHiddenSeries] = useState([])
+
+ const options = useMemo(
+ () => ({
+ width,
+ height: 175,
+ legend: {
+ show: false,
+ },
+ hooks: {
+ setCursor: [setTooltipValues],
+ },
+ cursor: {
+ bind: {
+ mouseenter: () => mouseEnter,
+ mouseleave: () => mouseLeave,
+ },
+ },
+ scales: {
+ '%': {
+ auto: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ range: (_u: any, _dataMin: number, _dataMax: number) => {
+ return [0, Math.max(100, chartData.max)] as [
+ number | null,
+ number | null,
+ ]
+ },
+ },
+ y: {
+ auto: false,
+ min: chartData.min,
+ max: chartData.max,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ range: (_u: any, dataMin: number, dataMax: number) =>
+ [dataMin, dataMax] as [number | null, number | null],
+ },
+ },
+ series: [
+ {},
+ ...Object.keys(chartData.seriesData).map((key, id) => ({
+ unit: unitToDisplay,
+ auto: false,
+ show: !hiddenSeries.includes(key),
+ scale: percentage ? '%' : 'y',
+ label: key,
+ stroke: chartColors[id],
+ fill: `${chartColors[id]}33`,
+ })),
+ ],
+ axes: [
+ {
+ grid: {
+ show: true,
+ stroke: theme.inverted.resetBackground,
+ width: 0.5,
+ },
+ ticks: {
+ show: true,
+ stroke: theme.inverted.resetBackground,
+ width: 0.5,
+ },
+ show: true,
+ side: 2,
+ labelSize: 8 + 12 + 8,
+ stroke: theme.inverted.secondary,
+ values: (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _uPlot: any,
+ splits: number[],
+ _axisIdx: number,
+ _foundSpace: number,
+ _foundIncr: number,
+ ) => {
+ return splits.map(split => Format.localHour(new Date(split * 1000)))
+ },
+ },
+ {
+ scale: percentage ? '%' : 'y',
+ show: true,
+ side: 3,
+ values: (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _uPlot: any,
+ splits: number[],
+ _axisIdx: number,
+ _foundSpace: number,
+ _foundIncr: number,
+ ) => {
+ return splits
+ },
+ stroke: theme.inverted.secondary,
+ grid: {
+ show: true,
+ stroke: theme.inverted.resetBackground,
+ width: 0.5,
+ },
+ ticks: {
+ show: true,
+ stroke: theme.inverted.resetBackground,
+ width: 0.5,
+ },
+ },
+ ],
+ }),
+ [mouseEnter, mouseLeave, chartData, hiddenSeries, width, percentage],
+ )
+
+ const toggleSeries = (name: string) => {
+ setHiddenSeries(hidden => {
+ if (hidden.includes(name)) {
+ return hidden.filter(h => h !== name)
+ }
+
+ return [...hidden, name]
+ })
+ }
+
+ return (
+
+
+
+ {title} [{unitToDisplay}]
+
+
+
+
+
+ {inView && (
+
+ )}
+
+
+
+ )
+}
+
+export default PerformanceChart
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/styles.ts b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/styles.ts
new file mode 100644
index 0000000000..ea548da055
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceChart/styles.ts
@@ -0,0 +1,67 @@
+import styled from 'styled-components'
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const ChartContainer = styled.div<{ ref?: any }>`
+ position: relative;
+ color: ${({ theme }) => theme.inverted.primary};
+ background-color: ${({ theme }) => theme.inverted.backgroundSecondary};
+ padding: ${({ theme }) => theme.spacing()};
+ padding-left: ${({ theme }) => theme.spacing(0.5)};
+ border-radius: ${({ theme }) => theme.borderRadius()};
+ max-width: 100%;
+ margin-top: ${({ theme }) => theme.spacing()};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`
+
+export const TooltipWrapper = styled.div`
+ position: fixed;
+ background-color: ${({ theme }) => theme.inverted.background};
+ border-radius: ${({ theme }) => theme.borderRadius()};
+ padding: ${({ theme }) => theme.spacing()};
+ transform: translate(-100%, -50%);
+ margin-left: ${({ theme }) => theme.spacing()};
+ z-index: 9001;
+ min-width: 175px;
+ & ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ & li {
+ display: flex;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.spacing(0.25)};
+ }
+`
+
+export const Legend = styled.div`
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-left: ${({ theme }) => theme.spacing()};
+ column-gap: ${({ theme }) => theme.spacing()};
+`
+
+export const LegendItem = styled.div`
+ display: flex;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.spacing(0.5)};
+ min-height: 1em;
+`
+
+export const SeriesColorIndicator = styled.div<{ color: string }>`
+ width: 1em;
+ height: 0.1em;
+ border-radius: 2px;
+ background-color: ${({ color }) => color};
+`
+
+export const TitleContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.spacing(0.5)};
+`
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceControls.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceControls.tsx
new file mode 100644
index 0000000000..9298b8b368
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/PerformanceControls.tsx
@@ -0,0 +1,121 @@
+import { useMemo } from 'react'
+import { useTheme } from 'styled-components'
+
+import Select from '../../../../components/Select'
+import { Option } from '../../../../components/Select/types'
+import FilterIcon from '../../../../styles/Icons/Filter'
+import RefreshRateIcon from '../../../../styles/Icons/RotateRight'
+import t from '../../../../locales'
+
+export interface TimeWindowOption extends Option {
+ resolution: number
+}
+
+const renderWindowOptions: TimeWindowOption[] = [
+ {
+ value: 30 * 60 * 1000,
+ key: '30m',
+ label: t.expertView.performance.renderWindowOptionsLabels.last30m,
+ resolution: 1,
+ },
+ {
+ value: 60 * 60 * 1000,
+ key: '1h',
+ label: t.expertView.performance.renderWindowOptionsLabels.last1h,
+ resolution: 1,
+ },
+ {
+ value: 2 * 60 * 60 * 1000,
+ key: '2h',
+ label: t.expertView.performance.renderWindowOptionsLabels.last2h,
+ resolution: 1,
+ },
+ {
+ value: 8 * 60 * 60 * 1000,
+ key: '8h',
+ label: t.expertView.performance.renderWindowOptionsLabels.last8h,
+ resolution: 60,
+ },
+ {
+ value: 24 * 60 * 60 * 1000,
+ key: '24h',
+ label: t.expertView.performance.renderWindowOptionsLabels.last24h,
+ resolution: 60,
+ },
+]
+export const defaultRenderWindow = renderWindowOptions[0]
+
+const refreshRateOptions = [
+ {
+ value: 1000,
+ key: '1s',
+ label: t.expertView.performance.refreshRateOptionsLabels.every1s,
+ },
+ {
+ value: 10 * 1000,
+ key: '10s',
+ label: t.expertView.performance.refreshRateOptionsLabels.every10s,
+ },
+ {
+ value: 60 * 1000,
+ key: '60s',
+ label: t.expertView.performance.refreshRateOptionsLabels.every60s,
+ },
+]
+export const defaultRefreshRate = refreshRateOptions[0]
+
+const PerformanceControls = ({
+ refreshRate,
+ onRefreshRateChange,
+ timeWindow,
+ onTimeWindowChange,
+}: {
+ refreshRate: Option
+ onRefreshRateChange: (option: Option) => void
+ timeWindow: TimeWindowOption
+ onTimeWindowChange: (option: TimeWindowOption) => void
+}) => {
+ const theme = useTheme()
+
+ const selectStyleOverrides = useMemo(
+ () => ({
+ icon: {
+ color: theme.secondary,
+ },
+ value: {
+ color: theme.placeholderText,
+ backgroundColor: theme.inverted.backgroundSecondary,
+ borderColor: (open?: boolean) =>
+ open ? theme.accent : theme.inverted.backgroundSecondary,
+ },
+ }),
+ [theme],
+ )
+
+ return (
+
+ }
+ fullWidth={false}
+ value={timeWindow}
+ options={renderWindowOptions}
+ onChange={(option: Option) =>
+ onTimeWindowChange(option as TimeWindowOption)
+ }
+ styles={selectStyleOverrides}
+ />
+
+ }
+ fullWidth={false}
+ value={refreshRate}
+ options={refreshRateOptions}
+ onChange={onRefreshRateChange}
+ styles={selectStyleOverrides}
+ />
+
+ )
+}
+
+export default PerformanceControls
diff --git a/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/index.tsx b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/index.tsx
new file mode 100644
index 0000000000..e771a15773
--- /dev/null
+++ b/applications/launchpad/gui-react/src/containers/Dashboard/ExpertView/Performance/index.tsx
@@ -0,0 +1,220 @@
+import { useEffect, useRef, useState, useMemo } from 'react'
+import { listen } from '@tauri-apps/api/event'
+
+import t from '../../../../locales'
+import { selectNetwork } from '../../../../store/baseNode/selectors'
+import { selectExpertView } from '../../../../store/app/selectors'
+import { selectAllContainerEventsChannels } from '../../../../store/containers/selectors'
+import { extractStatsFromEvent } from '../../../../store/containers/thunks'
+import { StatsEventPayload } from '../../../../store/containers/types'
+import { useAppSelector } from '../../../../store/hooks'
+import getStatsRepository from '../../../../persistence/statsRepository'
+import { Option } from '../../../../components/Select/types'
+
+import PerformanceControls, {
+ defaultRenderWindow,
+ defaultRefreshRate,
+ TimeWindowOption,
+} from './PerformanceControls'
+import PerformanceChart from './PerformanceChart'
+import { MinimalStatsEntry } from './types'
+
+const CPU_GETTER = (se: MinimalStatsEntry) => se.cpu
+const MEMORY_GETTER = (se: MinimalStatsEntry) => se.memory
+const NETWORK_GETTER = (se: MinimalStatsEntry) =>
+ (se.download || 0) / (1024 * 1024)
+
+/**
+ * @name PerformanceContainer
+ * @description container component for performance statistics, renders filtering controls and performance charts
+ * manages refresh rate and synchronizes refresh ticks for all charts
+ * delegates chart rendering etc to other components
+ *
+ */
+const PerformanceContainer = () => {
+ const configuredNetwork = useAppSelector(selectNetwork)
+ const expertView = useAppSelector(selectExpertView)
+ const statsRepository = useMemo(getStatsRepository, [])
+ const allContainerEventsChannels = useAppSelector(
+ selectAllContainerEventsChannels,
+ )
+ const unsubscribeFunctions = useRef<(() => void)[]>()
+
+ const [loadingData, setLoadingData] = useState(false)
+ const [timeWindow, setTimeWindow] =
+ useState(defaultRenderWindow)
+ const [refreshRate, setRefreshRate] = useState