feat: add database backed membership tiers
All checks were successful
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 20:29:53 -04:00
parent db16e79d9f
commit 6d92119c9c
23 changed files with 3512 additions and 30 deletions

View File

@@ -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;