mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c076086a1f | ||
|
|
fecf4b2067 | ||
|
|
fe40728f10 | ||
|
|
b4071b9bc3 |
@@ -6278,6 +6278,23 @@ upgradeSuccess = "Payment successful! Your subscription has been upgraded. The l
|
||||
upgradeTitle = "Upgrade to {{planName}}"
|
||||
yearly = "Yearly"
|
||||
|
||||
[payment.capacityStage]
|
||||
continue = "Continue to payment"
|
||||
customHint = "Rounded up to the next block of {{users}}."
|
||||
customLabel = "Number of users"
|
||||
dueToday = "Due today"
|
||||
enterpriseQuote = "Get an enterprise quote"
|
||||
lineItem = "Team · {{price}}{{period}} per {{block}} users"
|
||||
minimumForCurrentUsers = "You have {{users}} users, so the plan must cover at least {{minimum}}."
|
||||
modalTitle = "Upgrade to {{planName}}"
|
||||
other = "Other"
|
||||
perMonth = "/mo"
|
||||
perYear = "/yr"
|
||||
renewalNote = "Renews at {{total}}{{period}}. Cancel any time in Usage & Billing."
|
||||
subheading = "Covers everyone you invite, in blocks of {{users}} users."
|
||||
userTotal = "{{users}} users"
|
||||
usersLabel = "Users"
|
||||
|
||||
[payment.emailStage]
|
||||
continue = "Continue"
|
||||
description = "We'll use this to send your license key and receipts."
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/u
|
||||
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
|
||||
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
||||
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
|
||||
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
|
||||
import { blocksForUsers } from "@app/components/shared/stripeCheckout/utils/capacity";
|
||||
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
|
||||
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
|
||||
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
|
||||
@@ -83,6 +85,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
checkoutState.setCurrentLicenseKey,
|
||||
checkoutState.setPollingStatus,
|
||||
minimumSeats,
|
||||
checkoutState.serverQuantity,
|
||||
polling.pollForLicenseKey,
|
||||
onSuccess,
|
||||
onError,
|
||||
@@ -106,9 +109,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Only the Team tier is sold by capacity. Enterprise is priced per seat and free has nothing to
|
||||
// size, so both go straight to payment.
|
||||
const sellsCapacity = planGroup.tier === "server";
|
||||
|
||||
// Plan selection handler
|
||||
const handlePlanSelect = (period: "monthly" | "yearly") => {
|
||||
checkoutState.setSelectedPeriod(period);
|
||||
if (sellsCapacity) {
|
||||
// Arrive on the capacity an installation already needs rather than on a blocked minimum.
|
||||
checkoutState.setServerQuantity(blocksForUsers(minimumSeats));
|
||||
navigation.goToStage("capacity");
|
||||
return;
|
||||
}
|
||||
navigation.goToStage("payment");
|
||||
};
|
||||
|
||||
@@ -234,6 +247,17 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
case "capacity":
|
||||
return (
|
||||
<CapacityStage
|
||||
selectedPlan={checkoutState.selectedPlan}
|
||||
serverQuantity={checkoutState.serverQuantity}
|
||||
setServerQuantity={checkoutState.setServerQuantity}
|
||||
currentUsers={minimumSeats}
|
||||
onContinue={() => navigation.goToStage("payment")}
|
||||
/>
|
||||
);
|
||||
|
||||
case "payment":
|
||||
return (
|
||||
<PaymentStage
|
||||
|
||||
+3
@@ -19,6 +19,7 @@ export const useCheckoutSession = (
|
||||
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||
minimumSeats: number,
|
||||
serverQuantity: number,
|
||||
pollForLicenseKey: (installId: string) => Promise<void>,
|
||||
onSuccess?: (sessionId: string) => void,
|
||||
onError?: (error: string) => void,
|
||||
@@ -76,6 +77,7 @@ export const useCheckoutSession = (
|
||||
current_license_key: existingLicenseKey,
|
||||
requires_seats: selectedPlan.requiresSeats,
|
||||
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
|
||||
server_quantity: Math.max(1, serverQuantity || 1),
|
||||
email: state.email, // Pass collected email from Stage 1
|
||||
});
|
||||
|
||||
@@ -111,6 +113,7 @@ export const useCheckoutSession = (
|
||||
state.email,
|
||||
installationId,
|
||||
minimumSeats,
|
||||
serverQuantity,
|
||||
setState,
|
||||
setInstallationId,
|
||||
setCurrentLicenseKey,
|
||||
|
||||
+5
@@ -20,6 +20,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
|
||||
planGroup.yearly ? "yearly" : "monthly",
|
||||
);
|
||||
// Blocks of users to buy. Only the Team tier asks; every other tier stays at one.
|
||||
const [serverQuantity, setServerQuantity] = useState<number>(1);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
|
||||
null,
|
||||
@@ -50,6 +52,7 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||
setCurrentLicenseKey(null);
|
||||
setLicenseKey(null);
|
||||
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
|
||||
setServerQuantity(1);
|
||||
}, [planGroup]);
|
||||
|
||||
return {
|
||||
@@ -64,6 +67,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||
setEmailError,
|
||||
selectedPeriod,
|
||||
setSelectedPeriod,
|
||||
serverQuantity,
|
||||
setServerQuantity,
|
||||
installationId,
|
||||
setInstallationId,
|
||||
currentLicenseKey,
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { CapacityStage } from "@app/components/shared/stripeCheckout/stages/CapacityStage";
|
||||
import { PlanTier } from "@app/services/licenseService";
|
||||
|
||||
/**
|
||||
* The capacity step of the Stripe checkout, between billing period and payment. Only the Team tier
|
||||
* reaches it: the buyer picks users, and the plan is priced per block of 100.
|
||||
*/
|
||||
const yearlyPlan: PlanTier = {
|
||||
id: "server-yearly",
|
||||
name: "Team",
|
||||
price: 990,
|
||||
currency: "$",
|
||||
period: "/year",
|
||||
features: [],
|
||||
highlights: [],
|
||||
lookupKey: "selfhosted:server:yearly",
|
||||
};
|
||||
|
||||
const monthlyPlan: PlanTier = {
|
||||
...yearlyPlan,
|
||||
id: "server-monthly",
|
||||
price: 99,
|
||||
period: "/month",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "StripeCheckout/CapacityStage",
|
||||
component: CapacityStage,
|
||||
args: {
|
||||
selectedPlan: yearlyPlan,
|
||||
serverQuantity: 1,
|
||||
setServerQuantity: () => {},
|
||||
onContinue: () => {},
|
||||
},
|
||||
// The stepper is the point of this stage, so stories own the quantity and let it move.
|
||||
render: function Interactive(args) {
|
||||
const [quantity, setQuantity] = useState(args.serverQuantity);
|
||||
return (
|
||||
<CapacityStage
|
||||
{...args}
|
||||
serverQuantity={quantity}
|
||||
setServerQuantity={setQuantity}
|
||||
/>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof CapacityStage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A fresh purchase: the smallest block, 100 users. */
|
||||
export const SingleBlock: Story = {};
|
||||
|
||||
/** 300 users on the monthly plan, so the total is three block prices. */
|
||||
export const ThreeBlocksMonthly: Story = {
|
||||
args: { selectedPlan: monthlyPlan, serverQuantity: 3 },
|
||||
};
|
||||
|
||||
/**
|
||||
* An installation already running 240 users cannot buy cover for fewer than 300. Reducing capacity
|
||||
* is a renewal conversation, not something checkout does by stranding accounts.
|
||||
*/
|
||||
export const ConstrainedByCurrentUsers: Story = {
|
||||
args: { serverQuantity: 3, currentUsers: 240 },
|
||||
};
|
||||
|
||||
/** Below the minimum the continue button is blocked and the reason is stated. */
|
||||
export const BelowCurrentUsage: Story = {
|
||||
args: { serverQuantity: 1, currentUsers: 240 },
|
||||
};
|
||||
|
||||
/**
|
||||
* At the self-serve maximum the enterprise quote is offered beside the purchase. It is an option,
|
||||
* never a wall: self-serve checkout still completes.
|
||||
*/
|
||||
export const OffersEnterpriseQuote: Story = {
|
||||
args: { serverQuantity: 5, onContactSales: () => {} },
|
||||
};
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from "react";
|
||||
import { Stack, Text, Group, Divider, Alert, NumberInput } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/services/licenseService";
|
||||
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
||||
import {
|
||||
USERS_PER_BLOCK,
|
||||
SELF_SERVE_MAX_BLOCKS,
|
||||
USER_PRESETS,
|
||||
usersForBlocks,
|
||||
blocksForUsers,
|
||||
shouldOfferEnterprise,
|
||||
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
||||
|
||||
interface CapacityStageProps {
|
||||
/** The plan the buyer picked a billing period for; supplies block price and currency. */
|
||||
selectedPlan: PlanTier | null;
|
||||
/**
|
||||
* Blocks of users being bought. Held in blocks because that is what the Stripe line item counts,
|
||||
* but nothing shown to the buyer says so.
|
||||
*/
|
||||
serverQuantity: number;
|
||||
setServerQuantity: (quantity: number) => void;
|
||||
/** Users already on this installation, so capacity cannot be set below what is in use. */
|
||||
currentUsers?: number;
|
||||
onContinue: () => void;
|
||||
onContactSales?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose how many users the Team plan should cover.
|
||||
*
|
||||
* The buyer picks users; the plan is priced per block of {@link USERS_PER_BLOCK}. Presets cover the
|
||||
* common sizes and "Other" opens a free entry that rounds up to the next whole block, because a
|
||||
* part-block cannot be bought.
|
||||
*/
|
||||
export const CapacityStage: React.FC<CapacityStageProps> = ({
|
||||
selectedPlan,
|
||||
serverQuantity,
|
||||
setServerQuantity,
|
||||
currentUsers = 0,
|
||||
onContinue,
|
||||
onContactSales,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currency = selectedPlan?.currency || "$";
|
||||
const blockPrice = selectedPlan?.price || 0;
|
||||
const isYearly = selectedPlan?.period?.includes("year") ?? false;
|
||||
const covered = usersForBlocks(serverQuantity);
|
||||
const total = blockPrice * serverQuantity;
|
||||
|
||||
// Never sell less capacity than is already in use; reducing capacity happens at renewal rather
|
||||
// than by stranding accounts that already exist. The stage is entered pre-seeded to this minimum.
|
||||
const minBlocks = blocksForUsers(currentUsers);
|
||||
const minUsers = usersForBlocks(minBlocks);
|
||||
const maxUsers = usersForBlocks(SELF_SERVE_MAX_BLOCKS);
|
||||
const belowCurrentUsage = serverQuantity < minBlocks;
|
||||
const offerEnterprise = shouldOfferEnterprise(serverQuantity);
|
||||
|
||||
const presets = USER_PRESETS.filter((users) => users <= maxUsers);
|
||||
const [showCustom, setShowCustom] = useState(
|
||||
() => !presets.includes(usersForBlocks(serverQuantity)),
|
||||
);
|
||||
|
||||
const period = isYearly
|
||||
? t("payment.capacityStage.perYear", "/yr")
|
||||
: t("payment.capacityStage.perMonth", "/mo");
|
||||
|
||||
const selectUsers = (users: number) =>
|
||||
setServerQuantity(blocksForUsers(users));
|
||||
|
||||
return (
|
||||
<Stack gap="lg" style={{ padding: "1.5rem 2rem" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payment.capacityStage.subheading",
|
||||
"Covers everyone you invite, in blocks of {{users}} users.",
|
||||
{ users: USERS_PER_BLOCK },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("payment.capacityStage.usersLabel", "Users")}
|
||||
</Text>
|
||||
{presets.map((users) => (
|
||||
<Button
|
||||
key={users}
|
||||
variant={!showCustom && covered === users ? "primary" : "secondary"}
|
||||
disabled={users < minUsers}
|
||||
onClick={() => {
|
||||
setShowCustom(false);
|
||||
selectUsers(users);
|
||||
}}
|
||||
>
|
||||
{users}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant={showCustom ? "primary" : "secondary"}
|
||||
onClick={() => setShowCustom(true)}
|
||||
>
|
||||
{t("payment.capacityStage.other", "Other")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{showCustom && (
|
||||
<NumberInput
|
||||
label={t("payment.capacityStage.customLabel", "Number of users")}
|
||||
description={t(
|
||||
"payment.capacityStage.customHint",
|
||||
"Rounded up to the next block of {{users}}.",
|
||||
{ users: USERS_PER_BLOCK },
|
||||
)}
|
||||
value={covered}
|
||||
onChange={(value) => selectUsers(Number(value) || minUsers)}
|
||||
min={minUsers}
|
||||
max={maxUsers}
|
||||
step={USERS_PER_BLOCK}
|
||||
clampBehavior="strict"
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{belowCurrentUsage && (
|
||||
<Alert color="yellow" variant="light">
|
||||
{t(
|
||||
"payment.capacityStage.minimumForCurrentUsers",
|
||||
"You have {{users}} users, so the plan must cover at least {{minimum}}.",
|
||||
{ users: currentUsers, minimum: minUsers },
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payment.capacityStage.lineItem",
|
||||
"Team · {{price}}{{period}} per {{block}} users",
|
||||
{
|
||||
price: formatPrice(blockPrice, currency, 0),
|
||||
period,
|
||||
block: USERS_PER_BLOCK,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("payment.capacityStage.userTotal", "{{users}} users", {
|
||||
users: covered,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" align="baseline">
|
||||
<Text fw={600}>
|
||||
{t("payment.capacityStage.dueToday", "Due today")}
|
||||
</Text>
|
||||
<Text size="xl" fw={700}>
|
||||
{formatPrice(total, currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"payment.capacityStage.renewalNote",
|
||||
"Renews at {{total}}{{period}}. Cancel any time in Usage & Billing.",
|
||||
{ total: formatPrice(total, currency, 0), period },
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button onClick={onContinue} disabled={belowCurrentUsage} fullWidth>
|
||||
{t("payment.capacityStage.continue", "Continue to payment")}
|
||||
</Button>
|
||||
|
||||
{offerEnterprise && onContactSales && (
|
||||
<Button variant="secondary" onClick={onContactSales} fullWidth>
|
||||
{t(
|
||||
"payment.capacityStage.enterpriseQuote",
|
||||
"Get an enterprise quote",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ export interface StripeCheckoutProps {
|
||||
export type CheckoutStage =
|
||||
| "email"
|
||||
| "plan-selection"
|
||||
| "capacity"
|
||||
| "payment"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
USERS_PER_BLOCK,
|
||||
SELF_SERVE_MAX_BLOCKS,
|
||||
ENTERPRISE_ADVISORY_USERS,
|
||||
usersForBlocks,
|
||||
blocksForUsers,
|
||||
shouldOfferEnterprise,
|
||||
} from "@app/components/shared/stripeCheckout/utils/capacity";
|
||||
|
||||
describe("usersForBlocks", () => {
|
||||
it("multiplies blocks by the block size", () => {
|
||||
expect(usersForBlocks(1)).toBe(USERS_PER_BLOCK);
|
||||
expect(usersForBlocks(3)).toBe(USERS_PER_BLOCK * 3);
|
||||
});
|
||||
|
||||
it("treats a missing quantity as one block", () => {
|
||||
// Zero would read as "no capacity" and price the plan at nothing.
|
||||
expect(usersForBlocks(0)).toBe(USERS_PER_BLOCK);
|
||||
expect(usersForBlocks(-2)).toBe(USERS_PER_BLOCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocksForUsers", () => {
|
||||
it("rounds a part-full block up", () => {
|
||||
expect(blocksForUsers(1)).toBe(1);
|
||||
expect(blocksForUsers(USERS_PER_BLOCK)).toBe(1);
|
||||
expect(blocksForUsers(USERS_PER_BLOCK + 1)).toBe(2);
|
||||
expect(blocksForUsers(240)).toBe(3);
|
||||
});
|
||||
|
||||
it("never returns zero blocks", () => {
|
||||
// Checkout seeds the picker from this, and a zero would render a blocked stage.
|
||||
expect(blocksForUsers(0)).toBe(1);
|
||||
expect(blocksForUsers(-5)).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trips with usersForBlocks", () => {
|
||||
expect(blocksForUsers(usersForBlocks(4))).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldOfferEnterprise", () => {
|
||||
it("stays quiet for a small purchase", () => {
|
||||
expect(shouldOfferEnterprise(1)).toBe(false);
|
||||
expect(shouldOfferEnterprise(2)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the quote once the purchase reaches the self-serve maximum", () => {
|
||||
expect(shouldOfferEnterprise(SELF_SERVE_MAX_BLOCKS)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers the quote once the resulting capacity is enterprise-sized", () => {
|
||||
const servers = Math.ceil(ENTERPRISE_ADVISORY_USERS / USERS_PER_BLOCK);
|
||||
expect(usersForBlocks(servers)).toBeGreaterThanOrEqual(
|
||||
ENTERPRISE_ADVISORY_USERS,
|
||||
);
|
||||
expect(shouldOfferEnterprise(servers)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Team-plan capacity: the plan is sold in blocks of users.
|
||||
*
|
||||
* A block is what Stripe charges for (one unit of `selfhosted:server:*`), but nothing
|
||||
* customer-facing says so: the buyer picks a number of users and the line item prices it per block.
|
||||
* Only `server_quantity` on the checkout request speaks in blocks, because that is what the
|
||||
* subscription line item counts.
|
||||
*
|
||||
* The authoritative block size lives on the pricing policy and is resolved server-side when the
|
||||
* licence is issued, then baked into licence metadata. Before a purchase there is no licence to read
|
||||
* it from, so the checkout needs a display default. Keep it in step with
|
||||
* `pricing_policy.server_plan_user_block`; after purchase the licence is what counts, and the admin
|
||||
* surfaces read `userBlockSize` off `/license-info` rather than this constant.
|
||||
*/
|
||||
export const USERS_PER_BLOCK = 100;
|
||||
|
||||
/** Blocks a buyer can put through self-serve checkout in one go. */
|
||||
export const SELF_SERVE_MAX_BLOCKS = 5;
|
||||
|
||||
/** Capacity at which an enterprise quote is also worth offering. */
|
||||
export const ENTERPRISE_ADVISORY_USERS = 1000;
|
||||
|
||||
/** The user counts offered as one-click presets, before "Other". */
|
||||
export const USER_PRESETS = [100, 200, 300, 400];
|
||||
|
||||
/** Users covered by a given number of blocks. */
|
||||
export function usersForBlocks(blocks: number): number {
|
||||
return Math.max(1, blocks) * USERS_PER_BLOCK;
|
||||
}
|
||||
|
||||
/** Blocks needed to cover a given number of users. Always at least one. */
|
||||
export function blocksForUsers(users: number): number {
|
||||
return Math.max(1, Math.ceil(Math.max(0, users) / USERS_PER_BLOCK));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the enterprise door beside the purchase. Deliberately an option rather than a
|
||||
* gate: a buyer past these numbers can still complete self-serve checkout.
|
||||
*/
|
||||
export function shouldOfferEnterprise(blocks: number): boolean {
|
||||
return (
|
||||
blocks >= SELF_SERVE_MAX_BLOCKS ||
|
||||
usersForBlocks(blocks) >= ENTERPRISE_ADVISORY_USERS
|
||||
);
|
||||
}
|
||||
+4
@@ -36,6 +36,10 @@ export const getModalTitle = (
|
||||
"Select Billing Period - {{planName}}",
|
||||
{ planName },
|
||||
);
|
||||
case "capacity":
|
||||
return t("payment.capacityStage.modalTitle", "Upgrade to {{planName}}", {
|
||||
planName,
|
||||
});
|
||||
case "payment":
|
||||
return t(
|
||||
"payment.paymentStage.modalTitle",
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CheckoutSessionRequest {
|
||||
current_license_key?: string; // Current license key for upgrades
|
||||
requires_seats?: boolean; // Whether to add adjustable seat pricing
|
||||
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
|
||||
server_quantity?: number; // Blocks of users to buy; the Stripe line item counts these
|
||||
email?: string; // Customer email for checkout pre-fill
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
@@ -186,7 +187,7 @@ const licenseService = {
|
||||
{
|
||||
id: "selfhosted:server:monthly",
|
||||
lookupKey: "selfhosted:server:monthly",
|
||||
name: "Server - Monthly",
|
||||
name: "Team - Monthly",
|
||||
price: getPriceInfo("selfhosted:server:monthly"),
|
||||
currency: currencySymbol,
|
||||
period: "/month",
|
||||
@@ -197,7 +198,7 @@ const licenseService = {
|
||||
{
|
||||
id: "selfhosted:server:yearly",
|
||||
lookupKey: "selfhosted:server:yearly",
|
||||
name: "Server - Yearly",
|
||||
name: "Team - Yearly",
|
||||
price: getPriceInfo("selfhosted:server:yearly"),
|
||||
currency: currencySymbol,
|
||||
period: "/year",
|
||||
@@ -297,7 +298,7 @@ const licenseService = {
|
||||
if (serverMonthly || serverYearly) {
|
||||
groups.push({
|
||||
tier: "server",
|
||||
name: "Server",
|
||||
name: "Team",
|
||||
monthly: serverMonthly || null,
|
||||
yearly: serverYearly || null,
|
||||
features: (serverMonthly || serverYearly)!.features,
|
||||
@@ -352,6 +353,7 @@ const licenseService = {
|
||||
current_license_key: request.current_license_key,
|
||||
requires_seats: request.requires_seats,
|
||||
seat_count: request.seat_count || 1,
|
||||
server_quantity: request.server_quantity || 1,
|
||||
email: request.email,
|
||||
callback_base_url: baseUrl,
|
||||
ui_mode: checkoutMode,
|
||||
|
||||
Reference in New Issue
Block a user