This commit is contained in:
Mike 2024-11-20 13:10:36 +01:00 коммит произвёл GitHub
Родитель 5562d4115e
Коммит a0fb2edc2b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
10 изменённых файлов: 318 добавлений и 179 удалений

Просмотреть файл

@ -1,6 +1,6 @@
<template>
<section>
<div class="md:mx-auto pb-6 md:pb-0">
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Billing" text="Your workspace billing details" />
<template v-if="isBillingIntegrationEnabled">
<div class="flex flex-col gap-y-4 md:gap-y-6">
@ -14,7 +14,7 @@
<SettingsSectionHeader title="Billing summary" subheading class="pt-4" />
<div class="border border-outline-3 rounded-lg">
<div
class="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x"
class="grid grid-cols-1 md:grid-cols-3 divide-y divide-outline-3 md:divide-y-0 md:divide-x"
>
<div class="p-5 pt-4 flex flex-col gap-y-1">
<h3 class="text-body-xs text-foreground-2 pb-2">
@ -89,6 +89,7 @@
class="pt-6"
:workspace-id="workspaceId"
:current-plan="currentPlan"
:is-admin="isAdmin"
/>
</div>
</template>
@ -111,10 +112,12 @@ import {
import { useBillingActions } from '~/lib/billing/composables/actions'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import { Roles } from '@speckle/shared'
graphql(`
fragment SettingsWorkspacesBilling_Workspace on Workspace {
...BillingAlert_Workspace
id
role
plan {
...SettingsWorkspacesBillingPricingTable_WorkspacePlan
name
@ -186,6 +189,9 @@ const nextPaymentDue = computed(() =>
: 'Never'
: dayjs().add(30, 'days').format('MMMM D, YYYY')
)
const isAdmin = computed(
() => workspaceResult.value?.workspace.role === Roles.Workspace.Admin
)
onMounted(() => {
const paymentStatusQuery = route.query?.payment_status

Просмотреть файл

@ -1,156 +0,0 @@
<template>
<div class="flex flex-col gap-y-6">
<div class="flex justify-between">
<SettingsSectionHeader
:title="hasTrialPlan ? 'Start your subscription' : 'Upgrade your plan'"
subheading
/>
<div class="flex items-center gap-x-4">
<p class="text-foreground-3 text-body-xs">Save 20% with annual billing</p>
<FormSwitch v-model="isYearlyPlan" :show-label="false" name="annual billing" />
</div>
</div>
<table class="w-full flex flex-col">
<thead>
<tr class="w-full flex">
<th class="w-1/4 flex pl-5 pr-6 pt-4 pb-2 font-medium">
<h4>Compare plans</h4>
</th>
<th
v-for="plan in pricingPlans"
:key="plan.name"
class="w-1/4 flex flex-col gap-y-1 px-6 pt-4 pb-2 font-normal"
:class="[
plan.name === WorkspacePlans.Team
? 'border border-b-0 border-outline-3 bg-foundation-2 rounded-t-lg'
: ''
]"
scope="col"
>
<h4 class="text-foreground text-body-xs">
Workspace
<span class="capitalize">{{ plan.name }}</span>
</h4>
<p class="text-foreground text-heading font-normal">
£{{
isYearlyPlan
? plan.cost.yearly[Roles.Workspace.Member]
: plan.cost.monthly[Roles.Workspace.Member]
}}
per seat/month
</p>
<p class="text-foreground-2 text-body-2xs pt-1">
Billed {{ isYearlyPlan ? 'annually' : 'monthly' }}
</p>
<FormButton
:color="plan.name === WorkspacePlans.Team ? 'primary' : 'outline'"
:disabled="!hasTrialPlan && !canUpgradeToPlan(plan.name)"
class="mt-3"
@click="onUpgradePlanClick(plan.name)"
>
{{ hasTrialPlan ? 'Subscribe' : 'Upgrade' }} to&nbsp;
<span class="capitalize">{{ plan.name }}</span>
</FormButton>
</th>
</tr>
</thead>
<tbody class="w-full flex flex-col">
<tr v-for="(feature, key, index) in features" :key="key" class="flex">
<th
class="font-normal text-foreground text-body-xs w-1/4 pr-3 pt-1"
scope="row"
>
<div class="border-b border-outline-3 min-h-[42px] pl-5 flex items-center">
{{ feature.name }}
</div>
</th>
<td
v-for="plan in pricingPlans"
:key="plan.name"
class="px-3 w-1/4 pt-1"
:class="[
plan.name === WorkspacePlans.Team
? 'border-l border-r border-outline-3 bg-foundation-2'
: '',
plan.name === WorkspacePlans.Team &&
index === Object.values(features).length - 1
? 'pb-6 border-b rounded-b-lg'
: ''
]"
>
<div class="border-b border-outline-3 flex items-center px-3 min-h-[42px]">
<CheckIcon
v-if="plan.features.includes(feature.name)"
class="w-3 h-3 text-foreground"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { SettingsWorkspacesBillingPricingTable_WorkspacePlanFragment } from '~/lib/common/generated/gql/graphql'
import {
WorkspacePlans,
BillingInterval,
WorkspacePlanStatuses
} from '~/lib/common/generated/gql/graphql'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import { CheckIcon } from '@heroicons/vue/24/outline'
import { useBillingActions } from '~/lib/billing/composables/actions'
import { graphql } from '~/lib/common/generated/gql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { Roles } from '@speckle/shared'
graphql(`
fragment SettingsWorkspacesBillingPricingTable_WorkspacePlan on WorkspacePlan {
name
status
}
`)
const props = defineProps<{
workspaceId: string
currentPlan: MaybeNullOrUndefined<SettingsWorkspacesBillingPricingTable_WorkspacePlanFragment>
}>()
const { upgradePlanRedirect } = useBillingActions()
const pricingPlans = ref([
pricingPlansConfig.plans[WorkspacePlans.Team],
pricingPlansConfig.plans[WorkspacePlans.Pro],
pricingPlansConfig.plans[WorkspacePlans.Business]
])
const features = ref(pricingPlansConfig.features)
const isYearlyPlan = ref(false)
const onUpgradePlanClick = (plan: WorkspacePlans) => {
upgradePlanRedirect({
plan,
cycle: isYearlyPlan.value ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
}
const hasTrialPlan = computed(
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
)
const canUpgradeToPlan = (plan: WorkspacePlans) => {
if (!props.currentPlan?.name) return false
const allowedUpgrades: Record<WorkspacePlans, WorkspacePlans[]> = {
[WorkspacePlans.Team]: [WorkspacePlans.Pro, WorkspacePlans.Business],
[WorkspacePlans.Pro]: [WorkspacePlans.Business],
[WorkspacePlans.Business]: [],
[WorkspacePlans.Academia]: [],
[WorkspacePlans.Unlimited]: []
}
return allowedUpgrades[props.currentPlan.name]?.includes(plan) ?? false
}
</script>

Просмотреть файл

@ -0,0 +1,82 @@
<template>
<table class="w-full flex flex-col">
<thead>
<tr class="w-full flex">
<th class="w-1/4 flex pl-5 pr-6 pt-4 pb-2 font-medium">
<h4>Compare plans</h4>
</th>
<th
v-for="plan in plans"
:key="`desktop-${plan.name}`"
class="w-1/4 px-6 pt-4 pb-2"
:class="[
plan.name === WorkspacePlans.Team
? 'border border-b-0 border-outline-3 bg-foundation-2 rounded-t-lg'
: ''
]"
scope="col"
>
<SettingsWorkspacesBillingPricingTableHeader
:plan="plan"
:is-yearly-plan="isYearlyPlan"
:current-plan="currentPlan"
:workspace-id="workspaceId"
:is-admin="isAdmin"
/>
</th>
</tr>
</thead>
<tbody class="w-full flex flex-col">
<tr v-for="(feature, key, index) in features" :key="key" class="flex">
<th
class="font-normal text-foreground text-body-xs w-1/4 pr-3 pt-1"
scope="row"
>
<div class="border-b border-outline-3 min-h-[42px] pl-5 flex items-center">
{{ feature.name }}
</div>
</th>
<td
v-for="plan in plans"
:key="plan.name"
class="px-3 w-1/4 pt-1"
:class="[
plan.name === WorkspacePlans.Team
? 'border-l border-r border-outline-3 bg-foundation-2'
: '',
plan.name === WorkspacePlans.Team &&
index === Object.values(features).length - 1
? 'pb-6 border-b rounded-b-lg'
: ''
]"
>
<div class="border-b border-outline-3 flex items-center px-3 min-h-[42px]">
<CheckIcon
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
class="w-3 h-3 text-foreground"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
import { WorkspacePlans } from '~/lib/common/generated/gql/graphql'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
import { CheckIcon } from '@heroicons/vue/24/outline'
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineProps<{
isYearlyPlan: boolean
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
workspaceId: string
isAdmin: boolean
}>()
const plans = ref(pricingPlansConfig.plans)
const features = ref(pricingPlansConfig.features)
</script>

Просмотреть файл

@ -0,0 +1,79 @@
<template>
<div class="flex flex-col gap-y-1 font-normal">
<h4 class="text-foreground text-body-xs">
Workspace
<span class="capitalize">{{ plan.name }}</span>
</h4>
<p class="text-foreground text-heading">
£{{
isYearlyPlan
? plan.cost.yearly[Roles.Workspace.Member]
: plan.cost.monthly[Roles.Workspace.Member]
}}
per seat/month
</p>
<p class="text-foreground-2 text-body-2xs pt-1">
Billed {{ isYearlyPlan ? 'annually' : 'monthly' }}
</p>
<div class="w-full">
<FormButton
:color="plan.name === WorkspacePlans.Team ? 'primary' : 'outline'"
:disabled="(!hasTrialPlan && !canUpgradeToPlan) || !isAdmin"
class="mt-3"
full-width
@click="onUpgradePlanClick(plan.name)"
>
{{ hasTrialPlan ? 'Subscribe' : 'Upgrade' }} to&nbsp;
<span class="capitalize">{{ plan.name }}</span>
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import { type PricingPlan } from '@/lib/billing/helpers/types'
import { Roles } from '@speckle/shared'
import {
type WorkspacePlan,
WorkspacePlanStatuses,
WorkspacePlans,
BillingInterval
} from '~/lib/common/generated/gql/graphql'
import { useBillingActions } from '~/lib/billing/composables/actions'
import type { MaybeNullOrUndefined } from '@speckle/shared'
const props = defineProps<{
plan: PricingPlan
isYearlyPlan: boolean
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
workspaceId: string
isAdmin: boolean
}>()
const { upgradePlanRedirect } = useBillingActions()
const canUpgradeToPlan = computed(() => {
if (!props.currentPlan) return false
const allowedUpgrades: Record<WorkspacePlans, WorkspacePlans[]> = {
[WorkspacePlans.Team]: [WorkspacePlans.Pro, WorkspacePlans.Business],
[WorkspacePlans.Pro]: [WorkspacePlans.Business],
[WorkspacePlans.Business]: [],
[WorkspacePlans.Academia]: [],
[WorkspacePlans.Unlimited]: []
}
return allowedUpgrades[props.currentPlan.name].includes(props.plan.name)
})
const hasTrialPlan = computed(
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
)
const onUpgradePlanClick = (plan: WorkspacePlans) => {
upgradePlanRedirect({
plan,
cycle: props.isYearlyPlan ? BillingInterval.Yearly : BillingInterval.Monthly,
workspaceId: props.workspaceId
})
}
</script>

Просмотреть файл

@ -0,0 +1,48 @@
<template>
<div class="flex flex-col gap-y-4">
<div
v-for="plan in plans"
:key="`mobile-${plan.name}`"
class="border border-outline-3 bg-foundation rounded-lg p-4 pb-2"
>
<SettingsWorkspacesBillingPricingTableHeader
:plan="plan"
:is-yearly-plan="isYearlyPlan"
:current-plan="currentPlan"
:workspace-id="workspaceId"
:is-admin="isAdmin"
/>
<ul class="flex flex-col gap-y-2 mt-6">
<li
v-for="feature in features"
:key="feature.name"
class="flex items-center justify-between border-b last:border-b-0 border-outline-3 pb-2"
>
{{ feature.name }}
<CheckIcon
v-if="plan.features.includes(feature.name as PlanFeaturesList)"
class="w-3 h-3 text-foreground"
/>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import type { WorkspacePlan } from '~/lib/common/generated/gql/graphql'
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
import type { PlanFeaturesList } from '~/lib/billing/helpers/types'
import { CheckIcon } from '@heroicons/vue/24/outline'
import type { MaybeNullOrUndefined } from '@speckle/shared'
defineProps<{
isYearlyPlan: boolean
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
workspaceId: string
isAdmin: boolean
}>()
const plans = ref(pricingPlansConfig.plans)
const features = ref(pricingPlansConfig.features)
</script>

Просмотреть файл

@ -0,0 +1,60 @@
<template>
<div class="flex flex-col gap-y-6">
<div class="flex flex-col lg:flex-row justify-between gap-y-4">
<SettingsSectionHeader
:title="hasTrialPlan ? 'Start your subscription' : 'Upgrade your plan'"
subheading
/>
<div class="flex items-center gap-x-4">
<p class="text-foreground-3 text-body-xs">Save 20% with annual billing</p>
<FormSwitch v-model="isYearlyPlan" :show-label="false" name="annual billing" />
</div>
</div>
<component
:is="isDesktop ? DesktopTable : MobileTable"
:workspace-id="workspaceId"
:current-plan="currentPlan"
:is-yearly-plan="isYearlyPlan"
:is-admin="isAdmin"
/>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import {
WorkspacePlanStatuses,
type WorkspacePlan
} from '~/lib/common/generated/gql/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type { MaybeNullOrUndefined } from '@speckle/shared'
graphql(`
fragment SettingsWorkspacesBillingPricingTable_WorkspacePlan on WorkspacePlan {
name
status
}
`)
const props = defineProps<{
workspaceId: string
currentPlan: MaybeNullOrUndefined<WorkspacePlan>
isAdmin: boolean
}>()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const DesktopTable = defineAsyncComponent(
() => import('@/components/settings/workspaces/billing/PricingTable/Desktop.vue')
)
const MobileTable = defineAsyncComponent(
() => import('@/components/settings/workspaces/billing/PricingTable/Mobile.vue')
)
const isDesktop = breakpoints.greaterOrEqual('lg')
const isYearlyPlan = ref(false)
const hasTrialPlan = computed(
() => props.currentPlan?.status === WorkspacePlanStatuses.Trial || !props.currentPlan
)
</script>

Просмотреть файл

@ -1,16 +1,6 @@
import { WorkspacePlans, BillingInterval } from '~/lib/common/generated/gql/graphql'
import { Roles } from '@speckle/shared'
enum PlanFeaturesList {
Workspaces = 'Workspaces',
RoleManagement = 'Role management',
GuestUsers = 'Guest users',
PrivateAutomateFunctions = 'Private automate functions',
DomainSecurity = 'Domain security',
SSO = 'Single Sign-On (SSO)',
CustomerDataRegion = 'Customer data region',
PrioritySupport = 'Priority support'
}
import { PlanFeaturesList, type PricingPlan } from '@/lib/billing/helpers/types'
const baseFeatures = [
PlanFeaturesList.Workspaces,
@ -20,7 +10,13 @@ const baseFeatures = [
PlanFeaturesList.DomainSecurity
]
export const pricingPlansConfig = {
export const pricingPlansConfig: {
features: Record<PlanFeaturesList, { name: string; description: string }>
plans: Record<
WorkspacePlans.Team | WorkspacePlans.Pro | WorkspacePlans.Business,
PricingPlan
>
} = {
features: {
[PlanFeaturesList.Workspaces]: {
name: PlanFeaturesList.Workspaces,
@ -46,8 +42,8 @@ export const pricingPlansConfig = {
name: PlanFeaturesList.SSO,
description: ''
},
[PlanFeaturesList.CustomerDataRegion]: {
name: PlanFeaturesList.CustomerDataRegion,
[PlanFeaturesList.CustomDataRegion]: {
name: PlanFeaturesList.CustomDataRegion,
description: ''
},
[PlanFeaturesList.PrioritySupport]: {
@ -93,7 +89,7 @@ export const pricingPlansConfig = {
features: [
...baseFeatures,
PlanFeaturesList.SSO,
PlanFeaturesList.CustomerDataRegion,
PlanFeaturesList.CustomDataRegion,
PlanFeaturesList.PrioritySupport
],
cost: {

Просмотреть файл

@ -0,0 +1,24 @@
import type {
BillingInterval,
WorkspacePlans
} from '~/lib/common/generated/gql/graphql'
import type { WorkspaceRoles } from '@speckle/shared'
export enum PlanFeaturesList {
Workspaces = 'Workspaces',
RoleManagement = 'Role management',
GuestUsers = 'Guest users',
PrivateAutomateFunctions = 'Private automate functions',
DomainSecurity = 'Domain security',
SSO = 'Single Sign-On (SSO)',
CustomDataRegion = 'Custom data region',
PrioritySupport = 'Priority support'
}
export type PricingPlan = {
name: WorkspacePlans
features: PlanFeaturesList[]
cost: {
[I in BillingInterval]: Record<WorkspaceRoles, number>
}
}

Просмотреть файл

@ -114,7 +114,7 @@ const documents = {
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
"\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 defaultLogoIndex\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc,
@ -770,7 +770,7 @@ export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User o
/**
* 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 plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
export function graphql(source: "\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n ...SettingsWorkspacesBillingPricingTable_WorkspacePlan\n name\n status\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

Различия файлов скрыты, потому что одна или несколько строк слишком длинны