chore: Update the Pricing plan section
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: value => ['ruby', 'blue', 'teal', 'amber'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'i-lucide-info',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const colorClasses = {
|
||||
ruby: 'outline-n-ruby-4 bg-n-ruby-3 text-n-ruby-11',
|
||||
blue: 'outline-n-blue-4 bg-n-blue-3 text-n-blue-11',
|
||||
teal: 'outline-n-teal-4 bg-n-teal-3 text-n-teal-11',
|
||||
amber: 'outline-n-amber-4 bg-n-amber-3 text-n-amber-11',
|
||||
};
|
||||
|
||||
const titleColorClasses = {
|
||||
ruby: 'text-n-ruby-11',
|
||||
blue: 'text-n-blue-11',
|
||||
teal: 'text-n-teal-11',
|
||||
amber: 'text-n-amber-11',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 rounded-xl outline outline-1" :class="colorClasses[color]">
|
||||
<div class="flex gap-3 items-start">
|
||||
<Icon :icon="icon" class="flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
v-if="title"
|
||||
class="text-sm font-semibold"
|
||||
:class="titleColorClasses[color]"
|
||||
>
|
||||
{{ title }}
|
||||
</h4>
|
||||
<p v-if="message" class="mt-1 mb-0 text-sm">
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -417,6 +417,16 @@
|
||||
"LEDGER_TITLE": "Recent Transactions",
|
||||
"NO_TRANSACTIONS": "No transactions yet. Start by topping up or subscribing to a plan."
|
||||
},
|
||||
"PLAN_SUMMARY": {
|
||||
"TITLE": "Plan Summary",
|
||||
"PRICE_PER_MONTH": "Price/month",
|
||||
"RENEWAL_DATE": "Renewal Date",
|
||||
"CREDITS_INCLUDED": "Credits included/month",
|
||||
"NUMBER_OF_SEATS": "Number of Seats",
|
||||
"ADJUST_SEATS_HINT": "Adjust seats to scale your plan",
|
||||
"VIEW_ALL_PLANS": "View All Plans",
|
||||
"CANCEL_PLAN": "Cancel Plan"
|
||||
},
|
||||
"PRICING_PLANS": {
|
||||
"TITLE": "Pricing Plans",
|
||||
"DESCRIPTION": "Choose a plan that fits your team. All plans include credits that reset monthly.",
|
||||
@@ -426,14 +436,15 @@
|
||||
"MONTH": "month",
|
||||
"SEATS": "seats",
|
||||
"CREDITS_PER_SEAT": "credits/month",
|
||||
"CREDITS_INCLUDED": "credits included/month",
|
||||
"PER_CREDIT_OVERAGE": "per additional credit",
|
||||
"MIN_SEATS": "Minimum seats",
|
||||
"SUBSCRIBE": "Subscribe",
|
||||
"SWITCH_PLAN": "Switch Plan",
|
||||
"CURRENT_PLAN": "Current Plan",
|
||||
"CANCEL_SUBSCRIPTION": "Cancel Subscription",
|
||||
"BLOCKED": "Blocked",
|
||||
"PENDING_TITLE": "Pending Changes Scheduled",
|
||||
"PENDING_MESSAGE": "Your subscription changes will take effect on your next billing date.",
|
||||
"NEXT_PLAN": "Next Plan",
|
||||
"NEXT_QUANTITY": "Next Quantity",
|
||||
"EFFECTIVE_DATE": "Effective Date",
|
||||
"CANCELLING_TITLE": "Subscription Ending",
|
||||
"CANCELLING_MESSAGE": "Your subscription will be cancelled at the end of the current billing period. You can continue using your current plan until then.",
|
||||
"CANCELLING_BLOCKED_INFO": "You cannot change plans or subscribe to new plans while your subscription is ending.",
|
||||
@@ -441,8 +452,6 @@
|
||||
"NO_PLANS": "No pricing plans available.",
|
||||
"SUBSCRIBE_SUCCESS": "Successfully subscribed to the plan! Your credits have been updated.",
|
||||
"CANCEL_SUCCESS": "Subscription cancelled successfully.",
|
||||
"MANAGE_SEATS": "Adjust Seats",
|
||||
"UPDATE": "Update",
|
||||
"UPDATE_QUANTITY_SUCCESS": "Subscription quantity updated successfully!",
|
||||
"CHANGE_PLAN_SUCCESS": "Successfully changed to the new plan! Your credits have been updated."
|
||||
},
|
||||
@@ -460,7 +469,7 @@
|
||||
"CREDIT_GRANTS": {
|
||||
"TITLE": "Credit History",
|
||||
"DESCRIPTION": "View all your credit grants including topups and monthly credits.",
|
||||
"CREDITS": "{credits} credits",
|
||||
"CREDITS": "credits",
|
||||
"EXPIRES": "Expires",
|
||||
"NO_EXPIRY": "Never expires",
|
||||
"ADDED": "Added on {date}",
|
||||
@@ -474,6 +483,8 @@
|
||||
"NUMBER_OF_SEATS": "Number of seats",
|
||||
"MIN": "Min",
|
||||
"MAX": "Max",
|
||||
"PER_SEAT": "per seat",
|
||||
"TOTAL": "total",
|
||||
"BASE_PRICE": "Base price per seat",
|
||||
"INCLUDED_CREDITS": "Included credits",
|
||||
"CREDITS": "credits",
|
||||
|
||||
+8
-16
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Notice from 'dashboard/components-next/notice/Notice.vue';
|
||||
|
||||
defineProps({
|
||||
isCanceling: {
|
||||
@@ -41,21 +42,12 @@ defineExpose({ dialogRef });
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Warning -->
|
||||
<div
|
||||
class="p-4 rounded-xl outline outline-1 outline-n-ruby-4 bg-n-ruby-3 text-n-ruby-11"
|
||||
>
|
||||
<div class="flex gap-3 items-start">
|
||||
<Icon icon="i-lucide-alert-triangle" class="flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-n-ruby-11">
|
||||
{{ t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_TITLE') }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm">
|
||||
{{ t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Notice
|
||||
color="ruby"
|
||||
icon="i-lucide-alert-triangle"
|
||||
:title="t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_TITLE')"
|
||||
:message="t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_MESSAGE')"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="reason"
|
||||
|
||||
+2
-5
@@ -85,11 +85,8 @@ const getCategoryConfig = category =>
|
||||
{{ grant.name }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-n-slate-11">
|
||||
{{
|
||||
t('BILLING_SETTINGS_V2.CREDIT_GRANTS.CREDITS', {
|
||||
credits: formatNumber(grant.credits),
|
||||
})
|
||||
}}
|
||||
{{ formatNumber(grant.credits) }}
|
||||
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.CREDITS') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import format from 'date-fns/format';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
planName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pricePerMonth: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalPrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
renewalDate: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
currentSeats: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
minSeats: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
includedCredits: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isCancelling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isUpdatingSeats: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
updatingDirection: {
|
||||
type: String,
|
||||
default: null, // 'increase' or 'decrease'
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['view-all-plans', 'cancel-plan', 'updateSeats']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formattedRenewalDate = computed(() => {
|
||||
if (!props.renewalDate) return null;
|
||||
return format(new Date(props.renewalDate), 'MMM dd, yyyy');
|
||||
});
|
||||
|
||||
const formatPrice = price => {
|
||||
if (!price) return '$0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const canDecreaseSeats = computed(() => {
|
||||
return props.currentSeats > props.minSeats;
|
||||
});
|
||||
|
||||
const canIncreaseSeats = computed(() => {
|
||||
return props.currentSeats < 100; // Max 100 seats
|
||||
});
|
||||
|
||||
const handleDecreaseSeats = () => {
|
||||
if (canDecreaseSeats.value && !props.isUpdatingSeats) {
|
||||
emit('updateSeats', {
|
||||
quantity: props.currentSeats - 1,
|
||||
direction: 'decrease',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncreaseSeats = () => {
|
||||
if (canIncreaseSeats.value && !props.isUpdatingSeats) {
|
||||
emit('updateSeats', {
|
||||
quantity: props.currentSeats + 1,
|
||||
direction: 'increase',
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="p-5 rounded-xl outline outline-1 outline-n-weak bg-n-solid-1 mx-5 mt-5"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h3 class="text-base font-semibold text-n-slate-12">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.TITLE') }}
|
||||
</h3>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded-md bg-n-blue-3 text-n-blue-11 outline outline-1 outline-n-blue-4"
|
||||
>
|
||||
{{ planName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Details Grid -->
|
||||
<div class="grid grid-cols-3 gap-6 mb-4">
|
||||
<!-- Price/month -->
|
||||
<div>
|
||||
<p class="text-xs text-n-slate-11 mb-1">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.PRICE_PER_MONTH') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="text-lg font-semibold text-n-slate-12 mb-0">
|
||||
{{ formatPrice(totalPrice) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 mb-0">
|
||||
({{ formatPrice(pricePerMonth) }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Renewal Date -->
|
||||
<div>
|
||||
<p class="text-xs text-n-slate-11 mb-1">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.RENEWAL_DATE') }}
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-n-slate-12">
|
||||
{{ formattedRenewalDate || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Credits Included -->
|
||||
<div>
|
||||
<p class="text-xs text-n-slate-11 mb-1">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.CREDITS_INCLUDED') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-zap" class="text-n-amber-9" />
|
||||
<p class="text-lg font-semibold text-n-slate-12">
|
||||
{{ formatNumber(includedCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seats Management -->
|
||||
<div
|
||||
v-if="!isCancelling"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-n-slate-2 dark:bg-n-solid-2 mb-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="i-lucide-users" class="text-n-slate-11" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-n-slate-12">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.NUMBER_OF_SEATS') }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.ADJUST_SEATS_HINT') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-minus"
|
||||
type="button"
|
||||
:is-loading="isUpdatingSeats && updatingDirection === 'decrease'"
|
||||
:disabled="!canDecreaseSeats || isUpdatingSeats"
|
||||
@click="handleDecreaseSeats"
|
||||
/>
|
||||
<span class="min-w-12 text-center text-lg font-bold text-n-slate-12">
|
||||
{{ currentSeats }}
|
||||
</span>
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-plus"
|
||||
type="button"
|
||||
:is-loading="isUpdatingSeats && updatingDirection === 'increase'"
|
||||
:disabled="!canIncreaseSeats || isUpdatingSeats"
|
||||
@click="handleIncreaseSeats"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 pt-4 border-t border-n-weak"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="emit('view-all-plans')"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.VIEW_ALL_PLANS') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isCancelling"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
@click="emit('cancel-plan')"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.CANCEL_PLAN') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
plans: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentPlanId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hasActiveSubscription: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCancellingAtPeriodEnd: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-plan']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isCurrentPlan = plan => {
|
||||
return plan.id === props.currentPlanId;
|
||||
};
|
||||
|
||||
const formatPrice = price => {
|
||||
if (!price) return '$0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="grid gap-4 grid-cols-1 xs:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id || plan.display_name"
|
||||
class="relative p-4 rounded-xl outline outline-1 transition-all"
|
||||
:class="
|
||||
isCurrentPlan(plan)
|
||||
? 'outline-n-blue-8 bg-n-blue-3'
|
||||
: 'outline-n-weak hover:outline-n-slate-8 hover:shadow-sm'
|
||||
"
|
||||
>
|
||||
<!-- Recommended Badge -->
|
||||
<div
|
||||
v-if="plan.recommended"
|
||||
class="absolute -top-2 -right-2 px-2 py-0.5 bg-n-teal-9 text-white text-xs font-semibold rounded-md"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.RECOMMENDED') }}
|
||||
</div>
|
||||
|
||||
<h5 class="text-lg font-bold text-n-slate-12">{{ plan.name }}</h5>
|
||||
<p class="mt-1 text-sm text-n-slate-11">{{ plan.description }}</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-n-slate-12">
|
||||
{{ formatPrice(plan.base_price) }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
/{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.MONTH') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="i-lucide-check" class="text-n-teal-9" />
|
||||
<span class="text-n-slate-11">
|
||||
{{ formatNumber(plan.included_credits) }}
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.CREDITS_PER_SEAT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="plan.min_seats > 1" class="flex items-center gap-2">
|
||||
<Icon icon="i-lucide-info" class="text-n-blue-9" />
|
||||
<span class="text-n-slate-11 text-xs">
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.MIN_SEATS') }}:
|
||||
{{ plan.min_seats }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full mt-4"
|
||||
sm
|
||||
:solid="plan.recommended"
|
||||
:faded="!plan.recommended"
|
||||
:blue="plan.recommended"
|
||||
:slate="!plan.recommended"
|
||||
:disabled="isCurrentPlan(plan) || isCancellingAtPeriodEnd"
|
||||
@click="emit('select-plan', plan)"
|
||||
>
|
||||
{{
|
||||
isCurrentPlan(plan)
|
||||
? t('BILLING_SETTINGS_V2.PRICING_PLANS.CURRENT_PLAN')
|
||||
: isCancellingAtPeriodEnd
|
||||
? t('BILLING_SETTINGS_V2.PRICING_PLANS.BLOCKED')
|
||||
: hasActiveSubscription
|
||||
? t('BILLING_SETTINGS_V2.PRICING_PLANS.SWITCH_PLAN')
|
||||
: t('BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="plans.length === 0 && !isLoading" class="py-8 text-center">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NO_PLANS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+156
-418
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import BillingCard from '../../billing/components/BillingCard.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import SubscribeModal from './SubscribeModal.vue';
|
||||
import PlanSummary from './PlanSummary.vue';
|
||||
import PlansGrid from './PlansGrid.vue';
|
||||
import Notice from 'dashboard/components-next/notice/Notice.vue';
|
||||
import SubscribeDialog from './SubscribeDialog.vue';
|
||||
import CancelSubscriptionDialog from './CancelSubscriptionDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -35,82 +39,59 @@ const { t } = useI18n();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const showSubscribeModal = ref(false);
|
||||
const showAllPlans = ref(false);
|
||||
const cancelModalRef = ref(null);
|
||||
const subscribeDialogRef = ref(null);
|
||||
const selectedPlan = ref(null);
|
||||
const selectedQuantity = ref(1);
|
||||
const isUpdatingSeats = ref(false);
|
||||
const updatingDirection = ref(null);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return props.currentAccount.custom_attributes || {};
|
||||
});
|
||||
const customAttributes = computed(
|
||||
() => props.currentAccount.custom_attributes || {}
|
||||
);
|
||||
const currentPlanId = computed(
|
||||
() => customAttributes.value.stripe_pricing_plan_id
|
||||
);
|
||||
const currentPlanQuantity = computed(
|
||||
() => customAttributes.value.subscribed_quantity || 0
|
||||
);
|
||||
const subscriptionStatus = computed(
|
||||
() => customAttributes.value.subscription_status
|
||||
);
|
||||
const subscriptionEndsAt = computed(
|
||||
() => customAttributes.value.subscription_ends_at
|
||||
);
|
||||
const stripeSubscriptionId = computed(
|
||||
() => customAttributes.value.stripe_subscription_id
|
||||
);
|
||||
const pendingPlanId = computed(
|
||||
() => customAttributes.value.pending_stripe_pricing_plan_id
|
||||
);
|
||||
const pendingQuantity = computed(
|
||||
() => customAttributes.value.pending_subscription_quantity
|
||||
);
|
||||
const nextBillingDate = computed(
|
||||
() => customAttributes.value.next_billing_date
|
||||
);
|
||||
|
||||
const currentPlanId = computed(() => {
|
||||
return customAttributes.value.stripe_pricing_plan_id;
|
||||
});
|
||||
const hasActiveSubscription = computed(() => !!currentPlanId.value);
|
||||
const hasStripeSubscription = computed(() => !!stripeSubscriptionId.value);
|
||||
const isCancellingAtPeriodEnd = computed(
|
||||
() => subscriptionStatus.value === 'cancel_at_period_end'
|
||||
);
|
||||
const hasPendingChanges = computed(
|
||||
() => !!pendingPlanId.value || !!pendingQuantity.value
|
||||
);
|
||||
|
||||
const currentPlanQuantity = computed(() => {
|
||||
return customAttributes.value.subscribed_quantity || 0;
|
||||
});
|
||||
const formatEndDate = () =>
|
||||
subscriptionEndsAt.value
|
||||
? format(new Date(subscriptionEndsAt.value), 'MMMM dd, yyyy')
|
||||
: '';
|
||||
|
||||
const subscriptionStatus = computed(() => {
|
||||
return customAttributes.value.subscription_status;
|
||||
});
|
||||
|
||||
const subscriptionEndsAt = computed(() => {
|
||||
return customAttributes.value.subscription_ends_at;
|
||||
});
|
||||
|
||||
const stripeSubscriptionId = computed(() => {
|
||||
return customAttributes.value.stripe_subscription_id;
|
||||
});
|
||||
|
||||
const pendingPlanId = computed(() => {
|
||||
return customAttributes.value.pending_stripe_pricing_plan_id;
|
||||
});
|
||||
|
||||
const pendingQuantity = computed(() => {
|
||||
return customAttributes.value.pending_subscription_quantity;
|
||||
});
|
||||
|
||||
const nextBillingDate = computed(() => {
|
||||
return customAttributes.value.next_billing_date;
|
||||
});
|
||||
|
||||
const hasActiveSubscription = computed(() => {
|
||||
return !!currentPlanId.value;
|
||||
});
|
||||
|
||||
const hasStripeSubscription = computed(() => {
|
||||
return !!stripeSubscriptionId.value;
|
||||
});
|
||||
|
||||
const isCancellingAtPeriodEnd = computed(() => {
|
||||
return subscriptionStatus.value === 'cancel_at_period_end';
|
||||
});
|
||||
|
||||
const hasPendingChanges = computed(() => {
|
||||
return !!pendingPlanId.value || !!pendingQuantity.value;
|
||||
});
|
||||
|
||||
const formatEndDate = () => {
|
||||
if (!subscriptionEndsAt.value) return '';
|
||||
const date = new Date(subscriptionEndsAt.value);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatNextBillingDate = () => {
|
||||
if (!nextBillingDate.value) return '';
|
||||
const date = new Date(nextBillingDate.value); // Parse ISO 8601 string
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
const formatNextBillingDate = () =>
|
||||
nextBillingDate.value
|
||||
? format(new Date(nextBillingDate.value), 'MMMM dd, yyyy')
|
||||
: '';
|
||||
|
||||
// Transform backend component structure to flat structure
|
||||
const transformedPlans = computed(() => {
|
||||
@@ -145,48 +126,30 @@ const pendingPlan = computed(() => {
|
||||
return transformedPlans.value.find(plan => plan.id === pendingPlanId.value);
|
||||
});
|
||||
|
||||
const formatPrice = price => {
|
||||
if (!price) return '$0';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const openSubscribeModal = plan => {
|
||||
selectedPlan.value = plan;
|
||||
selectedQuantity.value = plan.min_seats || 1;
|
||||
showSubscribeModal.value = true;
|
||||
subscribeDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const openCancelModal = () => {
|
||||
cancelModalRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const handleSubscribe = async data => {
|
||||
// Use change plan API only if stripe_subscription_id exists
|
||||
// Otherwise, always use subscribe API
|
||||
if (hasStripeSubscription.value) {
|
||||
const result = await store.dispatch('accounts/v2ChangePlan', data);
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.CHANGE_PLAN_SUCCESS'));
|
||||
showSubscribeModal.value = false;
|
||||
} else if (result.error) {
|
||||
useAlert(result.error);
|
||||
}
|
||||
} else {
|
||||
const result = await store.dispatch('accounts/v2Subscribe', data);
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE_SUCCESS'));
|
||||
showSubscribeModal.value = false;
|
||||
} else if (result.error) {
|
||||
useAlert(result.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const action = hasStripeSubscription.value ? 'v2ChangePlan' : 'v2Subscribe';
|
||||
const result = await store.dispatch(`accounts/${action}`, data);
|
||||
|
||||
const openCancelModal = () => {
|
||||
cancelModalRef.value?.dialogRef?.open();
|
||||
if (result.success) {
|
||||
const msgKey = hasStripeSubscription.value
|
||||
? 'BILLING_SETTINGS_V2.PRICING_PLANS.CHANGE_PLAN_SUCCESS'
|
||||
: 'BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE_SUCCESS';
|
||||
useAlert(t(msgKey));
|
||||
subscribeDialogRef.value?.dialogRef?.close();
|
||||
} else if (result.error) {
|
||||
useAlert(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSubscription = async reason => {
|
||||
@@ -201,50 +164,13 @@ const handleCancelSubscription = async reason => {
|
||||
}
|
||||
};
|
||||
|
||||
const isUpdatingQuantity = ref(false);
|
||||
const newQuantity = ref(1);
|
||||
|
||||
// Watch for changes in current plan quantity and initialize
|
||||
watch(
|
||||
currentPlanQuantity,
|
||||
newValue => {
|
||||
newQuantity.value = newValue || 1;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const canDecreaseQuantity = computed(() => {
|
||||
return newQuantity.value > (currentPlan.value?.min_seats || 1);
|
||||
});
|
||||
|
||||
const canIncreaseQuantity = computed(() => {
|
||||
return newQuantity.value < 100; // Max 100 seats
|
||||
});
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
if (canDecreaseQuantity.value) {
|
||||
newQuantity.value -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
const increaseQuantity = () => {
|
||||
if (canIncreaseQuantity.value) {
|
||||
newQuantity.value += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const hasQuantityChanged = computed(() => {
|
||||
return newQuantity.value !== currentPlanQuantity.value;
|
||||
});
|
||||
|
||||
const handleUpdateQuantity = async () => {
|
||||
if (!hasQuantityChanged.value) return;
|
||||
|
||||
isUpdatingQuantity.value = true;
|
||||
const handleUpdateSeats = async ({ quantity, direction }) => {
|
||||
isUpdatingSeats.value = true;
|
||||
updatingDirection.value = direction;
|
||||
try {
|
||||
const result = await store.dispatch('accounts/v2UpdateQuantity', {
|
||||
pricingPlanId: currentPlanId.value,
|
||||
quantity: newQuantity.value,
|
||||
quantity,
|
||||
});
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.UPDATE_QUANTITY_SUCCESS'));
|
||||
@@ -252,13 +178,10 @@ const handleUpdateQuantity = async () => {
|
||||
useAlert(result.error);
|
||||
}
|
||||
} finally {
|
||||
isUpdatingQuantity.value = false;
|
||||
isUpdatingSeats.value = false;
|
||||
updatingDirection.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentPlan = plan => {
|
||||
return plan.id === currentPlanId.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -267,287 +190,102 @@ const isCurrentPlan = plan => {
|
||||
:title="$t('BILLING_SETTINGS_V2.PRICING_PLANS.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS_V2.PRICING_PLANS.DESCRIPTION')"
|
||||
>
|
||||
<template
|
||||
v-if="hasActiveSubscription && !isCancellingAtPeriodEnd"
|
||||
#action
|
||||
>
|
||||
<ButtonV4
|
||||
sm
|
||||
faded
|
||||
red
|
||||
:is-loading="isCanceling"
|
||||
@click="openCancelModal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCEL_SUBSCRIPTION') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
|
||||
<!-- Cancellation Notice -->
|
||||
<div
|
||||
<Notice
|
||||
v-if="isCancellingAtPeriodEnd"
|
||||
class="mx-5 mt-5 p-4 bg-y-50 border border-y-200 rounded-lg"
|
||||
color="amber"
|
||||
icon="i-lucide-alert-triangle"
|
||||
:title="$t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_TITLE')"
|
||||
:message="$t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_MESSAGE')"
|
||||
class="mx-5"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<span
|
||||
class="i-lucide-alert-triangle text-y-700 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-y-800">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_TITLE') }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-y-700">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_MESSAGE') }}
|
||||
</p>
|
||||
<p v-if="subscriptionEndsAt" class="mt-2 text-sm text-y-800">
|
||||
<span class="font-semibold">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.ENDS_ON') }}:
|
||||
</span>
|
||||
{{ formatEndDate() }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-medium text-y-800">
|
||||
{{
|
||||
$t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_BLOCKED_INFO')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="subscriptionEndsAt" class="mt-2 mb-0 text-sm">
|
||||
<span class="font-semibold">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.ENDS_ON') }}:
|
||||
</span>
|
||||
{{ formatEndDate() }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-sm">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_BLOCKED_INFO') }}
|
||||
</p>
|
||||
</Notice>
|
||||
|
||||
<!-- Pending Changes Notice -->
|
||||
<div
|
||||
<Notice
|
||||
v-if="hasPendingChanges && !isCancellingAtPeriodEnd"
|
||||
class="mx-5 mt-5 p-4 bg-b-50 border border-b-200 rounded-lg"
|
||||
color="blue"
|
||||
icon="i-lucide-clock"
|
||||
:title="t('BILLING_SETTINGS_V2.PRICING_PLANS.PENDING_TITLE')"
|
||||
:message="t('BILLING_SETTINGS_V2.PRICING_PLANS.PENDING_MESSAGE')"
|
||||
class="mx-5 mt-5"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<span class="i-lucide-clock text-b-700 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-b-800">
|
||||
Pending Changes Scheduled
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-b-700">
|
||||
Your subscription changes will take effect on your next billing
|
||||
date.
|
||||
</p>
|
||||
<div v-if="pendingPlan" class="mt-2 text-sm">
|
||||
<span class="font-semibold text-b-800">Next Plan: </span>
|
||||
<span class="text-b-700">{{ pendingPlan.name }}</span>
|
||||
</div>
|
||||
<div v-if="pendingQuantity" class="mt-1 text-sm">
|
||||
<span class="font-semibold text-b-800">Next Quantity: </span>
|
||||
<span class="text-b-700">{{ pendingQuantity }} seats</span>
|
||||
</div>
|
||||
<p v-if="nextBillingDate" class="mt-2 text-sm text-b-800">
|
||||
<span class="font-semibold">Effective Date: </span>
|
||||
{{ formatNextBillingDate() }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="pendingPlan" class="mt-2 text-sm">
|
||||
<span class="font-semibold">
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NEXT_PLAN') }}:
|
||||
</span>
|
||||
<span>{{ pendingPlan.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingQuantity" class="mt-1 text-sm">
|
||||
<span class="font-semibold">
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NEXT_QUANTITY') }}:
|
||||
</span>
|
||||
<span>
|
||||
{{ pendingQuantity }}
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.SEATS') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="nextBillingDate" class="mt-2 mb-0 text-sm">
|
||||
<span class="font-semibold">
|
||||
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.EFFECTIVE_DATE') }}:
|
||||
</span>
|
||||
{{ formatNextBillingDate() }}
|
||||
</p>
|
||||
</Notice>
|
||||
|
||||
<!-- Current Subscription Info -->
|
||||
<div
|
||||
<!-- Plan Summary -->
|
||||
<PlanSummary
|
||||
v-if="hasActiveSubscription && currentPlan"
|
||||
class="mx-5 mt-5 mb-6 p-5 rounded-lg border-2 border-b-500 bg-gradient-to-br from-b-50 to-b-25 shadow-sm"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="i-lucide-circle-check text-g-600 text-xl" />
|
||||
<h4
|
||||
class="text-xs font-semibold text-n-600 uppercase tracking-wide"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CURRENT_PLAN') }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-3 mt-2">
|
||||
<p class="text-2xl font-bold text-b-800">
|
||||
{{ currentPlan.name }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-b-600 rounded-md shadow-sm"
|
||||
>
|
||||
<span class="i-lucide-users text-white" />
|
||||
<span class="text-lg font-bold text-white">
|
||||
{{ currentPlanQuantity }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-b-100">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.SEATS') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Seats Section -->
|
||||
<div
|
||||
v-if="!isCancellingAtPeriodEnd"
|
||||
class="flex items-center gap-3 mt-3"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-500 uppercase tracking-wide">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.MANAGE_SEATS') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md bg-n-700 hover:bg-n-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
:disabled="!canDecreaseQuantity || isUpdatingQuantity"
|
||||
@click="decreaseQuantity"
|
||||
>
|
||||
<span class="i-lucide-minus text-white text-sm" />
|
||||
</button>
|
||||
<span
|
||||
class="min-w-8 text-center text-base font-bold text-n-800"
|
||||
>
|
||||
{{ newQuantity }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-md bg-n-700 hover:bg-n-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
:disabled="!canIncreaseQuantity || isUpdatingQuantity"
|
||||
@click="increaseQuantity"
|
||||
>
|
||||
<span class="i-lucide-plus text-white text-sm" />
|
||||
</button>
|
||||
<ButtonV4
|
||||
v-if="hasQuantityChanged"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:is-loading="isUpdatingQuantity"
|
||||
@click="handleUpdateQuantity"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.UPDATE') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-n-700">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="i-lucide-dollar-sign text-n-500" />
|
||||
<span class="font-semibold">
|
||||
{{ formatPrice(currentPlan.base_price) }}/{{
|
||||
$t('BILLING_SETTINGS_V2.PRICING_PLANS.MONTH')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="i-lucide-zap text-y-500" />
|
||||
<span class="font-semibold">
|
||||
{{ formatNumber(currentPlan.included_credits) }}
|
||||
</span>
|
||||
<span class="text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CREDITS_INCLUDED') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="nextBillingDate && !hasPendingChanges"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span class="i-lucide-calendar text-b-500" />
|
||||
<span class="text-n-600">Next billing: </span>
|
||||
<span class="font-semibold">{{ formatNextBillingDate() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:plan-name="currentPlan.name"
|
||||
:price-per-month="currentPlan.base_price"
|
||||
:total-price="currentPlan.base_price * currentPlanQuantity"
|
||||
:renewal-date="nextBillingDate"
|
||||
:current-seats="currentPlanQuantity"
|
||||
:min-seats="currentPlan.min_seats || 1"
|
||||
:included-credits="currentPlan.included_credits"
|
||||
:is-cancelling="isCancellingAtPeriodEnd"
|
||||
:is-updating-seats="isUpdatingSeats"
|
||||
:updating-direction="updatingDirection"
|
||||
@view-all-plans="showAllPlans = !showAllPlans"
|
||||
@cancel-plan="openCancelModal"
|
||||
@update-seats="handleUpdateSeats"
|
||||
/>
|
||||
|
||||
<!-- Plans Grid -->
|
||||
<div class="px-5 pb-5">
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="plan in transformedPlans"
|
||||
:key="plan.id || plan.display_name"
|
||||
class="relative p-4 border rounded-lg transition-all"
|
||||
:class="
|
||||
isCurrentPlan(plan)
|
||||
? 'border-b-500 bg-b-50'
|
||||
: 'border-n-weak hover:border-n-500 hover:shadow-sm'
|
||||
"
|
||||
>
|
||||
<!-- Recommended Badge -->
|
||||
<div
|
||||
v-if="plan.recommended"
|
||||
class="absolute -top-2 -right-2 px-2 py-0.5 bg-g-600 text-white text-xs font-semibold rounded"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.RECOMMENDED') }}
|
||||
</div>
|
||||
|
||||
<!-- Current Plan Badge -->
|
||||
<div
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="mb-2 px-2 py-1 bg-b-600 text-white text-xs font-semibold rounded inline-block"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CURRENT') }}
|
||||
</div>
|
||||
|
||||
<h5 class="text-lg font-bold text-n-800">{{ plan.name }}</h5>
|
||||
<p class="mt-1 text-sm text-n-600">{{ plan.description }}</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-n-800">
|
||||
{{ formatPrice(plan.base_price) }}
|
||||
</span>
|
||||
<span class="text-sm text-n-600">
|
||||
/{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.MONTH') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-check text-g-600" />
|
||||
<span class="text-n-700">
|
||||
{{ formatNumber(plan.included_credits) }}
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CREDITS_PER_SEAT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="plan.min_seats > 1" class="flex items-center gap-2">
|
||||
<span class="i-lucide-info text-b-600" />
|
||||
<span class="text-n-600 text-xs">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.MIN_SEATS') }}:
|
||||
{{ plan.min_seats }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonV4
|
||||
class="w-full mt-4"
|
||||
sm
|
||||
:solid="plan.recommended"
|
||||
:faded="!plan.recommended"
|
||||
:blue="plan.recommended"
|
||||
:slate="!plan.recommended"
|
||||
:disabled="isCurrentPlan(plan) || isCancellingAtPeriodEnd"
|
||||
@click="openSubscribeModal(plan)"
|
||||
>
|
||||
{{
|
||||
isCurrentPlan(plan)
|
||||
? $t('BILLING_SETTINGS_V2.PRICING_PLANS.CURRENT_PLAN')
|
||||
: isCancellingAtPeriodEnd
|
||||
? $t('BILLING_SETTINGS_V2.PRICING_PLANS.BLOCKED')
|
||||
: hasActiveSubscription
|
||||
? $t('BILLING_SETTINGS_V2.PRICING_PLANS.SWITCH_PLAN')
|
||||
: $t('BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE')
|
||||
}}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="plans.length === 0 && !isLoading" class="py-8 text-center">
|
||||
<p class="text-sm text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.NO_PLANS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2 scale-98"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-400 ease-in-out"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 -translate-y-2 scale-98"
|
||||
>
|
||||
<PlansGrid
|
||||
v-if="!hasActiveSubscription || showAllPlans"
|
||||
:plans="transformedPlans"
|
||||
:current-plan-id="currentPlanId"
|
||||
:has-active-subscription="hasActiveSubscription"
|
||||
:is-cancelling-at-period-end="isCancellingAtPeriodEnd"
|
||||
:is-loading="isLoading"
|
||||
@select-plan="openSubscribeModal"
|
||||
/>
|
||||
</Transition>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Subscribe Modal -->
|
||||
<SubscribeModal
|
||||
v-if="showSubscribeModal"
|
||||
<!-- Subscribe Dialog -->
|
||||
<SubscribeDialog
|
||||
ref="subscribeDialogRef"
|
||||
:plan="selectedPlan"
|
||||
:current-quantity="currentPlanQuantity"
|
||||
:is-subscribing="isSubscribing"
|
||||
@close="showSubscribeModal = false"
|
||||
@subscribe="handleSubscribe"
|
||||
/>
|
||||
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
plan: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isSubscribing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['subscribe']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const quantity = ref(props.plan?.min_seats || 1);
|
||||
|
||||
const minSeats = computed(() => props.plan?.min_seats || 1);
|
||||
const maxSeats = computed(() => props.plan?.max_seats || 999);
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
return (props.plan?.base_price || 0) * quantity.value;
|
||||
});
|
||||
|
||||
const totalCredits = computed(() => {
|
||||
// Credits are constant per plan, not multiplied by seats
|
||||
return props.plan?.included_credits || 0;
|
||||
});
|
||||
|
||||
const formatPrice = price => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const incrementQuantity = () => {
|
||||
if (quantity.value < maxSeats.value) {
|
||||
quantity.value += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity.value > minSeats.value) {
|
||||
quantity.value -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
emit('subscribe', {
|
||||
pricing_plan_id: props.plan.id,
|
||||
quantity: quantity.value,
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
dialogRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="xl"
|
||||
:title="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.TITLE')"
|
||||
:confirm-button-label="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CONFIRM')"
|
||||
:cancel-button-label="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CANCEL')"
|
||||
:is-loading="isSubscribing"
|
||||
@confirm="handleSubscribe"
|
||||
>
|
||||
<div v-if="plan" class="space-y-4">
|
||||
<!-- Plan Info -->
|
||||
<div class="p-4 rounded-lg bg-n-slate-2 dark:bg-n-solid-2">
|
||||
<h4 class="font-semibold text-n-slate-12">{{ plan.name }}</h4>
|
||||
<p class="mt-1 text-sm text-n-slate-11">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Selector -->
|
||||
<div
|
||||
class="p-4 rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border border-n-slate-4 dark:border-n-solid-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-n-slate-12">
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.NUMBER_OF_SEATS') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MIN') }}:
|
||||
{{ minSeats }} •
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MAX') }}:
|
||||
{{ maxSeats }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-minus"
|
||||
:disabled="quantity <= minSeats"
|
||||
@click="decrementQuantity"
|
||||
/>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="quantity"
|
||||
type="number"
|
||||
:min="String(minSeats)"
|
||||
:max="String(maxSeats)"
|
||||
custom-input-class="w-16 text-center font-semibold text-base"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="quantity >= maxSeats"
|
||||
@click="incrementQuantity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-n-slate-11">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-n-blue-9" />
|
||||
{{ formatPrice(plan.base_price) }}
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.PER_SEAT') }}
|
||||
</span>
|
||||
<span class="text-n-slate-8">•</span>
|
||||
<span class="font-semibold text-n-slate-12">
|
||||
{{ formatPrice(totalPrice) }}
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.TOTAL') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="p-4 rounded-lg bg-n-blue-2 dark:bg-n-blue-3 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.BASE_PRICE') }}
|
||||
</span>
|
||||
<span class="font-semibold text-n-slate-12">
|
||||
{{ formatPrice(plan.base_price) }} {{ '×' }} {{ quantity }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.INCLUDED_CREDITS') }}
|
||||
</span>
|
||||
<span class="font-semibold text-n-amber-11">
|
||||
{{ formatNumber(totalCredits) }}
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CREDITS') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-sm pt-2 border-t border-n-blue-4 dark:border-n-blue-5"
|
||||
>
|
||||
<span class="font-semibold text-n-slate-12">
|
||||
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MONTHLY_TOTAL') }}
|
||||
</span>
|
||||
<span class="text-lg font-bold text-n-blue-11">
|
||||
{{ formatPrice(totalPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
plan: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentQuantity: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isSubscribing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'subscribe']);
|
||||
|
||||
const show = ref(true);
|
||||
const quantity = ref(props.plan?.min_seats || 1);
|
||||
|
||||
const minSeats = computed(() => props.plan?.min_seats || 1);
|
||||
const maxSeats = computed(() => props.plan?.max_seats || 999);
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
return (props.plan?.base_price || 0) * quantity.value;
|
||||
});
|
||||
|
||||
const totalCredits = computed(() => {
|
||||
// Credits are constant per plan, not multiplied by seats
|
||||
return props.plan?.included_credits || 0;
|
||||
});
|
||||
|
||||
const formatPrice = price => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const incrementQuantity = () => {
|
||||
if (quantity.value < maxSeats.value) {
|
||||
quantity.value += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity.value > minSeats.value) {
|
||||
quantity.value -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = () => {
|
||||
emit('subscribe', {
|
||||
pricing_plan_id: props.plan.id,
|
||||
quantity: quantity.value,
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:show="show" :on-close="close">
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="w-full px-5 py-4 border-b border-n-weak">
|
||||
<h3 class="text-lg font-semibold text-n-800">
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Plan Info -->
|
||||
<div class="p-4 bg-n-25 rounded-lg">
|
||||
<h4 class="font-semibold text-n-800">{{ plan.name }}</h4>
|
||||
<p class="mt-1 text-sm text-n-600">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Selector -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-n-700">
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.NUMBER_OF_SEATS') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<ButtonV4
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-minus"
|
||||
:disabled="quantity <= minSeats"
|
||||
@click="decrementQuantity"
|
||||
/>
|
||||
<input
|
||||
v-model.number="quantity"
|
||||
type="number"
|
||||
:min="minSeats"
|
||||
:max="maxSeats"
|
||||
class="w-20 px-3 py-2 text-center border border-n-weak rounded-lg focus:outline-none focus:ring-2 focus:ring-b-500"
|
||||
/>
|
||||
<ButtonV4
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-plus"
|
||||
:disabled="quantity >= maxSeats"
|
||||
@click="incrementQuantity"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MIN') }}: {{ minSeats }}
|
||||
-
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MAX') }}: {{ maxSeats }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="p-4 bg-b-50 rounded-lg space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-n-700">{{
|
||||
$t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.BASE_PRICE')
|
||||
}}</span>
|
||||
<span class="font-semibold text-n-800">
|
||||
{{ formatPrice(plan.base_price) }} ×
|
||||
{{ quantity }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-n-700">{{
|
||||
$t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.INCLUDED_CREDITS')
|
||||
}}</span>
|
||||
<span class="font-semibold text-g-800">
|
||||
{{ formatNumber(totalCredits) }}
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CREDITS') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm pt-2 border-t border-b-200">
|
||||
<span class="font-semibold text-n-800">{{
|
||||
$t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MONTHLY_TOTAL')
|
||||
}}</span>
|
||||
<span class="text-lg font-bold text-b-800">
|
||||
{{ formatPrice(totalPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-n-weak">
|
||||
<ButtonV4 sm faded slate :disabled="isSubscribing" @click="close">
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CANCEL') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:is-loading="isSubscribing"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CONFIRM') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+2
-2
@@ -14,9 +14,9 @@ defineProps({
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-5">
|
||||
<div>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
<h6 class="text-base font-semibold text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
</h6>
|
||||
<p class="text-sm mt-1 text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user