diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx
index 510945c..814d8b7 100644
--- a/frontend/src/components/AIPanel.tsx
+++ b/frontend/src/components/AIPanel.tsx
@@ -1,7 +1,8 @@
import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
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';
interface AIPanelProps {
@@ -12,6 +13,8 @@ interface AIPanelProps {
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
+ const canUseAI = useLicenseStore((s) => s.canUseAI);
+ const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
const {
defaultProvider,
providers,
@@ -188,169 +191,205 @@ export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }:
{activeTab === 'filler' && (
-
- Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
- your transcript.
-
-
-
- setCustomFillerWords(e.target.value)}
- placeholder="e.g. okay, alright, anyway"
- className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
- />
-
-
-
- {error && (
-
-
{error}
+ {!canUseAI ? (
+
+
+
AI editing requires a license
+
+ Upgrade to Pro or Business to unlock filler word removal, clip suggestions, and more.
+
- )}
- {fillerResult && fillerResult.fillerWords.length > 0 && (
-
-
-
- Found {fillerResult.fillerWords.length} filler words
-
-
+ ) : (
+ <>
+
+ Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
+ your transcript.
+
+
+
+ setCustomFillerWords(e.target.value)}
+ placeholder="e.g. okay, alright, anyway"
+ className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
+ />
+
+
+
+ {error && (
+
+ {error}
-
-
-
- {fillerResult.fillerWords.map((fw) => (
-
-
- "{fw.word}"
- — {fw.reason}
+ )}
+ {fillerResult && fillerResult.fillerWords.length > 0 && (
+
+
+
+ Found {fillerResult.fillerWords.length} filler words
+
+
+
+
- ))}
-
-
- )}
+
+ {fillerResult.fillerWords.map((fw) => (
+
+
+ "{fw.word}"
+ — {fw.reason}
+
+
+ ))}
+
+
+ )}
- {fillerResult && fillerResult.fillerWords.length === 0 && (
-
No filler words detected.
+ {fillerResult && fillerResult.fillerWords.length === 0 && (
+
No filler words detected.
+ )}
+ >
)}
)}
{activeTab === 'clips' && (
-
- AI analyzes your transcript and suggests the most engaging segments for a
- YouTube Short or social media clip.
-
-
-
- {error && (
-
-
{error}
+ {!canUseAI ? (
+
+
+
AI clip suggestions require a license
+
+ Upgrade to Pro or Business to find the best segments for social media clips.
+
- )}
- {clipSuggestions.length > 0 && (
-
- {clipSuggestions.map((clip, i) => (
-
-
- {clip.title}
-
- {Math.round(clip.endTime - clip.startTime)}s
-
-
-
{clip.reason}
-
-
-
-
+ ) : (
+ <>
+
+ AI analyzes your transcript and suggests the most engaging segments for a
+ YouTube Short or social media clip.
+
+
+
+ {error && (
+
+ {error}
+
- ))}
-
+ )}
+ {clipSuggestions.length > 0 && (
+
+ {clipSuggestions.map((clip, i) => (
+
+
+ {clip.title}
+
+ {Math.round(clip.endTime - clip.startTime)}s
+
+
+
{clip.reason}
+
+
+
+
+
+ ))}
+
+ )}
+ >
)}
)}
diff --git a/frontend/src/store/licenseStore.test.ts b/frontend/src/store/licenseStore.test.ts
index 37f4892..d155c32 100644
--- a/frontend/src/store/licenseStore.test.ts
+++ b/frontend/src/store/licenseStore.test.ts
@@ -16,7 +16,7 @@ function mockElectronAPI(overrides: Record
= {}) {
describe('licenseStore', () => {
beforeEach(() => {
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', () => {
@@ -45,10 +45,28 @@ describe('licenseStore', () => {
});
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);
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', () => {
@@ -76,6 +94,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
+ expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('handles missing electronAPI', async () => {
@@ -83,6 +102,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
+ expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('sets isLoaded to true after check', async () => {
@@ -99,6 +119,7 @@ describe('licenseStore', () => {
expect(result).toBe(true);
expect(useLicenseStore.getState().status?.tag).toBe('Licensed');
expect(useLicenseStore.getState().canEdit).toBe(true);
+ expect(useLicenseStore.getState().canUseAI).toBe(true);
});
test('returns false on invalid key', async () => {
@@ -131,6 +152,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
+ expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('restores Trial when trial is still valid', async () => {
@@ -141,14 +163,16 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Trial');
expect(useLicenseStore.getState().canEdit).toBe(true);
+ expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('handles API error', async () => {
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();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
+ expect(useLicenseStore.getState().canUseAI).toBe(false);
});
});
diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts
index f32058b..bf599ad 100644
--- a/frontend/src/store/licenseStore.ts
+++ b/frontend/src/store/licenseStore.ts
@@ -25,6 +25,7 @@ interface LicenseState {
isLoaded: boolean;
showDialog: boolean;
canEdit: boolean;
+ canUseAI: boolean;
}
interface LicenseActions {
@@ -43,10 +44,12 @@ export const useLicenseStore = create()(
isLoaded: false,
showDialog: false,
canEdit: false,
+ canUseAI: false,
setStatus: (status) => {
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 }),
@@ -55,9 +58,10 @@ export const useLicenseStore = create()(
try {
const status = await window.electronAPI?.getAppStatus();
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 {
- 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()(
try {
const license = await window.electronAPI?.activateLicense(key);
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;
} catch {
return false;
@@ -77,9 +81,10 @@ export const useLicenseStore = create()(
await window.electronAPI?.deactivateLicense();
const s = await window.electronAPI?.getAppStatus();
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 {
- set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false });
+ set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
}
},