@@ -70,11 +69,10 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
{group.label}
- {unlockedCount}/{groupItems.length}
+ {groupItems.length}
{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 && (
)}
- {item.tier && locked &&
}
);
})}
diff --git a/frontend/src/components/settings/TierLockChip.tsx b/frontend/src/components/settings/TierLockChip.tsx
deleted file mode 100644
index ddcd58c9..00000000
--- a/frontend/src/components/settings/TierLockChip.tsx
+++ /dev/null
@@ -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 (
-
- {showIcon && }
- {tier === 'admiral' ? 'Admiral' : 'Skipper'}
-
- );
-}
diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx
index 01b9113d..14a14df3 100644
--- a/frontend/src/components/settings/UsersSection.tsx
+++ b/frontend/src/components/settings/UsersSection.tsx
@@ -259,7 +259,7 @@ export function UsersSection() {
};
return (
-
+
{!showForm && (
diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx
index e7ced158..df86b740 100644
--- a/frontend/src/components/settings/WebhooksSection.tsx
+++ b/frontend/src/components/settings/WebhooksSection.tsx
@@ -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 (
-
- );
- }
+ if (!isPaid) return null;
return (
diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts
index 48e49732..2b07e29e 100644
--- a/frontend/src/components/settings/index.ts
+++ b/frontend/src/components/settings/index.ts
@@ -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';
diff --git a/frontend/src/components/sidebar/SidebarBulkBar.tsx b/frontend/src/components/sidebar/SidebarBulkBar.tsx
index afed486c..25097bb7 100644
--- a/frontend/src/components/sidebar/SidebarBulkBar.tsx
+++ b/frontend/src/components/sidebar/SidebarBulkBar.tsx
@@ -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
- {isPaid ? (
+ {isPaid && (
- ) : (
-
-
-
-
-
-
-
-
- Bulk update requires a Skipper license
-
-
-
)}
diff --git a/frontend/src/components/tierUpsell.tsx b/frontend/src/components/tierUpsell.tsx
deleted file mode 100644
index 463b0deb..00000000
--- a/frontend/src/components/tierUpsell.tsx
+++ /dev/null
@@ -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 {children};
- * if (dismissed) return ;
- * return ;
- *
- * 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 (
-
- );
-}
-
-/**
- * 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 (
-
-
-
- );
-}
-
-/**
- * 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 (
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/hooks/__tests__/useDismissalState.test.ts b/frontend/src/hooks/__tests__/useDismissalState.test.ts
deleted file mode 100644
index 33dd93cd..00000000
--- a/frontend/src/hooks/__tests__/useDismissalState.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/frontend/src/hooks/useDismissalState.ts b/frontend/src/hooks/useDismissalState.ts
deleted file mode 100644
index 608efd71..00000000
--- a/frontend/src/hooks/useDismissalState.ts
+++ /dev/null
@@ -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;
-}