Files
sencho/frontend/src/components/AdmiralGate.tsx
T
Anso 677f0778e7 refactor(frontend): extract shared parts from PaidGate and AdmiralGate (#876)
PaidGate and AdmiralGate were ~95% identical: same state machine
(unlocked / compact-blurred / dismissed-pill / full-upsell-card), same
24h localStorage-backed dismissal logic, differing only in license
predicate, dismiss-storage key, icon, and copy strings. Two reviewers
flagged the duplication after PRs #874 and #875 landed identical
changes in both files; the rule-of-three threshold is met.

Extract the shared parts compositionally rather than as one big config-
driven gate (the latter would just inline both gates' contents behind
8 props of indirection):

- frontend/src/hooks/useDismissalState.ts owns the localStorage
  dismissal pattern. Lives under hooks/ to dodge the
  react-refresh/only-export-components lint rule that would fire if a
  hook coexisted with components in the same file. Validates the stored
  timestamp via Number.isFinite so a hand-edited or stale-extension
  garbage value defaults to "show the upsell" instead of crashing.
- frontend/src/components/tierUpsell.tsx exports CompactBlurredLock,
  DismissedPill, and FullUpsellCard plus a shared TierGateProps
  interface. The compact-mode JSDoc lives on TierGateProps so the doc
  string lives in exactly one place.

PaidGate and AdmiralGate become ~50-line compositions reading like a
state machine. Public API of both gates is byte-stable: all 13+
consumers across the app continue to use <PaidGate featureName="X">
and <AdmiralGate featureName="X" compact> exactly as before.

Two pre-existing security/polish issues fixed in passing while there
is one source of truth for the affected JSX:

- FullUpsellCard's window.open now passes 'noopener,noreferrer' to
  prevent the destination tab from accessing window.opener (reverse
  tabnabbing).
- The Number.parseInt + Number.isFinite guard replaces a bare
  parseInt that would have happily accepted any prefix-numeric input.

Adds a Vitest spec for useDismissalState covering: empty / recent /
expired / non-numeric storage values, dismiss() / restore() side
effects, the 24h boundary on fresh mount, and key independence.
2026-05-02 04:01:54 -04:00

56 lines
1.9 KiB
TypeScript

import { ShipWheel } from 'lucide-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.
*/
export function AdmiralGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
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}
/>
);
}