mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user