mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
677f0778e7
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.
56 lines
1.9 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|