Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/members/panels/MembersCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Icon } from "@iconify/react";
import { useTranslation } from "react-i18next";
import { openBillingCheckout } from "@/services/billingCheckout";
import { TEAMS_TRIAL_DAYS } from "@/services/billingTrial";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import { AvailableWithPlan } from "@/components/shared/AvailableWithPlan";

export function SignInToCloudCTA({ onSignIn }: { onSignIn: () => void }) {
const { t } = useTranslation();
Expand Down Expand Up @@ -44,6 +46,7 @@ export function UpgradeToTeamsCTA() {
{t("members.cta.upgradeDesc")}
</p>
</div>
{!CAN_OPEN_CHECKOUT ? <AvailableWithPlan plan="Teams" /> : (<>
<button
onClick={() => void openCheckout()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
Expand All @@ -52,6 +55,7 @@ export function UpgradeToTeamsCTA() {
{t("members.cta.upgradeBtn", { days: TEAMS_TRIAL_DAYS })}
</button>
<p className="-mt-3 text-xs text-(--t-text-dim)">{t("members.cta.trialNote")}</p>
</>)}
</div>
);
}
10 changes: 7 additions & 3 deletions src/components/mobile/screens/MobileAccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { getCurrentUserEmail, logout } from "@/services/account";
import { openBillingCheckout } from "@/services/billingCheckout";
import { TEAMS_TRIAL_DAYS } from "@/services/billingTrial";
import { openPortal } from "@/utils/billing";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import { AvailableWithPlan } from "@/components/shared/AvailableWithPlan";
import { useServerBillingEnabled } from "@/services/serverCapabilities";
import { useSeatReadout } from "@/hooks/useSeatReadout";
import { seatsUsedText } from "@/services/seatMath";
Expand Down Expand Up @@ -115,7 +117,9 @@ export default function MobileAccountPage() {
</div>
)}

{!isPro && (
{!isPro && !CAN_OPEN_CHECKOUT && <AvailableWithPlan plan="Pro" />}

{!isPro && CAN_OPEN_CHECKOUT && (
<button
data-account-upgrade-pro
disabled={checkoutBusy}
Expand All @@ -127,7 +131,7 @@ export default function MobileAccountPage() {
</button>
)}

{isPro && !isTeams && (
{isPro && !isTeams && CAN_OPEN_CHECKOUT && (
<>
<button
data-account-upgrade-teams
Expand All @@ -142,7 +146,7 @@ export default function MobileAccountPage() {
</>
)}

{billingEnabled && (
{billingEnabled && CAN_OPEN_CHECKOUT && (
<button
data-account-manage-billing
onClick={() => void openPortal()}
Expand Down
7 changes: 7 additions & 0 deletions src/components/shared/AvailableWithPlan.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useTranslation } from "react-i18next";

/** Stands in for a purchase button in builds that may not link to checkout. */
export function AvailableWithPlan({ plan, className = "" }: { plan: "Pro" | "Teams" | "Business"; className?: string }) {
const { t } = useTranslation();
return <p className={`text-xs text-center text-(--t-text-dim) ${className}`}>{t("shared.availableWith", { plan })}</p>;
}
4 changes: 4 additions & 0 deletions src/components/shared/BusinessLockBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Icon } from "@iconify/react";
import { useTranslation } from "react-i18next";
import { useBusinessLock } from "@/hooks/useBusinessLock";
import { openBillingCheckout } from "@/services/billingCheckout";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";

export function UpgradeAction({ teamId, look }: { teamId: string; look: "button" | "link" }) {
const { t } = useTranslation();
Expand All @@ -12,6 +13,9 @@ export function UpgradeAction({ teamId, look }: { teamId: string; look: "button"
return look === "link" ? null : <p className="text-xs text-(--t-text-dim)">{t("shared.businessLock.ownerOnly")}</p>;
}
if (isOwner !== true) return null;
if (!CAN_OPEN_CHECKOUT) {
return look === "link" ? null : <p className="text-xs text-(--t-text-dim)">{t("shared.availableWith", { plan: "Business" })}</p>;
}
if (look === "link") {
return <button type="button" onClick={upgrade} className="text-xs text-(--t-accent)">{t("shared.businessLock.upgrade")}</button>;
}
Expand Down
6 changes: 6 additions & 0 deletions src/components/shared/TrialExpiredModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { UpgradeStrip } from "@/components/shared/UpgradeStrip";
import { useSubscriptionStore } from "@/stores/subscriptionStore";
import { openBillingCheckout } from "@/services/billingCheckout";
import { TEAMS_TRIAL_DAYS } from "@/services/billingTrial";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import { AvailableWithPlan } from "@/components/shared/AvailableWithPlan";

const STORAGE_KEY = "voltius_trial_expired_shown";

Expand Down Expand Up @@ -65,20 +67,24 @@ export function TrialExpiredModal() {
{t("shared.trialExpiredModal.freeNote")}
</p>

{CAN_OPEN_CHECKOUT && (
<UpgradeStrip
variant="neutral"
label={t("shared.trialExpiredModal.teamsPrompt", { days: TEAMS_TRIAL_DAYS })}
buttonLabel={t("settings.account.plan.teamsButton")}
onClick={() => upgrade("teams")}
/>
)}

<div className="flex items-center justify-between gap-3">
{CAN_OPEN_CHECKOUT ? (
<button
onClick={() => upgrade("pro")}
className="btn btn-primary px-4 py-2 rounded-lg text-sm font-semibold"
>
{t("shared.trialExpiredModal.upgradeButton")}
</button>
) : <AvailableWithPlan plan="Pro" className="text-left" />}
<button
onClick={() => setVisible(false)}
className="text-xs text-(--t-text-dim) hover:text-(--t-text-primary) transition-colors"
Expand Down
4 changes: 4 additions & 0 deletions src/components/shared/VaultLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useSubscriptionStore } from "@/stores/subscriptionStore";
import { useVaultStore } from "@/stores/vaultStore";
import { useUIStore } from "@/stores/uiStore";
import { openBillingCheckout } from "@/services/billingCheckout";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import { AvailableWithPlan } from "@/components/shared/AvailableWithPlan";

/** Free accounts keep the one vault they start with; more vaults are a Pro feature. */
export function useVaultLimitReached(): boolean {
Expand Down Expand Up @@ -50,12 +52,14 @@ export function VaultLimitModal({ onClose }: { onClose: () => void }) {
</div>

<div className="flex flex-col gap-2">
{isCloudAccount && !CAN_OPEN_CHECKOUT ? <AvailableWithPlan plan="Pro" className="py-2" /> : (
<button
onClick={isCloudAccount ? () => void upgrade() : signIn}
className="w-full py-2.5 rounded-lg text-sm font-semibold bg-(--t-accent) text-white hover:opacity-90 transition-opacity"
>
{isCloudAccount ? t("layout.vaultSidebar.upgradeToPro") : t("layout.vaultSidebar.signInOrCreate")}
</button>
)}
<button
onClick={onClose}
className="w-full py-2.5 rounded-lg text-sm text-(--t-text-muted) hover:text-(--t-text-primary) transition-colors"
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/cs/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"memberLine": "Oprávnění pro jednotlivé členy vyžadují Business.",
"objectLine": "Oprávnění pro jednotlivé objekty vyžadují Business.",
"removeOverrides": "Odebrat přepsání"
}
},
"availableWith": "K dispozici s {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/en/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"memberLine": "Per-member permissions need Business.",
"objectLine": "Per-object permissions need Business.",
"removeOverrides": "Remove overrides"
}
},
"availableWith": "Available with {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/fr/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"memberLine": "Les permissions par membre nécessitent le forfait Business.",
"objectLine": "Les permissions par objet nécessitent le forfait Business.",
"removeOverrides": "Supprimer les dérogations"
}
},
"availableWith": "Disponible avec {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/pt-PT/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"failed": "Não foi possível eliminar a sua conta (erro {{status}}). Tente novamente mais tarde ou contacte o suporte.",
"ownsTeamWithMembers": "É o único proprietário de uma equipa que ainda tem outros membros ({{teams}}). Torne outro membro proprietário, remova os membros ou elimine a equipa e tente novamente.",
"subscriptionCancelFailed": "Não foi possível cancelar a sua subscrição, por isso a sua conta não foi eliminada. Tente novamente dentro de alguns minutos ou contacte o suporte."
}
},
"availableWith": "Disponível com {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/pt/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"failed": "Não foi possível excluir sua conta (erro {{status}}). Tente novamente mais tarde ou entre em contato com o suporte.",
"ownsTeamWithMembers": "Você é o único proprietário de uma equipe que ainda tem outros membros ({{teams}}). Torne outro membro proprietário, remova os membros ou exclua a equipe e tente novamente.",
"subscriptionCancelFailed": "Não foi possível cancelar sua assinatura, então sua conta não foi excluída. Tente novamente em alguns minutos ou entre em contato com o suporte."
}
},
"availableWith": "Disponível com {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/ru/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"memberLine": "Права отдельных участников доступны на тарифе Business.",
"objectLine": "Права отдельных объектов доступны на тарифе Business.",
"removeOverrides": "Удалить исключения"
}
},
"availableWith": "Доступно в {{plan}}"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/tr/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"memberLine": "Üye bazlı izinler Business planını gerektirir.",
"objectLine": "Nesne bazlı izinler Business planını gerektirir.",
"removeOverrides": "Geçersiz kılmaları kaldır"
}
},
"availableWith": "{{plan}} ile kullanılabilir"
}
}
3 changes: 2 additions & 1 deletion src/i18n/locales/zh/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"memberLine": "按成员的权限需要 Business 套餐。",
"objectLine": "按对象的权限需要 Business 套餐。",
"removeOverrides": "移除覆盖设置"
}
},
"availableWith": "{{plan}} 可用"
}
}
2 changes: 2 additions & 0 deletions src/services/billingCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { invoke } from "@/lib/invoke";
import { openUrl } from "@tauri-apps/plugin-opener";
import { appFetch } from "@/services/http";
import { checkoutRequiresEmailVerification } from "@/utils/emailVerification";
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";

export const EMAIL_VERIFICATION_REQUIRED_EVENT = "voltius:email-verification-required";

Expand All @@ -22,6 +23,7 @@ async function readResponseBody(res: Response): Promise<unknown> {
}

export async function openBillingCheckout(plan: BillingPlan, options: CheckoutOptions = {}): Promise<boolean> {
if (!CAN_OPEN_CHECKOUT) return false;
const [serverUrl, jwt] = await Promise.all([
invoke<string | null>("keychain_get", { key: "server_url" }),
invoke<string | null>("keychain_get", { key: "jwt" }),
Expand Down
53 changes: 53 additions & 0 deletions src/services/distribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";

const invoke = vi.hoisted(() => vi.fn());
const openUrl = vi.hoisted(() => vi.fn());
vi.mock("@/lib/invoke", () => ({ invoke }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl }));

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
invoke.mockReset();
openUrl.mockReset();
});

describe("Google Play build", () => {
it("opens neither checkout nor the billing portal", async () => {
vi.stubEnv("VITE_DISTRIBUTION", "play");
const { openBillingCheckout } = await import("@/services/billingCheckout");
const { openPortal } = await import("@/utils/billing");

expect(await openBillingCheckout("pro")).toBe(false);
await openPortal();

expect(invoke).not.toHaveBeenCalled();
expect(openUrl).not.toHaveBeenCalled();
});

it("offers no Voltius sync upgrade action", async () => {
vi.stubEnv("VITE_DISTRIBUTION", "play");
const { buildSyncProviders } = await import("@/services/syncProviders");
const [voltius] = buildSyncProviders({
voltius: {
label: "Voltius Sync",
state: { status: "idle", lastSync: null, error: null, blobSizeBytes: null },
accountMode: "server",
isPro: false,
paused: false,
syncNow: vi.fn(async () => {}),
},
plugins: [],
settingsPages: [],
});
expect(voltius).toMatchObject({ availability: "needs_upgrade", action: null });
});
});

describe("other builds", () => {
it("keep checkout", async () => {
const { CAN_OPEN_CHECKOUT } = await import("@/services/distribution");
expect(CAN_OPEN_CHECKOUT).toBe(true);
});
});
6 changes: 6 additions & 0 deletions src/services/distribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Google Play builds (VITE_DISTRIBUTION=play) must not send users to an outside
* payment page, so they show no purchase or billing-portal buttons. Plans are
* bought on the website or from the desktop app; signing in unlocks them here.
*/
export const CAN_OPEN_CHECKOUT: boolean = import.meta.env.VITE_DISTRIBUTION !== "play";
3 changes: 2 additions & 1 deletion src/services/syncProviders.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import type { PluginManifest, SettingsPage, SyncProviderAvailability, SyncProviderSummary } from "@/plugins/api";
import type { MarketplacePlugin } from "@/stores/marketplaceStore";
import { attributePage } from "@/plugins/attributePage";
Expand Down Expand Up @@ -69,7 +70,7 @@ const STATUS_RANK: Record<SyncStatus, number> = { idle: 0, success: 1, syncing:

const VOLTIUS_ACTIONS: Partial<Record<SyncProviderAvailability, SyncProviderAction>> = {
locked: { kind: "signIn" },
needs_upgrade: { kind: "upgrade" },
...(CAN_OPEN_CHECKOUT ? { needs_upgrade: { kind: "upgrade" } as const } : {}),
paused: { kind: "resume" },
};

Expand Down
2 changes: 2 additions & 0 deletions src/utils/billing.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { CAN_OPEN_CHECKOUT } from "@/services/distribution";
import { openUrl } from "@tauri-apps/plugin-opener";
import { invoke } from "@/lib/invoke";
import { isDefaultServer } from "@/utils/serverInstance";

export async function openPortal(): Promise<void> {
if (!CAN_OPEN_CHECKOUT) return;
const [serverUrl, jwt] = await Promise.all([
invoke<string | null>("keychain_get", { key: "server_url" }).catch(() => null),
invoke<string | null>("keychain_get", { key: "jwt" }).catch(() => null),
Expand Down
5 changes: 5 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

interface ImportMetaEnv {
/** "play" for the Google Play bundle; unset everywhere else. */
readonly VITE_DISTRIBUTION?: string;
}

declare module "virtual:lucide-subset" {
const data: import("@iconify/types").IconifyJSON;
export default data;
Expand Down
Loading