add fe changes
This commit is contained in:
@@ -23,6 +23,45 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
|
||||
// V2 Billing APIs
|
||||
creditsBalance() {
|
||||
return axios.get(`${this.url}credits_balance`);
|
||||
}
|
||||
|
||||
creditGrants() {
|
||||
return axios.get(`${this.url}credit_grants`);
|
||||
}
|
||||
|
||||
v2PricingPlans() {
|
||||
return axios.get(`${this.url}v2_pricing_plans`);
|
||||
}
|
||||
|
||||
v2TopupOptions() {
|
||||
return axios.get(`${this.url}v2_topup_options`);
|
||||
}
|
||||
|
||||
v2Topup(data) {
|
||||
return axios.post(`${this.url}v2_topup`, data);
|
||||
}
|
||||
|
||||
v2Subscribe(data) {
|
||||
return axios.post(`${this.url}v2_subscribe`, data);
|
||||
}
|
||||
|
||||
cancelSubscription(data = {}) {
|
||||
return axios.post(`${this.url}cancel_subscription`, data);
|
||||
}
|
||||
|
||||
updateSubscriptionQuantity(quantity) {
|
||||
return axios.post(`${this.url}update_subscription_quantity`, {
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
changePricingPlan(data) {
|
||||
return axios.post(`${this.url}change_pricing_plan`, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
STRIPE_V2: 'stripe_v2',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -397,6 +397,102 @@
|
||||
},
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
|
||||
},
|
||||
"BILLING_SETTINGS_V2": {
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage your usage-based subscription with flexible credit-based pricing. Top up anytime or subscribe to a plan.",
|
||||
"VIEW_PRICING": "View Pricing",
|
||||
"OPEN_STRIPE_DASHBOARD": "Open Stripe Dashboard",
|
||||
"CREDITS_BALANCE": {
|
||||
"TITLE": "Credits Balance",
|
||||
"DESCRIPTION": "Your current credit balance and recent transactions.",
|
||||
"CREDITS": "credits",
|
||||
"CREDITS_SHORT": "cr",
|
||||
"REFRESH": "Refresh",
|
||||
"USAGE_BASED_INFO": "Usage-based pricing: Credits are consumed as you use Chatwoot features.",
|
||||
"LEDGER_TITLE": "Recent Transactions",
|
||||
"NO_TRANSACTIONS": "No transactions yet. Start by topping up or subscribing to a plan."
|
||||
},
|
||||
"PRICING_PLANS": {
|
||||
"TITLE": "Pricing Plans",
|
||||
"DESCRIPTION": "Choose a plan that fits your team. All plans include credits that reset monthly.",
|
||||
"CURRENT_PLAN": "Current Subscription",
|
||||
"CURRENT": "Current Plan",
|
||||
"RECOMMENDED": "Recommended",
|
||||
"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",
|
||||
"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.",
|
||||
"ENDS_ON": "Subscription ends on",
|
||||
"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."
|
||||
},
|
||||
"TOPUP_OPTIONS": {
|
||||
"TITLE": "Top-up Credits",
|
||||
"DESCRIPTION": "Need more credits? Purchase additional credits anytime to keep your team running.",
|
||||
"CREDITS": "credits",
|
||||
"BUY_NOW": "Buy Now",
|
||||
"NO_OPTIONS": "No top-up options available.",
|
||||
"TOPUP_SUCCESS": "Credits added successfully! Your new balance is available now.",
|
||||
"NO_PAYMENT_METHOD_ERROR": "No payment method found. Please add a payment method in your Stripe dashboard before making a purchase. Click the 'Open Stripe Dashboard' button above to add one."
|
||||
},
|
||||
"CREDIT_GRANTS": {
|
||||
"TITLE": "Credit History",
|
||||
"DESCRIPTION": "View all your credit grants including topups and monthly credits.",
|
||||
"CREDITS": "credits",
|
||||
"EXPIRES": "Expires",
|
||||
"NO_EXPIRY": "Never expires",
|
||||
"ADDED": "Added",
|
||||
"NO_GRANTS": "No credit grants yet."
|
||||
},
|
||||
"SUBSCRIBE_MODAL": {
|
||||
"TITLE": "Confirm Subscription",
|
||||
"NUMBER_OF_SEATS": "Number of seats",
|
||||
"MIN": "Min",
|
||||
"MAX": "Max",
|
||||
"BASE_PRICE": "Base price per seat",
|
||||
"INCLUDED_CREDITS": "Included credits",
|
||||
"CREDITS": "credits",
|
||||
"MONTHLY_TOTAL": "Monthly Total",
|
||||
"OVERAGE_RATE": "Overage rate",
|
||||
"CREDIT": "credit",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM": "Confirm Subscription"
|
||||
},
|
||||
"TOPUP_MODAL": {
|
||||
"TITLE": "Confirm Top-up",
|
||||
"SELECTED_OPTION": "Selected Option",
|
||||
"CREDITS": "credits",
|
||||
"PRICE": "Price",
|
||||
"INFO": "Credits will be added to your account immediately and never expire.",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM": "Confirm Top-up"
|
||||
},
|
||||
"CANCEL_MODAL": {
|
||||
"TITLE": "Cancel Subscription",
|
||||
"WARNING_TITLE": "Are you sure?",
|
||||
"WARNING_MESSAGE": "Cancelling your subscription will stop monthly credit renewals. Any remaining credits will still be available until used.",
|
||||
"REASON_LABEL": "Reason for cancellation",
|
||||
"OPTIONAL": "(optional)",
|
||||
"REASON_PLACEHOLDER": "Help us improve by sharing why you're cancelling...",
|
||||
"INFO": "You can resubscribe anytime to continue receiving monthly credits.",
|
||||
"KEEP_SUBSCRIPTION": "Keep Subscription",
|
||||
"CONFIRM_CANCEL": "Confirm Cancellation"
|
||||
}
|
||||
},
|
||||
"SECURITY_SETTINGS": {
|
||||
"TITLE": "Security",
|
||||
"DESCRIPTION": "Manage your account security settings.",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import CreditsBalance from './components/CreditsBalance.vue';
|
||||
import CreditGrants from './components/CreditGrants.vue';
|
||||
import PricingPlans from './components/PricingPlans.vue';
|
||||
import TopupOptions from './components/TopupOptions.vue';
|
||||
import BillingHeader from '../billing/components/BillingHeader.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud, isCloudFeatureEnabled, currentAccount } =
|
||||
useAccount();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const v2BillingData = useMapGetter('accounts/getV2BillingData');
|
||||
const v2BillingUIFlags = useMapGetter('accounts/getV2BillingUIFlags');
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return (
|
||||
uiFlags.value.isFetchingItem ||
|
||||
v2BillingUIFlags.value.isFetchingBalance ||
|
||||
v2BillingUIFlags.value.isFetchingGrants ||
|
||||
v2BillingUIFlags.value.isFetchingPlans ||
|
||||
v2BillingUIFlags.value.isFetchingTopupOptions
|
||||
);
|
||||
});
|
||||
|
||||
const handleInitialLoad = async () => {
|
||||
// If self-hosted, redirect to dashboard
|
||||
if (!isOnChatwootCloud.value) {
|
||||
router.push({ name: 'home' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch all V2 billing data in parallel
|
||||
await Promise.all([
|
||||
store.dispatch('accounts/fetchCreditsBalance'),
|
||||
store.dispatch('accounts/fetchCreditGrants'),
|
||||
store.dispatch('accounts/fetchV2PricingPlans'),
|
||||
store.dispatch('accounts/fetchV2TopupOptions'),
|
||||
store.dispatch('accounts/subscription'),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
store.dispatch('accounts/fetchCreditsBalance');
|
||||
store.dispatch('accounts/fetchCreditGrants');
|
||||
store.dispatch('accounts/fetchV2PricingPlans');
|
||||
};
|
||||
|
||||
const onToggleChatWindow = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenStripeDashboard = async () => {
|
||||
await store.dispatch('accounts/checkout');
|
||||
};
|
||||
|
||||
onMounted(handleInitialLoad);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :is-loading="isLoading">
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('BILLING_SETTINGS_V2.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS_V2.DESCRIPTION')"
|
||||
:link-text="$t('BILLING_SETTINGS_V2.VIEW_PRICING')"
|
||||
feature-name="billing-v2"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Stripe Dashboard Button -->
|
||||
<div class="mb-6 flex justify-end">
|
||||
<ButtonV4
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-external-link"
|
||||
:loading="uiFlags.isCheckoutInProcess"
|
||||
@click="handleOpenStripeDashboard"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.OPEN_STRIPE_DASHBOARD') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-6">
|
||||
<!-- Credits Balance and Ledger -->
|
||||
<CreditsBalance
|
||||
:balance-data="v2BillingData.creditsBalance"
|
||||
:is-loading="v2BillingUIFlags.isFetchingBalance"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
|
||||
<!-- Pricing Plans -->
|
||||
<PricingPlans
|
||||
:plans="v2BillingData.pricingPlans"
|
||||
:current-account="currentAccount"
|
||||
:is-loading="v2BillingUIFlags.isFetchingPlans"
|
||||
:is-subscribing="v2BillingUIFlags.isSubscribeInProcess"
|
||||
:is-canceling="v2BillingUIFlags.isCancelInProcess"
|
||||
/>
|
||||
|
||||
<!-- Top-up Options -->
|
||||
<TopupOptions
|
||||
:options="v2BillingData.topupOptions"
|
||||
:is-loading="v2BillingUIFlags.isFetchingTopupOptions"
|
||||
:is-processing="v2BillingUIFlags.isTopupInProcess"
|
||||
/>
|
||||
|
||||
<!-- Credit History -->
|
||||
<CreditGrants
|
||||
:grants="v2BillingData.creditGrants"
|
||||
:is-loading="v2BillingUIFlags.isFetchingGrants"
|
||||
/>
|
||||
|
||||
<!-- Chat Support -->
|
||||
<BillingHeader
|
||||
class="px-1 mt-5"
|
||||
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
|
||||
>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@click="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</BillingHeader>
|
||||
</section>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import IndexV2 from './IndexV2.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/billing-v2'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'BILLING_SETTINGS_V2.TITLE',
|
||||
icon: 'credit-card-person',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'billing_settings_v2_index',
|
||||
component: IndexV2,
|
||||
meta: {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isCanceling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'cancel']);
|
||||
|
||||
const show = ref(true);
|
||||
const reason = ref('');
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel', reason.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.CANCEL_MODAL.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Warning -->
|
||||
<div class="p-4 bg-r-50 rounded-lg">
|
||||
<div class="flex gap-3">
|
||||
<span
|
||||
class="i-lucide-alert-triangle text-r-700 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-r-800">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_TITLE') }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-r-700">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-n-700">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.REASON_LABEL') }}
|
||||
<span class="text-n-500">{{
|
||||
$t('BILLING_SETTINGS_V2.CANCEL_MODAL.OPTIONAL')
|
||||
}}</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="reason"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-n-weak rounded-lg focus:outline-none focus:ring-2 focus:ring-b-500 resize-none"
|
||||
:placeholder="
|
||||
$t('BILLING_SETTINGS_V2.CANCEL_MODAL.REASON_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-3 bg-n-25 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
<span class="i-lucide-info text-n-600 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-n-700">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-n-weak">
|
||||
<ButtonV4 sm faded slate :disabled="isCanceling" @click="close">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.KEEP_SUBSCRIPTION') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4 sm solid red :loading="isCanceling" @click="handleCancel">
|
||||
{{ $t('BILLING_SETTINGS_V2.CANCEL_MODAL.CONFIRM_CANCEL') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BillingCard from '../../billing/components/BillingCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
grants: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatNumber = num => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const sortedGrants = computed(() => {
|
||||
return [...props.grants].sort((a, b) => {
|
||||
// Sort by: active first, then by creation date (newest first)
|
||||
if (a.voided_at && !b.voided_at) return 1;
|
||||
if (!a.voided_at && b.voided_at) return -1;
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
});
|
||||
|
||||
const getCategoryColor = category => {
|
||||
return category === 'paid' ? 'bg-b-50 text-b-700' : 'bg-g-50 text-g-700';
|
||||
};
|
||||
|
||||
const getCategoryLabel = category => {
|
||||
return category === 'paid' ? 'Topup' : 'Monthly';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS_V2.CREDIT_GRANTS.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS_V2.CREDIT_GRANTS.DESCRIPTION')"
|
||||
>
|
||||
<div class="px-5 pb-5">
|
||||
<!-- Grants List -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="grant in sortedGrants"
|
||||
:key="grant.id"
|
||||
class="p-4 border border-n-weak rounded-lg"
|
||||
:class="{ 'opacity-50': grant.voided_at }"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded"
|
||||
:class="getCategoryColor(grant.category)"
|
||||
>
|
||||
{{ getCategoryLabel(grant.category) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="grant.voided_at"
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded bg-r-50 text-r-700"
|
||||
>
|
||||
Voided
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-n-800">
|
||||
{{ grant.name }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-n-600">
|
||||
{{ formatNumber(grant.credits) }}
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDIT_GRANTS.CREDITS') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div v-if="grant.expires_at" class="text-xs text-n-600">
|
||||
<span class="font-medium">{{
|
||||
$t('BILLING_SETTINGS_V2.CREDIT_GRANTS.EXPIRES')
|
||||
}}</span>
|
||||
<br />
|
||||
{{ formatDate(grant.expires_at) }}
|
||||
</div>
|
||||
<div v-else class="text-xs text-g-600 font-medium">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDIT_GRANTS.NO_EXPIRY') }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-n-500">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDIT_GRANTS.ADDED') }}
|
||||
{{ formatDate(grant.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="grants.length === 0 && !isLoading" class="py-8 text-center">
|
||||
<p class="text-sm text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDIT_GRANTS.NO_GRANTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BillingCard>
|
||||
</template>
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BillingCard from '../../billing/components/BillingCard.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
balanceData: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh']);
|
||||
|
||||
const currentBalance = computed(() => {
|
||||
return props.balanceData?.total_credits || 0;
|
||||
});
|
||||
|
||||
const monthlyCredits = computed(() => {
|
||||
return props.balanceData?.monthly_credits || 0;
|
||||
});
|
||||
|
||||
const topupCredits = computed(() => {
|
||||
return props.balanceData?.topup_credits || 0;
|
||||
});
|
||||
|
||||
const usageThisMonth = computed(() => {
|
||||
return props.balanceData?.usage_this_month || 0;
|
||||
});
|
||||
|
||||
const usageTotal = computed(() => {
|
||||
return props.balanceData?.usage_total || 0;
|
||||
});
|
||||
|
||||
const currency = computed(() => {
|
||||
return props.balanceData?.currency || 'USD';
|
||||
});
|
||||
|
||||
const ledger = computed(() => {
|
||||
return props.balanceData?.ledger || [];
|
||||
});
|
||||
|
||||
const hasLedger = computed(() => {
|
||||
return ledger.value.length > 0;
|
||||
});
|
||||
|
||||
const formatDate = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatAmount = amount => {
|
||||
if (!amount) return '0';
|
||||
return new Intl.NumberFormat('en-US').format(amount);
|
||||
};
|
||||
|
||||
const formatMoney = amount => {
|
||||
if (!amount) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.value,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getTransactionTypeColor = type => {
|
||||
const colorMap = {
|
||||
topup: 'text-g-800',
|
||||
subscription: 'text-b-800',
|
||||
usage: 'text-y-800',
|
||||
refund: 'text-p-800',
|
||||
};
|
||||
return colorMap[type?.toLowerCase()] || 'text-n-800';
|
||||
};
|
||||
|
||||
const getTransactionTypeLabel = type => {
|
||||
if (!type) return '';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS_V2.CREDITS_BALANCE.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS_V2.CREDITS_BALANCE.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-refresh-cw"
|
||||
:loading="isLoading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDITS_BALANCE.REFRESH') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
|
||||
<!-- Current Balance -->
|
||||
<div class="px-5 pb-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-bold text-n-800">
|
||||
{{ formatAmount(currentBalance) }}
|
||||
</span>
|
||||
<span class="text-base text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDITS_BALANCE.CREDITS') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-n-600 uppercase">Monthly Credits</p>
|
||||
<p class="text-lg font-semibold text-b-800">
|
||||
{{ formatAmount(monthlyCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-n-600 uppercase">Top-up Credits</p>
|
||||
<p class="text-lg font-semibold text-g-800">
|
||||
{{ formatAmount(topupCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-n-600 uppercase">Used This Month</p>
|
||||
<p class="text-lg font-semibold text-y-800">
|
||||
{{ formatAmount(usageThisMonth) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-n-600 uppercase">Total Used</p>
|
||||
<p class="text-lg font-semibold text-n-700">
|
||||
{{ formatAmount(usageTotal) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDITS_BALANCE.USAGE_BASED_INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Credit Ledger -->
|
||||
<div v-if="hasLedger" class="border-t border-n-weak">
|
||||
<div class="px-5 py-3">
|
||||
<h4 class="text-sm font-semibold text-n-800">
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDITS_BALANCE.LEDGER_TITLE') }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="px-5 pb-4">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="transaction in ledger.slice(0, 10)"
|
||||
:key="transaction.id"
|
||||
class="flex items-center justify-between py-2 border-b border-n-weak last:border-0"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="getTransactionTypeColor(transaction.type)"
|
||||
>
|
||||
{{ getTransactionTypeLabel(transaction.type) }}
|
||||
</span>
|
||||
<span v-if="transaction.note" class="text-xs text-n-600">
|
||||
- {{ transaction.note }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-n-600 mt-0.5">
|
||||
{{ formatDate(transaction.ts) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="text-sm font-semibold"
|
||||
:class="
|
||||
transaction.amount_credits > 0 ? 'text-g-800' : 'text-r-800'
|
||||
"
|
||||
>
|
||||
{{ transaction.amount_credits > 0 ? '+' : ''
|
||||
}}{{ formatAmount(transaction.amount_credits) }}
|
||||
{{ $t('BILLING_SETTINGS_V2.CREDITS_BALANCE.CREDITS_SHORT') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="transaction.amount_money"
|
||||
class="text-xs text-n-600 mt-0.5"
|
||||
>
|
||||
{{ formatMoney(transaction.amount_money) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BillingCard>
|
||||
</template>
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import BillingCard from '../../billing/components/BillingCard.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import SubscribeModal from './SubscribeModal.vue';
|
||||
import CancelSubscriptionModal from './CancelSubscriptionModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
plans: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentAccount: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSubscribing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCanceling: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const showSubscribeModal = ref(false);
|
||||
const showCancelModal = ref(false);
|
||||
const selectedPlan = ref(null);
|
||||
const selectedQuantity = ref(1);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return props.currentAccount.custom_attributes || {};
|
||||
});
|
||||
|
||||
const currentPlanId = computed(() => {
|
||||
return customAttributes.value.stripe_pricing_plan_id;
|
||||
});
|
||||
|
||||
const currentPlanQuantity = computed(() => {
|
||||
return customAttributes.value.subscribed_quantity || 0;
|
||||
});
|
||||
|
||||
const subscriptionStatus = computed(() => {
|
||||
return customAttributes.value.subscription_status;
|
||||
});
|
||||
|
||||
const subscriptionEndsAt = computed(() => {
|
||||
return customAttributes.value.subscription_ends_at;
|
||||
});
|
||||
|
||||
const hasActiveSubscription = computed(() => {
|
||||
return !!currentPlanId.value;
|
||||
});
|
||||
|
||||
const isCancellingAtPeriodEnd = computed(() => {
|
||||
return subscriptionStatus.value === 'cancel_at_period_end';
|
||||
});
|
||||
|
||||
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 currentPlan = computed(() => {
|
||||
if (!currentPlanId.value) return null;
|
||||
return transformedPlans.value.find(plan => plan.id === currentPlanId.value);
|
||||
});
|
||||
|
||||
// Transform backend component structure to flat structure
|
||||
const transformedPlans = computed(() => {
|
||||
return props.plans.map(plan => {
|
||||
const components = plan.components || [];
|
||||
|
||||
// Extract values from components array
|
||||
const serviceAction = components.find(c => c.type === 'service_action');
|
||||
const licenseFeeLike = components.find(c => c.type === 'license_fee');
|
||||
const rateCard = components.find(c => c.type === 'rate_card');
|
||||
|
||||
return {
|
||||
...plan,
|
||||
name: plan.display_name || plan.name,
|
||||
description: plan.description || '',
|
||||
base_price: licenseFeeLike?.unit_amount || 0,
|
||||
included_credits: serviceAction?.credit_amount || 0,
|
||||
overage_rate: rateCard?.overage_rate || 0,
|
||||
min_seats: plan.min_seats || 1,
|
||||
recommended: plan.recommended || false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const handleSubscribe = async data => {
|
||||
// If there's an active subscription, use change plan API instead
|
||||
if (hasActiveSubscription.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 {
|
||||
const result = await store.dispatch('accounts/v2Subscribe', data);
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE_SUCCESS'));
|
||||
showSubscribeModal.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openCancelModal = () => {
|
||||
showCancelModal.value = true;
|
||||
};
|
||||
|
||||
const handleCancelSubscription = async reason => {
|
||||
const result = await store.dispatch('accounts/v2CancelSubscription', {
|
||||
reason,
|
||||
});
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCEL_SUCCESS'));
|
||||
showCancelModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
try {
|
||||
const result = await store.dispatch('accounts/v2UpdateQuantity', {
|
||||
quantity: newQuantity.value,
|
||||
});
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.UPDATE_QUANTITY_SUCCESS'));
|
||||
}
|
||||
} finally {
|
||||
isUpdatingQuantity.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentPlan = plan => {
|
||||
return plan.id === currentPlanId.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BillingCard
|
||||
: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
|
||||
:loading="isCanceling"
|
||||
@click="openCancelModal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCEL_SUBSCRIPTION') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
|
||||
<!-- Cancellation Notice -->
|
||||
<div
|
||||
v-if="isCancellingAtPeriodEnd"
|
||||
class="mx-5 mt-5 p-4 bg-y-50 border border-y-200 rounded-lg"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Current Subscription Info -->
|
||||
<div
|
||||
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
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Subscribe Modal -->
|
||||
<SubscribeModal
|
||||
v-if="showSubscribeModal"
|
||||
:plan="selectedPlan"
|
||||
:current-quantity="currentPlanQuantity"
|
||||
:is-subscribing="isSubscribing"
|
||||
@close="showSubscribeModal = false"
|
||||
@subscribe="handleSubscribe"
|
||||
/>
|
||||
|
||||
<!-- Cancel Subscription Modal -->
|
||||
<CancelSubscriptionModal
|
||||
v-if="showCancelModal"
|
||||
:is-canceling="isCanceling"
|
||||
@close="showCancelModal = false"
|
||||
@cancel="handleCancelSubscription"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<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
|
||||
:loading="isSubscribing"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CONFIRM') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Modal from 'dashboard/components/Modal.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isProcessing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'topup']);
|
||||
|
||||
const show = ref(true);
|
||||
|
||||
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 handleTopup = () => {
|
||||
emit('topup', {
|
||||
credits: props.option.credits,
|
||||
});
|
||||
};
|
||||
|
||||
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.TOPUP_MODAL.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Option Info -->
|
||||
<div class="p-4 bg-n-25 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-n-700 uppercase tracking-wide">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_MODAL.SELECTED_OPTION') }}
|
||||
</h4>
|
||||
<div class="mt-2 text-center">
|
||||
<div class="text-4xl font-bold text-b-800">
|
||||
{{ formatNumber(option.credits) }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_MODAL.CREDITS') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="p-4 bg-b-50 rounded-lg space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-700">{{
|
||||
$t('BILLING_SETTINGS_V2.TOPUP_MODAL.PRICE')
|
||||
}}</span>
|
||||
<span class="text-2xl font-bold text-b-800">
|
||||
{{ formatPrice(option.price) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="option.description" class="pt-2 border-t border-b-200">
|
||||
<p class="text-xs text-n-600">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-3 bg-g-50 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
<span class="i-lucide-info text-g-700 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-g-800">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_MODAL.INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-n-weak">
|
||||
<ButtonV4 sm faded slate :disabled="isProcessing" @click="close">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_MODAL.CANCEL') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isProcessing"
|
||||
:disabled="isProcessing"
|
||||
@click="handleTopup"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_MODAL.CONFIRM') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import BillingCard from '../../billing/components/BillingCard.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import TopupModal from './TopupModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isProcessing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const showTopupModal = ref(false);
|
||||
const selectedOption = ref(null);
|
||||
|
||||
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 openTopupModal = option => {
|
||||
selectedOption.value = option;
|
||||
showTopupModal.value = true;
|
||||
};
|
||||
|
||||
const handleTopup = async data => {
|
||||
const result = await store.dispatch('accounts/v2Topup', data);
|
||||
if (result.success) {
|
||||
useAlert(t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.TOPUP_SUCCESS'));
|
||||
showTopupModal.value = false;
|
||||
} else if (result.error) {
|
||||
// Display validation error from API
|
||||
showTopupModal.value = false;
|
||||
|
||||
// Check if it's a payment method error for custom message
|
||||
if (result.error.includes('payment method')) {
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.NO_PAYMENT_METHOD_ERROR'),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
// Display the error message from API
|
||||
useAlert(result.error, 'warning');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.DESCRIPTION')"
|
||||
>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
class="p-4 border border-n-weak rounded-lg hover:border-b-500 hover:shadow-sm transition-all cursor-pointer"
|
||||
@click="openTopupModal(option)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-b-800">
|
||||
{{ formatNumber(option.credits) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-n-600 uppercase tracking-wide">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.CREDITS') }}
|
||||
</div>
|
||||
<div class="mt-3 text-2xl font-bold text-n-800">
|
||||
{{ formatPrice(option.price) }}
|
||||
</div>
|
||||
<p v-if="option.description" class="mt-2 text-xs text-n-600">
|
||||
{{ option.description }}
|
||||
</p>
|
||||
<ButtonV4
|
||||
class="w-full mt-4"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
@click.stop="openTopupModal(option)"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.BUY_NOW') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="options.length === 0 && !isLoading" class="py-8 text-center">
|
||||
<p class="text-sm text-n-600">
|
||||
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.NO_OPTIONS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Topup Modal -->
|
||||
<TopupModal
|
||||
v-if="showTopupModal"
|
||||
:option="selectedOption"
|
||||
:is-processing="isProcessing"
|
||||
@close="showTopupModal = false"
|
||||
@topup="handleTopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import attributes from './attributes/attributes.routes';
|
||||
import automation from './automation/automation.routes';
|
||||
import auditlogs from './auditlogs/audit.routes';
|
||||
import billing from './billing/billing.routes';
|
||||
import billingV2 from './billing-v2/billing-v2.routes';
|
||||
import canned from './canned/canned.routes';
|
||||
import inbox from './inbox/inbox.routes';
|
||||
import integrations from './integrations/integrations.routes';
|
||||
@@ -52,6 +53,7 @@ export default {
|
||||
...automation.routes,
|
||||
...auditlogs.routes,
|
||||
...billing.routes,
|
||||
...billingV2.routes,
|
||||
...canned.routes,
|
||||
...inbox.routes,
|
||||
...integrations.routes,
|
||||
|
||||
@@ -19,6 +19,21 @@ const state = {
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
},
|
||||
v2Billing: {
|
||||
creditsBalance: null,
|
||||
creditGrants: [],
|
||||
pricingPlans: [],
|
||||
topupOptions: [],
|
||||
uiFlags: {
|
||||
isFetchingBalance: false,
|
||||
isFetchingGrants: false,
|
||||
isFetchingPlans: false,
|
||||
isFetchingTopupOptions: false,
|
||||
isTopupInProcess: false,
|
||||
isSubscribeInProcess: false,
|
||||
isCancelInProcess: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -28,6 +43,12 @@ export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getV2BillingData($state) {
|
||||
return $state.v2Billing;
|
||||
},
|
||||
getV2BillingUIFlags($state) {
|
||||
return $state.v2Billing.uiFlags;
|
||||
},
|
||||
isRTL: ($state, _getters, rootState, rootGetters) => {
|
||||
const accountId = Number(rootState.route?.params?.accountId);
|
||||
const userLocale = rootGetters?.getUISettings?.locale;
|
||||
@@ -152,6 +173,186 @@ export const actions = {
|
||||
getCacheKeys: async () => {
|
||||
return AccountAPI.getCacheKeys();
|
||||
},
|
||||
|
||||
// V2 Billing actions
|
||||
fetchCreditsBalance: async ({ commit }) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingBalance: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.creditsBalance();
|
||||
commit(types.default.SET_CREDITS_BALANCE, response.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isFetchingBalance: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchCreditGrants: async ({ commit }) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingGrants: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.creditGrants();
|
||||
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isFetchingGrants: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchV2PricingPlans: async ({ commit }) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingPlans: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.v2PricingPlans();
|
||||
commit(types.default.SET_V2_PRICING_PLANS, response.data.pricing_plans);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingPlans: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchV2TopupOptions: async ({ commit }) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isFetchingTopupOptions: true,
|
||||
});
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.v2TopupOptions();
|
||||
// Transform backend structure: rename 'amount' to 'price'
|
||||
const transformedOptions = response.data.topup_options.map(option => ({
|
||||
...option,
|
||||
price: option.amount,
|
||||
id: `topup-${option.credits}`,
|
||||
}));
|
||||
commit(types.default.SET_V2_TOPUP_OPTIONS, transformedOptions);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isFetchingTopupOptions: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
v2Topup: async ({ commit, dispatch }, data) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isTopupInProcess: true });
|
||||
try {
|
||||
await EnterpriseAccountAPI.v2Topup(data);
|
||||
// Refresh balance and ledger after successful topup
|
||||
await dispatch('fetchCreditsBalance');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.error || error.message;
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isTopupInProcess: false });
|
||||
}
|
||||
},
|
||||
|
||||
v2Subscribe: async ({ commit, dispatch }, data) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: true,
|
||||
});
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.v2Subscribe(data);
|
||||
// If backend returns redirect_url, redirect to Stripe checkout
|
||||
if (response.data.redirect_url) {
|
||||
window.location.href = response.data.redirect_url;
|
||||
return { success: true, redirecting: true };
|
||||
}
|
||||
// Otherwise refresh data (for non-Stripe flows)
|
||||
await Promise.all([
|
||||
dispatch('fetchCreditsBalance'),
|
||||
dispatch('fetchV2PricingPlans'),
|
||||
dispatch('subscription'),
|
||||
]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
v2CancelSubscription: async ({ commit, dispatch }, data) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, { isCancelInProcess: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.cancelSubscription(data);
|
||||
// Update account with new subscription status
|
||||
if (response.data.id && response.data.custom_attributes) {
|
||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||
}
|
||||
// Refresh balance and plans after cancellation
|
||||
await Promise.all([
|
||||
dispatch('fetchCreditsBalance'),
|
||||
dispatch('fetchV2PricingPlans'),
|
||||
]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isCancelInProcess: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
v2UpdateQuantity: async ({ commit, dispatch }, { quantity }) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: true,
|
||||
});
|
||||
try {
|
||||
const response =
|
||||
await EnterpriseAccountAPI.updateSubscriptionQuantity(quantity);
|
||||
// Update account with new quantity
|
||||
if (response.data.id && response.data.custom_attributes) {
|
||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||
}
|
||||
// Refresh balance after quantity update
|
||||
await dispatch('fetchCreditsBalance');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
v2ChangePlan: async ({ commit, dispatch }, data) => {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: true,
|
||||
});
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.changePricingPlan(data);
|
||||
// Update account with new plan
|
||||
if (response.data.id && response.data.custom_attributes) {
|
||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||
}
|
||||
// Refresh balance and plans after change
|
||||
await Promise.all([
|
||||
dispatch('fetchCreditsBalance'),
|
||||
dispatch('fetchV2PricingPlans'),
|
||||
]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
commit(types.default.SET_V2_BILLING_UI_FLAG, {
|
||||
isSubscribeInProcess: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -164,6 +365,26 @@ export const mutations = {
|
||||
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
|
||||
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
|
||||
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
|
||||
|
||||
// V2 Billing mutations
|
||||
[types.default.SET_V2_BILLING_UI_FLAG]($state, data) {
|
||||
$state.v2Billing.uiFlags = {
|
||||
...$state.v2Billing.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[types.default.SET_CREDITS_BALANCE]($state, data) {
|
||||
$state.v2Billing.creditsBalance = data;
|
||||
},
|
||||
[types.default.SET_CREDIT_GRANTS]($state, data) {
|
||||
$state.v2Billing.creditGrants = data;
|
||||
},
|
||||
[types.default.SET_V2_PRICING_PLANS]($state, data) {
|
||||
$state.v2Billing.pricingPlans = data;
|
||||
},
|
||||
[types.default.SET_V2_TOPUP_OPTIONS]($state, data) {
|
||||
$state.v2Billing.topupOptions = data;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -84,6 +84,13 @@ export default {
|
||||
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
|
||||
DELETE_ACCOUNT: 'DELETE_AGENT',
|
||||
|
||||
// V2 Billing
|
||||
SET_V2_BILLING_UI_FLAG: 'SET_V2_BILLING_UI_FLAG',
|
||||
SET_CREDITS_BALANCE: 'SET_CREDITS_BALANCE',
|
||||
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
|
||||
SET_V2_PRICING_PLANS: 'SET_V2_PRICING_PLANS',
|
||||
SET_V2_TOPUP_OPTIONS: 'SET_V2_TOPUP_OPTIONS',
|
||||
|
||||
// Agent
|
||||
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',
|
||||
SET_AGENT_CREATING_STATUS: 'SET_AGENT_CREATING_STATUS',
|
||||
|
||||
Reference in New Issue
Block a user