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 = (
+
+
+ }
+ styles={{
+ label: {
+ color: "var(--mantine-color-dark-9)",
+ },
+ }}
+ >
+ {t("team.invitationBanner.acceptButton", "Accept")}
+
+
+
+ );
+
+ 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 = (
-
- );
-
- 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 (
<>
-
+
>
);