mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user