From 2f00a136be76c498ee37ffd5579de07db494aecb Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 00:41:36 +0700 Subject: [PATCH] feat: implement pro entitlement activation and updater gating --- docs/commercial/checkout-migration.md | 8 + landing/README.md | 9 +- landing/api/pro/verify.js | 98 +++++++++++ landing/index.html | 7 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/pro.rs | 154 ++++++++++++++++++ src-tauri/src/lib.rs | 8 +- src-tauri/src/settings.rs | 24 +++ src-tauri/src/shortcut/mod.rs | 4 + src-tauri/src/tray.rs | 2 +- .../settings/UpdateChecksToggle.tsx | 37 ++++- .../settings/about/AboutSettings.tsx | 106 +++++++++++- .../update-checker/UpdateChecker.tsx | 5 +- src/hooks/useProEntitlement.ts | 13 ++ src/i18n/locales/en/translation.json | 15 +- src/stores/proEntitlementStore.ts | 87 ++++++++++ src/utils/proEntitlement.ts | 32 ++++ 17 files changed, 588 insertions(+), 22 deletions(-) create mode 100644 landing/api/pro/verify.js create mode 100644 src-tauri/src/commands/pro.rs create mode 100644 src/hooks/useProEntitlement.ts create mode 100644 src/stores/proEntitlementStore.ts create mode 100644 src/utils/proEntitlement.ts diff --git a/docs/commercial/checkout-migration.md b/docs/commercial/checkout-migration.md index 79b38ea..e82864c 100644 --- a/docs/commercial/checkout-migration.md +++ b/docs/commercial/checkout-migration.md @@ -42,6 +42,13 @@ Entitlement grants: - Auto-update eligibility - Priority support queue (if enabled) +Activation flow in app: + +- User opens **Settings -> About -> Upgrade to Dictx Pro** +- User enters purchase email + Polar checkout ID (`polar_cl_...`) +- App verifies against `https://dictx.splitlabs.io/api/pro/verify` +- On success, app stores active entitlement and enables updater checks + ## 3) Webhook Processing Use a webhook endpoint to sync purchases to your entitlement store. @@ -84,6 +91,7 @@ Before launch: - Checkout success flow creates receipt + customer record - Webhook signature validation works in production - `dictx_pro` entitlement is granted/revoked correctly +- `landing/api/pro/verify` returns `{ active: true }` only for valid paid checkout + matching email - Customer portal access works from receipt email - Purchase links from app + README resolve to `https://dictx.splitlabs.io/buy` diff --git a/landing/README.md b/landing/README.md index b0175cc..c7752d3 100644 --- a/landing/README.md +++ b/landing/README.md @@ -16,4 +16,11 @@ Attach `dictx.splitlabs.io` to this Vercel project. ## Routes - `/` serves `landing/index.html` -- `/buy` redirects to `https://buy.splitlabs.io` +- `/buy` redirects to Polar checkout +- `/api/pro/verify` validates a Polar checkout/email pair for in-app Pro activation + +## Environment Variables (Vercel) + +- `POLAR_ACCESS_TOKEN`: Polar API token +- `POLAR_DICTX_PRODUCT_IDS`: optional comma-separated product IDs allowed for Dictx Pro activation +- `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`) diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js new file mode 100644 index 0000000..5e684be --- /dev/null +++ b/landing/api/pro/verify.js @@ -0,0 +1,98 @@ +const POLAR_API_BASE = process.env.POLAR_API_BASE || "https://api.polar.sh/v1"; +const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN || ""; +const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + +const normalizeEmail = (value) => value.trim().toLowerCase(); + +const readBody = (req) => { + if (!req.body) return {}; + if (typeof req.body === "string") { + try { + return JSON.parse(req.body); + } catch (_error) { + return {}; + } + } + return req.body; +}; + +const statusLooksPaid = (status, paid) => { + if (paid === true) return true; + const value = (status || "").toLowerCase(); + return ( + value === "succeeded" || + value === "confirmed" || + value === "paid" || + value === "completed" + ); +}; + +module.exports = async (req, res) => { + if (req.method !== "POST") { + res.status(405).json({ error: "method_not_allowed" }); + return; + } + + if (!POLAR_ACCESS_TOKEN) { + res.status(500).json({ error: "missing_polar_access_token" }); + return; + } + + const body = readBody(req); + const checkoutId = (body.checkoutId || "").trim(); + const email = normalizeEmail(body.email || ""); + + if (!checkoutId || !email) { + res.status(400).json({ error: "checkoutId_and_email_required" }); + return; + } + + try { + const response = await fetch( + `${POLAR_API_BASE}/checkouts/${encodeURIComponent(checkoutId)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`, + "Content-Type": "application/json", + }, + }, + ); + + if (response.status === 404) { + res.status(404).json({ active: false }); + return; + } + + if (!response.ok) { + const text = await response.text(); + res.status(502).json({ error: "polar_api_error", detail: text }); + return; + } + + const checkout = await response.json(); + const checkoutEmail = normalizeEmail( + checkout.customer_email || + checkout.customer?.email || + checkout.metadata?.customer_email || + "", + ); + + const productId = + checkout.product_id == null ? "" : String(checkout.product_id); + const productAllowed = + ALLOWED_PRODUCT_IDS.length === 0 || ALLOWED_PRODUCT_IDS.includes(productId); + const emailMatches = checkoutEmail !== "" && checkoutEmail === email; + const paid = statusLooksPaid(checkout.status, checkout.paid); + + res.status(200).json({ active: Boolean(productAllowed && emailMatches && paid) }); + } catch (error) { + res.status(500).json({ + error: "internal_error", + detail: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/landing/index.html b/landing/index.html index a972324..526fb91 100644 --- a/landing/index.html +++ b/landing/index.html @@ -198,6 +198,13 @@

FAQ

workflow.

+
+ How do I activate Pro inside the app? +

+ Open Settings, go to About, and enter your purchase email and + Polar checkout ID (starts with polar_cl_). +

+
Where does /buy go?

diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 015c60b..127d16b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod audio; pub mod history; pub mod models; +pub mod pro; pub mod transcription; use crate::settings::{get_settings, write_settings, AppSettings, LogLevel}; diff --git a/src-tauri/src/commands/pro.rs b/src-tauri/src/commands/pro.rs new file mode 100644 index 0000000..c6dad03 --- /dev/null +++ b/src-tauri/src/commands/pro.rs @@ -0,0 +1,154 @@ +use crate::settings::{self, ProEntitlement}; +use chrono::Utc; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +const DEFAULT_PRO_VERIFY_URL: &str = "https://dictx.splitlabs.io/api/pro/verify"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifyRequest { + checkout_id: String, + email: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerifyResponse { + active: bool, +} + +fn get_verify_url() -> String { + std::env::var("DICTX_PRO_VERIFY_URL").unwrap_or_else(|_| DEFAULT_PRO_VERIFY_URL.to_string()) +} + +fn normalize_email(email: &str) -> String { + email.trim().to_lowercase() +} + +async fn verify_checkout(checkout_id: &str, email: &str) -> Result { + let payload = VerifyRequest { + checkout_id: checkout_id.trim().to_string(), + email: normalize_email(email), + }; + + if payload.checkout_id.is_empty() || payload.email.is_empty() { + return Err("Checkout ID and email are required".to_string()); + } + + let client = reqwest::Client::new(); + let response = client + .post(get_verify_url()) + .json(&payload) + .send() + .await + .map_err(|e| format!("Verification request failed: {}", e))?; + + if response.status() == StatusCode::OK { + let body: VerifyResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse verification response: {}", e))?; + Ok(body.active) + } else if response.status() == StatusCode::UNAUTHORIZED + || response.status() == StatusCode::NOT_FOUND + { + Ok(false) + } else { + let status = response.status(); + let text = response + .text() + .await + .unwrap_or_else(|_| "unknown error".to_string()); + Err(format!("Verification API error ({}): {}", status, text)) + } +} + +#[tauri::command] +#[specta::specta] +pub fn get_pro_entitlement(app: AppHandle) -> Result { + let settings = settings::get_settings(&app); + Ok(settings.pro_entitlement) +} + +#[tauri::command] +#[specta::specta] +pub fn clear_pro_entitlement(app: AppHandle) -> Result { + let mut app_settings = settings::get_settings(&app); + app_settings.pro_entitlement = ProEntitlement::default(); + app_settings.update_checks_enabled = false; + settings::write_settings(&app, app_settings.clone()); + crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); + Ok(app_settings.pro_entitlement) +} + +#[tauri::command] +#[specta::specta] +pub async fn activate_pro_entitlement( + app: AppHandle, + checkout_id: String, + email: String, +) -> Result { + let active = verify_checkout(&checkout_id, &email).await?; + if !active { + return Err("No active Dictx Pro entitlement found for this checkout/email".to_string()); + } + + let now = Utc::now().timestamp(); + let mut app_settings = settings::get_settings(&app); + app_settings.pro_entitlement = ProEntitlement { + active: true, + email: Some(normalize_email(&email)), + checkout_id: Some(checkout_id.trim().to_string()), + activated_at: Some(now), + last_verified_at: Some(now), + verification_error: None, + }; + app_settings.update_checks_enabled = true; + settings::write_settings(&app, app_settings.clone()); + crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); + + Ok(app_settings.pro_entitlement) +} + +#[tauri::command] +#[specta::specta] +pub async fn refresh_pro_entitlement(app: AppHandle) -> Result { + let mut app_settings = settings::get_settings(&app); + + let checkout_id = match app_settings.pro_entitlement.checkout_id.clone() { + Some(value) if !value.trim().is_empty() => value, + _ => return Ok(app_settings.pro_entitlement), + }; + + let email = match app_settings.pro_entitlement.email.clone() { + Some(value) if !value.trim().is_empty() => value, + _ => return Ok(app_settings.pro_entitlement), + }; + + let now = Utc::now().timestamp(); + match verify_checkout(&checkout_id, &email).await { + Ok(true) => { + app_settings.pro_entitlement.active = true; + app_settings.pro_entitlement.last_verified_at = Some(now); + app_settings.pro_entitlement.verification_error = None; + } + Ok(false) => { + app_settings.pro_entitlement.active = false; + app_settings.pro_entitlement.last_verified_at = Some(now); + app_settings.pro_entitlement.verification_error = + Some("Entitlement no longer active".to_string()); + app_settings.update_checks_enabled = false; + } + Err(err) => { + app_settings.pro_entitlement.verification_error = Some(err.clone()); + settings::write_settings(&app, app_settings.clone()); + return Err(err); + } + } + + settings::write_settings(&app, app_settings.clone()); + crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None); + Ok(app_settings.pro_entitlement) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bed808f..45c4add 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,7 +178,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { } "check_updates" => { let settings = settings::get_settings(app); - if settings.update_checks_enabled { + if settings.update_checks_enabled && settings.pro_entitlement.active { show_main_window(app); let _ = app.emit("check-for-updates", ()); } @@ -250,7 +250,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { #[specta::specta] fn trigger_update_check(app: AppHandle) -> Result<(), String> { let settings = settings::get_settings(&app); - if !settings.update_checks_enabled { + if !settings.update_checks_enabled || !settings.pro_entitlement.active { return Ok(()); } app.emit("check-for-updates", ()) @@ -358,6 +358,10 @@ pub fn run(cli_args: CliArgs) { commands::history::delete_history_entry, commands::history::update_history_limit, commands::history::update_recording_retention_period, + commands::pro::get_pro_entitlement, + commands::pro::activate_pro_entitlement, + commands::pro::refresh_pro_entitlement, + commands::pro::clear_pro_entitlement, helpers::clamshell::is_laptop, ]); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 514c025..3265f1b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -275,6 +275,22 @@ impl Default for TypingTool { } } +#[derive(Serialize, Deserialize, Debug, Clone, Type, Default)] +pub struct ProEntitlement { + #[serde(default)] + pub active: bool, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub checkout_id: Option, + #[serde(default)] + pub activated_at: Option, + #[serde(default)] + pub last_verified_at: Option, + #[serde(default)] + pub verification_error: Option, +} + /* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone, Type)] pub struct AppSettings { @@ -368,6 +384,8 @@ pub struct AppSettings { pub obsidian_export_subfolder: Option, #[serde(default)] pub obsidian_append_to_daily: bool, + #[serde(default)] + pub pro_entitlement: ProEntitlement, } fn default_model() -> String { @@ -740,6 +758,7 @@ pub fn get_default_settings() -> AppSettings { obsidian_vault_path: None, obsidian_export_subfolder: default_obsidian_export_subfolder(), obsidian_append_to_daily: false, + pro_entitlement: ProEntitlement::default(), } } @@ -868,6 +887,11 @@ pub fn get_history_limit(app: &AppHandle) -> usize { settings.history_limit } +pub fn is_pro_active(app: &AppHandle) -> bool { + let settings = get_settings(app); + settings.pro_entitlement.active +} + pub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod { let settings = get_settings(app); settings.recording_retention_period diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 84de4b5..52b9203 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -622,6 +622,10 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str #[tauri::command] #[specta::specta] pub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + if enabled && !settings::is_pro_active(&app) { + return Err("Dictx Pro activation is required for auto-updates".to_string()); + } + let mut settings = settings::get_settings(&app); settings.update_checks_enabled = enabled; settings::write_settings(&app, settings); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 407b7b6..5a7706d 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -112,7 +112,7 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& app, "check_updates", &strings.check_updates, - settings.update_checks_enabled, + settings.update_checks_enabled && settings.pro_entitlement.active, None::<&str>, ) .expect("failed to create check updates item"); diff --git a/src/components/settings/UpdateChecksToggle.tsx b/src/components/settings/UpdateChecksToggle.tsx index 675dc3a..bb06155 100644 --- a/src/components/settings/UpdateChecksToggle.tsx +++ b/src/components/settings/UpdateChecksToggle.tsx @@ -2,6 +2,9 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; +import { useProEntitlement } from "@/hooks/useProEntitlement"; +import { openProPurchasePage } from "@/utils/commerce"; +import { Button } from "../ui/Button"; interface UpdateChecksToggleProps { descriptionMode?: "inline" | "tooltip"; @@ -14,17 +17,33 @@ export const UpdateChecksToggle: React.FC = ({ }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); + const { entitlement } = useProEntitlement(); const updateChecksEnabled = getSetting("update_checks_enabled") ?? true; + const proActive = entitlement?.active ?? false; return ( - updateSetting("update_checks_enabled", enabled)} - isUpdating={isUpdating("update_checks_enabled")} - label={t("settings.debug.updateChecks.label")} - description={t("settings.debug.updateChecks.description")} - descriptionMode={descriptionMode} - grouped={grouped} - /> +

+ updateSetting("update_checks_enabled", enabled)} + isUpdating={isUpdating("update_checks_enabled")} + label={t("settings.debug.updateChecks.label")} + description={ + proActive + ? t("settings.debug.updateChecks.description") + : t("settings.debug.updateChecks.proRequired") + } + descriptionMode={descriptionMode} + grouped={grouped} + disabled={!proActive} + /> + {!proActive && ( +
+ +
+ )} +
); }; diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index d94b6d2..3e0e3d6 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -5,14 +5,27 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { SettingsGroup } from "../../ui/SettingsGroup"; import { SettingContainer } from "../../ui/SettingContainer"; import { Button } from "../../ui/Button"; +import { Input } from "../../ui/Input"; import { openProPurchasePage } from "@/utils/commerce"; import { AppDataDirectory } from "../AppDataDirectory"; import { AppLanguageSelector } from "../AppLanguageSelector"; import { LogDirectory } from "../debug"; +import { useProEntitlement } from "@/hooks/useProEntitlement"; export const AboutSettings: React.FC = () => { const { t } = useTranslation(); const [version, setVersion] = useState(""); + const [checkoutId, setCheckoutId] = useState(""); + const [email, setEmail] = useState(""); + const { + entitlement, + isLoading: proLoading, + isSubmitting: proSubmitting, + error: proError, + activate, + refresh, + clear, + } = useProEntitlement(); useEffect(() => { const fetchVersion = async () => { @@ -28,6 +41,13 @@ export const AboutSettings: React.FC = () => { fetchVersion(); }, []); + const handleActivate = async () => { + const ok = await activate(checkoutId, email); + if (ok) { + setCheckoutId(""); + } + }; + return (
@@ -45,14 +65,86 @@ export const AboutSettings: React.FC = () => { title={t("settings.about.supportDevelopment.title")} description={t("settings.about.supportDevelopment.description")} grouped={true} + layout="stacked" > - +
+
+ + + {entitlement?.active && ( + + )} +
+ +
+

+ {entitlement?.active + ? t("settings.about.proActivation.active") + : t("settings.about.proActivation.inactive")} +

+ {entitlement?.email && ( +

+ {t("settings.about.proActivation.email")}: {entitlement.email} +

+ )} + {entitlement?.checkout_id && ( +

+ {t("settings.about.proActivation.checkoutId")}:{" "} + {entitlement.checkout_id} +

+ )} +
+ setEmail(event.target.value)} + placeholder={t("settings.about.proActivation.emailPlaceholder")} + disabled={proSubmitting} + /> + setCheckoutId(event.target.value)} + placeholder={t( + "settings.about.proActivation.checkoutIdPlaceholder", + )} + disabled={proSubmitting} + /> +
+ + {proError &&

{proError}

} + {entitlement?.verification_error && ( +

+ {entitlement.verification_error} +

+ )} +
+
= ({ className = "" }) => { const [showUpToDate, setShowUpToDate] = useState(false); const { settings, isLoading } = useSettings(); + const { entitlement } = useProEntitlement(); const settingsLoaded = !isLoading && settings !== null; - const updateChecksEnabled = settings?.update_checks_enabled ?? false; + const proActive = entitlement?.active ?? false; + const updateChecksEnabled = (settings?.update_checks_enabled ?? false) && proActive; const upToDateTimeoutRef = useRef>(); const isManualCheckRef = useRef(false); diff --git a/src/hooks/useProEntitlement.ts b/src/hooks/useProEntitlement.ts new file mode 100644 index 0000000..36ac9ac --- /dev/null +++ b/src/hooks/useProEntitlement.ts @@ -0,0 +1,13 @@ +import { useEffect } from "react"; +import { useProEntitlementStore } from "@/stores/proEntitlementStore"; + +export const useProEntitlement = () => { + const initialize = useProEntitlementStore((state) => state.initialize); + const store = useProEntitlementStore(); + + useEffect(() => { + void initialize(); + }, [initialize]); + + return store; +}; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 24e6288..be6e8e7 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -457,7 +457,9 @@ }, "updateChecks": { "label": "Check for Updates", - "description": "Automatically check for new versions of Dictx" + "description": "Automatically check for new versions of Dictx", + "proRequired": "Auto-updates are available for active Dictx Pro installations.", + "upgrade": "Activate or Upgrade Pro" }, "soundTheme": { "label": "Sound Theme", @@ -537,6 +539,17 @@ "description": "One-time purchase for signed builds, in-app auto-updates, and priority support.", "button": "Upgrade Now" }, + "proActivation": { + "active": "Dictx Pro is active on this device.", + "inactive": "Dictx Pro is not active. Enter your purchase email and checkout ID to activate updater entitlement.", + "activate": "Activate Dictx Pro", + "refresh": "Re-check Entitlement", + "clear": "Remove Activation", + "email": "Email", + "checkoutId": "Checkout ID", + "emailPlaceholder": "Purchase email", + "checkoutIdPlaceholder": "polar_cl_..." + }, "acknowledgments": { "title": "Acknowledgments", "whisper": { diff --git a/src/stores/proEntitlementStore.ts b/src/stores/proEntitlementStore.ts new file mode 100644 index 0000000..5c1db6e --- /dev/null +++ b/src/stores/proEntitlementStore.ts @@ -0,0 +1,87 @@ +import { create } from "zustand"; +import { + activateProEntitlement, + clearProEntitlement, + getProEntitlement, + refreshProEntitlement, + type ProEntitlement, +} from "@/utils/proEntitlement"; + +interface ProEntitlementStore { + entitlement: ProEntitlement | null; + isLoading: boolean; + isSubmitting: boolean; + error: string | null; + initialize: () => Promise; + activate: (checkoutId: string, email: string) => Promise; + refresh: () => Promise; + clear: () => Promise; +} + +export const useProEntitlementStore = create( + (set, get) => ({ + entitlement: null, + isLoading: true, + isSubmitting: false, + error: null, + + initialize: async () => { + if (!get().isLoading) { + return; + } + try { + let entitlement = await getProEntitlement(); + if (entitlement.checkout_id && entitlement.email) { + try { + entitlement = await refreshProEntitlement(); + } catch (_error) { + // Keep last known state if background verification fails. + } + } + set({ entitlement, error: null }); + } catch (error) { + set({ error: error instanceof Error ? error.message : String(error) }); + } finally { + set({ isLoading: false }); + } + }, + + activate: async (checkoutId: string, email: string) => { + set({ isSubmitting: true, error: null }); + try { + const entitlement = await activateProEntitlement(checkoutId, email); + set({ entitlement }); + return true; + } catch (error) { + set({ error: error instanceof Error ? error.message : String(error) }); + return false; + } finally { + set({ isSubmitting: false }); + } + }, + + refresh: async () => { + set({ isSubmitting: true, error: null }); + try { + const entitlement = await refreshProEntitlement(); + set({ entitlement }); + } catch (error) { + set({ error: error instanceof Error ? error.message : String(error) }); + } finally { + set({ isSubmitting: false }); + } + }, + + clear: async () => { + set({ isSubmitting: true, error: null }); + try { + const entitlement = await clearProEntitlement(); + set({ entitlement }); + } catch (error) { + set({ error: error instanceof Error ? error.message : String(error) }); + } finally { + set({ isSubmitting: false }); + } + }, + }), +); diff --git a/src/utils/proEntitlement.ts b/src/utils/proEntitlement.ts new file mode 100644 index 0000000..20ee27b --- /dev/null +++ b/src/utils/proEntitlement.ts @@ -0,0 +1,32 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface ProEntitlement { + active: boolean; + email?: string | null; + checkout_id?: string | null; + activated_at?: number | null; + last_verified_at?: number | null; + verification_error?: string | null; +} + +export const getProEntitlement = async (): Promise => { + return await invoke("get_pro_entitlement"); +}; + +export const activateProEntitlement = async ( + checkoutId: string, + email: string, +): Promise => { + return await invoke("activate_pro_entitlement", { + checkoutId, + email, + }); +}; + +export const refreshProEntitlement = async (): Promise => { + return await invoke("refresh_pro_entitlement"); +}; + +export const clearProEntitlement = async (): Promise => { + return await invoke("clear_pro_entitlement"); +};