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 }); } },