Compare commits

...

25 Commits

Author SHA1 Message Date
Calum H.
46fdb29ba6 feat: billing interval change support for servers 2025-06-11 23:05:34 +01:00
Alejandro González
a3839461cf perf(labrinth/random_projects_get): speed up through spatial queries according to profiling results (#3762) 2025-06-09 22:19:58 +00:00
Prospector
858c7e393f Add handling for new loaders, fix max height being applied when scrolling is disabled from #2898 (#3761) 2025-06-09 21:59:59 +00:00
Josiah Glosson
0278241006 Update a bunch of dependencies (#3766) 2025-06-09 19:48:03 +00:00
Josiah Glosson
3afb682fc6 Make get_user_from_headers and check_is_moderator_from_headers take in a bitflag of Scopes rather than a slice of Scopes (#3765) 2025-06-09 19:29:32 +00:00
Emma Alexia
06f1df1995 Fix random_projects route not returning the requested number of projects (#3758)
* Fix random_projects route not returning the requested number of projects

* fix(labrinth): further improve random project route SQL query

* chore: fix typo in comment

* tweak(labrinth): more apparent and fast randomness for `random_projects_get`

* tweak(labrinth): even better random projects query

* chore: address formatting review

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-06-08 23:49:39 +00:00
Magnus Jensen
3489771d2e fix: reset reset-icon state value correctly in edit world modal (#3748) 2025-06-05 22:03:52 +00:00
Erb3
448ae5a2b7 fix(frontend): remove fixed height from ManySelect (#2898)
* fix(frontend): remove fixed height from ManySelect

Frontend development is not my passion, there might be a better fix.

I've tested my changes in all places that I found using the chganed components (ManySelect, ScrollablePanel):

- Changelog filters
- Version filters
- Download dialog
- Search filters

Fixes #2334

* Revert incorrect merge

* fix merge conflict
2025-06-04 22:07:32 +00:00
Prospector
72340790e5 Show up to 15 projects in chart tooltips (#3739) 2025-06-04 20:19:06 +00:00
Prospector
c9423fe478 Fix server intro not ending when installing loader 2025-06-03 20:28:23 -07:00
Prospector
02a850ae63 lint 2025-06-03 18:15:23 -07:00
Prospector
ede6d0c3cc Change region order 2025-06-03 16:23:37 -07:00
Prospector
7685989a8c Update regions FAQ 2025-06-03 16:21:35 -07:00
Prospector
4e8ebb5e5c Servers fixes 2025-06-03 16:16:56 -07:00
Prospector
3f77ab19ed Fix skeleton not showing on purchase 2025-06-03 13:18:05 -07:00
Prospector
d3d0c8c523 Fix skeleton sticking around on back, add new server indicator 2025-06-03 12:30:21 -07:00
Prospector
4e093131f3 Install issues 2025-06-03 11:25:31 -07:00
Prospector
6ca8a4e5fd Fix QA issues 2025-06-03 11:09:22 -07:00
Prospector
63b15ded60 Send region 2025-06-03 10:57:37 -07:00
Prospector
85e65aeffe intl 2025-06-03 10:43:05 -07:00
Prospector
ad44398492 en dash 2025-06-03 10:40:35 -07:00
Prospector
a4ba41bf15 Lint, make save button not clickable when quarterly is selected 2025-06-03 10:37:29 -07:00
Prospector
4441be5380 Fixes to billing 2025-06-03 09:22:54 -07:00
Prospector
c0accb42fa Servers new purchase flow (#3719)
* New purchase flow for servers, region selector, etc.

* Lint

* Lint

* Fix expanding total
2025-06-03 09:20:53 -07:00
François-Xavier Talbot
7223c2b197 Include region in user subscription metadata (#3733) 2025-06-02 05:13:06 +00:00
88 changed files with 3714 additions and 1353 deletions

405
Cargo.lock generated
View File

@@ -481,7 +481,7 @@ dependencies = [
"serde_cbor",
"serde_json",
"thiserror 2.0.12",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -551,9 +551,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.23"
version = "0.4.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07"
checksum = "d615619615a650c571269c00dca41db04b9210037fa76ed8239f70404ab56985"
dependencies = [
"brotli 8.0.1",
"bzip2",
@@ -1354,7 +1354,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -1412,9 +1412,9 @@ checksum = "93a719913643003b84bd13022b4b7e703c09342cd03b679c4641c7d2e50dc34d"
[[package]]
name = "clap"
version = "4.5.38"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1422,9 +1422,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.38"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [
"anstream",
"anstyle",
@@ -1434,9 +1434,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.32"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -1452,9 +1452,9 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "clickhouse"
version = "0.13.2"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9894248c4c5a4402f76a56c273836a0c32547ec8a68166aedee7e01b7b8d102"
checksum = "9a9a81a1dffadd762ee662635ce409232258ce9beebd7cc0fa227df0b5e7efc0"
dependencies = [
"bstr",
"bytes",
@@ -1474,7 +1474,7 @@ dependencies = [
"time",
"tokio",
"url",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -2000,7 +2000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -2634,9 +2634,9 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.1.1"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
dependencies = [
"crc32fast",
"libz-rs-sys",
@@ -2992,7 +2992,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -3681,22 +3681,28 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.11"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"hyper 1.6.0",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -3711,7 +3717,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.0",
"windows-core",
]
[[package]]
@@ -4027,6 +4033,16 @@ version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -4377,12 +4393,12 @@ dependencies = [
"tracing-actix-web",
"url",
"urlencoding",
"uuid 1.16.0",
"uuid 1.17.0",
"validator",
"webp",
"woothee",
"yaserde",
"zip 3.0.0",
"zip 4.0.0",
"zxcvbn",
]
@@ -4409,9 +4425,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]]
name = "lettre"
version = "0.11.16"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2"
checksum = "cb2a0354e9ece2fcdcf9fa53417f6de587230c0c248068eb058fa26c4a753179"
dependencies = [
"base64 0.22.1",
"chumsky",
@@ -4523,9 +4539,9 @@ dependencies = [
[[package]]
name = "libz-rs-sys"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a"
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
dependencies = [
"zlib-rs",
]
@@ -4753,7 +4769,7 @@ dependencies = [
"serde_json",
"thiserror 1.0.69",
"time",
"uuid 1.16.0",
"uuid 1.17.0",
"wasm-bindgen-futures",
"web-sys",
"yaup",
@@ -4849,7 +4865,7 @@ dependencies = [
"smallvec",
"tagptr",
"thiserror 1.0.69",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -6719,9 +6735,9 @@ checksum = "e3a8614ee435691de62bcffcf4a66d91b3594bf1428a5722e79103249a095690"
[[package]]
name = "reqwest"
version = "0.12.15"
version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"async-compression",
"base64 0.22.1",
@@ -6748,25 +6764,23 @@ dependencies = [
"quinn",
"rustls 0.23.27",
"rustls-native-certs 0.8.1",
"rustls-pemfile 2.2.0",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-rustls 0.26.2",
"tokio-util",
"tower 0.5.2",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.26.11",
"windows-registry 0.4.0",
"webpki-roots 1.0.0",
]
[[package]]
@@ -6854,7 +6868,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -7056,7 +7070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe",
"rustls-pemfile 1.0.4",
"rustls-pemfile",
"schannel",
"security-framework 2.11.1",
]
@@ -7082,15 +7096,6 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@@ -7200,7 +7205,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -7449,7 +7454,7 @@ dependencies = [
"thiserror 2.0.12",
"time",
"url",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -7474,9 +7479,9 @@ dependencies = [
[[package]]
name = "serde-xml-rs"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "176b7ff880ab6ead7a020e773e9c096467fe347615a3e22ac29300cbdef67a5d"
checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
dependencies = [
"log",
"serde",
@@ -7927,9 +7932,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -7940,9 +7945,9 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -7978,9 +7983,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
@@ -7991,9 +7996,9 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
@@ -8010,16 +8015,15 @@ dependencies = [
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.101",
"tempfile",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8061,9 +8065,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8100,9 +8104,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
@@ -8295,16 +8299,16 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.35.1"
version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79251336d17c72d9762b8b54be4befe38d2db56fbbc0241396d70f173c39d47a"
checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -8380,8 +8384,8 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.1",
"windows-core 0.61.0",
"windows",
"windows-core",
"windows-version",
"x11-dl",
]
@@ -8469,7 +8473,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -8519,7 +8523,7 @@ dependencies = [
"thiserror 2.0.12",
"time",
"url",
"uuid 1.16.0",
"uuid 1.17.0",
"walkdir",
]
@@ -8556,9 +8560,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.2.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dba4412f30eaff6f5d210e20383c2d6835593977402092e95b72497a4f8632fa"
checksum = "e4976ac728ebc0487515aa956cfdf200abcc52b784e441493fc544bc6ce369c8"
dependencies = [
"dunce",
"rust-ini",
@@ -8570,15 +8574,15 @@ dependencies = [
"thiserror 2.0.12",
"tracing",
"url",
"windows-registry 0.5.1",
"windows-registry",
"windows-result",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcaf6e5d6062423a0f711a23c2a573ccba222b6a16a9322d8499928f27e41376"
checksum = "a33318fe222fc2a612961de8b0419e2982767f213f54a4d3a21b0d7b85c41df8"
dependencies = [
"log",
"raw-window-handle",
@@ -8594,9 +8598,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.2.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682"
checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da"
dependencies = [
"anyhow",
"dunce",
@@ -8612,14 +8616,13 @@ dependencies = [
"thiserror 2.0.12",
"toml",
"url",
"uuid 1.16.0",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.2.6"
version = "2.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fdc6cb608e04b7d2b6d1f21e9444ad49245f6d03465ba53323d692d1ceb1a30"
checksum = "66644b71a31ec1a8a52c4a16575edd28cf763c87cf4a7da24c884122b5c77097"
dependencies = [
"dunce",
"glob",
@@ -8633,7 +8636,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.12",
"url",
"windows 0.60.0",
"windows",
"zbus",
]
@@ -8657,9 +8660,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.2.3"
version = "2.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1320af4d866a7fb5f5721d299d14d0dd9e4e6bc0359ff3e263124a2bf6814efa"
checksum = "97d0e07b40fb2eb13778e30778f5979347a2bf30e1b9d47f78ff7fe92d2e4b3d"
dependencies = [
"serde",
"serde_json",
@@ -8736,7 +8739,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.12",
"url",
"windows 0.61.1",
"windows",
]
[[package]]
@@ -8762,7 +8765,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.1",
"windows",
"wry",
]
@@ -8800,7 +8803,7 @@ dependencies = [
"toml",
"url",
"urlpattern",
"uuid 1.16.0",
"uuid 1.17.0",
"walkdir",
]
@@ -8900,10 +8903,10 @@ dependencies = [
"tracing-error",
"tracing-subscriber",
"url",
"uuid 1.16.0",
"uuid 1.17.0",
"whoami",
"winreg 0.55.0",
"zip 3.0.0",
"zip 4.0.0",
]
[[package]]
@@ -8936,7 +8939,7 @@ dependencies = [
"tracing-error",
"url",
"urlencoding",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -9113,9 +9116,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.45.0"
version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [
"backtrace",
"bytes",
@@ -9338,6 +9341,24 @@ dependencies = [
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -9372,7 +9393,7 @@ dependencies = [
"mutually_exclusive_features",
"pin-project",
"tracing",
"uuid 1.16.0",
"uuid 1.17.0",
]
[[package]]
@@ -9699,9 +9720,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.16.0"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
@@ -10052,9 +10073,9 @@ checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.61.1",
"windows-core 0.61.0",
"windows-implement 0.60.0",
"windows",
"windows-core",
"windows-implement",
"windows-interface",
]
@@ -10076,8 +10097,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295"
dependencies = [
"thiserror 2.0.12",
"windows 0.61.1",
"windows-core 0.61.0",
"windows",
"windows-core",
]
[[package]]
@@ -10171,39 +10192,17 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529"
dependencies = [
"windows-collections 0.1.1",
"windows-core 0.60.1",
"windows-future 0.1.1",
"windows-link",
"windows-numerics 0.1.1",
]
[[package]]
name = "windows"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [
"windows-collections 0.2.0",
"windows-core 0.61.0",
"windows-future 0.2.0",
"windows-collections",
"windows-core",
"windows-future",
"windows-link",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows-collections"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec"
dependencies = [
"windows-core 0.60.1",
"windows-numerics",
]
[[package]]
@@ -10212,20 +10211,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.0",
]
[[package]]
name = "windows-core"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247"
dependencies = [
"windows-implement 0.59.0",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.3.1",
"windows-core",
]
[[package]]
@@ -10234,21 +10220,11 @@ version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement 0.60.0",
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings 0.4.0",
]
[[package]]
name = "windows-future"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0"
dependencies = [
"windows-core 0.60.1",
"windows-link",
"windows-strings",
]
[[package]]
@@ -10257,21 +10233,10 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
"windows-core 0.61.0",
"windows-core",
"windows-link",
]
[[package]]
name = "windows-implement"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
@@ -10300,37 +10265,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-numerics"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed"
dependencies = [
"windows-core 0.60.1",
"windows-link",
]
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.0",
"windows-core",
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
]
[[package]]
name = "windows-registry"
version = "0.5.1"
@@ -10339,7 +10283,7 @@ checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e"
dependencies = [
"windows-link",
"windows-result",
"windows-strings 0.4.0",
"windows-strings",
]
[[package]]
@@ -10351,15 +10295,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.0"
@@ -10444,29 +10379,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows-version"
version = "0.1.4"
@@ -10494,12 +10413,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -10518,12 +10431,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -10542,24 +10449,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -10578,12 +10473,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -10602,12 +10491,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -10626,12 +10509,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -10650,12 +10527,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -10771,8 +10642,8 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.61.1",
"windows-core 0.61.0",
"windows",
"windows-core",
"windows-version",
]
@@ -11042,9 +10913,9 @@ dependencies = [
[[package]]
name = "zip"
version = "3.0.0"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
dependencies = [
"arbitrary",
"bzip2",
@@ -11059,9 +10930,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8"
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
[[package]]
name = "zopfli"

View File

@@ -21,7 +21,7 @@ actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.23", default-features = false }
async-compression = { version = "0.4.24", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -33,12 +33,12 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
async-walkdir = "2.1.0"
async_zip = "0.0.17"
base64 = "0.22.1"
bitflags = "2.9.0"
bitflags = "2.9.1"
bytes = "1.10.1"
censor = "0.3.0"
chrono = "0.4.41"
clap = "4.5.38"
clickhouse = "0.13.2"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
@@ -51,7 +51,7 @@ dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
enumset = "1.1.6"
flate2 = "1.1.1"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
@@ -59,7 +59,7 @@ hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.11"
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
@@ -67,7 +67,7 @@ indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.16", default-features = false, features = [
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
@@ -89,9 +89,9 @@ quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.31.0"
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.15", default-features = false }
reqwest = { version = "0.12.19", default-features = false }
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
@@ -113,7 +113,7 @@ sentry = { version = "0.38.1", default-features = false, features = [
] }
sentry-actix = "0.38.1"
serde = "1.0.219"
serde-xml-rs = "0.8.0" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
@@ -123,16 +123,16 @@ sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.5", default-features = false }
sysinfo = { version = "0.35.1", default-features = false }
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "2.2.7"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-single-instance = "2.2.4"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
"rustls-tls",
"zip",
@@ -143,7 +143,7 @@ theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
@@ -153,14 +153,14 @@ tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.16.0"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "3.0.0", default-features = false, features = [
zip = { version = "4.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",

View File

@@ -6,7 +6,7 @@ import {
getWorldIdentifier,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
@@ -108,20 +108,6 @@ const serverIncompatible = computed(
props.serverStatus.version.protocol !== props.currentProtocol,
)
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({

View File

@@ -56,6 +56,7 @@ function show(world: SingleplayerWorld) {
icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show()
}

View File

@@ -0,0 +1,128 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<button
v-for="(option, index) in options"
:key="`option-group-${index}`"
ref="optionButtons"
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
:class="{
'text-button-textSelected': modelValue === option,
'text-primary': modelValue !== option,
}"
@click="setOption(option)"
>
<slot :option="option" :selected="modelValue === option" />
</button>
<div
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: initialized ? 1 : 0,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts" generic="T">
import { ref, computed, onMounted } from "vue";
const modelValue = defineModel<T>({ required: true });
const props = defineProps<{
options: T[];
}>();
const scrollContainer = ref<HTMLElement | null>(null);
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
const optionButtons = ref();
const initialized = ref(false);
function setOption(option: T) {
modelValue.value = option;
}
watch(modelValue, () => {
startAnimation(props.options.indexOf(modelValue.value));
});
function startAnimation(index: number) {
const el = optionButtons.value[index];
if (!el || !el.offsetParent) return;
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
initialized.value = true;
}
onMounted(() => {
startAnimation(props.options.indexOf(modelValue.value));
});
</script>
<style scoped>
.navtabs-transition {
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
</style>

View File

@@ -133,7 +133,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
props,
);
} else {
const returnTopN = 5;
const returnTopN = 15;
const listEntries = series
.map((value, index) => [

View File

@@ -63,6 +63,7 @@ const props = defineProps<{
loader: string | null;
loader_version: string | null;
};
ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();

View File

@@ -127,7 +127,10 @@
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
@@ -146,7 +149,10 @@
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
/>
</div>
<div class="mt-4 flex justify-start gap-4">
@@ -217,6 +223,7 @@ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
initialSetup?: boolean;
}>();
const emit = defineEmits<{
@@ -313,7 +320,7 @@ const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
}
if (loader === "purpur") {
@@ -456,7 +463,7 @@ const handleReinstall = async () => {
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
hardReset.value,
props.initialSetup ? true : hardReset.value,
);
emit("reinstall", {

View File

@@ -0,0 +1,278 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
:initial-setup="ignoreCurrentInstallation"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const { formatMessage } = useVIntl();
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
ignoreCurrentInstallation?: boolean;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const isInstalling = computed(() => props.server.general?.status === "installing");
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const data = computed(() => props.server.general);
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -43,7 +43,14 @@
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
@@ -73,7 +80,7 @@
</template>
<script setup lang="ts">
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>();
@@ -106,4 +113,5 @@ if (import.meta.server && projectData.value?.icon_url) {
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
</script>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
import type { MessageDescriptor } from "@vintl/vintl";
import { formatPrice } from "../../../../../../../packages/utils";
const { formatMessage } = useVIntl();
const { formatMessage, locale } = useVIntl();
const emit = defineEmits<{
(e: "select" | "scroll-to-faq"): void;
@@ -18,8 +18,8 @@ const plans: Record<
accentText: string;
accentBg: string;
name: MessageDescriptor;
symbol: MessageDescriptor;
description: MessageDescriptor;
mostPopular: boolean;
}
> = {
small: {
@@ -30,15 +30,11 @@ const plans: Record<
id: "servers.plan.small.name",
defaultMessage: "Small",
}),
symbol: defineMessage({
id: "servers.plan.small.symbol",
defaultMessage: "S",
}),
description: defineMessage({
id: "servers.plan.small.description",
defaultMessage:
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
defaultMessage: "Perfect for 15 friends with a few light mods.",
}),
mostPopular: false,
},
medium: {
buttonColor: "green",
@@ -48,14 +44,11 @@ const plans: Record<
id: "servers.plan.medium.name",
defaultMessage: "Medium",
}),
symbol: defineMessage({
id: "servers.plan.medium.symbol",
defaultMessage: "M",
}),
description: defineMessage({
id: "servers.plan.medium.description",
defaultMessage: "Great for modded multiplayer and small communities.",
defaultMessage: "Great for 615 players and multiple mods.",
}),
mostPopular: true,
},
large: {
buttonColor: "purple",
@@ -65,14 +58,11 @@ const plans: Record<
id: "servers.plan.large.name",
defaultMessage: "Large",
}),
symbol: defineMessage({
id: "servers.plan.large.symbol",
defaultMessage: "L",
}),
description: defineMessage({
id: "servers.plan.large.description",
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
defaultMessage: "Ideal for 1525 players, modpacks, or heavy modding.",
}),
mostPopular: false,
},
};
@@ -83,42 +73,30 @@ const props = defineProps<{
storage: number;
cpus: number;
price: number;
interval: "monthly" | "quarterly" | "yearly";
currency: string;
isUsa: boolean;
}>();
const outOfStock = computed(() => {
return !props.capacity || props.capacity === 0;
});
const lowStock = computed(() => {
return !props.capacity || props.capacity < 8;
});
const formattedRam = computed(() => {
return props.ram / 1024;
});
const formattedStorage = computed(() => {
return props.storage / 1024;
});
const sharedCpus = computed(() => {
return props.cpus / 2;
const billingMonths = computed(() => {
if (props.interval === "yearly") {
return 12;
} else if (props.interval === "quarterly") {
return 3;
}
return 1;
});
</script>
<template>
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div
v-if="lowStock"
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
>
<template v-if="outOfStock"> Out of stock! </template>
<template v-else> Only {{ capacity }} left in stock! </template>
</div>
<li class="relative flex w-full flex-col justify-between">
<div
:style="
plan === 'medium'
plans[plan].mostPopular
? {
background: `radial-gradient(
86.12% 101.64% at 95.97% 94.07%,
@@ -131,55 +109,41 @@ const sharedCpus = computed(() => {
: undefined
"
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': lowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-row flex-wrap items-center gap-3">
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
<div
class="grid size-8 place-content-center rounded-full text-xs font-bold"
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
v-if="plans[plan].mostPopular"
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
>
{{ formatMessage(plans[plan].symbol) }}
Most popular
</div>
</div>
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
<div
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
>
<p class="m-0">{{ formattedRam }} GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ formattedStorage }} GB SSD</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
</div>
<div class="flex items-center gap-2 text-secondary">
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
<nuxt-link
v-tooltip="
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
"
to="/servers#cpu-burst"
@click="() => emit('scroll-to-faq')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</nuxt-link>
</div>
<span class="m-0 text-2xl font-bold text-contrast">
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
{{ formatPrice(locale, price / billingMonths, currency, true) }}
{{ isUsa ? "" : currency }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
</span>
</span>
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
</div>
<ButtonStyled
:color="plans[plan].buttonColor"
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
size="large"
>
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
<button v-else @click="() => emit('select')">
Get Started
<RightArrowIcon class="shrink-0" />
</button>
<button v-else @click="() => emit('select')">Select plan</button>
</ButtonStyled>
<ServersSpecs
:ram="ram"
:storage="storage"
:cpus="cpus"
:bursting-link="'/servers#cpu-burst'"
@click-bursting-link="() => emit('scroll-to-faq')"
/>
</div>
</li>
</template>

View File

@@ -24,6 +24,7 @@ export const useUserCountry = () => {
if (import.meta.client) {
onMounted(() => {
if (fromServer.value) return;
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage
const lang = navigator.language || navigator.userLanguage || "";
const region = lang.split("-")[1];
if (region) {

View File

@@ -49,7 +49,9 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
const fullUrl = override?.url
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`
: `${base}/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;

View File

@@ -2,6 +2,7 @@
import { $fetch, FetchError } from "ofetch";
import type { ServerNotice } from "@modrinth/utils";
import type { FilesystemOp, FSQueuedOp, WSBackupState, WSBackupTask } from "~/types/servers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -330,6 +331,9 @@ interface General {
token: string;
instance: string;
};
flows?: {
intro?: boolean;
};
}
interface Allocation {
@@ -1221,6 +1225,13 @@ const modules: any = {
suspend: suspendServer,
getMotd,
setMotd,
endIntro: async () => {
await usePyroFetch(`servers/${internalServerReference.value.serverId}/flows/intro`, {
method: "DELETE",
version: 1,
});
await internalServerReference.value.refresh(["general"]);
},
},
content: {
get: async (serverId: string) => {
@@ -1451,6 +1462,8 @@ type GeneralFunctions = {
* @deprecated Use fs.downloadFile instead
*/
fetchConfigFile: (fileName: string) => Promise<any>;
endIntro: () => Promise<void>;
};
type ContentFunctions = {

View File

@@ -77,6 +77,9 @@ const errorMessages = computed(
const route = useRoute();
// TODO: REMOVE BEFORE MERGE
console.log(props.error);
watch(route, () => {
console.log(route);
});

View File

@@ -159,7 +159,7 @@
"message": "Subscribe to updates about Modrinth"
},
"auth.welcome.description": {
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods."
},
"auth.welcome.label.tos": {
"message": "By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
@@ -350,11 +350,14 @@
"layout.banner.add-email.button": {
"message": "Visit account settings"
},
"layout.banner.add-email.description": {
"message": "For security reasons, Modrinth needs you to register an email address to your account."
},
"layout.banner.build-fail.description": {
"message": "This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}"
},
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building"
"message": "Error generating state from API when building."
},
"layout.banner.staging.description": {
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
@@ -365,12 +368,12 @@
"layout.banner.subscription-payment-failed.button": {
"message": "Update billing info"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required"
},
"layout.banner.subscription-payment-failed.description": {
"message": "One or more subscriptions failed to renew. Please update your payment method to prevent losing access!"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Billing action required."
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
@@ -1047,32 +1050,23 @@
"message": "No notices"
},
"servers.plan.large.description": {
"message": "Ideal for larger communities, modpacks, and heavy modding."
"message": "Ideal for 1525 players, modpacks, or heavy modding."
},
"servers.plan.large.name": {
"message": "Large"
},
"servers.plan.large.symbol": {
"message": "L"
},
"servers.plan.medium.description": {
"message": "Great for modded multiplayer and small communities."
"message": "Great for 615 players and multiple mods."
},
"servers.plan.medium.name": {
"message": "Medium"
},
"servers.plan.medium.symbol": {
"message": "M"
},
"servers.plan.small.description": {
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
"message": "Perfect for 15 friends with a few light mods."
},
"servers.plan.small.name": {
"message": "Small"
},
"servers.plan.small.symbol": {
"message": "S"
},
"settings.billing.modal.cancel.action": {
"message": "Cancel subscription"
},

View File

@@ -4,27 +4,28 @@
data-pyro
class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8"
>
<PurchaseModal
v-if="showModal && selectedProduct && customer"
:key="selectedProduct.id"
<ModrinthServersPurchaseModal
v-if="customer"
:key="`purchase-modal-${customer.id}`"
ref="purchaseModal"
:product="selectedProduct"
:country="country"
:custom-server="customServer"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
:initiate-payment="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:fetch-payment-data="fetchPaymentData"
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
:fetch-capacity-statuses="fetchCapacityStatuses"
:out-of-stock-url="outOfStockUrl"
@hidden="handleModalHidden"
:fetch-capacity-statuses="fetchCapacityStatuses"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
/>
<section
@@ -442,8 +443,8 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Currently, Modrinth Servers are located on the east coast of the United States in
Vint Hill, Virginia. More regions to come in the future!
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
Germany. More regions to come in the future!
</p>
</details>
@@ -497,98 +498,6 @@
</div>
</section>
<section
v-if="false"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Server Locations
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Coast-to-Coast Coverage
</h1>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-brand"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
US Coverage
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With strategically placed servers in New York, California, Texas, Florida, and
Washington, we ensure low latency connections for players across North America.
Each location is equipped with high-performance hardware and DDoS protection.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue"
>
<path d="M12 2a10 10 0 1 0 10 10" />
<path d="M18 13a6 6 0 0 0-6-6" />
<path d="M13 2.05a10 10 0 0 1 2 2" />
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Global Expansion
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
We're expanding to Europe and Asia-Pacific regions soon, bringing Modrinth's
seamless hosting experience worldwide. Join our Discord to stay updated on new
region launches.
</p>
</div>
</div>
</div>
<Globe />
</div>
</div>
</section>
<section
id="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
@@ -596,19 +505,35 @@
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
Start your server on Modrinth
There's a server for everyone
</h1>
<h2
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
{{
isAtCapacity && !loggedOut
? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs."
}}
</h2>
<p class="m-0 flex items-center gap-1">
Available in North America and Europe for wide coverage.
</p>
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<span></span>
<OptionGroup v-slot="{ option }" v-model="billingPeriod" :options="billingPeriods">
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
</OptionGroup>
<template v-if="billingPeriods.includes('quarterly')">
<button
v-if="billingPeriod !== 'quarterly'"
class="bg-transparent p-0 text-sm font-medium text-brand hover:underline active:scale-95"
@click="billingPeriod = 'quarterly'"
>
Save 16% with quarterly billing!
</button>
<span v-else class="bg-transparent p-0 text-sm font-medium text-brand">
Save 16% with quarterly billing!
</span>
</template>
<span v-else></span>
</div>
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
plan="small"
@@ -616,9 +541,12 @@
:storage="plans.small.metadata.storage"
:cpus="plans.small.metadata.cpu"
:price="
plans.small?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.small?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('small')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -629,9 +557,12 @@
:storage="plans.medium.metadata.storage"
:cpus="plans.medium.metadata.cpu"
:price="
plans.medium?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.medium?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:interval="billingPeriod"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
@select="selectProduct('medium')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -641,10 +572,13 @@
:storage="plans.large.metadata.storage"
:cpus="plans.large.metadata.cpu"
:price="
plans.large?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
?.monthly
plans.large?.prices?.find((x) => x.currency_code === selectedCurrency)?.prices
?.intervals?.[billingPeriod]
"
:currency="selectedCurrency"
:is-usa="country.toLowerCase() === 'us'"
plan="large"
:interval="billingPeriod"
@select="selectProduct('large')"
@scroll-to-faq="scrollToFaq()"
/>
@@ -654,10 +588,9 @@
class="mb-24 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
>
<div class="flex flex-col gap-4">
<h1 class="m-0">Build your own</h1>
<h1 class="m-0">Know exactly what you need?</h1>
<h2 class="m-0 text-base font-normal text-primary">
If you're a more technical server administrator, you can pick your own RAM and storage
options.
Pick a customized plan with just the specs you need.
</h2>
</div>
@@ -666,7 +599,7 @@
>
<ButtonStyled color="standard" size="large">
<button class="w-full md:w-fit" @click="selectProduct('custom')">
Build your own
Get started
<RightArrowIcon class="shrink-0" />
</button>
</ButtonStyled>
@@ -679,7 +612,7 @@
</template>
<script setup>
import { ButtonStyled, PurchaseModal } from "@modrinth/ui";
import { ButtonStyled, ModrinthServersPurchaseModal } from "@modrinth/ui";
import {
BoxIcon,
GameIcon,
@@ -691,8 +624,11 @@ import {
} from "@modrinth/assets";
import { products } from "~/generated/state.json";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import Globe from "~/components/ui/servers/Globe.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue";
const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter(
@@ -711,16 +647,6 @@ useSeoMeta({
ogDescription: description,
});
useHead({
script: [
{
src: "https://js.stripe.com/v3/",
defer: true,
async: true,
},
],
});
const auth = await useAuth();
const data = useNuxtApp();
const config = useRuntimeConfig();
@@ -740,6 +666,7 @@ const isDeleting = ref(false);
const typingSpeed = 75;
const deletingSpeed = 25;
const pauseTime = 2000;
const selectedCurrency = ref("USD");
const loggedOut = computed(() => !auth.value.user);
const outOfStockUrl = "https://discord.modrinth.com";
@@ -754,6 +681,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
}
});
function fetchStock(region, request) {
return usePyroFetch(`stock?region=${region.shortcode}`, {
method: "POST",
body: {
...request,
},
bypassAuth: true,
}).then((res) => res.available);
}
async function fetchCapacityStatuses(customProduct = null) {
try {
const productsToCheck = customProduct?.metadata
@@ -841,23 +778,6 @@ const handleError = (err) => {
});
};
const handleModalHidden = () => {
showModal.value = false;
};
watch(selectedProduct, async (newProduct) => {
if (newProduct) {
showModal.value = false;
await nextTick();
showModal.value = true;
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
}
});
async function fetchPaymentData() {
if (!auth.value.user) return;
try {
@@ -954,8 +874,10 @@ const selectProduct = async (product) => {
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
if (product === "custom") {
purchaseModal.value?.show(billingPeriod.value);
} else {
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value);
}
};
@@ -966,9 +888,82 @@ const planQuery = () => {
}
};
const regions = ref([]);
const regionPings = ref([]);
function pingRegions() {
usePyroFetch("regions", {
method: "GET",
version: 1,
bypassAuth: true,
}).then((res) => {
regions.value = res;
regions.value.forEach((region) => {
runPingTest(region);
});
});
}
const PING_COUNT = 20;
const PING_INTERVAL = 200;
const MAX_PING_TIME = 1000;
function runPingTest(region, index = 1) {
if (index > 10) {
regionPings.value.push({
region: region.shortcode,
ping: -1,
});
return;
}
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`;
try {
const socket = new WebSocket(wsUrl);
const pings = [];
socket.onopen = () => {
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(performance.now());
}, i * PING_INTERVAL);
}
setTimeout(
() => {
socket.close();
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)]);
if (median) {
regionPings.value.push({
region: region.shortcode,
ping: median,
});
}
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
);
};
socket.onmessage = (event) => {
pings.push(performance.now() - event.data);
};
socket.onerror = (event) => {
console.error(
`Failed to connect pingtest WebSocket with ${wsUrl}, trying index ${index + 1}:`,
event,
);
runPingTest(region, index + 1);
};
} catch (error) {
console.error(`Failed to connect pingtest WebSocket with ${wsUrl}:`, error);
}
}
onMounted(() => {
startTyping();
planQuery();
pingRegions();
});
watch(customer, (newCustomer) => {

View File

@@ -207,6 +207,7 @@
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
@@ -220,7 +221,14 @@
</div>
</div>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<UiServersServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
@@ -231,149 +239,189 @@
</div>
</div>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<template v-if="serverData.flows?.intro">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<LazyUiServersPanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
<div
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may not
be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version. You
can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling your
server with a different version or loader, and if the problem persists, please
contact Modrinth Support with your server's debug information.
<ServerInstallation
:server="server"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
</template>
<template v-else>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret — try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</template>
</div>
<div
v-if="flags.advancedDebugInfo"
class="experimental-styles-within relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
JSON.stringify(server, null, " ")
}}</pre>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import {
SettingsIcon,
CopyIcon,
IssuesIcon,
LeftArrowIcon,
@@ -392,6 +440,7 @@ import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/t
import { usePyroConsole } from "~/store/console.ts";
import { type Backup } from "~/composables/pyroServers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
const app = useNuxtApp() as unknown as { $notify: any };
@@ -401,6 +450,7 @@ const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isFirstMount = ref(true);
const isMounted = ref(true);
const flags = useFeatureFlags();
const INTERCOM_APP_ID = ref("ykeritl9");
const auth = (await useAuth()) as unknown as {
@@ -812,6 +862,10 @@ const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null);
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
server.general?.endIntro();
}
if (!serverData.value) return;
serverData.value.status = "installing";
@@ -1131,6 +1185,10 @@ onMounted(() => {
connectWebSocket();
}
if (server.general?.flows?.intro && server.general?.project) {
server.general?.endIntro();
}
if (username.value && email.value && userId.value && createdAt.value) {
const currentUser = auth.value?.user as any;
const matches =

View File

@@ -1,162 +1,15 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
<ServerInstallation
:server="props.server"
:current-loader="data?.loader as Loaders"
:backup-in-progress="backupInProgress"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="data"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const { formatMessage } = useVIntl();
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
@@ -166,104 +19,4 @@ const props = defineProps<{
const emit = defineEmits<{
reinstall: [any?];
}>();
const isInstalling = computed(() => props.server.general?.status === "installing");
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const data = computed(() => props.server.general);
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="hasError || fetchError"
@@ -89,7 +89,10 @@
</div>
</div>
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
<ul
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
@@ -102,6 +105,7 @@
:mc_version="server.mc_version"
:upstream="server.upstream"
:net="server.net"
:flows="server.flows"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
@@ -133,6 +137,7 @@ interface ServerResponse {
servers: Server[];
}
const router = useRouter();
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
@@ -163,11 +168,19 @@ const fuse = computed(() => {
});
});
function introToTop(array: Server[]): Server[] {
return array.slice().sort((a, b) => {
return Number(b.flows?.intro) - Number(a.flows?.intro);
});
}
const filteredData = computed(() => {
if (!searchInput.value.trim()) {
return serverList.value;
return introToTop(serverList.value);
}
return fuse.value ? fuse.value.search(searchInput.value).map((result) => result.item) : [];
return fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: [];
});
const previousServerList = ref<Server[]>([]);
@@ -179,6 +192,7 @@ const checkForNewServers = async () => {
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
router.replace({ query: {} });
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false;
clearInterval(intervalId);

View File

@@ -353,6 +353,21 @@
Upgrade
</button>
</ButtonStyled>
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
color="purple"
color-fill="text"
>
<button @click="showPyroIntervalChange(subscription)">
<TransferIcon />
<!-- TODO: Make this attractive af for monthly subscribers -->
Change billing interval
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="
getPyroCharge(subscription) &&
@@ -412,6 +427,31 @@
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal
v-if="currentProduct"
ref="pyroIntervalModal"
:product="[currentProduct]"
:country="country"
custom-server
interval-change-only
:existing-subscription="currentSubscription"
:existing-plan="currentProduct"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) => {
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
internal: true,
method: 'PATCH',
body,
});
}
"
:renewal-date="currentSubRenewalDate"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal
ref="pyroPurchaseModal"
:product="upgradeProducts"
@@ -444,39 +484,13 @@
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<NewModal ref="addPaymentMethodModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.paymentMethodTitle) }}
</span>
</template>
<div class="min-h-[16rem] md:w-[600px]">
<div
v-show="loadingPaymentMethodModal !== 2"
class="flex min-h-[16rem] items-center justify-center"
>
<AnimatedLogo class="w-[80px]" />
</div>
<div v-show="loadingPaymentMethodModal === 2" class="min-h-[16rem] p-1">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<AddPaymentMethodModal
ref="addPaymentMethodModal"
:publishable-key="config.public.stripePublishableKey"
:return-url="`${config.public.siteUrl}/settings/billing`"
:create-setup-intent="createSetupIntent"
:on-error="handleError"
/>
<div class="header__row">
<div class="header__title">
<h2 class="text-2xl">{{ formatMessage(messages.paymentMethodTitle) }}</h2>
@@ -590,9 +604,8 @@
<script setup>
import {
ConfirmModal,
NewModal,
AddPaymentMethodModal,
OverflowMenu,
AnimatedLogo,
PurchaseModal,
ButtonStyled,
CopyCode,
@@ -617,7 +630,7 @@ import {
UpdatedIcon,
HistoryIcon,
} from "@modrinth/assets";
import { calculateSavings, formatPrice, createStripeElements, getCurrency } from "@modrinth/utils";
import { calculateSavings, formatPrice, getCurrency } from "@modrinth/utils";
import { ref, computed } from "vue";
import { products } from "~/generated/state.json";
@@ -754,19 +767,6 @@ const paymentMethodTypes = defineMessages({
},
});
let stripe = null;
let elements = null;
function loadStripe() {
try {
if (!stripe) {
stripe = Stripe(config.public.stripePublishableKey);
}
} catch (error) {
console.error("Error loading Stripe:", error);
}
}
const [
{ data: paymentMethods, refresh: refreshPaymentMethods },
{ data: charges, refresh: refreshCharges },
@@ -842,69 +842,16 @@ const primaryPaymentMethodId = computed(() => {
});
const addPaymentMethodModal = ref();
const loadingPaymentMethodModal = ref(0);
async function addPaymentMethod() {
try {
loadingPaymentMethodModal.value = 0;
addPaymentMethodModal.value.show();
const result = await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
});
loadStripe();
const {
elements: elementsVal,
addressElement,
paymentElement,
} = createStripeElements(stripe, paymentMethods.value, {
clientSecret: result.client_secret,
});
elements = elementsVal;
paymentElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
addressElement.on("ready", () => {
loadingPaymentMethodModal.value += 1;
});
} catch (err) {
data.$notify({
group: "main",
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
function addPaymentMethod() {
addPaymentMethodModal.value.show(paymentMethods.value);
}
const loadingAddMethod = ref(false);
async function submit() {
startLoading();
loadingAddMethod.value = true;
loadStripe();
const { error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${config.public.siteUrl}/settings/billing`,
},
async function createSetupIntent() {
return await useBaseFetch("billing/payment_method", {
internal: true,
method: "POST",
});
if (error && error.type !== "validation_error") {
data.$notify({
group: "main",
title: "An error occurred",
text: error.message,
type: "error",
});
} else if (!error) {
await refresh();
addPaymentMethodModal.value.close();
}
loadingAddMethod.value = false;
stopLoading();
}
const removePaymentMethodIndex = ref();
@@ -915,6 +862,18 @@ const oppositeInterval = computed(() =>
midasCharge.value?.subscription_interval === "yearly" ? "monthly" : "yearly",
);
async function showPyroIntervalChange(subscription) {
currentSubscription.value = subscription;
currentSubRenewalDate.value = getPyroCharge(subscription).due;
currentProduct.value = getPyroProduct(subscription);
upgradeProducts.value = [currentProduct.value];
upgradeProducts.value.metadata = { type: "pyro" };
await nextTick();
pyroIntervalModal.value.show();
}
async function switchMidasInterval(interval) {
changingInterval.value = true;
startLoading();
@@ -1034,6 +993,7 @@ const getProductPrice = (product, interval) => {
const modalCancel = ref(null);
const pyroPurchaseModal = ref();
const pyroIntervalModal = ref();
const currentSubscription = ref(null);
const currentProduct = ref(null);
const upgradeProducts = ref([]);

View File

@@ -104,6 +104,9 @@ export interface Server {
version_id: string;
};
motd: string;
flows: {
intro?: boolean;
};
}
export interface Stats {

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43"
}

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7"
}

View File

@@ -0,0 +1,12 @@
-- This index substantially brings down the cost of the query plan for the
-- hot query at `labrinth::routes::v3::projects::random_projects_get`, from
-- 354.04..363.39 to 171.33..180.68 (~2x improvement).
--
-- The numbers above were calculated in a clean PostgreSQL 17.5.0 container
-- with 10k mods created with the SQL below.
--
-- WITH seq AS (SELECT n FROM GENERATE_SERIES(1, 10000) AS n)
-- INSERT INTO mods (id, team_id, name, summary, icon_url, license_url, slug, status)
-- SELECT n, 1, n, '', '', '', n, (ARRAY['approved', 'pending'])[n % 2 + 1] from seq;
CREATE INDEX mods_status ON mods(status);

View File

@@ -0,0 +1,12 @@
-- The spatial query for retrieving random searchable projects is greatly sped
-- up by this index on a fixture of 1M mods, bringing down the total cost of
-- the query plan and runtime to be comparable to primary key lookups. See the
-- `labrinth::routes::v3::projects::random_projects_get` function and the
-- previous 20250608183828_random-project-index.sql migration for more details.
--
-- That previous migration created a non-spatial index for the status column which
-- does not get used in the new spatial query, but may still be useful for other
-- queries that filter mods by status.
CREATE INDEX mods_searchable_ids_gist ON mods USING gist (POINT(id, 0))
WHERE status = ANY(ARRAY['approved', 'archived']);

View File

@@ -68,7 +68,7 @@ pub async fn init_oauth(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -323,7 +323,7 @@ pub async fn accept_or_reject_client_scopes(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;

View File

@@ -15,7 +15,7 @@ pub async fn get_user_from_headers<'a, E>(
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Option<&[Scopes]>,
required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
@@ -33,12 +33,8 @@ where
let user = User::from_full(db_user);
if let Some(required_scopes) = required_scopes {
for scope in required_scopes {
if !scopes.contains(*scope) {
return Err(AuthenticationError::InvalidCredentials);
}
}
if !scopes.contains(required_scopes) {
return Err(AuthenticationError::InvalidCredentials);
}
Ok((scopes, user))
@@ -175,7 +171,7 @@ pub async fn check_is_moderator_from_headers<'a, 'b, E>(
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Option<&[Scopes]>,
required_scopes: Scopes,
) -> Result<User, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,

View File

@@ -145,7 +145,7 @@ impl SubscriptionStatus {
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum SubscriptionMetadata {
Pyro { id: String },
Pyro { id: String, region: Option<String> },
}
#[derive(Serialize, Deserialize)]

View File

@@ -519,6 +519,9 @@ impl ProjectStatus {
}
// Project can be displayed in search
// IMPORTANT: if this is changed, make sure to update the `mods_searchable_ids_gist`
// index in the DB to keep random project queries fast (see the
// `20250609134334_spatial-random-project-index.sql` migration)
pub fn is_searchable(&self) -> bool {
matches!(self, ProjectStatus::Approved | ProjectStatus::Archived)
}

View File

@@ -55,10 +55,15 @@ pub async fn page_view_ingest(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user =
get_user_from_headers(&req, &**pool, &redis, &session_queue, None)
.await
.ok();
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await
.ok();
let conn_info = req.connection_info().peer_addr().map(|x| x.to_string());
let url = Url::parse(&url_input.url).map_err(|_| {
@@ -177,7 +182,7 @@ pub async fn playtime_ingest(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PERFORM_ANALYTICS]),
Scopes::PERFORM_ANALYTICS,
)
.await?;

View File

@@ -102,7 +102,7 @@ pub async fn subscriptions(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -161,7 +161,7 @@ pub async fn refund_charge(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -325,7 +325,7 @@ pub async fn edit_subscription(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -585,7 +585,7 @@ pub async fn user_customer(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -623,7 +623,7 @@ pub async fn charges(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -682,7 +682,7 @@ pub async fn add_payment_method_flow(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -736,7 +736,7 @@ pub async fn edit_payment_method(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -805,7 +805,7 @@ pub async fn remove_payment_method(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -892,7 +892,7 @@ pub async fn payment_methods(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -954,17 +954,19 @@ pub async fn active_servers(
pub server_id: String,
pub price_id: crate::models::ids::ProductPriceId,
pub interval: PriceDuration,
pub region: Option<String>,
}
let server_ids = servers
.into_iter()
.filter_map(|x| {
x.metadata.as_ref().map(|metadata| match metadata {
SubscriptionMetadata::Pyro { id } => ActiveServer {
SubscriptionMetadata::Pyro { id, region } => ActiveServer {
user_id: x.user_id.into(),
server_id: id.clone(),
price_id: x.price_id.into(),
interval: x.interval,
region: region.clone(),
},
})
})
@@ -1087,7 +1089,7 @@ pub async fn initiate_payment(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -1764,8 +1766,10 @@ pub async fn stripe_webhook(
{
let client = reqwest::Client::new();
if let Some(SubscriptionMetadata::Pyro { id }) =
&subscription.metadata
if let Some(SubscriptionMetadata::Pyro {
id,
region: _,
}) = &subscription.metadata
{
client
.post(format!(
@@ -1880,6 +1884,7 @@ pub async fn stripe_webhook(
subscription.metadata =
Some(SubscriptionMetadata::Pyro {
id: res.uuid,
region: server_region,
});
}
}
@@ -2240,7 +2245,7 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
true
}
ProductMetadata::Pyro { .. } => {
if let Some(SubscriptionMetadata::Pyro { id }) =
if let Some(SubscriptionMetadata::Pyro { id, region: _ }) =
&subscription.metadata
{
let res = reqwest::Client::new()

View File

@@ -1243,7 +1243,7 @@ pub async fn delete_auth_provider(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -1663,7 +1663,7 @@ pub async fn begin_2fa_flow(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -1708,7 +1708,7 @@ pub async fn finish_2fa_flow(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -2140,7 +2140,7 @@ pub async fn set_email(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -2223,7 +2223,7 @@ pub async fn resend_verify_email(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;
@@ -2328,7 +2328,7 @@ pub async fn subscribe_newsletter(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_AUTH_WRITE]),
Scopes::USER_AUTH_WRITE,
)
.await?
.1;

View File

@@ -22,7 +22,7 @@ pub async fn export(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;

View File

@@ -39,7 +39,7 @@ pub async fn get_projects(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await?;
@@ -82,7 +82,7 @@ pub async fn get_project_meta(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await?;
@@ -234,7 +234,7 @@ pub async fn set_project_meta(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await?;

View File

@@ -39,7 +39,7 @@ pub async fn get_pats(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_READ]),
Scopes::PAT_READ,
)
.await?
.1;
@@ -99,7 +99,7 @@ pub async fn create_pat(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_CREATE]),
Scopes::PAT_CREATE,
)
.await?
.1;
@@ -174,7 +174,7 @@ pub async fn edit_pat(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_WRITE]),
Scopes::PAT_WRITE,
)
.await?
.1;
@@ -266,7 +266,7 @@ pub async fn delete_pat(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_DELETE]),
Scopes::PAT_DELETE,
)
.await?
.1;

View File

@@ -141,7 +141,7 @@ pub async fn list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_READ]),
Scopes::SESSION_READ,
)
.await?
.1;
@@ -178,7 +178,7 @@ pub async fn delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_DELETE]),
Scopes::SESSION_DELETE,
)
.await?
.1;
@@ -212,10 +212,15 @@ pub async fn refresh(
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user =
get_user_from_headers(&req, &**pool, &redis, &session_queue, None)
.await?
.1;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
let session = req
.headers()
.get(AUTHORIZATION)

View File

@@ -88,7 +88,7 @@ pub async fn maven_metadata(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -294,7 +294,7 @@ pub async fn version_file(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -360,7 +360,7 @@ pub async fn version_file_sha1(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -405,7 +405,7 @@ pub async fn version_file_sha512(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)

View File

@@ -51,7 +51,7 @@ pub async fn forge_updates(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)

View File

@@ -82,7 +82,7 @@ pub async fn playtimes_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
Scopes::ANALYTICS,
)
.await
.map(|x| x.1)?;
@@ -151,7 +151,7 @@ pub async fn views_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
Scopes::ANALYTICS,
)
.await
.map(|x| x.1)?;
@@ -220,7 +220,7 @@ pub async fn downloads_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
Scopes::ANALYTICS,
)
.await
.map(|x| x.1)?;
@@ -289,7 +289,7 @@ pub async fn revenue_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
Scopes::PAYOUTS_READ,
)
.await
.map(|x| x.1)?;
@@ -430,7 +430,7 @@ pub async fn countries_downloads_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
Scopes::ANALYTICS,
)
.await
.map(|x| x.1)?;
@@ -503,7 +503,7 @@ pub async fn countries_views_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
Scopes::ANALYTICS,
)
.await
.map(|x| x.1)?;

View File

@@ -71,7 +71,7 @@ pub async fn collection_create(
&**client,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_CREATE]),
Scopes::COLLECTION_CREATE,
)
.await?
.1;
@@ -156,7 +156,7 @@ pub async fn collections_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
Scopes::COLLECTION_READ,
)
.await
.map(|x| x.1)
@@ -185,7 +185,7 @@ pub async fn collection_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
Scopes::COLLECTION_READ,
)
.await
.map(|x| x.1)
@@ -231,7 +231,7 @@ pub async fn collection_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
Scopes::COLLECTION_WRITE,
)
.await?
.1;
@@ -390,7 +390,7 @@ pub async fn collection_icon_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
Scopes::COLLECTION_WRITE,
)
.await?
.1;
@@ -471,7 +471,7 @@ pub async fn delete_collection_icon(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
Scopes::COLLECTION_WRITE,
)
.await?
.1;
@@ -528,7 +528,7 @@ pub async fn collection_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_DELETE]),
Scopes::COLLECTION_DELETE,
)
.await?
.1;

View File

@@ -36,7 +36,7 @@ pub async fn add_friend(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -154,7 +154,7 @@ pub async fn remove_friend(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -200,7 +200,7 @@ pub async fn friends(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
Scopes::USER_READ,
)
.await?
.1;

View File

@@ -49,14 +49,12 @@ pub async fn images_add(
) -> Result<HttpResponse, ApiError> {
let mut context = ImageContext::from_str(&data.context, None);
let scopes = vec![context.relevant_scope()];
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&scopes),
context.relevant_scope(),
)
.await?
.1;

View File

@@ -40,7 +40,7 @@ pub async fn notifications_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
Scopes::NOTIFICATION_READ,
)
.await?
.1;
@@ -82,7 +82,7 @@ pub async fn notification_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
Scopes::NOTIFICATION_READ,
)
.await?
.1;
@@ -119,7 +119,7 @@ pub async fn notification_read(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
Scopes::NOTIFICATION_WRITE,
)
.await?
.1;
@@ -169,7 +169,7 @@ pub async fn notification_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
Scopes::NOTIFICATION_WRITE,
)
.await?
.1;
@@ -220,7 +220,7 @@ pub async fn notifications_read(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
Scopes::NOTIFICATION_WRITE,
)
.await?
.1;
@@ -273,7 +273,7 @@ pub async fn notifications_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
Scopes::NOTIFICATION_WRITE,
)
.await?
.1;

View File

@@ -69,7 +69,7 @@ pub async fn get_user_clients(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -167,7 +167,7 @@ pub async fn oauth_client_create(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -228,7 +228,7 @@ pub async fn oauth_client_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -285,7 +285,7 @@ pub async fn oauth_client_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -363,7 +363,7 @@ pub async fn oauth_client_icon_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -430,7 +430,7 @@ pub async fn oauth_client_icon_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -477,7 +477,7 @@ pub async fn get_user_oauth_authorizations(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;
@@ -507,7 +507,7 @@ pub async fn revoke_oauth_authorization(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await?
.1;

View File

@@ -63,7 +63,7 @@ pub async fn organization_projects_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]),
Scopes::ORGANIZATION_READ | Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -127,7 +127,7 @@ pub async fn organization_create(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_CREATE]),
Scopes::ORGANIZATION_CREATE,
)
.await?
.1;
@@ -224,7 +224,7 @@ pub async fn organization_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)
@@ -315,7 +315,7 @@ pub async fn organizations_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)
@@ -396,7 +396,7 @@ pub async fn organizations_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
Scopes::ORGANIZATION_WRITE,
)
.await?
.1;
@@ -559,7 +559,7 @@ pub async fn organization_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_DELETE]),
Scopes::ORGANIZATION_DELETE,
)
.await?
.1;
@@ -700,7 +700,7 @@ pub async fn organization_projects_add(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE,
)
.await?
.1;
@@ -863,7 +863,7 @@ pub async fn organization_projects_remove(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE,
)
.await?
.1;
@@ -1051,7 +1051,7 @@ pub async fn organization_icon_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
Scopes::ORGANIZATION_WRITE,
)
.await?
.1;
@@ -1154,7 +1154,7 @@ pub async fn delete_organization_icon(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
Scopes::ORGANIZATION_WRITE,
)
.await?
.1;

View File

@@ -313,7 +313,7 @@ pub async fn user_payouts(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
Scopes::PAYOUTS_READ,
)
.await?
.1;
@@ -653,7 +653,7 @@ pub async fn cancel_payout(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_WRITE]),
Scopes::PAYOUTS_WRITE,
)
.await?
.1;
@@ -783,7 +783,7 @@ pub async fn get_balance(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
Scopes::PAYOUTS_READ,
)
.await?
.1;

View File

@@ -342,7 +342,7 @@ async fn project_create_inner(
pool,
redis,
session_queue,
Some(&[Scopes::PROJECT_CREATE]),
Scopes::PROJECT_CREATE,
)
.await?
.1;

View File

@@ -94,14 +94,21 @@ pub async fn random_projects_get(
})?;
let project_ids = sqlx::query!(
"
SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2)
",
count.count as i32,
// IDs are randomly generated (see the `generate_ids` macro), so fetching a
// number of mods nearest to a random point in the ID space is equivalent to
// random sampling
"WITH random_id_point AS (
SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point
)
SELECT id FROM mods
WHERE status = ANY($1)
ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)
LIMIT $2",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
.map(|x| x.to_string())
.collect::<Vec<String>>(),
count.count as i32,
)
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
@@ -139,7 +146,7 @@ pub async fn projects_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -168,7 +175,7 @@ pub async fn project_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -258,7 +265,7 @@ pub async fn project_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1014,7 +1021,7 @@ pub async fn dependency_list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -1126,7 +1133,7 @@ pub async fn projects_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1423,7 +1430,7 @@ pub async fn project_icon_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1534,7 +1541,7 @@ pub async fn delete_project_icon(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1641,7 +1648,7 @@ pub async fn add_gallery_item(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1799,7 +1806,7 @@ pub async fn edit_gallery_item(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1966,7 +1973,7 @@ pub async fn delete_gallery_item(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -2075,7 +2082,7 @@ pub async fn project_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_DELETE]),
Scopes::PROJECT_DELETE,
)
.await?
.1;
@@ -2178,7 +2185,7 @@ pub async fn project_follow(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -2258,7 +2265,7 @@ pub async fn project_unfollow(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -2334,7 +2341,7 @@ pub async fn project_get_organization(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::ORGANIZATION_READ]),
Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)

View File

@@ -58,7 +58,7 @@ pub async fn report_create(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_CREATE]),
Scopes::REPORT_CREATE,
)
.await?
.1;
@@ -254,7 +254,7 @@ pub async fn reports(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
Scopes::REPORT_READ,
)
.await?
.1;
@@ -338,7 +338,7 @@ pub async fn reports_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
Scopes::REPORT_READ,
)
.await?
.1;
@@ -364,7 +364,7 @@ pub async fn report_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
Scopes::REPORT_READ,
)
.await?
.1;
@@ -406,7 +406,7 @@ pub async fn report_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_WRITE]),
Scopes::REPORT_WRITE,
)
.await?
.1;
@@ -506,7 +506,7 @@ pub async fn report_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_DELETE]),
Scopes::REPORT_DELETE,
)
.await?;

View File

@@ -57,7 +57,7 @@ pub async fn team_members_get_project(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -142,7 +142,7 @@ pub async fn team_members_get_organization(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)
@@ -222,7 +222,7 @@ pub async fn team_members_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -294,7 +294,7 @@ pub async fn teams_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -348,7 +348,7 @@ pub async fn join_team(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -437,7 +437,7 @@ pub async fn add_team_member(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -692,7 +692,7 @@ pub async fn edit_team_member(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -878,7 +878,7 @@ pub async fn transfer_ownership(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;
@@ -1050,7 +1050,7 @@ pub async fn remove_team_member(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
Scopes::PROJECT_WRITE,
)
.await?
.1;

View File

@@ -285,7 +285,7 @@ pub async fn thread_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
Scopes::THREAD_READ,
)
.await?
.1;
@@ -341,7 +341,7 @@ pub async fn threads_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
Scopes::THREAD_READ,
)
.await?
.1;
@@ -379,7 +379,7 @@ pub async fn thread_send_message(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
Scopes::THREAD_WRITE,
)
.await?
.1;
@@ -577,7 +577,7 @@ pub async fn message_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
Scopes::THREAD_WRITE,
)
.await?
.1;

View File

@@ -60,7 +60,7 @@ pub async fn admin_user_email(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await
.map(|x| x.1)?;
@@ -114,7 +114,7 @@ pub async fn projects_list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -150,7 +150,7 @@ pub async fn user_auth_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
Scopes::USER_READ,
)
.await?;
@@ -200,7 +200,7 @@ pub async fn user_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
Scopes::SESSION_ACCESS,
)
.await
.map(|x| x.1)
@@ -231,7 +231,7 @@ pub async fn collections_list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
Scopes::COLLECTION_READ,
)
.await
.map(|x| x.1)
@@ -279,7 +279,7 @@ pub async fn orgs_list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
@@ -389,7 +389,7 @@ pub async fn user_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?;
@@ -561,7 +561,7 @@ pub async fn user_icon_edit(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -633,7 +633,7 @@ pub async fn user_icon_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
Scopes::USER_WRITE,
)
.await?
.1;
@@ -685,7 +685,7 @@ pub async fn user_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_DELETE]),
Scopes::USER_DELETE,
)
.await?
.1;
@@ -726,7 +726,7 @@ pub async fn user_follows(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
Scopes::USER_READ,
)
.await?
.1;
@@ -768,7 +768,7 @@ pub async fn user_notifications(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
Scopes::NOTIFICATION_READ,
)
.await?
.1;

View File

@@ -169,7 +169,7 @@ async fn version_create_inner(
pool,
redis,
session_queue,
Some(&[Scopes::VERSION_CREATE]),
Scopes::VERSION_CREATE,
)
.await?
.1;
@@ -599,7 +599,7 @@ async fn upload_file_to_version_inner(
&**client,
&redis,
session_queue,
Some(&[Scopes::VERSION_WRITE]),
Scopes::VERSION_WRITE,
)
.await?
.1;

View File

@@ -46,7 +46,7 @@ pub async fn get_version_from_hash(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -132,7 +132,7 @@ pub async fn get_update_from_hash(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -231,7 +231,7 @@ pub async fn get_versions_from_hashes(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -285,7 +285,7 @@ pub async fn get_projects_from_hashes(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
Scopes::PROJECT_READ | Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -437,7 +437,7 @@ pub async fn update_individual_files(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -569,7 +569,7 @@ pub async fn delete_file(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
Scopes::VERSION_WRITE,
)
.await?
.1;
@@ -699,7 +699,7 @@ pub async fn download_version(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)

View File

@@ -80,7 +80,7 @@ pub async fn version_project_get_helper(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
Scopes::PROJECT_READ | Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -145,7 +145,7 @@ pub async fn versions_get(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -184,7 +184,7 @@ pub async fn version_get_helper(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -281,7 +281,7 @@ pub async fn version_edit_helper(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
Scopes::VERSION_WRITE,
)
.await?
.1;
@@ -736,7 +736,7 @@ pub async fn version_list(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
Scopes::PROJECT_READ | Scopes::VERSION_READ,
)
.await
.map(|x| x.1)
@@ -883,7 +883,7 @@ pub async fn version_delete(
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_DELETE]),
Scopes::VERSION_DELETE,
)
.await?
.1;

View File

@@ -1,14 +1 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-8 top-8 size-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cpu-icon lucide-cpu"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 555 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database-icon lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-memory-stick-icon lucide-memory-stick"><path d="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -77,6 +77,7 @@ import _CopyrightIcon from './icons/copyright.svg?component'
import _CrownIcon from './icons/crown.svg?component'
import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component'
import _DatabaseIcon from './icons/database.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
@@ -125,6 +126,7 @@ import _LogOutIcon from './icons/log-out.svg?component'
import _MailIcon from './icons/mail.svg?component'
import _ManageIcon from './icons/manage.svg?component'
import _MaximizeIcon from './icons/maximize.svg?component'
import _MemoryStickIcon from './icons/memory-stick.svg?component'
import _MessageIcon from './icons/message.svg?component'
import _MicrophoneIcon from './icons/microphone.svg?component'
import _MinimizeIcon from './icons/minimize.svg?component'
@@ -295,6 +297,7 @@ export const CopyrightIcon = _CopyrightIcon
export const CrownIcon = _CrownIcon
export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon
export const DatabaseIcon = _DatabaseIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
@@ -344,6 +347,7 @@ export const LogOutIcon = _LogOutIcon
export const MailIcon = _MailIcon
export const ManageIcon = _ManageIcon
export const MaximizeIcon = _MaximizeIcon
export const MemoryStickIcon = _MemoryStickIcon
export const MessageIcon = _MessageIcon
export const MicrophoneIcon = _MicrophoneIcon
export const MinimizeIcon = _MinimizeIcon

View File

@@ -200,6 +200,8 @@ html {
--color-platform-sponge: #f9e580;
--hover-brightness: 1.25;
--experimental-color-button-bg: #33363d;
}
.oled-mode {
@@ -257,7 +259,7 @@ html {
}
.dark-experiments {
--color-button-bg: #33363d;
--color-button-bg: var(--experimental-color-button-bg);
}
.dark-mode:not(.oled-mode),

View File

@@ -11,10 +11,12 @@
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@stripe/stripe-js": "^7.3.1",
"@vintl/unplugin": "^1.5.1",
"@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"stripe": "^18.1.1",
"tsconfig": "workspace:*",
"typescript": "^5.4.5",
"vue": "^3.5.13",

View File

@@ -34,7 +34,7 @@
"
/>
</div>
<ScrollablePanel v-if="search" class="h-[17rem]">
<ScrollablePanel v-if="search">
<Button
v-for="(option, index) in filteredOptions"
:key="`option-${index}`"

View File

@@ -3,9 +3,9 @@
<button
v-for="(item, index) in items"
:key="`radio-button-${index}`"
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
:class="{
'text-contrast font-medium bg-button-bg': selected === item,
'text-contrast bg-button-bg': selected === item,
'text-primary bg-transparent': selected !== item,
}"
@click="selected = item"

View File

@@ -3,11 +3,18 @@
<div
class="wrapper-wrapper"
:class="{
'top-fade': !scrollableAtTop && !props.disableScrolling,
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
'top-fade': !scrollableAtTop && !disableScrolling,
'bottom-fade': !scrollableAtBottom && !disableScrolling,
}"
>
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
<div
ref="scrollablePane"
:class="{
'max-h-[19rem]': !disableScrolling,
}"
class="scrollable-pane"
@scroll="onScroll"
>
<slot />
</div>
</div>
@@ -17,7 +24,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(
withDefaults(
defineProps<{
disableScrolling?: boolean
}>(),

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import { createStripeElements } from '@modrinth/utils'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import { loadStripe, type Stripe as StripsJs, type StripeElements } from '@stripe/stripe-js'
const emit = defineEmits<{
(e: 'startLoading' | 'stopLoading'): void
}>()
export type SetupIntentResponse = {
client_secret: string
}
export type AddPaymentMethodProps = {
publishableKey: string
createSetupIntent: () => Promise<SetupIntentResponse>
returnUrl: string
onError: (error: Error) => void
}
const props = defineProps<AddPaymentMethodProps>()
const elementsLoaded = ref<0 | 1 | 2>(0)
const stripe = ref<StripsJs>()
const elements = ref<StripeElements>()
const error = ref(false)
function handleError(error: Error) {
props.onError(error)
error.value = true
}
async function reload(paymentMethods: Stripe.PaymentMethod[]) {
try {
elementsLoaded.value = 0
error.value = false
const result = await props.createSetupIntent()
stripe.value = await loadStripe(props.publishableKey)
const {
elements: newElements,
addressElement,
paymentElement,
} = createStripeElements(stripe.value, paymentMethods, {
clientSecret: result.client_secret,
})
elements.value = newElements
paymentElement.on('ready', () => {
elementsLoaded.value += 1
})
addressElement.on('ready', () => {
elementsLoaded.value += 1
})
} catch (err) {
handleError(err)
}
}
async function submit(): Promise<boolean> {
emit('startLoading')
const result = await stripe.value.confirmSetup({
elements: elements.value,
confirmParams: {
return_url: props.returnUrl,
},
})
console.log(result)
const { error } = result
emit('stopLoading')
if (error && error.type !== 'validation_error') {
handleError(error.message)
return false
} else if (!error) {
return true
}
}
defineExpose({
reload,
submit,
})
</script>
<template>
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
<div v-show="elementsLoaded < 2">
<ModalLoadingIndicator :error="error">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ButtonStyled, NewModal } from '../index'
import { defineMessages, useVIntl } from '@vintl/vintl'
import AddPaymentMethod from './AddPaymentMethod.vue'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
import { commonMessages } from '../../utils'
import { PlusIcon, XIcon } from '@modrinth/assets'
const { formatMessage } = useVIntl()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const addPaymentMethod = useTemplateRef<InstanceType<typeof AddPaymentMethod>>('addPaymentMethod')
const props = defineProps<AddPaymentMethodProps>()
const loading = ref(false)
async function open(paymentMethods: Stripe.PaymentMethod[]) {
modal.value?.show()
await nextTick()
await addPaymentMethod.value?.reload(paymentMethods)
}
const messages = defineMessages({
addingPaymentMethod: {
id: 'modal.add-payment-method.title',
defaultMessage: 'Adding a payment method',
},
paymentMethodAdd: {
id: 'modal.add-payment-method.action',
defaultMessage: 'Add payment method',
},
})
defineExpose({
show: open,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.addingPaymentMethod) }}
</span>
</template>
<div class="w-[40rem] max-w-full">
<AddPaymentMethod
ref="addPaymentMethod"
:publishable-key="props.publishableKey"
:return-url="props.returnUrl"
:create-setup-intent="props.createSetupIntent"
:on-error="props.onError"
@start-loading="loading = true"
@stop-loading="loading = false"
/>
<div class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loading" @click="addPaymentMethod.submit()">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import { formatPrice } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
const { locale } = useVIntl()
export type BillingItem = {
title: string
amount: number
}
const props = defineProps<{
period?: string
currency: string
total: number
billingItems: BillingItem[]
loading?: boolean
}>()
const periodSuffix = computed(() => {
return props.period ? ` / ${props.period}` : ''
})
</script>
<template>
<Accordion
class="rounded-2xl overflow-hidden bg-bg"
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
>
<template #title>
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
<div class="text-right mr-1">
<span class="text-primary font-bold">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, total, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</span>
</div>
</div>
</template>
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
<div
v-for="{ title, amount } in billingItems"
:key="title"
class="flex items-center justify-between"
>
<div class="font-semibold">
{{ title }}
</div>
<div class="text-right">
<template v-if="loading">
<SpinnerIcon class="animate-spin size-4" />
</template>
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
</div>
</div>
</div>
</Accordion>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
import { commonMessages, paymentMethodMessages } from '../../utils'
import type Stripe from 'stripe'
import { useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps<{
method: Stripe.PaymentMethod
}>()
</script>
<template>
<template v-if="'type' in method">
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
<UnknownIcon v-else class="size-[1.5em]" />
<span v-if="method.type === 'card' && 'card' in method && method.card">
{{
formatMessage(commonMessages.paymentMethodCardDisplay, {
card_brand:
formatMessage(paymentMethodMessages[method.card.brand]) ??
formatMessage(paymentMethodMessages.unknown),
last_four: method.card.last4,
})
}}
</span>
<template v-else>
{{
formatMessage(paymentMethodMessages[method.type]) ??
formatMessage(paymentMethodMessages.unknown)
}}
</template>
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
({{ method.cashapp.cashtag }})
</span>
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
({{ method.paypal.payer_email }})
</span>
</template>
</template>

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
import { ref, computed, useTemplateRef, nextTick, watch } from 'vue'
import NewModal from '../modal/NewModal.vue'
import { type MessageDescriptor, useVIntl, defineMessage } from '@vintl/vintl'
import {
ChevronRightIcon,
LeftArrowIcon,
RightArrowIcon,
XIcon,
CheckCircleIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type {
CreatePaymentIntentRequest,
CreatePaymentIntentResponse,
ServerBillingInterval,
ServerPlan,
ServerRegion,
ServerStockRequest,
UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse,
} from '../../utils/billing'
import { ButtonStyled } from '../index'
import type Stripe from 'stripe'
import { commonMessages } from '../../utils'
import RegionSelector from './ServersPurchase1Region.vue'
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
import { useStripe } from '../../composables/stripe'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
const { formatMessage } = useVIntl()
export type RegionPing = {
region: string
ping: number
}
const props = defineProps<{
publishableKey: string
returnUrl: string
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
pings: RegionPing[]
regions: ServerRegion[]
availableProducts: ServerPlan[]
refreshPaymentMethods: () => Promise<void>
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
onError: (err: Error) => void
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<ServerPlan>()
const selectedInterval = ref<ServerBillingInterval>('quarterly')
const loading = ref(false)
const selectedRegion = ref<string>()
const {
initializeStripe,
selectPaymentMethod,
primaryPaymentMethodId,
loadStripeElements,
selectedPaymentMethod,
inputtedPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
tax,
total,
paymentMethodLoading,
reloadPaymentIntent,
hasPaymentMethod,
submitPayment,
completingPurchase,
} = useStripe(
props.publishableKey,
props.customer,
props.paymentMethods,
props.currency,
selectedPlan,
selectedInterval,
selectedRegion,
props.initiatePayment,
props.onError,
)
const customServer = ref<boolean>(false)
const acceptedEula = ref<boolean>(false)
const skipPaymentMethods = ref<boolean>(true)
type Step = 'region' | 'payment' | 'review'
const steps: Step[] = ['region', 'payment', 'review']
const titles: Record<Step, MessageDescriptor> = {
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
payment: defineMessage({
id: 'servers.purchase.step.payment.title',
defaultMessage: 'Payment method',
}),
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
}
const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
const currentPing = computed(() => {
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
})
const currentStep = ref<Step>()
const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1))
const previousStep = computed(() => {
const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined
if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) {
return 'region'
}
return step
})
const nextStep = computed(() =>
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
)
const canProceed = computed(() => {
switch (currentStep.value) {
case 'region':
return selectedRegion.value && selectedPlan.value && selectedInterval.value
case 'payment':
return selectedPaymentMethod.value || !loadingElements.value
case 'review':
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
default:
return false
}
})
async function beforeProceed(step: string) {
switch (step) {
case 'region':
return true
case 'payment':
await initializeStripe()
if (primaryPaymentMethodId.value && skipPaymentMethods.value) {
const paymentMethod = await props.paymentMethods.find(
(x) => x.id === primaryPaymentMethodId.value,
)
await selectPaymentMethod(paymentMethod)
await setStep('review', true)
return false
}
return true
case 'review':
if (selectedPaymentMethod.value) {
return true
} else {
const token = await createNewPaymentMethod()
return !!token
}
}
}
async function afterProceed(step: string) {
switch (step) {
case 'region':
break
case 'payment':
await loadStripeElements()
break
case 'review':
break
}
}
async function setStep(step: Step | undefined, skipValidation = false) {
if (!step) {
await submitPayment(props.returnUrl)
return
}
if (!skipValidation && !canProceed.value) {
return
}
if (await beforeProceed(step)) {
currentStep.value = step
await nextTick()
await afterProceed(step)
}
}
watch(selectedPlan, () => {
console.log(selectedPlan.value)
})
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
loading.value = false
selectedPlan.value = plan
selectedInterval.value = interval
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
skipPaymentMethods.value = true
modal.value?.show()
}
defineExpose({
show: begin,
})
</script>
<template>
<NewModal ref="modal">
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<template v-for="(title, id, index) in titles" :key="id">
<button
v-if="index < currentStepIndex"
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
@click="setStep(id, true)"
>
{{ formatMessage(title) }}
</button>
<span
v-else
:class="{
'text-contrast': index === currentStepIndex,
}"
>
{{ formatMessage(title) }}
</span>
<ChevronRightIcon
v-if="index < steps.length - 1"
class="h-5 w-5 text-secondary"
stroke-width="3"
/>
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<RegionSelector
v-if="currentStep === 'region'"
v-model:region="selectedRegion"
v-model:plan="selectedPlan"
:regions="regions"
:pings="pings"
:custom="customServer"
:available-products="availableProducts"
:fetch-stock="fetchStock"
/>
<PaymentMethodSelector
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
:payment-methods="paymentMethods"
:selected="selectedPaymentMethod"
:loading-elements="loadingElements"
:loading-elements-failed="loadingElementsFailed"
@select="selectPaymentMethod"
/>
<ConfirmPurchase
v-else-if="
currentStep === 'review' &&
hasPaymentMethod &&
currentRegion &&
selectedInterval &&
selectedPlan
"
v-model:interval="selectedInterval"
v-model:accepted-eula="acceptedEula"
:currency="currency"
:plan="selectedPlan"
:region="currentRegion"
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:tax="tax"
:total="total"
@change-payment-method="
() => {
skipPaymentMethods = false
setStep('payment', true)
}
"
@reload-payment-intent="reloadPaymentIntent"
/>
<div v-else>Something went wrong</div>
<div
v-show="
selectedPaymentMethod === undefined &&
currentStep === 'payment' &&
selectedPlan &&
selectedInterval
"
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
>
<div v-show="loadingElements">
<ModalLoadingIndicator :error="loadingElementsFailed">
Loading...
<template #error> Error loading Stripe payment UI. </template>
</ModalLoadingIndicator>
</div>
<div class="w-full">
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
</div>
</div>
<div class="flex gap-2 justify-between mt-4">
<ButtonStyled>
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula
? 'You must accept the Minecraft EULA to proceed.'
: undefined
"
:disabled="!canProceed"
@click="setStep(nextStep)"
>
<template v-if="currentStep === 'review'">
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
<CheckCircleIcon v-else />
Subscribe
</template>
<template v-else>
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
</template>
</button>
</ButtonStyled>
</div>
</NewModal>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { RadioButtonIcon, RadioButtonCheckedIcon, SpinnerIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
const emit = defineEmits<{
(e: 'select'): void
}>()
withDefaults(
defineProps<{
item: Stripe.PaymentMethod | undefined
selected: boolean
loading?: boolean
}>(),
{
loading: false,
},
)
</script>
<template>
<button
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
@click="emit('select')"
>
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<template v-if="item === undefined">
<span>New payment method</span>
</template>
<FormattedPaymentMethod v-else-if="item" :method="item" />
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
</button>
</template>

View File

@@ -2,7 +2,8 @@
<NewModal ref="purchaseModal">
<template #title>
<span class="text-contrast text-xl font-extrabold">
<template v-if="productType === 'midas'">Subscribe to Modrinth+!</template>
<template v-if="intervalChangeOnly"> Change billing interval </template>
<template v-else-if="productType === 'midas'">Subscribe to Modrinth+!</template>
<template v-else-if="productType === 'pyro'">
<template v-if="existingSubscription"> Upgrade server plan </template>
<template v-else> Subscribe to Modrinth Servers! </template>
@@ -11,11 +12,11 @@
</span>
</template>
<div class="flex items-center gap-1 pb-4">
<template v-if="productType === 'pyro' && !projectId">
<template v-if="!props.intervalChangeOnly && productType === 'pyro' && !projectId">
<span
:class="{
'text-secondary': purchaseModalStep !== 0,
'font-bold': purchaseModalStep === 0,
'text-secondary': !isConfigStep,
'font-bold': isConfigStep,
}"
>
Configure
@@ -25,8 +26,8 @@
</template>
<span
:class="{
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 1 : 0),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 1 : 0),
'text-secondary': !isBillingStep,
'font-bold': isBillingStep,
}"
>
{{ productType === 'pyro' ? 'Billing' : 'Plan' }}
@@ -37,8 +38,8 @@
<ChevronRightIcon class="h-5 w-5 text-secondary" />
<span
:class="{
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 2 : 1),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 2 : 1),
'text-secondary': !isPaymentStep,
'font-bold': isPaymentStep,
}"
>
Payment
@@ -46,17 +47,14 @@
<ChevronRightIcon class="h-5 w-5 text-secondary" />
<span
:class="{
'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 3 : 2),
'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 3 : 2),
'text-secondary': !isReviewStep,
'font-bold': isReviewStep,
}"
>
Review
</span>
</div>
<div
v-if="productType === 'pyro' && !projectId && purchaseModalStep === 0"
class="md:w-[600px] flex flex-col gap-4"
>
<div v-if="!props.intervalChangeOnly && isConfigStep" class="md:w-[600px] flex flex-col gap-4">
<div v-if="!existingSubscription">
<p class="my-2 text-lg font-bold">Configure your server</p>
<div class="flex flex-col gap-4">
@@ -182,10 +180,7 @@
</div>
</div>
</div>
<div
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0)"
class="md:w-[600px]"
>
<div v-if="isBillingStep" class="md:w-[600px]">
<div>
<p class="my-2 text-lg font-bold">Choose billing interval</p>
<div v-if="existingPlan" class="flex flex-col gap-3 mb-4 text-secondary">
@@ -214,10 +209,17 @@
{{ interval }}
</span>
<span
v-if="interval === 'yearly'"
v-if="interval === 'yearly' || interval === 'quarterly'"
class="rounded-full bg-brand px-2 py-1 font-bold text-brand-inverted"
>
SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice) }}%
SAVE
{{
calculateSavings(
price.prices.intervals.monthly,
rawPrice,
interval === 'quarterly' ? 3 : 12,
)
}}%
</span>
<span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }">
{{ formatPrice(locale, rawPrice, price.currency_code) }}
@@ -242,9 +244,7 @@
</div>
</div>
</div>
<template
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 2 : 1)"
>
<template v-if="isPaymentStep">
<div
v-show="loadingPaymentMethodModal !== 2"
class="flex min-h-[16rem] items-center justify-center md:w-[600px]"
@@ -256,12 +256,20 @@
<div id="payment-element" class="mt-4"></div>
</div>
</template>
<div
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)"
class="md:w-[650px]"
>
<div v-if="isReviewStep" class="md:w-[650px]">
<div v-if="props.intervalChangeOnly" class="r-4 rounded-xl bg-bg p-4">
<p class="my-2 text-lg font-bold text-primary">Billing interval change</p>
<div class="mb-2 flex justify-between">
<span class="text-secondary">
Current interval: {{ props.existingSubscription?.interval }}
</span>
</div>
<div class="mb-2 flex justify-between">
<span class="text-secondary"> New interval: {{ selectedPlan }} </span>
</div>
</div>
<div
v-if="mutatedProduct.metadata.type === 'pyro' && !existingSubscription"
v-else-if="mutatedProduct.metadata.type === 'pyro' && !existingSubscription"
class="r-4 rounded-xl bg-bg p-4 mb-4"
>
<p class="my-2 text-lg font-bold text-primary">Server details</p>
@@ -422,7 +430,7 @@
</p>
</div>
<div class="input-group push-right pt-4">
<template v-if="purchaseModalStep === 0">
<template v-if="!props.intervalChangeOnly && purchaseModalStep === 0">
<button class="btn" @click="$refs.purchaseModal.hide()">
<XIcon />
Cancel
@@ -447,40 +455,21 @@
</template>
</button>
</template>
<template
v-else-if="
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0)
"
>
<button
class="btn"
@click="
purchaseModalStep =
mutatedProduct.metadata.type === 'pyro' && !projectId ? 0 : purchaseModalStep
"
>
<template v-else-if="isBillingStep">
<button v-if="!props.intervalChangeOnly" class="btn" @click="purchaseModalStep = 0">
Back
</button>
<button class="btn btn-primary" :disabled="paymentLoading" @click="beginPurchaseFlow(true)">
<button
class="btn btn-primary"
:disabled="paymentLoading || isSameInterval"
@click="beginPurchaseFlow(true)"
>
<RightArrowIcon />
Select
</button>
</template>
<template
v-else-if="
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 2 : 1)
"
>
<button
class="btn"
@click="
() => {
purchaseModalStep = mutatedProduct.metadata.type === 'pyro' && !projectId ? 1 : 0
loadingPaymentMethodModal = 0
paymentLoading = false
}
"
>
<template v-else-if="isPaymentStep">
<button class="btn" @click="purchaseModalStep = props.intervalChangeOnly ? 0 : 1">
Back
</button>
<button class="btn btn-primary" :disabled="paymentLoading" @click="validatePayment">
@@ -488,11 +477,7 @@
Continue
</button>
</template>
<template
v-else-if="
purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)
"
>
<template v-else-if="isReviewStep">
<button class="btn" @click="$refs.purchaseModal.hide()">
<XIcon />
Cancel
@@ -504,7 +489,7 @@
@click="submitPayment"
>
<CheckCircleIcon />
Subscribe
{{ props.intervalChangeOnly ? 'Change Interval' : 'Subscribe' }}
</button>
<!-- Default Subscribe Button, so M+ still works -->
<button v-else class="btn btn-primary" :disabled="paymentLoading" @click="submitPayment">
@@ -632,10 +617,40 @@ const props = defineProps({
required: false,
default: null,
},
intervalChangeOnly: {
type: Boolean,
default: false,
},
})
const productType = computed(() => (props.customServer ? 'pyro' : props.product.metadata.type))
const isConfigStep = computed(
() =>
productType.value === 'pyro' &&
!props.projectId &&
!props.intervalChangeOnly &&
purchaseModalStep.value === 0,
)
const isBillingStep = computed(
() =>
purchaseModalStep.value ===
(props.intervalChangeOnly ? 0 : productType.value === 'pyro' && !props.projectId ? 1 : 0),
)
const isPaymentStep = computed(
() =>
purchaseModalStep.value ===
(props.intervalChangeOnly ? 1 : productType.value === 'pyro' && !props.projectId ? 2 : 1),
)
const isReviewStep = computed(
() =>
purchaseModalStep.value ===
(props.intervalChangeOnly ? 2 : productType.value === 'pyro' && !props.projectId ? 3 : 2),
)
const messages = defineMessages({
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
@@ -742,6 +757,10 @@ const customServerConfig = reactive({
})
const updateCustomServerProduct = () => {
if (!props.product || !Array.isArray(props.product)) {
return
}
customMatchingProduct.value = props.product.find(
(product) => product.metadata.ram === customServerConfig.ram,
)
@@ -782,6 +801,10 @@ const updateCustomServerStock = async () => {
}
function updateRamValues() {
if (!props.product || !Array.isArray(props.product)) {
return
}
const ramValues = props.product.map((product) => product.metadata.ram / 1024)
customMinRam.value = Math.min(...ramValues)
customMaxRam.value = Math.max(...ramValues)
@@ -861,10 +884,19 @@ const sharedCpus = computed(() => {
return (mutatedProduct.value?.metadata?.cpu ?? 0) / 2
})
const isSameInterval = computed(() => {
return (
props.intervalChangeOnly &&
props.existingSubscription &&
selectedPlan.value === props.existingSubscription.interval
)
})
function nextStep() {
if (
mutatedProduct.value.metadata.type === 'pyro' &&
!props.projectId &&
!props.intervalChangeOnly &&
purchaseModalStep.value === 0
) {
purchaseModalStep.value = 1
@@ -884,13 +916,19 @@ async function beginPurchaseFlow(skip = false) {
paymentLoading.value = true
await refreshPayment(null, primaryPaymentMethodId.value)
paymentLoading.value = false
purchaseModalStep.value =
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 3 : 2
purchaseModalStep.value = props.intervalChangeOnly
? 2
: mutatedProduct.value.metadata.type === 'pyro' && !props.projectId
? 3
: 2
} else {
try {
loadingPaymentMethodModal.value = 0
purchaseModalStep.value =
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 2 : 1
purchaseModalStep.value = props.intervalChangeOnly
? 1
: mutatedProduct.value.metadata.type === 'pyro' && !props.projectId
? 2
: 1
await nextTick()
@@ -948,8 +986,11 @@ async function validatePayment() {
loadingPaymentMethodModal.value = 0
confirmationToken.value = await createConfirmationToken()
purchaseModalStep.value =
mutatedProduct.value.metadata.type === 'pyro' && !props.projectId ? 3 : 2
purchaseModalStep.value = props.intervalChangeOnly
? 2
: mutatedProduct.value.metadata.type === 'pyro' && !props.projectId
? 3
: 2
paymentLoading.value = false
}
@@ -999,7 +1040,7 @@ async function refreshPayment(confirmationId, paymentMethodId) {
},
)
if (!paymentIntentId.value) {
if (result.payment_intent_id && !paymentIntentId.value) {
paymentIntentId.value = result.payment_intent_id
clientSecret.value = result.client_secret
}

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import ServersRegionButton from './ServersRegionButton.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { onMounted, ref, computed, watch } from 'vue'
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
import type { ServerPlan, ServerRegion, ServerStockRequest } from '../../utils/billing'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import Slider from '../base/Slider.vue'
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
regions: ServerRegion[]
pings: RegionPing[]
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
custom: boolean
availableProducts: ServerPlan[]
}>()
const loading = ref(true)
const checkingCustomStock = ref(false)
const selectedPlan = defineModel<ServerPlan>('plan')
const selectedRegion = defineModel<string>('region')
const regionOrder: string[] = ['us-vin', 'eu-lim']
const sortedRegions = computed(() => {
return props.regions.slice().sort((a, b) => {
return regionOrder.indexOf(a.shortcode) - regionOrder.indexOf(b.shortcode)
})
})
const selectedRam = ref<number>(-1)
const ramOptions = computed(() => {
return props.availableProducts
.map((product) => (product.metadata.ram ?? 0) / 1024)
.filter((x) => x > 0)
})
const minRam = computed(() => {
return Math.min(...ramOptions.value)
})
const maxRam = computed(() => {
return Math.max(...ramOptions.value)
})
const lowestProduct = computed(() => {
return (
props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
) ?? props.availableProducts[0]
)
})
function updateRamStock(regionToCheck: string, newRam: number) {
if (newRam > 0) {
checkingCustomStock.value = true
const plan = props.availableProducts.find(
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
)
if (plan) {
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
if (region) {
props
.fetchStock(region, {
cpu: plan.metadata.cpu ?? 0,
memory_mb: plan.metadata.ram ?? 0,
swap_mb: plan.metadata.swap ?? 0,
storage_mb: plan.metadata.storage ?? 0,
})
.then((stock: number) => {
if (stock > 0) {
selectedPlan.value = plan
} else {
selectedPlan.value = undefined
}
})
.finally(() => {
checkingCustomStock.value = false
})
} else {
checkingCustomStock.value = false
}
}
}
}
watch(selectedRam, (newRam: number) => {
if (props.custom && selectedRegion.value) {
updateRamStock(selectedRegion.value, newRam)
}
})
watch(selectedRegion, (newRegion: string | undefined) => {
if (props.custom && newRegion) {
updateRamStock(newRegion, selectedRam.value)
}
})
const currentStock = ref<{ [region: string]: number }>({})
const bestPing = ref<string>()
const messages = defineMessages({
prompt: {
id: 'servers.region.prompt',
defaultMessage: 'Where would you like your server to be located?',
},
regionUnsupported: {
id: 'servers.region.region-unsupported',
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
},
customPrompt: {
id: 'servers.region.custom.prompt',
defaultMessage: `How much RAM do you want your server to have?`,
},
})
async function updateStock() {
currentStock.value = {}
const capacityChecks = sortedRegions.value.map((region) =>
props.fetchStock(
region,
selectedPlan.value
? {
cpu: selectedPlan.value?.metadata.cpu ?? 0,
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
}
: {
cpu: lowestProduct.value.metadata.cpu ?? 0,
memory_mb: lowestProduct.value.metadata.ram ?? 0,
swap_mb: lowestProduct.value.metadata.swap ?? 0,
storage_mb: lowestProduct.value.metadata.storage ?? 0,
},
),
)
const results = await Promise.all(capacityChecks)
results.forEach((result, index) => {
currentStock.value[sortedRegions.value[index].shortcode] = result
})
}
onMounted(() => {
// auto select region with lowest ping
loading.value = true
bestPing.value =
props.pings.length > 0
? props.pings.reduce((acc, cur) => {
return acc.ping < cur.ping ? acc : cur
})?.region
: undefined
selectedRegion.value = undefined
selectedRam.value = minRam.value
checkingCustomStock.value = true
updateStock().then(() => {
const firstWithStock = sortedRegions.value.find(
(region) => currentStock.value[region.shortcode] > 0,
)
let stockedRegion = selectedRegion.value
if (!stockedRegion) {
stockedRegion =
bestPing.value && currentStock.value[bestPing.value] > 0
? bestPing.value
: firstWithStock?.shortcode
}
selectedRegion.value = stockedRegion
if (props.custom && stockedRegion) {
updateRamStock(stockedRegion, minRam.value)
}
loading.value = false
})
})
</script>
<template>
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
Checking availability...
</ModalLoadingIndicator>
<template v-else>
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ServersRegionButton
v-for="region in sortedRegions"
:key="region.shortcode"
v-model="selectedRegion"
:region="region"
:out-of-stock="currentStock[region.shortcode] === 0"
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
:best-ping="bestPing === region.shortcode"
/>
</div>
<div class="mt-3 text-sm">
<IntlFormatted :message-id="messages.regionUnsupported">
<template #link="{ children }">
<a
class="text-link"
target="_blank"
rel="noopener noreferrer"
href="https://surveys.modrinth.com/servers-region-waitlist"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<template v-if="custom">
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
{{ formatMessage(messages.customPrompt) }}
</h2>
<div>
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
<div class="bg-bg rounded-xl p-4 mt-4 text-secondary">
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
</div>
<div v-else-if="selectedPlan">
<ServersSpecs
class="!flex-row justify-between"
:ram="selectedPlan.metadata.ram ?? 0"
:storage="selectedPlan.metadata.storage ?? 0"
:cpus="selectedPlan.metadata.cpu ?? 0"
/>
</div>
<div v-else class="flex gap-2 items-center">
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
{{ selectedRam }} GB RAM in this region.
</div>
</div>
<div class="flex gap-2 mt-2">
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
<span class="text-sm text-secondary">
Storage and shared CPU count are currently not configurable independently, and are based
on the amount of RAM you select.
</span>
</div>
</div>
</template>
</template>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type Stripe from 'stripe'
import { useVIntl, defineMessages } from '@vintl/vintl'
import PaymentMethodOption from './PaymentMethodOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
}>()
defineProps<{
paymentMethods: Stripe.PaymentMethod[]
selected?: Stripe.PaymentMethod
loadingElements: boolean
loadingElementsFailed: boolean
}>()
const messages = defineMessages({
prompt: {
id: 'servers.purchase.step.payment.prompt',
defaultMessage: 'Select a payment method',
},
description: {
id: 'servers.purchase.step.payment.description',
defaultMessage: `You won't be charged yet.`,
},
})
</script>
<template>
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
{{ formatMessage(messages.prompt) }}
</h2>
<p class="mt-0 mb-4 text-secondary">
{{ formatMessage(messages.description) }}
</p>
<div class="flex flex-col gap-1">
<PaymentMethodOption
v-for="method in paymentMethods"
:key="method.id"
:item="method"
:selected="selected?.id === method.id"
@select="emit('select', method)"
/>
<PaymentMethodOption
:loading="false"
:item="undefined"
:selected="selected === undefined"
@select="emit('select', undefined)"
/>
</div>
</template>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
import TagItem from '../base/TagItem.vue'
import ServersSpecs from './ServersSpecs.vue'
import { formatPrice, getPingLevel } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { regionOverrides } from '../../utils/regions'
import {
EditIcon,
RightArrowIcon,
SignalIcon,
SpinnerIcon,
XIcon,
RadioButtonIcon,
RadioButtonCheckedIcon,
ExternalIcon,
} from '@modrinth/assets'
import type Stripe from 'stripe'
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
const vintl = useVIntl()
const { locale, formatMessage } = vintl
const emit = defineEmits<{
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
}>()
const props = defineProps<{
plan: ServerPlan
region: ServerRegion
tax?: number
total?: number
currency: string
ping?: number
loading?: boolean
selectedPaymentMethod: Stripe.PaymentMethod | undefined
}>()
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
const prices = computed(() => {
return props.plan.prices.find((x) => x.currency_code === props.currency)
})
const planName = computed(() => {
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
const ram = props.plan.metadata.ram
if (ram === 4096) return 'Small'
if (ram === 6144) return 'Medium'
if (ram === 8192) return 'Large'
return 'Custom'
})
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
const period = computed(() => {
if (interval.value === 'monthly') return 'month'
if (interval.value === 'quarterly') return '3 months'
if (interval.value === 'yearly') return 'year'
return '???'
})
const monthsInInterval: Record<ServerBillingInterval, number> = {
monthly: 1,
quarterly: 3,
yearly: 12,
}
function setInterval(newInterval: ServerBillingInterval) {
interval.value = newInterval
emit('reloadPaymentIntent')
}
</script>
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
</div>
<div>
<ServersSpecs
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
class="!grid sm:grid-cols-2"
:ram="plan.metadata.ram"
:storage="plan.metadata.storage"
:cpus="plan.metadata.cpu"
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="font-semibold">
{{ title }}
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<template v-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-4">
<button
:class="
interval === 'monthly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('monthly')"
>
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
>Pay monthly</span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
month</span
>
</div>
</button>
<button
:class="
interval === 'quarterly'
? 'bg-button-bg border-transparent'
: 'bg-transparent border-button-border'
"
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
@click="setInterval('quarterly')"
>
<RadioButtonCheckedIcon v-if="interval === 'quarterly'" class="size-6 text-brand" />
<RadioButtonIcon v-else class="size-6 text-secondary" />
<div class="flex flex-col items-start gap-1 font-medium text-primary">
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'quarterly' }"
>Pay quarterly
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
></span
>
<span class="text-sm text-secondary flex items-center gap-1"
>{{
formatPrice(
locale,
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
currency,
true,
)
}}
/ month</span
>
</div>
</button>
</div>
<div class="mt-2">
<ExpandableInvoiceTotal
:period="period"
:currency="currency"
:loading="loading"
:total="total ?? -1"
:billing-items="
total !== undefined && tax !== undefined
? [
{
title: `Modrinth Servers (${planName})`,
amount: total - tax,
},
{
title: 'Tax',
amount: tax,
},
]
: []
"
/>
</div>
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
<template v-if="selectedPaymentMethod">
<FormattedPaymentMethod :method="selectedPaymentMethod" />
</template>
<template v-else>
<div class="flex items-center gap-2 text-red">
<XIcon />
No payment method selected
</div>
</template>
<ButtonStyled size="small" type="transparent">
<button class="ml-auto" @click="emit('changePaymentMethod')">
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
<template v-else> Select payment method <RightArrowIcon /> </template>
</button>
</ButtonStyled>
</div>
<p class="m-0 mt-4 text-sm text-secondary">
<span class="font-semibold"
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
>
<br />
You'll be charged
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
formatPrice(locale, total, currency)
}}</template>
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
anytime from your settings page.
</p>
<div class="mt-2 flex items-center gap-1 text-sm">
<Checkbox
v-model="acceptedEula"
label="I acknowledge that I have read and agree to the"
description="I acknowledge that I have read and agree to the Minecraft EULA"
/>
<a
href="https://www.minecraft.net/en-us/eula"
target="_blank"
class="text-brand underline hover:brightness-[--hover-brightness]"
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
/></a>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { useVIntl } from '@vintl/vintl'
import { getPingLevel } from '@modrinth/utils'
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import type { ServerRegion } from '../../utils/billing'
import { regionOverrides } from '../../utils/regions'
const { formatMessage } = useVIntl()
const currentRegion = defineModel<string | undefined>({ required: true })
const props = defineProps<{
region: ServerRegion
ping?: number
bestPing?: boolean
outOfStock?: boolean
}>()
const isCurrentRegion = computed(() => currentRegion.value === props.region.shortcode)
const flag = computed(
() =>
regionOverrides[props.region.shortcode]?.flag ??
`https://flagcdn.com/${props.region.country_code}.svg`,
)
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
const title = computed(() =>
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
)
const locationSubtitle = computed(() =>
overrideTitle.value ? props.region.display_name : undefined,
)
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
function setRegion() {
currentRegion.value = props.region.shortcode
}
</script>
<template>
<button
:disabled="outOfStock"
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
:class="{
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
'opacity-50 cursor-not-allowed': outOfStock,
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
!outOfStock,
}"
@click="setRegion"
>
<img
v-if="flag"
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
:class="[
isCurrentRegion ? 'border-brand' : 'border-button-border',
{ 'saturate-[0.25]': outOfStock },
]"
:src="flag"
alt=""
aria-hidden="true"
/>
<span class="flex flex-col gap-1 items-center">
<span class="flex items-center gap-1 flex-wrap justify-center">
{{ title }} <span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
</span>
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
<template v-if="locationSubtitle">
<span>
{{ locationSubtitle }}
</span>
<span v-if="ping !== -1"></span>
</template>
<template v-if="ping !== -1">
<SignalIcon
v-if="ping"
aria-hidden="true"
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
stroke-width="3px"
class="shrink-0"
/>
<SpinnerIcon v-else class="animate-spin" />
<template v-if="ping"> {{ ping }}ms </template>
<span v-else> Testing connection... </span>
</template>
</span>
</span>
</button>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoLink from '../base/AutoLink.vue'
import { MemoryStickIcon, DatabaseIcon, CPUIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
const emit = defineEmits<{
(e: 'click-bursting-link'): void
}>()
const props = withDefaults(
defineProps<{
ram: number
storage: number
cpus: number
burstingLink?: string
}>(),
{
burstingLink: undefined,
},
)
const formattedRam = computed(() => {
return props.ram / 1024
})
const formattedStorage = computed(() => {
return props.storage / 1024
})
const sharedCpus = computed(() => {
return props.cpus / 2
})
</script>
<template>
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
<li class="flex items-center gap-2">
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
</li>
<li class="flex items-center gap-2">
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
</li>
<li class="flex items-center gap-2">
<CPUIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
<AutoLink
v-if="burstingLink"
v-tooltip="
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
"
class="flex"
:to="burstingLink"
@click="() => emit('click-bursting-link')"
>
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
</AutoLink>
</li>
</ul>
</template>

View File

@@ -96,6 +96,8 @@ export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
@@ -107,3 +109,4 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
export { default as ServersSpecs } from './billing/ServersSpecs.vue'

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
withDefaults(
defineProps<{
error?: boolean
}>(),
{
error: false,
},
)
</script>
<template>
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
<XCircleIcon v-if="error" class="w-6 h-6" />
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
<slot v-if="error" name="error" />
<slot v-else />
</div>
</template>
<style scoped>
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
scale: 1;
}
50% {
scale: 0.95;
}
}
</style>

View File

@@ -0,0 +1,403 @@
import type Stripe from 'stripe'
import { type Stripe as StripeJs, loadStripe, type StripeElements } from '@stripe/stripe-js'
import { computed, ref, type Ref } from 'vue'
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
import type {
ServerPlan,
BasePaymentIntentResponse,
ChargeRequestType,
CreatePaymentIntentRequest,
CreatePaymentIntentResponse,
PaymentRequestType,
ServerBillingInterval,
UpdatePaymentIntentRequest,
UpdatePaymentIntentResponse,
} from '../utils/billing.ts'
// export type CreateElements = (
// paymentMethods: Stripe.PaymentMethod[],
// options: StripeElementsOptionsMode,
// ) => {
// elements: StripeElements
// paymentElement: StripePaymentElement
// addressElement: StripeAddressElement
// }
export const useStripe = (
publishableKey: string,
customer: Stripe.Customer,
paymentMethods: Stripe.PaymentMethod[],
currency: string,
product: Ref<ServerPlan | undefined>,
interval: Ref<ServerBillingInterval>,
region: Ref<string | undefined>,
initiatePayment: (
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
onError: (err: Error) => void,
) => {
const stripe = ref<StripeJs | null>(null)
let elements: StripeElements | undefined = undefined
const elementsLoaded = ref<0 | 1 | 2>(0)
const loadingElementsFailed = ref<boolean>(false)
const paymentMethodLoading = ref(false)
const loadingFailed = ref<string>()
const paymentIntentId = ref<string>()
const tax = ref<number>()
const total = ref<number>()
const confirmationToken = ref<string>()
const submittingPayment = ref(false)
const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
const clientSecret = ref<string>()
const completingPurchase = ref<boolean>(false)
async function initialize() {
stripe.value = await loadStripe(publishableKey)
}
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
}
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
}
const planPrices = computed(() => {
return product.value?.prices.find((x) => x.currency_code === currency)
})
const createElements = (options) => {
const styles = getComputedStyle(document.body)
if (!stripe.value) {
throw new Error('Stripe API not yet loaded')
}
elements = stripe.value.elements({
appearance: {
variables: {
colorPrimary: styles.getPropertyValue('--color-brand'),
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
colorText: styles.getPropertyValue('--color-base'),
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
colorDanger: styles.getPropertyValue('--color-red'),
fontFamily: styles.getPropertyValue('--font-standard'),
spacingUnit: '0.25rem',
borderRadius: '0.75rem',
},
},
loader: 'never',
...options,
})
const paymentElement = elements.create('payment', {
layout: {
type: 'tabs',
defaultCollapsed: false,
},
})
paymentElement.mount('#payment-element')
const contacts: ContactOption[] = []
paymentMethods.forEach((method) => {
const addr = method.billing_details?.address
if (
addr &&
addr.line1 &&
addr.city &&
addr.postal_code &&
addr.country &&
addr.state &&
method.billing_details.name
) {
contacts.push({
address: {
line1: addr.line1,
line2: addr.line2 ?? undefined,
city: addr.city,
state: addr.state,
postal_code: addr.postal_code,
country: addr.country,
},
name: method.billing_details.name,
})
}
})
const addressElement = elements.create('address', {
mode: 'billing',
contacts: contacts.length > 0 ? contacts : undefined,
})
addressElement.mount('#address-element')
return { elements, paymentElement, addressElement }
}
const primaryPaymentMethodId = computed<string | null>(() => {
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
const method = customer.invoice_settings.default_payment_method
if (typeof method === 'string') {
return method
} else {
return method.id
}
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
return paymentMethods[0].id
} else {
return null
}
})
const loadStripeElements = async () => {
loadingFailed.value = undefined
try {
if (!customer && primaryPaymentMethodId.value) {
paymentMethodLoading.value = true
await refreshPaymentIntent(primaryPaymentMethodId.value, false)
paymentMethodLoading.value = false
}
if (!selectedPaymentMethod.value) {
elementsLoaded.value = 0
const {
elements: newElements,
addressElement,
paymentElement,
} = createElements({
mode: 'payment',
currency: currency.toLowerCase(),
amount: product.value?.prices.find((x) => x.currency_code === currency)?.prices.intervals[
interval.value
],
paymentMethodCreation: 'manual',
setupFutureUsage: 'off_session',
})
elements = newElements
paymentElement.on('ready', () => {
elementsLoaded.value += 1
})
addressElement.on('ready', () => {
elementsLoaded.value += 1
})
}
} catch (err) {
loadingFailed.value = String(err)
console.log(err)
}
}
async function refreshPaymentIntent(id: string, confirmation: boolean) {
try {
paymentMethodLoading.value = true
if (!confirmation) {
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
}
const requestType: PaymentRequestType = confirmation
? {
type: 'confirmation_token',
token: id,
}
: {
type: 'payment_method',
id: id,
}
if (!product.value) {
return handlePaymentError('No product selected')
}
const charge: ChargeRequestType = {
type: 'new',
product_id: product.value?.id,
interval: interval.value,
}
let result: BasePaymentIntentResponse
if (paymentIntentId.value) {
result = await updateIntent({
...requestType,
charge,
existing_payment_intent: paymentIntentId.value,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
})
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
} else {
;({
payment_intent_id: paymentIntentId.value,
client_secret: clientSecret.value,
...result
} = await createIntent({
...requestType,
charge,
metadata: {
type: 'pyro',
server_region: region.value,
source: {},
},
}))
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
}
tax.value = result.tax
total.value = result.total
if (confirmation) {
confirmationToken.value = id
if (result.payment_method) {
inputtedPaymentMethod.value = result.payment_method
}
}
} catch (err) {
handlePaymentError(err as string)
}
paymentMethodLoading.value = false
}
async function createConfirmationToken() {
if (!elements) {
return handlePaymentError('No elements')
}
if (!stripe.value) {
return handlePaymentError('No stripe')
}
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
elements,
})
if (error) {
handlePaymentError(error.message ?? 'Unknown error creating confirmation token')
return
}
return confirmation.id
}
function handlePaymentError(err: string | Error) {
paymentMethodLoading.value = false
completingPurchase.value = false
onError(typeof err === 'string' ? new Error(err) : err)
}
async function createNewPaymentMethod() {
paymentMethodLoading.value = true
if (!elements) {
return handlePaymentError('No elements')
}
const { error: submitError } = await elements.submit()
if (submitError) {
return handlePaymentError(submitError.message ?? 'Unknown error creating payment method')
}
const token = await createConfirmationToken()
if (!token) {
return handlePaymentError('Failed to create confirmation token')
}
await refreshPaymentIntent(token, true)
if (!planPrices.value) {
return handlePaymentError('No plan prices')
}
if (!total.value) {
return handlePaymentError('No total amount')
}
elements.update({ currency: planPrices.value.currency_code.toLowerCase(), amount: total.value })
elementsLoaded.value = 0
confirmationToken.value = token
paymentMethodLoading.value = false
return token
}
async function selectPaymentMethod(paymentMethod: Stripe.PaymentMethod | undefined) {
selectedPaymentMethod.value = paymentMethod
if (paymentMethod === undefined) {
await loadStripeElements()
} else {
refreshPaymentIntent(paymentMethod.id, false)
}
}
const loadingElements = computed(() => elementsLoaded.value < 2)
async function submitPayment(returnUrl: string) {
completingPurchase.value = true
const secert = clientSecret.value
if (!secert) {
return handlePaymentError('No client secret')
}
if (!stripe.value) {
return handlePaymentError('No stripe')
}
submittingPayment.value = true
const { error } = await stripe.value.confirmPayment({
clientSecret: secert,
confirmParams: {
confirmation_token: confirmationToken.value,
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
},
})
if (error) {
handlePaymentError(error.message ?? 'Unknown error submitting payment')
return false
}
submittingPayment.value = false
completingPurchase.value = false
return true
}
async function reloadPaymentIntent() {
console.log('selected:', selectedPaymentMethod.value)
console.log('token:', confirmationToken.value)
if (selectedPaymentMethod.value) {
await refreshPaymentIntent(selectedPaymentMethod.value.id, false)
} else if (confirmationToken.value) {
await refreshPaymentIntent(confirmationToken.value, true)
} else {
throw new Error('No payment method selected')
}
}
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
return {
initializeStripe: initialize,
selectPaymentMethod,
reloadPaymentIntent,
primaryPaymentMethodId,
selectedPaymentMethod,
inputtedPaymentMethod,
hasPaymentMethod,
createNewPaymentMethod,
loadingElements,
loadingElementsFailed,
paymentMethodLoading,
loadStripeElements,
tax,
total,
submitPayment,
completingPurchase,
}
}

View File

@@ -1,4 +1,7 @@
{
"button.back": {
"defaultMessage": "Back"
},
"button.cancel": {
"defaultMessage": "Cancel"
},
@@ -23,6 +26,9 @@
"button.edit": {
"defaultMessage": "Edit"
},
"button.next": {
"defaultMessage": "Next"
},
"button.open-folder": {
"defaultMessage": "Open folder"
},
@@ -173,6 +179,12 @@
"label.visit-your-profile": {
"defaultMessage": "Visit your profile"
},
"modal.add-payment-method.action": {
"defaultMessage": "Add payment method"
},
"modal.add-payment-method.title": {
"defaultMessage": "Adding a payment method"
},
"notification.error.title": {
"defaultMessage": "An error occurred"
},
@@ -485,6 +497,36 @@
"servers.notice.undismissable": {
"defaultMessage": "Undismissable"
},
"servers.purchase.step.payment.description": {
"defaultMessage": "You won't be charged yet."
},
"servers.purchase.step.payment.prompt": {
"defaultMessage": "Select a payment method"
},
"servers.purchase.step.payment.title": {
"defaultMessage": "Payment method"
},
"servers.purchase.step.region.title": {
"defaultMessage": "Region"
},
"servers.purchase.step.review.title": {
"defaultMessage": "Review"
},
"servers.region.custom.prompt": {
"defaultMessage": "How much RAM do you want your server to have?"
},
"servers.region.europe": {
"defaultMessage": "Europe"
},
"servers.region.north-america": {
"defaultMessage": "North America"
},
"servers.region.prompt": {
"defaultMessage": "Where would you like your server to be located?"
},
"servers.region.region-unsupported": {
"defaultMessage": "Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>"
},
"settings.account.title": {
"defaultMessage": "Account and security"
},

View File

@@ -0,0 +1,102 @@
import type Stripe from 'stripe'
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
export interface ServerPlan {
id: string
name: string
description: string
metadata: {
type: string
ram?: number
cpu?: number
storage?: number
swap?: number
}
prices: {
id: string
currency_code: string
prices: {
intervals: {
monthly: number
yearly: number
}
}
}[]
}
export interface ServerStockRequest {
cpu?: number
memory_mb?: number
swap_mb?: number
storage_mb?: number
}
export interface ServerRegion {
shortcode: string
country_code: string
display_name: string
lat: number
lon: number
}
/*
Request types
*/
export type PaymentMethodRequest = {
type: 'payment_method'
id: string
}
export type ConfirmationTokenRequest = {
type: 'confirmation_token'
token: string
}
export type PaymentRequestType = PaymentMethodRequest | ConfirmationTokenRequest
export type ChargeRequestType =
| {
type: 'existing'
id: string
}
| {
type: 'new'
product_id: string
interval?: ServerBillingInterval
}
export type CreatePaymentIntentRequest = PaymentRequestType & {
charge: ChargeRequestType
metadata?: {
type: 'pyro'
server_name?: string
server_region?: string
source: {
loader?: string
game_version?: string
loader_version?: string
}
}
}
export type UpdatePaymentIntentRequest = CreatePaymentIntentRequest & {
existing_payment_intent: string
}
/*
Response types
*/
export type BasePaymentIntentResponse = {
price_id: string
tax: number
total: number
payment_method: Stripe.PaymentMethod
}
export type UpdatePaymentIntentResponse = BasePaymentIntentResponse
export type CreatePaymentIntentResponse = BasePaymentIntentResponse & {
payment_intent_id: string
client_secret: string
}

View File

@@ -17,6 +17,14 @@ export const commonMessages = defineMessages({
id: 'button.continue',
defaultMessage: 'Continue',
},
nextButton: {
id: 'button.next',
defaultMessage: 'Next',
},
backButton: {
id: 'button.back',
defaultMessage: 'Back',
},
copyIdButton: {
id: 'button.copy-id',
defaultMessage: 'Copy ID',
@@ -205,6 +213,10 @@ export const commonMessages = defineMessages({
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display',
defaultMessage: '{card_brand} ending in {last_four}',
},
})
export const commonSettingsMessages = defineMessages({
@@ -245,3 +257,51 @@ export const commonSettingsMessages = defineMessages({
defaultMessage: 'Billing and subscriptions',
},
})
export const paymentMethodMessages = defineMessages({
visa: {
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
},
amex: {
id: 'omorphia.component.purchase_modal.payment_method_type.amex',
defaultMessage: 'American Express',
},
diners: {
id: 'omorphia.component.purchase_modal.payment_method_type.diners',
defaultMessage: 'Diners Club',
},
discover: {
id: 'omorphia.component.purchase_modal.payment_method_type.discover',
defaultMessage: 'Discover',
},
eftpos: {
id: 'omorphia.component.purchase_modal.payment_method_type.eftpos',
defaultMessage: 'EFTPOS',
},
jcb: { id: 'omorphia.component.purchase_modal.payment_method_type.jcb', defaultMessage: 'JCB' },
mastercard: {
id: 'omorphia.component.purchase_modal.payment_method_type.mastercard',
defaultMessage: 'MasterCard',
},
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
},
paypal: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
cashapp: {
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
defaultMessage: 'Cash App',
},
amazon_pay: {
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
defaultMessage: 'Amazon Pay',
},
unknown: {
id: 'omorphia.component.purchase_modal.payment_method_type.unknown',
defaultMessage: 'Unknown payment method',
},
})

View File

@@ -0,0 +1,16 @@
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
export const regionOverrides = {
'us-vin': {
name: defineMessage({ id: 'servers.region.north-america', defaultMessage: 'North America' }),
flag: 'https://flagcdn.com/us.svg',
},
'eu-lim': {
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
flag: 'https://flagcdn.com/eu.svg',
},
'de-fra': {
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
flag: 'https://flagcdn.com/eu.svg',
},
} satisfies Record<string, { name?: MessageDescriptor; flag?: string }>

View File

@@ -5,5 +5,6 @@
"compilerOptions": {
"lib": ["esnext", "dom"],
"noImplicitAny": false
}
},
"types": ["@stripe/stripe-js"]
}

View File

@@ -61,23 +61,33 @@ export const getCurrency = (userCountry) => {
return countryCurrency[userCountry] ?? 'USD'
}
export const formatPrice = (locale, price, currency) => {
const formatter = new Intl.NumberFormat(locale, {
export const formatPrice = (locale, price, currency, trimZeros = false) => {
let formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
})
const maxDigits = formatter.resolvedOptions().maximumFractionDigits
const convertedPrice = price / Math.pow(10, maxDigits)
let minimumFractionDigits = maxDigits
if (trimZeros && Number.isInteger(convertedPrice)) {
minimumFractionDigits = 0
}
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
})
return formatter.format(convertedPrice)
}
export const calculateSavings = (monthlyPlan, annualPlan) => {
const monthlyAnnualized = monthlyPlan * 12
export const calculateSavings = (monthlyPlan, plan, months = 12) => {
const monthlyAnnualized = monthlyPlan * months
return Math.floor(((monthlyAnnualized - annualPlan) / monthlyAnnualized) * 100)
return Math.floor(((monthlyAnnualized - plan) / monthlyAnnualized) * 100)
}
export const createStripeElements = (stripe, paymentMethods, options) => {
@@ -87,13 +97,13 @@ export const createStripeElements = (stripe, paymentMethods, options) => {
appearance: {
variables: {
colorPrimary: styles.getPropertyValue('--color-brand'),
colorBackground: styles.getPropertyValue('--color-bg'),
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
colorText: styles.getPropertyValue('--color-base'),
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
colorDanger: styles.getPropertyValue('--color-red'),
fontFamily: styles.getPropertyValue('--font-standard'),
spacingUnit: '0.25rem',
borderRadius: '1rem',
borderRadius: '0.75rem',
},
},
loader: 'never',

View File

@@ -177,8 +177,15 @@ export const formatCategory = (name) => {
return 'Colored Lighting'
} else if (name === 'optifine') {
return 'OptiFine'
} else if (name === 'bta-babric') {
return 'BTA (Babric)'
} else if (name === 'legacy-fabric') {
return 'Legacy Fabric'
} else if (name === 'java-agent') {
return 'Java Agent'
} else if (name === 'nilloader') {
return 'NilLoader'
}
return capitalizeString(name)
}
@@ -341,3 +348,17 @@ export const getArrayOrString = (x: string[] | string): string[] => {
return x
}
}
export function getPingLevel(ping: number) {
if (ping < 120) {
return 5
} else if (ping < 200) {
return 4
} else if (ping < 300) {
return 3
} else if (ping < 400) {
return 2
} else {
return 1
}
}

205
pnpm-lock.yaml generated
View File

@@ -460,6 +460,9 @@ importers:
'@formatjs/cli':
specifier: ^6.2.12
version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4))
'@stripe/stripe-js':
specifier: ^7.3.1
version: 7.3.1
'@vintl/unplugin':
specifier: ^1.5.1
version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
@@ -472,6 +475,9 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
stripe:
specifier: ^18.1.1
version: 18.1.1(@types/node@22.4.1)
tsconfig:
specifier: workspace:*
version: link:../tsconfig
@@ -2338,6 +2344,10 @@ packages:
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
'@stripe/stripe-js@7.3.1':
resolution: {integrity: sha512-pTzb864TQWDRQBPLgSPFRoyjSDUqpCkbEgTzpsjiTjGz1Z5SxZNXJek28w1s6Dyry4CyW4/Izj5jHE/J9hCJYQ==}
engines: {node: '>=12.16'}
'@stylistic/eslint-plugin@2.9.0':
resolution: {integrity: sha512-OrDyFAYjBT61122MIY1a3SfEgy3YCMgt2vL4eoPmvTwDBwyQhAXurxNQznlRD/jESNfYWfID8Ej+31LljvF7Xg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3313,10 +3323,18 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.7:
resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
call-me-maybe@1.0.2:
resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
@@ -3828,6 +3846,10 @@ packages:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
@@ -3895,6 +3917,10 @@ packages:
resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
@@ -3909,6 +3935,10 @@ packages:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.0.3:
resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==}
engines: {node: '>= 0.4'}
@@ -4446,9 +4476,17 @@ packages:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-port-please@3.1.2:
resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -4536,6 +4574,10 @@ packages:
gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -4573,6 +4615,10 @@ packages:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
@@ -5211,6 +5257,10 @@ packages:
markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@@ -5646,6 +5696,10 @@ packages:
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
engines: {node: '>= 0.4'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
@@ -6209,6 +6263,10 @@ packages:
peerDependencies:
vue: ^3.0.0
qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -6563,10 +6621,26 @@ packages:
shiki@1.29.2:
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.0.6:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -6736,6 +6810,15 @@ packages:
strip-literal@2.1.1:
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
stripe@18.1.1:
resolution: {integrity: sha512-hlF0ripc2nJrihpsJZQDl3xirS7tpdpS7DlmSNLEDRW8j7Qr215y5DHOI3+aEY/lq6PG8y4GR1RZPtEoIoAs/g==}
engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
style-mod@4.1.2:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
@@ -9373,7 +9456,7 @@ snapshots:
'@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)':
dependencies:
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
'@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
'@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
eslint: 9.13.0(jiti@2.4.1)
@@ -9386,10 +9469,10 @@ snapshots:
- supports-color
- typescript
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))':
'@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))':
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.1))
@@ -9852,6 +9935,8 @@ snapshots:
'@sindresorhus/merge-streams@2.3.0': {}
'@stripe/stripe-js@7.3.1': {}
'@stylistic/eslint-plugin@2.9.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)':
dependencies:
'@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
@@ -11242,7 +11327,7 @@ snapshots:
c12@2.0.1(magicast@0.3.5):
dependencies:
chokidar: 4.0.1
chokidar: 4.0.3
confbox: 0.1.8
defu: 6.1.4
dotenv: 16.4.5
@@ -11259,6 +11344,11 @@ snapshots:
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.7:
dependencies:
es-define-property: 1.0.0
@@ -11267,6 +11357,11 @@ snapshots:
get-intrinsic: 1.2.4
set-function-length: 1.2.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
call-me-maybe@1.0.2: {}
callsites@3.1.0: {}
@@ -11704,6 +11799,12 @@ snapshots:
dset@3.1.4: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
duplexer@0.1.2: {}
eastasianwidth@0.2.0: {}
@@ -11801,6 +11902,8 @@ snapshots:
dependencies:
get-intrinsic: 1.2.4
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.5.4: {}
@@ -11811,6 +11914,10 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.0.3:
dependencies:
get-intrinsic: 1.2.4
@@ -11968,10 +12075,10 @@ snapshots:
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
dependencies:
eslint: 9.13.0(jiti@2.4.1)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.1))
@@ -11997,7 +12104,7 @@ snapshots:
debug: 4.4.0(supports-color@9.4.0)
enhanced-resolve: 5.17.1
eslint: 9.13.0(jiti@2.4.1)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))
fast-glob: 3.3.2
get-tsconfig: 4.7.5
@@ -12009,7 +12116,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)):
eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -12020,16 +12127,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)
eslint: 9.13.0(jiti@2.4.1)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
eslint-plugin-es@3.0.1(eslint@9.13.0(jiti@2.4.1)):
dependencies:
eslint: 9.13.0(jiti@2.4.1)
@@ -12069,7 +12166,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.13.0(jiti@2.4.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1))
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
hasown: 2.0.2
is-core-module: 2.15.0
is-glob: 4.0.3
@@ -12096,7 +12193,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.13.0(jiti@2.4.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1))
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))
hasown: 2.0.2
is-core-module: 2.15.0
is-glob: 4.0.3
@@ -12672,8 +12769,26 @@ snapshots:
has-symbols: 1.0.3
hasown: 2.0.2
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-port-please@3.1.2: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@@ -12693,7 +12808,7 @@ snapshots:
citty: 0.1.6
consola: 3.2.3
defu: 6.1.4
node-fetch-native: 1.6.4
node-fetch-native: 1.6.6
nypm: 0.3.12
ohash: 1.1.4
pathe: 1.1.2
@@ -12782,6 +12897,8 @@ snapshots:
dependencies:
get-intrinsic: 1.2.4
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
grapheme-splitter@1.0.4: {}
@@ -12829,6 +12946,8 @@ snapshots:
has-symbols@1.0.3: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.0.3
@@ -13573,6 +13692,8 @@ snapshots:
markdown-table@3.0.3: {}
math-intrinsics@1.1.0: {}
mdast-util-definitions@6.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -14431,6 +14552,8 @@ snapshots:
object-inspect@1.13.2: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
object.assign@4.1.5:
@@ -14932,6 +15055,10 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
qs@6.14.0:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
queue-tick@1.0.1: {}
@@ -15486,6 +15613,26 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.0.6:
dependencies:
call-bind: 1.0.7
@@ -15493,6 +15640,14 @@ snapshots:
get-intrinsic: 1.2.4
object-inspect: 1.13.2
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.0
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -15671,6 +15826,12 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@18.1.1(@types/node@22.4.1):
dependencies:
qs: 6.14.0
optionalDependencies:
'@types/node': 22.4.1
style-mod@4.1.2: {}
style-to-object@0.4.4:
@@ -15994,7 +16155,7 @@ snapshots:
dependencies:
acorn: 8.14.0
estree-walker: 3.0.3
magic-string: 0.30.14
magic-string: 0.30.17
unplugin: 1.16.0
undici-types@5.26.5: {}