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')}
+>
+
+
+ {#if filteredUsers.length > 0}
+ {#each filteredUsers as user}
+
toggleMember(user.id)}
+ >
+
+
+ toggleMember(user.id)}
+ disabled={!selected.has(user.id) && selected.size >= maxSelectable}
+ class="peer h-4 w-4 cursor-pointer appearance-none rounded-sm border border-[#2A2F3A] bg-[#1E222C] checked:border-[#2B74FF] checked:bg-[#2B74FF] focus:outline-none"
+ />
+
+
+
+
+
+
+ {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')}
+>
+
+
+
+
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}
+
+
+
+ Downgrade to {selectedPlan === 'community' ? 'Community Edition' : `${selectedPlan} Plan`}
+
+
+ ✕
+
+
+
+
+
+
+
+
+
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}:
+
+
+
+ {#each getDowngradeLimitations(currentPlan, selectedPlan) as limitation}
+ {limitation}
+ {/each}
+
+
+
+ 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.
+
+
+
+
+
\ 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}
+
- Go to Dashboard
+
+ {buttonText}
+
-
+
\ 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}