From f52d020a3c3bade4d3869a465959a97830367e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 26 May 2025 17:21:52 -0400 Subject: [PATCH 01/64] Support specifying a region when creating servers (#3709) * Support specifying a region when creating servers * Remove hardcoded default server region --- apps/labrinth/src/routes/internal/billing.rs | 60 +++++++++++--------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index b0dc2f58..4fec9bb5 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -997,6 +997,7 @@ pub enum ChargeRequestType { pub enum PaymentRequestMetadata { Pyro { server_name: Option, + server_region: Option, source: serde_json::Value, }, } @@ -1789,33 +1790,39 @@ pub async fn stripe_webhook( .await? .error_for_status()?; } else { - let (server_name, source) = if let Some( - PaymentRequestMetadata::Pyro { - ref server_name, - ref source, - }, - ) = - metadata.payment_metadata - { - (server_name.clone(), source.clone()) - } else { - // Create a server with the latest version of Minecraft - let minecraft_versions = crate::database::models::legacy_loader_fields::MinecraftGameVersion::list( - Some("release"), - None, - &**pool, - &redis, - ).await?; + let (server_name, server_region, source) = + if let Some( + PaymentRequestMetadata::Pyro { + ref server_name, + ref server_region, + ref source, + }, + ) = metadata.payment_metadata + { + ( + server_name.clone(), + server_region.clone(), + source.clone(), + ) + } else { + // Create a server with the latest version of Minecraft + let minecraft_versions = crate::database::models::legacy_loader_fields::MinecraftGameVersion::list( + Some("release"), + None, + &**pool, + &redis, + ).await?; - ( - None, - serde_json::json!({ - "loader": "Vanilla", - "game_version": minecraft_versions.first().map(|x| x.version.clone()), - "loader_version": "" - }), - ) - }; + ( + None, + None, + serde_json::json!({ + "loader": "Vanilla", + "game_version": minecraft_versions.first().map(|x| x.version.clone()), + "loader_version": "" + }), + ) + }; let server_name = server_name .unwrap_or_else(|| { @@ -1845,6 +1852,7 @@ pub async fn stripe_webhook( "swap_mb": swap, "storage_mb": storage, }, + "region": server_region, "source": source, "payment_interval": metadata.charge_item.subscription_interval.map(|x| match x { PriceDuration::Monthly => 1, From be37f077d32f738eed641ae3525dd74c818de93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Thu, 29 May 2025 00:18:24 +0200 Subject: [PATCH 02/64] feat(labrinth): quarterly billing support (#3714) --- apps/labrinth/src/models/v3/billing.rs | 12 +++++++++++- apps/labrinth/src/routes/internal/billing.rs | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index 1e9055e5..9c46675a 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -49,6 +49,7 @@ pub enum Price { #[serde(rename_all = "kebab-case")] pub enum PriceDuration { Monthly, + Quarterly, Yearly, } @@ -56,6 +57,7 @@ impl PriceDuration { pub fn duration(&self) -> chrono::Duration { match self { PriceDuration::Monthly => chrono::Duration::days(30), + PriceDuration::Quarterly => chrono::Duration::days(90), PriceDuration::Yearly => chrono::Duration::days(365), } } @@ -63,19 +65,27 @@ impl PriceDuration { pub fn from_string(string: &str) -> PriceDuration { match string { "monthly" => PriceDuration::Monthly, + "quarterly" => PriceDuration::Quarterly, "yearly" => PriceDuration::Yearly, _ => PriceDuration::Monthly, } } + pub fn as_str(&self) -> &'static str { match self { PriceDuration::Monthly => "monthly", + PriceDuration::Quarterly => "quarterly", PriceDuration::Yearly => "yearly", } } pub fn iterator() -> impl Iterator { - vec![PriceDuration::Monthly, PriceDuration::Yearly].into_iter() + vec![ + PriceDuration::Monthly, + PriceDuration::Quarterly, + PriceDuration::Yearly, + ] + .into_iter() } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 4fec9bb5..2c4920da 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -1856,6 +1856,7 @@ pub async fn stripe_webhook( "source": source, "payment_interval": metadata.charge_item.subscription_interval.map(|x| match x { PriceDuration::Monthly => 1, + PriceDuration::Quarterly => 3, PriceDuration::Yearly => 12, }) })) From a9cfc37aacbb1e21a47bda45f590537e13c03c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Thu, 29 May 2025 22:51:30 +0200 Subject: [PATCH 03/64] Some small Labrinth refactors and fixes (#3698) * chore(labrinth): fix typos, simplify out `remove_duplicates` func * fix(labrinth): implement `capitalize_first` so that it can't panic on wide chars * chore(labrinth): refactor out unneeded clone highlighted by nightly Clippy lints * chore(labrinth): simplify `capitalize_first` implementation * fix(labrinth): preserve ordering when deduplicating project field values This addresses an unintended behavior change on 157647faf2778c74096e624aeef9cdb79539489c. * fix(labrinth/tests): make `index_swaps` test run successfully I wonder why we don't run these more often... * refactor: rename `.env.example` files to `.env.local`, make local envs more consistent between frontend and backend * chore(labrinth/.env.local): proper email verif. and password reset paths --- .../{.env.example => .env.local} | 0 .../src/content/docs/contributing/labrinth.md | 2 +- apps/frontend/.env.example | 3 -- apps/frontend/.env.local | 5 ++++ apps/labrinth/{.env.example => .env.local} | 6 ++-- apps/labrinth/src/models/v3/projects.rs | 25 +++++------------ apps/labrinth/src/routes/v2_reroute.rs | 10 +++---- apps/labrinth/src/routes/v3/version_file.rs | 28 ++++++++----------- apps/labrinth/tests/search.rs | 5 ++-- packages/app-lib/{.env.example => .env.local} | 0 10 files changed, 36 insertions(+), 48 deletions(-) rename apps/daedalus_client/{.env.example => .env.local} (100%) delete mode 100644 apps/frontend/.env.example create mode 100644 apps/frontend/.env.local rename apps/labrinth/{.env.example => .env.local} (95%) rename packages/app-lib/{.env.example => .env.local} (100%) diff --git a/apps/daedalus_client/.env.example b/apps/daedalus_client/.env.local similarity index 100% rename from apps/daedalus_client/.env.example rename to apps/daedalus_client/.env.local diff --git a/apps/docs/src/content/docs/contributing/labrinth.md b/apps/docs/src/content/docs/contributing/labrinth.md index 8ce0ac88..155e854c 100644 --- a/apps/docs/src/content/docs/contributing/labrinth.md +++ b/apps/docs/src/content/docs/contributing/labrinth.md @@ -7,7 +7,7 @@ This project is part of our [monorepo](https://github.com/modrinth/code). You ca [labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000. -To get a basic configuration, copy the `.env.example` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo: +To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo: ```bash cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example deleted file mode 100644 index 43ceb1d5..00000000 --- a/apps/frontend/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://api.modrinth.com/v2/ -BROWSER_BASE_URL=https://api.modrinth.com/v2/ -PYRO_BASE_URL=https://archon.modrinth.com/ diff --git a/apps/frontend/.env.local b/apps/frontend/.env.local new file mode 100644 index 00000000..f764f851 --- /dev/null +++ b/apps/frontend/.env.local @@ -0,0 +1,5 @@ +BASE_URL=http://127.0.0.1:8000/v2/ +BROWSER_BASE_URL=http://127.0.0.1:8000/v2/ +PYRO_BASE_URL=https://staging-archon.modrinth.com +PROD_OVERRIDE=true + diff --git a/apps/labrinth/.env.example b/apps/labrinth/.env.local similarity index 95% rename from apps/labrinth/.env.example rename to apps/labrinth/.env.local index 1fbb8eeb..8675bcd6 100644 --- a/apps/labrinth/.env.example +++ b/apps/labrinth/.env.local @@ -2,7 +2,7 @@ DEBUG=true RUST_LOG=info,sqlx::query=warn SENTRY_DSN=none -SITE_URL=https://modrinth.com +SITE_URL=http://localhost:3000 CDN_URL=https://staging-cdn.modrinth.com LABRINTH_ADMIN_KEY=feedbeef RATE_LIMIT_IGNORE_KEY=feedbeef @@ -87,8 +87,8 @@ SMTP_HOST=none SMTP_PORT=465 SMTP_TLS=tls -SITE_VERIFY_EMAIL_PATH=none -SITE_RESET_PASSWORD_PATH=none +SITE_VERIFY_EMAIL_PATH=auth/verify-email +SITE_RESET_PASSWORD_PATH=auth/reset-password SITE_BILLING_PATH=none SENDY_URL=none diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 55631a6f..6e9f17cf 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -1,4 +1,5 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +use std::mem; use crate::database::models::loader_fields::VersionField; use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; @@ -8,6 +9,7 @@ use crate::models::ids::{ }; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -95,19 +97,6 @@ pub struct Project { pub fields: HashMap>, } -fn remove_duplicates(values: Vec) -> Vec { - let mut seen = HashSet::new(); - values - .into_iter() - .filter(|value| { - // Convert the JSON value to a string for comparison - let as_string = value.to_string(); - // Check if the string is already in the set - seen.insert(as_string) - }) - .collect() -} - // This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values // This allows for removal of duplicates pub fn from_duplicate_version_fields( @@ -132,9 +121,9 @@ pub fn from_duplicate_version_fields( } } - // Remove duplicates by converting to string and back + // Remove duplicates for (_, v) in fields.iter_mut() { - *v = remove_duplicates(v.clone()); + *v = mem::take(v).into_iter().unique().collect_vec(); } fields } @@ -624,7 +613,7 @@ pub struct Version { pub downloads: u32, /// The type of the release - `Alpha`, `Beta`, or `Release`. pub version_type: VersionType, - /// The status of tne version + /// The status of the version pub status: VersionStatus, /// The requested status of the version (used for scheduling) pub requested_status: Option, @@ -880,7 +869,7 @@ impl std::fmt::Display for DependencyType { } impl DependencyType { - // These are constant, so this can remove unneccessary allocations (`to_string`) + // These are constant, so this can remove unnecessary allocations (`to_string`) pub fn as_str(&self) -> &'static str { match self { DependencyType::Required => "required", diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index a73baac4..b6a19375 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -264,11 +264,11 @@ pub fn convert_side_types_v2_bools( } pub fn capitalize_first(input: &str) -> String { - let mut result = input.to_owned(); - if let Some(first_char) = result.get_mut(0..1) { - first_char.make_ascii_uppercase(); - } - result + input + .chars() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_ascii_uppercase() } else { c }) + .collect() } #[cfg(test)] diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 744aa8d9..4c713322 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -52,10 +52,9 @@ pub async fn get_version_from_hash( .map(|x| x.1) .ok(); let hash = info.into_inner().0.to_lowercase(); - let algorithm = hash_query - .algorithm - .clone() - .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let algorithm = hash_query.algorithm.clone().unwrap_or_else(|| { + default_algorithm_from_hashes(std::slice::from_ref(&hash)) + }); let file = database::models::DBVersion::get_file_from_hash( algorithm, hash, @@ -140,10 +139,9 @@ pub async fn get_update_from_hash( .ok(); let hash = info.into_inner().0.to_lowercase(); if let Some(file) = database::models::DBVersion::get_file_from_hash( - hash_query - .algorithm - .clone() - .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])), + hash_query.algorithm.clone().unwrap_or_else(|| { + default_algorithm_from_hashes(std::slice::from_ref(&hash)) + }), hash, hash_query.version_id.map(|x| x.into()), &**pool, @@ -577,10 +575,9 @@ pub async fn delete_file( .1; let hash = info.into_inner().0.to_lowercase(); - let algorithm = hash_query - .algorithm - .clone() - .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let algorithm = hash_query.algorithm.clone().unwrap_or_else(|| { + default_algorithm_from_hashes(std::slice::from_ref(&hash)) + }); let file = database::models::DBVersion::get_file_from_hash( algorithm.clone(), hash, @@ -709,10 +706,9 @@ pub async fn download_version( .ok(); let hash = info.into_inner().0.to_lowercase(); - let algorithm = hash_query - .algorithm - .clone() - .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let algorithm = hash_query.algorithm.clone().unwrap_or_else(|| { + default_algorithm_from_hashes(std::slice::from_ref(&hash)) + }); let file = database::models::DBVersion::get_file_from_hash( algorithm.clone(), hash, diff --git a/apps/labrinth/tests/search.rs b/apps/labrinth/tests/search.rs index e05b13de..e8562f5c 100644 --- a/apps/labrinth/tests/search.rs +++ b/apps/labrinth/tests/search.rs @@ -151,7 +151,7 @@ async fn index_swaps() { test_env.api.remove_project("alpha", USER_USER_PAT).await; assert_status!(&resp, StatusCode::NO_CONTENT); - // We should not get any results, because the project has been deleted + // Deletions should not be indexed immediately let projects = test_env .api .search_deserialized( @@ -160,7 +160,8 @@ async fn index_swaps() { USER_USER_PAT, ) .await; - assert_eq!(projects.total_hits, 0); + assert_eq!(projects.total_hits, 1); + assert!(projects.hits[0].slug.as_ref().unwrap().contains("alpha")); // But when we reindex, it should be gone let resp = test_env.api.reset_search_index().await; diff --git a/packages/app-lib/.env.example b/packages/app-lib/.env.local similarity index 100% rename from packages/app-lib/.env.example rename to packages/app-lib/.env.local From b66d99c59cfe35488f84ad01933ec291f249648b Mon Sep 17 00:00:00 2001 From: Emma Alexia Date: Fri, 30 May 2025 12:28:00 -0400 Subject: [PATCH 04/64] Improve error when Modrinth's PayPal account is out of funds (#3718) * Improve error when Modrinth's PayPal account is out of funds * improve msg --- apps/labrinth/src/queue/payouts.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index b6e95031..69269b02 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -193,9 +193,12 @@ impl PayoutsQueue { pub error_description: String, } - if let Ok(error) = + if let Ok(mut error) = serde_json::from_value::(value.clone()) { + if error.name == "INSUFFICIENT_FUNDS" { + error.message = "We're currently transferring funds to our PayPal account. Please try again in a couple days.".to_string(); + } return Err(ApiError::Payments(format!( "error name: {}, message: {}", error.name, error.message From 6fa1369c49224efa18a32251da567fcf09132ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Sat, 31 May 2025 11:00:08 +0200 Subject: [PATCH 05/64] fix(labrinth): tentative billing period update fix (#3722) * fix(labrinth/billing): add Spain and Singapore to the list of countries for currency inferences This should fix payments in those countries not going through with their local currencies for products that do not have USD-only pricing. * fix(labrinth/billing): tentative fix for subscription periods not updating --- apps/labrinth/src/routes/internal/billing.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 2c4920da..39b0a476 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -1020,6 +1020,7 @@ fn infer_currency_code(country: &str) -> String { "BE" => "EUR", "CY" => "EUR", "EE" => "EUR", + "ES" => "EUR", "FI" => "EUR", "FR" => "EUR", "DE" => "EUR", @@ -1066,6 +1067,7 @@ fn infer_currency_code(country: &str) -> String { "TW" => "TWD", "SA" => "SAR", "QA" => "QAR", + "SG" => "SGD", _ => "USD", } .to_string() @@ -1302,6 +1304,12 @@ pub async fn initiate_payment( amount: Some(price), currency: Some(stripe_currency), customer: Some(customer), + metadata: interval.map(|interval| { + HashMap::from([( + "modrinth_subscription_interval".to_string(), + interval.as_str().to_string(), + )]) + }), ..Default::default() }; From 0aa76567a6fffdd1e10cce5b2ac5b42d78354b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:51:00 +0200 Subject: [PATCH 06/64] docs(issue template): do not skip closed issues in link to existing issues (#3728) When an issue has been already handled by our part, and thus gets closed, but affects many users and the fix takes a while to be rolled out, it usually happens that those who notice the matter later on don't notice previous reports and create duplicate issues. Let's try to improve a little bit on that by not filtering out closed issues in the links for checking whether the same issue was already reported before. This should make it more obvious to users who follow the link whether an issue for their problem already exists. --- .github/ISSUE_TEMPLATE/1-app-bug.yml | 2 +- .github/ISSUE_TEMPLATE/2-web-bug.yml | 2 +- .github/ISSUE_TEMPLATE/3-api-bug.yml | 2 +- .github/ISSUE_TEMPLATE/4-feature-request.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-app-bug.yml b/.github/ISSUE_TEMPLATE/1-app-bug.yml index 201ab4c2..3b0a6791 100644 --- a/.github/ISSUE_TEMPLATE/1-app-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-app-bug.yml @@ -6,7 +6,7 @@ body: attributes: label: Please confirm the following. options: - - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems + - label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems required: true - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) required: true diff --git a/.github/ISSUE_TEMPLATE/2-web-bug.yml b/.github/ISSUE_TEMPLATE/2-web-bug.yml index dcef0bb7..118f2133 100644 --- a/.github/ISSUE_TEMPLATE/2-web-bug.yml +++ b/.github/ISSUE_TEMPLATE/2-web-bug.yml @@ -6,7 +6,7 @@ body: attributes: label: Please confirm the following. options: - - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems + - label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems required: true - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) required: true diff --git a/.github/ISSUE_TEMPLATE/3-api-bug.yml b/.github/ISSUE_TEMPLATE/3-api-bug.yml index a17ed85e..19f7f764 100644 --- a/.github/ISSUE_TEMPLATE/3-api-bug.yml +++ b/.github/ISSUE_TEMPLATE/3-api-bug.yml @@ -6,7 +6,7 @@ body: attributes: label: Please confirm the following. options: - - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems + - label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems required: true - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) required: true diff --git a/.github/ISSUE_TEMPLATE/4-feature-request.yml b/.github/ISSUE_TEMPLATE/4-feature-request.yml index 03450b46..945d6841 100644 --- a/.github/ISSUE_TEMPLATE/4-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/4-feature-request.yml @@ -7,7 +7,7 @@ body: attributes: label: Please confirm the following. options: - - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests + - label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests required: true - label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com) required: true From 7b535a1c2af6d7954ea7b81ad1de4b2ade447ebc Mon Sep 17 00:00:00 2001 From: Emma Alexia Date: Sun, 1 Jun 2025 19:53:45 -0400 Subject: [PATCH 07/64] Enable charity payouts through Tremendous (#3732) --- apps/labrinth/src/queue/payouts.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index 69269b02..ccc51a6d 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -390,6 +390,7 @@ impl PayoutsQueue { "bank", "ach", "visa_card", + "charity", ]; if !SUPPORTED_METHODS.contains(&&*product.category) From 7223c2b19761e0ff3ab8bf77048d483647c3bc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Mon, 2 Jun 2025 01:13:06 -0400 Subject: [PATCH 08/64] Include region in user subscription metadata (#3733) --- apps/labrinth/src/models/v3/billing.rs | 2 +- apps/labrinth/src/routes/internal/billing.rs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index 9c46675a..2757a7fc 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -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 }, } #[derive(Serialize, Deserialize)] diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 39b0a476..05e30224 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -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, } 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(), }, }) }) @@ -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() From c0accb42fa08d0ed60ce00b71d08daa7016757a1 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Tue, 3 Jun 2025 09:20:53 -0700 Subject: [PATCH 09/64] Servers new purchase flow (#3719) * New purchase flow for servers, region selector, etc. * Lint * Lint * Fix expanding total --- .../src/components/ui/world/WorldItem.vue | 16 +- .../src/components/ui/OptionGroup.vue | 128 ++++++ .../components/ui/servers/LoaderSelector.vue | 1 + .../ui/servers/PlatformVersionSelectModal.vue | 2 +- .../ui/servers/ServerInstallation.vue | 277 +++++++++++++ .../servers/marketing/ServerPlanSelector.vue | 120 ++---- apps/frontend/src/composables/country.ts | 1 + apps/frontend/src/composables/pyroFetch.ts | 4 +- apps/frontend/src/composables/pyroServers.ts | 3 + apps/frontend/src/locales/en-US/index.json | 28 +- apps/frontend/src/pages/servers/index.vue | 315 ++++++++------- .../src/pages/servers/manage/[id].vue | 311 +++++++++------ .../servers/manage/[id]/options/loader.vue | 253 +----------- .../src/pages/settings/billing/index.vue | 123 +----- packages/assets/icons/cpu.svg | 15 +- packages/assets/icons/database.svg | 1 + packages/assets/icons/memory-stick.svg | 1 + packages/assets/index.ts | 4 + packages/assets/styles/variables.scss | 4 +- packages/ui/package.json | 2 + .../ui/src/components/base/RadioButtons.vue | 4 +- .../components/billing/AddPaymentMethod.vue | 104 +++++ .../billing/AddPaymentMethodModal.vue | 72 ++++ .../billing/ExpandableInvoiceTotal.vue | 65 +++ .../billing/FormattedPaymentMethod.vue | 43 ++ .../billing/ModrinthServersPurchaseModal.vue | 297 ++++++++++++++ .../billing/PaymentMethodOption.vue | 37 ++ .../billing/ServersPurchase1Region.vue | 229 +++++++++++ .../billing/ServersPurchase2PaymentMethod.vue | 69 ++++ .../billing/ServersPurchase3Review.vue | 264 ++++++++++++ .../billing/ServersRegionButton.vue | 93 +++++ .../src/components/billing/ServersSpecs.vue | 60 +++ packages/ui/src/components/index.ts | 3 + .../modal/ModalLoadingIndicator.vue | 35 ++ packages/ui/src/composables/stripe.ts | 376 ++++++++++++++++++ packages/ui/src/locales/en-US/index.json | 42 ++ packages/ui/src/utils/billing.ts | 101 +++++ packages/ui/src/utils/common-messages.ts | 60 +++ packages/ui/src/utils/regions.ts | 16 + packages/ui/tsconfig.json | 3 +- packages/utils/billing.ts | 20 +- packages/utils/utils.ts | 14 + pnpm-lock.yaml | 205 +++++++++- 43 files changed, 3021 insertions(+), 800 deletions(-) create mode 100644 apps/frontend/src/components/ui/OptionGroup.vue create mode 100644 apps/frontend/src/components/ui/servers/ServerInstallation.vue create mode 100644 packages/assets/icons/database.svg create mode 100644 packages/assets/icons/memory-stick.svg create mode 100644 packages/ui/src/components/billing/AddPaymentMethod.vue create mode 100644 packages/ui/src/components/billing/AddPaymentMethodModal.vue create mode 100644 packages/ui/src/components/billing/ExpandableInvoiceTotal.vue create mode 100644 packages/ui/src/components/billing/FormattedPaymentMethod.vue create mode 100644 packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue create mode 100644 packages/ui/src/components/billing/PaymentMethodOption.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase1Region.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue create mode 100644 packages/ui/src/components/billing/ServersPurchase3Review.vue create mode 100644 packages/ui/src/components/billing/ServersRegionButton.vue create mode 100644 packages/ui/src/components/billing/ServersSpecs.vue create mode 100644 packages/ui/src/components/modal/ModalLoadingIndicator.vue create mode 100644 packages/ui/src/composables/stripe.ts create mode 100644 packages/ui/src/utils/billing.ts create mode 100644 packages/ui/src/utils/regions.ts diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue index 3516a3b1..2062b673 100644 --- a/apps/app-frontend/src/components/ui/world/WorldItem.vue +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -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({ diff --git a/apps/frontend/src/components/ui/OptionGroup.vue b/apps/frontend/src/components/ui/OptionGroup.vue new file mode 100644 index 00000000..f00cd327 --- /dev/null +++ b/apps/frontend/src/components/ui/OptionGroup.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/LoaderSelector.vue b/apps/frontend/src/components/ui/servers/LoaderSelector.vue index ec37299a..b5f52655 100644 --- a/apps/frontend/src/components/ui/servers/LoaderSelector.vue +++ b/apps/frontend/src/components/ui/servers/LoaderSelector.vue @@ -63,6 +63,7 @@ const props = defineProps<{ loader: string | null; loader_version: string | null; }; + ignoreCurrentInstallation?: boolean; isInstalling?: boolean; }>(); diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue index cdc71fa1..ed281e08 100644 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue @@ -313,7 +313,7 @@ const selectedLoaderVersions = computed(() => { 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") { diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue new file mode 100644 index 00000000..cd11fec3 --- /dev/null +++ b/apps/frontend/src/components/ui/servers/ServerInstallation.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue index 7c08bdf9..8aa18f3e 100644 --- a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue +++ b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue @@ -1,9 +1,9 @@ diff --git a/apps/frontend/src/composables/country.ts b/apps/frontend/src/composables/country.ts index 0d3dd6a8..cde074ff 100644 --- a/apps/frontend/src/composables/country.ts +++ b/apps/frontend/src/composables/country.ts @@ -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) { diff --git a/apps/frontend/src/composables/pyroFetch.ts b/apps/frontend/src/composables/pyroFetch.ts index 1c2e45a9..210135be 100644 --- a/apps/frontend/src/composables/pyroFetch.ts +++ b/apps/frontend/src/composables/pyroFetch.ts @@ -49,7 +49,9 @@ export async function usePyroFetch(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; diff --git a/apps/frontend/src/composables/pyroServers.ts b/apps/frontend/src/composables/pyroServers.ts index 9c758e11..ac55ac47 100644 --- a/apps/frontend/src/composables/pyroServers.ts +++ b/apps/frontend/src/composables/pyroServers.ts @@ -330,6 +330,9 @@ interface General { token: string; instance: string; }; + flows?: { + intro?: boolean; + }; } interface Allocation { diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 5e1ecbe6..6c261632 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -159,7 +159,7 @@ "message": "Subscribe to updates about Modrinth" }, "auth.welcome.description": { - "message": "You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods." + "message": "You’re 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 and Privacy Policy." @@ -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 15-25 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 6–15 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 1–5 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" }, diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index e96a8c8f..9030c0e0 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -4,27 +4,28 @@ data-pyro class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8" > - 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" />
-
-
-
-
-
-
-
- Server Locations -
-

- Coast-to-Coast Coverage -

-
- -
-
-
-
- - - - -
-

- US Coverage -

-
-

- 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. -

-
- -
-
-
- - - - - - -
-

- Global Expansion -

-
-

- 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. -

-
-
-
- -
-
-
-

- Start your server on Modrinth + There's a server for everyone

-

- {{ - isAtCapacity && !loggedOut - ? "We are currently at capacity. Please try again later." - : "There's a plan for everyone! Choose the one that fits your needs." - }} -

+

+ Available in North America and Europe for wide coverage. +

-
    +
    + + + + Pay quarterly + Pay yearly + + + + +
    + +
      @@ -629,9 +569,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 +584,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 +600,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" >
      -

      Build your own

      +

      Know exactly what you need?

      - 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.

      @@ -666,7 +611,7 @@ > @@ -679,7 +624,7 @@ - - diff --git a/apps/frontend/src/pages/settings/billing/index.vue b/apps/frontend/src/pages/settings/billing/index.vue index 4b3fc7a8..517a41c9 100644 --- a/apps/frontend/src/pages/settings/billing/index.vue +++ b/apps/frontend/src/pages/settings/billing/index.vue @@ -444,39 +444,13 @@ :return-url="`${config.public.siteUrl}/servers/manage`" :server-name="`${auth?.user?.username}'s server`" /> - - -
      -
      - -
      -
      -
      -
      -
      -
      - - - - - - -
      -
      -
      +

      {{ formatMessage(messages.paymentMethodTitle) }}

      @@ -590,9 +564,8 @@ + + diff --git a/packages/ui/src/components/billing/AddPaymentMethodModal.vue b/packages/ui/src/components/billing/AddPaymentMethodModal.vue new file mode 100644 index 00000000..8175501e --- /dev/null +++ b/packages/ui/src/components/billing/AddPaymentMethodModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue b/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue new file mode 100644 index 00000000..d586464c --- /dev/null +++ b/packages/ui/src/components/billing/ExpandableInvoiceTotal.vue @@ -0,0 +1,65 @@ + + diff --git a/packages/ui/src/components/billing/FormattedPaymentMethod.vue b/packages/ui/src/components/billing/FormattedPaymentMethod.vue new file mode 100644 index 00000000..617adf81 --- /dev/null +++ b/packages/ui/src/components/billing/FormattedPaymentMethod.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue new file mode 100644 index 00000000..bdd3fc9e --- /dev/null +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -0,0 +1,297 @@ + + diff --git a/packages/ui/src/components/billing/PaymentMethodOption.vue b/packages/ui/src/components/billing/PaymentMethodOption.vue new file mode 100644 index 00000000..a600054e --- /dev/null +++ b/packages/ui/src/components/billing/PaymentMethodOption.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase1Region.vue b/packages/ui/src/components/billing/ServersPurchase1Region.vue new file mode 100644 index 00000000..af8f4304 --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase1Region.vue @@ -0,0 +1,229 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue new file mode 100644 index 00000000..f13a31ca --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase2PaymentMethod.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue new file mode 100644 index 00000000..40f7d712 --- /dev/null +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -0,0 +1,264 @@ + + + diff --git a/packages/ui/src/components/billing/ServersRegionButton.vue b/packages/ui/src/components/billing/ServersRegionButton.vue new file mode 100644 index 00000000..bf364f85 --- /dev/null +++ b/packages/ui/src/components/billing/ServersRegionButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/ui/src/components/billing/ServersSpecs.vue b/packages/ui/src/components/billing/ServersSpecs.vue new file mode 100644 index 00000000..f9a58124 --- /dev/null +++ b/packages/ui/src/components/billing/ServersSpecs.vue @@ -0,0 +1,60 @@ + + diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 899a8932..a1c39233 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/modal/ModalLoadingIndicator.vue b/packages/ui/src/components/modal/ModalLoadingIndicator.vue new file mode 100644 index 00000000..8815d74d --- /dev/null +++ b/packages/ui/src/components/modal/ModalLoadingIndicator.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts new file mode 100644 index 00000000..12144bb4 --- /dev/null +++ b/packages/ui/src/composables/stripe.ts @@ -0,0 +1,376 @@ +import type Stripe from 'stripe' +import type { StripeElementsOptionsMode } from '@stripe/stripe-js/dist/stripe-js/elements-group' +import { + type Stripe as StripeJs, + loadStripe, + type StripeAddressElement, + type StripeElements, + type StripePaymentElement, +} 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' + +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[], + clientSecret: string, + currency: string, + product: Ref, + interval: Ref, + initiatePayment: ( + body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, + ) => Promise, +) => { + const stripe = ref(null) + + let elements: StripeElements | undefined = undefined + const elementsLoaded = ref<0 | 1 | 2>(0) + const loadingElementsFailed = ref(false) + + const paymentMethodLoading = ref(false) + const loadingFailed = ref() + const paymentIntentId = ref() + const tax = ref() + const total = ref() + const confirmationToken = ref() + const submittingPayment = ref(false) + const selectedPaymentMethod = ref() + const inputtedPaymentMethod = ref() + + async function initialize() { + stripe.value = await loadStripe(publishableKey) + } + + function createIntent(body: CreatePaymentIntentRequest): Promise { + return initiatePayment(body) as Promise + } + + function updateIntent(body: UpdatePaymentIntentRequest): Promise { + return initiatePayment(body) as Promise + } + + const planPrices = computed(() => { + return product.value.prices.find((x) => x.currency_code === currency) + }) + + const createElements: 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(() => { + 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 = false + try { + if (!customer) { + paymentMethodLoading.value = true + await props.refreshPaymentMethods() + 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, + } + + 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, + }) + console.log(`Updated payment intent: ${interval.value} for ${result.total}`) + } else { + ;({ + payment_intent_id: paymentIntentId.value, + client_secret: clientSecret, + ...result + } = await createIntent({ + ...requestType, + charge, + })) + 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) { + emit('error', err) + } + paymentMethodLoading.value = false + } + + async function createConfirmationToken() { + if (!elements) { + return handlePaymentError('No elements') + } + + const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({ + elements, + }) + + if (error) { + emit('error', error) + return + } + + return confirmation.id + } + + function handlePaymentError(err: string | Error) { + paymentMethodLoading.value = false + emit('error', 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) + } + + 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) { + submittingPayment.value = true + const { error } = await stripe.value.confirmPayment({ + clientSecret, + confirmParams: { + confirmation_token: confirmationToken.value, + return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`, + }, + }) + + if (error) { + props.onError(error) + return false + } + submittingPayment.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, + } +} diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 44ba1c6e..e6088508 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -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? Let us know where you'd like to see Modrinth Servers next!" + }, "settings.account.title": { "defaultMessage": "Account and security" }, diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts new file mode 100644 index 00000000..8b473609 --- /dev/null +++ b/packages/ui/src/utils/billing.ts @@ -0,0 +1,101 @@ +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 + 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 +} diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts index d878f11d..424d33ca 100644 --- a/packages/ui/src/utils/common-messages.ts +++ b/packages/ui/src/utils/common-messages.ts @@ -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', + }, +}) diff --git a/packages/ui/src/utils/regions.ts b/packages/ui/src/utils/regions.ts new file mode 100644 index 00000000..5167a291 --- /dev/null +++ b/packages/ui/src/utils/regions.ts @@ -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 diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a5d165a0..3c734084 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,5 +5,6 @@ "compilerOptions": { "lib": ["esnext", "dom"], "noImplicitAny": false - } + }, + "types": ["@stripe/stripe-js"] } diff --git a/packages/utils/billing.ts b/packages/utils/billing.ts index 7e27099c..db2372dd 100644 --- a/packages/utils/billing.ts +++ b/packages/utils/billing.ts @@ -61,16 +61,26 @@ 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) } @@ -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', diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts index 56f34523..7ac4ed66 100644 --- a/packages/utils/utils.ts +++ b/packages/utils/utils.ts @@ -341,3 +341,17 @@ export const getArrayOrString = (x: string[] | string): string[] => { return x } } + +export 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 + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70096d6d..be9ad0a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} From 4441be538060b37e7b24c7adc69b2a6938c1a3e6 Mon Sep 17 00:00:00 2001 From: Prospector Date: Tue, 3 Jun 2025 09:21:19 -0700 Subject: [PATCH 10/64] Fixes to billing --- apps/frontend/src/pages/servers/index.vue | 10 +-- .../billing/ModrinthServersPurchaseModal.vue | 70 ++++++++++++------ .../src/components/billing/PurchaseModal.vue | 4 +- .../billing/ServersPurchase2PaymentMethod.vue | 15 ---- .../billing/ServersPurchase3Review.vue | 27 ++++--- packages/ui/src/composables/stripe.ts | 74 ++++++++++++------- packages/utils/billing.ts | 6 +- 7 files changed, 115 insertions(+), 91 deletions(-) diff --git a/apps/frontend/src/pages/servers/index.vue b/apps/frontend/src/pages/servers/index.vue index 9030c0e0..9ccd98e8 100644 --- a/apps/frontend/src/pages/servers/index.vue +++ b/apps/frontend/src/pages/servers/index.vue @@ -520,27 +520,19 @@
      @@ -639,7 +631,7 @@ import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue"; import OptionGroup from "~/components/ui/OptionGroup.vue"; -const billingPeriods = ref(["monthly", "yearly"]); +const billingPeriods = ref(["monthly", "quarterly"]); const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly"); const pyroProducts = products.filter((p) => p.metadata.type === "pyro"); diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index bdd3fc9e..b988105f 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -8,6 +8,7 @@ import { RightArrowIcon, XIcon, CheckCircleIcon, + SpinnerIcon, } from '@modrinth/assets' import type { CreatePaymentIntentRequest, @@ -27,6 +28,7 @@ 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() @@ -49,11 +51,12 @@ const props = defineProps<{ initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, ) => Promise + onError: (err: Error) => void }>() const modal = useTemplateRef>('modal') const selectedPlan = ref() -const selectedInterval = ref() +const selectedInterval = ref('quarterly') const loading = ref(false) const { @@ -72,21 +75,22 @@ const { reloadPaymentIntent, hasPaymentMethod, submitPayment, + completingPurchase, } = useStripe( props.publishableKey, props.customer, props.paymentMethods, - props.clientSecret, props.currency, selectedPlan, selectedInterval, props.initiatePayment, - console.error, + props.onError, ) const selectedRegion = ref() const customServer = ref(false) const acceptedEula = ref(false) +const firstTimeThru = ref(true) type Step = 'region' | 'payment' | 'review' @@ -111,9 +115,13 @@ const currentPing = computed(() => { const currentStep = ref() -const currentStepIndex = computed(() => steps.indexOf(currentStep.value)) -const previousStep = computed(() => steps[steps.indexOf(currentStep.value) - 1]) -const nextStep = computed(() => steps[steps.indexOf(currentStep.value) + 1]) +const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1)) +const previousStep = computed(() => + currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined, +) +const nextStep = computed(() => + currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined, +) const canProceed = computed(() => { switch (currentStep.value) { @@ -122,7 +130,7 @@ const canProceed = computed(() => { case 'payment': return selectedPaymentMethod.value || !loadingElements.value case 'review': - return acceptedEula.value && hasPaymentMethod.value + return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value default: return false } @@ -135,13 +143,14 @@ async function beforeProceed(step: string) { case 'payment': await initializeStripe() - if (primaryPaymentMethodId.value) { + if (primaryPaymentMethodId.value && firstTimeThru.value) { const paymentMethod = await props.paymentMethods.find( (x) => x.id === primaryPaymentMethodId.value, ) await selectPaymentMethod(paymentMethod) await setStep('review', true) - return true + firstTimeThru.value = false + return false } return true case 'review': @@ -166,13 +175,13 @@ async function afterProceed(step: string) { } } -async function setStep(step: Step, skipValidation = false) { +async function setStep(step: Step | undefined, skipValidation = false) { if (!step) { await submitPayment(props.returnUrl) return } - if (!canProceed.value || skipValidation) { + if (!skipValidation && !canProceed.value) { return } @@ -191,6 +200,7 @@ function begin(interval: ServerBillingInterval, plan?: ServerPlan) { customServer.value = !selectedPlan.value selectedPaymentMethod.value = undefined currentStep.value = steps[0] + firstTimeThru.value = true modal.value?.show() } @@ -206,7 +216,7 @@ defineExpose({ @@ -249,31 +259,48 @@ defineExpose({ v-else-if=" currentStep === 'review' && hasPaymentMethod && - selectedRegion && + currentRegion && selectedInterval && selectedPlan " - ref="currentStepRef" v-model:interval="selectedInterval" v-model:accepted-eula="acceptedEula" :currency="currency" :plan="selectedPlan" - :region="regions.find((x) => x.shortcode === selectedRegion)" + :region="currentRegion" :ping="currentPing" :loading="paymentMethodLoading" :selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod" :tax="tax" :total="total" - :on-error="console.error" - @change-payment-method="setStep('payment')" + @change-payment-method="setStep('payment', true)" @reload-payment-intent="reloadPaymentIntent" - @error="console.error" />
      Something went wrong
      +
      +
      + + Loading... + + +
      +
      +
      +
      +
      +
      - -
-
+
-
- + {{ pyroConsole.filteredOutput.value.length }} @@ -29,11 +30,13 @@ :class="[ 'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300', { 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen }, + { 'pointer-events-none': loading }, ]" + :aria-hidden="loading" tabindex="-1" >
-
+
+
-

- {{ formatBytes(stats.storage_usage_bytes) }} + {{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}

Storage usage

-
+
diff --git a/apps/frontend/src/pages/servers/manage/[id]/index.vue b/apps/frontend/src/pages/servers/manage/[id]/index.vue index 1b2fd2fb..198a49dc 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/index.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/index.vue @@ -1,11 +1,7 @@ diff --git a/packages/assets/icons/cpu.svg b/packages/assets/icons/cpu.svg index 52c74371..0b3bb3e3 100644 --- a/packages/assets/icons/cpu.svg +++ b/packages/assets/icons/cpu.svg @@ -1 +1,18 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/assets/icons/db.svg b/packages/assets/icons/db.svg deleted file mode 100644 index 196cf349..00000000 --- a/packages/assets/icons/db.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 576cd03c..df7df424 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -207,7 +207,6 @@ import _CubeIcon from './icons/cube.svg?component' import _CloudIcon from './icons/cloud.svg?component' import _CogIcon from './icons/cog.svg?component' import _CPUIcon from './icons/cpu.svg?component' -import _DBIcon from './icons/db.svg?component' import _LoaderIcon from './icons/loader.svg?component' import _ImportIcon from './icons/import.svg?component' import _TimerIcon from './icons/timer.svg?component' @@ -438,7 +437,6 @@ export const CubeIcon = _CubeIcon export const CloudIcon = _CloudIcon export const CogIcon = _CogIcon export const CPUIcon = _CPUIcon -export const DBIcon = _DBIcon export const LoaderIcon = _LoaderIcon export const ImportIcon = _ImportIcon export const CardIcon = _CardIcon From a2e323c9eefbe274757587f5314b96b1e2df0768 Mon Sep 17 00:00:00 2001 From: IMB11 Date: Wed, 11 Jun 2025 22:54:08 +0100 Subject: [PATCH 33/64] fix: MOD-292 repair button showing during installation (#3734) * fix: MOD-292 repair button showing during installation * fix: lint * Update apps/app-frontend/src/pages/instance/Index.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: IMB11 * fix: lint issues --------- Signed-off-by: IMB11 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/app-frontend/src/pages/instance/Index.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 65bfbf68..8b6d63bf 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -32,7 +32,11 @@ @@ -331,16 +331,13 @@ import { XIcon, ExternalIcon, } from "@modrinth/assets"; -import { useRelativeTime } from "@modrinth/ui"; +import { Avatar, ProjectStatusBadge, CopyCode, useRelativeTime } from "@modrinth/ui"; import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue"; import { getProjectLink, getVersionLink } from "~/helpers/projects.js"; import { getUserLink } from "~/helpers/users.js"; import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js"; import { markAsRead } from "~/helpers/notifications.ts"; import DoubleIcon from "~/components/ui/DoubleIcon.vue"; -import Avatar from "~/components/ui/Avatar.vue"; -import Badge from "~/components/ui/Badge.vue"; -import CopyCode from "~/components/ui/CopyCode.vue"; import Categories from "~/components/ui/search/Categories.vue"; const app = useNuxtApp(); diff --git a/apps/frontend/src/components/ui/Pagination.vue b/apps/frontend/src/components/ui/Pagination.vue deleted file mode 100644 index 99fb555c..00000000 --- a/apps/frontend/src/components/ui/Pagination.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/ProjectCard.vue b/apps/frontend/src/components/ui/ProjectCard.vue index fbb147c2..1be6f86c 100644 --- a/apps/frontend/src/components/ui/ProjectCard.vue +++ b/apps/frontend/src/components/ui/ProjectCard.vue @@ -29,7 +29,7 @@ {{ author }}

- +

{{ description }} @@ -91,18 +91,16 @@