Gate AI editing features behind license (trial users no longer get AI editing)

This commit is contained in:
2026-05-06 23:21:45 -06:00
parent 850b373d42
commit 8bd1ad5b69
3 changed files with 219 additions and 151 deletions

View File

@ -1,7 +1,8 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore'; import { useAIStore } from '../store/aiStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw } from 'lucide-react'; import { useLicenseStore } from '../store/licenseStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw, Lock } from 'lucide-react';
import type { ClipSuggestion } from '../types/project'; import type { ClipSuggestion } from '../types/project';
interface AIPanelProps { interface AIPanelProps {
@ -12,6 +13,8 @@ interface AIPanelProps {
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) { export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore(); const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const canUseAI = useLicenseStore((s) => s.canUseAI);
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
const { const {
defaultProvider, defaultProvider,
providers, providers,
@ -188,6 +191,22 @@ export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }:
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{activeTab === 'filler' && ( {activeTab === 'filler' && (
<div className="space-y-4"> <div className="space-y-4">
{!canUseAI ? (
<div className="text-center py-8 px-4">
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
<p className="text-sm font-medium mb-1">AI editing requires a license</p>
<p className="text-xs text-editor-text-muted mb-4">
Upgrade to Pro or Business to unlock filler word removal, clip suggestions, and more.
</p>
<button
onClick={() => setShowLicenseDialog(true)}
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
>
Upgrade Now
</button>
</div>
) : (
<>
<p className="text-xs text-editor-text-muted"> <p className="text-xs text-editor-text-muted">
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
your transcript. your transcript.
@ -276,11 +295,29 @@ export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }:
{fillerResult && fillerResult.fillerWords.length === 0 && ( {fillerResult && fillerResult.fillerWords.length === 0 && (
<p className="text-xs text-editor-success">No filler words detected.</p> <p className="text-xs text-editor-success">No filler words detected.</p>
)} )}
</>
)}
</div> </div>
)} )}
{activeTab === 'clips' && ( {activeTab === 'clips' && (
<div className="space-y-4"> <div className="space-y-4">
{!canUseAI ? (
<div className="text-center py-8 px-4">
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
<p className="text-sm font-medium mb-1">AI clip suggestions require a license</p>
<p className="text-xs text-editor-text-muted mb-4">
Upgrade to Pro or Business to find the best segments for social media clips.
</p>
<button
onClick={() => setShowLicenseDialog(true)}
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
>
Upgrade Now
</button>
</div>
) : (
<>
<p className="text-xs text-editor-text-muted"> <p className="text-xs text-editor-text-muted">
AI analyzes your transcript and suggests the most engaging segments for a AI analyzes your transcript and suggests the most engaging segments for a
YouTube Short or social media clip. YouTube Short or social media clip.
@ -352,6 +389,8 @@ export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }:
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
)} )}

View File

@ -16,7 +16,7 @@ function mockElectronAPI(overrides: Record<string, any> = {}) {
describe('licenseStore', () => { describe('licenseStore', () => {
beforeEach(() => { beforeEach(() => {
mockElectronAPI(); mockElectronAPI();
useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, showDialog: false }); useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, canUseAI: false, showDialog: false });
}); });
describe('canEdit', () => { describe('canEdit', () => {
@ -45,10 +45,28 @@ describe('licenseStore', () => {
}); });
test('is false when status is null', () => { test('is false when status is null', () => {
useLicenseStore.setState({ status: null, canEdit: true }); useLicenseStore.setState({ status: null, canEdit: true, canUseAI: false });
useLicenseStore.getState().setStatus(null); useLicenseStore.getState().setStatus(null);
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
}); });
test('is true for Licensed status', () => {
useLicenseStore.getState().setStatus({ tag: 'Licensed', license: { license_id: 'x', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 } });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(true);
});
test('is false for Trial status', () => {
useLicenseStore.getState().setStatus({ tag: 'Trial', days_remaining: 5, started_at: Date.now() });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('is false for Expired status', () => {
useLicenseStore.getState().setStatus({ tag: 'Expired' });
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
}); });
describe('checkStatus', () => { describe('checkStatus', () => {
@ -76,6 +94,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus(); await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('handles missing electronAPI', async () => { test('handles missing electronAPI', async () => {
@ -83,6 +102,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus(); await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('sets isLoaded to true after check', async () => { test('sets isLoaded to true after check', async () => {
@ -99,6 +119,7 @@ describe('licenseStore', () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(useLicenseStore.getState().status?.tag).toBe('Licensed'); expect(useLicenseStore.getState().status?.tag).toBe('Licensed');
expect(useLicenseStore.getState().canEdit).toBe(true); expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(true);
}); });
test('returns false on invalid key', async () => { test('returns false on invalid key', async () => {
@ -131,6 +152,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('restores Trial when trial is still valid', async () => { test('restores Trial when trial is still valid', async () => {
@ -141,14 +163,16 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Trial'); expect(useLicenseStore.getState().status?.tag).toBe('Trial');
expect(useLicenseStore.getState().canEdit).toBe(true); expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('handles API error', async () => { test('handles API error', async () => {
mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) }); mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) });
useLicenseStore.setState({ status: { tag: 'Licensed', license: {} as any }, canEdit: true }); useLicenseStore.setState({ status: { tag: 'Licensed', license: {} as any }, canEdit: true, canUseAI: true });
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
}); });

View File

@ -25,6 +25,7 @@ interface LicenseState {
isLoaded: boolean; isLoaded: boolean;
showDialog: boolean; showDialog: boolean;
canEdit: boolean; canEdit: boolean;
canUseAI: boolean;
} }
interface LicenseActions { interface LicenseActions {
@ -43,10 +44,12 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
isLoaded: false, isLoaded: false,
showDialog: false, showDialog: false,
canEdit: false, canEdit: false,
canUseAI: false,
setStatus: (status) => { setStatus: (status) => {
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial'; const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status, isLoaded: true, canEdit }); const canUseAI = status?.tag === 'Licensed';
set({ status, isLoaded: true, canEdit, canUseAI });
}, },
setShowDialog: (show) => set({ showDialog: show }), setShowDialog: (show) => set({ showDialog: show }),
@ -55,9 +58,10 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
try { try {
const status = await window.electronAPI?.getAppStatus(); const status = await window.electronAPI?.getAppStatus();
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial'; const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit }); const canUseAI = status?.tag === 'Licensed';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch { } catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false }); set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
} }
}, },
@ -65,7 +69,7 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
try { try {
const license = await window.electronAPI?.activateLicense(key); const license = await window.electronAPI?.activateLicense(key);
if (!license) return false; if (!license) return false;
set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true }); set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true, canUseAI: true });
return true; return true;
} catch { } catch {
return false; return false;
@ -77,9 +81,10 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
await window.electronAPI?.deactivateLicense(); await window.electronAPI?.deactivateLicense();
const s = await window.electronAPI?.getAppStatus(); const s = await window.electronAPI?.getAppStatus();
const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial'; const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit }); const canUseAI = s?.tag === 'Licensed';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch { } catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false }); set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
} }
}, },