Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma a1a31e50ad add UI for stripe_v2 2025-12-01 20:01:59 +05:30
14 changed files with 1601 additions and 96 deletions
@@ -6,6 +6,7 @@ class EnterpriseAccountAPI extends ApiClient {
super('', { accountScoped: true, enterprise: true });
}
// V1 endpoints
checkout() {
return axios.post(`${this.url}checkout`);
}
@@ -23,6 +24,49 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
// V2 Billing endpoints
get v2BillingUrl() {
const accountId = this.accountIdFromRoute;
return `/enterprise/api/v2/accounts/${accountId}/billing`;
}
getCreditGrants() {
return axios.get(`${this.v2BillingUrl}/credit_grants`);
}
getPricingPlans() {
return axios.get(`${this.v2BillingUrl}/pricing_plans`);
}
getTopupOptions() {
return axios.get(`${this.v2BillingUrl}/topup_options`);
}
topupCredits(credits) {
return axios.post(`${this.v2BillingUrl}/topup`, { credits });
}
subscribeToPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/subscribe`, {
pricing_plan_id: pricingPlanId,
quantity,
});
}
cancelSubscription(reason = null, feedback = null) {
return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
reason,
feedback,
});
}
changePricingPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
pricing_plan_id: pricingPlanId,
quantity,
});
}
}
export default new EnterpriseAccountAPI();
@@ -382,6 +382,8 @@
"BILLING_SETTINGS": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"BILLING_PORTAL": "Billing Portal",
"VIEW_ALL_PLANS": "View all plans",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
@@ -394,6 +396,25 @@
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"SUBSCRIPTION": {
"TITLE": "Subscription",
"DESCRIPTION": "Manage your current plan, seats, and billing cycle.",
"CURRENT_PLAN": "Current plan",
"NUMBER_OF_SEATS": "Number of seats",
"RENEWS_ON": "Renews on",
"ENDS_ON": "Ends on",
"CANCELLED_ON": "Cancelled on",
"CHANGE_PLAN": "Change",
"CHANGE_SEATS": "Change",
"CANCEL_SUBSCRIPTION": "Cancel"
},
"CAPTAIN_AI": {
"TITLE": "Captain AI",
"DESCRIPTION": "Purchase and manage credits for Captain AI features.",
"AI_CREDITS": "AI credits",
"PURCHASE_CREDITS": "Purchase credits",
"VIEW_HISTORY": "View credit history"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
@@ -402,6 +423,78 @@
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHANGE_PLAN_MODAL": {
"TITLE": "Change plan",
"DESCRIPTION": "Select a plan that best fits your needs.",
"SUBSCRIBE_TITLE": "Choose a plan",
"SUBSCRIBE_DESCRIPTION": "Select a plan to subscribe and unlock all features.",
"CURRENT_PLAN": "Current plan",
"CREDITS_MONTH": "{credits} credits/month",
"PER_USER_MONTH": "/user/month",
"AI_CREDITS_MONTH": "AI credits/month",
"CANCEL": "Cancel",
"SELECT_PLAN": "Select plan",
"CHANGE_PLAN": "Change plan",
"SUBSCRIBE": "Subscribe"
},
"CANCEL_MODAL": {
"TITLE": "Cancel subscription",
"WARNING_TITLE": "You're about to cancel your {plan} plan",
"WARNING_DESCRIPTION": "Your subscription will remain active until {date}. After that, you'll lose access to:",
"FEATURE_AI_CREDITS": "AI credits for Captain features",
"FEATURE_TEAM_COLLABORATION": "Team collaboration tools",
"FEATURE_PRIORITY_SUPPORT": "Priority customer support",
"KEEP_SUBSCRIPTION": "Keep subscription",
"CANCEL_SUBSCRIPTION": "Cancel subscription"
},
"FEEDBACK_MODAL": {
"TITLE": "Help us improve",
"DESCRIPTION": "We're sorry to see you go. Please help us improve by sharing your reason for cancelling.",
"REASON_LABEL": "Why are you cancelling?",
"REASONS": {
"TOO_EXPENSIVE": "It's too expensive",
"NOT_USING": "I'm not using it enough",
"MISSING_FEATURES": "Missing features I need",
"BETTER_ALTERNATIVE": "Found a better alternative",
"TECHNICAL_ISSUES": "Technical issues",
"OTHER": "Other reason"
},
"FEEDBACK_LABEL": "Additional feedback (optional)",
"FEEDBACK_PLACEHOLDER": "Tell us more about your experience...",
"GO_BACK": "Go back",
"SUBMIT_CANCEL": "Submit and cancel"
},
"PURCHASE_MODAL": {
"TITLE": "Purchase AI credits",
"DESCRIPTION": "Choose a credit package that suits your needs.",
"MOST_POPULAR": "Most popular",
"CREDITS": "credits",
"NOTE": "Credits will be added to your account immediately and are non-refundable. Unused credits expire after 12 months.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase"
},
"CREDIT_HISTORY": {
"TITLE": "Credit history",
"DESCRIPTION": "View your credit purchases and allocations.",
"NAME": "Name",
"CREDITS": "Credits",
"SOURCE": "Source",
"EFFECTIVE_AT": "Effective at",
"EXPIRES_AT": "Expires at",
"NO_RECORDS": "No credit history found.",
"CLOSE": "Close"
},
"HELP": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"CHAT": "Chat with us"
},
"ALERTS": {
"PLAN_CHANGED": "Your plan has been changed successfully.",
"SEATS_UPDATED": "Number of seats updated successfully.",
"SUBSCRIPTION_CANCELLED": "Your subscription has been scheduled for cancellation.",
"CREDITS_PURCHASED": "Credits purchased successfully."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
@@ -1,124 +1,252 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAlert } from 'dashboard/composables';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import SubscriptionRow from './components/SubscriptionRow.vue';
import SeatStepper from './components/SeatStepper.vue';
import ChangePlanModal from './components/ChangePlanModal.vue';
import CancelSubscriptionModal from './components/CancelSubscriptionModal.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import CreditHistoryModal from './components/CreditHistoryModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
fetchLimits: fetchCaptainLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const pricingPlans = useMapGetter('accounts/getPricingPlans');
const topupOptions = useMapGetter('accounts/getTopupOptions');
const creditGrants = useMapGetter('accounts/getCreditGrants');
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
// Modal refs
const changePlanModalRef = ref(null);
const cancelModalRef = ref(null);
const purchaseCreditsModalRef = ref(null);
const creditHistoryModalRef = ref(null);
// State
const isWaitingForBilling = ref(false);
const isEditingSeats = ref(false);
const editedSeats = ref(0);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
/**
* Computed property for plan name
* @returns {string|undefined}
*/
const planName = computed(() => {
return customAttributes.value.plan_name;
});
const planName = computed(() => customAttributes.value.plan_name);
const currentPlanId = computed(
() => customAttributes.value.stripe_pricing_plan_id
);
const subscriptionId = computed(
() => customAttributes.value.stripe_subscription_id
);
const subscribedQuantity = computed(
() => customAttributes.value.subscribed_quantity
);
const subscriptionStatus = computed(
() => customAttributes.value.subscription_status
);
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
*/
const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
// Check if user has an active subscription (required for seat changes and cancellation)
const hasActiveSubscription = computed(() => !!subscriptionId.value);
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
// return date as 12 Jan, 2034
const subscriptionEndsAt = computed(() => {
if (!customAttributes.value.subscription_ends_at) return '';
const endDate = new Date(customAttributes.value.subscription_ends_at);
return format(endDate, 'dd MMM, yyyy');
});
/**
* Computed property indicating if user has a billing plan
* @returns {boolean}
*/
const hasABillingPlan = computed(() => {
return !!planName.value;
const subscriptionCancelledAt = computed(() => {
if (!customAttributes.value.subscription_cancelled_at) return '';
const cancelledDate = new Date(customAttributes.value.subscription_cancelled_at);
return format(cancelledDate, 'dd MMM, yyyy');
});
const hasABillingPlan = computed(() => !!planName.value);
const isCancelled = computed(() => {
return subscriptionStatus.value === 'cancel_at_period_end';
});
// Captain credits
const creditsUsed = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.consumed || 0;
});
const creditsTotal = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.totalCount || 0;
});
// Actions
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch captain limits to show AI credits
fetchCaptainLimits();
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const onClickBillingPortal = () => {
const openBillingPortal = () => {
store.dispatch('accounts/checkout');
};
const onToggleChatWindow = () => {
const openChatWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
// Modal handlers
const openChangePlanModal = async () => {
await store.dispatch('accounts/fetchPricingPlans');
changePlanModalRef.value?.open();
};
const handlePlanSelect = async plan => {
try {
// If no active subscription, use subscribe API (for Hacker plan users)
if (!hasActiveSubscription.value) {
await store.dispatch('accounts/subscribeToPlan', {
pricingPlanId: plan.id,
quantity: 1,
});
// subscribeToPlan redirects to Stripe, so no need to close modal
return;
}
// Otherwise use change plan API (for existing subscribers)
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: plan.id,
quantity: subscribedQuantity.value || 1,
});
changePlanModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.PLAN_CHANGED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Seats editing
const startEditingSeats = () => {
editedSeats.value = subscribedQuantity.value || 1;
isEditingSeats.value = true;
};
const cancelEditingSeats = () => {
isEditingSeats.value = false;
};
const saveSeats = async newSeats => {
if (newSeats === subscribedQuantity.value) {
isEditingSeats.value = false;
return;
}
try {
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: currentPlanId.value,
quantity: newSeats,
});
isEditingSeats.value = false;
useAlert(t('BILLING_SETTINGS.ALERTS.SEATS_UPDATED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Cancel subscription
const openCancelModal = () => {
cancelModalRef.value?.open();
};
const handleCancelConfirm = async ({ reason, feedback }) => {
try {
await store.dispatch('accounts/cancelAccountSubscription', {
reason,
feedback,
});
cancelModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.SUBSCRIPTION_CANCELLED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Purchase credits
const openPurchaseCreditsModal = async () => {
await store.dispatch('accounts/fetchTopupOptions');
purchaseCreditsModalRef.value?.open();
};
const handlePurchaseCredits = async option => {
try {
await store.dispatch('accounts/purchaseCredits', {
credits: option.credits,
});
purchaseCreditsModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.CREDITS_PURCHASED'));
fetchCaptainLimits();
} catch {
// Error already handled
}
};
// Credit history
const openCreditHistoryModal = async () => {
await store.dispatch('accounts/fetchCreditGrants');
creditHistoryModalRef.value?.open();
};
onMounted(handleBillingPageLogic);
</script>
@@ -137,92 +265,187 @@ onMounted(handleBillingPageLogic);
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS.TITLE')"
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
/>
>
<template #actions>
<Button
solid
blue
sm
:label="$t('BILLING_SETTINGS.BILLING_PORTAL')"
:is-loading="uiFlags.isCheckoutInProcess"
@click="openBillingPortal"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
<section class="flex flex-col gap-6">
<a
href="https://www.chatwoot.com/pricing"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-n-blue-text hover:underline"
>
<template #action>
<ButtonV4 sm solid blue @click="onClickBillingPortal">
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
</ButtonV4>
</template>
<div
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
>
<DetailItem
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
{{ $t('BILLING_SETTINGS.VIEW_ALL_PLANS') }}
<span class="i-lucide-chevron-right size-4" />
</a>
<!-- Subscription Card -->
<BillingCard
:title="$t('BILLING_SETTINGS.SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.SUBSCRIPTION.DESCRIPTION')"
>
<div class="px-5">
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CURRENT_PLAN')"
:value="planName"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_PLAN')"
:action-disabled="isCancelled"
@action="openChangePlanModal"
/>
<DetailItem
v-if="subscribedQuantity"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.NUMBER_OF_SEATS')"
:value="subscribedQuantity"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_SEATS')"
:show-action="
!isEditingSeats && !isCancelled && hasActiveSubscription
"
@action="startEditingSeats"
>
<template v-if="isEditingSeats" #value>
<SeatStepper
v-model="editedSeats"
:min="1"
:is-loading="uiFlags.isChangingPlan"
@save="saveSeats"
@cancel="cancelEditingSeats"
/>
</template>
</SubscriptionRow>
<SubscriptionRow
v-if="isCancelled"
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CANCELLED_ON')"
:value="subscriptionCancelledAt"
/>
<DetailItem
v-if="subscriptionRenewsOn"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
<SubscriptionRow
:label="
isCancelled
? $t('BILLING_SETTINGS.SUBSCRIPTION.ENDS_ON')
: $t('BILLING_SETTINGS.SUBSCRIPTION.RENEWS_ON')
"
:value="subscriptionEndsAt"
:action-text="
isCancelled || !hasActiveSubscription
? ''
: $t('BILLING_SETTINGS.SUBSCRIPTION.CANCEL_SUBSCRIPTION')
"
@action="openCancelModal"
/>
</div>
</BillingCard>
<!-- Captain AI Card -->
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN_AI.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
<Button
solid
slate
sm
:label="$t('BILLING_SETTINGS.CAPTAIN_AI.PURCHASE_CREDITS')"
@click="openPurchaseCreditsModal"
/>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<div class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
v-bind="responseLimits"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.AI_CREDITS')"
:consumed="creditsUsed"
:total-count="creditsTotal"
/>
</div>
<div v-if="captainLimits && documentLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
v-bind="documentLimits"
/>
<div class="px-5 pt-2">
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click="openCreditHistoryModal"
>
{{ $t('BILLING_SETTINGS.CAPTAIN_AI.VIEW_HISTORY') }}
</button>
</div>
</BillingCard>
<!-- Captain AI Upgrade Card (when not enabled) -->
<BillingCard
v-else
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
>
<template #action>
<ButtonV4 sm solid slate @click="onClickBillingPortal">
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
</ButtonV4>
<Button
solid
slate
sm
:label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@click="openBillingPortal"
/>
</template>
</BillingCard>
<!-- Help Section -->
<BillingHeader
class="px-1 mt-5"
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
class="px-1 mt-2"
:title="$t('BILLING_SETTINGS.HELP.TITLE')"
:description="$t('BILLING_SETTINGS.HELP.DESCRIPTION')"
>
<ButtonV4
sm
<Button
solid
slate
icon="i-lucide-life-buoy"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
sm
icon="i-lucide-message-circle"
:label="$t('BILLING_SETTINGS.HELP.CHAT')"
@click="openChatWidget"
/>
</BillingHeader>
</section>
<!-- Modals -->
<ChangePlanModal
ref="changePlanModalRef"
:plans="pricingPlans"
:current-plan-id="currentPlanId"
:is-loading="uiFlags.isChangingPlan || uiFlags.isSubscribing"
:is-subscribe-mode="!hasActiveSubscription"
@select="handlePlanSelect"
/>
<CancelSubscriptionModal
ref="cancelModalRef"
:plan-name="planName"
:renews-on="subscriptionEndsAt"
:is-loading="uiFlags.isCancellingSubscription"
@confirm="handleCancelConfirm"
/>
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
:options="topupOptions"
:is-loading="uiFlags.isPurchasingCredits"
@purchase="handlePurchaseCredits"
/>
<CreditHistoryModal
ref="creditHistoryModalRef"
:credit-grants="creditGrants"
:is-loading="uiFlags.isFetchingCreditGrants"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CancellationFeedbackForm from './CancellationFeedbackForm.vue';
defineProps({
planName: {
type: String,
default: '',
},
renewsOn: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const step = ref('warning'); // 'warning' | 'feedback'
const features = [
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_AI_CREDITS',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_TEAM_COLLABORATION',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_PRIORITY_SUPPORT',
];
const handleProceedToFeedback = () => {
step.value = 'feedback';
};
const handleBack = () => {
step.value = 'warning';
};
const handleFeedbackSubmit = ({ reason, feedback }) => {
emit('confirm', { reason, feedback });
};
const handleClose = () => {
step.value = 'warning';
emit('close');
};
const open = () => {
step.value = 'warning';
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
step === 'warning'
? t('BILLING_SETTINGS.CANCEL_MODAL.TITLE')
: t('BILLING_SETTINGS.FEEDBACK_MODAL.TITLE')
"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Warning Step -->
<template v-if="step === 'warning'">
<div class="p-4 border rounded-lg bg-n-ruby-9/5 border-n-ruby-9/20">
<div class="flex gap-3">
<span
class="flex-shrink-0 i-lucide-alert-triangle size-5 text-n-ruby-9"
/>
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-n-ruby-11">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_TITLE', {
plan: planName,
})
}}
</p>
<p class="text-sm text-n-ruby-11/80">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_DESCRIPTION', {
date: renewsOn,
})
}}
</p>
<ul class="mt-2 space-y-1">
<li
v-for="feature in features"
:key="feature"
class="flex items-center gap-2 text-sm text-n-ruby-11"
>
<span class="size-1.5 rounded-full bg-n-ruby-9" />
{{ t(feature) }}
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-3 mt-6">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CANCEL_MODAL.KEEP_SUBSCRIPTION')"
class="flex-1"
@click="close"
/>
<Button
color="ruby"
solid
:label="t('BILLING_SETTINGS.CANCEL_MODAL.CANCEL_SUBSCRIPTION')"
class="flex-1"
@click="handleProceedToFeedback"
/>
</div>
</template>
<!-- Feedback Step -->
<template v-else>
<CancellationFeedbackForm
:is-loading="isLoading"
@submit="handleFeedbackSubmit"
@back="handleBack"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'back']);
const { t } = useI18n();
const selectedReason = ref('');
const additionalFeedback = ref('');
const cancellationReasons = [
{
key: 'too_expensive',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TOO_EXPENSIVE',
},
{
key: 'not_using',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.NOT_USING',
},
{
key: 'missing_features',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.MISSING_FEATURES',
},
{
key: 'better_alternative',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.BETTER_ALTERNATIVE',
},
{
key: 'technical_issues',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TECHNICAL_ISSUES',
},
{ key: 'other', label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.OTHER' },
];
const handleSubmit = () => {
emit('submit', {
reason: selectedReason.value,
feedback: additionalFeedback.value,
});
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.DESCRIPTION') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.REASON_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<label
v-for="reason in cancellationReasons"
:key="reason.key"
class="flex items-center gap-3 p-3 transition-colors border rounded-lg cursor-pointer border-n-weak hover:bg-n-alpha-1"
:class="{
'border-n-teal-9 bg-n-teal-9/5': selectedReason === reason.key,
}"
>
<input
v-model="selectedReason"
type="radio"
name="cancellation-reason"
:value="reason.key"
class="w-4 h-4 accent-n-teal-9"
/>
<span class="text-sm text-n-slate-12">{{ t(reason.label) }}</span>
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_LABEL') }}
</label>
<textarea
v-model="additionalFeedback"
rows="3"
class="w-full p-3 text-sm transition-colors border rounded-lg resize-none bg-n-alpha-1 border-n-weak text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none focus:border-n-strong"
:placeholder="t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_PLACEHOLDER')"
/>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border rounded-lg border-n-weak text-n-slate-12 hover:bg-n-alpha-1"
:disabled="isLoading"
@click="handleBack"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.GO_BACK') }}
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium text-white transition-colors rounded-lg bg-n-slate-9 hover:bg-n-slate-10 disabled:opacity-50"
:disabled="!selectedReason || isLoading"
@click="handleSubmit"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.SUBMIT_CANCEL') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PlanCard from './PlanCard.vue';
const props = defineProps({
plans: {
type: Array,
default: () => [],
},
currentPlanId: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
isSubscribeMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const selectedPlanId = ref(props.currentPlanId);
const selectedPlan = computed(() => {
return filteredPlans.value.find(p => p.id === selectedPlanId.value);
});
// Filter out Hacker plan when user already has a subscription (change plan mode)
const filteredPlans = computed(() => {
if (props.isSubscribeMode) {
// In subscribe mode (no subscription), show all plans including Hacker
return props.plans;
}
// In change plan mode (has subscription), hide Hacker plan
return props.plans.filter(
plan => plan.display_name?.toLowerCase() !== 'hacker'
);
});
const isCurrentPlanSelected = computed(() => {
return selectedPlanId.value === props.currentPlanId;
});
const modalTitle = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_TITLE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.TITLE');
});
const modalDescription = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_DESCRIPTION');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.DESCRIPTION');
});
const confirmButtonLabel = computed(() => {
if (isCurrentPlanSelected.value) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN');
}
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CHANGE_PLAN');
});
const handlePlanSelect = plan => {
selectedPlanId.value = plan.id;
};
const handleConfirm = () => {
if (!isCurrentPlanSelected.value && selectedPlan.value) {
emit('select', selectedPlan.value);
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
selectedPlanId.value = props.currentPlanId;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="modalTitle"
:description="modalDescription"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-3 gap-4">
<PlanCard
v-for="plan in filteredPlans"
:key="plan.id"
:plan="plan"
:is-selected="selectedPlanId === plan.id"
:is-current="plan.id === currentPlanId"
:is-disabled="isLoading || plan.id === currentPlanId"
@select="handlePlanSelect"
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CANCEL')"
class="w-full"
:disabled="isLoading"
@click="close"
/>
<Button
color="slate"
solid
:label="confirmButtonLabel"
class="w-full"
:disabled="isCurrentPlanSelected || isLoading"
:is-loading="isLoading"
@click="handleConfirm"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,146 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
creditGrants: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const dialogRef = ref(null);
const formattedGrants = computed(() => {
return props.creditGrants.map(grant => ({
...grant,
formattedEffectiveAt: grant.effective_at
? format(new Date(grant.effective_at), 'dd MMM, yyyy')
: '-',
formattedExpiresAt: grant.expires_at
? format(new Date(grant.expires_at), 'dd MMM, yyyy')
: '-',
}));
});
const handleClose = () => {
emit('close');
};
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.CREDIT_HISTORY.TITLE')"
:description="t('BILLING_SETTINGS.CREDIT_HISTORY.DESCRIPTION')"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
@close="handleClose"
>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="i-lucide-loader-2 size-6 animate-spin text-n-slate-11" />
</div>
<div v-else-if="formattedGrants.length === 0" class="py-8 text-center">
<span
class="i-lucide-credit-card size-12 text-n-slate-9 mx-auto block mb-3"
/>
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NO_RECORDS') }}
</p>
</div>
<div v-else class="overflow-hidden border rounded-lg border-n-weak">
<table class="w-full">
<thead class="bg-n-alpha-1">
<tr>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NAME') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.CREDITS') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.SOURCE') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EFFECTIVE_AT') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EXPIRES_AT') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr v-for="grant in formattedGrants" :key="grant.id">
<td class="px-4 py-3 text-sm text-n-slate-12">
{{ grant.name || '-' }}
</td>
<td
class="px-4 py-3 text-sm font-medium tabular-nums text-n-slate-12"
>
{{ grant.credits?.toLocaleString() || 0 }}
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs font-medium rounded bg-n-alpha-2 text-n-slate-11"
>
{{ grant.source || grant.category || '-' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedEffectiveAt }}
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedExpiresAt }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end w-full">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CREDIT_HISTORY.CLOSE')"
@click="close"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
isPopular: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
const { t } = useI18n();
const formattedCredits = computed(() => {
return props.credits.toLocaleString();
});
const formattedAmount = computed(() => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(props.amount);
});
const cardClasses = computed(() => {
const baseClasses =
'relative flex flex-col gap-2 p-4 transition-all border rounded-xl cursor-pointer';
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5`;
}
return `${baseClasses} border-n-weak hover:border-n-strong`;
});
</script>
<template>
<div :class="cardClasses" @click="$emit('select')">
<div
v-if="isPopular"
class="absolute px-2 py-1 text-xs font-medium text-white rounded -top-3 left-3 bg-n-teal-9"
>
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.MOST_POPULAR') }}
</div>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
>
<span class="text-white i-lucide-check size-4" />
</div>
<div class="text-2xl font-semibold text-n-slate-12">
{{ formattedCredits }}
</div>
<div class="text-xs font-medium tracking-wider uppercase text-n-slate-10">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.CREDITS') }}
</div>
<div class="mt-2">
<span class="text-lg font-semibold text-n-slate-12">{{
formattedAmount
}}</span>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
plan: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isCurrent: {
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
const { t } = useI18n();
const displayPrice = computed(() => {
const licenseFee = props.plan.components?.find(c => c.type === 'license_fee');
const amount = licenseFee?.unit_amount || 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
});
const monthlyCredits = computed(() => {
const serviceAction = props.plan.components?.find(
c => c.type === 'service_action'
);
return serviceAction?.credit_amount || 0;
});
const cardClasses = computed(() => {
const baseClasses = 'relative flex flex-col gap-2 p-4 transition-all border rounded-xl';
if (props.isDisabled) {
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 opacity-70 cursor-not-allowed`;
}
return `${baseClasses} border-n-weak opacity-50 cursor-not-allowed`;
}
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 cursor-pointer`;
}
return `${baseClasses} border-n-weak hover:border-n-strong cursor-pointer`;
});
const handleClick = () => {
if (!props.isDisabled) {
// Emit is handled in template
}
};
</script>
<template>
<div :class="cardClasses" @click="!isDisabled && $emit('select', plan)">
<div
v-if="isCurrent"
class="absolute px-2 py-1 text-xs font-medium rounded -top-3 left-3 bg-n-solid-3 text-n-slate-11"
>
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN') }}
</div>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
>
<span class="text-white i-lucide-check size-4" />
</div>
<h3 class="text-lg font-medium text-n-slate-12">
{{ plan.display_name }}
</h3>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-n-slate-12">
{{ displayPrice }}
</span>
<span class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.PER_USER_MONTH') }}
</span>
</div>
<div class="text-sm text-n-slate-11">
{{ monthlyCredits }}
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.AI_CREDITS_MONTH') }}
</div>
</div>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['purchase', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const selectedCredits = ref(null);
// The 3rd option (5000 credits) is marked as most popular based on Figma
const popularCreditsAmount = 5000;
const selectedOption = computed(() => {
return props.options.find(o => o.credits === selectedCredits.value);
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const handlePurchase = () => {
if (selectedOption.value) {
emit('purchase', selectedOption.value);
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = props.options.find(
o => o.credits === popularCreditsAmount
);
selectedCredits.value = popularOption?.credits || props.options[0]?.credits;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.PURCHASE_MODAL.TITLE')"
:description="t('BILLING_SETTINGS.PURCHASE_MODAL.DESCRIPTION')"
width="xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in options"
:key="option.credits"
:credits="option.credits"
:amount="option.amount"
:is-popular="option.credits === popularCreditsAmount"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.NOTE') }}
</p>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="teal"
solid
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,104 @@
<script setup>
import { ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
min: {
type: Number,
default: 1,
},
max: {
type: Number,
default: 999,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
const localValue = ref(props.modelValue);
watch(
() => props.modelValue,
newVal => {
localValue.value = newVal;
}
);
const decrement = () => {
if (localValue.value > props.min) {
localValue.value -= 1;
emit('update:modelValue', localValue.value);
}
};
const increment = () => {
if (localValue.value < props.max) {
localValue.value += 1;
emit('update:modelValue', localValue.value);
}
};
const handleSave = () => {
emit('save', localValue.value);
};
const handleCancel = () => {
localValue.value = props.modelValue;
emit('cancel');
};
</script>
<template>
<div class="flex items-center gap-4">
<div
class="flex items-center overflow-hidden border rounded-lg border-n-weak"
>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue <= min || isLoading"
@click="decrement"
>
<span class="i-lucide-minus size-4" />
</button>
<span
class="flex items-center justify-center w-12 h-10 text-base font-medium tabular-nums text-n-slate-12"
>
{{ localValue }}
</span>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue >= max || isLoading"
@click="increment"
>
<span class="i-lucide-plus size-4" />
</button>
</div>
<div class="flex items-center gap-2">
<Button
variant="link"
color="slate"
label="Cancel"
:disabled="isLoading"
@click="handleCancel"
/>
<Button
solid
slate
sm
label="Save"
:is-loading="isLoading"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
default: '',
},
actionText: {
type: String,
default: '',
},
actionDisabled: {
type: Boolean,
default: false,
},
showAction: {
type: Boolean,
default: true,
},
});
defineEmits(['action']);
</script>
<template>
<div
class="flex items-center justify-between py-4 border-b border-n-weak last:border-b-0"
>
<div class="flex flex-col gap-1">
<span
class="text-xs font-medium tracking-wider uppercase text-n-slate-10"
>
{{ label }}
</span>
<slot name="value">
<span class="text-base font-medium text-n-slate-12">
{{ value }}
</span>
</slot>
</div>
<slot name="action">
<button
v-if="actionText && showAction"
type="button"
class="text-sm font-medium transition-colors text-n-slate-11 hover:text-n-slate-12 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="actionDisabled"
@click="$emit('action')"
>
{{ actionText }}
</button>
</slot>
</div>
</template>
@@ -18,7 +18,17 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingPricingPlans: false,
isFetchingTopupOptions: false,
isFetchingCreditGrants: false,
isChangingPlan: false,
isCancellingSubscription: false,
isPurchasingCredits: false,
isSubscribing: false,
},
pricingPlans: [],
topupOptions: [],
creditGrants: [],
};
export const getters = {
@@ -28,6 +38,15 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getPricingPlans($state) {
return $state.pricingPlans;
},
getTopupOptions($state) {
return $state.topupOptions;
},
getCreditGrants($state) {
return $state.creditGrants;
},
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
@@ -152,6 +171,120 @@ export const actions = {
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
// V2 Billing Actions
fetchPricingPlans: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingPricingPlans: true });
try {
const response = await EnterpriseAccountAPI.getPricingPlans();
commit(types.default.SET_PRICING_PLANS, response.data.pricing_plans);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingPricingPlans: false,
});
}
},
fetchTopupOptions: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingTopupOptions: true });
try {
const response = await EnterpriseAccountAPI.getTopupOptions();
commit(types.default.SET_TOPUP_OPTIONS, response.data.topup_options);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingTopupOptions: false,
});
}
},
fetchCreditGrants: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingCreditGrants: true });
try {
const response = await EnterpriseAccountAPI.getCreditGrants();
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingCreditGrants: false,
});
}
},
changePricingPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: true });
try {
const response = await EnterpriseAccountAPI.changePricingPlan(
pricingPlanId,
quantity
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: false });
}
},
cancelAccountSubscription: async ({ commit }, { reason, feedback }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: true,
});
try {
const response = await EnterpriseAccountAPI.cancelSubscription(
reason,
feedback
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: false,
});
}
},
purchaseCredits: async ({ commit }, { credits }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: true });
try {
const response = await EnterpriseAccountAPI.topupCredits(credits);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: false });
}
},
subscribeToPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: true });
try {
const response = await EnterpriseAccountAPI.subscribeToPlan(
pricingPlanId,
quantity
);
// Redirect to Stripe checkout
if (response.data.redirect_url) {
window.location = response.data.redirect_url;
}
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: false });
}
},
};
export const mutations = {
@@ -164,6 +297,15 @@ export const mutations = {
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
[types.default.SET_PRICING_PLANS]($state, data) {
$state.pricingPlans = data;
},
[types.default.SET_TOPUP_OPTIONS]($state, data) {
$state.topupOptions = data;
},
[types.default.SET_CREDIT_GRANTS]($state, data) {
$state.creditGrants = data;
},
};
export default {
@@ -76,13 +76,16 @@ export default {
EDIT_INBOXES: 'EDIT_INBOXES',
DELETE_INBOXES: 'DELETE_INBOXES',
// Agent
// Account
SET_ACCOUNT_UI_FLAG: 'SET_ACCOUNT_UI_FLAG',
SET_ACCOUNT_LIMITS: 'SET_ACCOUNT_LIMITS',
SET_ACCOUNTS: 'SET_ACCOUNTS',
ADD_ACCOUNT: 'ADD_ACCOUNT',
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
DELETE_ACCOUNT: 'DELETE_AGENT',
SET_PRICING_PLANS: 'SET_PRICING_PLANS',
SET_TOPUP_OPTIONS: 'SET_TOPUP_OPTIONS',
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
// Agent
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',