fix(frontend): replace post-dismissal blur in PaidGate / AdmiralGate with click-to-restore pill (#874)

* fix(frontend): replace post-dismissal blur with click-to-restore pill

PaidGate and AdmiralGate fell through to a "blurred children + small
pill" render when a user clicked Dismiss on the full-page upsell, for
the next 24h. The blurred-children path rendered the gated subtree,
so any lazy chunk behind the gate (FleetView, AuditLogView, etc.)
fetched on click during the dismissal window even though the user
saw only an obscured preview. This was the last lazy-chunk leakage
path remaining after the recent splitting work; CapabilityGate's
short-circuit refactor closed the others.

Split the dismissed branch from the compact branch. Compact mode
(used for inline list-item locks like a single SSO provider card) is
unchanged: its blur is intentional UX and the IP exposure is minor
because the children are tiny inline UI. Dismissed mode now renders
only a small pill in an empty 200px-tall area, with no children
mounted, so the lazy chunks behind the gate never fetch during the
dismissal window.

The pill is a button: clicking it removes the dismissal flag from
localStorage and re-renders the full upsell card. Users who dismissed
accidentally or want to revisit pricing have a way back without
clearing site data manually.

The dismissal flow itself is preserved: users who want to mute the
upsell pressure for 24h still can, they just get the static pill
instead of a blurred preview during that window.

* test(e2e): narrow upgrade-pill locator to file-upload button

The dismissed PaidGate branch introduced in this PR now renders a
<button> (was a <div>), which matched the same /upgrade to unlock/i
regex as the FileUploadDropzone button. Narrowing to
/upgrade to unlock upload/i targets only the file-upload pill and
resolves the strict-mode locator ambiguity.
This commit is contained in:
Anso
2026-05-02 03:06:33 -04:00
committed by GitHub
parent 6b74767388
commit 6fa0272e79
3 changed files with 58 additions and 3 deletions
+1 -1
View File
@@ -198,7 +198,7 @@ test.describe('File explorer - community (read-only)', () => {
test('upgrade pill is visible in the left pane', async ({ page }) => {
await expect(
page.getByRole('button', { name: /upgrade to unlock/i })
page.getByRole('button', { name: /upgrade to unlock upload/i })
).toBeVisible({ timeout: 5_000 });
});
+29 -1
View File
@@ -25,7 +25,11 @@ export function AdmiralGate({ children, featureName = 'This feature', compact =
if (isPaid && license?.variant === 'admiral') return <>{children}</>;
if (compact || dismissed) {
// Inline lock for list items (single SSO provider card, etc.). Renders the
// children blurred behind a small pill so the surrounding list keeps its
// shape; the IP exposure is minor because the children are tiny inline UI,
// and the visual continuity is intentional. Dismissal does not apply here.
if (compact) {
return (
<div className="relative">
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
@@ -41,6 +45,30 @@ export function AdmiralGate({ children, featureName = 'This feature', compact =
);
}
// Post-dismissal placeholder for full-page consumers. Renders only the
// pill so the lazy-loaded children behind an Admiral view never mount
// during the dismissal window. Clicking the pill clears the dismissal
// flag and restores the full upsell card so users who dismissed
// accidentally or want to revisit pricing have a way back without
// clearing localStorage.
if (dismissed) {
return (
<div className="flex items-start justify-center pt-8 min-h-[200px]">
<button
type="button"
onClick={() => {
localStorage.removeItem(DISMISS_KEY);
setDismissed(false);
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs hover:bg-muted hover:text-foreground transition-colors"
>
<ShipWheel className="w-3 h-3" />
Upgrade to Admiral to unlock
</button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-full gap-6 p-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted/50 border border-border">
+28 -1
View File
@@ -25,7 +25,11 @@ export function PaidGate({ children, featureName = 'This feature', compact = fal
if (isPaid) return <>{children}</>;
if (compact || dismissed) {
// Inline lock for list items (single SSO provider card, etc.). Renders the
// children blurred behind a small pill so the surrounding list keeps its
// shape; the IP exposure is minor because the children are tiny inline UI,
// and the visual continuity is intentional. Dismissal does not apply here.
if (compact) {
return (
<div className="relative">
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
@@ -41,6 +45,29 @@ export function PaidGate({ children, featureName = 'This feature', compact = fal
);
}
// Post-dismissal placeholder for full-page consumers. Renders only the
// pill so the lazy-loaded children behind a paid view never mount during
// the dismissal window. Clicking the pill clears the dismissal flag and
// restores the full upsell card so users who dismissed accidentally or
// want to revisit pricing have a way back without clearing localStorage.
if (dismissed) {
return (
<div className="flex items-start justify-center pt-8 min-h-[200px]">
<button
type="button"
onClick={() => {
localStorage.removeItem(DISMISS_KEY);
setDismissed(false);
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs hover:bg-muted hover:text-foreground transition-colors"
>
<Compass className="w-3 h-3" />
Upgrade to unlock {featureName}
</button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-full gap-6 p-8">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted/50 border border-border">