fix(saas): show team invitation banner in SaaS web build (#6612)

## 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 `<UpgradeBanner />`.
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.
This commit is contained in:
ConnorYoh
2026-06-11 18:01:58 +01:00
committed by GitHub
parent 34ead60194
commit 5fa5e12c64
4 changed files with 138 additions and 141 deletions
@@ -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 = [
@@ -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 = (
<Text
component="span"
size="sm"
fw={500}
style={{ color: "rgba(255, 255, 255, 0.95)" }}
>
<strong>{invitation.inviterEmail}</strong>{" "}
{t("team.invitationBanner.message", "has invited you to join")}{" "}
<strong>{invitation.teamName}</strong>
</Text>
);
const actionButtons = (
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
color="gray"
size="xs"
onClick={handleAccept}
loading={processing}
leftSection={
<LocalIcon
icon="check"
width="0.9rem"
height="0.9rem"
style={{ color: "var(--mantine-color-dark-9)" }}
/>
}
styles={{
label: {
color: "var(--mantine-color-dark-9)",
},
}}
>
{t("team.invitationBanner.acceptButton", "Accept")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={handleReject}
loading={processing}
style={{ color: "rgba(255, 255, 255, 0.7)" }}
>
{t("team.invitationBanner.rejectButton", "Decline")}
</Button>
</Group>
);
return (
<InfoBanner
icon="mail"
message={
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{ width: "100%" }}
>
{message}
{actionButtons}
</Group>
}
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)"
/>
);
}
@@ -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 = (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling PDF"
style={{
width: "1.5rem",
height: "1.5rem",
objectFit: "contain",
}}
/>
);
setBanner(
<InfoBanner
icon={logoIcon}
tone="info"
message={message}
buttonText={t("plan.trial.subscribe", "Subscribe to Pro")}
buttonIcon="credit-card-rounded"
onButtonClick={handleOpenCheckout}
onDismiss={handleDismiss}
dismissible={true}
show={true}
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)"
buttonColor="gray"
buttonVariant="white"
buttonTextColor="var(--mantine-color-dark-9)"
closeIconColor="rgba(255, 255, 255, 0.7)"
/>,
);
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 && (
<Suspense fallback={null}>
<StripeCheckout
opened={checkoutOpen}
onClose={() => setCheckoutOpen(false)}
purchaseType="subscription"
planId="pro"
creditsPack={null}
planName="Pro"
onSuccess={handleCheckoutSuccess}
onError={(error) => console.error("Checkout error:", error)}
isTrialConversion={true}
/>
</Suspense>
)}
</>
);
}
+2 -2
View File
@@ -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 (
<>
<GuestUserBanner />
<TrialStatusBanner />
<TeamInvitationBanner />
<HomePage />
</>
);