From 5fa5e12c648c239d25a852e75db88d058a8843e7 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:01:58 +0100 Subject: [PATCH] fix(saas): show team invitation banner in SaaS web build (#6612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a user is invited to a team, the SaaS web app shows **no invitation banner** — even though the pending invite is returned by `/api/v1/team/invitations/pending` on refresh. ## Root causes 1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in `desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS banner stack rendered only ``. 2. **Single banner slot.** `BannerContext` holds one node; `setBanner` replaces it. `TrialStatusBanner` called `setBanner(null)` when there was no active trial (and re-fired once `trialStatus` resolved async), wiping any other banner. 3. **Shadowing was too fragile.** A first attempt shadowed the proprietary `UpgradeBannerInitializer` from the saas layer, but `vite-tsconfig-paths` resolves the `@app` specifier once at dev-server start — a newly-added shadow of an already-resolved module isn't picked up on a browser refresh, only a full restart. So the proprietary initializer kept running and no invite banner appeared (while the SaaS team context still fetched + populated the invite, which is why the pending call was visible). ## Fix - Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from desktop, minus the desktop `connectionMode` gate and explicit billing refresh (SaaS `acceptInvitation` already refreshes credits + session). - Render it **inline in `saas/routes/Landing.tsx`** next to `GuestUserBanner` — a new import specifier in an existing file (HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the proven `GuestUserBanner` pattern. No dependency on the single banner slot. - **Remove `TrialStatusBanner`** (trials are being retired) so it can't clobber banners. Also drops the stale mention from the stripe-lazy-load test comment. ## Verification - `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files; total unchanged from baseline (37 pre-existing, unrelated). - Manual: pull + verify the Accept/Decline banner appears for an account with a pending invite. --- .../tests/stubbed/stripe-lazy-load.spec.ts | 6 +- .../shared/TeamInvitationBanner.tsx | 133 +++++++++++++++++ .../components/shared/TrialStatusBanner.tsx | 136 ------------------ frontend/editor/src/saas/routes/Landing.tsx | 4 +- 4 files changed, 138 insertions(+), 141 deletions(-) create mode 100644 frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx delete mode 100644 frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx diff --git a/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts index 561f0d1bc..d74382aee 100644 --- a/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts @@ -4,9 +4,9 @@ import { test, expect } from "@app/tests/helpers/stub-test-base"; * Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js, * and the js.stripe.com remote script) is NOT fetched on cold page loads — * only when the checkout modal actually mounts. The proprietary - * CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings - * page all gate the modal behind React.lazy + a conditional render so the - * Stripe chunk lives in its own async bundle. + * CheckoutProvider and the SaaS Plan settings page gate the modal behind + * React.lazy + a conditional render so the Stripe chunk lives in its own + * async bundle. */ const STRIPE_URL_FRAGMENTS = [ diff --git a/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx new file mode 100644 index 000000000..aa07d823d --- /dev/null +++ b/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { Button, Group, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; + +/** + * SaaS-web team invitation banner. Shown at the top of the app when the + * signed-in user has a pending invitation to join a team. + * + * Ported from the desktop banner, with two differences: there is no + * {@code connectionMode} gate (web is always SaaS), and there is no explicit + * billing refresh — {@link useSaaSTeam.acceptInvitation} already refreshes + * credits and the session after the team membership changes. + */ +export function TeamInvitationBanner() { + const { t } = useTranslation(); + const { receivedInvitations, acceptInvitation, rejectInvitation } = + useSaaSTeam(); + + const [processing, setProcessing] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const invitation = receivedInvitations[0]; // Show first invitation + + const handleAccept = async () => { + if (!invitation) return; + setProcessing(true); + try { + await acceptInvitation(invitation.invitationToken); + setDismissed(true); + } catch (error) { + console.error( + "[TeamInvitationBanner] Failed to accept invitation:", + error, + ); + } finally { + setProcessing(false); + } + }; + + const handleReject = async () => { + if (!invitation) return; + setProcessing(true); + try { + await rejectInvitation(invitation.invitationToken); + setDismissed(true); + } catch (error) { + console.error( + "[TeamInvitationBanner] Failed to reject invitation:", + error, + ); + } finally { + setProcessing(false); + } + }; + + const shouldShow = !dismissed && receivedInvitations.length > 0; + if (!shouldShow) return null; + + const message = ( + + {invitation.inviterEmail}{" "} + {t("team.invitationBanner.message", "has invited you to join")}{" "} + {invitation.teamName} + + ); + + const actionButtons = ( + + + + + ); + + return ( + + {message} + {actionButtons} + + } + show={shouldShow} + dismissible={false} + background="var(--mantine-color-dark-7)" + borderColor="var(--mantine-color-dark-5)" + textColor="rgba(255, 255, 255, 0.95)" + iconColor="rgba(255, 255, 255, 0.95)" + /> + ); +} diff --git a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx b/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx deleted file mode 100644 index 7efec40eb..000000000 --- a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { lazy, Suspense, useEffect, useState, useCallback } from "react"; -import { useBanner } from "@app/contexts/BannerContext"; -import { useAuth } from "@app/auth/UseSession"; -import { useTranslation } from "react-i18next"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; -import { BASE_PATH } from "@app/constants/app"; - -const StripeCheckout = lazy( - () => import("@app/components/shared/StripeCheckoutSaas"), -); - -const SESSION_STORAGE_KEY = "trialBannerDismissed"; - -export function TrialStatusBanner() { - const { setBanner } = useBanner(); - const { t } = useTranslation(); - const { trialStatus } = useAuth(); - const [dismissed, setDismissed] = useState(() => { - return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true"; - }); - const [checkoutOpen, setCheckoutOpen] = useState(false); - - // Only show banner during ACTIVE trial (not after expiration - modal handles that) - // Don't show if payment method already added (user has scheduled subscription) - const shouldShowBanner = - trialStatus && - trialStatus.isTrialing && // Only show during active trial - trialStatus.daysRemaining > 0 && // Trial hasn't expired yet - !trialStatus.hasPaymentMethod && - !trialStatus.hasScheduledSub && - !dismissed; - - if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) { - console.log("Subscription scheduled - hiding trial banner"); - } - - const handleOpenCheckout = useCallback(() => { - setCheckoutOpen(true); - }, []); - - const handleDismiss = useCallback(() => { - setDismissed(true); - sessionStorage.setItem(SESSION_STORAGE_KEY, "true"); - }, []); - - useEffect(() => { - if (!shouldShowBanner) { - setBanner(null); - return; - } - - const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString( - "en-GB", - { - month: "short", - day: "numeric", - }, - ); - - const message = t( - "plan.trial.message", - `Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? "s" : ""} (${trialEndDate}). Subscribe to continue Pro access.`, - { days: trialStatus.daysRemaining, date: trialEndDate }, - ); - - const logoIcon = ( - Stirling PDF - ); - - setBanner( - , - ); - - return () => { - setBanner(null); - }; - }, [ - shouldShowBanner, - trialStatus, - setBanner, - t, - handleOpenCheckout, - handleDismiss, - ]); - - const handleCheckoutSuccess = () => { - // Refresh to hide banner and show updated plan - window.location.reload(); - }; - - return ( - <> - {trialStatus && checkoutOpen && ( - - setCheckoutOpen(false)} - purchaseType="subscription" - planId="pro" - creditsPack={null} - planName="Pro" - onSuccess={handleCheckoutSuccess} - onError={(error) => console.error("Checkout error:", error)} - isTrialConversion={true} - /> - - )} - - ); -} diff --git a/frontend/editor/src/saas/routes/Landing.tsx b/frontend/editor/src/saas/routes/Landing.tsx index f82df010b..a3e65d1e8 100644 --- a/frontend/editor/src/saas/routes/Landing.tsx +++ b/frontend/editor/src/saas/routes/Landing.tsx @@ -6,7 +6,7 @@ import { isToolRoute } from "@app/utils/pathUtils"; import HomePage from "@app/pages/HomePage"; import Login from "@app/routes/Login"; import GuestUserBanner from "@app/components/auth/GuestUserBanner"; -import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner"; +import { TeamInvitationBanner } from "@app/components/shared/TeamInvitationBanner"; export default function Landing() { const { session, loading } = useAuth(); @@ -71,7 +71,7 @@ export default function Landing() { return ( <> - + );