diff --git a/Cargo.lock b/Cargo.lock index 777d87841..c8d780797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,9 +498,11 @@ dependencies = [ "db-core", "db-user", "dotenv", + "envy", "futures-core", "futures-util", "listener-interface", + "membership-interface", "nango", "notion", "openai", @@ -3494,6 +3496,7 @@ dependencies = [ "db-core", "db-script", "db-user", + "envy", "futures-util", "hound", "reqwest 0.12.15", @@ -3521,6 +3524,7 @@ dependencies = [ "tauri-plugin-listener", "tauri-plugin-local-llm", "tauri-plugin-local-stt", + "tauri-plugin-membership", "tauri-plugin-misc", "tauri-plugin-notification", "tauri-plugin-opener", @@ -3941,6 +3945,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -7906,6 +7919,15 @@ dependencies = [ "regex", ] +[[package]] +name = "membership-interface" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "specta", +] + [[package]] name = "memchr" version = "2.7.4" @@ -13305,7 +13327,6 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-auth", - "tauri-plugin-flags", "tauri-plugin-local-llm", "tauri-plugin-local-stt", "tauri-plugin-store2", @@ -13562,6 +13583,26 @@ dependencies = [ "ws-utils", ] +[[package]] +name = "tauri-plugin-membership" +version = "0.1.0" +dependencies = [ + "membership-interface", + "reqwest 0.12.15", + "schemars", + "serde", + "serde_json", + "specta", + "specta-typescript", + "strum 0.26.3", + "tauri", + "tauri-plugin", + "tauri-plugin-store", + "tauri-plugin-store2", + "tauri-specta", + "thiserror 2.0.12", +] + [[package]] name = "tauri-plugin-misc" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bde5b39c6..7445f44fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ hypr-ws-utils = { path = "crates/ws-utils", package = "ws-utils" } hypr-auth-interface = { path = "plugins/auth-interface", package = "auth-interface" } hypr-listener-interface = { path = "plugins/listener-interface", package = "listener-interface" } +hypr-membership-interface = { path = "plugins/membership-interface", package = "membership-interface" } tauri = "2" tauri-build = "2" @@ -83,6 +84,7 @@ tauri-plugin-flags = { path = "plugins/flags" } tauri-plugin-listener = { path = "plugins/listener" } tauri-plugin-local-llm = { path = "plugins/local-llm" } tauri-plugin-local-stt = { path = "plugins/local-stt" } +tauri-plugin-membership = { path = "plugins/membership" } tauri-plugin-misc = { path = "plugins/misc" } tauri-plugin-notification = { path = "plugins/notification" } tauri-plugin-sfx = { path = "plugins/sfx" } @@ -112,6 +114,8 @@ codes-iso-639 = "0.1.5" derive_more = "2" dirs = "6.0.0" dotenv = "0.15.0" +dotenvy = "0.15.7" +envy = "0.4" include_url_macro = "0.1.0" indoc = "2" insta = "1.42" diff --git a/Taskfile.yaml b/Taskfile.yaml index aa14a285c..f25dff432 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -10,7 +10,7 @@ tasks: py:init: POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-cache --no-interaction --all-extras py:run: poetry run python3 {{.CLI_ARGS}} - stripe: stripe listen --skip-verify --forward-to http://localhost:5000/webhook/stripe + stripe: stripe listen --skip-verify --forward-to http://localhost:1234/webhook/stripe bacon: bacon {{.CLI_ARGS}} bump: diff --git a/apps/app/server/Cargo.toml b/apps/app/server/Cargo.toml index b2f4e33e0..af8f0e2a8 100644 --- a/apps/app/server/Cargo.toml +++ b/apps/app/server/Cargo.toml @@ -9,6 +9,7 @@ dotenv = { workspace = true } [dependencies] hypr-auth-interface = { workspace = true } hypr-listener-interface = { workspace = true } +hypr-membership-interface = { workspace = true } hypr-analytics = { workspace = true } hypr-buffer = { workspace = true } @@ -49,6 +50,7 @@ bytes = { workspace = true } chrono = { workspace = true } codes-iso-639 = { workspace = true } dotenv = { workspace = true } +envy = { workspace = true } thiserror = { workspace = true } url = { workspace = true } uuid = { workspace = true } diff --git a/apps/app/server/openapi.gen.json b/apps/app/server/openapi.gen.json index 8528f38d4..fb2ec86de 100644 --- a/apps/app/server/openapi.gen.json +++ b/apps/app/server/openapi.gen.json @@ -27,22 +27,6 @@ } } }, - "/api/desktop/subscription": { - "get": { - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Subscription" - } - } - } - } - } - } - }, "/api/web/connect": { "post": { "requestBody": { @@ -69,6 +53,18 @@ } } }, + "/api/web/checkout": { + "get": { + "responses": { + "200": { + "description": "plain text", + "content": { + "text/plain; charset=utf-8": {} + } + } + } + } + }, "/api/web/session/{id}": { "get": { "parameters": [ @@ -1245,14 +1241,6 @@ "mp3" ] }, - "Membership": { - "type": "string", - "enum": [ - "Trial", - "Basic", - "Pro" - ] - }, "NangoConnectSessionRequest": { "type": "object", "required": [ @@ -1674,17 +1662,6 @@ } ] }, - "Subscription": { - "type": "object", - "required": [ - "membership" - ], - "properties": { - "membership": { - "$ref": "#/components/schemas/Membership" - } - } - }, "TranscriptChunk": { "type": "object", "required": [ diff --git a/apps/app/server/src/env.rs b/apps/app/server/src/env.rs new file mode 100644 index 000000000..b4c3a2ce0 --- /dev/null +++ b/apps/app/server/src/env.rs @@ -0,0 +1,35 @@ +pub fn load() -> ENV { + #[cfg(debug_assertions)] + dotenv::from_filename(".env.local").ok(); + + envy::from_env::().unwrap() +} + +#[derive(Debug, serde::Deserialize)] +pub struct ENV { + pub sentry_dsn: String, + pub turso_api_key: String, + pub turso_org_slug: String, + pub clerk_secret_key: String, + pub deepgram_api_key: String, + pub clova_api_key: String, + pub turso_admin_db_name: String, + pub nango_api_base: String, + pub nango_api_key: String, + pub posthog_api_key: String, + pub s3_endpoint_url: String, + pub s3_bucket_name: String, + pub s3_access_key_id: String, + pub s3_secret_access_key: String, + pub openai_api_key: String, + pub openai_api_base: String, + pub stripe_secret_key: String, + pub stripe_webhook_signing_secret: String, + pub app_static_dir: String, + #[serde(default = "default_port")] + pub port: String, +} + +fn default_port() -> String { + "1234".to_string() +} diff --git a/apps/app/server/src/main.rs b/apps/app/server/src/main.rs index d57cf5a46..4bf4cc7e8 100644 --- a/apps/app/server/src/main.rs +++ b/apps/app/server/src/main.rs @@ -1,3 +1,4 @@ +mod env; mod error; mod middleware; mod nango; @@ -5,8 +6,8 @@ mod native; mod openapi; mod slack; mod state; -#[path = "stripe.rs"] -mod stripe_webhook; +#[path = "stripe/mod.rs"] +mod stripe_mod; mod types; mod web; mod worker; @@ -42,14 +43,13 @@ use clerk_rs::{ ClerkConfiguration, }; -use state::{AuthState, WorkerState}; +use state::{AnalyticsState, AuthState, WorkerState}; fn main() { - #[cfg(debug_assertions)] - dotenv::from_filename(".env.local").unwrap(); + let config = env::load(); let _guard = sentry::init(( - get_env("SENTRY_DSN"), + config.sentry_dsn.clone(), sentry::ClientOptions { release: sentry::release_name!(), ..Default::default() @@ -62,23 +62,11 @@ fn main() { .unwrap() .block_on(async { let layer = { - #[cfg(debug_assertions)] { tracing_subscriber::fmt::layer() .with_file(true) .with_line_number(true) } - - #[cfg(not(debug_assertions))] - { - tracing_axiom::builder("hyprnote-server") - .with_token(get_env("AXIOM_TOKEN")) - .unwrap() - .with_dataset(get_env("AXIOM_DATASET")) - .unwrap() - .build() - .unwrap() - } }; Registry::default() @@ -100,29 +88,29 @@ fn main() { let turso = hypr_turso::TursoClient::builder() .with_token_cache(128) - .api_key(get_env("TURSO_API_KEY")) - .org_slug(get_env("TURSO_ORG_SLUG")) + .api_key(&config.turso_api_key) + .org_slug(&config.turso_org_slug) .build(); let clerk_config = - ClerkConfiguration::new(None, None, Some(get_env("CLERK_SECRET_KEY")), None); + ClerkConfiguration::new(None, None, Some(config.clerk_secret_key.clone()), None); let clerk = Clerk::new(clerk_config); let realtime_stt = hypr_stt::realtime::Client::builder() - .deepgram_api_key(get_env("DEEPGRAM_API_KEY")) - .clova_api_key(get_env("CLOVA_API_KEY")) + .deepgram_api_key(&config.deepgram_api_key) + .clova_api_key(&config.clova_api_key) .build(); let recorded_stt = hypr_stt::recorded::Client::builder() - .deepgram_api_key(get_env("DEEPGRAM_API_KEY")) - .clova_api_key(get_env("CLOVA_API_KEY")) + .deepgram_api_key(&config.deepgram_api_key) + .clova_api_key(&config.clova_api_key) .build(); let admin_db = { let base_db = { - let name = get_env("TURSO_ADMIN_DB_NAME"); + let name = &config.turso_admin_db_name; - let db_name = turso.format_db_name(&name); + let db_name = turso.format_db_name(name); let db_url = turso.format_db_url(&db_name); let db_token = turso.generate_db_token(&db_name).await.unwrap(); @@ -140,25 +128,25 @@ fn main() { }; let nango = hypr_nango::NangoClientBuilder::default() - .api_base(get_env("NANGO_API_BASE")) - .api_key(get_env("NANGO_API_KEY")) + .api_base(&config.nango_api_base) + .api_key(&config.nango_api_key) .build(); - let analytics = hypr_analytics::AnalyticsClient::new(get_env("POSTHOG_API_KEY")); + let analytics = hypr_analytics::AnalyticsClient::new(&config.posthog_api_key); let s3 = hypr_s3::Client::builder() - .endpoint_url(get_env("S3_ENDPOINT_URL")) - .bucket(get_env("S3_BUCKET_NAME")) - .credentials(get_env("S3_ACCESS_KEY_ID"), get_env("S3_SECRET_ACCESS_KEY")) + .endpoint_url(&config.s3_endpoint_url) + .bucket(&config.s3_bucket_name) + .credentials(&config.s3_access_key_id, &config.s3_secret_access_key) .build() .await; let openai = hypr_openai::OpenAIClient::builder() - .api_key(get_env("OPENAI_API_KEY")) - .api_base(get_env("OPENAI_API_BASE")) + .api_key(&config.openai_api_key) + .api_base(&config.openai_api_base) .build(); - let stripe_client = stripe::Client::new(get_env("STRIPE_SECRET_KEY")); + let stripe_client = stripe::Client::new(&config.stripe_secret_key); let state = state::AppState { clerk: clerk.clone(), @@ -171,28 +159,25 @@ fn main() { s3, openai, stripe: stripe_client, + stripe_webhook_signing_secret: config.stripe_webhook_signing_secret, }; let web_connect_router = ApiRouter::new().api_route("/connect", api_post(web::connect::handler)); let web_other_router = ApiRouter::new() + .api_route("/checkout", api_get(web::checkout::handler)) .api_route("/session/{id}", api_get(web::session::handler)) .api_route( "/integration/connection", api_post(web::integration::create_connection), ) - .layer( - tower::builder::ServiceBuilder::new() - .layer(axum::middleware::from_fn_with_state( - AuthState::from_ref(&state), - middleware::attach_user_from_clerk, - )) - .layer(axum::middleware::from_fn_with_state( - AuthState::from_ref(&state), - middleware::attach_user_db, - )), - ); + .layer(tower::builder::ServiceBuilder::new().layer( + axum::middleware::from_fn_with_state( + AuthState::from_ref(&state), + middleware::attach_user_from_clerk, + ), + )); let web_router = web_connect_router .merge(web_other_router) @@ -207,30 +192,24 @@ fn main() { "/user/integrations", api_get(native::user::list_integrations), ) - .api_route("/subscription", api_get(native::subscription::handler)) - .route("/listen/realtime", get(native::listen::realtime::handler)); - // .layer( - // tower::builder::ServiceBuilder::new() - // .layer(axum::middleware::from_fn_with_state( - // AuthState::from_ref(&state), - // middleware::verify_api_key, - // )) - // .layer(axum::middleware::from_fn_with_state( - // AuthState::from_ref(&state), - // middleware::attach_user_db, - // )) - // .layer(axum::middleware::from_fn_with_state( - // AnalyticsState::from_ref(&state), - // middleware::send_analytics, - // )), - // ); - - let slack_router = ApiRouter::new(); + .route("/subscription", get(native::subscription::handler)) + .route("/listen/realtime", get(native::listen::realtime::handler)) + .layer( + tower::builder::ServiceBuilder::new() + .layer(axum::middleware::from_fn_with_state( + AuthState::from_ref(&state), + middleware::verify_api_key, + )) + .layer(axum::middleware::from_fn_with_state( + AnalyticsState::from_ref(&state), + middleware::send_analytics, + )), + ); let webhook_router = ApiRouter::new() + .route("/stripe", post(stripe_mod::webhook::handler)) .route("/nango", post(nango::handler)) - .route("/stripe", post(stripe_webhook::handler)) - .nest("/slack", slack_router); + .with_state(state::WebhookState::from_ref(&state)); let mut router = ApiRouter::new() .route("/openapi.json", get(openapi::handler)) @@ -258,7 +237,7 @@ fn main() { { router = router.fallback_service({ - let static_dir: std::path::PathBuf = get_env("APP_STATIC_DIR").into(); + let static_dir: std::path::PathBuf = config.app_static_dir.clone().into(); ServeDir::new(&static_dir) .append_index_html_on_directories(false) @@ -268,7 +247,7 @@ fn main() { let mut api = OpenApi::default(); - let port = std::env::var("PORT").unwrap_or("1234".to_string()); + let port = &config.port; let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) .await .unwrap(); @@ -310,10 +289,6 @@ fn main() { }); } -fn get_env(key: &str) -> String { - std::env::var(key).unwrap_or_else(|_| panic!("env: '{}' is not set", key)) -} - #[cfg(test)] mod tests { #[test] diff --git a/apps/app/server/src/middleware.rs b/apps/app/server/src/middleware.rs index 8121b3246..28214ca7f 100644 --- a/apps/app/server/src/middleware.rs +++ b/apps/app/server/src/middleware.rs @@ -42,8 +42,15 @@ pub async fn verify_api_key( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::UNAUTHORIZED, "account_not_found".into()))?; + let billing = state + .admin_db + .get_billing_by_account_id(&account.id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + req.extensions_mut().insert(user); req.extensions_mut().insert(account); + req.extensions_mut().insert(billing); Ok(next.run(req).await) } @@ -83,6 +90,7 @@ pub async fn attach_user_from_clerk( Ok(next.run(req).await) } +#[allow(unused)] #[tracing::instrument(skip_all)] pub async fn attach_user_db( State(state): State, diff --git a/apps/app/server/src/nango.rs b/apps/app/server/src/nango.rs index 20e516f76..79b985cb8 100644 --- a/apps/app/server/src/nango.rs +++ b/apps/app/server/src/nango.rs @@ -4,11 +4,11 @@ use axum::{ response::IntoResponse, }; -use crate::state::AppState; +use crate::state::WebhookState; // https://docs.nango.dev/guides/webhooks/webhooks-from-nango#auth-webhooks pub async fn handler( - State(state): State, + State(state): State, Json(input): Json, ) -> Result { let connection = state diff --git a/apps/app/server/src/native/subscription.rs b/apps/app/server/src/native/subscription.rs index ca71f4f08..1d2408783 100644 --- a/apps/app/server/src/native/subscription.rs +++ b/apps/app/server/src/native/subscription.rs @@ -1,15 +1,33 @@ -use axum::{extract::State, http::StatusCode, Extension, Json}; - -use crate::{ - state::AppState, - types::{Membership, Subscription}, -}; +use axum::{http::StatusCode, Extension, Json}; +use hypr_membership_interface::{Subscription, SubscriptionStatus}; pub async fn handler( - State(_state): State, - Extension(_org): Extension, + Extension(billing): Extension, ) -> Result, StatusCode> { - Ok(Json(Subscription { - membership: Membership::Trial, - })) + let subscription = billing.stripe_subscription.map(|s| Subscription { + status: match s.status { + stripe::SubscriptionStatus::Active => SubscriptionStatus::Active, + stripe::SubscriptionStatus::Canceled => SubscriptionStatus::Canceled, + stripe::SubscriptionStatus::Incomplete => SubscriptionStatus::Incomplete, + stripe::SubscriptionStatus::IncompleteExpired => SubscriptionStatus::IncompleteExpired, + stripe::SubscriptionStatus::PastDue => SubscriptionStatus::PastDue, + stripe::SubscriptionStatus::Paused => SubscriptionStatus::Paused, + stripe::SubscriptionStatus::Trialing => SubscriptionStatus::Trialing, + stripe::SubscriptionStatus::Unpaid => SubscriptionStatus::Unpaid, + }, + current_period_end: s.current_period_end, + trial_end: s.trial_end, + price_id: s + .items + .data + .first() + .map(|item| item.price.as_ref().map(|p| p.id.to_string())) + .flatten(), + }); + + if let Some(subscription) = subscription { + Ok(Json(subscription)) + } else { + Err(StatusCode::NOT_FOUND) + } } diff --git a/apps/app/server/src/state.rs b/apps/app/server/src/state.rs index a95e93294..672fd6985 100644 --- a/apps/app/server/src/state.rs +++ b/apps/app/server/src/state.rs @@ -20,6 +20,15 @@ pub struct AppState { pub nango: NangoClient, pub s3: S3Client, pub stripe: stripe::Client, + pub stripe_webhook_signing_secret: String, +} + +#[derive(Clone)] +pub struct WebhookState { + pub nango: NangoClient, + pub admin_db: AdminDatabase, + pub stripe: stripe::Client, + pub stripe_webhook_signing_secret: String, } #[derive(Clone)] @@ -48,6 +57,17 @@ pub struct AnalyticsState { pub analytics: AnalyticsClient, } +impl FromRef for WebhookState { + fn from_ref(s: &AppState) -> WebhookState { + WebhookState { + nango: s.nango.clone(), + admin_db: s.admin_db.clone(), + stripe: s.stripe.clone(), + stripe_webhook_signing_secret: s.stripe_webhook_signing_secret.clone(), + } + } +} + impl FromRef for STTState { fn from_ref(app_state: &AppState) -> STTState { STTState { diff --git a/apps/app/server/src/stripe/mod.rs b/apps/app/server/src/stripe/mod.rs new file mode 100644 index 000000000..516021e4c --- /dev/null +++ b/apps/app/server/src/stripe/mod.rs @@ -0,0 +1,2 @@ +pub mod ops; +pub mod webhook; diff --git a/apps/app/server/src/stripe/ops.rs b/apps/app/server/src/stripe/ops.rs new file mode 100644 index 000000000..2f6b305d4 --- /dev/null +++ b/apps/app/server/src/stripe/ops.rs @@ -0,0 +1,99 @@ +use stripe::{ + CheckoutSession, CheckoutSessionMode, Client, CreateCheckoutSession, + CreateCheckoutSessionLineItems, CreateCheckoutSessionSubscriptionDataTrialSettings, + CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior, + CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod, + CreateCustomer, CreateSubscription, CreateSubscriptionTrialSettings, + CreateSubscriptionTrialSettingsEndBehavior, + CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, Customer, CustomerId, + Subscription, +}; + +pub fn get_line_items() -> Vec { + vec![CreateCheckoutSessionLineItems { + quantity: Some(1), + price: Some(get_price_id()), + ..Default::default() + }] +} + +pub async fn create_checkout_without_trial( + client: &Client, + customer_id: CustomerId, +) -> Result { + let mut params = CreateCheckoutSession::new(); + params.customer = Some(customer_id); + params.mode = Some(CheckoutSessionMode::Subscription); + params.line_items = Some(get_line_items()); + + let success_url = get_success_url(); + params.success_url = Some(&success_url); + + CheckoutSession::create(client, params) + .await + .map_err(|e| e.to_string()) +} + +pub async fn create_subscription_with_trial( + client: &Client, + customer_id: CustomerId, + trial_period_days: u32, +) -> Result { + let mut params = CreateSubscription::new(customer_id); + + params.trial_settings = Some(trial_settings_for_subscription()); + params.trial_period_days = Some(trial_period_days); + + let subscription = Subscription::create(client, params) + .await + .map_err(|e| e.to_string())?; + + Ok(subscription) +} + +pub async fn create_customer(client: &Client) -> Result { + let customer = Customer::create(client, CreateCustomer::default()) + .await + .map_err(|e| e.to_string())?; + + Ok(customer) +} + +// https://docs.stripe.com/billing/subscriptions/trials#create-free-trials-without-payment +fn trial_settings_for_subscription() -> CreateSubscriptionTrialSettings { + CreateSubscriptionTrialSettings { + end_behavior: CreateSubscriptionTrialSettingsEndBehavior { + missing_payment_method: + CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel, + }, + } +} + +#[allow(dead_code)] +fn trial_settings_for_checkout() -> CreateCheckoutSessionSubscriptionDataTrialSettings { + CreateCheckoutSessionSubscriptionDataTrialSettings { + end_behavior: CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior { + missing_payment_method: CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod::Cancel, + }, + } +} + +#[cfg(debug_assertions)] +pub fn get_price_id() -> String { + "price_1RNJnZEABq1oJeLyqULb2gtm".to_string() +} + +#[cfg(not(debug_assertions))] +pub fn get_price_id() -> String { + "price_1RMxR4EABq1oJeLyOpEFuV2Q".to_string() +} + +#[cfg(debug_assertions)] +fn get_success_url() -> String { + "http://127.0.0.1:1234/checkout/success".to_string() +} + +#[cfg(not(debug_assertions))] +fn get_success_url() -> String { + "https://app.hyprnote.com/checkout/success".to_string() +} diff --git a/apps/app/server/src/stripe.rs b/apps/app/server/src/stripe/webhook.rs similarity index 86% rename from apps/app/server/src/stripe.rs rename to apps/app/server/src/stripe/webhook.rs index 5d5282130..12a5cd55b 100644 --- a/apps/app/server/src/stripe.rs +++ b/apps/app/server/src/stripe/webhook.rs @@ -8,19 +8,18 @@ use axum::{ }; use stripe::{CustomerId, Event, EventObject, EventType, Expandable, Object}; -use crate::state::AppState; +use crate::state::WebhookState; // https://github.com/arlyon/async-stripe/blob/c71a7eb/examples/webhook-axum.rs pub struct StripeEvent(Event); -impl FromRequest for StripeEvent -where - String: FromRequest, - S: Send + Sync, -{ +impl FromRequest for StripeEvent { type Rejection = Response; - async fn from_request(req: Request, state: &S) -> Result { + async fn from_request( + req: Request, + state: &WebhookState, + ) -> Result { let signature = if let Some(sig) = req.headers().get("stripe-signature") { sig.to_owned() } else { @@ -32,8 +31,12 @@ where .map_err(IntoResponse::into_response)?; Ok(Self( - stripe::Webhook::construct_event(&payload, signature.to_str().unwrap(), "whsec_xxxxx") - .map_err(|_| StatusCode::BAD_REQUEST.into_response())?, + stripe::Webhook::construct_event( + &payload, + signature.to_str().unwrap(), + &state.stripe_webhook_signing_secret, + ) + .map_err(|_| StatusCode::BAD_REQUEST.into_response())?, )) } } @@ -41,7 +44,7 @@ where // https://github.com/t3dotgg/stripe-recommendations // https://docs.stripe.com/api/events/types pub async fn handler( - State(state): State, + State(state): State, StripeEvent(event): StripeEvent, ) -> impl IntoResponse { match event.type_ { @@ -96,12 +99,9 @@ pub async fn handler( // TODO: do this with background worker with concurrency limit if let Some(stripe_customer_id) = stripe_customer_id { tokio::spawn({ - let stripe_client = state.stripe.clone(); - let admin_db = state.admin_db.clone(); - async move { let customer = stripe::Customer::retrieve( - &stripe_client, + &state.stripe, CustomerId::from_str(&stripe_customer_id).as_ref().unwrap(), &["subscriptions"], ) @@ -110,14 +110,15 @@ pub async fn handler( let customer_id = customer.id().to_string(); - if let Err(e) = admin_db.update_stripe_customer(&customer).await { + if let Err(e) = state.admin_db.update_stripe_customer(&customer).await { tracing::error!("stripe_customer_update_failed: {:?}", e); } let subscriptions = customer.subscriptions.unwrap_or_default(); let subscription = subscriptions.data.first(); - if let Err(e) = admin_db + if let Err(e) = state + .admin_db .update_stripe_subscription(customer_id, subscription) .await { diff --git a/apps/app/server/src/types.rs b/apps/app/server/src/types.rs index 637caa23b..82691a9ae 100644 --- a/apps/app/server/src/types.rs +++ b/apps/app/server/src/types.rs @@ -1,19 +1,5 @@ use serde::{ser::Serializer, Serialize}; -#[derive( - Debug, Clone, serde::Serialize, serde::Deserialize, strum::Display, schemars::JsonSchema, -)] -pub enum Membership { - Trial, - Basic, - Pro, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] -pub struct Subscription { - pub membership: Membership, -} - #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] diff --git a/apps/app/server/src/web/checkout.rs b/apps/app/server/src/web/checkout.rs new file mode 100644 index 000000000..8f881dbc2 --- /dev/null +++ b/apps/app/server/src/web/checkout.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::{Extension, State}, + http::StatusCode, +}; + +use clerk_rs::validators::authorizer::ClerkJwt; + +use crate::{state::AppState, stripe_mod::ops as stripe_ops}; + +pub async fn handler( + State(state): State, + Extension(jwt): Extension, +) -> Result { + let (clerk_user_id, clerk_org_id) = (jwt.sub, jwt.org.map(|o| o.id)); + + let account = { + if let Some(clerk_org_id) = &clerk_org_id { + state + .admin_db + .get_account_by_clerk_org_id(clerk_org_id) + .await + } else { + state + .admin_db + .get_account_by_clerk_user_id(&clerk_user_id) + .await + } + } + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "account_not_found".to_string()))?; + + let billing = state + .admin_db + .get_billing_by_account_id(&account.id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let customer_id = match billing.and_then(|b| b.stripe_customer) { + Some(c) => c.id, + None => { + let c = stripe_ops::create_customer(&state.stripe) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + c.id + } + }; + + let checkout_session = stripe_ops::create_checkout_without_trial(&state.stripe, customer_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(checkout_session.url.unwrap()) +} diff --git a/apps/app/server/src/web/connect.rs b/apps/app/server/src/web/connect.rs index ccfabcd54..d326cd7aa 100644 --- a/apps/app/server/src/web/connect.rs +++ b/apps/app/server/src/web/connect.rs @@ -3,10 +3,9 @@ use axum::{ http::StatusCode, Json, }; - -use crate::state::AppState; use clerk_rs::validators::authorizer::ClerkJwt; +use crate::{state::AppState, stripe_mod::ops as stripe_ops}; use hypr_auth_interface::{RequestParams, ResponseParams}; pub async fn handler( @@ -17,12 +16,16 @@ pub async fn handler( let (clerk_user_id, clerk_org_id) = (jwt.sub, jwt.org.map(|o| o.id)); let existing_account = { - let db = state.admin_db.clone(); - if let Some(clerk_org_id) = &clerk_org_id { - db.get_account_by_clerk_org_id(clerk_org_id).await + state + .admin_db + .get_account_by_clerk_org_id(clerk_org_id) + .await } else { - db.get_account_by_clerk_user_id(&clerk_user_id).await + state + .admin_db + .get_account_by_clerk_user_id(&clerk_user_id) + .await } } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -38,6 +41,17 @@ pub async fn handler( // make sure we use same format in tauri side let turso_db_name = hypr_turso::format_db_name(account_id.clone()); + let customer = stripe_ops::create_customer(&state.stripe).await.unwrap(); + let subscription = + stripe_ops::create_subscription_with_trial(&state.stripe, customer.id.clone(), 7) + .await + .unwrap(); + + let _ = db + .create_billing(&account_id, customer, subscription) + .await + .unwrap(); + db.upsert_account(hypr_db_admin::Account { id: account_id, turso_db_name, diff --git a/apps/app/server/src/web/mod.rs b/apps/app/server/src/web/mod.rs index 8e1846de7..0655a52c1 100644 --- a/apps/app/server/src/web/mod.rs +++ b/apps/app/server/src/web/mod.rs @@ -1,3 +1,4 @@ +pub mod checkout; pub mod connect; pub mod integration; pub mod session; diff --git a/apps/app/src/routeTree.gen.ts b/apps/app/src/routeTree.gen.ts index fce054ecb..447b236bb 100644 --- a/apps/app/src/routeTree.gen.ts +++ b/apps/app/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as IntegrationImport } from './routes/integration' import { Route as IndexImport } from './routes/index' import { Route as SIdImport } from './routes/s.$id' +import { Route as CheckoutSuccessImport } from './routes/checkout.success' import { Route as AuthSsoCallbackImport } from './routes/auth.sso-callback' import { Route as AuthSignOutImport } from './routes/auth.sign-out' import { Route as AuthSignInImport } from './routes/auth.sign-in' @@ -39,6 +40,12 @@ const SIdRoute = SIdImport.update({ getParentRoute: () => rootRoute, } as any) +const CheckoutSuccessRoute = CheckoutSuccessImport.update({ + id: '/checkout/success', + path: '/checkout/success', + getParentRoute: () => rootRoute, +} as any) + const AuthSsoCallbackRoute = AuthSsoCallbackImport.update({ id: '/auth/sso-callback', path: '/auth/sso-callback', @@ -109,6 +116,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSsoCallbackImport parentRoute: typeof rootRoute } + '/checkout/success': { + id: '/checkout/success' + path: '/checkout/success' + fullPath: '/checkout/success' + preLoaderRoute: typeof CheckoutSuccessImport + parentRoute: typeof rootRoute + } '/s/$id': { id: '/s/$id' path: '/s/$id' @@ -128,6 +142,7 @@ export interface FileRoutesByFullPath { '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-out': typeof AuthSignOutRoute '/auth/sso-callback': typeof AuthSsoCallbackRoute + '/checkout/success': typeof CheckoutSuccessRoute '/s/$id': typeof SIdRoute } @@ -138,6 +153,7 @@ export interface FileRoutesByTo { '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-out': typeof AuthSignOutRoute '/auth/sso-callback': typeof AuthSsoCallbackRoute + '/checkout/success': typeof CheckoutSuccessRoute '/s/$id': typeof SIdRoute } @@ -149,6 +165,7 @@ export interface FileRoutesById { '/auth/sign-in': typeof AuthSignInRoute '/auth/sign-out': typeof AuthSignOutRoute '/auth/sso-callback': typeof AuthSsoCallbackRoute + '/checkout/success': typeof CheckoutSuccessRoute '/s/$id': typeof SIdRoute } @@ -161,6 +178,7 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/auth/sign-out' | '/auth/sso-callback' + | '/checkout/success' | '/s/$id' fileRoutesByTo: FileRoutesByTo to: @@ -170,6 +188,7 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/auth/sign-out' | '/auth/sso-callback' + | '/checkout/success' | '/s/$id' id: | '__root__' @@ -179,6 +198,7 @@ export interface FileRouteTypes { | '/auth/sign-in' | '/auth/sign-out' | '/auth/sso-callback' + | '/checkout/success' | '/s/$id' fileRoutesById: FileRoutesById } @@ -190,6 +210,7 @@ export interface RootRouteChildren { AuthSignInRoute: typeof AuthSignInRoute AuthSignOutRoute: typeof AuthSignOutRoute AuthSsoCallbackRoute: typeof AuthSsoCallbackRoute + CheckoutSuccessRoute: typeof CheckoutSuccessRoute SIdRoute: typeof SIdRoute } @@ -200,6 +221,7 @@ const rootRouteChildren: RootRouteChildren = { AuthSignInRoute: AuthSignInRoute, AuthSignOutRoute: AuthSignOutRoute, AuthSsoCallbackRoute: AuthSsoCallbackRoute, + CheckoutSuccessRoute: CheckoutSuccessRoute, SIdRoute: SIdRoute, } @@ -219,6 +241,7 @@ export const routeTree = rootRoute "/auth/sign-in", "/auth/sign-out", "/auth/sso-callback", + "/checkout/success", "/s/$id" ] }, @@ -240,6 +263,9 @@ export const routeTree = rootRoute "/auth/sso-callback": { "filePath": "auth.sso-callback.tsx" }, + "/checkout/success": { + "filePath": "checkout.success.tsx" + }, "/s/$id": { "filePath": "s.$id.tsx" } diff --git a/apps/app/src/routes/checkout.success.tsx b/apps/app/src/routes/checkout.success.tsx new file mode 100644 index 000000000..897896c34 --- /dev/null +++ b/apps/app/src/routes/checkout.success.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/checkout/success")({ + component: Component, +}); + +function Component() { + return
Hello "/checkout/success"!
; +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1656ad27a..66b923ca8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,6 +28,7 @@ "@hypr/plugin-listener": "workspace:^", "@hypr/plugin-local-llm": "workspace:^", "@hypr/plugin-local-stt": "workspace:^", + "@hypr/plugin-membership": "workspace:^", "@hypr/plugin-misc": "workspace:^", "@hypr/plugin-notification": "workspace:^", "@hypr/plugin-sfx": "workspace:^", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9856d78f0..9541ed6ad 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tauri-plugin-flags = { workspace = true } tauri-plugin-listener = { workspace = true } tauri-plugin-local-llm = { workspace = true } tauri-plugin-local-stt = { workspace = true } +tauri-plugin-membership = { workspace = true } tauri-plugin-misc = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-opener = { workspace = true } @@ -82,6 +83,7 @@ serde_json = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } codes-iso-639 = { workspace = true } +envy = { workspace = true } url = { workspace = true } uuid = { workspace = true } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 60c92bd24..fdeb6a54e 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -47,10 +47,15 @@ "deep-link:default", "notification:default", "fs:default", + "membership:default", "task:default", { "identifier": "opener:allow-open-url", - "allow": [{ "url": "https://**" }] + "allow": [ + { "url": "https://**" }, + { "url": "http://localhost:*" }, + { "url": "http://127.0.0.1:*" } + ] }, { "identifier": "fs:allow-exists", diff --git a/apps/desktop/src-tauri/src/env.rs b/apps/desktop/src-tauri/src/env.rs new file mode 100644 index 000000000..40f89d602 --- /dev/null +++ b/apps/desktop/src-tauri/src/env.rs @@ -0,0 +1,11 @@ +#[derive(Debug, serde::Deserialize)] +pub struct ENV { + #[cfg_attr(debug_assertions, serde(default))] + pub sentry_dsn: String, + #[cfg_attr(debug_assertions, serde(default))] + pub posthog_api_key: String, +} + +pub fn load() -> ENV { + envy::from_env::().unwrap() +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 433620977..4115a0b0b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod env; mod ext; mod store; @@ -15,6 +16,8 @@ use tracing_subscriber::{ pub async fn main() { tauri::async_runtime::set(tokio::runtime::Handle::current()); + let config = env::load(); + { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); @@ -27,17 +30,7 @@ pub async fn main() { } let client = tauri_plugin_sentry::sentry::init(( - { - #[cfg(not(debug_assertions))] - { - env!("SENTRY_DSN") - } - - #[cfg(debug_assertions)] - { - option_env!("SENTRY_DSN").unwrap_or_default() - } - }, + config.sentry_dsn, tauri_plugin_sentry::sentry::ClientOptions { release: tauri_plugin_sentry::sentry::release_name!(), traces_sample_rate: 1.0, @@ -67,6 +60,7 @@ pub async fn main() { .plugin(tauri_plugin_listener::init()) .plugin(tauri_plugin_sse::init()) .plugin(tauri_plugin_misc::init()) + .plugin(tauri_plugin_membership::init()) .plugin(tauri_plugin_db::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::default().build()) @@ -88,7 +82,7 @@ pub async fn main() { .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_analytics::init()) + .plugin(tauri_plugin_analytics::init(config.posthog_api_key)) .plugin(tauri_plugin_tray::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_clipboard_manager::init()) diff --git a/apps/desktop/src/components/left-sidebar/top-area/settings-button.tsx b/apps/desktop/src/components/left-sidebar/top-area/settings-button.tsx index fafa8004c..657816761 100644 --- a/apps/desktop/src/components/left-sidebar/top-area/settings-button.tsx +++ b/apps/desktop/src/components/left-sidebar/top-area/settings-button.tsx @@ -1,6 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { getName, getVersion } from "@tauri-apps/api/app"; -import { CogIcon, CpuIcon } from "lucide-react"; +import { CogIcon, CpuIcon, TrainFrontIcon } from "lucide-react"; import { useState } from "react"; import Shortcut from "@/components/shortcut"; @@ -18,7 +18,7 @@ import { useQuery } from "@tanstack/react-query"; export function SettingsButton() { const [open, setOpen] = useState(false); - const { userId } = useHypr(); + const { userId, isPro } = useHypr(); const versionQuery = useQuery({ queryKey: ["appVersion"], @@ -52,27 +52,7 @@ export function SettingsButton() { -
-
-
-
- -
-
- Local mode -
-
- Privacy-focused AI -
-
-
-
+ {isPro ? : }
); } + +function ProMode({ onClick }: { onClick: () => void }) { + return ( +
+
+
+
+ +
+
+ Pro mode +
+
+ For professional use +
+
+
+
+ ); +} + +function LocalMode({ onClick }: { onClick: () => void }) { + return ( +
+
+
+
+ +
+
+ Local mode +
+
+ Privacy-focused AI +
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/settings/views/billing.tsx b/apps/desktop/src/components/settings/views/billing.tsx deleted file mode 100644 index 5049c75da..000000000 --- a/apps/desktop/src/components/settings/views/billing.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { Trans, useLingui } from "@lingui/react/macro"; -import { CheckIcon, ExternalLinkIcon } from "lucide-react"; -import { useState } from "react"; - -import { Button } from "@hypr/ui/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@hypr/ui/components/ui/card"; -import { Tabs, TabsList, TabsTrigger } from "@hypr/ui/components/ui/tabs"; - -interface BillingProps { - currentPlan?: string; - trialDaysLeft?: number; -} - -export default function Billing({ currentPlan, trialDaysLeft }: BillingProps) { - const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly"); - const { t } = useLingui(); - - const pricingPlans = [ - { - name: t`Free`, - description: t`For those who are serious about their privacy`, - monthlyPrice: 0, - annualPrice: 0, - features: [ - t`Works both in-person and remotely`, - t`Format notes using templates`, - t`Ask questions about past meetings`, - t`Live summary of the meeting`, - t`Works offline`, - ], - }, - { - name: t`Pro`, - description: t`For those who are serious about their performance`, - monthlyPrice: 19, - annualPrice: 15, - features: [ - t`Integration with other apps like Notion and Google Calendar`, - t`Long-term memory for past meetings and attendees`, - t`Much better AI performance`, - t`Meeting note sharing via links`, - t`Synchronization across multiple devices`, - ], - }, - { - name: t`Team`, - description: t`For fast growing teams like energetic startups`, - monthlyPrice: 25, - annualPrice: 20, - features: [ - t`Search & ask across all notes in workspace`, - t`Collaborate with others in meetings`, - t`Single sign-on for all users`, - ], - isPerSeat: true, - comingSoon: true, - }, - ]; - - const getButtonText = (planName: string) => { - const plan = planName.toLowerCase(); - if (plan === "team") { - return t`Coming Soon`; - } - if (currentPlan === plan) { - return t`Current Plan`; - } - if (currentPlan === "basic" && plan === "pro") { - return t`Upgrade`; - } - if (trialDaysLeft && plan === "pro") { - return t`Free Trial`; - } - return billingCycle === "monthly" - ? t`Start Monthly Plan` - : t`Start Annual Plan`; - }; - - const getButtonProps = (planName: string) => { - const plan = planName.toLowerCase(); - if (plan === "team") { - return { - disabled: true, - variant: "outline" as const, - }; - } - if (currentPlan === plan) { - return { - variant: "outline" as const, - }; - } - return { - variant: "default" as const, - }; - }; - - return ( -
-
-
- Coming Soon -
-

- - Billing features are currently under development and will be available in a future update. - -

-
- -
-
-

- There's a plan for everyone -

- - setBillingCycle(value as "monthly" | "annual")} - > - - - Monthly - - - Annual - - - -
- -
-
- {pricingPlans.map((plan) => ( - - -
- {plan.name} - {plan.name === "Pro" && ( - - Best - - )} -
- {plan.description} -
- -
- $ - {billingCycle === "monthly" - ? plan.monthlyPrice - : plan.annualPrice} - - {plan.isPerSeat ? "/seat" : ""} /month - -
-
- {plan.features.map((feature) => ( -
-
- -
- {feature} -
- ))} -
-
- - - {trialDaysLeft && plan.name.toLowerCase() === "pro" && ( -

- {trialDaysLeft} days left in trial -

- )} -
-
- ))} -
- - {billingCycle === "annual" && ( -

- Save up to 20% with annual billing -

- )} - - -
-
-
- ); -} diff --git a/apps/desktop/src/components/settings/views/index.ts b/apps/desktop/src/components/settings/views/index.ts index 7ec552e46..47942f544 100644 --- a/apps/desktop/src/components/settings/views/index.ts +++ b/apps/desktop/src/components/settings/views/index.ts @@ -1,9 +1,7 @@ export { default as LocalAI } from "./ai"; -export { default as Billing } from "./billing"; export { default as Calendar } from "./calendar"; export { default as Feedback } from "./feedback"; export { default as General } from "./general"; -export { default as Lab } from "./lab"; export { default as Notifications } from "./notifications"; export { default as Profile } from "./profile"; export { default as Sound } from "./sound"; diff --git a/apps/desktop/src/components/settings/views/lab.tsx b/apps/desktop/src/components/settings/views/lab.tsx deleted file mode 100644 index 54a294155..000000000 --- a/apps/desktop/src/components/settings/views/lab.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { CloudLightningIcon } from "lucide-react"; - -import { commands as flagsCommands } from "@hypr/plugin-flags"; -import { Switch } from "@hypr/ui/components/ui/switch"; - -export default function Lab() { - return ( -
-
- -
-
- ); -} - -function CloudPreview() { - const flagQuery = useQuery({ - queryKey: ["flags", "CloudPreview"], - queryFn: () => flagsCommands.isEnabled("CloudPreview"), - }); - - const flagMutation = useMutation({ - mutationFn: async (enabled: boolean) => { - if (enabled) { - flagsCommands.enable("CloudPreview"); - } else { - flagsCommands.disable("CloudPreview"); - } - }, - onSuccess: () => { - flagQuery.refetch(); - }, - }); - - const handleToggle = (enabled: boolean) => { - flagMutation.mutate(enabled); - }; - - return ( - } - enabled={flagQuery.data ?? false} - onToggle={handleToggle} - /> - ); -} - -function FeatureFlag({ - title, - description, - icon, - enabled, - onToggle, -}: { - title: string; - description: string; - icon: React.ReactNode; - enabled: boolean; - onToggle: (enabled: boolean) => void; -}) { - return ( -
-
-
-
- {icon} -
-
-
- {title} -
-
- {description} -
-
-
-
- -
-
-
- ); -} diff --git a/apps/desktop/src/components/welcome-modal/index.tsx b/apps/desktop/src/components/welcome-modal/index.tsx index 973b2773a..f6ec99771 100644 --- a/apps/desktop/src/components/welcome-modal/index.tsx +++ b/apps/desktop/src/components/welcome-modal/index.tsx @@ -1,10 +1,7 @@ import { useMutation } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { message } from "@tauri-apps/plugin-dialog"; import { useEffect, useState } from "react"; import { commands } from "@/types"; -import { commands as authCommands, events } from "@hypr/plugin-auth"; import { commands as localSttCommands, SupportedModel } from "@hypr/plugin-local-stt"; import { commands as sfxCommands } from "@hypr/plugin-sfx"; import { Modal, ModalBody } from "@hypr/ui/components/ui/modal"; @@ -19,50 +16,12 @@ interface WelcomeModalProps { } export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { - const navigate = useNavigate(); - const [port, setPort] = useState(null); - const [showModelSelection, setShowModelSelection] = useState(false); + const [step, setStep] = useState<"0_login" | "1_model-selection">("0_login"); const selectSTTModel = useMutation({ mutationFn: (model: SupportedModel) => localSttCommands.setCurrentModel(model), }); - useEffect(() => { - let cleanup: (() => void) | undefined; - let unlisten: (() => void) | undefined; - - if (isOpen) { - authCommands.startOauthServer().then((port) => { - setPort(port); - - events.authEvent - .listen(({ payload }) => { - if (payload === "success") { - commands.setupDbForCloud().then(() => { - onClose(); - }); - return; - } - - if (payload.error) { - message("Error occurred while authenticating!"); - return; - } - }) - .then((fn) => { - unlisten = fn; - }); - - cleanup = () => { - unlisten?.(); - authCommands.stopOauthServer(port); - }; - }); - } - - return () => cleanup?.(); - }, [isOpen, onClose, navigate]); - useEffect(() => { if (isOpen) { commands.setOnboardingNeeded(false); @@ -72,10 +31,6 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { } }, [isOpen]); - const handleStartLocal = () => { - setShowModelSelection(true); - }; - const handleModelSelected = (model: SupportedModel) => { selectSTTModel.mutate(model); onClose(); @@ -91,13 +46,8 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { >
- {!showModelSelection - ? ( - - ) + {step === "0_login" + ? setStep("1_model-selection")} /> : ( ( -
- {label} -
- {[...Array(maxRating)].map((_, i) => ( - - ))} -
-
-); - export const ModelSelectionView = ({ onContinue, }: { @@ -55,7 +26,10 @@ export const ModelSelectionView = ({ }) => { const [selectedModel, setSelectedModel] = useState("QuantizedSmall"); - const supportedSTTModels = useQuery({ + const supportedSTTModels = useQuery<{ + model: string; + is_downloaded: boolean; + }[]>({ queryKey: ["local-stt", "supported-models"], queryFn: async () => { const models = await localSttCommands.listSupportedModels(); @@ -153,3 +127,27 @@ export const ModelSelectionView = ({
); }; + +const RatingDisplay = ( + { label, rating, maxRating = 3, icon: Icon }: { + label: string; + rating: number; + maxRating?: number; + icon: React.ElementType; + }, +) => ( +
+ {label} +
+ {[...Array(maxRating)].map((_, i) => ( + + ))} +
+
+); diff --git a/apps/desktop/src/components/welcome-modal/rating-display.tsx b/apps/desktop/src/components/welcome-modal/rating-display.tsx index dcf8a4c20..440446e0e 100644 --- a/apps/desktop/src/components/welcome-modal/rating-display.tsx +++ b/apps/desktop/src/components/welcome-modal/rating-display.tsx @@ -1,7 +1,8 @@ -import { cn } from "@hypr/ui/lib/utils"; import { GlobeIcon } from "lucide-react"; import React from "react"; +import { cn } from "@hypr/ui/lib/utils"; + export const RatingDisplay = ( { label, rating, maxRating = 3, icon: Icon }: { label: string; diff --git a/apps/desktop/src/components/welcome-modal/welcome-view.tsx b/apps/desktop/src/components/welcome-modal/welcome-view.tsx index 907674518..00f5f1041 100644 --- a/apps/desktop/src/components/welcome-modal/welcome-view.tsx +++ b/apps/desktop/src/components/welcome-modal/welcome-view.tsx @@ -1,15 +1,72 @@ -import PushableButton from "@hypr/ui/components/ui/pushable-button"; -import { TextAnimate } from "@hypr/ui/components/ui/text-animate"; import { Trans, useLingui } from "@lingui/react/macro"; -import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { message } from "@tauri-apps/plugin-dialog"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useEffect, useState } from "react"; -interface WelcomeViewProps { - portReady: boolean; - onGetStarted: () => void; -} +import { baseUrl } from "@/client"; +import { commands as authCommands, events } from "@hypr/plugin-auth"; +import PushableButton from "@hypr/ui/components/ui/pushable-button"; +import { TextAnimate } from "@hypr/ui/components/ui/text-animate"; -export const WelcomeView: React.FC = ({ portReady, onGetStarted }) => { +export const WelcomeView = ({ onContinue }: { onContinue: () => void }) => { const { t } = useLingui(); + const [port, setPort] = useState(null); + + useEffect(() => { + let cleanup: (() => void) | undefined; + let unlisten: (() => void) | undefined; + + authCommands.startOauthServer().then((port) => { + setPort(port); + + events.authEvent + .listen(({ payload }) => { + if (payload === "success") { + message("Successfully authenticated!"); + return; + } + + if (payload.error) { + message("Error occurred while authenticating!"); + return; + } + }) + .then((fn) => { + unlisten = fn; + }); + + cleanup = () => { + unlisten?.(); + authCommands.stopOauthServer(port); + }; + }); + + return () => cleanup?.(); + }, []); + + const url = useQuery({ + queryKey: ["oauth-url", port], + enabled: !!port, + queryFn: () => { + const u = new URL(baseUrl); + u.pathname = "/auth/connect"; + u.searchParams.set("c", window.crypto.randomUUID()); + u.searchParams.set("f", "fingerprint"); + u.searchParams.set("p", port!.toString()); + return u.toString(); + }, + }); + + const handleStartCloud = () => { + if (url.data) { + openUrl(url.data); + } + }; + + const handleStartLocal = () => { + onContinue(); + }; return (
@@ -28,13 +85,22 @@ export const WelcomeView: React.FC = ({ portReady, onGetStarte {t`The AI Meeting Notepad`} - - Get Started - +
+ + Get Started + + + +
); }; diff --git a/apps/desktop/src/contexts/hypr.tsx b/apps/desktop/src/contexts/hypr.tsx index 1bae33a8d..7f027f30e 100644 --- a/apps/desktop/src/contexts/hypr.tsx +++ b/apps/desktop/src/contexts/hypr.tsx @@ -3,16 +3,19 @@ import { createContext, useContext } from "react"; import { commands as authCommands } from "@hypr/plugin-auth"; import { commands as dbCommands } from "@hypr/plugin-db"; +import { commands as membershipCommands, type Subscription } from "@hypr/plugin-membership"; export interface HyprContext { userId: string; onboardingSessionId: string; + subscription?: Subscription; + isPro: boolean; } const HyprContext = createContext(null); export function HyprProvider({ children }: { children: React.ReactNode }) { - const [userId, onboardingSessionId] = useQueries({ + const [userId, onboardingSessionId, subscription] = useQueries({ queries: [ { queryKey: ["auth-user-id"], @@ -22,6 +25,10 @@ export function HyprProvider({ children }: { children: React.ReactNode }) { queryKey: ["onboarding-session-id"], queryFn: () => dbCommands.onboardingSessionId(), }, + { + queryKey: ["subscription"], + queryFn: () => membershipCommands.refresh(), + }, ], }); @@ -38,8 +45,15 @@ export function HyprProvider({ children }: { children: React.ReactNode }) { return null; } + const value = { + userId: userId.data, + onboardingSessionId: onboardingSessionId.data, + subscription: subscription.data, + isPro: subscription.data?.status === "active" || subscription.data?.status === "trialing", + }; + return ( - + {children} ); @@ -48,7 +62,7 @@ export function HyprProvider({ children }: { children: React.ReactNode }) { export function useHypr() { const context = useContext(HyprContext); if (!context) { - throw new Error("useHypr must be used within an AuthProvider"); + throw new Error("useHypr must be used within an HyprProvider"); } return context; } diff --git a/apps/desktop/src/contexts/index.ts b/apps/desktop/src/contexts/index.ts index 2fc8ad79a..6150051cb 100644 --- a/apps/desktop/src/contexts/index.ts +++ b/apps/desktop/src/contexts/index.ts @@ -1,7 +1,6 @@ export * from "./edit-mode-context"; export * from "./hypr"; export * from "./left-sidebar"; -export * from "./login-modal"; export * from "./new-note"; export * from "./right-panel"; export * from "./search"; diff --git a/apps/desktop/src/contexts/login-modal.tsx b/apps/desktop/src/contexts/login-modal.tsx deleted file mode 100644 index e5b22f35c..000000000 --- a/apps/desktop/src/contexts/login-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; - -interface LoginModalContextType { - isLoginModalOpen: boolean; - openLoginModal: () => void; - closeLoginModal: () => void; - shouldShowLoginModal: boolean; - setShouldShowLoginModal: (value: boolean) => void; -} - -const LoginModalContext = createContext(undefined); - -export function useLoginModal() { - const context = useContext(LoginModalContext); - if (context === undefined) { - throw new Error("useLoginModal must be used within a LoginModalProvider"); - } - return context; -} - -interface LoginModalProviderProps { - children: React.ReactNode; -} - -export function LoginModalProvider({ children }: LoginModalProviderProps) { - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); - const [shouldShowLoginModal, setShouldShowLoginModal] = useState(true); - - useEffect(() => { - const hasLoginBeenDismissed = localStorage.getItem("loginModalDismissed") === "true"; - - if (shouldShowLoginModal && !hasLoginBeenDismissed) { - setIsLoginModalOpen(true); - } - }, [shouldShowLoginModal]); - - const openLoginModal = () => setIsLoginModalOpen(true); - - const closeLoginModal = () => { - localStorage.setItem("loginModalDismissed", "true"); - setIsLoginModalOpen(false); - }; - - return ( - - {children} - - ); -} diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index f34f7d229..a5ecc2516 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -270,13 +270,17 @@ msgstr "{0} calendars selected" msgid "{category}" msgstr "{category}" -#: src/components/settings/views/lab.tsx:77 -msgid "{description}" -msgstr "{description}" +#: src/components/settings/views/lab.tsx:103 +#~ msgid "{description}" +#~ msgstr "{description}" -#: src/components/settings/views/lab.tsx:74 -msgid "{title}" -msgstr "{title}" +#: src/components/settings/components/calendar/calendar-selector.tsx:74 +#~ msgid "{selectedCount} calendars selected" +#~ msgstr "{selectedCount} calendars selected" + +#: src/components/settings/views/lab.tsx:100 +#~ msgid "{title}" +#~ msgstr "{title}" #: src/components/human-profile/past-notes.tsx:91 msgid "<0>Create Note" @@ -325,7 +329,7 @@ msgstr "Admin" msgid "AI" msgstr "AI" -#: src/components/login-modal.tsx:97 +#: src/components/login-modal.tsx:118 #~ msgid "AI notepad for meetings" #~ msgstr "AI notepad for meetings" @@ -339,8 +343,8 @@ msgid "and {0} more members" msgstr "and {0} more members" #: src/components/settings/views/billing.tsx:131 -msgid "Annual" -msgstr "Annual" +#~ msgid "Annual" +#~ msgstr "Annual" #: src/components/share-and-permission/publish.tsx:18 msgid "Anyone with the link can view this page" @@ -364,20 +368,24 @@ msgid "Are you sure you want to delete this note?" msgstr "Are you sure you want to delete this note?" #: src/components/settings/views/billing.tsx:27 -msgid "Ask questions about past meetings" -msgstr "Ask questions about past meetings" +#~ msgid "Ask questions about past meetings" +#~ msgstr "Ask questions about past meetings" #: src/components/right-panel/components/chat/chat-message.tsx:18 msgid "Assistant:" msgstr "Assistant:" +#: src/routes/app.settings.tsx:111 +#~ msgid "Back to Settings" +#~ msgstr "Back to Settings" + #: src/routes/app.settings.tsx:49 msgid "Billing" msgstr "Billing" #: src/components/settings/views/billing.tsx:104 -msgid "Billing features are currently under development and will be available in a future update." -msgstr "Billing features are currently under development and will be available in a future update." +#~ msgid "Billing features are currently under development and will be available in a future update." +#~ msgstr "Billing features are currently under development and will be available in a future update." #: src/components/settings/components/templates-sidebar.tsx:68 msgid "Built-in Templates" @@ -421,12 +429,10 @@ msgid "Close" msgstr "Close" #: src/components/settings/views/billing.tsx:52 -msgid "Collaborate with others in meetings" -msgstr "Collaborate with others in meetings" +#~ msgid "Collaborate with others in meetings" +#~ msgstr "Collaborate with others in meetings" #: src/components/settings/views/team.tsx:69 -#: src/components/settings/views/billing.tsx:63 -#: src/components/settings/views/billing.tsx:101 msgid "Coming Soon" msgstr "Coming Soon" @@ -463,7 +469,7 @@ msgstr "Connect your calendar and track events" msgid "Contacts Access" msgstr "Contacts Access" -#: src/components/welcome-modal/model-selection-view.tsx:151 +#: src/components/welcome-modal/model-selection-view.tsx:125 msgid "Continue" msgstr "Continue" @@ -485,8 +491,8 @@ msgid "Create Note" msgstr "Create Note" #: src/components/settings/views/billing.tsx:66 -msgid "Current Plan" -msgstr "Current Plan" +#~ msgid "Current Plan" +#~ msgstr "Current Plan" #: src/components/settings/components/ai/llm-view.tsx:174 msgid "Custom Endpoint" @@ -512,11 +518,11 @@ msgstr "Describe the issue" #: src/components/settings/views/template.tsx:109 msgid "Description" -msgstr "Description" +msgstr "Description<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:349 #~ msgid "Did you get consent from everyone in the meeting?" -#~ msgstr "Did you get consent from everyone in the meeting?" +#~ msgstr "Did you get consent from everyone in the meeting?>>>>>>> origin/main" #. placeholder {0}: metadata?.size && `(${metadata.size})` #: src/components/settings/components/ai/stt-view.tsx:273 @@ -576,28 +582,28 @@ msgid "Feedback" msgstr "Feedback" #: src/components/settings/views/billing.tsx:47 -msgid "For fast growing teams like energetic startups" -msgstr "For fast growing teams like energetic startups" +#~ msgid "For fast growing teams like energetic startups" +#~ msgstr "For fast growing teams like energetic startups" #: src/components/settings/views/billing.tsx:34 -msgid "For those who are serious about their performance" -msgstr "For those who are serious about their performance" +#~ msgid "For those who are serious about their performance" +#~ msgstr "For those who are serious about their performance" #: src/components/settings/views/billing.tsx:21 -msgid "For those who are serious about their privacy" -msgstr "For those who are serious about their privacy" +#~ msgid "For those who are serious about their privacy" +#~ msgstr "For those who are serious about their privacy" #: src/components/settings/views/billing.tsx:26 -msgid "Format notes using templates" -msgstr "Format notes using templates" +#~ msgid "Format notes using templates" +#~ msgstr "Format notes using templates" #: src/components/settings/views/billing.tsx:20 -msgid "Free" -msgstr "Free" +#~ msgid "Free" +#~ msgstr "Free" #: src/components/settings/views/billing.tsx:72 -msgid "Free Trial" -msgstr "Free Trial" +#~ msgid "Free Trial" +#~ msgstr "Free Trial" #: src/components/settings/views/profile.tsx:119 msgid "Full name" @@ -608,7 +614,7 @@ msgstr "Full name" msgid "General" msgstr "General" -#: src/components/welcome-modal/welcome-view.tsx:36 +#: src/components/welcome-modal/welcome-view.tsx:94 msgid "Get Started" msgstr "Get Started" @@ -634,8 +640,8 @@ msgid "How can I help you today?" msgstr "How can I help you today?" #: src/components/settings/views/billing.tsx:38 -msgid "Integration with other apps like Notion and Google Calendar" -msgstr "Integration with other apps like Notion and Google Calendar" +#~ msgid "Integration with other apps like Notion and Google Calendar" +#~ msgstr "Integration with other apps like Notion and Google Calendar" #: src/components/share-and-permission/invite-list.tsx:30 msgid "Invite" @@ -670,16 +676,16 @@ msgid "Language" msgstr "Language" #: src/components/settings/views/billing.tsx:200 -msgid "Learn more about our pricing plans" -msgstr "Learn more about our pricing plans" +#~ msgid "Learn more about our pricing plans" +#~ msgstr "Learn more about our pricing plans" #: src/components/settings/views/profile.tsx:210 msgid "LinkedIn username" msgstr "LinkedIn username" #: src/components/settings/views/billing.tsx:28 -msgid "Live summary of the meeting" -msgstr "Live summary of the meeting" +#~ msgid "Live summary of the meeting" +#~ msgstr "Live summary of the meeting" #: src/components/settings/components/ai/llm-view.tsx:259 msgid "Loading available models..." @@ -694,21 +700,21 @@ msgstr "Loading events..." msgid "Loading..." msgstr "Loading..." -#: src/components/left-sidebar/top-area/settings-button.tsx:68 +#: src/components/left-sidebar/top-area/settings-button.tsx:121 msgid "Local mode" msgstr "Local mode" #: src/components/settings/views/billing.tsx:39 -msgid "Long-term memory for past meetings and attendees" -msgstr "Long-term memory for past meetings and attendees" +#~ msgid "Long-term memory for past meetings and attendees" +#~ msgstr "Long-term memory for past meetings and attendees" #: src/components/share-and-permission/publish.tsx:24 msgid "Make it public" msgstr "Make it public" #: src/components/settings/views/billing.tsx:41 -msgid "Meeting note sharing via links" -msgstr "Meeting note sharing via links" +#~ msgid "Meeting note sharing via links" +#~ msgstr "Meeting note sharing via links" #: src/components/settings/views/team.tsx:145 #: src/components/settings/views/team.tsx:232 @@ -728,14 +734,14 @@ msgid "Model Name" msgstr "Model Name" #: src/components/settings/views/billing.tsx:125 -msgid "Monthly" -msgstr "Monthly" +#~ msgid "Monthly" +#~ msgstr "Monthly" #: src/components/settings/views/billing.tsx:40 -msgid "Much better AI performance" -msgstr "Much better AI performance" +#~ msgid "Much better AI performance" +#~ msgstr "Much better AI performance" -#: src/components/left-sidebar/top-area/settings-button.tsx:89 +#: src/components/left-sidebar/top-area/settings-button.tsx:69 msgid "My Profile" msgstr "My Profile" @@ -824,10 +830,18 @@ msgstr "Open Note" msgid "Optional for participant suggestions" msgstr "Optional for participant suggestions" +#: src/components/welcome-modal/welcome-view.tsx:101 +msgid "or skip signup & use locally" +msgstr "or skip signup & use locally" + +#: src/components/login-modal.tsx:137 +#~ msgid "or, just use it locally" +#~ msgstr "or, just use it locally" + #: src/components/settings/views/team.tsx:139 #: src/components/settings/views/team.tsx:226 msgid "Owner" -msgstr "Owner" +msgstr "Owner<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:269 msgid "Pause" @@ -846,8 +860,12 @@ msgid "Play video" msgstr "Play video" #: src/components/settings/views/billing.tsx:33 -msgid "Pro" -msgstr "Pro" +#~ msgid "Pro" +#~ msgstr "Pro" + +#: src/components/left-sidebar/top-area/settings-button.tsx:95 +msgid "Pro mode" +msgstr "Pro mode" #: src/routes/app.settings.tsx:35 msgid "Profile" @@ -859,15 +877,15 @@ msgstr "Publish your note" #: src/components/organization-profile/recent-notes.tsx:42 msgid "Recent Notes" -msgstr "Recent Notes" +msgstr "Recent Notes<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:366 #~ msgid "Record me only" -#~ msgstr "Record me only" +#~ msgstr "Record me only>>>>>>> origin/main<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:346 #~ msgid "Recording Started" -#~ msgstr "Recording Started" +#~ msgstr "Recording Started>>>>>>> origin/main" #: src/components/editor-area/note-header/chips/participants-chip.tsx:222 #~ msgid "Remove {0} from list" @@ -899,8 +917,8 @@ msgid "Save recordings" msgstr "Save recordings" #: src/components/settings/views/billing.tsx:51 -msgid "Search & ask across all notes in workspace" -msgstr "Search & ask across all notes in workspace" +#~ msgid "Search & ask across all notes in workspace" +#~ msgstr "Search & ask across all notes in workspace" #: src/components/settings/views/team.tsx:204 msgid "Search names or emails" @@ -919,7 +937,7 @@ msgstr "Search..." msgid "Sections" msgstr "Sections" -#: src/components/welcome-modal/model-selection-view.tsx:81 +#: src/components/welcome-modal/model-selection-view.tsx:55 msgid "Select a transcribing model" msgstr "Select a transcribing model" @@ -935,7 +953,7 @@ msgstr "Select or enter the model name required by your endpoint." msgid "Send invite" msgstr "Send invite" -#: src/components/left-sidebar/top-area/settings-button.tsx:82 +#: src/components/left-sidebar/top-area/settings-button.tsx:62 msgid "Settings" msgstr "Settings" @@ -952,24 +970,24 @@ msgid "Show notifications when you join a meeting." msgstr "Show notifications when you join a meeting." #: src/components/settings/views/billing.tsx:53 -msgid "Single sign-on for all users" -msgstr "Single sign-on for all users" +#~ msgid "Single sign-on for all users" +#~ msgstr "Single sign-on for all users" #: src/routes/app.settings.tsx:87 msgid "Sound" msgstr "Sound" #: src/components/settings/views/billing.tsx:76 -msgid "Start Annual Plan" -msgstr "Start Annual Plan" +#~ msgid "Start Annual Plan" +#~ msgstr "Start Annual Plan" #: src/components/settings/views/billing.tsx:75 -msgid "Start Monthly Plan" -msgstr "Start Monthly Plan" +#~ msgid "Start Monthly Plan" +#~ msgstr "Start Monthly Plan" #: src/components/editor-area/note-header/listen-button.tsx:126 msgid "Start recording" -msgstr "Start recording" +msgstr "Start recording<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:277 msgid "Stop" @@ -984,15 +1002,14 @@ msgid "Summarize meeting" msgstr "Summarize meeting" #: src/components/settings/views/billing.tsx:42 -msgid "Synchronization across multiple devices" -msgstr "Synchronization across multiple devices" +#~ msgid "Synchronization across multiple devices" +#~ msgstr "Synchronization across multiple devices" #: src/components/settings/views/sound.tsx:106 msgid "System Audio Access" msgstr "System Audio Access" #: src/routes/app.settings.tsx:47 -#: src/components/settings/views/billing.tsx:46 msgid "Team" msgstr "Team" @@ -1008,13 +1025,13 @@ msgstr "Teamspace" msgid "Templates" msgstr "Templates" -#: src/components/welcome-modal/welcome-view.tsx:28 +#: src/components/welcome-modal/welcome-view.tsx:85 msgid "The AI Meeting Notepad" msgstr "The AI Meeting Notepad" #: src/components/settings/views/billing.tsx:113 -msgid "There's a plan for everyone" -msgstr "There's a plan for everyone" +#~ msgid "There's a plan for everyone" +#~ msgstr "There's a plan for everyone" #: src/components/settings/views/profile.tsx:188 msgid "This is a short description of your company." @@ -1073,8 +1090,8 @@ msgid "Upcoming Events" msgstr "Upcoming Events" #: src/components/settings/views/billing.tsx:69 -msgid "Upgrade" -msgstr "Upgrade" +#~ msgid "Upgrade" +#~ msgstr "Upgrade" #: src/components/settings/components/ai/llm-view.tsx:152 msgid "Use the local Llama 3.2 model for enhanced privacy and offline capability." @@ -1122,16 +1139,16 @@ msgid "Word Error Rate (WER) indicates transcription accuracy (lower is better). msgstr "Word Error Rate (WER) indicates transcription accuracy (lower is better). Data based on the FLEURS dataset, measured with OpenAI's Whisper <0>large-v3-turbo model. <1>More info" #: src/components/settings/views/billing.tsx:25 -msgid "Works both in-person and remotely" -msgstr "Works both in-person and remotely" +#~ msgid "Works both in-person and remotely" +#~ msgstr "Works both in-person and remotely" #: src/components/settings/views/billing.tsx:29 -msgid "Works offline" -msgstr "Works offline" +#~ msgid "Works offline" +#~ msgstr "Works offline<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:358 #~ msgid "Yes, activate speaker" -#~ msgstr "Yes, activate speaker" +#~ msgstr "Yes, activate speaker>>>>>>> origin/main" #: src/components/settings/views/general.tsx:265 msgid "You can make Hyprnote takes these words into account when transcribing" diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 296481289..336df57ba 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -270,13 +270,17 @@ msgstr "" msgid "{category}" msgstr "" -#: src/components/settings/views/lab.tsx:77 -msgid "{description}" -msgstr "" +#: src/components/settings/views/lab.tsx:103 +#~ msgid "{description}" +#~ msgstr "" -#: src/components/settings/views/lab.tsx:74 -msgid "{title}" -msgstr "" +#: src/components/settings/components/calendar/calendar-selector.tsx:74 +#~ msgid "{selectedCount} calendars selected" +#~ msgstr "" + +#: src/components/settings/views/lab.tsx:100 +#~ msgid "{title}" +#~ msgstr "" #: src/components/human-profile/past-notes.tsx:91 msgid "<0>Create Note" @@ -325,7 +329,7 @@ msgstr "" msgid "AI" msgstr "" -#: src/components/login-modal.tsx:97 +#: src/components/login-modal.tsx:118 #~ msgid "AI notepad for meetings" #~ msgstr "" @@ -339,8 +343,8 @@ msgid "and {0} more members" msgstr "" #: src/components/settings/views/billing.tsx:131 -msgid "Annual" -msgstr "" +#~ msgid "Annual" +#~ msgstr "" #: src/components/share-and-permission/publish.tsx:18 msgid "Anyone with the link can view this page" @@ -364,20 +368,24 @@ msgid "Are you sure you want to delete this note?" msgstr "" #: src/components/settings/views/billing.tsx:27 -msgid "Ask questions about past meetings" -msgstr "" +#~ msgid "Ask questions about past meetings" +#~ msgstr "" #: src/components/right-panel/components/chat/chat-message.tsx:18 msgid "Assistant:" msgstr "" +#: src/routes/app.settings.tsx:111 +#~ msgid "Back to Settings" +#~ msgstr "" + #: src/routes/app.settings.tsx:49 msgid "Billing" msgstr "" #: src/components/settings/views/billing.tsx:104 -msgid "Billing features are currently under development and will be available in a future update." -msgstr "" +#~ msgid "Billing features are currently under development and will be available in a future update." +#~ msgstr "" #: src/components/settings/components/templates-sidebar.tsx:68 msgid "Built-in Templates" @@ -421,12 +429,10 @@ msgid "Close" msgstr "" #: src/components/settings/views/billing.tsx:52 -msgid "Collaborate with others in meetings" -msgstr "" +#~ msgid "Collaborate with others in meetings" +#~ msgstr "" #: src/components/settings/views/team.tsx:69 -#: src/components/settings/views/billing.tsx:63 -#: src/components/settings/views/billing.tsx:101 msgid "Coming Soon" msgstr "" @@ -463,7 +469,7 @@ msgstr "" msgid "Contacts Access" msgstr "" -#: src/components/welcome-modal/model-selection-view.tsx:151 +#: src/components/welcome-modal/model-selection-view.tsx:125 msgid "Continue" msgstr "" @@ -485,8 +491,8 @@ msgid "Create Note" msgstr "" #: src/components/settings/views/billing.tsx:66 -msgid "Current Plan" -msgstr "" +#~ msgid "Current Plan" +#~ msgstr "" #: src/components/settings/components/ai/llm-view.tsx:174 msgid "Custom Endpoint" @@ -512,11 +518,11 @@ msgstr "" #: src/components/settings/views/template.tsx:109 msgid "Description" -msgstr "" +msgstr "<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:349 #~ msgid "Did you get consent from everyone in the meeting?" -#~ msgstr "" +#~ msgstr ">>>>>>> origin/main" #. placeholder {0}: metadata?.size && `(${metadata.size})` #: src/components/settings/components/ai/stt-view.tsx:273 @@ -576,28 +582,28 @@ msgid "Feedback" msgstr "" #: src/components/settings/views/billing.tsx:47 -msgid "For fast growing teams like energetic startups" -msgstr "" +#~ msgid "For fast growing teams like energetic startups" +#~ msgstr "" #: src/components/settings/views/billing.tsx:34 -msgid "For those who are serious about their performance" -msgstr "" +#~ msgid "For those who are serious about their performance" +#~ msgstr "" #: src/components/settings/views/billing.tsx:21 -msgid "For those who are serious about their privacy" -msgstr "" +#~ msgid "For those who are serious about their privacy" +#~ msgstr "" #: src/components/settings/views/billing.tsx:26 -msgid "Format notes using templates" -msgstr "" +#~ msgid "Format notes using templates" +#~ msgstr "" #: src/components/settings/views/billing.tsx:20 -msgid "Free" -msgstr "" +#~ msgid "Free" +#~ msgstr "" #: src/components/settings/views/billing.tsx:72 -msgid "Free Trial" -msgstr "" +#~ msgid "Free Trial" +#~ msgstr "" #: src/components/settings/views/profile.tsx:119 msgid "Full name" @@ -608,7 +614,7 @@ msgstr "" msgid "General" msgstr "" -#: src/components/welcome-modal/welcome-view.tsx:36 +#: src/components/welcome-modal/welcome-view.tsx:94 msgid "Get Started" msgstr "" @@ -634,8 +640,8 @@ msgid "How can I help you today?" msgstr "" #: src/components/settings/views/billing.tsx:38 -msgid "Integration with other apps like Notion and Google Calendar" -msgstr "" +#~ msgid "Integration with other apps like Notion and Google Calendar" +#~ msgstr "" #: src/components/share-and-permission/invite-list.tsx:30 msgid "Invite" @@ -670,16 +676,16 @@ msgid "Language" msgstr "" #: src/components/settings/views/billing.tsx:200 -msgid "Learn more about our pricing plans" -msgstr "" +#~ msgid "Learn more about our pricing plans" +#~ msgstr "" #: src/components/settings/views/profile.tsx:210 msgid "LinkedIn username" msgstr "" #: src/components/settings/views/billing.tsx:28 -msgid "Live summary of the meeting" -msgstr "" +#~ msgid "Live summary of the meeting" +#~ msgstr "" #: src/components/settings/components/ai/llm-view.tsx:259 msgid "Loading available models..." @@ -694,21 +700,21 @@ msgstr "" msgid "Loading..." msgstr "" -#: src/components/left-sidebar/top-area/settings-button.tsx:68 +#: src/components/left-sidebar/top-area/settings-button.tsx:121 msgid "Local mode" msgstr "" #: src/components/settings/views/billing.tsx:39 -msgid "Long-term memory for past meetings and attendees" -msgstr "" +#~ msgid "Long-term memory for past meetings and attendees" +#~ msgstr "" #: src/components/share-and-permission/publish.tsx:24 msgid "Make it public" msgstr "" #: src/components/settings/views/billing.tsx:41 -msgid "Meeting note sharing via links" -msgstr "" +#~ msgid "Meeting note sharing via links" +#~ msgstr "" #: src/components/settings/views/team.tsx:145 #: src/components/settings/views/team.tsx:232 @@ -728,14 +734,14 @@ msgid "Model Name" msgstr "" #: src/components/settings/views/billing.tsx:125 -msgid "Monthly" -msgstr "" +#~ msgid "Monthly" +#~ msgstr "" #: src/components/settings/views/billing.tsx:40 -msgid "Much better AI performance" -msgstr "" +#~ msgid "Much better AI performance" +#~ msgstr "" -#: src/components/left-sidebar/top-area/settings-button.tsx:89 +#: src/components/left-sidebar/top-area/settings-button.tsx:69 msgid "My Profile" msgstr "" @@ -824,10 +830,18 @@ msgstr "" msgid "Optional for participant suggestions" msgstr "" +#: src/components/welcome-modal/welcome-view.tsx:101 +msgid "or skip signup & use locally" +msgstr "" + +#: src/components/login-modal.tsx:137 +#~ msgid "or, just use it locally" +#~ msgstr "" + #: src/components/settings/views/team.tsx:139 #: src/components/settings/views/team.tsx:226 msgid "Owner" -msgstr "" +msgstr "<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:269 msgid "Pause" @@ -846,7 +860,11 @@ msgid "Play video" msgstr "" #: src/components/settings/views/billing.tsx:33 -msgid "Pro" +#~ msgid "Pro" +#~ msgstr "" + +#: src/components/left-sidebar/top-area/settings-button.tsx:95 +msgid "Pro mode" msgstr "" #: src/routes/app.settings.tsx:35 @@ -859,15 +877,15 @@ msgstr "" #: src/components/organization-profile/recent-notes.tsx:42 msgid "Recent Notes" -msgstr "" +msgstr "<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:366 #~ msgid "Record me only" -#~ msgstr "" +#~ msgstr ">>>>>>> origin/main<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:346 #~ msgid "Recording Started" -#~ msgstr "" +#~ msgstr ">>>>>>> origin/main" #: src/components/editor-area/note-header/chips/participants-chip.tsx:222 #~ msgid "Remove {0} from list" @@ -899,8 +917,8 @@ msgid "Save recordings" msgstr "" #: src/components/settings/views/billing.tsx:51 -msgid "Search & ask across all notes in workspace" -msgstr "" +#~ msgid "Search & ask across all notes in workspace" +#~ msgstr "" #: src/components/settings/views/team.tsx:204 msgid "Search names or emails" @@ -919,7 +937,7 @@ msgstr "" msgid "Sections" msgstr "" -#: src/components/welcome-modal/model-selection-view.tsx:81 +#: src/components/welcome-modal/model-selection-view.tsx:55 msgid "Select a transcribing model" msgstr "" @@ -935,7 +953,7 @@ msgstr "" msgid "Send invite" msgstr "" -#: src/components/left-sidebar/top-area/settings-button.tsx:82 +#: src/components/left-sidebar/top-area/settings-button.tsx:62 msgid "Settings" msgstr "" @@ -952,24 +970,24 @@ msgid "Show notifications when you join a meeting." msgstr "" #: src/components/settings/views/billing.tsx:53 -msgid "Single sign-on for all users" -msgstr "" +#~ msgid "Single sign-on for all users" +#~ msgstr "" #: src/routes/app.settings.tsx:87 msgid "Sound" msgstr "" #: src/components/settings/views/billing.tsx:76 -msgid "Start Annual Plan" -msgstr "" +#~ msgid "Start Annual Plan" +#~ msgstr "" #: src/components/settings/views/billing.tsx:75 -msgid "Start Monthly Plan" -msgstr "" +#~ msgid "Start Monthly Plan" +#~ msgstr "" #: src/components/editor-area/note-header/listen-button.tsx:126 msgid "Start recording" -msgstr "" +msgstr "<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:277 msgid "Stop" @@ -984,15 +1002,14 @@ msgid "Summarize meeting" msgstr "" #: src/components/settings/views/billing.tsx:42 -msgid "Synchronization across multiple devices" -msgstr "" +#~ msgid "Synchronization across multiple devices" +#~ msgstr "" #: src/components/settings/views/sound.tsx:106 msgid "System Audio Access" msgstr "" #: src/routes/app.settings.tsx:47 -#: src/components/settings/views/billing.tsx:46 msgid "Team" msgstr "" @@ -1008,13 +1025,13 @@ msgstr "" msgid "Templates" msgstr "" -#: src/components/welcome-modal/welcome-view.tsx:28 +#: src/components/welcome-modal/welcome-view.tsx:85 msgid "The AI Meeting Notepad" msgstr "" #: src/components/settings/views/billing.tsx:113 -msgid "There's a plan for everyone" -msgstr "" +#~ msgid "There's a plan for everyone" +#~ msgstr "" #: src/components/settings/views/profile.tsx:188 msgid "This is a short description of your company." @@ -1073,8 +1090,8 @@ msgid "Upcoming Events" msgstr "" #: src/components/settings/views/billing.tsx:69 -msgid "Upgrade" -msgstr "" +#~ msgid "Upgrade" +#~ msgstr "" #: src/components/settings/components/ai/llm-view.tsx:152 msgid "Use the local Llama 3.2 model for enhanced privacy and offline capability." @@ -1122,16 +1139,16 @@ msgid "Word Error Rate (WER) indicates transcription accuracy (lower is better). msgstr "" #: src/components/settings/views/billing.tsx:25 -msgid "Works both in-person and remotely" -msgstr "" +#~ msgid "Works both in-person and remotely" +#~ msgstr "" #: src/components/settings/views/billing.tsx:29 -msgid "Works offline" -msgstr "" +#~ msgid "Works offline" +#~ msgstr "<<<<<<< HEAD" #: src/components/editor-area/note-header/listen-button.tsx:358 #~ msgid "Yes, activate speaker" -#~ msgstr "" +#~ msgstr ">>>>>>> origin/main" #: src/components/settings/views/general.tsx:265 msgid "You can make Hyprnote takes these words into account when transcribing" diff --git a/apps/desktop/src/routes/app.plans.tsx b/apps/desktop/src/routes/app.plans.tsx index 660e2d8b1..4bac920b8 100644 --- a/apps/desktop/src/routes/app.plans.tsx +++ b/apps/desktop/src/routes/app.plans.tsx @@ -1,57 +1,34 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { Check } from "lucide-react"; + +import { useHypr } from "@/contexts"; +import { type Subscription } from "@hypr/plugin-membership"; import { Button } from "@hypr/ui/components/ui/button"; import { ProgressiveBlur } from "@hypr/ui/components/ui/progressive-blur"; import { cn } from "@hypr/ui/lib/utils"; -import { createFileRoute } from "@tanstack/react-router"; -import { Check } from "lucide-react"; export const Route = createFileRoute("/app/plans")({ component: Component, }); function Component() { - return ( -
-
-
- + const { subscription } = useHypr(); - -
-
-
- ); + if (!subscription) { + return ; + } + + const { status, trial_end } = subscription; + const isActive = ["active", "trialing"].includes(status); + + if (!isActive) { + return ; + } + + return trial_end + ? + : ; } interface PricingCardProps { @@ -65,6 +42,8 @@ interface PricingCardProps { text: string; onClick: () => void; }; + isActive?: boolean; + subscriptionInfo?: React.ReactNode; } function PricingCard({ @@ -75,6 +54,8 @@ function PricingCard({ features, className, secondaryAction, + isActive = false, + subscriptionInfo, }: PricingCardProps) { const isLocalPlan = title === "Local"; const bgImage = isLocalPlan ? "/assets/bg-local-card.jpg" : "/assets/bg-pro-card.jpg"; @@ -82,8 +63,9 @@ function PricingCard({ return (
- {isLocalPlan ? "Free" : "Private Beta"} + {isLocalPlan ? "Free" : "Public Beta"}
+ + {isActive && ( +
+ Active +
+ )}
{/* Wrapper for content to ensure it's above the blur */}
-
-

{title}

-

{description}

+
+

{title}

+

{description}

+ {subscriptionInfo}
{secondaryAction && ( )} -
+
{features.map((feature, i) => (
@@ -149,29 +138,217 @@ function PricingCard({ variant={buttonVariant} size="md" className={cn( - "w-full py-4 text-md font-medium rounded-xl transition-all duration-300 relative z-10 text-center", + "w-full py-3 text-md font-medium rounded-xl transition-all duration-300 relative z-10 text-center", buttonVariant === "default" ? "bg-blue-500 hover:bg-blue-600 shadow-md hover:shadow-lg text-white" : "bg-white/20 hover:bg-white/30 hover:text-white text-white border-white/40", )} + disabled={isActive} > - {buttonText} + {isActive ? "Current Plan" : buttonText} ) : ( - - {buttonText} - + <> + {!isActive + ? ( +
+ + Upgrade to Pro + +
+

+ 7-day free trial. No credit card required. +

+
+
+ ) + : ( + + )} + )}
); } + +function RenderActiveWithoutTrial({ subscription }: { subscription: Subscription }) { + const nextBillingDate = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000) + : null; + + return ( +
+
+
+ + + +

Next billing: {format(nextBillingDate, "MMMM dd, yyyy")}

+
+ )} + /> +
+ +
+ ); +} + +function RenderActiveWithTrial({ subscription }: { subscription: Subscription }) { + const trialEndDate = subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null; + + return ( +
+
+
+ + + +

Trial ends: {format(trialEndDate, "MMMM dd, yyyy")}

+
+ )} + /> +
+ +
+ ); +} + +function RenderInactive() { + return ( +
+
+
+ + + +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/app.settings.tsx b/apps/desktop/src/routes/app.settings.tsx index 603f8618c..384657b7b 100644 --- a/apps/desktop/src/routes/app.settings.tsx +++ b/apps/desktop/src/routes/app.settings.tsx @@ -5,7 +5,7 @@ import { z } from "zod"; import { TabIcon } from "@/components/settings/components/tab-icon"; import { type Tab, TABS } from "@/components/settings/components/types"; -import { Calendar, Feedback, General, Lab, LocalAI, Notifications, Sound } from "@/components/settings/views"; +import { Calendar, Feedback, General, LocalAI, Notifications, Sound } from "@/components/settings/views"; import { cn } from "@hypr/ui/lib/utils"; const schema = z.object({ @@ -100,9 +100,7 @@ function Component() {
- {/* Main Content */}
- {/* Header */}
@@ -113,14 +111,12 @@ function Component() {
- {/* Actual Content */}
{search.tab === "general" && } {search.tab === "calendar" && } {search.tab === "notifications" && } {search.tab === "sound" && } {search.tab === "ai" && } - {search.tab === "lab" && } {search.tab === "feedback" && }
diff --git a/apps/docs/data/i18n.json b/apps/docs/data/i18n.json index 71fa9bad7..ded36bdb9 100644 --- a/apps/docs/data/i18n.json +++ b/apps/docs/data/i18n.json @@ -1,12 +1,12 @@ [ { "language": "ko", - "total": 262, - "missing": 262 + "total": 267, + "missing": 258 }, { "language": "en (source)", - "total": 262, + "total": 267, "missing": 0 } ] diff --git a/crates/db-admin/src/billings_migration.sql b/crates/db-admin/src/billings_migration.sql index 183285f66..946b49a37 100644 --- a/crates/db-admin/src/billings_migration.sql +++ b/crates/db-admin/src/billings_migration.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS billings ( id TEXT PRIMARY KEY, - organization_id TEXT NOT NULL, + account_id TEXT NOT NULL, stripe_customer TEXT NOT NULL, stripe_subscription TEXT NOT NULL, - FOREIGN KEY (organization_id) REFERENCES organizations(id) + FOREIGN KEY (account_id) REFERENCES accounts(id) ); diff --git a/crates/db-admin/src/billings_ops.rs b/crates/db-admin/src/billings_ops.rs index 7771ac5a7..264c9c822 100644 --- a/crates/db-admin/src/billings_ops.rs +++ b/crates/db-admin/src/billings_ops.rs @@ -1,26 +1,50 @@ use super::{AdminDatabase, Billing}; impl AdminDatabase { - pub async fn create_empty_billing( + pub async fn get_billing_by_account_id( &self, - organization_id: impl Into, + account_id: impl Into, + ) -> Result, crate::Error> { + let conn = self.conn()?; + + let mut rows = conn + .query( + "SELECT * FROM billings WHERE account_id = ?", + vec![account_id.into()], + ) + .await?; + + match rows.next().await? { + None => Ok(None), + Some(row) => { + let billing: Billing = libsql::de::from_row(&row).unwrap(); + Ok(Some(billing)) + } + } + } + + pub async fn create_billing( + &self, + account_id: impl Into, + stripe_customer: stripe::Customer, + stripe_subscription: stripe::Subscription, ) -> Result, crate::Error> { let conn = self.conn()?; let mut rows = conn .query( "INSERT INTO billings ( - id, - organization_id, - stripe_subscription, - stripe_customer - ) VALUES (?, ?, ?, ?) - RETURNING *", + id, + account_id, + stripe_subscription, + stripe_customer + ) VALUES (?, ?, ?, ?) + RETURNING *", vec![ libsql::Value::Text(uuid::Uuid::new_v4().to_string()), - libsql::Value::Text(organization_id.into()), - libsql::Value::Null, - libsql::Value::Null, + libsql::Value::Text(account_id.into()), + libsql::Value::Text(serde_json::to_string(&stripe_subscription).unwrap()), + libsql::Value::Text(serde_json::to_string(&stripe_customer).unwrap()), ], ) .await?; diff --git a/crates/db-admin/src/billings_types.rs b/crates/db-admin/src/billings_types.rs index 86addb502..f13819762 100644 --- a/crates/db-admin/src/billings_types.rs +++ b/crates/db-admin/src/billings_types.rs @@ -3,7 +3,7 @@ use crate::admin_common_derives; admin_common_derives! { pub struct Billing { pub id: String, - pub organization_id: String, + pub account_id: String, #[schemars(skip)] pub stripe_subscription: Option, #[schemars(skip)] @@ -15,7 +15,7 @@ impl Billing { pub fn from_row(row: &libsql::Row) -> Result { Ok(Self { id: row.get(0).expect("id"), - organization_id: row.get(1).expect("organization_id"), + account_id: row.get(1).expect("account_id"), stripe_subscription: row .get_str(2) .map(|s| serde_json::from_str(s).unwrap()) diff --git a/packages/client/generated/@tanstack/react-query.gen.ts b/packages/client/generated/@tanstack/react-query.gen.ts index 524f352c0..c6e1ebca9 100644 --- a/packages/client/generated/@tanstack/react-query.gen.ts +++ b/packages/client/generated/@tanstack/react-query.gen.ts @@ -1,8 +1,8 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type Options, getHealth, getApiDesktopUserIntegrations, getApiDesktopSubscription, postApiWebConnect, getApiWebSessionById, postApiWebIntegrationConnection, postChatCompletions } from '../sdk.gen'; +import { type Options, getHealth, getApiDesktopUserIntegrations, postApiWebConnect, getApiWebCheckout, getApiWebSessionById, postApiWebIntegrationConnection, postChatCompletions } from '../sdk.gen'; import { queryOptions, type UseMutationOptions, type DefaultError } from '@tanstack/react-query'; -import type { GetHealthData, GetApiDesktopUserIntegrationsData, GetApiDesktopSubscriptionData, PostApiWebConnectData, PostApiWebConnectResponse, GetApiWebSessionByIdData, PostApiWebIntegrationConnectionData, PostApiWebIntegrationConnectionResponse, PostChatCompletionsData } from '../types.gen'; +import type { GetHealthData, GetApiDesktopUserIntegrationsData, PostApiWebConnectData, PostApiWebConnectResponse, GetApiWebCheckoutData, GetApiWebSessionByIdData, PostApiWebIntegrationConnectionData, PostApiWebIntegrationConnectionResponse, PostChatCompletionsData } from '../types.gen'; import { client as _heyApiClient } from '../client.gen'; export type QueryKey = [ @@ -70,23 +70,6 @@ export const getApiDesktopUserIntegrationsOptions = (options?: Options) => createQueryKey('getApiDesktopSubscription', options); - -export const getApiDesktopSubscriptionOptions = (options?: Options) => { - return queryOptions({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getApiDesktopSubscription({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getApiDesktopSubscriptionQueryKey(options) - }); -}; - export const postApiWebConnectQueryKey = (options: Options) => createQueryKey('postApiWebConnect', options); export const postApiWebConnectOptions = (options: Options) => { @@ -118,6 +101,23 @@ export const postApiWebConnectMutation = (options?: Partial) => createQueryKey('getApiWebCheckout', options); + +export const getApiWebCheckoutOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getApiWebCheckout({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getApiWebCheckoutQueryKey(options) + }); +}; + export const getApiWebSessionByIdQueryKey = (options: Options) => createQueryKey('getApiWebSessionById', options); export const getApiWebSessionByIdOptions = (options: Options) => { diff --git a/packages/client/generated/sdk.gen.ts b/packages/client/generated/sdk.gen.ts index 7ab49dcb2..d1220f6c6 100644 --- a/packages/client/generated/sdk.gen.ts +++ b/packages/client/generated/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { GetHealthData, GetApiDesktopUserIntegrationsData, GetApiDesktopUserIntegrationsResponse, GetApiDesktopSubscriptionData, GetApiDesktopSubscriptionResponse, PostApiWebConnectData, PostApiWebConnectResponse, GetApiWebSessionByIdData, GetApiWebSessionByIdResponse, PostApiWebIntegrationConnectionData, PostApiWebIntegrationConnectionResponse, PostChatCompletionsData } from './types.gen'; +import type { GetHealthData, GetApiDesktopUserIntegrationsData, GetApiDesktopUserIntegrationsResponse, PostApiWebConnectData, PostApiWebConnectResponse, GetApiWebCheckoutData, GetApiWebSessionByIdData, GetApiWebSessionByIdResponse, PostApiWebIntegrationConnectionData, PostApiWebIntegrationConnectionResponse, PostChatCompletionsData } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -32,13 +32,6 @@ export const getApiDesktopUserIntegrations = (options?: Options) => { - return (options?.client ?? _heyApiClient).get({ - url: '/api/desktop/subscription', - ...options - }); -}; - export const postApiWebConnect = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/api/web/connect', @@ -50,6 +43,13 @@ export const postApiWebConnect = (options: }); }; +export const getApiWebCheckout = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/api/web/checkout', + ...options + }); +}; + export const getApiWebSessionById = (options: Options) => { return (options.client ?? _heyApiClient).get({ url: '/api/web/session/{id}', diff --git a/packages/client/generated/types.gen.ts b/packages/client/generated/types.gen.ts index c75f74bd8..b66213b6c 100644 --- a/packages/client/generated/types.gen.ts +++ b/packages/client/generated/types.gen.ts @@ -385,8 +385,9 @@ export type CreateChatCompletionRequest = { }; export type DiarizationChunk = { + confidence?: number | null; end: number; - speaker: string; + speaker: number; start: number; }; @@ -458,8 +459,6 @@ export type InputAudio = { export type InputAudioFormat = 'wav' | 'mp3'; -export type Membership = 'Trial' | 'Basic' | 'Pro'; - export type NangoConnectSessionRequest = { allowed_integrations: Array; end_user: NangoConnectSessionRequestUser; @@ -578,11 +577,8 @@ export type Session = { export type Stop = string | Array; -export type Subscription = { - membership: Membership; -}; - export type TranscriptChunk = { + confidence?: number | null; end: number; start: number; text: string; @@ -608,19 +604,6 @@ export type GetApiDesktopUserIntegrationsResponses = { export type GetApiDesktopUserIntegrationsResponse = GetApiDesktopUserIntegrationsResponses[keyof GetApiDesktopUserIntegrationsResponses]; -export type GetApiDesktopSubscriptionData = { - body?: never; - path?: never; - query?: never; - url: '/api/desktop/subscription'; -}; - -export type GetApiDesktopSubscriptionResponses = { - 200: Subscription; -}; - -export type GetApiDesktopSubscriptionResponse = GetApiDesktopSubscriptionResponses[keyof GetApiDesktopSubscriptionResponses]; - export type PostApiWebConnectData = { body: RequestParams; path?: never; @@ -634,6 +617,20 @@ export type PostApiWebConnectResponses = { export type PostApiWebConnectResponse = PostApiWebConnectResponses[keyof PostApiWebConnectResponses]; +export type GetApiWebCheckoutData = { + body?: never; + path?: never; + query?: never; + url: '/api/web/checkout'; +}; + +export type GetApiWebCheckoutResponses = { + /** + * plain text + */ + 200: unknown; +}; + export type GetApiWebSessionByIdData = { body?: never; path: { diff --git a/plugins/analytics/src/lib.rs b/plugins/analytics/src/lib.rs index fcd134356..1daaeb57f 100644 --- a/plugins/analytics/src/lib.rs +++ b/plugins/analytics/src/lib.rs @@ -24,24 +24,12 @@ fn make_specta_builder() -> tauri_specta::Builder { .error_handling(tauri_specta::ErrorHandlingMode::Throw) } -pub fn init() -> tauri::plugin::TauriPlugin { +pub fn init(api_key: String) -> tauri::plugin::TauriPlugin { let specta_builder = make_specta_builder(); tauri::plugin::Builder::new(PLUGIN_NAME) .invoke_handler(specta_builder.invoke_handler()) .setup(|app, _api| { - let api_key = { - #[cfg(not(debug_assertions))] - { - env!("POSTHOG_API_KEY") - } - - #[cfg(debug_assertions)] - { - option_env!("POSTHOG_API_KEY").unwrap_or_default() - } - }; - let client = hypr_analytics::AnalyticsClient::new(api_key); assert!(app.manage(client)); Ok(()) @@ -71,7 +59,10 @@ mod test { ctx.config_mut().identifier = "com.hyprnote.dev".to_string(); ctx.config_mut().version = Some("0.0.1".to_string()); - builder.plugin(init()).build(ctx).unwrap() + builder + .plugin(init("API_KEY".to_string())) + .build(ctx) + .unwrap() } #[test] diff --git a/plugins/auth/js/bindings.gen.ts b/plugins/auth/js/bindings.gen.ts index 5f12da3d1..866ee74e0 100644 --- a/plugins/auth/js/bindings.gen.ts +++ b/plugins/auth/js/bindings.gen.ts @@ -51,7 +51,7 @@ authEvent: "plugin:auth:auth-event" export type AuthEvent = "success" | { error: string } export type RequestParams = { c: string; f: string; p: number } export type ResponseParams = { ui: string; ai: string; st: string; dt: string } -export type StoreKey = "auth-user-id" | "auth-account-id" +export type StoreKey = "auth-user-id" | "auth-account-id" | "auth-plan" export type VaultKey = "remote-database" | "remote-server" | "twenty-api-key" /** tauri-specta globals **/ diff --git a/plugins/auth/src/store.rs b/plugins/auth/src/store.rs index 5dcadaf10..f37872b85 100644 --- a/plugins/auth/src/store.rs +++ b/plugins/auth/src/store.rs @@ -10,6 +10,10 @@ pub enum StoreKey { #[serde(rename = "auth-account-id")] #[specta(rename = "auth-account-id")] AccountId, + #[strum(serialize = "auth-plan")] + #[serde(rename = "auth-plan")] + #[specta(rename = "auth-plan")] + Plan, } pub fn get_store>( diff --git a/plugins/connector/Cargo.toml b/plugins/connector/Cargo.toml index 590f78cdd..87bfd7b1b 100644 --- a/plugins/connector/Cargo.toml +++ b/plugins/connector/Cargo.toml @@ -14,12 +14,10 @@ tauri-plugin = { workspace = true, features = ["build"] } specta-typescript = { workspace = true } [dependencies] +tauri = { workspace = true, features = ["test"] } tauri-plugin-auth = { workspace = true } tauri-plugin-local-llm = { workspace = true } tauri-plugin-local-stt = { workspace = true } - -tauri = { workspace = true, features = ["test"] } -tauri-plugin-flags = { workspace = true } tauri-plugin-store2 = { workspace = true } tauri-specta = { workspace = true, features = ["derive", "typescript"] } diff --git a/plugins/connector/src/ext.rs b/plugins/connector/src/ext.rs index b0cb3167f..49872f776 100644 --- a/plugins/connector/src/ext.rs +++ b/plugins/connector/src/ext.rs @@ -106,11 +106,12 @@ impl> ConnectorPluginExt for T { async fn get_llm_connection(&self) -> Result { { - use tauri_plugin_flags::{FlagsPluginExt, StoreKey as FlagsStoreKey}; + use tauri_plugin_auth::{AuthPluginExt, StoreKey}; if self - .is_enabled(FlagsStoreKey::CloudPreview) - .unwrap_or(false) + .get_from_store(StoreKey::Plan)? + .unwrap_or("free".to_string()) + == "pro".to_string() { let api_base = if cfg!(debug_assertions) { "http://127.0.0.1:1234".to_string() @@ -168,11 +169,12 @@ impl> ConnectorPluginExt for T { async fn get_stt_connection(&self) -> Result { { - use tauri_plugin_flags::{FlagsPluginExt, StoreKey as FlagsStoreKey}; + use tauri_plugin_auth::{AuthPluginExt, StoreKey}; if self - .is_enabled(FlagsStoreKey::CloudPreview) - .unwrap_or(false) + .get_from_store(StoreKey::Plan)? + .unwrap_or("free".to_string()) + == "pro".to_string() { let api_base = if cfg!(debug_assertions) { "http://127.0.0.1:1234".to_string() diff --git a/plugins/membership-interface/Cargo.toml b/plugins/membership-interface/Cargo.toml new file mode 100644 index 000000000..12530e73a --- /dev/null +++ b/plugins/membership-interface/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "membership-interface" +version = "0.1.0" +edition = "2021" + +[dependencies] +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["derive"] } diff --git a/plugins/membership-interface/src/lib.rs b/plugins/membership-interface/src/lib.rs new file mode 100644 index 000000000..8a4cc51c3 --- /dev/null +++ b/plugins/membership-interface/src/lib.rs @@ -0,0 +1,32 @@ +#[macro_export] +macro_rules! common_derives { + ($item:item) => { + #[derive( + Debug, Clone, serde::Deserialize, serde::Serialize, schemars::JsonSchema, specta::Type, + )] + $item + }; +} + +common_derives! { + pub struct Subscription { + pub status: SubscriptionStatus, + pub current_period_end: i64, + pub trial_end: Option, + pub price_id: Option, + } +} + +common_derives! { + #[serde(rename_all = "snake_case")] + pub enum SubscriptionStatus { + Active, + Canceled, + Incomplete, + IncompleteExpired, + PastDue, + Paused, + Trialing, + Unpaid, + } +} diff --git a/plugins/membership/.gitignore b/plugins/membership/.gitignore new file mode 100644 index 000000000..50d8e32e8 --- /dev/null +++ b/plugins/membership/.gitignore @@ -0,0 +1,17 @@ +/.vs +.DS_Store +.Thumbs.db +*.sublime* +.idea/ +debug.log +package-lock.json +.vscode/settings.json +yarn.lock + +/.tauri +/target +Cargo.lock +node_modules/ + +dist-js +dist diff --git a/plugins/membership/Cargo.toml b/plugins/membership/Cargo.toml new file mode 100644 index 000000000..6826fc595 --- /dev/null +++ b/plugins/membership/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tauri-plugin-membership" +version = "0.1.0" +authors = ["You"] +edition = "2021" +exclude = ["./js"] +links = "tauri-plugin-membership" +description = "" + +[build-dependencies] +tauri-plugin = { workspace = true, features = ["build"] } + +[dev-dependencies] +specta-typescript = { workspace = true } +tauri-plugin-store = { workspace = true } + +[dependencies] +hypr-membership-interface = { workspace = true } + +tauri = { workspace = true, features = ["test"] } +tauri-plugin-store2 = { workspace = true } +tauri-specta = { workspace = true, features = ["derive", "typescript"] } + +reqwest = { workspace = true, features = ["json"] } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +specta = { workspace = true } +strum = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } diff --git a/plugins/membership/build.rs b/plugins/membership/build.rs new file mode 100644 index 000000000..cdce1433d --- /dev/null +++ b/plugins/membership/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["refresh", "get_subscription"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/plugins/membership/js/bindings.gen.ts b/plugins/membership/js/bindings.gen.ts new file mode 100644 index 000000000..943550aa7 --- /dev/null +++ b/plugins/membership/js/bindings.gen.ts @@ -0,0 +1,89 @@ +// @ts-nocheck + + +// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. + +/** user-defined commands **/ + + +export const commands = { +async refresh() : Promise { + return await TAURI_INVOKE("plugin:membership|refresh"); +}, +async getSubscription() : Promise { + return await TAURI_INVOKE("plugin:membership|get_subscription"); +} +} + +/** user-defined events **/ + + + +/** user-defined constants **/ + + + +/** user-defined types **/ + +export type Subscription = { status: SubscriptionStatus; current_period_end: number; trial_end: number | null; price_id: string | null } +export type SubscriptionStatus = "active" | "canceled" | "incomplete" | "incomplete_expired" | "past_due" | "paused" | "trialing" | "unpaid" + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; + +type __EventObj__ = { + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +export type Result = + | { status: "ok"; data: T } + | { status: "error"; error: E }; + +function __makeEvents__>( + mappings: Record, +) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); +} diff --git a/plugins/membership/js/index.ts b/plugins/membership/js/index.ts new file mode 100644 index 000000000..a96e122f0 --- /dev/null +++ b/plugins/membership/js/index.ts @@ -0,0 +1 @@ +export * from "./bindings.gen"; diff --git a/plugins/membership/package.json b/plugins/membership/package.json new file mode 100644 index 000000000..2db23f1a1 --- /dev/null +++ b/plugins/membership/package.json @@ -0,0 +1,11 @@ +{ + "name": "@hypr/plugin-membership", + "private": true, + "main": "./js/index.ts", + "scripts": { + "codegen": "cargo test -p tauri-plugin-membership" + }, + "dependencies": { + "@tauri-apps/api": "^2.5.0" + } +} diff --git a/plugins/membership/permissions/autogenerated/commands/get_subscription.toml b/plugins/membership/permissions/autogenerated/commands/get_subscription.toml new file mode 100644 index 000000000..d8621fba0 --- /dev/null +++ b/plugins/membership/permissions/autogenerated/commands/get_subscription.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-subscription" +description = "Enables the get_subscription command without any pre-configured scope." +commands.allow = ["get_subscription"] + +[[permission]] +identifier = "deny-get-subscription" +description = "Denies the get_subscription command without any pre-configured scope." +commands.deny = ["get_subscription"] diff --git a/plugins/membership/permissions/autogenerated/commands/refresh.toml b/plugins/membership/permissions/autogenerated/commands/refresh.toml new file mode 100644 index 000000000..3ab74367a --- /dev/null +++ b/plugins/membership/permissions/autogenerated/commands/refresh.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-refresh" +description = "Enables the refresh command without any pre-configured scope." +commands.allow = ["refresh"] + +[[permission]] +identifier = "deny-refresh" +description = "Denies the refresh command without any pre-configured scope." +commands.deny = ["refresh"] diff --git a/plugins/membership/permissions/autogenerated/reference.md b/plugins/membership/permissions/autogenerated/reference.md new file mode 100644 index 000000000..d1ae86b7b --- /dev/null +++ b/plugins/membership/permissions/autogenerated/reference.md @@ -0,0 +1,70 @@ +## Default Permission + +Default permissions for the plugin + +#### This default permission set includes the following: + +- `allow-refresh` +- `allow-get-subscription` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`membership:allow-get-subscription` + + + +Enables the get_subscription command without any pre-configured scope. + +
+ +`membership:deny-get-subscription` + + + +Denies the get_subscription command without any pre-configured scope. + +
+ +`membership:allow-refresh` + + + +Enables the refresh command without any pre-configured scope. + +
+ +`membership:deny-refresh` + + + +Denies the refresh command without any pre-configured scope. + +
diff --git a/plugins/membership/permissions/default.toml b/plugins/membership/permissions/default.toml new file mode 100644 index 000000000..75e459a13 --- /dev/null +++ b/plugins/membership/permissions/default.toml @@ -0,0 +1,3 @@ +[default] +description = "Default permissions for the plugin" +permissions = ["allow-refresh", "allow-get-subscription"] diff --git a/plugins/membership/permissions/schemas/schema.json b/plugins/membership/permissions/schemas/schema.json new file mode 100644 index 000000000..88a70047c --- /dev/null +++ b/plugins/membership/permissions/schemas/schema.json @@ -0,0 +1,330 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the get_subscription command without any pre-configured scope.", + "type": "string", + "const": "allow-get-subscription", + "markdownDescription": "Enables the get_subscription command without any pre-configured scope." + }, + { + "description": "Denies the get_subscription command without any pre-configured scope.", + "type": "string", + "const": "deny-get-subscription", + "markdownDescription": "Denies the get_subscription command without any pre-configured scope." + }, + { + "description": "Enables the refresh command without any pre-configured scope.", + "type": "string", + "const": "allow-refresh", + "markdownDescription": "Enables the refresh command without any pre-configured scope." + }, + { + "description": "Denies the refresh command without any pre-configured scope.", + "type": "string", + "const": "deny-refresh", + "markdownDescription": "Denies the refresh command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-refresh`\n- `allow-get-subscription`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-refresh`\n- `allow-get-subscription`" + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/membership/src/commands.rs b/plugins/membership/src/commands.rs new file mode 100644 index 000000000..99bdc8396 --- /dev/null +++ b/plugins/membership/src/commands.rs @@ -0,0 +1,18 @@ +use crate::MembershipPluginExt; +use hypr_membership_interface::Subscription; + +#[tauri::command] +#[specta::specta] +pub(crate) async fn refresh( + app: tauri::AppHandle, +) -> Result { + app.refresh().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn get_subscription( + app: tauri::AppHandle, +) -> Result, String> { + app.get_subscription().await.map_err(|e| e.to_string()) +} diff --git a/plugins/membership/src/error.rs b/plugins/membership/src/error.rs new file mode 100644 index 000000000..42c1a7515 --- /dev/null +++ b/plugins/membership/src/error.rs @@ -0,0 +1,18 @@ +use serde::{ser::Serializer, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error(transparent)] + StoreError(#[from] tauri_plugin_store2::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/membership/src/ext.rs b/plugins/membership/src/ext.rs new file mode 100644 index 000000000..0e2f45fec --- /dev/null +++ b/plugins/membership/src/ext.rs @@ -0,0 +1,39 @@ +use std::future::Future; +use tauri_plugin_store2::StorePluginExt; + +use hypr_membership_interface::Subscription; + +pub trait MembershipPluginExt { + fn membership_store(&self) -> tauri_plugin_store2::ScopedStore; + fn get_subscription(&self) -> impl Future, crate::Error>>; + fn refresh(&self) -> impl Future>; +} + +impl> MembershipPluginExt for T { + fn membership_store(&self) -> tauri_plugin_store2::ScopedStore { + self.scoped_store(crate::PLUGIN_NAME).unwrap() + } + + async fn get_subscription(&self) -> Result, crate::Error> { + let data = self + .membership_store() + .get::(crate::StoreKey::Subscription)?; + + Ok(data) + } + + async fn refresh(&self) -> Result { + let url = if cfg!(debug_assertions) { + "http://localhost:1234/api/desktop/subscription" + } else { + "https://app.hypr.com/api/desktop/subscription" + }; + + let resp = reqwest::get(url).await?; + let data: Subscription = resp.json().await?; + + self.membership_store() + .set(crate::StoreKey::Subscription, data.clone())?; + Ok(data) + } +} diff --git a/plugins/membership/src/lib.rs b/plugins/membership/src/lib.rs new file mode 100644 index 000000000..219c8ae95 --- /dev/null +++ b/plugins/membership/src/lib.rs @@ -0,0 +1,59 @@ +mod commands; +mod error; +mod ext; +mod store; + +pub use error::*; +pub use ext::*; +pub use store::*; + +const PLUGIN_NAME: &str = "membership"; + +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .plugin_name(PLUGIN_NAME) + .commands(tauri_specta::collect_commands![ + commands::refresh::, + commands::get_subscription::, + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + let specta_builder = make_specta_builder(); + + tauri::plugin::Builder::new(PLUGIN_NAME) + .invoke_handler(specta_builder.invoke_handler()) + .build() +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn export_types() { + make_specta_builder::() + .export( + specta_typescript::Typescript::default() + .header("// @ts-nocheck\n\n") + .formatter(specta_typescript::formatter::prettier) + .bigint(specta_typescript::BigIntExportBehavior::Number), + "./js/bindings.gen.ts", + ) + .unwrap() + } + + fn create_app(builder: tauri::Builder) -> tauri::App { + builder + .plugin(init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap() + } + + #[test] + fn test_membership() { + let _app = create_app(tauri::test::mock_builder()); + } +} diff --git a/plugins/membership/src/store.rs b/plugins/membership/src/store.rs new file mode 100644 index 000000000..b725f6b98 --- /dev/null +++ b/plugins/membership/src/store.rs @@ -0,0 +1,8 @@ +use tauri_plugin_store2::ScopedStoreKey; + +#[derive(serde::Deserialize, specta::Type, PartialEq, Eq, Hash, strum::Display)] +pub enum StoreKey { + Subscription, +} + +impl ScopedStoreKey for StoreKey {} diff --git a/plugins/membership/tsconfig.json b/plugins/membership/tsconfig.json new file mode 100644 index 000000000..13b985325 --- /dev/null +++ b/plugins/membership/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./js/*.ts"], + "exclude": ["node_modules"] +} diff --git a/plugins/store2/permissions/autogenerated/commands/ping.toml b/plugins/store2/permissions/autogenerated/commands/ping.toml deleted file mode 100644 index 1d1358807..000000000 --- a/plugins/store2/permissions/autogenerated/commands/ping.toml +++ /dev/null @@ -1,13 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -"$schema" = "../../schemas/schema.json" - -[[permission]] -identifier = "allow-ping" -description = "Enables the ping command without any pre-configured scope." -commands.allow = ["ping"] - -[[permission]] -identifier = "deny-ping" -description = "Denies the ping command without any pre-configured scope." -commands.deny = ["ping"] diff --git a/plugins/store2/permissions/autogenerated/reference.md b/plugins/store2/permissions/autogenerated/reference.md index fc1c01fff..af119546b 100644 --- a/plugins/store2/permissions/autogenerated/reference.md +++ b/plugins/store2/permissions/autogenerated/reference.md @@ -4,7 +4,12 @@ Default permissions for the plugin #### This default permission set includes the following: -- `allow-ping` +- `allow-get-str` +- `allow-set-str` +- `allow-get-bool` +- `allow-set-bool` +- `allow-get-number` +- `allow-set-number` ## Permission Table @@ -96,32 +101,6 @@ Denies the get_str command without any pre-configured scope. -`store2:allow-ping` - - - - -Enables the ping command without any pre-configured scope. - - - - - - - -`store2:deny-ping` - - - - -Denies the ping command without any pre-configured scope. - - - - - - - `store2:allow-set-bool` diff --git a/plugins/store2/permissions/default.toml b/plugins/store2/permissions/default.toml index cc5a76f22..3bd8d29bb 100644 --- a/plugins/store2/permissions/default.toml +++ b/plugins/store2/permissions/default.toml @@ -1,3 +1,10 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-ping"] +permissions = [ + "allow-get-str", + "allow-set-str", + "allow-get-bool", + "allow-set-bool", + "allow-get-number", + "allow-set-number", +] diff --git a/plugins/store2/permissions/schemas/schema.json b/plugins/store2/permissions/schemas/schema.json index 8ebfaf46a..5edd10a05 100644 --- a/plugins/store2/permissions/schemas/schema.json +++ b/plugins/store2/permissions/schemas/schema.json @@ -330,18 +330,6 @@ "const": "deny-get-str", "markdownDescription": "Denies the get_str command without any pre-configured scope." }, - { - "description": "Enables the ping command without any pre-configured scope.", - "type": "string", - "const": "allow-ping", - "markdownDescription": "Enables the ping command without any pre-configured scope." - }, - { - "description": "Denies the ping command without any pre-configured scope.", - "type": "string", - "const": "deny-ping", - "markdownDescription": "Denies the ping command without any pre-configured scope." - }, { "description": "Enables the set_bool command without any pre-configured scope.", "type": "string", @@ -379,10 +367,10 @@ "markdownDescription": "Denies the set_str command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-str`\n- `allow-set-str`\n- `allow-get-bool`\n- `allow-set-bool`\n- `allow-get-number`\n- `allow-set-number`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-str`\n- `allow-set-str`\n- `allow-get-bool`\n- `allow-set-bool`\n- `allow-get-number`\n- `allow-set-number`" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3690535a9..ad8526a3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@hypr/plugin-local-stt': specifier: workspace:^ version: link:../../plugins/local-stt + '@hypr/plugin-membership': + specifier: workspace:^ + version: link:../../plugins/membership '@hypr/plugin-misc': specifier: workspace:^ version: link:../../plugins/misc @@ -402,7 +405,7 @@ importers: version: 2.2.5(mime-types@3.0.1)(vite@5.4.19(@types/node@22.15.21)) vitest: specifier: ^3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3)) + version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jiti@2.4.2)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0) apps/docs: dependencies: @@ -501,16 +504,16 @@ importers: version: link:../utils '@remixicon/react': specifier: ^4.6.0 - version: 4.6.0(react@19.0.0) + version: 4.6.0(react@18.3.1) '@sereneinserenade/tiptap-search-and-replace': specifier: ^0.1.1 version: 0.1.1(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) '@tanstack/react-query': specifier: ^5.76.2 - version: 5.76.2(react@19.0.0) + version: 5.76.2(react@18.3.1) '@tanstack/react-router': specifier: ^1.120.5 - version: 1.120.5(react-dom@18.3.1(react@19.0.0))(react@19.0.0) + version: 1.120.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/core': specifier: ^2.12.0 version: 2.12.0(@tiptap/pm@2.12.0) @@ -564,7 +567,7 @@ importers: version: 2.12.0 '@tiptap/react': specifier: ^2.12.0 - version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@18.3.1(react@19.0.0))(react@19.0.0) + version: 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': specifier: ^2.12.0 version: 2.12.0 @@ -576,7 +579,7 @@ importers: version: 2.1.1 lucide-react: specifier: ^0.511.0 - version: 0.511.0(react@19.0.0) + version: 0.511.0(react@18.3.1) prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 @@ -588,10 +591,10 @@ importers: version: 1.4.3 react: specifier: ^18.3.1 - version: 19.0.0 + version: 18.3.1 react-dom: specifier: ^18.3.1 - version: 18.3.1(react@19.0.0) + version: 18.3.1(react@18.3.1) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -613,7 +616,7 @@ importers: version: 5.0.5 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3)) + version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jiti@2.4.2)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0) packages/ui: dependencies: @@ -804,7 +807,7 @@ importers: version: 18.3.22 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3)) + version: 3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jiti@2.4.2)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0) plugins/analytics: dependencies: @@ -860,6 +863,12 @@ importers: specifier: ^2.5.0 version: 2.5.0 + plugins/membership: + dependencies: + '@tauri-apps/api': + specifier: ^2.5.0 + version: 2.5.0 + plugins/misc: dependencies: '@tauri-apps/api': @@ -9455,10 +9464,6 @@ snapshots: dependencies: react: 18.3.1 - '@remixicon/react@4.6.0(react@19.0.0)': - dependencies: - react: 19.0.0 - '@rollup/pluginutils@5.1.4(rollup@4.41.0)': dependencies: '@types/estree': 1.0.7 @@ -9736,17 +9741,6 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-router@1.120.5(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': - dependencies: - '@tanstack/history': 1.115.0 - '@tanstack/react-store': 0.7.0(react-dom@18.3.1(react@19.0.0))(react@19.0.0) - '@tanstack/router-core': 1.120.5 - jsesc: 3.1.0 - react: 19.0.0 - react-dom: 18.3.1(react@19.0.0) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/react-store@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/store': 0.7.0 @@ -9754,13 +9748,6 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) - '@tanstack/react-store@0.7.0(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': - dependencies: - '@tanstack/store': 0.7.0 - react: 19.0.0 - react-dom: 18.3.1(react@19.0.0) - use-sync-external-store: 1.5.0(react@19.0.0) - '@tanstack/router-core@1.120.5': dependencies: '@tanstack/history': 1.115.0 @@ -10114,7 +10101,7 @@ snapshots: prosemirror-transform: 1.10.4 prosemirror-view: 1.39.3 - '@tiptap/react@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': + '@tiptap/react@2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) '@tiptap/extension-bubble-menu': 2.12.0(@tiptap/core@2.12.0(@tiptap/pm@2.12.0))(@tiptap/pm@2.12.0) @@ -10122,9 +10109,9 @@ snapshots: '@tiptap/pm': 2.12.0 '@types/use-sync-external-store': 0.0.6 fast-deep-equal: 3.1.3 - react: 19.0.0 - react-dom: 18.3.1(react@19.0.0) - use-sync-external-store: 1.5.0(react@19.0.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.5.0(react@18.3.1) '@tiptap/starter-kit@2.12.0': dependencies: @@ -10568,14 +10555,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.21))': + '@vitest/mocker@3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.1.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.8.4(@types/node@22.15.21)(typescript@5.8.3) - vite: 5.4.19(@types/node@22.15.21) + vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -12031,7 +12018,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.3.4 + debug: 4.4.1(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -12282,7 +12269,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.3.4 + debug: 4.4.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12329,7 +12316,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.0.8 + minimatch: 3.1.2 once: 1.4.0 path-is-absolute: 1.0.1 @@ -13007,9 +12994,9 @@ snapshots: dependencies: react: 18.3.1 - lucide-react@0.511.0(react@19.0.0): + lucide-react@0.511.0(react@18.3.1): dependencies: - react: 19.0.0 + react: 18.3.1 lunr@2.3.9: {} @@ -13479,7 +13466,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.3.4 + debug: 4.4.1(supports-color@8.1.1) get-uri: 6.0.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -13807,7 +13794,7 @@ snapshots: proxy-agent@6.3.1: dependencies: agent-base: 7.1.3 - debug: 4.3.4 + debug: 4.4.1(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -13875,12 +13862,6 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-dom@18.3.1(react@19.0.0): - dependencies: - loose-envify: 1.4.0 - react: 19.0.0 - scheduler: 0.23.2 - react-draggable@4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 1.2.1 @@ -14360,7 +14341,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.3.4 + debug: 4.4.1(supports-color@8.1.1) socks: 2.8.4 transitivePeerDependencies: - supports-color @@ -15053,10 +15034,6 @@ snapshots: dependencies: react: 18.3.1 - use-sync-external-store@1.5.0(react@19.0.0): - dependencies: - react: 19.0.0 - userhome@1.0.1: {} util-deprecate@1.0.2: {} @@ -15088,15 +15065,16 @@ snapshots: vite: 5.4.19(@types/node@22.15.21) watchpack: 2.4.4 - vite-node@3.1.4(@types/node@22.15.21): + vite-node@3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.19(@types/node@22.15.21) + vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -15105,6 +15083,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vite-plugin-posthog@2.1.0(@types/react@18.3.22)(react@18.3.1): dependencies: @@ -15187,10 +15167,10 @@ snapshots: - universal-cookie - yaml - vitest@3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3)): + vitest@3.1.4(@types/debug@4.1.12)(@types/node@22.15.21)(jiti@2.4.2)(jsdom@25.0.1)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4)(yaml@2.8.0): dependencies: '@vitest/expect': 3.1.4 - '@vitest/mocker': 3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@5.4.19(@types/node@22.15.21)) + '@vitest/mocker': 3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0)) '@vitest/pretty-format': 3.1.4 '@vitest/runner': 3.1.4 '@vitest/snapshot': 3.1.4 @@ -15207,14 +15187,15 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 5.4.19(@types/node@22.15.21) - vite-node: 3.1.4(@types/node@22.15.21) + vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) + vite-node: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.15.21 jsdom: 25.0.1 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -15224,6 +15205,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml vue-flow-layout@0.1.1(vue@3.5.14(typescript@5.8.3)): dependencies: