Feat: Update billing with new design (#4309)

This commit is contained in:
Mike
2025-04-02 16:42:19 +02:00
committed by GitHub
parent bb54ef0b0b
commit d752bcb274
16 changed files with 315 additions and 1155 deletions
@@ -2,43 +2,48 @@
<div
class="border border-outline-3 bg-foundation text-foreground rounded-lg p-5 flex flex-col w-full"
>
<div class="lg:h-32">
<div class="flex items-center gap-x-2">
<h4 class="text-body font-medium">
{{ formatName(plan) }}
</h4>
<CommonBadge v-if="badgeText" rounded>
{{ badgeText }}
</CommonBadge>
</div>
<p class="text-body mt-1">
<span class="font-medium">
{{ planPrice }}
</span>
per seat/month
</p>
<p
v-if="plan === WorkspacePlans.Free"
class="text-body-xs text-foreground-2 mt-2.5"
>
For individuals and small teams trying Speckle.
</p>
<template v-else>
<div class="flex items-center gap-x-2 mt-3 px-1">
<FormSwitch
v-model="isYearlyIntervalSelected"
:show-label="false"
name="billing-interval"
@update:model-value="
(newValue) => $emit('onYearlyIntervalSelected', newValue)
"
/>
<span class="text-body-2xs">Billed yearly</span>
<CommonBadge rounded color-classes="text-foreground-2 bg-primary-muted">
-10%
<div class="lg:h-32 flex flex-col">
<div class="flex-1">
<div class="flex items-center gap-x-2">
<h4 class="text-body font-medium">
{{ formatName(plan) }}
</h4>
<CommonBadge v-if="badgeText" rounded>
{{ badgeText }}
</CommonBadge>
</div>
<div class="w-full mt-4">
<p class="text-body mt-1">
<span class="font-medium">
{{ planPrice }}
</span>
per seat/month
</p>
<template v-if="plan !== WorkspacePlans.Free">
<div class="flex items-center gap-x-2 mt-3 px-1">
<FormSwitch
v-model="isYearlyIntervalSelected"
:show-label="false"
name="billing-interval"
@update:model-value="
(newValue) => $emit('onYearlyIntervalSelected', newValue)
"
/>
<span class="text-body-2xs">Billed yearly</span>
<CommonBadge rounded color-classes="text-foreground-2 bg-primary-muted">
-10%
</CommonBadge>
</div>
</template>
</div>
<div class="w-full mt-4">
<div v-if="hasCta">
<slot name="cta" />
</div>
<div
v-else
:key="`tooltip-${yearlyIntervalSelected}-${plan}-${currentPlan?.name}`"
v-tippy="buttonTooltip"
>
<FormButton
:color="buttonColor"
:disabled="!isSelectable"
@@ -48,7 +53,7 @@
{{ buttonText }}
</FormButton>
</div>
</template>
</div>
</div>
<ul class="flex flex-col gap-y-2 mt-4 pt-3 border-t border-outline-3">
<li
@@ -95,6 +100,7 @@ import { XMarkIcon } from '@heroicons/vue/24/outline'
import { useWorkspacePlanPrices } from '~/lib/billing/composables/prices'
import { formatPrice, formatName } from '~/lib/billing/helpers/plan'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { SetupContext } from 'vue'
defineEmits<{
(e: 'onYearlyIntervalSelected', value: boolean): void
@@ -103,13 +109,14 @@ defineEmits<{
const props = defineProps<{
plan: WorkspacePlans
yearlyIntervalSelected: boolean
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
isAdmin: boolean
activeBillingInterval: MaybeNullOrUndefined<BillingInterval>
hasSubscription: boolean
workspaceId: MaybeNullOrUndefined<string>
canUpgrade: boolean
workspaceId?: MaybeNullOrUndefined<string>
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
activeBillingInterval?: MaybeNullOrUndefined<BillingInterval>
hasSubscription?: MaybeNullOrUndefined<boolean>
}>()
const slots: SetupContext['slots'] = useSlots()
const { pricesNew } = useWorkspacePlanPrices()
const { upgradePlan, redirectToCheckout } = useBillingActions()
@@ -130,6 +137,8 @@ const planPrice = computed(() => {
})
})
const hasCta = computed(() => !!slots.cta)
const canUpgradeToPlan = computed(() => {
if (!props.currentPlan) return false
@@ -151,9 +160,12 @@ const isDowngrade = computed(() => {
return !canUpgradeToPlan.value && props.currentPlan?.name !== props.plan
})
const isCurrentPlan = computed(
() => isMatchingInterval.value && props.currentPlan?.name === props.plan
)
const isCurrentPlan = computed(() => {
if (props.plan === WorkspacePlans.Free) {
return props.currentPlan?.name === props.plan
}
return isMatchingInterval.value && props.currentPlan?.name === props.plan
})
const isAnnualToMonthly = computed(() => {
return (
@@ -172,7 +184,10 @@ const isMonthlyToAnnual = computed(() => {
})
const isSelectable = computed(() => {
if (!props.isAdmin) return false
if (!props.canUpgrade) return false
// Free CTA has no clickable scenario
if (props.plan === WorkspacePlans.Free) return false
// Always enable buttons during expired or canceled state
if (
props.currentPlan?.status === WorkspacePlanStatuses.Expired ||
@@ -210,6 +225,10 @@ const buttonColor = computed(() => {
})
const buttonText = computed(() => {
// Current plan case
if (isCurrentPlan.value) {
return 'Current plan'
}
// Allow if current plan is Free, or the current plan is expired/canceled
if (
props.currentPlan?.name === WorkspacePlans.Free ||
@@ -218,10 +237,6 @@ const buttonText = computed(() => {
) {
return `Subscribe to ${formatName(props.plan)}`
}
// Current plan case
if (isCurrentPlan.value) {
return 'Current plan'
}
// Billing interval and lower plan case
if (isDowngrade.value) {
return `Downgrade to ${props.plan}`
@@ -237,6 +252,37 @@ const buttonText = computed(() => {
return canUpgradeToPlan.value ? `Upgrade to ${formatName(props.plan)}` : ''
})
const buttonTooltip = computed(() => {
if (!props.canUpgrade) {
return 'You must be a workspace admin.'
}
if (
isCurrentPlan.value ||
props.currentPlan?.status === WorkspacePlanStatuses.Expired ||
props.currentPlan?.status === WorkspacePlanStatuses.Canceled
)
return undefined
if (isDowngrade.value) {
return 'Downgrading is not supported at the moment. Please contact billing@speckle.systems.'
}
if (isAnnualToMonthly.value) {
return 'Changing from an annual to a monthly plan is currently not supported. Please contact billing@speckle.systems.'
}
if (
props.activeBillingInterval === BillingInterval.Yearly &&
!props.yearlyIntervalSelected &&
canUpgradeToPlan.value
) {
return 'Upgrading from an annual plan to a monthly plan is not supported. Please contact billing@speckle.systems.'
}
return undefined
})
const badgeText = computed(() =>
props.currentPlan?.name === props.plan ? 'Current plan' : ''
)
@@ -7,7 +7,7 @@
:current-plan="currentPlan"
:yearly-interval-selected="isYearlySelected"
:active-billing-interval="billingInterval"
:is-admin="isAdmin"
:can-upgrade="isAdmin"
:workspace-id="props.workspaceId"
:has-subscription="!!subscription"
@on-yearly-interval-selected="onYearlyIntervalSelected"
@@ -19,16 +19,8 @@
import { BillingInterval } from '~/lib/common/generated/gql/graphql'
import { WorkspacePlans } from '@speckle/shared'
import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { graphql } from '~/lib/common/generated/gql'
import { type MaybeNullOrUndefined, type WorkspaceRoles, Roles } from '@speckle/shared'
graphql(`
fragment PricingTable_Workspace on Workspace {
id
role
}
`)
const props = defineProps<{
slug: string
role: MaybeNullOrUndefined<WorkspaceRoles>
@@ -1,271 +1,95 @@
<!-- "Old" billing page, the component for the new workspace plans is in PageNew.vue -->
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<FormButton
v-if="isWorkspaceNewPlansEnabled && isServerAdmin"
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick"
>
𝓒𝓱𝓪𝓷𝓰𝓮 𝓽𝓸 𝓷𝓮𝔀 𝓹𝓵𝓪𝓷 💸
</FormButton>
<SettingsSectionHeader title="Billing" text="Your workspace billing details" />
<template v-if="isBillingIntegrationEnabled">
<div class="flex flex-col gap-y-4 md:gap-y-6">
<BillingAlert
v-if="workspace && workspace?.plan?.status !== WorkspacePlanStatuses.Valid"
:workspace="workspace"
/>
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
<div class="border border-outline-3 rounded-lg">
<div
class="grid grid-cols-1 lg:grid-cols-3 divide-y divide-outline-3 lg:divide-y-0 lg:divide-x"
>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-1">
{{ summaryPlanHeading }}
</h3>
<div class="flex gap-x-2">
<p class="text-heading-lg text-foreground">
Workspace
<span class="capitalize">
{{ currentPlan?.name ?? WorkspacePlans.Starter }}
</span>
</p>
<div>
<CommonBadge v-if="showStatusBadge" rounded>TRIAL</CommonBadge>
</div>
</div>
<p v-if="isPurchasablePlan" class="text-body-xs text-foreground-2">
<span v-if="statusIsTrial">
<span class="line-through mr-1">
{{ formatPrice(seatPrice?.[Roles.Workspace.Member]) }} per
seat/month
</span>
Free
</span>
<span
v-else-if="currentPlan?.status === WorkspacePlanStatuses.Expired"
>
{{ formatPrice(seatPrice?.[Roles.Workspace.Member]) }} per
seat/month
</span>
<span v-else>
{{ formatPrice(seatPrice?.[Roles.Workspace.Member]) }} per
seat/month, billed
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'annually'
: 'monthly'
}}
</span>
</p>
</div>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<template v-if="isPurchasablePlan || statusIsTrial">
<h3 class="text-body-xs text-foreground-2 pb-1">
{{ summaryBillHeading }}
</h3>
<p class="text-heading-lg text-foreground inline-block">
{{ summaryBillValue }} per
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'year'
: 'month'
}}
</p>
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
{{ summaryBillDescription }}
<InformationCircleIcon
v-tippy="billTooltip"
class="w-4 h-4 text-foreground cursor-pointer"
/>
</p>
</template>
<template v-else>
<h3 class="text-body-xs text-foreground-2 pb-1">Expected bill</h3>
<p class="text-heading-lg text-foreground inline-block">
{{ isAcademiaPlan ? 'Free' : 'Not applicable' }}
</p>
</template>
</div>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-1">
{{ summaryDateHeading }}
</h3>
<p class="text-heading-lg text-foreground capitalize">
{{ isPurchasablePlan ? nextPaymentDue : 'Never' }}
</p>
<p
v-if="showSummaryDateDescription"
class="text-body-xs text-foreground-2"
>
<span v-if="statusIsTrial">Subscribe before this date</span>
<span v-else>
{{
subscription?.billingInterval === BillingInterval.Yearly
? 'Annual'
: 'Monthly'
}}
billing period
</span>
</p>
</div>
</div>
<div
v-if="isActivePlan && isPurchasablePlan"
class="flex flex-row gap-x-4 p-5 items-center border-t border-outline-3"
>
<div class="text-body-xs gap-y-2 flex-1">
<p class="font-medium text-foreground">Billing portal</p>
<p class="text-foreground-2">
View invoices, edit payment details, and manage your subscription.
</p>
</div>
<FormButton color="outline" @click="billingPortalRedirect(workspace?.id)">
Open billing portal
</FormButton>
</div>
</div>
<template v-if="isPurchasablePlan || statusIsTrial">
<SettingsSectionHeader
:title="pricingTableHeading"
subheading
class="pt-4"
/>
<SettingsWorkspacesBillingPricingTable
:workspace-id="workspace?.id"
:current-plan="currentPlan"
:active-billing-interval="subscription?.billingInterval"
:is-admin="isAdmin"
@on-plan-selected="onPlanSelected"
/>
</template>
</div>
<div v-if="isInvoicedPlan" class="mt-8 text-foreground-2 text-body-xs">
Need help?
<a
class="text-foreground hover:underline"
href="mailto:billing@speckle.systems"
@click="
mixpanel.track('Workspace Support Link Clicked', {
workspace_id: workspace?.id,
plan: currentPlan?.name
})
"
>
Contact us
</a>
</div>
<div
v-else-if="isPurchasablePlan"
class="mt-8 text-center text-foreground-2 text-body-xs"
>
Need help?
<NuxtLink
class="text-foreground"
:to="guideBillingUrl"
target="_blank"
@click="
mixpanel.track('Workspace Docs Link Clicked', {
workspace_id: workspace?.id,
plan: currentPlan?.name
})
"
>
<span class="hover:underline">Read the docs</span>
</NuxtLink>
or
<a
class="text-foreground hover:underline"
href="mailto:billing@speckle.systems"
@click="
mixpanel.track('Workspace Support Link Clicked', {
workspace_id: workspace?.id,
plan: currentPlan?.name
})
"
>
contact support
</a>
</div>
<SettingsWorkspacesBillingUpgradeDialog
v-if="selectedPlanName && selectedPlanCycle && workspace?.id"
v-model:open="isUpgradeDialogOpen"
:plan="selectedPlanName"
:billing-interval="selectedPlanCycle"
:workspace-id="workspace.id"
/>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0 flex flex-col gap-y-2 md:gap-y-4">
<SettingsSectionHeader
title="Billing and plans"
text="Update your payment information or switch plans according to your needs"
/>
<CommonAlert v-if="!isNewPlan" color="danger">
<template #title>You are on an old plan</template>
<template #description>
<p>If you are a server admin use the buttons below to upgrade</p>
</template>
</CommonAlert>
<div class="flex flex-col gap-y-6 md:gap-y-10">
<section v-if="isServerAdmin" class="flex flex-col gap-y-4 md:gap-y-6">
<div class="flex gap-x-4">
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Free)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 free 𝖕𝖑𝖆𝖓
</FormButton>
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Team)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 Starter 𝖕𝖑𝖆𝖓
</FormButton>
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Pro)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 Business 𝖕𝖑𝖆𝖓
</FormButton>
</div>
</section>
<template v-if="isNewPlan">
<section v-if="isPurchasablePlan" class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Summary" subheading />
<SettingsWorkspacesBillingSummary :workspace-id="workspace?.id" />
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Usage" subheading />
<SettingsWorkspacesBillingUsage :slug="slug" />
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Upgrade your plan" subheading />
<PricingTable
:slug="slug"
:workspace-id="workspace?.id"
:role="workspace?.role as WorkspaceRoles"
/>
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Add-ons" subheading />
<SettingsWorkspacesBillingAddOns :slug="slug" />
</section>
</template>
<template v-else>Coming soon</template>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~/lib/common/generated/gql'
<script lang="ts" setup>
import { useQuery, useMutation } from '@vue/apollo-composable'
import { adminUpdateWorkspacePlanMutation } from '~/lib/billing/graphql/mutations'
import { settingsWorkspaceBillingQuery } from '~/lib/settings/graphql/queries'
import { useIsBillingIntegrationEnabled } from '~/composables/globals'
import {
WorkspacePlans,
WorkspacePlanStatuses,
BillingInterval,
WorkspacePaymentMethod,
type PaidWorkspacePlans
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { PaidWorkspacePlansOld } from '@speckle/shared'
import { Roles } from '@speckle/shared'
import { InformationCircleIcon } from '@heroicons/vue/24/outline'
import { isPaidPlan } from '@/lib/billing/helpers/types'
import { useMixpanel } from '~/lib/core/composables/mp'
import { guideBillingUrl } from '~/lib/common/helpers/route'
import { adminUpdateWorkspacePlanMutation } from '~/lib/billing/graphql/mutations'
import { useWorkspacePlanPrices } from '~/lib/billing/composables/prices'
import { formatPrice } from '~/lib/billing/helpers/plan'
PaidWorkspacePlanStatuses,
type WorkspaceRoles
} from '@speckle/shared'
import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { graphql } from '~/lib/common/generated/gql'
graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
...BillingAlert_Workspace
fragment WorkspaceBillingPage_Workspace on Workspace {
id
role
plan {
name
status
createdAt
paymentMethod
}
subscription {
billingInterval
currentBillingCycleEnd
seats {
guest
plan
}
}
team {
items {
id
role
}
}
}
`)
const slug = computed(() => (route.params.slug as string) || '')
const { prices } = useWorkspacePlanPrices()
const { isAdmin: isServerAdmin } = useActiveUser()
const route = useRoute()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const slug = computed(() => (route.params.slug as string) || '')
const { isAdmin: isServerAdmin } = useActiveUser()
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const { isPurchasablePlan, isNewPlan } = useWorkspacePlan(slug.value)
const { mutate: mutateWorkspacePlan } = useMutation(adminUpdateWorkspacePlanMutation)
const { result: workspaceResult } = useQuery(
settingsWorkspaceBillingQuery,
() => ({
@@ -275,203 +99,21 @@ const { result: workspaceResult } = useQuery(
enabled: isBillingIntegrationEnabled
})
)
const { billingPortalRedirect, redirectToCheckout } = useBillingActions()
const mixpanel = useMixpanel()
const { mutate: mutateWorkspacePlan } = useMutation(adminUpdateWorkspacePlanMutation)
const selectedPlanName = ref<PaidWorkspacePlansOld>()
const selectedPlanCycle = ref<BillingInterval>()
const isUpgradeDialogOpen = ref(false)
const seatPrices = computed(() => ({
[WorkspacePlans.Starter]: prices.value?.[WorkspacePlans.Starter],
[WorkspacePlans.Plus]: prices.value?.[WorkspacePlans.Plus],
[WorkspacePlans.Business]: prices.value?.[WorkspacePlans.Business],
...(isWorkspaceNewPlansEnabled.value
? {
[WorkspacePlans.Team]: prices.value?.[WorkspacePlans.Team],
[WorkspacePlans.Pro]: prices.value?.[WorkspacePlans.Pro]
}
: {})
}))
const workspace = computed(() => workspaceResult.value?.workspaceBySlug)
const currentPlan = computed(() => workspace.value?.plan)
const subscription = computed(() => workspace.value?.subscription)
const statusIsTrial = computed(
() =>
currentPlan.value?.status === WorkspacePlanStatuses.Trial ||
!currentPlan.value?.status
)
const isActivePlan = computed(
() =>
currentPlan.value &&
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled &&
currentPlan.value?.status !== WorkspacePlanStatuses.Expired
)
const isAcademiaPlan = computed(
() => currentPlan.value?.name === WorkspacePlans.Academia
)
const isPurchasablePlan = computed(() => isPaidPlan(currentPlan.value?.name))
const seatPrice = computed(() =>
currentPlan.value && subscription.value
? seatPrices.value?.[currentPlan.value.name as keyof typeof seatPrices.value]?.[
subscription.value.billingInterval
]
: seatPrices.value?.[WorkspacePlans.Starter]?.[BillingInterval.Monthly]
)
const nextPaymentDue = computed(() =>
isPurchasablePlan.value
? subscription.value?.currentBillingCycleEnd
? dayjs(subscription.value?.currentBillingCycleEnd).format('MMMM D, YYYY')
: dayjs(currentPlan.value?.createdAt).add(31, 'days').format('MMMM D, YYYY')
: 'Never'
)
const isInvoicedPlan = computed(
() => currentPlan.value?.paymentMethod === WorkspacePaymentMethod.Invoice
)
const isAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
const guestSeatCount = computed(() =>
isActivePlan.value
? workspace.value?.subscription?.seats.guest ?? 0
: workspace.value?.team.items.filter((user) => user.role === Roles.Workspace.Guest)
.length ?? 0
)
const memberSeatCount = computed(() =>
isActivePlan.value
? workspace.value?.subscription?.seats.plan ?? 0
: workspace.value
? workspace.value.team.items.length - guestSeatCount.value
: 0
)
const summaryBillValue = computed(() => {
if (!seatPrice.value) return 'loading'
const guestPrice =
seatPrice.value[Roles.Workspace.Guest].amount * guestSeatCount.value
const memberPrice =
seatPrice.value[Roles.Workspace.Member].amount * memberSeatCount.value
const totalPrice = guestPrice + memberPrice
const isAnnual = subscription.value?.billingInterval === BillingInterval.Yearly
return isPurchasablePlan.value ? `£${isAnnual ? totalPrice * 12 : totalPrice}` : '£0'
})
const summaryBillDescription = computed(() => {
const memberText =
memberSeatCount.value > 1 ? `${memberSeatCount.value} members` : '1 member'
const guestText =
guestSeatCount.value > 1 ? `${guestSeatCount.value} guests` : '1 guest'
return `${memberText}${guestSeatCount.value > 0 ? `, ${guestText}` : ''}`
})
const billTooltip = computed(() => {
if (!seatPrice.value) return undefined
const memberText = `${memberSeatCount.value} member${
memberSeatCount.value === 1 ? '' : 's'
} at ${formatPrice(seatPrice.value[Roles.Workspace.Member])}/month`
const guestText = `${guestSeatCount.value} guest${
guestSeatCount.value === 1 ? '' : 's'
} at ${formatPrice(seatPrice.value[Roles.Workspace.Guest])}/month`
return `${memberText}${guestSeatCount.value > 0 ? `, ${guestText}` : ''}`
})
const summaryPlanHeading = computed(() => {
switch (currentPlan.value?.status) {
case WorkspacePlanStatuses.Trial:
return 'Trial plan'
case WorkspacePlanStatuses.Expired:
case WorkspacePlanStatuses.Canceled:
return 'Plan'
default:
return 'Current plan'
}
})
const summaryBillHeading = computed(() => {
switch (currentPlan.value?.status) {
case WorkspacePlanStatuses.Trial:
case WorkspacePlanStatuses.Expired:
case WorkspacePlanStatuses.Canceled:
return 'Expected bill'
default:
return subscription.value?.billingInterval === BillingInterval.Yearly
? 'Annual bill'
: 'Monthly bill'
}
})
const summaryDateHeading = computed(() => {
if (statusIsTrial.value && isPurchasablePlan.value) {
return 'Trial ends'
} else if (currentPlan.value?.status === WorkspacePlanStatuses.Expired) {
return 'Trial expired at'
} else if (currentPlan.value?.status === WorkspacePlanStatuses.Canceled) {
return 'Cancels'
} else {
return 'Next payment due'
}
})
const showSummaryDateDescription = computed(() => {
return statusIsTrial.value && isPurchasablePlan.value
})
const pricingTableHeading = computed(() => {
switch (currentPlan.value?.status) {
case WorkspacePlanStatuses.Trial:
case WorkspacePlanStatuses.Expired:
return 'Start your subscription'
case WorkspacePlanStatuses.Canceled:
return 'Restart your subscription'
default:
return 'Upgrade your plan'
}
})
const showStatusBadge = computed(() => {
return (
(statusIsTrial.value ||
currentPlan.value?.status === WorkspacePlanStatuses.Expired) &&
isPurchasablePlan.value
)
})
const onPlanSelected = (plan: {
name: PaidWorkspacePlansOld
cycle: BillingInterval
}) => {
const { name, cycle } = plan
if (!isPaidPlan(name) || !workspace.value?.id) return
if (
statusIsTrial.value ||
currentPlan.value?.status === WorkspacePlanStatuses.Expired ||
currentPlan.value?.status === WorkspacePlanStatuses.Canceled
) {
mixpanel.track('Workspace Subscribe Button Clicked', {
plan,
cycle,
// eslint-disable-next-line camelcase
workspace_id: workspace.value?.id
})
redirectToCheckout({
plan: name as unknown as PaidWorkspacePlans,
cycle,
workspaceId: workspace.value?.id
})
} else {
selectedPlanName.value = name
selectedPlanCycle.value = cycle
isUpgradeDialogOpen.value = true
}
}
const handleUpgradeClick = () => {
if (!workspace.value?.id) return
// Temporary hack to change workspace plans to the new free plan
// Temporary hack to change workspace plans to the new free plan
const handleUpgradeClick = (plan: WorkspacePlans) => {
if (!workspaceResult.value?.workspaceBySlug.id) return
mutateWorkspacePlan({
input: {
workspaceId: workspace.value?.id,
plan: WorkspacePlans.Free,
status: WorkspacePlanStatuses.Valid
workspaceId: workspaceResult.value.workspaceBySlug.id,
plan,
status: PaidWorkspacePlanStatuses.Valid
}
})
// Reload to show the new plan, will be gone soon
window.location.reload()
}
</script>
@@ -1,115 +0,0 @@
<!-- This is a temporary component and will replace SettingsWorkspacesBillingPage post-migration -->
<template>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0 flex flex-col gap-y-2 md:gap-y-4">
<SettingsSectionHeader
title="Billing and plans"
text="Update your payment information or switch plans according to your needs"
/>
<div class="flex flex-col gap-y-6 md:gap-y-10">
<!-- Temporary until we can test with real upgrades -->
<section v-if="isServerAdmin" class="flex flex-col gap-y-4 md:gap-y-6">
<div class="flex gap-x-4">
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Free)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 𝖋𝖗𝖊𝖊 𝖕𝖑𝖆𝖓
</FormButton>
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Team)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 𝖙𝖊𝖆𝖒 𝖕𝖑𝖆𝖓
</FormButton>
<FormButton
size="lg"
class="!bg-pink-500 !border-pink-700 mb-4"
@click="handleUpgradeClick(WorkspacePlans.Pro)"
>
𝕮𝖍𝖆𝖓𝖌𝖊 𝖙𝖔 𝖕𝖗𝖔 𝖕𝖑𝖆𝖓
</FormButton>
</div>
</section>
<section v-if="isPurchasablePlan" class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Summary" subheading />
<SettingsWorkspacesBillingSummary :workspace-id="workspace?.id" />
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Usage" subheading />
<SettingsWorkspacesBillingUsage />
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Upgrade your plan" subheading />
<PricingTable
:slug="slug"
:workspace-id="workspace?.id"
:role="workspace?.role as WorkspaceRoles"
/>
</section>
<section class="flex flex-col gap-y-4 md:gap-y-6">
<SettingsSectionHeader title="Add-ons" subheading />
<SettingsWorkspacesBillingAddOns :slug="slug" />
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { useQuery, useMutation } from '@vue/apollo-composable'
import { adminUpdateWorkspacePlanMutation } from '~/lib/billing/graphql/mutations'
import { settingsWorkspaceBillingQueryNew } from '~/lib/settings/graphql/queries'
import {
WorkspacePlans,
PaidWorkspacePlanStatuses,
type WorkspaceRoles
} from '@speckle/shared'
import { useWorkspacePlan } from '~~/lib/workspaces/composables/plan'
import { graphql } from '~/lib/common/generated/gql'
graphql(`
fragment WorkspaceBillingPageNew_Workspace on Workspace {
id
...PricingTable_Workspace
}
`)
const route = useRoute()
const slug = computed(() => (route.params.slug as string) || '')
const { isAdmin: isServerAdmin } = useActiveUser()
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
const { isPurchasablePlan } = useWorkspacePlan(slug.value)
const { mutate: mutateWorkspacePlan } = useMutation(adminUpdateWorkspacePlanMutation)
const { result: workspaceResult } = useQuery(
settingsWorkspaceBillingQueryNew,
() => ({
slug: slug.value
}),
() => ({
enabled: isBillingIntegrationEnabled
})
)
const workspace = computed(() => workspaceResult.value?.workspaceBySlug)
// Temporary hack to change workspace plans to the new free plan
const handleUpgradeClick = (plan: WorkspacePlans) => {
if (!workspaceResult.value?.workspaceBySlug.id) return
mutateWorkspacePlan({
input: {
workspaceId: workspaceResult.value.workspaceBySlug.id,
plan,
status: PaidWorkspacePlanStatuses.Valid
}
})
// Reload to show the new plan, will be gone soon
window.location.reload()
}
</script>
@@ -1,318 +0,0 @@
<template>
<div
class="border border-outline-3 bg-foundation text-foreground rounded-lg p-5 flex flex-col w-full"
>
<div class="flex items-center gap-x-2">
<h4 class="text-body font-medium">
Workspace
<span class="capitalize">{{ plan }}</span>
</h4>
<CommonBadge v-if="badgeText" rounded>
{{ badgeText }}
</CommonBadge>
</div>
<p class="text-body mt-1">
<span class="font-medium">
{{
formatPrice(
props.yearlyIntervalSelected && planPrice?.['workspace:member']
? {
...planPrice['workspace:member'],
amount: planPrice['workspace:member'].amount * 0.8
}
: planPrice?.['workspace:member']
)
}}
</span>
per seat/month
</p>
<div class="flex items-center gap-x-2 mt-3 px-1">
<FormSwitch
v-model="isYearlyIntervalSelected"
:show-label="false"
name="domain-protection"
@update:model-value="(newValue) => $emit('onYearlyIntervalSelected', newValue)"
/>
<span class="text-body-2xs">Billed annually</span>
<CommonBadge rounded color-classes="text-foreground-2 bg-primary-muted">
20% off
</CommonBadge>
</div>
<div v-if="workspaceId || hasCta" class="w-full mt-4">
<div v-if="hasCta">
<slot name="cta" />
</div>
<div v-else>
<!-- Key to fix tippy reactivity -->
<div
:key="`tooltip-${yearlyIntervalSelected}-${plan}-${currentPlan?.name}`"
v-tippy="buttonTooltip"
>
<FormButton
:color="buttonColor"
:disabled="!isSelectable"
full-width
@click="onCtaClick"
>
{{ buttonText }}
</FormButton>
</div>
</div>
</div>
<ul class="flex flex-col gap-y-2 mt-4 pt-3 border-t border-outline-3">
<li
v-for="(featureMetadata, feature) in WorkspacePlanFeaturesMetadata"
:key="feature"
class="flex items-center text-body-xs"
:class="{
'lg:hidden': !planFeatures.includes(feature)
}"
>
<IconCheck
v-if="planFeatures.includes(feature)"
class="w-4 h-4 text-foreground mx-2"
/>
<XMarkIcon v-else class="w-4 h-4 mx-2 text-foreground-3" />
<span
v-tippy="
isFunction(featureMetadata.description)
? featureMetadata.description({
price: formatPrice(planPrice?.[Roles.Workspace.Guest])
})
: featureMetadata.description
"
class="underline decoration-outline-5 decoration-dashed underline-offset-4 cursor-help"
:class="{
'text-foreground-2': !planFeatures.includes(feature)
}"
>
{{ featureMetadata.displayName }}
</span>
</li>
</ul>
<SettingsWorkspacesBillingUpgradeDialog
v-if="currentPlan?.name && workspaceId"
v-model:open="isUpgradeDialogOpen"
:plan="plan"
:billing-interval="
yearlyIntervalSelected ? BillingInterval.Yearly : BillingInterval.Monthly
"
:workspace-id="workspaceId"
/>
</div>
</template>
<script setup lang="ts">
import {
type PaidWorkspacePlansOld,
type MaybeNullOrUndefined,
WorkspacePlans,
WorkspacePlanFeaturesMetadata
} from '@speckle/shared'
import { Roles, WorkspacePlanConfigs } from '@speckle/shared'
import {
type WorkspacePlan,
WorkspacePlanStatuses,
BillingInterval
} from '~/lib/common/generated/gql/graphql'
import { startCase, isFunction } from 'lodash'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import type { SetupContext } from 'vue'
import { useWorkspacePlanPrices } from '~/lib/billing/composables/prices'
import { formatPrice } from '~/lib/billing/helpers/plan'
const emit = defineEmits<{
(e: 'onYearlyIntervalSelected', value: boolean): void
(e: 'onPlanSelected', value: PaidWorkspacePlansOld): void
}>()
const props = defineProps<{
plan: PaidWorkspacePlansOld
yearlyIntervalSelected: boolean
badgeText?: string
// The following props are optional if the table is for informational purposes
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
workspaceId?: string
isAdmin?: boolean
activeBillingInterval?: BillingInterval
}>()
const slots: SetupContext['slots'] = useSlots()
const { prices } = useWorkspacePlanPrices()
const isUpgradeDialogOpen = ref(false)
const isYearlyIntervalSelected = ref(props.yearlyIntervalSelected)
const planFeatures = computed(() => WorkspacePlanConfigs[props.plan].features)
const planPrice = computed(() => prices.value?.[props.plan]?.monthly)
const hasCta = computed(() => !!slots.cta)
const canUpgradeToPlan = computed(() => {
if (!props.currentPlan) return false
const allowedUpgrades: Record<WorkspacePlans, WorkspacePlans[]> = {
[WorkspacePlans.Starter]: [WorkspacePlans.Plus, WorkspacePlans.Business],
[WorkspacePlans.Plus]: [WorkspacePlans.Business],
[WorkspacePlans.Business]: [],
[WorkspacePlans.Academia]: [],
[WorkspacePlans.Unlimited]: [],
[WorkspacePlans.StarterInvoiced]: [],
[WorkspacePlans.PlusInvoiced]: [],
[WorkspacePlans.BusinessInvoiced]: [],
// New
[WorkspacePlans.Free]: [],
[WorkspacePlans.Team]: [],
[WorkspacePlans.Pro]: []
}
return allowedUpgrades[props.currentPlan.name].includes(props.plan)
})
const statusIsTrial = computed(
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
)
const isMatchingInterval = computed(
() =>
props.activeBillingInterval ===
(props.yearlyIntervalSelected ? BillingInterval.Yearly : BillingInterval.Monthly)
)
const isDowngrade = computed(() => {
return !canUpgradeToPlan.value && props.currentPlan?.name !== props.plan
})
const isCurrentPlan = computed(
() => isMatchingInterval.value && props.currentPlan?.name === props.plan
)
const isAnnualToMonthly = computed(() => {
return (
!isMatchingInterval.value &&
props.currentPlan?.name === props.plan &&
!props.yearlyIntervalSelected
)
})
const isMonthlyToAnnual = computed(() => {
return (
!isMatchingInterval.value &&
props.currentPlan?.name === props.plan &&
props.yearlyIntervalSelected
)
})
const isSelectable = computed(() => {
if (!props.isAdmin) return false
// Always enable buttons during trial, expired or canceled state
if (
statusIsTrial.value ||
props.currentPlan?.status === WorkspacePlanStatuses.Expired ||
props.currentPlan?.status === WorkspacePlanStatuses.Canceled
)
return true
// Allow selection if switching from monthly to yearly for the same plan
if (isMonthlyToAnnual.value && props.currentPlan?.name === props.plan) return true
// Disable if current plan and intervals match
if (isCurrentPlan.value) return false
// Handle billing interval changes
if (!isMatchingInterval.value) {
// Allow yearly upgrades from monthly plans
if (isMonthlyToAnnual.value) return canUpgradeToPlan.value
// Never allow switching to monthly if currently on yearly billing
if (props.activeBillingInterval === BillingInterval.Yearly) return false
// Allow monthly plan changes only for upgrades
return canUpgradeToPlan.value
}
// Allow upgrades to higher tier plans
return canUpgradeToPlan.value
})
const buttonColor = computed(() => {
if (
statusIsTrial.value ||
props.currentPlan?.status === WorkspacePlanStatuses.Expired
) {
return props.plan === WorkspacePlans.Starter ? 'primary' : 'outline'
}
return 'outline'
})
const buttonText = computed(() => {
// Allow selection during trial, expired or canceled state
if (
statusIsTrial.value ||
props.currentPlan?.status === WorkspacePlanStatuses.Expired ||
props.currentPlan?.status === WorkspacePlanStatuses.Canceled
) {
return `Subscribe to ${startCase(props.plan)}`
}
// Current plan case
if (isCurrentPlan.value) {
return 'Current plan'
}
// Billing interval and lower plan case
if (isDowngrade.value) {
return `Downgrade to ${props.plan}`
}
// Billing interval change and current plan
if (isAnnualToMonthly.value) {
return 'Change to monthly plan'
}
if (isMonthlyToAnnual.value) {
return 'Change to annual plan'
}
// Upgrade case
return canUpgradeToPlan.value ? `Upgrade to ${startCase(props.plan)}` : ''
})
const buttonTooltip = computed(() => {
if (!props.isAdmin) {
return 'You must be a workspace admin.'
}
if (
statusIsTrial.value ||
isCurrentPlan.value ||
props.currentPlan?.status === WorkspacePlanStatuses.Expired ||
props.currentPlan?.status === WorkspacePlanStatuses.Canceled
)
return undefined
if (isDowngrade.value) {
return 'Downgrading is not supported at the moment. Please contact billing@speckle.systems.'
}
if (isAnnualToMonthly.value) {
return 'Changing from an annual to a monthly plan is currently not supported. Please contact billing@speckle.systems.'
}
if (
props.activeBillingInterval === BillingInterval.Yearly &&
!props.yearlyIntervalSelected &&
canUpgradeToPlan.value
) {
return 'Upgrading from an annual plan to a monthly plan is not supported. Please contact billing@speckle.systems.'
}
return undefined
})
const onCtaClick = () => {
emit('onPlanSelected', props.plan)
}
watch(
() => props.yearlyIntervalSelected,
(newValue) => {
isYearlyIntervalSelected.value = newValue
}
)
</script>
@@ -1,55 +0,0 @@
<template>
<div class="flex flex-col lg:grid lg:grid-cols-3 gap-4 w-full">
<SettingsWorkspacesBillingPricingTablePlan
v-for="plan in oldPlans"
:key="plan"
:plan="plan"
:yearly-interval-selected="isYearlySelected"
v-bind="$props"
@on-yearly-interval-selected="onYearlyIntervalSelected"
@on-plan-selected="onPlanSelected"
/>
</div>
</template>
<script setup lang="ts">
// DEPCRECATED: This is only used for old workspace plans
import { type WorkspacePlan, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { type MaybeNullOrUndefined, PaidWorkspacePlansOld } from '@speckle/shared'
const emit = defineEmits<{
(
e: 'onPlanSelected',
value: { name: PaidWorkspacePlansOld; cycle: BillingInterval }
): void
}>()
const props = defineProps<{
currentPlan?: MaybeNullOrUndefined<WorkspacePlan>
workspaceId?: string
isAdmin?: boolean
activeBillingInterval?: BillingInterval
}>()
const isYearlySelected = ref(false)
const oldPlans = computed(() => Object.values(PaidWorkspacePlansOld))
const onYearlyIntervalSelected = (newValue: boolean) => {
isYearlySelected.value = newValue
}
const onPlanSelected = (value: PaidWorkspacePlansOld) => {
emit('onPlanSelected', {
name: value,
cycle: isYearlySelected.value ? BillingInterval.Yearly : BillingInterval.Monthly
})
}
watch(
() => props.activeBillingInterval,
(newVal) => {
isYearlySelected.value = newVal === BillingInterval.Yearly
},
{ immediate: true }
)
</script>
@@ -0,0 +1,78 @@
<!-- TODO: Update these with real limits once available -->
<template>
<div class="border border-outline-3 rounded-lg divide-y divide-outline-3">
<div class="px-5 py-8 gap-y-6 flex flex-col sm:items-center sm:flex-row">
<div
class="flex-1 flex flex-col gap-y-4 xl:w-[66%] lg:grid lg:grid-cols-2 lg:gap-x-4"
>
<div>
<h3 class="text-body-xs text-foreground-2 pb-2">Editor seats</h3>
<div class="flex items-center gap-x-2">
<p class="text-body-xs text-foreground font-medium leading-none">4</p>
<CommonBadge rounded>2 unused</CommonBadge>
</div>
</div>
<div>
<h3 class="text-body-xs text-foreground-2 pb-2">Viewer seats</h3>
<p class="text-body-xs text-foreground font-medium leading-none">4</p>
</div>
</div>
<div class="flex xl:w-[34%] xl:justify-end">
<FormButton
color="outline"
@click="navigateTo(settingsWorkspaceRoutes.members.route(slug))"
>
Manage members
</FormButton>
</div>
</div>
<div class="px-5 py-8 gap-y-6 flex flex-col sm:items-center sm:flex-row">
<div
class="flex-1 flex flex-col gap-y-4 xl:w-[66%] lg:grid lg:grid-cols-2 lg:gap-x-4"
>
<div>
<h3 class="text-body-xs text-foreground-2 pb-2">Projects</h3>
<p class="text-body-xs text-foreground font-medium pb-3 leading-none">
{{ formatUsageText(10, 100, 'project') }}
</p>
<CommonProgressBar
class="max-w-72 w-full"
:current-value="10"
:max-value="100"
/>
</div>
<div>
<h3 class="text-body-xs text-foreground-2 pb-2">Models</h3>
<p class="text-body-xs text-foreground font-medium pb-3 leading-none">
{{ formatUsageText(10, 100, 'model') }}
</p>
<CommonProgressBar
class="max-w-72 w-full"
:current-value="10"
:max-value="100"
/>
</div>
</div>
<div class="flex xl:w-[34%] xl:justify-end">
<FormButton
color="outline"
@click="navigateTo(settingsWorkspaceRoutes.projects.route(slug))"
>
Manage projects
</FormButton>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
defineProps<{
slug: string
}>()
const formatUsageText = (current: number, max: number, type: string) => {
return `${current} ${type}${current === 1 ? '' : 's'} used / ${max} included`
}
</script>
@@ -1,34 +0,0 @@
<template>
<div class="border border-outline-3 rounded-lg p-6 flex items-center justify-between">
<div class="flex-1 flex flex-col">
<p class="text-body-xs text-foreground font-medium pb-3 leading-none">
{{ text }}
</p>
<CommonProgressBar
class="max-w-72 w-full"
:current-value="currentValue"
:max-value="maxValue"
/>
</div>
<FormButton color="outline">{{ buttonText }}</FormButton>
</div>
</template>
<script lang="ts" setup>
import { CommonProgressBar } from '@speckle/ui-components'
type UsageType = 'seat' | 'project' | 'model'
const props = defineProps<{
buttonText: string
currentValue: number
maxValue: number
type: UsageType
}>()
const text = computed(() => {
return `${props.currentValue} ${props.type}${
props.currentValue === 1 ? '' : 's'
} used / ${props.maxValue} included`
})
</script>
@@ -1,25 +0,0 @@
<!-- TODO: Update these with real limits once available -->
<template>
<div class="flex flex-col gap-y-4">
<SettingsWorkspacesBillingUsageCard
button-text="Manage seats"
:current-value="7"
:max-value="8"
type="seat"
/>
<SettingsWorkspacesBillingUsageCard
button-text="Manage projects"
:current-value="7"
:max-value="50"
type="project"
/>
<SettingsWorkspacesBillingUsageCard
button-text="Manage models"
:current-value="50"
:max-value="50"
type="model"
/>
</div>
</template>
<script lang="ts" setup></script>
@@ -2,32 +2,28 @@
<WorkspaceWizardStep title="Choose a plan">
<div class="flex flex-col max-w-5xl w-full items-center">
<div class="grid lg:grid-cols-3 gap-y-2 gap-x-2 w-full">
<SettingsWorkspacesBillingPricingTablePlan
v-for="plan in oldPlans"
<PricingTablePlan
v-for="plan in plans"
:key="plan"
:plan="plan"
:yearly-interval-selected="isYearlySelected"
:badge-text="
plan === WorkspacePlans.Starter && !isYearlySelected
? '30-day free trial'
: undefined
"
can-upgrade
@on-yearly-interval-selected="onYearlyIntervalSelected"
>
<template #cta>
<FormButton
:color="plan === WorkspacePlans.Starter ? 'primary' : 'outline'"
:color="plan === WorkspacePlans.Free ? 'primary' : 'outline'"
full-width
@click="onCtaClick(plan)"
>
{{
plan === WorkspacePlans.Starter && !isYearlySelected
? 'Start 30-day free trial'
plan === WorkspacePlans.Free && !isYearlySelected
? 'Get started for free'
: `Subscribe to ${startCase(plan)}`
}}
</FormButton>
</template>
</SettingsWorkspacesBillingPricingTablePlan>
</PricingTablePlan>
</div>
<div class="flex flex-col gap-3 mt-4 w-full md:max-w-96">
<FormButton color="subtle" size="lg" full-width @click.stop="goToPreviousStep">
@@ -39,22 +35,22 @@
</template>
<script setup lang="ts">
import {
type PaidWorkspacePlans,
BillingInterval,
WorkspacePlans
} from '~/lib/common/generated/gql/graphql'
import { BillingInterval } from '~/lib/common/generated/gql/graphql'
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
import { useMixpanel } from '~/lib/core/composables/mp'
import { WorkspacePlans, type PaidWorkspacePlans } from '@speckle/shared'
import { startCase } from 'lodash'
import { PaidWorkspacePlansOld } from '@speckle/shared'
const { goToNextStep, goToPreviousStep, state } = useWorkspacesWizard()
const mixpanel = useMixpanel()
const isYearlySelected = ref(false)
const oldPlans = computed(() => Object.values(PaidWorkspacePlansOld))
const plans = computed(() => [
WorkspacePlans.Free,
WorkspacePlans.Team,
WorkspacePlans.Pro
])
const onCtaClick = (plan: WorkspacePlans) => {
state.value.plan = plan as unknown as PaidWorkspacePlans
@@ -76,8 +72,8 @@ const onYearlyIntervalSelected = (newValue: boolean) => {
watch(
() => state.value.billingInterval,
() => {
isYearlySelected.value = state.value.billingInterval === BillingInterval.Yearly
(newVal) => {
isYearlySelected.value = newVal === BillingInterval.Yearly
},
{ immediate: true }
)
@@ -45,19 +45,18 @@ type Documents = {
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": typeof types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\n }\n": typeof types.HeaderWorkspaceSwitcherActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\n }\n }\n": typeof types.HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\n }\n }\n }\n": typeof types.HeaderWorkspaceSwitcherWorkspaceList_UserFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": typeof types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": typeof types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": typeof types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": typeof types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": typeof types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": typeof types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
"\n fragment PricingTable_Workspace on Workspace {\n id\n role\n }\n": typeof types.PricingTable_WorkspaceFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": typeof types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": typeof types.ProjectModelPageVersionsPaginationFragmentDoc,
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": typeof types.ProjectModelPageVersionsProjectFragmentDoc,
@@ -121,8 +120,7 @@ type Documents = {
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": typeof types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": typeof types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": typeof types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n": typeof types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPageNew_Workspace on Workspace {\n id\n ...PricingTable_Workspace\n }\n": typeof types.WorkspaceBillingPageNew_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n }\n": typeof types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersChangeRoleDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": typeof types.SettingsWorkspacesMembersChangeRoleDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n seatType\n joinDate\n user {\n id\n avatar\n name\n workspaceDomainPolicyCompliant(workspaceSlug: $slug)\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": typeof types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n slug\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n team(limit: 250) {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": typeof types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
@@ -314,8 +312,7 @@ type Documents = {
"\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n": typeof types.SettingsSidebarDocument,
"\n query SettingsSidebarAutomateFunctions {\n activeUser {\n ...Sidebar_User\n }\n }\n": typeof types.SettingsSidebarAutomateFunctionsDocument,
"\n query SettingsWorkspaceGeneral($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": typeof types.SettingsWorkspaceGeneralDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n": typeof types.SettingsWorkspaceBillingDocument,
"\n query SettingsWorkspaceBillingNew($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPageNew_Workspace\n }\n }\n": typeof types.SettingsWorkspaceBillingNewDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPage_Workspace\n }\n }\n": typeof types.SettingsWorkspaceBillingDocument,
"\n query SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {\n workspace(id: $workspaceId) {\n customerPortalUrl\n }\n }\n": typeof types.SettingsWorkspaceBillingCustomerPortalDocument,
"\n query SettingsWorkspaceRegions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesRegions_Workspace\n }\n serverInfo {\n ...SettingsWorkspacesRegions_ServerInfo\n }\n }\n": typeof types.SettingsWorkspaceRegionsDocument,
"\n query SettingsWorkspacesMembers($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembers_Workspace\n }\n }\n": typeof types.SettingsWorkspacesMembersDocument,
@@ -454,19 +451,18 @@ const documents: Documents = {
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": types.FormUsersSelectItemFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherActiveWorkspace_Workspace on Workspace {\n id\n name\n logo\n ...HeaderWorkspaceSwitcherHeaderWorkspace_Workspace\n }\n": types.HeaderWorkspaceSwitcherActiveWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_Workspace on Workspace {\n id\n name\n logo\n role\n slug\n creationState {\n completed\n }\n plan {\n name\n }\n }\n": types.HeaderWorkspaceSwitcherWorkspaceList_WorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherWorkspaceList_User on User {\n id\n expiredSsoSessions {\n id\n ...HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace\n }\n workspaces {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceList_Workspace\n }\n }\n }\n": types.HeaderWorkspaceSwitcherWorkspaceList_UserFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.HeaderWorkspaceSwitcherHeaderExpiredSso_LimitedWorkspaceFragmentDoc,
"\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n": types.HeaderWorkspaceSwitcherHeaderWorkspace_WorkspaceFragmentDoc,
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
"\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.HeaderNavNotificationsProjectInvite_PendingStreamCollaboratorFragmentDoc,
"\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n name\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
"\n fragment PricingTable_Workspace on Workspace {\n id\n role\n }\n": types.PricingTable_WorkspaceFragmentDoc,
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": types.ProjectModelPageHeaderProjectFragmentDoc,
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectModelPageVersionsPaginationFragmentDoc,
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": types.ProjectModelPageVersionsProjectFragmentDoc,
@@ -530,8 +526,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspaceGeneralDeleteDialog_Workspace on Workspace {\n id\n name\n }\n": types.SettingsWorkspaceGeneralDeleteDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneralEditSlugDialog_Workspace on Workspace {\n id\n name\n slug\n }\n": types.SettingsWorkspacesGeneralEditSlugDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPageNew_Workspace on Workspace {\n id\n ...PricingTable_Workspace\n }\n": types.WorkspaceBillingPageNew_WorkspaceFragmentDoc,
"\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n }\n": types.WorkspaceBillingPage_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersChangeRoleDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsWorkspacesMembersChangeRoleDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n seatType\n joinDate\n user {\n id\n avatar\n name\n workspaceDomainPolicyCompliant(workspaceSlug: $slug)\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc,
"\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n slug\n name\n ...SettingsWorkspacesMembersTableHeader_Workspace\n ...SettingsSharedDeleteUserDialog_Workspace\n team(limit: 250) {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc,
@@ -723,8 +718,7 @@ const documents: Documents = {
"\n query SettingsSidebar {\n activeUser {\n ...SettingsSidebar_User\n }\n }\n": types.SettingsSidebarDocument,
"\n query SettingsSidebarAutomateFunctions {\n activeUser {\n ...Sidebar_User\n }\n }\n": types.SettingsSidebarAutomateFunctionsDocument,
"\n query SettingsWorkspaceGeneral($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n": types.SettingsWorkspaceGeneralDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n": types.SettingsWorkspaceBillingDocument,
"\n query SettingsWorkspaceBillingNew($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPageNew_Workspace\n }\n }\n": types.SettingsWorkspaceBillingNewDocument,
"\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPage_Workspace\n }\n }\n": types.SettingsWorkspaceBillingDocument,
"\n query SettingsWorkspaceBillingCustomerPortal($workspaceId: String!) {\n workspace(id: $workspaceId) {\n customerPortalUrl\n }\n }\n": types.SettingsWorkspaceBillingCustomerPortalDocument,
"\n query SettingsWorkspaceRegions($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesRegions_Workspace\n }\n serverInfo {\n ...SettingsWorkspacesRegions_ServerInfo\n }\n }\n": types.SettingsWorkspaceRegionsDocument,
"\n query SettingsWorkspacesMembers($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembers_Workspace\n }\n }\n": types.SettingsWorkspacesMembersDocument,
@@ -970,6 +964,18 @@ export function graphql(source: "\n fragment FormSelectProjects_Project on Proj
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n"): (typeof documents)["\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n"): (typeof documents)["\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -990,18 +996,6 @@ export function graphql(source: "\n fragment HeaderWorkspaceSwitcherHeaderExpir
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment HeaderWorkspaceSwitcherHeaderWorkspace_Workspace on Workspace {\n ...InviteDialogWorkspace_Workspace\n id\n name\n logo\n role\n plan {\n name\n }\n team {\n totalCount\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n"): (typeof documents)["\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsProjectInvite_PendingStreamCollaborator on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"): (typeof documents)["\n fragment HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1018,10 +1012,6 @@ export function graphql(source: "\n fragment InviteDialogProjectWorkspaceMember
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"): (typeof documents)["\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PricingTable_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment PricingTable_Workspace on Workspace {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1277,11 +1267,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesGeneralEditSlugD
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceBillingPageNew_Workspace on Workspace {\n id\n ...PricingTable_Workspace\n }\n"): (typeof documents)["\n fragment WorkspaceBillingPageNew_Workspace on Workspace {\n id\n ...PricingTable_Workspace\n }\n"];
export function graphql(source: "\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n }\n"): (typeof documents)["\n fragment WorkspaceBillingPage_Workspace on Workspace {\n id\n role\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2049,11 +2035,7 @@ export function graphql(source: "\n query SettingsWorkspaceGeneral($slug: Strin
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesBilling_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query SettingsWorkspaceBillingNew($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPageNew_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceBillingNew($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPageNew_Workspace\n }\n }\n"];
export function graphql(source: "\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPage_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspaceBilling($slug: String!) {\n workspaceBySlug(slug: $slug) {\n id\n ...WorkspaceBillingPage_Workspace\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -28,17 +28,7 @@ export const settingsWorkspaceBillingQuery = graphql(`
query SettingsWorkspaceBilling($slug: String!) {
workspaceBySlug(slug: $slug) {
id
...SettingsWorkspacesBilling_Workspace
}
}
`)
// TODO: Remove old one post-migration
export const settingsWorkspaceBillingQueryNew = graphql(`
query SettingsWorkspaceBillingNew($slug: String!) {
workspaceBySlug(slug: $slug) {
id
...WorkspaceBillingPageNew_Workspace
...WorkspaceBillingPage_Workspace
}
}
`)
@@ -17,7 +17,7 @@ import { useMutation } from '@vue/apollo-composable'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { mapMainRoleToGqlWorkspaceRole } from '~/lib/workspaces/helpers/roles'
import { mapServerRoleToGqlServerRole } from '~/lib/common/helpers/roles'
import { Roles } from '@speckle/shared'
import { Roles, WorkspacePlans } from '@speckle/shared'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useNavigation } from '~/lib/navigation/composables/navigation'
@@ -120,7 +120,7 @@ export const useWorkspacesWizard = () => {
mixpanel.stop_session_recording()
const needsCheckout =
wizardState.value.state.plan !== PaidWorkspacePlans.Starter ||
wizardState.value.state.plan !== WorkspacePlans.Free ||
wizardState.value.state.billingInterval === BillingInterval.Yearly
const workspaceId = ref(wizardState.value.state.id)
const isNewWorkspace = !workspaceId.value
@@ -263,7 +263,7 @@ export const useWorkspacesWizard = () => {
}
if (
state.plan === PaidWorkspacePlans.Starter &&
state.plan === WorkspacePlans.Free &&
state.billingInterval === BillingInterval.Monthly
) {
triggerNotification({
@@ -1,6 +1,6 @@
import type {
BillingInterval,
PaidWorkspacePlans,
WorkspacePlans,
SettingsWorkspacesRegionsSelect_ServerRegionItemFragment
} from '~/lib/common/generated/gql/graphql'
@@ -34,7 +34,7 @@ export type WorkspaceWizardState = {
name: string
slug: string
invites: string[]
plan: PaidWorkspacePlans | null
plan: WorkspacePlans | null
billingInterval: BillingInterval | null
id: string
region: SettingsWorkspacesRegionsSelect_ServerRegionItemFragment | null
@@ -1,7 +1,6 @@
<template>
<div>
<SettingsWorkspacesBillingPageNew v-if="isWorkspaceNewPlansEnabled && !forceOld" />
<SettingsWorkspacesBillingPage v-else />
<SettingsWorkspacesBillingPage />
</div>
</template>
@@ -13,8 +12,4 @@ definePageMeta({
useHead({
title: 'Settings | Workspace - Billing'
})
const route = useRoute()
const isWorkspaceNewPlansEnabled = useWorkspaceNewPlansEnabled()
const forceOld = computed(() => route.query.old === 'true')
</script>