mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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.
This commit is contained in:
@@ -1,105 +1,65 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { Compass } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
interface PaidGateProps {
|
||||
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.
|
||||
compact?: boolean;
|
||||
}
|
||||
import { useDismissalState } from '@/hooks/useDismissalState';
|
||||
import {
|
||||
CompactBlurredLock,
|
||||
DismissedPill,
|
||||
FullUpsellCard,
|
||||
type TierGateProps,
|
||||
} from './tierUpsell';
|
||||
|
||||
const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed';
|
||||
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours (session-like)
|
||||
|
||||
function isDismissedFromStorage(): boolean {
|
||||
const dismissedAt = localStorage.getItem(DISMISS_KEY);
|
||||
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
|
||||
}
|
||||
|
||||
export function PaidGate({ children, featureName = 'This feature', compact = false }: PaidGateProps) {
|
||||
/**
|
||||
* Gate for any paid-tier feature (Skipper or Admiral). Composes the
|
||||
* shared tier-upsell primitives in `./tierUpsell` according to the
|
||||
* current state:
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function PaidGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
|
||||
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
|
||||
|
||||
if (isPaid) return <>{children}</>;
|
||||
|
||||
// Inline lock for list items (single SSO provider card, etc.). Renders the
|
||||
// children blurred behind a small pill so the surrounding list keeps its
|
||||
// shape; the IP exposure is minor because the children are tiny inline UI,
|
||||
// and the visual continuity is intentional. Dismissal does not apply here.
|
||||
const pillText = `Upgrade to unlock ${featureName}`;
|
||||
|
||||
if (compact) {
|
||||
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">
|
||||
<Compass className="w-3 h-3" />
|
||||
Upgrade to unlock {featureName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompactBlurredLock icon={Compass} pillText={pillText}>
|
||||
{children}
|
||||
</CompactBlurredLock>
|
||||
);
|
||||
}
|
||||
|
||||
// Post-dismissal placeholder for full-page consumers. Renders only the
|
||||
// pill so the lazy-loaded children behind a paid view never mount during
|
||||
// the dismissal window. Clicking the pill clears the dismissal flag and
|
||||
// restores the full upsell card so users who dismissed accidentally or
|
||||
// want to revisit pricing have a way back without clearing localStorage.
|
||||
if (dismissed) {
|
||||
return (
|
||||
<div className="flex items-start justify-center pt-8 min-h-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(DISMISS_KEY);
|
||||
setDismissed(false);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<Compass className="w-3 h-3" />
|
||||
Upgrade to unlock {featureName}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return <DismissedPill icon={Compass} pillText={pillText} onClick={restore} />;
|
||||
}
|
||||
|
||||
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">
|
||||
<Compass className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-2">{featureName} requires a paid license</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<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>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
localStorage.setItem(DISMISS_KEY, Date.now().toString());
|
||||
setDismissed(true);
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
|
||||
>
|
||||
<Compass className="w-4 h-4 mr-2" />
|
||||
View Plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
ctaIcon={Compass}
|
||||
ctaLabel="View Plans"
|
||||
ctaHref="https://sencho.io/pricing"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user