feat: add database backed membership tiers
This commit is contained in:
@@ -18,12 +18,15 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
const client = useClient();
|
||||
|
||||
const organizations = ref([]);
|
||||
const membershipTiers = ref([]);
|
||||
const selectedOrganizationId = ref(null);
|
||||
const detailsById = ref({});
|
||||
const isLoading = ref(false);
|
||||
const isLoadingDetails = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isLoadingMembershipTiers = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isUpdatingMembershipTier = ref(false);
|
||||
const isAddingMember = ref(false);
|
||||
const isUploadingLogo = ref(false);
|
||||
const error = ref(null);
|
||||
@@ -90,6 +93,28 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMembershipTiers() {
|
||||
if (membershipTiers.value.length > 0) {
|
||||
return membershipTiers.value;
|
||||
}
|
||||
|
||||
isLoadingMembershipTiers.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/organization-membership-tiers');
|
||||
membershipTiers.value = response.data ?? [];
|
||||
return membershipTiers.value;
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch organization membership tiers:', fetchError);
|
||||
membershipTiers.value = [];
|
||||
error.value = 'Failed to load membership tiers.';
|
||||
return [];
|
||||
} finally {
|
||||
isLoadingMembershipTiers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrganization(organizationId) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
return null;
|
||||
@@ -190,6 +215,51 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMembershipTier(organizationId, membershipTierId) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to update an organization membership tier.');
|
||||
}
|
||||
|
||||
isUpdatingMembershipTier.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/organizations/${organizationId}/membership-tier`, {
|
||||
membershipTierId,
|
||||
});
|
||||
const organization = response.data;
|
||||
|
||||
if (organization) {
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...(currentDetails ?? {}),
|
||||
...organization,
|
||||
members: currentDetails?.members ?? organization.members ?? [],
|
||||
workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [],
|
||||
usage: currentDetails?.usage ?? organization.usage ?? null,
|
||||
availableMembershipTiers: currentDetails?.availableMembershipTiers ?? organization.availableMembershipTiers ?? [],
|
||||
},
|
||||
};
|
||||
organizations.value = organizations.value.map(candidate =>
|
||||
candidate.id === organizationId
|
||||
? { ...candidate, ...organization }
|
||||
: candidate
|
||||
);
|
||||
}
|
||||
|
||||
await fetchOrganization(organizationId);
|
||||
return detailsById.value[organizationId] ?? organization;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update organization membership tier:', updateError);
|
||||
error.value = 'Failed to update organization membership tier.';
|
||||
throw updateError;
|
||||
} finally {
|
||||
isUpdatingMembershipTier.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to add an organization member.');
|
||||
@@ -291,13 +361,16 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
|
||||
return {
|
||||
organizations,
|
||||
membershipTiers,
|
||||
selectedOrganizationId,
|
||||
activeOrganization,
|
||||
detailsById,
|
||||
isLoading,
|
||||
isLoadingDetails,
|
||||
isCreating,
|
||||
isLoadingMembershipTiers,
|
||||
isSaving,
|
||||
isUpdatingMembershipTier,
|
||||
isAddingMember,
|
||||
isUploadingLogo,
|
||||
error,
|
||||
@@ -305,9 +378,11 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
setSelectedOrganization,
|
||||
setSelectedOrganizationFromWorkspace,
|
||||
fetchOrganizations,
|
||||
fetchMembershipTiers,
|
||||
fetchOrganization,
|
||||
createOrganization,
|
||||
updateOrganization,
|
||||
updateMembershipTier,
|
||||
addMember,
|
||||
uploadLogo,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
membershipTierId: null,
|
||||
});
|
||||
const accessForm = reactive({
|
||||
targetType: 'organization',
|
||||
@@ -27,6 +28,29 @@
|
||||
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
|
||||
{ title: t('organizationOnboarding.request.types.workspace'), value: 'workspace' },
|
||||
]);
|
||||
const membershipTierOptions = computed(() =>
|
||||
organizationStore.membershipTiers.map(tier => ({
|
||||
title: tier.name,
|
||||
value: tier.id,
|
||||
props: {
|
||||
subtitle: formatTierPrice(tier),
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
function formatTierPrice(tier) {
|
||||
if (tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined) {
|
||||
return t('organizationOnboarding.create.tiers.customPrice');
|
||||
}
|
||||
|
||||
if (tier.monthlyPriceCents === 0) {
|
||||
return t('organizationOnboarding.create.tiers.freePrice');
|
||||
}
|
||||
|
||||
return t('organizationOnboarding.create.tiers.monthlyPrice', {
|
||||
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function createOrganization() {
|
||||
if (organizationStore.isCreating) {
|
||||
@@ -42,7 +66,10 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.createOrganization({ name });
|
||||
await organizationStore.createOrganization({
|
||||
name,
|
||||
membershipTierId: createForm.membershipTierId,
|
||||
});
|
||||
await Promise.all([
|
||||
organizationStore.fetchOrganizations(),
|
||||
workspaceStore.fetchWorkspaces(),
|
||||
@@ -73,6 +100,20 @@
|
||||
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
requestStatus.value = t('organizationOnboarding.request.sent');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const tiers = await organizationStore.fetchMembershipTiers();
|
||||
createForm.membershipTierId = tiers[0]?.id ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => organizationStore.membershipTiers,
|
||||
tiers => {
|
||||
if (!createForm.membershipTierId) {
|
||||
createForm.membershipTierId = tiers[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -114,6 +155,15 @@
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<v-select
|
||||
v-model="createForm.membershipTierId"
|
||||
:items="membershipTierOptions"
|
||||
:label="t('organizationOnboarding.create.fields.membershipTier')"
|
||||
:loading="organizationStore.isLoadingMembershipTiers"
|
||||
:disabled="organizationStore.isCreating"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
:loading="organizationStore.isCreating"
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
email: '',
|
||||
role: 'Member',
|
||||
});
|
||||
const membershipTierForm = reactive({
|
||||
membershipTierId: null,
|
||||
});
|
||||
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
|
||||
|
||||
const organizationId = computed(() => route.params.organizationId);
|
||||
@@ -62,6 +65,18 @@
|
||||
permissions.value.includes(organizationPermissions.createWorkspaces)
|
||||
);
|
||||
const usageItems = computed(() => organization.value?.usage?.items ?? []);
|
||||
const membershipTierOptions = computed(() =>
|
||||
(organization.value?.availableMembershipTiers?.length
|
||||
? organization.value.availableMembershipTiers
|
||||
: organizationStore.membershipTiers
|
||||
).map(tier => ({
|
||||
title: tier.name,
|
||||
value: tier.id,
|
||||
props: {
|
||||
subtitle: formatTierSummary(tier),
|
||||
},
|
||||
}))
|
||||
);
|
||||
const visibleSections = computed(() => [
|
||||
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
|
||||
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
|
||||
@@ -79,7 +94,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await organizationStore.fetchOrganization(organizationId.value);
|
||||
await Promise.all([
|
||||
organizationStore.fetchMembershipTiers(),
|
||||
organizationStore.fetchOrganization(organizationId.value),
|
||||
]);
|
||||
}
|
||||
|
||||
async function submitProfile() {
|
||||
@@ -156,6 +174,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMembershipTier() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
if (!membershipTierForm.membershipTierId) {
|
||||
settingsError.value = t('organizationSettings.errors.membershipTierRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await organizationStore.updateMembershipTier(
|
||||
organizationId.value,
|
||||
membershipTierForm.membershipTierId
|
||||
);
|
||||
settingsStatus.value = t('organizationSettings.tierSaved');
|
||||
} catch (error) {
|
||||
console.error('Failed to update organization membership tier:', error);
|
||||
settingsError.value = t('organizationSettings.errors.tierSaveFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function formatTierSummary(tier) {
|
||||
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
|
||||
? t('organizationSettings.tiers.customPrice')
|
||||
: tier.monthlyPriceCents === 0
|
||||
? t('organizationSettings.tiers.freePrice')
|
||||
: t('organizationSettings.tiers.monthlyPrice', {
|
||||
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
|
||||
});
|
||||
|
||||
return t('organizationSettings.tiers.summary', {
|
||||
price,
|
||||
workspaces: formatLimit(tier.workspaceLimit),
|
||||
members: formatLimit(tier.memberLimit),
|
||||
activeContent: formatLimit(tier.activeContentLimit),
|
||||
});
|
||||
}
|
||||
|
||||
function formatLimit(limit) {
|
||||
return limit ?? t('organizationSettings.usage.unlimited');
|
||||
}
|
||||
|
||||
function usagePercent(item) {
|
||||
if (!item.limit) {
|
||||
return 0;
|
||||
@@ -171,6 +231,7 @@
|
||||
organization,
|
||||
currentOrganization => {
|
||||
profileForm.name = currentOrganization?.name ?? '';
|
||||
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -408,8 +469,30 @@
|
||||
>
|
||||
<div class="usage-plan">
|
||||
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
|
||||
<span>{{ organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
|
||||
<span>{{ organization.membershipTier?.name ?? organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
|
||||
</div>
|
||||
<v-form
|
||||
v-if="canViewBilling"
|
||||
class="tier-form"
|
||||
@submit.prevent="submitMembershipTier"
|
||||
>
|
||||
<v-select
|
||||
v-model="membershipTierForm.membershipTierId"
|
||||
:items="membershipTierOptions"
|
||||
:label="t('organizationSettings.fields.membershipTier')"
|
||||
:loading="organizationStore.isLoadingMembershipTiers"
|
||||
:disabled="organizationStore.isUpdatingMembershipTier"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
type="submit"
|
||||
:loading="organizationStore.isUpdatingMembershipTier"
|
||||
>
|
||||
{{ t('organizationSettings.saveTier') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
<div
|
||||
v-for="item in usageItems"
|
||||
:key="item.key"
|
||||
@@ -717,6 +800,11 @@
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.tier-form {
|
||||
@apply grid gap-3 rounded-[0.75rem] p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.usage-plan,
|
||||
.usage-row {
|
||||
@apply rounded-[0.75rem] p-4;
|
||||
|
||||
Reference in New Issue
Block a user