Gate AI editing features behind license (trial users no longer get AI editing)
This commit is contained in:
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user