fix(billing): compact Plans page and show pay-as-you-go vs annual pricing (#15559)

This commit is contained in:
Prabhat Sharma
2026-10-20 12:31:43 +00:00
committed by GitHub
parent ac0c1d3a3f
commit bbb0da9309
9 changed files with 518 additions and 161 deletions
+1
View File
@@ -26,6 +26,7 @@ export const config = {
export const siteURL = {
contactSales: "https://openobserve.ai/contactus/",
contactSupport: "https://openobserve.ai/contactus/",
// Schema (pricingOptions, inPriceBlock, additionalInfo) is documented in website5 src/content/pages/pricing.json `_comment`.
pricingJsonUrl: "https://openobserve.ai/pricing.json",
};
@@ -294,3 +294,39 @@ describe("pa gap fixes: plan button test ids (P8)", () => {
expect(w.find("button").attributes("data-test")).toBeTruthy();
});
});
describe("enterprisePlan.vue price line", () => {
const mountPlan = (props = {}) =>
mount(enterprisePlan, {
props: { features: mockEnterpriseFeatures, ...props },
global: { plugins: [i18n], provide: { store }, stubs: { OIcon: true } },
});
it("renders the price and its note when given", () => {
const w = mountPlan({ price: { price: "Custom", note: "volume discounts, BYOB" } });
const line = w.get('[data-test="billing-plans-enterprise-price"]').text();
expect(line).toContain("Custom");
expect(line).toContain("volume discounts, BYOB");
});
it("keeps the seven subgrid rows as the card's direct children, priced or not", () => {
expect(mountPlan().classes()).toContain("grid-rows-subgrid");
const priced = mountPlan({ price: { price: "Custom", note: "volume discounts, BYOB" } });
expect(priced.element.children).toHaveLength(7);
expect(mountPlan().element.children).toHaveLength(7);
});
it("renders no price line without a price", () => {
expect(mountPlan().find('[data-test="billing-plans-enterprise-price"]').exists()).toBe(false);
});
it("shows the discount tag once: in the price block when priced, in the header otherwise", () => {
const priced = mountPlan({ price: { price: "Custom", note: "volume discounts, BYOB" } });
expect(priced.text().match(/On discount/g)).toHaveLength(1);
expect(priced.get('[data-test="billing-plans-enterprise-price"]').text()).toContain(
"On discount",
);
const unpriced = mountPlan();
expect(unpriced.text().match(/On discount/g)).toHaveLength(1);
});
});
@@ -16,38 +16,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<OCard
class="border-card-glass-border bg-card-glass-bg rounded-default dark:bg-surface-base dark:border-border-default flex h-full w-full flex-col border shadow-none"
class="border-card-glass-border bg-card-glass-bg rounded-default dark:bg-surface-base dark:border-border-default row-span-7 grid! h-full w-full grid-rows-subgrid gap-0 border shadow-none max-lg:row-span-1 max-lg:flex!"
>
<div class="flex items-center justify-between px-3 py-2">
<div class="flex items-start justify-between gap-3 px-4 py-3">
<div>
<h3 class="text-text-heading m-0 pt-2 text-base leading-6 font-semibold">
<h3 class="text-text-heading m-0 text-base leading-6 font-semibold">
{{ t("billing.enterpriseLabel") }}
</h3>
<p class="text-text-secondary m-0 mt-2 text-sm leading-[1.125rem] font-normal">
<p class="text-text-secondary m-0 text-xs leading-4 font-normal">
{{ t("billing.enterpriseSubtitle") }}
</p>
</div>
<OTag type="billingTag" value="discount" class="mt-2" />
<OTag v-if="!price" type="billingTag" value="discount" class="shrink-0" />
</div>
<OSeparator class="my-2" />
<!-- Always rendered: the card is a subgrid row, so the price slot must exist even when empty. -->
<div :class="{ 'px-4 pb-3': price }">
<div
v-if="price"
class="rounded-default border-border-default flex h-full flex-col border px-2.5 py-2"
data-test="billing-plans-enterprise-price"
>
<div class="flex min-h-4 items-center">
<OTag variant="primary-soft" size="xs" :label="t('billing.discountTag')" />
</div>
<span class="text-text-heading text-2xl leading-7 font-semibold">{{
raw(price.price)
}}</span>
<span class="text-text-secondary text-xs leading-4">{{ raw(price.note) }}</span>
</div>
</div>
<div class="h-137.5 px-3 pt-2">
<h4 class="text-compact text-text-heading m-0 leading-[0.983rem] font-semibold">
{{ t("billing.features") }}
</h4>
<p class="text-compact text-text-secondary m-0 mt-1 mb-3 leading-[1.125rem] font-normal">
{{ t("billing.included") }}
</p>
<OSeparator />
<div class="flex-1 px-4 py-3">
<div
v-if="pricingError && !features?.length"
class="text-status-error-text mb-2 flex items-center"
>
<OIcon name="warning" size="sm" class="me-2" />
<span class="text-text-body text-base leading-[1.375rem]">{{
t("billing.pricingErrorMessage")
}}</span>
<span class="text-text-body text-sm leading-5">{{ t("billing.pricingErrorMessage") }}</span>
<OButton
variant="ghost"
size="sm"
@@ -58,40 +66,44 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("common.retry") }}
</OButton>
</div>
<div
v-for="(feature, index) in features"
:key="index"
class="mb-2 flex items-center justify-between"
>
<div class="flex items-center">
<OIcon
v-if="feature.is_parent"
name="check-circle"
size="md"
class="text-status-positive check-icon me-2"
/>
<div
class="text-text-body text-base leading-[1.375rem]"
:class="{ 'ms-6': !feature.is_parent }"
>
{{ feature.name }}
<div class="xl:columns-2 xl:gap-6">
<div
v-for="(feature, index) in features"
:key="index"
class="flex break-inside-avoid items-center justify-between py-0.5"
>
<div class="flex items-start">
<OIcon
v-if="feature.is_parent"
name="check-circle"
size="sm"
class="text-status-positive check-icon me-2 mt-0.5 shrink-0"
/>
<div
class="text-sm leading-5"
:class="feature.is_parent ? 'text-text-body' : 'text-text-secondary ms-6'"
>
{{ feature.name }}
</div>
</div>
<div class="text-text-body text-sm leading-5 font-semibold whitespace-nowrap">
{{ feature.price }}
</div>
</div>
<div class="text-text-body text-base leading-[1.375rem] font-bold">{{ feature.price }}</div>
</div>
</div>
<OSeparator />
<p class="text-compact text-text-secondary m-0 px-3 pt-2 leading-[1.125rem] font-normal">
<p class="text-text-secondary m-0 px-4 pt-3 text-xs leading-4 font-normal">
{{ t("billing.enterpriseNote") }}
</p>
<div class="mt-4.5 flex justify-between p-3">
<div class="flex justify-between px-4 pt-2 pb-3">
<OButton
variant="primary"
variant="outline"
size="sm-action"
class="w-full"
block
data-test="billing-plans-enterprise-contact-btn"
@click="contactSales"
>
@@ -103,7 +115,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
import { defineComponent } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { useI18nTyped, raw } from "@/types/i18n";
import { siteURL } from "@/constants/config";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
@@ -115,7 +127,7 @@ import analytics from "@/services/product_analytics";
export default defineComponent({
name: "enterprisePlan",
components: { OSeparator, OButton, OTag, OIcon, OCard },
props: ["features", "pricingError"],
props: ["features", "price", "pricingError"],
setup() {
const { t } = useI18nTyped();
@@ -126,6 +138,7 @@ export default defineComponent({
return {
t,
raw,
contactSales,
};
},
@@ -24,6 +24,7 @@ import * as zincutils from "@/utils/zincutils";
import { nextTick } from "vue";
import analytics from "@/services/product_analytics";
import config from "@/aws-exports";
import { siteURL } from "@/constants/config";
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
@@ -80,6 +81,8 @@ vi.mock("@/aws-exports", () => ({
vi.mock("./enterprisePlan.vue", () => ({
default: {
name: "EnterprisePlan",
props: ["features", "price", "pricingError"],
emits: ["retry-pricing"],
template: "<div>Enterprise Plan Component</div>",
},
}));
@@ -87,6 +90,21 @@ vi.mock("./enterprisePlan.vue", () => ({
vi.mock("./proPlan.vue", () => ({
default: {
name: "ProPlan",
props: [
"planType",
"billingProvider",
"subscriptionType",
"features",
"pricingOptions",
"additionalInfo",
"pricingError",
],
emits: [
"retry-pricing",
"contact-sales",
"update:proSubscription",
"update:cancelSubscription",
],
template: "<div>Pro Plan Component</div>",
},
}));
@@ -776,4 +794,148 @@ describe("Plans Component", () => {
expect(open).toHaveBeenCalledWith("https://openobserve.ai/contactus/", "_blank");
open.mockRestore();
});
describe("pricing JSON", () => {
const proFeaturesOld = [
{ title: "Logs, Metrics, Traces, RUM, Session Replay, Error tracking" },
{ title: "Ingestion", price: "$0.50 per GB*", isSubItem: true },
{ title: "Query", price: "$0.01 per GB", isSubItem: true },
];
const enterprisePlan = {
title: "Enterprise",
price: "Custom",
priceNote: "volume discounts, BYOB",
features: [{ title: "Pipelines" }, { title: "SLAs" }],
};
const oldJson = {
data: [
{
cloud: [
{
title: "Cloud Professional",
price: "$0.50",
period: "/GB ingested",
priceNote: "+ $0.01/GB queried · billed monthly",
additionalInfo: "*Includes 30% discount for annual commitment",
features: proFeaturesOld,
},
enterprisePlan,
],
},
],
};
const newJson = {
data: [
{
cloud: [
{
title: "Cloud Professional",
price: "$0.70",
period: "/GB ingested",
pricingOptions: [
{
label: "Pay as you go",
price: "$0.70",
period: "/GB ingested",
note: "No commitment, billed monthly",
},
{
label: "Annual contract",
price: "$0.50",
period: "/GB ingested",
note: "12-month commitment",
badge: "Save 28%",
},
],
features: [
{ title: "Logs, Metrics, Traces, RUM, Session Replay, Error tracking" },
{
title: "Ingestion, pay as you go",
price: "$0.70 per GB",
isSubItem: true,
inPriceBlock: true,
},
{
title: "Ingestion, annual contract",
price: "$0.50 per GB",
isSubItem: true,
inPriceBlock: true,
},
{ title: "Query", price: "$0.01 per GB", isSubItem: true },
],
},
enterprisePlan,
],
},
],
};
const loadPricing = async (json: unknown) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => json })),
);
await wrapper.vm.fetchPricingData();
vi.unstubAllGlobals();
};
it("maps pricing options and skips the rows they replace", async () => {
await loadPricing(newJson);
expect(wrapper.vm.proPlanPricingOptions.map((o: any) => o.price)).toEqual(["$0.70", "$0.50"]);
expect(wrapper.vm.proPlanPricingOptions[1].badge).toBe("Save 28%");
expect(wrapper.vm.proPlanFeatures.map((f: any) => f.name)).toEqual([
"Logs, Metrics, Traces, RUM, Session Replay, Error tracking",
"Query",
]);
expect(wrapper.vm.enterprisePlanPrice).toEqual({
price: "Custom",
note: "volume discounts, BYOB",
});
expect(wrapper.vm.enterprisePlanFeatures.map((f: any) => f.name)).toEqual([
"Pipelines",
"SLAs",
]);
expect(wrapper.vm.proPlanAdditionalInfo).toBe("");
});
it("passes the mapped pricing down to both plan cards", async () => {
await flushPromises();
await loadPricing(newJson);
await nextTick();
const pro = wrapper.findComponent({ name: "ProPlan" });
expect(pro.props("pricingOptions").map((o: any) => o.price)).toEqual(["$0.70", "$0.50"]);
expect(pro.props("additionalInfo")).toBe("");
expect(pro.props("features").map((f: any) => f.name)).toEqual([
"Logs, Metrics, Traces, RUM, Session Replay, Error tracking",
"Query",
]);
const enterprise = wrapper.findComponent({ name: "EnterprisePlan" });
expect(enterprise.props("price")).toEqual({
price: "Custom",
note: "volume discounts, BYOB",
});
expect(enterprise.props("features").map((f: any) => f.name)).toEqual(["Pipelines", "SLAs"]);
});
it("opens the contact page when the pro card asks for sales", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
await flushPromises();
await wrapper.findComponent({ name: "ProPlan" }).vm.$emit("contact-sales");
expect(open).toHaveBeenCalledWith(siteURL.contactSales, "_blank");
expect(analytics.track).toHaveBeenCalledWith("billing_contact_sales_clicked", {
plan: "annual-contract",
});
open.mockRestore();
});
it("keeps every row and shows no price block for the old JSON", async () => {
await loadPricing(oldJson);
expect(wrapper.vm.proPlanPricingOptions).toEqual([]);
expect(wrapper.vm.enterprisePlanPrice).toBeNull();
expect(wrapper.vm.proPlanAdditionalInfo).toBe("*Includes 30% discount for annual commitment");
expect(wrapper.vm.proPlanFeatures.map((f: any) => f.name)).toEqual([
"Logs, Metrics, Traces, RUM, Session Replay, Error tracking",
"Ingestion",
"Query",
]);
});
});
});
@@ -15,80 +15,67 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="rounded-default overflow-auto px-4 pt-3" style="min-height: inherit">
<div class="rounded-default min-h-[inherit] overflow-auto px-4 pt-3">
<!-- Page title is supplied by the parent Billing.vue OPageHeader; no local title here. -->
<TrialPeriod v-if="!isChildOrg" class="mb-3" currentPage="billing"></TrialPeriod>
<!-- AI Credits card: shown to child orgs too, whose AI use draws on the same credits -->
<div v-if="aiUsage" class="mb-4 grid w-full grid-cols-1 gap-4">
<div
class="bg-card-glass-bg border-card-glass-border rounded-default dark:bg-surface-base dark:border-border-default border p-4 shadow-none transition-shadow duration-200 hover:shadow-sm"
>
<div
class="rounded-default flex min-h-full flex-col justify-between text-center transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
>
<div class="flex flex-col justify-between">
<div class="flex items-center justify-between">
<div class="text-text-heading text-left text-base leading-5 font-medium">
{{ t("billing.aiCredits") }}
</div>
<div class="opacity-80">
<img :src="aiIcon" />
</div>
</div>
<OTag type="aiMode" :value="aiUsage.mode" class="mt-2" style="width: fit-content" />
</div>
<div class="mt-3 mb-2">
<OProgressBar :value="aiUsageRatio" size="sm" :variant="aiUsageVariant" />
</div>
<div class="text-text-body flex items-end text-left text-2xl leading-7 font-semibold">
{{ aiUsage.credits_used }} / {{ aiUsage.credits_limit }}
{{ t("billing.aiFreeUsedLabel") }}
</div>
<div class="text-text-secondary text-compact mt-1 text-left">
{{ t("billing.aiFreeOneTime") }}
</div>
<div
v-if="aiUsage.used_by_feature"
class="text-text-secondary text-compact text-left"
data-test="billing-ai-used-by-feature"
>
{{
t("billing.aiUsedByFeature", {
chat: aiUsage.used_by_feature.chat,
incident: aiUsage.used_by_feature.incident,
reanalysis: aiUsage.used_by_feature.incident_reanalysis,
})
}}
</div>
<div
v-if="aiUsage.mode === 'exhausted'"
class="text-status-error-text text-compact mt-2 text-left"
>
{{
aiUsage.requires_additional_credits
? t("billing.aiContractExhaustedMessage")
: aiUsage.payer_org_id
? t("billing.aiPayerSubscribeMessage", { payer: aiUsage.payer_org_id })
: t("billing.aiExhaustedMessage")
}}
<OButton
v-if="aiUsage.requires_additional_credits"
variant="ghost"
size="sm"
class="ms-1"
data-test="billing-ai-contact-sales"
@click="contactSales"
>
{{ t("billing.contactLabel") }}
</OButton>
</div>
<PaidAiUsageControl
:mode="aiUsage.mode"
class="border-border-default mt-3 border-t pt-3 text-left"
@change="fetchAiUsage"
/>
<div
v-if="aiUsage"
class="bg-card-glass-bg border-card-glass-border rounded-default dark:bg-surface-base dark:border-border-default mb-3 flex w-full flex-col gap-2 border px-4 py-2.5 text-left shadow-none transition-shadow duration-200 hover:shadow-sm"
>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<img :src="aiIcon" class="size-5 opacity-80" />
<div class="text-text-heading text-sm leading-5 font-semibold">
{{ t("billing.aiCredits") }}
</div>
<OTag type="aiMode" :value="aiUsage.mode" class="w-fit" />
<OProgressBar
:value="aiUsageRatio"
size="sm"
:variant="aiUsageVariant"
class="min-w-24 flex-1 max-md:order-last max-md:basis-full"
/>
<div class="text-text-heading ms-auto text-sm leading-5 font-semibold">
{{ aiUsage.credits_used }} / {{ aiUsage.credits_limit }}
{{ t("billing.aiFreeUsedLabel") }}
</div>
</div>
<div class="text-text-secondary flex flex-wrap gap-x-3 text-xs">
<span>{{ t("billing.aiFreeOneTime") }}</span>
<span v-if="aiUsage.used_by_feature" data-test="billing-ai-used-by-feature">
{{
t("billing.aiUsedByFeature", {
chat: aiUsage.used_by_feature.chat,
incident: aiUsage.used_by_feature.incident,
reanalysis: aiUsage.used_by_feature.incident_reanalysis,
})
}}
</span>
</div>
<div v-if="aiUsage.mode === 'exhausted'" class="text-status-error-text text-compact">
{{
aiUsage.requires_additional_credits
? t("billing.aiContractExhaustedMessage")
: aiUsage.payer_org_id
? t("billing.aiPayerSubscribeMessage", { payer: aiUsage.payer_org_id })
: t("billing.aiExhaustedMessage")
}}
<OButton
v-if="aiUsage.requires_additional_credits"
variant="ghost"
size="sm"
class="ms-1"
data-test="billing-ai-contact-sales"
@click="contactSales"
>
{{ t("billing.contactLabel") }}
</OButton>
</div>
<PaidAiUsageControl
:mode="aiUsage.mode"
class="border-border-default border-t pt-2"
@change="fetchAiUsage"
/>
</div>
<!-- Managed billing empty state for child orgs -->
<!-- eslint-disable local/no-hardcoded-px -- mixed with vh/vw — vh tracks the window while rem tracks font-size; keep the expression unit-consistent -->
@@ -159,27 +146,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
store.state.selectedOrganization.hasOwnProperty('note') &&
store.state.selectedOrganization.note
"
class="text-status-error-text flex items-center justify-start gap-2 ps-6 pb-4 text-xl font-semibold"
class="text-status-error-text flex items-center justify-start gap-2 pb-3 text-sm font-semibold"
>
<OIcon name="warning" size="sm" class="pt-2" />
<OIcon name="warning" size="sm" />
>{{ store.state.selectedOrganization.note }}
</div>
<div v-if="loading" class="text-center text-xl font-medium font-semibold">
<OSpinner size="md" class="mx-auto mt-3 block text-center" />
</div>
<div v-else class="mt-3 grid grid-cols-2 gap-3 max-lg:grid-cols-1">
<div v-else class="grid grid-cols-2 gap-x-3 max-lg:grid-cols-1 max-lg:gap-y-3">
<ProPlan
:planType="planType"
:billingProvider="billingProvider"
:subscriptionType="subscriptionType"
:features="proPlanFeatures"
:pricingOptions="proPlanPricingOptions"
:additionalInfo="proPlanAdditionalInfo"
:pricingError="pricingError"
@retry-pricing="fetchPricingData"
@contact-sales="contactSalesForAnnual"
@update:proSubscription="onLoadSubscription(config.paidPlan)"
@update:cancelSubscription="onUnsubscribe"
></ProPlan>
<EnterprisePlan
:features="enterprisePlanFeatures"
:price="enterprisePlanPrice"
:pricingError="pricingError"
@retry-pricing="fetchPricingData"
></EnterprisePlan>
@@ -274,6 +265,10 @@ export default defineComponent({
contactSales() {
window.open(siteURL.contactSales, "_blank");
},
contactSalesForAnnual() {
analytics.track("billing_contact_sales_clicked", { plan: "annual-contract" });
this.contactSales();
},
async fetchPricingData() {
this.pricingError = false;
try {
@@ -295,24 +290,30 @@ export default defineComponent({
cloudPlans.find((p: any) => p.title?.toLowerCase().includes("enterprise")) ??
cloudPlans[1];
const proFeatures = payAsYouGo?.features ? mapFeatures(payAsYouGo.features) : [];
const entFeatures = enterprise?.features ? mapFeatures(enterprise.features) : [];
const pricingOptions = Array.isArray(payAsYouGo?.pricingOptions)
? payAsYouGo.pricingOptions.map((o: any) => ({
label: o.label ?? "",
price: o.price ?? "",
period: o.period ?? "",
note: o.note ?? "",
badge: o.badge ?? "",
}))
: [];
// Production JSON already has a plan price, so only pricingOptions opts into the price block.
const hasPriceBlock = pricingOptions.length > 0;
const proFeatures = payAsYouGo?.features ?? [];
const diff = proFeatures.length - entFeatures.length + 3;
const paddedEntFeatures =
diff > 0
? [
...entFeatures,
...Array.from({ length: diff }, () => ({
name: "",
price: "",
is_parent: false,
})),
]
: entFeatures;
this.proPlanFeatures = proFeatures;
this.enterprisePlanFeatures = paddedEntFeatures;
this.proPlanFeatures = mapFeatures(
hasPriceBlock ? proFeatures.filter((f: any) => !f.inPriceBlock) : proFeatures,
);
this.enterprisePlanFeatures = mapFeatures(enterprise?.features ?? []);
this.proPlanPricingOptions = pricingOptions;
this.proPlanAdditionalInfo = payAsYouGo?.additionalInfo ?? "";
// Both cards show a price block or neither, so their feature lists stay aligned.
this.enterprisePlanPrice =
hasPriceBlock && enterprise?.price
? { price: enterprise.price, note: enterprise.priceNote ?? "" }
: null;
} catch {
this.pricingError = true;
}
@@ -442,6 +443,9 @@ export default defineComponent({
});
const proPlanFeatures: any = ref([]);
const enterprisePlanFeatures: any = ref([]);
const proPlanPricingOptions: any = ref([]);
const proPlanAdditionalInfo = ref("");
const enterprisePlanPrice = ref<{ price: string; note: string } | null>(null);
const pricingError = ref(false);
const membership = ref<any>(null);
const isChildOrg = computed(() => membership.value != null);
@@ -481,6 +485,9 @@ export default defineComponent({
aiUsageVariant,
proPlanFeatures,
enterprisePlanFeatures,
proPlanPricingOptions,
proPlanAdditionalInfo,
enterprisePlanPrice,
pricingError,
membership,
isChildOrg,
@@ -414,3 +414,85 @@ describe("pa gap fixes: plan button test ids (P8)", () => {
expect(subscribe.attributes("data-test")).not.toBe(cancel.attributes("data-test"));
});
});
describe("proPlan.vue price block", () => {
const pricingOptions = [
{
label: "Pay as you go",
price: "$0.70",
period: "/GB ingested",
note: "No commitment, billed monthly",
badge: "",
},
{
label: "Annual contract",
price: "$0.50",
period: "/GB ingested",
note: "12-month commitment",
badge: "Save 28%",
},
];
const mountPlan = (props = {}) =>
mount(proPlan, {
props: { planType: "free", billingProvider: "stripe", features: mockProFeatures, ...props },
global: { plugins: [i18n], provide: { store }, stubs: { OIcon: true } },
});
it("renders one tile per option with its price, period, note and badge", () => {
const w = mountPlan({ pricingOptions });
const payg = w.get('[data-test="billing-plans-pro-price-option-0"]').text();
const annual = w.get('[data-test="billing-plans-pro-price-option-1"]').text();
expect(payg).toContain("Pay as you go");
expect(payg).toContain("$0.70");
expect(payg).toContain("/GB ingested");
expect(payg).toContain("No commitment, billed monthly");
expect(annual).toContain("$0.50");
expect(annual).toContain("12-month commitment");
expect(w.findAll('[data-test="billing-plans-pro-price-option-badge"]')).toHaveLength(1);
expect(w.get('[data-test="billing-plans-pro-price-option-badge"]').text()).toBe("Save 28%");
});
it("offers contact sales on the annual tile only, and emits it upward", async () => {
const w = mountPlan({ pricingOptions });
const buttons = w.findAll('[data-test="billing-plans-pro-contact-sales-btn"]');
expect(buttons).toHaveLength(1);
expect(w.get('[data-test="billing-plans-pro-price-option-1"]').find("button").exists()).toBe(
true,
);
await buttons[0].trigger("click");
expect(w.emitted("contact-sales")).toHaveLength(1);
});
it("keeps the seven subgrid rows as the card's direct children, priced or not", () => {
expect(mountPlan().classes()).toContain("grid-rows-subgrid");
expect(mountPlan({ pricingOptions }).element.children).toHaveLength(7);
expect(mountPlan().element.children).toHaveLength(7);
});
it("renders no price block without pricing options", () => {
const w = mountPlan();
expect(w.find('[data-test="billing-plans-pro-price-options"]').exists()).toBe(false);
expect(w.find('[data-test="billing-plans-pro-contact-sales-btn"]').exists()).toBe(false);
});
});
describe("proPlan.vue additional info", () => {
const mountPlan = (props = {}) =>
mount(proPlan, {
props: { planType: "free", billingProvider: "stripe", features: mockProFeatures, ...props },
global: { plugins: [i18n], provide: { store }, stubs: { OIcon: true } },
});
it("renders the footnote when present", () => {
const w = mountPlan({ additionalInfo: "*Includes 30% discount for annual commitment" });
expect(w.get('[data-test="billing-plans-pro-additional-info"]').text()).toBe(
"*Includes 30% discount for annual commitment",
);
});
it("renders nothing when absent", () => {
expect(mountPlan().find('[data-test="billing-plans-pro-additional-info"]').exists()).toBe(
false,
);
});
});
@@ -16,38 +16,77 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<OCard
class="border-card-glass-border bg-card-glass-bg rounded-default dark:bg-surface-base dark:border-border-default flex h-full w-full flex-col border shadow-none"
class="border-card-glass-border bg-card-glass-bg rounded-default dark:bg-surface-base dark:border-border-default row-span-7 grid! h-full w-full grid-rows-subgrid gap-0 border shadow-none max-lg:row-span-1 max-lg:flex!"
>
<div class="flex items-center justify-between px-3 py-2">
<div class="flex items-start justify-between gap-3 px-4 py-3">
<div>
<h3 class="text-text-heading m-0 pt-2 text-base leading-6 font-semibold">
<h3 class="text-text-heading m-0 text-base leading-6 font-semibold">
{{ t("billing.proPlanLabel") }}
</h3>
<p class="text-text-secondary m-0 mt-2 text-sm leading-4.5 font-normal">
<p class="text-text-secondary m-0 text-xs leading-4 font-normal">
{{ t("billing.proPlanSubtitle") }}
</p>
</div>
<OTag v-if="planType == planName" type="billingTag" value="subscribed" class="mt-2" />
<OTag v-if="planType == planName" type="billingTag" value="subscribed" class="shrink-0" />
</div>
<OSeparator class="my-2" />
<!-- Always rendered: the card is a subgrid row, so the price slot must exist even when empty. -->
<div :class="{ 'px-4 pb-3': pricingOptions?.length }">
<div
v-if="pricingOptions?.length"
class="grid h-full grid-cols-1 gap-1.5 md:grid-cols-2"
data-test="billing-plans-pro-price-options"
>
<!-- The first option is what Subscribe buys; the rest are contract terms sold by sales. -->
<div
v-for="(option, index) in pricingOptions"
:key="index"
class="rounded-default flex flex-col border px-2.5 py-2"
:class="index === 0 ? 'border-accent' : 'border-border-default'"
:data-test="`billing-plans-pro-price-option-${index}`"
>
<div class="flex min-h-4 items-center gap-2">
<span class="text-text-secondary text-xs">{{ raw(option.label) }}</span>
<OTag
v-if="option.badge"
variant="success-soft"
size="xs"
:label="raw(option.badge)"
data-test="billing-plans-pro-price-option-badge"
/>
</div>
<div class="flex items-baseline gap-1">
<span class="text-text-heading text-2xl leading-7 font-semibold">
{{ raw(option.price) }}
</span>
<span class="text-text-secondary text-xs">{{ raw(option.period) }}</span>
</div>
<div class="flex flex-wrap items-center gap-x-2">
<span class="text-text-secondary text-xs leading-4">{{ raw(option.note) }}</span>
<OButton
v-if="index !== 0"
variant="ghost-primary"
size="xs"
class="ms-auto"
data-test="billing-plans-pro-contact-sales-btn"
@click="$emit('contact-sales')"
>
{{ t("billing.contactLabel") }}
</OButton>
</div>
</div>
</div>
</div>
<div class="px-3 py-2">
<h4 class="text-compact text-text-heading m-0 leading-[0.983rem] font-semibold">
{{ t("billing.features") }}
</h4>
<p class="text-compact text-text-secondary m-0 mt-1 mb-3 leading-4.5 font-normal">
{{ t("billing.included") }}
</p>
<OSeparator />
<div class="flex-1 px-4 py-3">
<div
v-if="pricingError && !features?.length"
class="text-status-error-text mb-2 flex items-center"
>
<OIcon name="warning" size="sm" class="me-2" />
<span class="text-text-body text-base leading-5.5">{{
t("billing.pricingErrorMessage")
}}</span>
<span class="text-text-body text-sm leading-5">{{ t("billing.pricingErrorMessage") }}</span>
<OButton
variant="ghost"
size="sm"
@@ -61,16 +100,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-for="(feature, index) in features"
:key="index"
class="mb-2 flex items-center justify-between"
class="flex items-center justify-between py-0.5"
>
<div class="flex items-center">
<div class="flex items-start">
<OIcon
v-if="feature.is_parent"
name="check-circle"
size="md"
class="text-status-positive check-icon me-2"
size="sm"
class="text-status-positive check-icon me-2 mt-0.5 shrink-0"
/>
<div class="text-text-body text-base leading-5.5" :class="{ 'ms-6': !feature.is_parent }">
<div
class="text-sm leading-5"
:class="feature.is_parent ? 'text-text-body' : 'text-text-secondary ms-6'"
>
{{ feature.name }}
</div>
</div>
@@ -78,18 +120,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="feature.price !== ''"
class="border-border-default mx-2 h-0 flex-1 border-t border-dotted opacity-40"
></div>
<div class="text-text-body text-base leading-5.5 font-bold">{{ feature.price }}</div>
<div class="text-text-body text-sm leading-5 font-semibold whitespace-nowrap">
{{ feature.price }}
</div>
</div>
</div>
<OSeparator />
<p class="text-compact text-text-secondary m-0 px-3 pt-2 leading-4.5 font-normal">
<p class="text-text-secondary m-0 px-4 pt-3 text-xs leading-4 font-normal">
{{ t("billing.unlimitedNote") }}<br />
{{ t("billing.paymentNote") }}
<template v-if="additionalInfo">
<br />
<span data-test="billing-plans-pro-additional-info">{{ raw(additionalInfo) }}</span>
</template>
</p>
<div class="flex justify-between p-3">
<div class="flex justify-between px-4 pt-2 pb-3">
<!-- AWS Marketplace billing - show managed externally message -->
<div v-if="billingProvider === 'aws'" class="w-full text-center">
<OTag type="billingManagement" value="aws" class="inline-flex items-center gap-1">
@@ -149,7 +197,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
import { defineComponent, ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { useI18nTyped, raw } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -160,7 +208,16 @@ import analytics from "@/services/product_analytics";
export default defineComponent({
name: "proPlan",
components: { OSeparator, OButton, OTag, OIcon, OCard },
props: ["planType", "billingProvider", "subscriptionType", "features", "pricingError"],
props: [
"planType",
"billingProvider",
"subscriptionType",
"features",
"pricingOptions",
"additionalInfo",
"pricingError",
],
emits: ["retry-pricing", "contact-sales", "update:proSubscription", "update:cancelSubscription"],
setup(props, { emit }) {
const { t } = useI18nTyped();
const planName = "pay-as-you-go";
@@ -187,6 +244,7 @@ export default defineComponent({
return {
t,
raw,
cancelSubscription,
onSubscribe,
planName,
@@ -362,7 +362,7 @@ describe("UsageView AI credits warning", () => {
const banner = wrapper.find('[data-test="billings-usageview-ai-exhausted"]');
expect(banner.text()).toContain("Contact your account manager");
expect(wrapper.find('[data-test="billings-usageview-ai-exhausted-action"]').text()).toBe(
"Contact Sales",
"Contact sales",
);
});
+3 -5
View File
@@ -8149,16 +8149,14 @@
"invoiceHistoryLabel": "Invoice History",
"upgradeNow": "Upgrade Now",
"subscribe": "Subscribe",
"features": "FEATURES",
"included": "What's included?",
"proPlanSubtitle": "Custom solutions for small teams",
"unlimitedNote": "Unlimited users, logs, metrics, traces & more.",
"paymentNote": "Payment calculated by usage at the end of monthly billing cycle.",
"proPlanLabel": "Pay as you go",
"contactLabel": "Contact Sales",
"enterpriseLabel": "Enterprise Plan",
"contactLabel": "Contact sales",
"enterpriseLabel": "Enterprise plan",
"enterpriseSubtitle": "Advanced functionality and support for Enterprise demands",
"discountTag": "On Discount",
"discountTag": "On discount",
"enterpriseNote": "High volume needs? Let's talk scale! Contact us for tailored solutions.",
"contactSupport": "Contact Support",
"usageTrends": {