feat(ui): hide paid features from community-tier dashboard (#891)

* feat(ui): hide paid features from community-tier dashboard

Community installs render only the features they can use. Tier-locked
sections, lock badges, upsell cards, and "Upgrade" buttons no longer
appear anywhere except the License page in Settings, which is the
single discoverable upgrade path.

Concretely:

- PaidGate and AdmiralGate now render null for non-qualifying tiers
  instead of upsell cards.
- SectionGate (settings) hides tier-locked sections entirely.
- Settings sidebar and command palette filter out items the operator
  cannot reach.
- Configuration Status widget on the dashboard drops the Automation
  section for community and hides any locked rows in remaining
  sections.
- Fleet > Status node cards drop locked summary rows.
- Stack action menu, sidebar bulk bar, file upload / download, scan
  comparison, network topology toggle, node label picker all hide
  for community instead of showing disabled affordances or "Upgrade"
  literal text.
- Removes tierUpsell, TierLockChip, and useDismissalState (no longer
  referenced).

Backend tier guards remain authoritative; this changes UI discovery
only.

* test(e2e): assert upload control is absent in community tier

The community-clean-ui change removes the "Upgrade to unlock upload"
pill from the file explorer. Update the matching e2e assertion to
verify the upload control is not rendered, instead of waiting for a
pill that no longer exists.
This commit is contained in:
Anso
2026-05-03 01:17:02 -04:00
committed by GitHub
parent b9ada7f50b
commit 1f8ce773ff
34 changed files with 196 additions and 806 deletions
+2 -4
View File
@@ -196,10 +196,8 @@ test.describe('File explorer - community (read-only)', () => {
await context.unroute('/api/license');
});
test('upgrade pill is visible in the left pane', async ({ page }) => {
await expect(
page.getByRole('button', { name: /upgrade to unlock upload/i })
).toBeVisible({ timeout: 5_000 });
test('upload control is absent in community tier', async ({ page }) => {
await expect(page.getByLabel('Upload file')).toHaveCount(0, { timeout: 1_000 });
});
test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => {
+9 -49
View File
@@ -1,55 +1,15 @@
import { ShipWheel } from 'lucide-react';
import type { ReactNode } from 'react';
import { useLicense } from '@/context/LicenseContext';
import { useDismissalState } from '@/hooks/useDismissalState';
import {
CompactBlurredLock,
DismissedPill,
FullUpsellCard,
type TierGateProps,
} from './tierUpsell';
const DISMISS_KEY = 'sencho-admiral-upgrade-prompt-dismissed';
/**
* Gate for Admiral-tier-only features. Mirrors PaidGate's state machine
* but with a stricter license predicate (requires variant === 'admiral')
* and Admiral-themed icon/copy.
* Thin wrapper that renders its children only for licensees on the
* Admiral plan. All other tiers (Community, Skipper) see nothing in
* this slot. Backend tier guards (`requireAdmiral`) remain the
* authoritative enforcement; this component only controls UI
* visibility.
*/
export function AdmiralGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
export function AdmiralGate({ children }: { children: ReactNode }) {
const { isPaid, license } = useLicense();
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
if (isPaid && license?.variant === 'admiral') return <>{children}</>;
const pillText = 'Upgrade to Admiral to unlock';
if (compact) {
return (
<CompactBlurredLock icon={ShipWheel} pillText={pillText}>
{children}
</CompactBlurredLock>
);
}
if (dismissed) {
return <DismissedPill icon={ShipWheel} pillText={pillText} onClick={restore} />;
}
return (
<FullUpsellCard
icon={ShipWheel}
title={`${featureName} requires Sencho Admiral`}
body={
<>
Unlock team features like LDAP / Active Directory, audit logging, API tokens, and unlimited user accounts with a Sencho Admiral license.
For enterprise pricing or questions, contact{' '}
<a href="mailto:licensing@sencho.io" className="text-brand hover:underline">licensing@sencho.io</a>.
</>
}
ctaIcon={ShipWheel}
ctaLabel="Get Admiral"
ctaHref="https://sencho.io/pricing"
onDismiss={dismiss}
/>
);
const isAdmiral = isPaid && license?.variant === 'admiral';
return isAdmiral ? <>{children}</> : null;
}
+1 -1
View File
@@ -141,7 +141,7 @@ export function ApiTokensSection() {
};
return (
<AdmiralGate featureName="API Tokens">
<AdmiralGate>
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<div className="space-y-6">
<div className="flex justify-end">
@@ -642,7 +642,7 @@ function AutoUpdateReadinessContent() {
export default function AutoUpdateReadinessView() {
return (
<PaidGate featureName="Auto-Update Readiness">
<PaidGate>
<AutoUpdateReadinessContent />
</PaidGate>
);
+1 -1
View File
@@ -2522,7 +2522,7 @@ export default function EditorLayout() {
) : activeView === 'resources' ? (
<ResourcesView />
) : activeView === 'host-console' ? (
<AdmiralGate featureName="Host Console">
<AdmiralGate>
<CapabilityGate capability="host-console" featureName="Host Console">
<LazyBoundary>
<Suspense fallback={<ViewSkeleton />}>
+8 -29
View File
@@ -27,7 +27,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
import { springs } from '@/lib/motion';
import { apiFetch, fetchForNode } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from './PaidGate';
import { AdmiralGate } from './AdmiralGate';
import FleetSnapshots from './FleetSnapshots';
import { FleetConfiguration } from './fleet/FleetConfiguration';
@@ -1354,24 +1353,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</p>
)}
{/* Free tier: paid gate for advanced features */}
{!isPaid && nodes.length > 0 && (
<div className="mt-6">
<PaidGate featureName="Fleet Management">
{/* Preview of what paid tier unlocks */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
</div>
<div className="flex gap-3 mb-4">
<div className="h-9 rounded-md border border-card-border bg-card flex-1 max-w-sm" />
<div className="h-9 rounded-md border border-card-border bg-card w-[150px]" />
</div>
</PaidGate>
</div>
)}
</>
)}
</TabsContent>
@@ -1383,7 +1364,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)}
{isAdmiral && experimental && (
<TabsContent value="routing">
<AdmiralGate featureName="Sencho Mesh">
<AdmiralGate>
<RoutingTab />
</AdmiralGate>
</TabsContent>
@@ -1397,15 +1378,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{isPaid ? (
<DeploymentsTab />
) : (
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
/>
</PaidGate>
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
/>
)}
</TabsContent>
<TabsContent value="federation">
+1 -1
View File
@@ -562,7 +562,7 @@ export function NodeManager() {
{isPaid ? (
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
) : (
<span className="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Upgrade</span>
<span className="text-muted-foreground text-sm">—</span>
)}
</TableCell>
<TableCell>
+11 -58
View File
@@ -1,65 +1,18 @@
import { Compass } from 'lucide-react';
import type { ReactNode } from 'react';
import { useLicense } from '@/context/LicenseContext';
import { useDismissalState } from '@/hooks/useDismissalState';
import {
CompactBlurredLock,
DismissedPill,
FullUpsellCard,
type TierGateProps,
} from './tierUpsell';
const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed';
/**
* Gate for any paid-tier feature (Skipper or Admiral). Composes the
* shared tier-upsell primitives in `./tierUpsell` according to the
* current state:
* Thin wrapper that renders its children only for licensees on a paid
* plan (Skipper or Admiral). Community-tier users see nothing in this
* slot. Backend tier guards (`requirePaid`) remain the authoritative
* enforcement; this component only controls UI visibility.
*
* isPaid render children (unlocked).
* compact blurred children + small pill (inline list items).
* dismissed pill-only placeholder for 24h, click to restore.
* default full upsell card with View Plans CTA.
*
* The compact branch retains the blurred-children render because it is
* used for tiny inline UI where the visual continuity is intentional.
* The dismissed and default branches do not render children, so any
* lazy chunks behind the gate are never fetched on those paths.
* Use only when wrapping a discrete fragment that has no neighboring
* context for Community users. Where possible, prefer a parent-level
* `useLicense().isPaid` check that lifts the visibility decision out
* of the rendering tree entirely.
*/
export function PaidGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
export function PaidGate({ children }: { children: ReactNode }) {
const { isPaid } = useLicense();
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
if (isPaid) return <>{children}</>;
const pillText = `Upgrade to unlock ${featureName}`;
if (compact) {
return (
<CompactBlurredLock icon={Compass} pillText={pillText}>
{children}
</CompactBlurredLock>
);
}
if (dismissed) {
return <DismissedPill icon={Compass} pillText={pillText} onClick={restore} />;
}
return (
<FullUpsellCard
icon={Compass}
title={`${featureName} requires a paid license`}
body={
<>
Unlock features like fleet management, viewer accounts, one-click Google / GitHub / Okta SSO, and more with a Skipper or Admiral license.
For enterprise pricing or questions, contact{' '}
<a href="mailto:licensing@sencho.io" className="text-brand hover:underline">licensing@sencho.io</a>.
</>
}
ctaIcon={Compass}
ctaLabel="View Plans"
ctaHref="https://sencho.io/pricing"
onDismiss={dismiss}
/>
);
return isPaid ? <>{children}</> : null;
}
@@ -282,7 +282,7 @@ export function RegistriesSection() {
};
return (
<AdmiralGate featureName="Private Registry Management">
<AdmiralGate>
<CapabilityGate capability="registries" featureName="Private Registries">
<div className="space-y-6">
<div className="flex justify-end">
+23 -22
View File
@@ -984,27 +984,28 @@ export default function ResourcesView() {
/>
)}
<div className="flex items-center gap-2 pr-3">
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
{!isPaid && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Skipper</Badge>}
</button>
</div>
{isPaid && (
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
</button>
</div>
)}
{isAdmin && networkViewMode === 'list' && (
<Button
variant="outline"
@@ -1021,7 +1022,7 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<PaidGate featureName="Network Topology">
<PaidGate>
<CapabilityGate capability="network-topology" featureName="Network Topology">
<LazyBoundary>
<Suspense fallback={
+2 -2
View File
@@ -397,9 +397,9 @@ function ProviderCardWithGate(props: {
const card = <ProviderCard {...props} />;
if (props.providerId === 'oidc_custom') return card;
if (props.providerId === 'ldap') {
return <AdmiralGate compact featureName="LDAP / Active Directory">{card}</AdmiralGate>;
return <AdmiralGate>{card}</AdmiralGate>;
}
return <PaidGate compact featureName={`${props.label} SSO`}>{card}</PaidGate>;
return <PaidGate>{card}</PaidGate>;
}
export function SSOSection() {
+15 -17
View File
@@ -164,23 +164,21 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
<div className="flex items-center justify-between gap-3">
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-border"
onClick={compareSelected}
disabled={compareDisabled || !isPaid}
title={
!isPaid
? 'Scan comparison requires Skipper or Admiral'
: compareDisabled
? 'Select exactly two completed scans to compare'
: 'Compare the two selected scans'
}
>
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
Compare ({selected.length}/2)
</Button>
{isPaid && (
<Button
variant="outline"
size="sm"
className="border-border"
onClick={compareSelected}
disabled={compareDisabled}
title={compareDisabled
? 'Select exactly two completed scans to compare'
: 'Compare the two selected scans'}
>
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
Compare ({selected.length}/2)
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -312,7 +312,7 @@ export function StackAutoHealSheet({ stackName, open, onOpenChange }: StackAutoH
];
return (
<PaidGate featureName="Auto-Heal Policies">
<PaidGate>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-[440px] flex flex-col">
<SheetHeader>
@@ -211,7 +211,7 @@ describe('SecurityHistoryView', () => {
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('disables Compare button for community tier', async () => {
it('hides Compare button for community tier', async () => {
licenseState.isPaid = false;
mockedFetch.mockResolvedValue(
listResponse([
@@ -226,7 +226,6 @@ describe('SecurityHistoryView', () => {
await user.click(checkboxes[0]);
await user.click(checkboxes[1]);
const compareBtn = screen.getByRole('button', { name: /Compare \(2\/2\)/ });
expect(compareBtn).toBeDisabled();
expect(screen.queryByRole('button', { name: /Compare/ })).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
import { formatCount } from '@/lib/utils';
import { useLicense } from '@/context/LicenseContext';
import { useConfigurationStatus } from './useConfigurationStatus';
import type { SectionId } from '@/components/settings/types';
@@ -8,19 +9,7 @@ interface ConfigurationStatusProps {
onOpenSection?: (section: SectionId) => void;
}
function StatusBadge({ value, locked, requiredTier }: {
value: string;
locked?: boolean;
requiredTier?: string;
}) {
if (locked && requiredTier) {
const label = requiredTier === 'admiral' ? 'Admiral' : 'Skipper';
return (
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
{label}
</span>
);
}
function StatusBadge({ value }: { value: string }) {
const lower = value.toLowerCase();
if (lower === 'on' || lower === 'enabled') {
return (
@@ -41,14 +30,12 @@ function StatusBadge({ value, locked, requiredTier }: {
);
}
function Row({ label, value, locked, requiredTier, onClick }: {
function Row({ label, value, onClick }: {
label: string;
value: string;
locked?: boolean;
requiredTier?: string;
onClick?: () => void;
}) {
const labelClass = `text-xs ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`;
const labelClass = 'text-xs text-stat-subtitle';
if (onClick) {
return (
@@ -60,7 +47,7 @@ function Row({ label, value, locked, requiredTier, onClick }: {
<div className="flex items-center justify-between py-1 px-1">
<span className={`${labelClass} group-hover:text-stat-value transition-colors`}>{label}</span>
<div className="flex items-center gap-1.5">
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
<StatusBadge value={value} />
<ChevronRight className="h-3 w-3 text-stat-icon opacity-0 group-hover:opacity-60 transition-opacity shrink-0" strokeWidth={1.5} />
</div>
</div>
@@ -71,7 +58,7 @@ function Row({ label, value, locked, requiredTier, onClick }: {
return (
<div className="flex items-center justify-between py-1 px-1 rounded-sm">
<span className={labelClass}>{label}</span>
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
<StatusBadge value={value} />
</div>
);
}
@@ -96,6 +83,7 @@ function SkeletonRow() {
export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps = {}) {
const { status, loading } = useConfigurationStatus();
const { isPaid } = useLicense();
const open = (section: SectionId) => () => onOpenSection?.(section);
@@ -160,39 +148,43 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
value={formatCount(notifications.alertRules, 'rule')}
onClick={open('notifications')}
/>
<Row
label="Notification routing"
value={notifications.routingRules.locked ? '' : formatCount(notifications.routingRules.enabledCount, 'route')}
locked={notifications.routingRules.locked}
requiredTier={notifications.routingRules.locked ? notifications.routingRules.requiredTier : undefined}
onClick={open('notification-routing')}
/>
{!notifications.routingRules.locked && (
<Row
label="Notification routing"
value={formatCount(notifications.routingRules.enabledCount, 'route')}
onClick={open('notification-routing')}
/>
)}
<SectionHeader icon={Zap} label="Automation" />
<Row
label="Auto-heal policies"
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
onClick={open('system')}
/>
<Row
label="Auto-update stacks"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
onClick={open('system')}
/>
<Row
label="Webhooks"
value={automation.webhooks.locked ? '' : formatCount(automation.webhooks.enabled, 'active')}
locked={automation.webhooks.locked}
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined}
onClick={open('webhooks')}
/>
<Row
label="Scheduled tasks"
value={automation.scheduledTasks.locked ? '' : formatCount(automation.scheduledTasks.enabled, 'active')}
locked={automation.scheduledTasks.locked}
requiredTier={automation.scheduledTasks.locked ? automation.scheduledTasks.requiredTier : undefined}
onClick={open('system')}
/>
{isPaid && (
<>
<SectionHeader icon={Zap} label="Automation" />
<Row
label="Auto-heal policies"
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
onClick={open('system')}
/>
<Row
label="Auto-update stacks"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
onClick={open('system')}
/>
{!automation.webhooks.locked && (
<Row
label="Webhooks"
value={formatCount(automation.webhooks.enabled, 'active')}
onClick={open('webhooks')}
/>
)}
{!automation.scheduledTasks.locked && (
<Row
label="Scheduled tasks"
value={formatCount(automation.scheduledTasks.enabled, 'active')}
onClick={open('system')}
/>
)}
</>
)}
<SectionHeader icon={Shield} label="Security" />
<Row
@@ -201,22 +193,22 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
onClick={open('account')}
/>
<Row label="SSO" value={ssoLabel} onClick={open('sso')} />
<Row
label="Vulnerability scanning"
value={security.scanPolicies.locked ? '' : formatCount(security.scanPolicies.enabled, 'policy')}
locked={security.scanPolicies.locked}
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined}
onClick={open('security')}
/>
{!security.scanPolicies.locked && (
<Row
label="Vulnerability scanning"
value={formatCount(security.scanPolicies.enabled, 'policy')}
onClick={open('security')}
/>
)}
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
<Row
label="Cloud Backup"
value={backup.locked ? '' : backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
locked={backup.locked}
requiredTier={backup.locked ? backup.requiredTier : undefined}
onClick={open('cloud-backup')}
/>
{!backup.locked && (
<Row
label="Cloud Backup"
value={backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
onClick={open('cloud-backup')}
/>
)}
<Row
label="Alert thresholds"
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
@@ -1,5 +1,5 @@
import { useRef } from 'react';
import { UploadCloud, Compass } from 'lucide-react';
import { UploadCloud } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { uploadStackFile } from '@/lib/stackFilesApi';
@@ -20,18 +20,7 @@ export function FileUploadDropzone({
const { isPaid } = useLicense();
const inputRef = useRef<HTMLInputElement>(null);
if (!isPaid) {
return (
<button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs cursor-pointer hover:border-brand/50 transition-colors"
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<Compass className="w-3 h-3" />
Upgrade to unlock upload, edit, and delete
</button>
);
}
if (!isPaid) return null;
const handleFile = async (file: File) => {
if (file.size > MAX_BYTES) {
+2 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, Suspense } from 'react';
import { Editor } from '@/lib/monacoLoader';
import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react';
import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
@@ -72,7 +72,7 @@ function SpecialFilePanel({
<p className="font-mono text-sm text-stat-title">{filename}</p>
<p className="text-xs text-stat-subtitle">{label} &middot; {formatBytes(size)}</p>
</div>
{canDownload ? (
{canDownload && (
<Button
variant="outline"
size="sm"
@@ -86,13 +86,6 @@ function SpecialFilePanel({
)}
Download
</Button>
) : (
<div title="Upgrade to download files">
<Button variant="outline" size="sm" disabled>
<Lock className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Download
</Button>
</div>
)}
</div>
);
@@ -160,15 +160,14 @@ describe('FileViewer', () => {
expect(downloadBtn).not.toBeDisabled();
});
it('shows disabled Download button for community tier', async () => {
it('hides Download button for community tier', async () => {
licenseState.isPaid = false;
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).toBeDisabled();
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
});
it('re-fetches when selectedPath changes', async () => {
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import {
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2,
} from 'lucide-react';
import { useLicense } from '@/context/LicenseContext';
import type { ConfigurationStatusPayload } from '@/components/dashboard';
interface FleetNodeConfiguration {
@@ -16,34 +17,21 @@ interface FleetNodeConfiguration {
configuration: ConfigurationStatusPayload | null;
}
function TierChip({ tier }: { tier: string }) {
const label = tier === 'admiral' ? 'Admiral' : 'Skipper';
return (
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] leading-3 font-mono tracking-[0.18em] uppercase text-warning/80">
{label}
</span>
);
}
function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: {
function SummaryRow({ icon: Icon, label, value }: {
icon: typeof Bell;
label: string;
value: string;
locked?: boolean;
requiredTier?: string;
}) {
return (
<div className="flex items-center gap-2 py-0.5">
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
<span className={`text-xs flex-1 ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`}>{label}</span>
{locked && requiredTier
? <TierChip tier={requiredTier} />
: <span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>}
<span className="text-xs flex-1 text-stat-subtitle">{label}</span>
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
</div>
);
}
function NodeCard({ node }: { node: FleetNodeConfiguration }) {
function NodeCard({ node, isPaid }: { node: FleetNodeConfiguration; isPaid: boolean }) {
const isRemote = node.type === 'remote';
if (!node.configuration) {
return (
@@ -90,39 +78,27 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) {
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
<SummaryRow icon={Bell} label="Alert rules"
value={formatCount(notifications.alertRules, 'rule')} />
<SummaryRow icon={Zap} label="Auto-heal"
value={automation.autoHeal.total === 0
? 'None'
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
<SummaryRow icon={Zap} label="Webhooks"
value={
automation.webhooks.locked
? ''
: formatCount(automation.webhooks.enabled, 'active')
}
locked={automation.webhooks.locked}
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined} />
{isPaid && (
<SummaryRow icon={Zap} label="Auto-heal"
value={automation.autoHeal.total === 0
? 'None'
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
)}
{!automation.webhooks.locked && (
<SummaryRow icon={Zap} label="Webhooks"
value={formatCount(automation.webhooks.enabled, 'active')} />
)}
{!isRemote && (
<SummaryRow icon={Shield} label="MFA"
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
)}
<SummaryRow icon={Shield} label="Scanning"
value={
security.scanPolicies.locked
? ''
: formatCount(security.scanPolicies.enabled, 'policy')
}
locked={security.scanPolicies.locked}
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined} />
{!isRemote && (
{!security.scanPolicies.locked && (
<SummaryRow icon={Shield} label="Scanning"
value={formatCount(security.scanPolicies.enabled, 'policy')} />
)}
{!isRemote && !backup.locked && (
<SummaryRow icon={HardDrive} label="Backup"
value={
backup.locked
? ''
: backup.provider === 'disabled' ? 'Disabled' : 'Enabled'
}
locked={backup.locked}
requiredTier={backup.locked ? backup.requiredTier : undefined} />
value={backup.provider === 'disabled' ? 'Disabled' : 'Enabled'} />
)}
<SummaryRow icon={HardDrive} label="Crash detect"
value={thresholds.globalCrash ? 'On' : 'Off'} />
@@ -133,6 +109,7 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) {
}
export function FleetConfiguration() {
const { isPaid } = useLicense();
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -193,7 +170,7 @@ export function FleetConfiguration() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
{nodes.map(node => <NodeCard key={node.id} node={node} />)}
{nodes.map(node => <NodeCard key={node.id} node={node} isPaid={isPaid} />)}
</div>
);
}
@@ -293,7 +293,7 @@ export function CloudBackupSection() {
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
return (
<AdmiralGate featureName="Cloud Backup">
<AdmiralGate>
<div className="space-y-6">
<div className={PANEL_CLASS}>
<Label className="text-sm">Storage Mode</Label>
@@ -142,7 +142,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
};
return (
<PaidGate featureName="Stack Labels">
<PaidGate>
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex justify-end">
@@ -308,7 +308,7 @@ export function NotificationRoutingSection() {
);
return (
<AdmiralGate featureName="Notification Routing">
<AdmiralGate>
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex justify-end">
@@ -1,29 +1,22 @@
import React from 'react';
import { Lock } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { getSettingsItem, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext } from './registry';
import type { SectionId } from './types';
import { LockCard } from '../ui/LockCard';
interface TierLockedCardProps {
tier: 'skipper' | 'admiral';
}
function TierLockedCard({ tier }: TierLockedCardProps) {
const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature';
return (
<LockCard icon={Lock} title={title} body="Upgrade to unlock more features." />
);
}
interface SectionGateProps {
sectionId: SectionId;
children: React.ReactNode;
}
/**
* Renders the section body only if the registry says it is visible AND
* the operator has the entitlement to use it. Tier-locked sections are
* hidden entirely from operators who do not qualify. Backend tier
* guards remain the authoritative enforcement.
*/
export function SectionGate({ sectionId, children }: SectionGateProps) {
const { isAdmin } = useAuth();
const { isPaid, license } = useLicense();
@@ -41,15 +34,8 @@ export function SectionGate({ sectionId, children }: SectionGateProps) {
const item = getSettingsItem(sectionId);
// SettingsPage routes invisible sections back to a visible default before this
// component renders, so reaching this branch means the registry shape changed
// mid-session. Render nothing rather than throw — SettingsPage's effect will
// resolve to a valid section on the next tick.
if (!item || !isItemVisible(item, visibility)) return null;
if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) {
return <TierLockedCard tier={item.tier} />;
}
if (isItemLocked(item, visibility)) return null;
return <>{children}</>;
}
@@ -279,7 +279,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
if (!isPaid) {
return (
<div className="space-y-6">
<PaidGate featureName="Scan Policies">
<PaidGate>
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
@@ -37,8 +37,6 @@ import LazyBoundary from '../LazyBoundary';
import { SectionGate } from './SectionGate';
import { SettingsSidebar } from './SettingsSidebar';
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
import { TierLockChip } from './TierLockChip';
import { cn } from '@/lib/utils';
// Paid-tier sections are loaded on demand. SectionGate short-circuits to a
// TierLockedCard for Community / wrong-variant operators before reaching the
@@ -116,9 +114,10 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
// node-scoped item on a remote, or admin-only item for a non-admin), fall back to
// the first visible item.
const safeSection: SectionId = useMemo(() => {
const reachable = (i: SettingsItemMeta) => isItemVisible(i, visibility) && !isItemLocked(i, visibility);
const direct = SETTINGS_ITEMS.find(i => i.id === currentSection);
if (direct && isItemVisible(direct, visibility)) return direct.id;
const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility));
if (direct && reachable(direct)) return direct.id;
const fallback = SETTINGS_ITEMS.find(reachable);
return fallback?.id ?? 'appearance';
}, [currentSection, visibility]);
useEffect(() => {
@@ -155,8 +154,13 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
const nodeName = activeNode?.name ?? 'local';
// Items reachable in this session: visible AND not tier-locked. Tier-locked
// items are hidden entirely from operators who do not qualify so the
// command palette and the sidebar never surface unreachable destinations.
const visibleItems = useMemo(
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
() => SETTINGS_ITEMS.filter(item =>
isItemVisible(item, visibility) && !isItemLocked(item, visibility),
),
[visibility],
);
@@ -282,7 +286,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
key={item.id}
item={item}
glyph={group.glyph}
visibility={visibility}
onSelect={() => {
setCommandOpen(false);
onSectionChange(item.id);
@@ -305,24 +308,20 @@ function scopeLabel(item: SettingsItemMeta): string {
function SettingsCommandItem({
item,
glyph,
visibility,
onSelect,
}: {
item: SettingsItemMeta;
glyph: string;
visibility: VisibilityContext;
onSelect: () => void;
}) {
const locked = isItemLocked(item, visibility);
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
return (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[10px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier && locked ? <TierLockChip tier={item.tier} /> : null}
</CommandItem>
);
}
@@ -6,7 +6,6 @@ import { useNodes } from '@/context/NodeContext';
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext, SettingsItemMeta } from './registry';
import type { SectionId } from './types';
import { TierLockChip } from './TierLockChip';
import { cn } from '@/lib/utils';
interface SettingsSidebarProps {
@@ -31,8 +30,12 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
isRemote,
};
function isVisible(item: SettingsItemMeta): boolean {
return isItemVisible(item, visibility);
// An item appears in the sidebar only if its registry visibility predicate
// passes AND the operator has the entitlement for it. Tier-locked items
// are hidden entirely from operators who do not qualify so the Community
// surface stays uncluttered. Backend tier guards remain authoritative.
function isReachable(item: SettingsItemMeta): boolean {
return isItemVisible(item, visibility) && !isItemLocked(item, visibility);
}
return (
@@ -54,15 +57,11 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
<nav className="pb-4">
{SETTINGS_GROUPS.map(group => {
const groupItems = SETTINGS_ITEMS.filter(
item => item.group === group.id && isVisible(item),
item => item.group === group.id && isReachable(item),
);
if (groupItems.length === 0) return null;
const unlockedCount = groupItems.filter(
item => !isItemLocked(item, visibility),
).length;
return (
<div key={group.id} className="mb-1 mt-3">
<div className="mb-1 flex items-center justify-between gap-2 px-2">
@@ -70,11 +69,10 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
{group.label}
</span>
<span className="font-mono text-[10px] leading-3 tabular-nums text-stat-subtitle/50">
{unlockedCount}/{groupItems.length}
{groupItems.length}
</span>
</div>
{groupItems.map(item => {
const locked = isItemLocked(item, visibility);
const isDirty = dirtyFlags?.[item.id] ?? false;
const isActive = item.id === currentSection;
@@ -89,7 +87,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
isActive
? 'text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
locked && 'opacity-60',
)}
>
{isActive && (
@@ -109,7 +106,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
{isDirty && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
)}
{item.tier && locked && <TierLockChip tier={item.tier} showIcon={false} />}
</button>
);
})}
@@ -1,24 +0,0 @@
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
export type TierLockTier = 'skipper' | 'admiral';
interface TierLockChipProps {
tier: TierLockTier;
showIcon?: boolean;
className?: string;
}
export function TierLockChip({ tier, showIcon = true, className }: TierLockChipProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/80',
className,
)}
>
{showIcon && <Lock className="h-2.5 w-2.5" strokeWidth={1.5} />}
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
</span>
);
}
@@ -259,7 +259,7 @@ export function UsersSection() {
};
return (
<PaidGate featureName="User management">
<PaidGate>
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
{!showForm && (
@@ -7,8 +7,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
@@ -154,20 +152,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
}
};
if (!isPaid) {
return (
<div className="space-y-6">
<PaidGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</PaidGate>
</div>
);
}
if (!isPaid) return null;
return (
<div className="flex flex-col gap-10">
@@ -39,5 +39,4 @@ export { SettingsSection } from './SettingsSection';
export { SettingsField, type SettingsFieldTone } from './SettingsField';
export { SettingsCallout, type SettingsCalloutTone } from './SettingsCallout';
export { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
export { TierLockChip } from './TierLockChip';
export { useMastheadStats } from './MastheadStatsContext';
@@ -1,7 +1,5 @@
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TierBadge } from '@/components/TierBadge';
import type { BulkAction } from '@/hooks/useBulkStackActions';
interface SidebarBulkBarProps {
@@ -29,24 +27,8 @@ export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: Sid
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('start')}>Start</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('stop')}>Stop</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('restart')}>Restart</Button>
{isPaid ? (
{isPaid && (
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('update')}>Update</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono gap-1 pointer-events-none opacity-60" disabled>
Update
<TierBadge tier="paid" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Bulk update requires a Skipper license</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
-147
View File
@@ -1,147 +0,0 @@
import { type ReactNode } from 'react';
import { type LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* Shared rendering parts used by `PaidGate` and `AdmiralGate`. The two
* gates only differ in their license predicate, dismissal-storage key,
* icon, and copy strings; everything else (the compact-blurred-lock
* JSX, the dismissed-pill JSX, the full upsell card layout) is
* identical. Dismissal-state logic lives in `useDismissalState` under
* `frontend/src/hooks/`.
*
* These primitives let each gate compose its state machine in ~25
* lines:
*
* if (isUnlocked) return children;
* if (compact) return <CompactBlurredLock icon pillText>{children}</CompactBlurredLock>;
* if (dismissed) return <DismissedPill icon pillText onClick={restore} />;
* return <FullUpsellCard icon title body ctaIcon ctaLabel ctaHref onDismiss={dismiss} />;
*
* A future third gate (Skipper-only, or some hypothetical Enterprise
* tier) can mix and match these without re-writing the same JSX.
*/
/**
* Public props shape for both `PaidGate` and `AdmiralGate`. Hoisted
* here so the `compact` doc string lives in exactly one place.
*/
export interface TierGateProps {
children: ReactNode;
featureName?: string;
/**
* Inline compact lock for list items (e.g. a single SSO provider
* card). Skips the full-page upsell and dismiss timer; always
* renders the blurred + pill style so the surrounding list keeps
* its shape.
*/
compact?: boolean;
}
/**
* Inline list-item lock. Renders children blurred behind a small pill
* so the surrounding list keeps its shape. Used for tiny inline UI
* like a single SSO provider card; the IP exposure of the blurred
* children is minor and the visual continuity is intentional.
* Dismissal does not apply here.
*/
export function CompactBlurredLock({
icon: Icon,
pillText,
children,
}: {
icon: LucideIcon;
pillText: string;
children: ReactNode;
}) {
return (
<div className="relative">
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
{children}
</div>
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Icon className="w-3 h-3" />
{pillText}
</div>
</div>
</div>
);
}
/**
* Post-dismissal placeholder. Renders only the pill so any lazy-loaded
* children behind a paid view never mount during the 24h dismissal
* window. The pill is a button: clicking it calls `onClick` (typically
* the gate's `restore` action) so the full upsell card returns and the
* user has a way back without clearing localStorage.
*/
export function DismissedPill({
icon: Icon,
pillText,
onClick,
}: {
icon: LucideIcon;
pillText: string;
onClick: () => void;
}) {
return (
<div className="flex items-start justify-center pt-8 min-h-[200px]">
<button
type="button"
onClick={onClick}
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"
>
<Icon className="w-3 h-3" />
{pillText}
</button>
</div>
);
}
/**
* Full-page upsell card. Centered icon chip + title + body (which can
* carry inline links) + Dismiss / CTA actions. The CTA opens the
* pricing page in a new tab with `noopener,noreferrer` to prevent the
* destination from accessing `window.opener` (reverse tabnabbing).
* Dismiss invokes `onDismiss`, which the gate uses to flip dismissal
* state.
*/
export function FullUpsellCard({
icon: Icon,
title,
body,
ctaIcon: CtaIcon,
ctaLabel,
ctaHref,
onDismiss,
}: {
icon: LucideIcon;
title: string;
body: ReactNode;
ctaIcon: LucideIcon;
ctaLabel: string;
ctaHref: string;
onDismiss: () => void;
}) {
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">
<Icon className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-sm text-muted-foreground">{body}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
<Button size="sm" onClick={() => window.open(ctaHref, '_blank', 'noopener,noreferrer')}>
<CtaIcon className="w-4 h-4 mr-2" />
{ctaLabel}
</Button>
</div>
</div>
);
}
@@ -1,160 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useDismissalState } from '../useDismissalState';
const KEY = 'test-dismiss-key';
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000;
describe('useDismissalState', () => {
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns dismissed=false when no timestamp is stored', () => {
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(false);
});
it('returns dismissed=true when a recent timestamp is stored', () => {
localStorage.setItem(KEY, String(Date.now() - 1000));
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(true);
});
it('returns dismissed=false when the stored timestamp is past the 24h window', () => {
localStorage.setItem(KEY, String(Date.now() - DISMISS_DURATION_MS - 1));
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(false);
});
it('returns dismissed=false when the stored value is non-numeric garbage', () => {
// Future-proofing: a stale extension or hand-edit could leave a non-
// numeric value at the key; the hook must not crash and must default
// to "show the upsell."
localStorage.setItem(KEY, 'not-a-number');
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(false);
});
it('dismiss() flips dismissed to true and writes a parseable timestamp', () => {
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(false);
act(() => result.current.dismiss());
expect(result.current.dismissed).toBe(true);
const stored = localStorage.getItem(KEY);
expect(stored).not.toBeNull();
expect(Number.isFinite(Number.parseInt(stored as string, 10))).toBe(true);
});
it('restore() flips dismissed to false and removes the storage entry', () => {
localStorage.setItem(KEY, String(Date.now()));
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(true);
act(() => result.current.restore());
expect(result.current.dismissed).toBe(false);
expect(localStorage.getItem(KEY)).toBeNull();
});
it('respects the 24h boundary: a fresh mount past expiry sees dismissed=false', () => {
// Dismiss now, then advance time past the window, then mount fresh.
// The original hook instance keeps its `dismissed: true` state in
// React (lazy initializer runs once), so the boundary is observable
// only on a fresh mount, not via re-render of the same hook.
const { result, unmount } = renderHook(() => useDismissalState(KEY));
act(() => result.current.dismiss());
expect(result.current.dismissed).toBe(true);
unmount();
vi.advanceTimersByTime(DISMISS_DURATION_MS + 1000);
const fresh = renderHook(() => useDismissalState(KEY));
expect(fresh.result.current.dismissed).toBe(false);
});
it('different keys are independent', () => {
const { result: a } = renderHook(() => useDismissalState('key-a'));
const { result: b } = renderHook(() => useDismissalState('key-b'));
act(() => a.current.dismiss());
expect(a.current.dismissed).toBe(true);
expect(b.current.dismissed).toBe(false);
});
it('syncs to dismissed=true when another tab fires a storage event with a recent timestamp', () => {
// Browsers fire `storage` events only in OTHER tabs than the one
// that wrote the change, so this test simulates "tab B receives
// a dismiss from tab A" by dispatching a synthetic StorageEvent.
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(false);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: KEY,
newValue: String(Date.now()),
}),
);
});
expect(result.current.dismissed).toBe(true);
});
it('syncs to dismissed=false when another tab fires a storage event with a null newValue', () => {
// null newValue is what the spec emits when localStorage.removeItem
// is called in another tab.
localStorage.setItem(KEY, String(Date.now()));
const { result } = renderHook(() => useDismissalState(KEY));
expect(result.current.dismissed).toBe(true);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: KEY,
newValue: null,
}),
);
});
expect(result.current.dismissed).toBe(false);
});
it('ignores storage events for unrelated keys', () => {
const { result } = renderHook(() => useDismissalState(KEY));
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'unrelated-key',
newValue: String(Date.now()),
}),
);
});
expect(result.current.dismissed).toBe(false);
});
it('treats a storage event carrying a stale timestamp as not-dismissed', () => {
const { result } = renderHook(() => useDismissalState(KEY));
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: KEY,
newValue: String(Date.now() - DISMISS_DURATION_MS - 1),
}),
);
});
expect(result.current.dismissed).toBe(false);
});
});
-64
View File
@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000;
/**
* Tracks the localStorage-backed dismissal flag for an upsell gate.
* `dismiss()` writes the current timestamp and flips state to dismissed;
* `restore()` removes the timestamp so the next render falls through to
* the full upsell card. The dismissal window is 24h; after expiry,
* `localStorage.getItem(key)` still returns a stale timestamp but the
* lazy initializer treats it as expired and returns `dismissed: false`,
* so the gate shows the full upsell again on next mount.
*
* Cross-tab sync: a `storage` event listener on `window` propagates
* dismiss / restore actions from other tabs. The browser fires
* `storage` events only on tabs OTHER than the one that wrote the
* change, so this listener handles tab B updates after tab A
* dismisses or restores; same-tab updates flow through `setDismissed`
* directly. Stale timestamps that arrive past the 24h window are
* treated as not-dismissed.
*/
export function useDismissalState(key: string) {
const [dismissed, setDismissed] = useState(() => readDismissedFromStorage(key));
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key !== key) return;
// event.newValue is null when the entry was removed (restore)
// and a string when it was set (dismiss).
if (event.newValue === null) {
setDismissed(false);
return;
}
const ts = Number.parseInt(event.newValue, 10);
setDismissed(Number.isFinite(ts) && Date.now() - ts < DISMISS_DURATION_MS);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
// setDismissed is stable per React's setter guarantee; the handler
// closes over `key` only. Do not add setDismissed (harmless) or
// dismissed (would re-attach the listener on every state change)
// to satisfy a future drive-by exhaustive-deps "fix."
}, [key]);
const dismiss = () => {
localStorage.setItem(key, Date.now().toString());
setDismissed(true);
};
const restore = () => {
localStorage.removeItem(key);
setDismissed(false);
};
return { dismissed, dismiss, restore };
}
function readDismissedFromStorage(key: string): boolean {
const dismissedAt = localStorage.getItem(key);
if (!dismissedAt) return false;
const ts = Number.parseInt(dismissedAt, 10);
if (!Number.isFinite(ts)) return false;
return Date.now() - ts < DISMISS_DURATION_MS;
}