diff --git a/.github/workflows/sparrowadmin-staging.yml b/.github/workflows/sparrowadmin-staging.yml new file mode 100644 index 00000000..0d35a1b8 --- /dev/null +++ b/.github/workflows/sparrowadmin-staging.yml @@ -0,0 +1,57 @@ +name: Sparrow-admin-Stg +on: + push: + branches: + - release/2.34.0 + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + environment: sparrow_admin_app_stg + steps: + - uses: actions/checkout@master + + - uses: Azure/docker-login@v1 + with: + login-server: sparrowprod.azurecr.io + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - run: | + docker build \ + --build-arg VITE_SPARROW_LAUNCH_URL=${{ vars.VITE_SPARROW_LAUNCH_URL }} \ + --build-arg VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL }} \ + --build-arg VITE_LOGIN_REDIRECT=${{ vars.VITE_LOGIN_REDIRECT }} \ + --build-arg VITE_SPARROW_DOCS_URL=${{ vars.VITE_SPARROW_DOCS_URL }} \ + --build-arg VITE_ENVIRONMENT=${{ vars.VITE_ENVIRONMENT }} \ + --build-arg VITE_POSTHOG_CONNECTION_API_KEY=${{ vars.VITE_POSTHOG_CONNECTION_API_KEY }} \ + --build-arg VITE_SPARROW_MARKETING_URL=${{ vars.VITE_SPARROW_MARKETING_URL }} \ + --build-arg VITE_APP_EDITION=${{ vars.VITE_APP_EDITION }} \ + -t sparrowprod.azurecr.io/sparrow-admin-app:${{ github.run_number }} . + + docker push sparrowprod.azurecr.io/sparrow-admin-app:${{ github.run_number }} + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: richardrigutins/replace-in-files@v1 + with: + files: "./deployment/deployment.yml" + search-text: '_BUILD__ID_' + replacement-text: '${{ github.run_number }}' + + - uses: azure/setup-kubectl@v2.0 + + - uses: Azure/k8s-set-context@v2 + with: + kubeconfig: ${{ secrets.KUBE_CONFIG }} + + - uses: Azure/k8s-deploy@v4 + with: + action: deploy + namespace: sparrow-staging + manifests: | + ./deployment/deployment.yml diff --git a/package.json b/package.json index e60d14ba..e176074d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sparrow-admin-app", "private": true, - "version": "2.33.0", + "version": "2.34.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/src/assets/icons/ErrorCircleIcon.svelte b/src/assets/icons/ErrorCircleIcon.svelte new file mode 100644 index 00000000..4007810f --- /dev/null +++ b/src/assets/icons/ErrorCircleIcon.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/src/components/DowngradePlan/ChooseActiveMembersModal.svelte b/src/components/DowngradePlan/ChooseActiveMembersModal.svelte new file mode 100644 index 00000000..80921499 --- /dev/null +++ b/src/components/DowngradePlan/ChooseActiveMembersModal.svelte @@ -0,0 +1,184 @@ + + + dispatch('close')} +> +
+ Step 1 +
+
+
+ 2 +
+ Step 2 +
+
+
+ {#if filteredUsers.length > 0} + {#each filteredUsers as user} +
toggleMember(user.id)} + > +
+ +
+ + {getFirstName(user.name || user.email)} + + ({getRoleLabel(user.role)}) +
{user.email}
+
+
+
+ Last active: {getTimeDifference(user.lastActiveAt)} +
+
+ {/each} + {:else} +
+ No active members present for the selected workspaces. +
+ {/if} +
+
diff --git a/src/components/DowngradePlan/ChooseActiveWorkspaceModal.svelte b/src/components/DowngradePlan/ChooseActiveWorkspaceModal.svelte new file mode 100644 index 00000000..eeb84697 --- /dev/null +++ b/src/components/DowngradePlan/ChooseActiveWorkspaceModal.svelte @@ -0,0 +1,205 @@ + + + dispatch('close')} +> +
+
+
+ 1 +
+ Step 1 +
+
+ Step 2 +
+ +
+ + + + + + + + + + + + {#each workspaces as ws} + toggleWorkspace(ws.id)} + > + + + + + + + + {/each} + +
WorkspacesCollectionsContributorsLast updated
+ + {ws.name}{workspaceDetails.get(ws.id)?.collections ?? '-'}{workspaceDetails.get(ws.id)?.contributors ?? '-'}{workspaceDetails.get(ws.id)?.updated ?? '-'}
+
+
diff --git a/src/components/DowngradePlan/Common/TableModalCommonLayout.svelte b/src/components/DowngradePlan/Common/TableModalCommonLayout.svelte new file mode 100644 index 00000000..83eea7aa --- /dev/null +++ b/src/components/DowngradePlan/Common/TableModalCommonLayout.svelte @@ -0,0 +1,106 @@ + + +{#if isOpen} + +{/if} \ No newline at end of file diff --git a/src/components/DowngradePlan/DowngradePlanModal.svelte b/src/components/DowngradePlan/DowngradePlanModal.svelte new file mode 100644 index 00000000..58f41b37 --- /dev/null +++ b/src/components/DowngradePlan/DowngradePlanModal.svelte @@ -0,0 +1,158 @@ + + +
+
+

+ Downgrade to {selectedPlan === 'community' ? 'Community Edition' : `${selectedPlan} Plan`} +

+ +
+ + +
+
+

Hub Name

+

{hubName}

+
+ +
+

Current Plan

+

{currentPlan}

+
+
+ + +

+ Are you sure you want to downgrade your plan from {currentPlan} to {selectedPlan}? Some features and access will + change after the downgrade. +

+ + +

+ Here's the major limitations compared to {currentPlan}: +

+ + + +

+ Not sure what's included in each plan? Take a quick look to + + compare plans + + before downgrading. +

+ + + {#if currentPlan !== 'Enterprise'} +
+
+
+ +
+

+ You're downgrading '{hubName}'. Paid features stay active + until + {expiryDate}, after which your hub will move + to the {selectedPlan} plan. +

+
+ {/if} + +
diff --git a/src/components/DowngradePlan/ReviewScheduledDowngradeModal.svelte b/src/components/DowngradePlan/ReviewScheduledDowngradeModal.svelte new file mode 100644 index 00000000..56b0ed69 --- /dev/null +++ b/src/components/DowngradePlan/ReviewScheduledDowngradeModal.svelte @@ -0,0 +1,116 @@ + + +{#if isOpen} +
+ +
+

+ Please Review Your Scheduled Downgrade +

+ +
+ + +

+ You are about to schedule your downgrade plan from + {currentPlan} edition to + {selectedPlan} edition for + {expiryDate}. Please review the changes below before you confirm. You can go back + to make changes. +

+ + +
+

Active Workspaces

+ {#if selectedWorkspaces.length === 0} +

+ No active workspaces for this hub +

+ {:else} +
+ {#each selectedWorkspaces as ws} + • {ws.name || ws} + {/each} +
+ {/if} +
+ + {#if selectedPlan?.toLowerCase() === 'community'} +
+

Active Members

+ + {#if selectedMembers.length === 0} +

+ No active members for this hub +

+ {:else} +
+ {#each selectedMembers as member} + • {member.email || member} + {/each} +
+ {/if} +
+ {/if} + + +
+

Share Feedback

+ +

{feedback.length}/{maxChars}

+
+ + +
+ window.open('mailto:contactus@sparrowapp.dev', '_blank')} + /> +
+
+{/if} diff --git a/src/components/Modal/Modal.svelte b/src/components/Modal/Modal.svelte index b8f9ae47..b7bca190 100644 --- a/src/components/Modal/Modal.svelte +++ b/src/components/Modal/Modal.svelte @@ -18,7 +18,7 @@ }); -
+
diff --git a/src/components/PlanUpdateStatus/PlanDowngradeFailed.svelte b/src/components/PlanUpdateStatus/PlanDowngradeFailed.svelte new file mode 100644 index 00000000..d3c04cda --- /dev/null +++ b/src/components/PlanUpdateStatus/PlanDowngradeFailed.svelte @@ -0,0 +1,67 @@ + + +
+ +
+
+

+ Failed to Schedule Downgrade +

+
+ + +
+ + +

+ We're sorry, but we were unable to schedule the downgrade for '{hubName}' at this time. +

+

+ Your plan has not been changed. Please try again. If the problem continues, our support team is + ready to help. +

+ + +
+ + Contact Support + + +
+ + +
+
+
\ No newline at end of file diff --git a/src/components/PlanUpdateStatus/PlanUpdateSuccess.svelte b/src/components/PlanUpdateStatus/PlanUpdateSuccess.svelte index 12454426..a1693276 100644 --- a/src/components/PlanUpdateStatus/PlanUpdateSuccess.svelte +++ b/src/components/PlanUpdateStatus/PlanUpdateSuccess.svelte @@ -16,6 +16,10 @@ export let nextBillingDate: string = ''; export let fromPlan: string = ''; export let toPlan: string = ''; + export let PlanUpdateTitle: string = ''; + export let description: string = ''; + export let isUpgrade: boolean = false; + export let buttonText: string = ''; function handleClose() { dispatch('close'); @@ -38,19 +42,23 @@
-
- -
-

Plan Updated

+ {#if isUpgrade} +
+ +
+ {/if} +

{PlanUpdateTitle}

-

- You've successfully switched from the {fromPlan} to the {toPlan} plan. -

+ {#if isUpgrade} +

+ You've successfully switched from the {fromPlan} to the {toPlan} plan. +

+ {/if}
@@ -70,10 +78,16 @@
+ {#if description} +

+ {description} +

+ {/if} +
- +
-
+ \ No newline at end of file diff --git a/src/components/PlanUpdateStatus/index.ts b/src/components/PlanUpdateStatus/index.ts index aa4cdaee..fa7c3d8d 100644 --- a/src/components/PlanUpdateStatus/index.ts +++ b/src/components/PlanUpdateStatus/index.ts @@ -1,3 +1,4 @@ export { default as PlanUpdateFailed } from './PlanUpdateFailed.svelte'; export { default as PlanUpdateSuccess } from './PlanUpdateSuccess.svelte'; export { default as PlanScheduleSuccess } from './PlanScheduleSuccess.svelte'; +export { default as PlanDowngradeFailed} from './PlanDowngradeFailed.svelte' \ No newline at end of file diff --git a/src/pages/Hubs/WorkSpaceOverview/BottomWorkspace.svelte b/src/pages/Hubs/WorkSpaceOverview/BottomWorkspace.svelte index 536fbc33..bc5ccc5a 100644 --- a/src/pages/Hubs/WorkSpaceOverview/BottomWorkspace.svelte +++ b/src/pages/Hubs/WorkSpaceOverview/BottomWorkspace.svelte @@ -111,7 +111,6 @@ teamName: 'Members', workspaces: data?.users || [], }; - $: columns = selectedTab === 'resources' ? [ diff --git a/src/pages/Hubs/WorkSpaceOverview/WorkSpaceOverview.svelte b/src/pages/Hubs/WorkSpaceOverview/WorkSpaceOverview.svelte index e94bd02a..6aec81ac 100644 --- a/src/pages/Hubs/WorkSpaceOverview/WorkSpaceOverview.svelte +++ b/src/pages/Hubs/WorkSpaceOverview/WorkSpaceOverview.svelte @@ -150,7 +150,6 @@ tab: queryState.tab, resources: queryState.resourceType, }; - return hubsService.getWorkspaceDetails(queryParams); }); diff --git a/src/pages/Payment/Overview/Overview.svelte b/src/pages/Payment/Overview/Overview.svelte index 9d71a893..2f051e92 100644 --- a/src/pages/Payment/Overview/Overview.svelte +++ b/src/pages/Payment/Overview/Overview.svelte @@ -15,6 +15,7 @@ processSubscriptionData, getCurrentPlanDetails, initializePlanDetails, + DEFAULT_PLAN_DETAILS } from '@/utils/pricing'; import { getDynamicCssClasses } from '@/utils/planTagStyles'; import { captureEvent } from '@/utils/posthogConfig'; @@ -38,6 +39,9 @@ import RedirectIcon from '@/assets/icons/RedirectIcon.svelte'; import CircularLoader from '@/ui/CircularLoader/CircularLoader.svelte'; import GreenCheckIconFill from '@/assets/icons/GreenCheckIconFill.svelte'; + import ChooseActiveWorkspaceModal from '@/components/DowngradePlan/ChooseActiveWorkspaceModal.svelte'; + import ChooseActiveMembersModal from '@/components/DowngradePlan/ChooseActiveMembersModal.svelte'; + import ReviewScheduledDowngradeModal from '@/components/DowngradePlan/ReviewScheduledDowngradeModal.svelte'; // ===== CONSTANTS ===== const location = useLocation(); @@ -65,7 +69,16 @@ let invoiceUrl = ''; let isScheduledDowngrade = false; let promoDiscount = null; - + let workspaces = []; + let selectedWorkspaces = []; + let unSelectedWorkspaces = []; + let selectedMembers = []; + let membersFromSelectedWorkspaces = []; + let hubOwner = null + let hubUsers = [] + let showMembersModal = false + let createdAt = null + let feedback = '' // UI state let isLoadingSubscription = false; let hasRedirected = false; // Prevent multiple redirects @@ -75,13 +88,16 @@ // Cancel subscription state let showCancelConfirmModal = false; + let showWorkspaceModal = false; let showCancelSuccessModal = false; let cancelInProgress = false; + let showReviewModal = false; // Resubscribe state let showResubscribeModal = false; let resubscribeInProgress = false; + // Animation stores for cards const cardOpacity = tweened(0, { duration: 600, @@ -181,12 +197,41 @@ } } + function getExpiryDate(createdAt){ + if (!createdAt) return '-'; + const created = new Date(createdAt); + const expiry = new Date(created); + expiry.setDate(expiry.getDate() + 30); + + // Format like "28 Aug 2025, 10:00 AM UTC" + const options= { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZone: 'UTC', + }; + + // Calculate days left + const now = new Date(); + const diffInMs = expiry.getTime() - now.getTime(); + const daysLeft = Math.max(Math.ceil(diffInMs / (1000 * 60 * 60 * 24)), 0); // avoid negative + + const formattedExpiry = expiry.toLocaleString('en-GB', options) + ' UTC'; + return `${formattedExpiry} (${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`; + } // Process hub data when it changes $: if ($hubData !== undefined) { currentHubData = $hubData?.data || null; hubName = currentHubData?.name || ''; userCount = $hubData?.data?.users?.length + $hubData?.data?.invites?.length || 1; planStatus = currentHubData?.billing?.status; + workspaces = currentHubData?.workspaces; + hubUsers = currentHubData?.users; + hubOwner = currentHubData?.owner; + createdAt = currentHubData?.createdAt; // Use plan name from the database currentPlan = currentHubData?.plan?.name || 'Community'; invoiceUrl = @@ -209,7 +254,6 @@ ) { // Process subscription data using the utility function const processedData = processSubscriptionData(subscriptionData, currentPlan); - // Update all subscription-related variables subscriptionId = processedData.subscriptionId; // Don't override currentPlan from the database @@ -266,6 +310,8 @@ } } + $: expiryDate = getExpiryDate(createdAt); + // Add animation trigger $: if (!$isFetchingSubscription && !$isFetchingHub && $hubData?.data) { setTimeout(() => { @@ -294,32 +340,156 @@ status: subscriptionStatus, userCount: userCount.toString(), inTrial: $hubData?.data?.billing?.in_trial ? 'true' : 'false', + mode: 'upgrade', }); navigate(`/billing/billingInformation/changePlan/${hubId}?${searchParams.toString()}`); } + function handleChangePlanClick() { + captureUserClickUpgradePlan(); + if (planStatus === 'payment_failed' || planStatus === 'action_required') { + if (isRedirecting) { + isRedirecting = false; + return; + } else { + notification.error('Please resolve the payment issue before changing your plan.'); + } + return; + } + const searchParams = new URLSearchParams({ + currentPlan, + currentBillingCycle, + subscriptionId: subscriptionId || '', + status: subscriptionStatus, + userCount: userCount.toString(), + inTrial: $hubData?.data?.billing?.in_trial ? 'true' : 'false', + mode: 'change-plan', + isScheduledDowngrade: isScheduledDowngrade ? 'true' : 'false', + isScheduledCancelled: subscriptionData?.cancel_at_period_end ? 'true' : 'false' + }); + + navigate(`/billing/billingInformation/changePlan/${hubId}?${searchParams.toString()}`); + } + + $: plans = DEFAULT_PLAN_DETAILS; + // Cancel subscription functions function openCancelModal() { showCancelConfirmModal = true; + } + + function openWorkspaceModal(){ + showWorkspaceModal = true; + } + + function openMembersModal(){ + showMembersModal = true; + } + + function openReviewModal(){ + showReviewModal = true; } function closeCancelConfirmModal() { showCancelConfirmModal = false; } + function closeActiveWorkspaceModal(){ + showWorkspaceModal = false; + cancelInProgress = false; + } + + function closeActiveMembersModal(){ + showMembersModal = false; + cancelInProgress = false; + } + + function closeReviewModal(){ + showReviewModal = false; + } + function closeCancelSuccessModal() { showCancelSuccessModal = false; } - async function confirmCancelSubscription() { + function removeAdminFromMembersList(hubOwner, members = []) { + if (!hubOwner) return members; + return members.filter((member) => member.id !== hubOwner); + } + + function confirmWorkspaceSelection(e){ + selectedWorkspaces = e.detail.selected; + unSelectedWorkspaces = e.detail.unselected; + membersFromSelectedWorkspaces = removeAdminFromMembersList(hubOwner, e.detail.members) || []; + closeActiveWorkspaceModal(); + if(membersFromSelectedWorkspaces?.length > plans.community.monthly.collaborators){ + openMembersModal() + }else{ + selectedMembers = membersFromSelectedWorkspaces; + openReviewModal() + } + } + + function confirmMemberSelection(e){ + selectedMembers = e.detail.selected; + closeActiveMembersModal(); + openReviewModal() + } + + function confirmCancelSubscription() { cancelInProgress = true; + closeCancelConfirmModal() + if(workspaces.length > plans.community.monthly.workspaces){ + openWorkspaceModal() + }else{ + selectedWorkspaces = workspaces; + if(hubUsers?.length > plans.community.monthly.collaborators){ + openMembersModal() + }else{ + selectedMembers = removeAdminFromMembersList(hubOwner, hubUsers) || []; + openReviewModal() + } + } + } + + function closeReviewScheduledDowngradeModal(){ + if (hubUsers?.length > plans.community.monthly.collaborators) { + closeReviewModal(); + openMembersModal(); + if (workspaces.length > plans.community.monthly.workspaces) { + closeActiveMembersModal(); + openWorkspaceModal(); + } + } else if (workspaces.length > plans.community.monthly.workspaces) { + closeReviewModal(); + openWorkspaceModal(); + } else { + closeReviewModal(); + } + } + + async function confirmDowngradeToCommunity(e){ + feedback = e.detail.feedback; + + const downgradePayload = { + teamId: hubId, + workspaces: selectedWorkspaces.map((ws) => ({ + id: ws._id || ws.id, + name: ws.name, + })), + users: selectedMembers.map((user) => ({ + id: user._id || user.id, + email: user.email, + })), + }; + try { // Call the cancel subscription API - await billingService.cancelSubscription({ subscriptionId }); + await billingService.cancelSubscription({ subscriptionId, ...downgradePayload }); - // Close confirmation modal and show success modal - closeCancelConfirmModal(); + // Close review modal and show success modal + closeReviewModal(); showCancelSuccessModal = true; // Update local state @@ -436,7 +606,7 @@ {/if} -{#if $hubData?.data} +{#if $hubData?.data && currentHubData}
@@ -530,21 +700,27 @@
{#if !isScheduledDowngrade} - {#if subscriptionId && subscriptionData?.status === 'active' && !subscriptionData?.cancel_at_period_end} - - {/if} - {#if subscriptionId && subscriptionData?.cancel_at_period_end} - + {#if currentPlan !== 'Community'} + {#if subscriptionId && subscriptionData?.status === 'active' && !subscriptionData?.cancel_at_period_end} + + {/if} + {#if subscriptionId && subscriptionData?.cancel_at_period_end} + + {/if} + {:else} + + Community plan — upgrade to unlock premium features + {/if} {/if}
@@ -592,14 +768,12 @@

- {#if !isScheduledDowngrade && subscriptionData?.cancel_at_period_end && subscriptionData?.status === 'canceled'} - - {/if} +
@@ -621,11 +795,6 @@ on:click={handleUpgradeClick} disabled={isScheduledDowngrade || (subscriptionData?.cancel_at_period_end && subscriptionData?.status !== 'canceled')} - tooltipText={isScheduledDowngrade - ? `Scheduled plan change. Your subscription will downgrade on ${nextBillingDate}. Plan changes are locked until then.` - : ''} - tooltipPosition="bottom" - tooltipDelay={100} > Upgrade @@ -745,6 +914,49 @@ {/if} + {#if showWorkspaceModal} + + {/if} + + {#if showMembersModal} + plans.community.monthly.workspaces + ? membersFromSelectedWorkspaces + : removeAdminFromMembersList(hubOwner, hubUsers)} + planLimits = {plans.community.monthly.collaborators} + isOpen={showMembersModal} + expiryDate={expiryDate} + on:close={closeActiveMembersModal} + on:next={confirmMemberSelection} + /> + {/if} + + {#if showReviewModal} + + + + {/if} + {#if showCancelSuccessModal} diff --git a/src/pages/Payment/PaymentInformation/ChangePlanPage.svelte b/src/pages/Payment/PaymentInformation/ChangePlanPage.svelte index 4e4078ab..229020db 100644 --- a/src/pages/Payment/PaymentInformation/ChangePlanPage.svelte +++ b/src/pages/Payment/PaymentInformation/ChangePlanPage.svelte @@ -7,9 +7,10 @@ // Components import Breadcrumbs from '@/components/Breadcrumbs/Breadcrumbs.svelte'; + import { hubsService } from '@/services/hubs.service'; import Tag from '@/ui/Tag/Tag.svelte'; import Button from '@/ui/Button/Button.svelte'; - + import Modal from '@/components/Modal/Modal.svelte'; // Icons import CheckIcon from '@/assets/icons/Check.svelte'; @@ -25,6 +26,17 @@ type BillingCycleType, } from '@/utils/pricing'; import { captureEvent } from '@/utils/posthogConfig'; + import DowngradePlan from '@/components/DowngradePlan/DowngradePlanModal.svelte'; + import { createQuery } from '@/services/api.common'; + import DowngradePlanModal from '@/components/DowngradePlan/DowngradePlanModal.svelte'; + import ChooseActiveWorkspaceModal from '@/components/DowngradePlan/ChooseActiveWorkspaceModal.svelte'; + import ChooseActiveMembersModal from '@/components/DowngradePlan/ChooseActiveMembersModal.svelte'; + import ReviewScheduledDowngradeModal from '@/components/DowngradePlan/ReviewScheduledDowngradeModal.svelte'; + import { billingService } from '@/services/billing.service'; + import { notification } from '@/components/Toast'; + import PaymentProcessingModal from '@/components/PaymentProcessingModal/PaymentProcessingModal.svelte'; + import { PlanUpdateSuccess } from '@/components/PlanUpdateStatus'; + import { PlanDowngradeFailed } from '@/components/PlanUpdateStatus'; const location = useLocation(); @@ -64,6 +76,35 @@ let subscriptionStatus: string = ''; let userCount: number = 1; let inTrial: boolean = false; + let downgradeModal: boolean = false; + let allowDowngrade: boolean = false; + let selectedPlan: string = ''; + let hubName: string = ''; + let hubWorkspaces: []; + let mode: string = 'change-plan'; + let isScheduledDowngrade: boolean = false; + let isScheduledCancelled: boolean = false; + let createdAt: string; + let hubUsers: []; + let hubOwner: string; + let downgradeFlow = { + downgradeModal: false, + chooseWorkspaceModal: false, + chooseMembersModal: false, + reviewModal: false, + showProcessingModal: false, + showDowngradeConfirmModal: false, + showDowngradeFailedModal: false, + }; + let priceId: string; + let downgradeData = { + selectedWorkspaces: [], + unselectedWorkspaces: [], + selectedMembers: [], + feedback: '', + members: [], + }; + let planLimits: []; // URL parsing $: { @@ -86,17 +127,43 @@ subscriptionStatus = searchParams.get('status') || ''; userCount = parseInt(searchParams.get('userCount') || '1', 10); inTrial = searchParams.get('inTrial') === 'true'; + mode = searchParams.get('mode') || 'change-plan'; + isScheduledDowngrade = searchParams.get('isScheduledDowngrade') === 'true'; + isScheduledCancelled = searchParams.get('isScheduledCancelled') === 'true'; } + $: pageHeading = mode === 'upgrade' ? 'Upgrade Plan' : 'Change Your Plan'; + $: pageDescription = + mode === 'upgrade' + ? "You're ready for the next level. Upgrade to access more and features. You can switch plans at any time." + : 'Select a different plan that fits your needs. Your current plan is highlighted below. The new plan will take effect immediately or at the start of your next billing cycle depending on your billing terms.'; + // Plan details for comparison let planDetails = getCurrentPlanDetails(); - // Initialize plan details with API data onMount(async () => { await initializePlanDetails(); planDetails = getCurrentPlanDetails(); + if (hubId) await getHubName(hubId); }); + async function getHubName(hubId: string) { + try { + const response = await hubsService.getHubDetails(hubId); + if (response?.data) { + const data = response.data; + hubOwner = data.owner; + hubName = data.name || ' '; + hubWorkspaces = data.workspaces; + hubUsers = data.users; + createdAt = data.createdAt; + currentPlan = data.plan.name; + } + } catch (error) { + console.log(error); + } + } + // State let billingCycle: BillingCycleType = currentBillingCycle; @@ -118,22 +185,24 @@ // Handle plan selection function selectPlan(plan) { + selectedPlan = plan; + const selectedPlanLimits = planDetails?.[plan]?.[billingCycle] ?? {}; + planLimits = { + workspacesPerHub: { value: selectedPlanLimits.workspaces }, + usersPerHub: { value: selectedPlanLimits.collaborators }, + }; if (plan === 'enterprise') { captureUserPlanUpgradeClick(currentPlan, plan); - // Open contact form in a new tab window.open('mailto:contactus@sparrowapp.dev', '_blank'); } else { const targetPlanId = getPlanId(plan, billingCycle); - // Don't allow selecting the current plan if (targetPlanId !== currentPlanId) { - // Get the price ID based on billing cycle - const priceId = + priceId = billingCycle === 'monthly' ? planDetails[plan].monthly.priceId : planDetails[plan].annual.priceId; - // Determine if this is a downgrade const isDowngradeChange = isDowngrade( currentPlanLower, plan, @@ -141,17 +210,35 @@ billingCycle, ); - if (!isDowngradeChange) { + if (isDowngradeChange) { + const selectedPlanLimits = planDetails?.[plan]?.[billingCycle] ?? {}; + const maxWorkspaces = selectedPlanLimits.workspaces ?? 0; + const maxUsers = selectedPlanLimits.collaborators ?? 0; + + const currentWorkspaceCount = hubWorkspaces?.length || 0; + const currentUserCount = hubUsers?.length || 0; + + const hasWorkspaces = currentWorkspaceCount > 0; + const hasMembers = currentUserCount > 0; + downgradeFlow.downgradeModal = true; + downgradeData.selectedWorkspaces = []; + downgradeData.selectedMembers = []; + if (!hasWorkspaces && !hasMembers) { + downgradeFlow.downgradeModal = false; + downgradeFlow.reviewModal = true; + return; + } + + return; + } else { captureUserPlanUpgradeClick(currentPlan, plan); } - // Store current Change Plan page URL in session storage const url = new URL(window.location.href); const searchParamsChangePlan = new URLSearchParams(url.search); const updatedUrl = url.pathname + '?' + searchParamsChangePlan.toString(); sessionStorage.setItem('changePlanPageUrl', updatedUrl); - // Navigate to payment method selection page const searchParams = new URLSearchParams({ plan: capitalizeFirstLetter(plan), billingCycle: billingCycle, @@ -170,12 +257,21 @@ } } - // Check if a plan is selectable based on current plan and billing cycle - function isPlanSelectable(plan) { - return checkPlanSelectable(currentPlanLower, plan, billingCycle, currentBillingCycle); + function isPlanSelectable(plan: string) { + if (isScheduledDowngrade) { + if (currentPlan === 'Professional' && (plan === 'community' || plan === 'standard')) { + return false; + } + return true; + } else if (isScheduledCancelled) { + if (plan === 'community') { + return false; + } + return true; + } + return true; } - // Get button text for a plan function getButtonText(plan) { const isCurrentPlan = plan === currentPlanLower && billingCycle === currentBillingCycle; @@ -194,16 +290,16 @@ if (targetPlanValue > currentPlanValue) { return 'Upgrade'; } else if (targetPlanValue < currentPlanValue) { - return 'Downgrade'; + return 'Select Plan'; } else { return 'Your plan'; } } - // Prepare breadcrumb navigation + $: breadcrumbLabel = mode === 'upgrade' ? 'Upgrade Plan' : 'Change Plan'; $: breadcrumbItems = [ { label: 'Billing', path: `/billing/billingOverview/${hubId}` }, - { label: 'Change Plan', path: '' }, + { label: breadcrumbLabel, path: '' }, ]; // Make plan values reactive @@ -259,6 +355,68 @@ }; captureEvent('admin_plan_upgraded', eventProperties); }; + + function getExpiryDate(createdAt: string): string { + if (!createdAt) return '-'; + const created = new Date(createdAt); + const expiry = new Date(created); + expiry.setDate(expiry.getDate() + 30); + + // Format like "28 Aug 2025, 10:00 AM UTC" + const options: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZone: 'UTC', + }; + + // Calculate days left + const now = new Date(); + const diffInMs = expiry.getTime() - now.getTime(); + const daysLeft = Math.max(Math.ceil(diffInMs / (1000 * 60 * 60 * 24)), 0); // avoid negative + + const formattedExpiry = expiry.toLocaleString('en-GB', options) + ' UTC'; + return `${formattedExpiry} (${daysLeft} day${daysLeft !== 1 ? 's' : ''} left)`; + } + $: expiryDate = getExpiryDate(createdAt); + function removeAdminFromMembersList(hubOwner, members = []) { + if (!hubOwner) return members; + return members.filter((member) => member.id !== hubOwner); + } + + function filterOutOwners(users) { + return (users || []).filter((u) => (u.role || '').toLowerCase() !== 'owner'); + } + + function getDowngradeConstraints() { + const workspaceCount = hubWorkspaces?.length || 0; + const memberCount = hubUsers?.length || 0; + const maxWorkspaces = planLimits?.workspacesPerHub?.value || 0; + const maxUsers = planLimits?.usersPerHub?.value || 0; + + const withinWorkspaceLimit = workspaceCount <= maxWorkspaces; + const withinUserLimit = memberCount <= maxUsers; + const isCommunity = selectedPlan?.toLowerCase() === 'community'; + const isStandard = selectedPlan?.toLowerCase() === 'standard'; + const hasWorkspaces = workspaceCount > 0; + const hasMembers = memberCount > 0; + + return { + workspaceCount, + memberCount, + maxWorkspaces, + maxUsers, + withinWorkspaceLimit, + withinUserLimit, + isCommunity, + isStandard, + hasWorkspaces, + hasMembers, + }; + }
-

Upgrade Plan

-

- You're ready for the next level. Upgrade to access more and features. You can switch plans at - any time. -

+

{pageHeading}

+

{pageDescription}

@@ -310,12 +465,12 @@ > {#each planValues as { plan, planName, isCurrentPlan, planPrice, planUnit, buttonText, hasDiscount, discount }, index}
handleCardHover(index, true)} + class={`hover:border-surface-50 overflow-hidden rounded-[10px] border-2 transition-all duration-200 + ${isCurrentPlan ? 'border-neutral-50' : 'border-surface-500'} + ${!isPlanSelectable(plan) && !isCurrentPlan ? 'opacity-70' : 'hover:bg-surface-600'} + `} + style="transform: scale({hoveredCardIndex === index ? $cardScale : 1});" + on:mouseenter={() => isPlanSelectable(plan) && handleCardHover(index, true)} on:mouseleave={() => handleCardHover(index, false)} > @@ -366,9 +521,11 @@ {:else} @@ -456,6 +613,255 @@
+{#if downgradeFlow.downgradeModal} + (downgradeFlow.downgradeModal = false)}> + (downgradeFlow.downgradeModal = false)} + on:cancel={() => (downgradeFlow.downgradeModal = false)} + on:continue={() => { + downgradeFlow.downgradeModal = false; + const c = getDowngradeConstraints(); + + // Handle workspaces + if (c.withinWorkspaceLimit) { + downgradeData.selectedWorkspaces = hubWorkspaces || []; + downgradeData.members = filterOutOwners(hubUsers) || []; + } else { + downgradeFlow.chooseWorkspaceModal = true; + return; + } + + // Handle members + if (c.isCommunity) { + if (c.withinUserLimit) { + downgradeData.selectedMembers = filterOutOwners(hubUsers) || []; + downgradeFlow.reviewModal = true; + } else { + downgradeFlow.chooseMembersModal = true; + } + } else if (c.isStandard) { + downgradeData.selectedMembers = + c.workspaceCount > 0 ? [] : filterOutOwners(hubUsers) || []; + downgradeFlow.reviewModal = true; + } + }} + /> + +{/if} + +{#if downgradeFlow.chooseWorkspaceModal} + { + downgradeFlow.chooseWorkspaceModal = false; + }} + on:next={(e) => { + downgradeData.selectedWorkspaces = e.detail.selected; + downgradeData.unselectedWorkspaces = e.detail.unselected; + downgradeData.members = removeAdminFromMembersList(hubOwner, e.detail.members) || []; + downgradeFlow.chooseWorkspaceModal = false; + + const c = getDowngradeConstraints(); + + if (c.isCommunity) { + const workspaceMemberCount = downgradeData.members?.length || 0; + const withinUserLimit = workspaceMemberCount <= c.maxUsers; + + if (workspaceMemberCount === 0) { + downgradeData.selectedMembers = []; + downgradeFlow.reviewModal = true; + } else if (withinUserLimit) { + downgradeData.selectedMembers = downgradeData.members; + downgradeFlow.reviewModal = true; + } else { + downgradeFlow.chooseMembersModal = true; + } + } else if (c.isStandard) { + downgradeFlow.reviewModal = true; + } + }} + /> +{/if} + +{#if downgradeFlow.chooseMembersModal} + u.role === 'owner')} + users={downgradeData.members.length > 0 + ? filterOutOwners(downgradeData.members) + : filterOutOwners(hubUsers)} + on:close={() => { + downgradeFlow.chooseMembersModal = false; + const c = getDowngradeConstraints(); + if (c.workspaceCount > c.maxWorkspaces) downgradeFlow.chooseWorkspaceModal = true; + }} + on:next={(e) => { + downgradeData.selectedMembers = e.detail.selected; + downgradeFlow.chooseMembersModal = false; + downgradeFlow.reviewModal = true; + }} + /> +{/if} + +{#if downgradeFlow.reviewModal} + (downgradeFlow.reviewModal = false)}> + { + downgradeFlow.reviewModal = false; + const c = getDowngradeConstraints(); + + if (c.isStandard) { + if (c.workspaceCount > c.maxWorkspaces) downgradeFlow.chooseWorkspaceModal = true; + else downgradeFlow.downgradeModal = true; + } else if (c.isCommunity) { + if (downgradeData.members?.length > c.maxUsers) downgradeFlow.chooseMembersModal = true; + else if (c.workspaceCount > c.maxWorkspaces) downgradeFlow.chooseWorkspaceModal = true; + else downgradeFlow.downgradeModal = true; + } + }} + on:confirm={async (e) => { + downgradeData.feedback = e.detail.feedback; + let updatePlanName; + if(selectedPlan?.toLowerCase() === 'standard'){ + updatePlanName = "Standard" + } + const metadata = { + hubId, + userCount: userCount.toString(), + planName: updatePlanName, + trial_period_days: 0, + trial_end_date: null, + }; + const downgradePayload = { + teamId: hubId, + workspaces: downgradeData.selectedWorkspaces.map((ws) => ({ + id: ws._id || ws.id, + name: ws.name, + })), + users: filterOutOwners(downgradeData.selectedMembers).map((user) => ({ + id: user._id || user.id, + email: user.email, + })), + }; + downgradeFlow.reviewModal = false; + downgradeFlow.showProcessingModal = true; + try { + if (selectedPlan?.toLowerCase() === 'community') { + await billingService.cancelSubscription({ + subscriptionId, + ...downgradePayload, + }); + downgradeFlow.showProcessingModal = false; + downgradeFlow.showDowngradeConfirmModal = true; + } else if (selectedPlan?.toLowerCase() === 'standard') { + await billingService.updateSubscription({ + priceId, + subscriptionId, + metadata, + isDowngrade: true, + ...downgradePayload, + }); + downgradeFlow.showProcessingModal = false; + downgradeFlow.showDowngradeConfirmModal = true; + } + } catch (error) { + downgradeFlow.showProcessingModal = false; + downgradeFlow.showDowngradeFailedModal = true; + } + }} + /> + +{/if} + +{#if downgradeFlow.showProcessingModal} + (downgradeFlow.showProcessingModal = false)}> + (downgradeFlow.showProcessingModal = false)} + /> + +{/if} +{#if downgradeFlow.showDowngradeConfirmModal} + { + downgradeFlow.showDowngradeConfirmModal = false; + navigate(`/billing/billingOverview/${hubId}`); + }} + > + { + downgradeFlow.showDowngradeConfirmModal = false; + navigate(`/billing/billingOverview/${hubId}`); + }} + on:goToDashboard={() => { + downgradeFlow.showDowngradeConfirmModal = false; + navigate(`/billing/billingOverview/${hubId}`); + }} + /> + +{/if} + +{#if downgradeFlow.showDowngradeFailedModal} + { + downgradeFlow.showDowngradeFailedModal = false; + navigate(`/billing/billingOverview/${hubId}`); + }} + > + { + downgradeFlow.showDowngradeFailedModal = false; + navigate(`/billing/billingOverview/${hubId}`); + }} + on:contactSupport={() => { + window.open('mailto:contactus@sparrowapp.dev', '_blank'); + }} + on:tryAgain={() => { + downgradeFlow.showDowngradeFailedModal = false; + downgradeFlow.reviewModal = true; + }} + /> + +{/if} +