jettypod 4.4.115 → 4.4.118
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/install-claude/page.tsx +8 -6
- package/apps/dashboard/app/login/page.tsx +229 -0
- package/apps/dashboard/app/page.tsx +5 -3
- package/apps/dashboard/app/settings/page.tsx +2 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +23 -0
- package/apps/dashboard/components/AppShell.tsx +51 -9
- package/apps/dashboard/components/CardMenu.tsx +14 -5
- package/apps/dashboard/components/ClaudePanel.tsx +65 -9
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
- package/apps/dashboard/components/DragContext.tsx +73 -64
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/GateCard.tsx +21 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
- package/apps/dashboard/components/KanbanBoard.tsx +173 -56
- package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
- package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
- package/apps/dashboard/components/SubscribeContent.tsx +191 -0
- package/apps/dashboard/components/TipCard.tsx +176 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
- package/apps/dashboard/contexts/UsageContext.tsx +131 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +220 -114
- package/apps/dashboard/electron/main.js +415 -37
- package/apps/dashboard/electron/preload.js +23 -4
- package/apps/dashboard/electron/session-manager.js +141 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/lib/claude-process-manager.ts +6 -4
- package/apps/dashboard/lib/db-bridge.ts +32 -0
- package/apps/dashboard/lib/db.ts +159 -13
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +76 -13
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/next.config.js +19 -14
- package/apps/dashboard/package.json +3 -1
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1074 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/docs/bdd-guidance.md +390 -0
- package/jettypod.js +5 -4
- package/lib/migrations/027-plan-at-creation-column.js +31 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/schema.js +3 -1
- package/lib/seed-onboarding.js +100 -68
- package/lib/work-commands/index.js +43 -13
- package/lib/work-tracking/index.js +46 -27
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +5 -11
- package/skills-templates/request-routing/SKILL.md +24 -11
- package/skills-templates/simple-improvement/SKILL.md +35 -19
- package/skills-templates/stable-mode/SKILL.md +5 -6
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- package/skills-templates/project-discovery/SKILL.md +0 -372
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
4
4
|
import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
|
|
5
|
+
import { useClaudeSession } from '../contexts/ClaudeSessionContext';
|
|
5
6
|
import type { TestDashboardData, TestFeature } from '@/lib/tests';
|
|
6
7
|
|
|
7
8
|
interface RealTimeTestsWrapperProps {
|
|
@@ -31,6 +32,10 @@ const statusColors = {
|
|
|
31
32
|
|
|
32
33
|
const SELECTED_FEATURE_KEY = 'tests-selected-feature-id';
|
|
33
34
|
|
|
35
|
+
function parseUtcDate(dateStr: string): Date {
|
|
36
|
+
return new Date(dateStr.endsWith('Z') ? dateStr : dateStr.replace(' ', 'T') + 'Z');
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
type StepStatus = 'pending' | 'running' | 'passed' | 'failed';
|
|
35
40
|
|
|
36
41
|
export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps) {
|
|
@@ -49,6 +54,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
|
|
|
49
54
|
|
|
50
55
|
const elapsedStartRef = useRef<Record<string, number>>({});
|
|
51
56
|
const allFeaturesRef = useRef<TestFeature[]>([]);
|
|
57
|
+
const { createFixScenarioSession } = useClaudeSession();
|
|
52
58
|
|
|
53
59
|
const refreshUndefinedSteps = useCallback(() => {
|
|
54
60
|
fetch('/api/tests/undefined')
|
|
@@ -397,7 +403,7 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
|
|
|
397
403
|
)}
|
|
398
404
|
{data.summary.lastRun && (
|
|
399
405
|
<div className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-500">
|
|
400
|
-
Last run: {
|
|
406
|
+
Last run: {parseUtcDate(data.summary.lastRun).toLocaleString()}
|
|
401
407
|
</div>
|
|
402
408
|
)}
|
|
403
409
|
</div>
|
|
@@ -573,7 +579,14 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
|
|
|
573
579
|
{isRunning && elapsed !== undefined ? (
|
|
574
580
|
<span className="text-xs font-mono text-blue-500">{elapsed}s</span>
|
|
575
581
|
) : (
|
|
576
|
-
<
|
|
582
|
+
<div className="flex items-center gap-2">
|
|
583
|
+
<span className="text-xs font-mono text-zinc-400">{scenario.duration}</span>
|
|
584
|
+
{scenario.lastRun && (
|
|
585
|
+
<span className="text-xs text-zinc-400" title={parseUtcDate(scenario.lastRun).toLocaleString()}>
|
|
586
|
+
{parseUtcDate(scenario.lastRun).toLocaleString()}
|
|
587
|
+
</span>
|
|
588
|
+
)}
|
|
589
|
+
</div>
|
|
577
590
|
)}
|
|
578
591
|
<button
|
|
579
592
|
onClick={(e) => {
|
|
@@ -671,6 +684,21 @@ export function RealTimeTestsWrapper({ initialData }: RealTimeTestsWrapperProps)
|
|
|
671
684
|
{scenario.error}
|
|
672
685
|
</pre>
|
|
673
686
|
</div>
|
|
687
|
+
<button
|
|
688
|
+
onClick={(e) => {
|
|
689
|
+
e.stopPropagation();
|
|
690
|
+
createFixScenarioSession(
|
|
691
|
+
selectedFeature.featureFile,
|
|
692
|
+
scenario.title,
|
|
693
|
+
scenario.error!,
|
|
694
|
+
scenario.failedStep,
|
|
695
|
+
scenario.steps
|
|
696
|
+
);
|
|
697
|
+
}}
|
|
698
|
+
className="mt-3 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors text-violet-700 bg-violet-100 hover:bg-violet-200 border border-violet-200"
|
|
699
|
+
>
|
|
700
|
+
Fix with Claude
|
|
701
|
+
</button>
|
|
674
702
|
</div>
|
|
675
703
|
)}
|
|
676
704
|
</div>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
|
|
7
|
+
|
|
8
|
+
const buttonGradientStyle = {
|
|
9
|
+
background: 'linear-gradient(145deg, #ffffff 0%, #faf9f7 10%, #f0f4f4 35%, #c8d9da 55%, #819D9F 90%)',
|
|
10
|
+
color: '#3d4d4e',
|
|
11
|
+
boxShadow: `
|
|
12
|
+
0 1px 1px rgba(0, 0, 0, 0.02),
|
|
13
|
+
0 2px 4px rgba(0, 0, 0, 0.03),
|
|
14
|
+
0 6px 12px rgba(0, 0, 0, 0.05),
|
|
15
|
+
0 12px 24px rgba(0, 0, 0, 0.06),
|
|
16
|
+
0 20px 40px rgba(129, 157, 159, 0.2),
|
|
17
|
+
0 32px 64px rgba(129, 157, 159, 0.18),
|
|
18
|
+
inset 0 2px 4px rgba(255, 255, 255, 1),
|
|
19
|
+
inset 0 -2px 4px rgba(129, 157, 159, 0.05)
|
|
20
|
+
`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type PageState = 'idle' | 'checkout-opened' | 'polling' | 'upgraded';
|
|
24
|
+
type PlanType = 'monthly' | 'lifetime';
|
|
25
|
+
|
|
26
|
+
interface SubscribeContentProps {
|
|
27
|
+
onClose?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SubscribeContent({ onClose }: SubscribeContentProps) {
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const [checkoutPlan, setCheckoutPlan] = useState<string | null>(null);
|
|
33
|
+
const [pageState, setPageState] = useState<PageState>('idle');
|
|
34
|
+
const [userEmail, setUserEmail] = useState<string | null>(null);
|
|
35
|
+
const [userPlan, setUserPlan] = useState<string | null>(null);
|
|
36
|
+
const [pollingPlan, setPollingPlan] = useState<PlanType | null>(null);
|
|
37
|
+
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
async function loadAuth() {
|
|
41
|
+
if (!window.electronAPI?.isElectron) return;
|
|
42
|
+
const status = await window.electronAPI.auth.getStatus();
|
|
43
|
+
if (status.authenticated && status.user) {
|
|
44
|
+
setUserEmail(status.user.email);
|
|
45
|
+
setUserPlan(status.user.plan || 'free');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
loadAuth();
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
return () => {
|
|
53
|
+
if (pollingRef.current) clearInterval(pollingRef.current);
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const startPolling = () => {
|
|
58
|
+
setPageState('polling');
|
|
59
|
+
|
|
60
|
+
pollingRef.current = setInterval(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const token = await window.electronAPI!.auth.getToken();
|
|
63
|
+
if (!token) return;
|
|
64
|
+
|
|
65
|
+
const res = await fetch(`${API_BASE}/auth/me`, {
|
|
66
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!res.ok) return;
|
|
70
|
+
|
|
71
|
+
const data = await res.json() as { user: { plan: string; email: string }; token?: string };
|
|
72
|
+
|
|
73
|
+
if (data.user.plan !== 'free') {
|
|
74
|
+
if (data.token) {
|
|
75
|
+
await window.electronAPI!.auth.saveToken(data.token, data.user);
|
|
76
|
+
}
|
|
77
|
+
setUserPlan(data.user.plan);
|
|
78
|
+
setPageState('upgraded');
|
|
79
|
+
if (pollingRef.current) clearInterval(pollingRef.current);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Silently continue polling on network/parse errors
|
|
83
|
+
}
|
|
84
|
+
}, 3000);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleCheckout = async (plan: 'monthly' | 'lifetime') => {
|
|
88
|
+
if (!window.electronAPI?.isElectron) return;
|
|
89
|
+
setCheckoutPlan(plan);
|
|
90
|
+
setError(null);
|
|
91
|
+
|
|
92
|
+
const result = await window.electronAPI.subscription.createCheckout(plan);
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
setError(result.error || 'Failed to start checkout.');
|
|
95
|
+
setCheckoutPlan(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setCheckoutPlan(null);
|
|
100
|
+
setPollingPlan(plan);
|
|
101
|
+
startPolling();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleDone = () => {
|
|
105
|
+
if (onClose) {
|
|
106
|
+
onClose();
|
|
107
|
+
} else {
|
|
108
|
+
window.location.href = '/';
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (pageState === 'upgraded') {
|
|
113
|
+
return (
|
|
114
|
+
<div className="max-w-md w-full space-y-8 text-center">
|
|
115
|
+
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
|
116
|
+
You're all set!
|
|
117
|
+
</h1>
|
|
118
|
+
<p className="text-zinc-500 dark:text-zinc-400">
|
|
119
|
+
Your plan has been upgraded to <span className="font-medium text-zinc-900 dark:text-zinc-100">{userPlan}</span>.
|
|
120
|
+
</p>
|
|
121
|
+
<button
|
|
122
|
+
onClick={handleDone}
|
|
123
|
+
className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100"
|
|
124
|
+
style={buttonGradientStyle}
|
|
125
|
+
>
|
|
126
|
+
{onClose ? 'Done' : 'Go to Dashboard'}
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isPolling = pageState === 'polling';
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="max-w-md w-full space-y-8">
|
|
136
|
+
<div className="flex flex-col items-center space-y-4">
|
|
137
|
+
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
|
|
138
|
+
Unlock Unlimited Use
|
|
139
|
+
</h1>
|
|
140
|
+
<p className="text-zinc-500 dark:text-zinc-400 text-center">
|
|
141
|
+
You're currently on the free plan.
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{userEmail && (
|
|
146
|
+
<div className="bg-zinc-50 dark:bg-zinc-800 rounded-xl px-4 py-3 text-sm">
|
|
147
|
+
<div className="flex justify-between items-center">
|
|
148
|
+
<span className="text-zinc-500 dark:text-zinc-400">{userEmail}</span>
|
|
149
|
+
<span className="text-zinc-400 dark:text-zinc-500 capitalize">{userPlan}</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{error && (
|
|
155
|
+
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
|
156
|
+
{error}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
<div className="pt-4 space-y-3">
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => handleCheckout('monthly')}
|
|
163
|
+
disabled={checkoutPlan !== null || isPolling}
|
|
164
|
+
className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100 disabled:opacity-50 disabled:pointer-events-none"
|
|
165
|
+
style={{ cursor: (checkoutPlan || isPolling) ? 'default' : 'pointer', ...buttonGradientStyle }}
|
|
166
|
+
>
|
|
167
|
+
{pollingPlan === 'monthly' ? (
|
|
168
|
+
<span className="inline-flex items-center gap-2">
|
|
169
|
+
<span className="animate-spin inline-block h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
|
170
|
+
Upgrading...
|
|
171
|
+
</span>
|
|
172
|
+
) : checkoutPlan === 'monthly' ? 'Opening checkout...' : 'Monthly'}
|
|
173
|
+
</button>
|
|
174
|
+
|
|
175
|
+
<button
|
|
176
|
+
onClick={() => handleCheckout('lifetime')}
|
|
177
|
+
disabled={checkoutPlan !== null || isPolling}
|
|
178
|
+
className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100 disabled:opacity-50 disabled:pointer-events-none"
|
|
179
|
+
style={{ cursor: (checkoutPlan || isPolling) ? 'default' : 'pointer', ...buttonGradientStyle }}
|
|
180
|
+
>
|
|
181
|
+
{pollingPlan === 'lifetime' ? (
|
|
182
|
+
<span className="inline-flex items-center gap-2">
|
|
183
|
+
<span className="animate-spin inline-block h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
|
184
|
+
Upgrading...
|
|
185
|
+
</span>
|
|
186
|
+
) : checkoutPlan === 'lifetime' ? 'Opening checkout...' : 'Lifetime Access'}
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
|
|
5
|
+
import ReactMarkdown from 'react-markdown';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'jettypod-dismissed-tips';
|
|
8
|
+
|
|
9
|
+
function getDismissedTips(): string[] {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function dismissTip(tipId: string): void {
|
|
18
|
+
try {
|
|
19
|
+
const dismissed = getDismissedTips();
|
|
20
|
+
if (!dismissed.includes(tipId)) {
|
|
21
|
+
dismissed.push(tipId);
|
|
22
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(dismissed));
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// localStorage unavailable (private browsing, storage full, etc.)
|
|
26
|
+
// Dismiss still works for current session via React state
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Multi-layer shadow matching kanban card system
|
|
31
|
+
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.02)';
|
|
32
|
+
|
|
33
|
+
interface TipCardProps {
|
|
34
|
+
tipId: string;
|
|
35
|
+
icon: string;
|
|
36
|
+
title: string;
|
|
37
|
+
body: string | React.ReactNode;
|
|
38
|
+
onDismiss?: (tipId: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
|
|
42
|
+
const prefersReducedMotion = useReducedMotion();
|
|
43
|
+
const [dismissed, setDismissed] = useState(() => getDismissedTips().includes(tipId));
|
|
44
|
+
|
|
45
|
+
// Sync with localStorage on mount (in case it changed between renders)
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
setDismissed(getDismissedTips().includes(tipId));
|
|
48
|
+
}, [tipId]);
|
|
49
|
+
|
|
50
|
+
function handleDismiss() {
|
|
51
|
+
dismissTip(tipId);
|
|
52
|
+
setDismissed(true);
|
|
53
|
+
onDismiss?.(tipId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<AnimatePresence>
|
|
58
|
+
{!dismissed && (
|
|
59
|
+
<motion.div
|
|
60
|
+
data-testid={`tip-card-${tipId}`}
|
|
61
|
+
initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
|
|
62
|
+
animate={prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
|
63
|
+
exit={prefersReducedMotion
|
|
64
|
+
? { opacity: 0 }
|
|
65
|
+
: { opacity: 0, y: -8, scale: 0.97, transition: { duration: 0.25 } }
|
|
66
|
+
}
|
|
67
|
+
transition={{ duration: prefersReducedMotion ? 0.15 : 0.35, ease: [0.22, 1, 0.36, 1] }}
|
|
68
|
+
style={{
|
|
69
|
+
background: 'linear-gradient(135deg, #f0fdfa 0%, #f0fdfb 100%)',
|
|
70
|
+
border: '1px solid #ccfbf1',
|
|
71
|
+
borderRadius: 12,
|
|
72
|
+
padding: 14,
|
|
73
|
+
boxShadow: CARD_SHADOW,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{/* Header: icon + label/title */}
|
|
77
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
|
78
|
+
<div
|
|
79
|
+
style={{
|
|
80
|
+
width: 34,
|
|
81
|
+
height: 34,
|
|
82
|
+
borderRadius: '50%',
|
|
83
|
+
background: 'rgba(20, 184, 166, 0.12)',
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
justifyContent: 'center',
|
|
87
|
+
fontSize: 16,
|
|
88
|
+
flexShrink: 0,
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{icon}
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<div
|
|
95
|
+
style={{
|
|
96
|
+
fontSize: 10,
|
|
97
|
+
fontWeight: 700,
|
|
98
|
+
textTransform: 'uppercase' as const,
|
|
99
|
+
letterSpacing: '0.06em',
|
|
100
|
+
color: '#0d9488',
|
|
101
|
+
opacity: 0.7,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
Tip
|
|
105
|
+
</div>
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
fontSize: 14,
|
|
109
|
+
fontWeight: 600,
|
|
110
|
+
color: '#134e4a',
|
|
111
|
+
marginTop: 1,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{title}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Body */}
|
|
120
|
+
<div
|
|
121
|
+
style={{
|
|
122
|
+
fontSize: 13,
|
|
123
|
+
lineHeight: 1.55,
|
|
124
|
+
color: '#52525b',
|
|
125
|
+
marginLeft: 44,
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{typeof body === 'string' ? (
|
|
129
|
+
<ReactMarkdown
|
|
130
|
+
components={{
|
|
131
|
+
p: ({ children }) => <p style={{ margin: '0 0 8px 0' }}>{children}</p>,
|
|
132
|
+
strong: ({ children }) => <strong style={{ fontWeight: 600, color: '#3f3f46' }}>{children}</strong>,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{body.replace(/\n/g, ' \n')}
|
|
136
|
+
</ReactMarkdown>
|
|
137
|
+
) : body}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* Footer: Got it button */}
|
|
141
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 10 }}>
|
|
142
|
+
<button
|
|
143
|
+
onClick={handleDismiss}
|
|
144
|
+
data-testid={`tip-dismiss-${tipId}`}
|
|
145
|
+
style={{
|
|
146
|
+
background: 'transparent',
|
|
147
|
+
border: '1px solid #99f6e4',
|
|
148
|
+
fontSize: 12,
|
|
149
|
+
fontWeight: 600,
|
|
150
|
+
color: '#0d9488',
|
|
151
|
+
cursor: 'pointer',
|
|
152
|
+
padding: '5px 14px',
|
|
153
|
+
borderRadius: 8,
|
|
154
|
+
transition: 'background 0.15s, border-color 0.15s',
|
|
155
|
+
}}
|
|
156
|
+
onMouseEnter={(e) => {
|
|
157
|
+
e.currentTarget.style.background = 'rgba(20, 184, 166, 0.08)';
|
|
158
|
+
e.currentTarget.style.borderColor = '#14b8a6';
|
|
159
|
+
}}
|
|
160
|
+
onMouseLeave={(e) => {
|
|
161
|
+
e.currentTarget.style.background = 'transparent';
|
|
162
|
+
e.currentTarget.style.borderColor = '#99f6e4';
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
Got it
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</motion.div>
|
|
169
|
+
)}
|
|
170
|
+
</AnimatePresence>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isTipDismissed(tipId: string): boolean {
|
|
175
|
+
return getDismissedTips().includes(tipId);
|
|
176
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useUsage } from '../contexts/UsageContext';
|
|
4
|
+
|
|
5
|
+
export function UpgradeBanner() {
|
|
6
|
+
const { allowed, used, limit, plan, loading } = useUsage();
|
|
7
|
+
|
|
8
|
+
if (loading || allowed || plan !== 'free') return null;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200 px-4 py-3 rounded-lg flex items-center justify-between flex-shrink-0"
|
|
13
|
+
data-testid="upgrade-banner"
|
|
14
|
+
>
|
|
15
|
+
<div className="flex items-center gap-2">
|
|
16
|
+
<span className="text-amber-600 dark:text-amber-400 text-lg">⚠</span>
|
|
17
|
+
<span className="text-sm font-medium">
|
|
18
|
+
Weekly limit reached ({used}/{limit} work items). Claude features are disabled until your usage resets.
|
|
19
|
+
</span>
|
|
20
|
+
</div>
|
|
21
|
+
<a
|
|
22
|
+
href="/subscribe"
|
|
23
|
+
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
|
24
|
+
>
|
|
25
|
+
Upgrade
|
|
26
|
+
</a>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -5,12 +5,14 @@ import type { RecentProject } from '@/lib/db-bridge';
|
|
|
5
5
|
|
|
6
6
|
interface WelcomeScreenProps {
|
|
7
7
|
recentProjects?: RecentProject[];
|
|
8
|
+
onNewProject?: () => void;
|
|
8
9
|
onOpenProject?: () => void;
|
|
9
10
|
onSelectRecentProject?: (project: RecentProject) => void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function WelcomeScreen({
|
|
13
14
|
recentProjects = [],
|
|
15
|
+
onNewProject,
|
|
14
16
|
onOpenProject,
|
|
15
17
|
onSelectRecentProject,
|
|
16
18
|
}: WelcomeScreenProps) {
|
|
@@ -31,10 +33,10 @@ export function WelcomeScreen({
|
|
|
31
33
|
</p>
|
|
32
34
|
</div>
|
|
33
35
|
|
|
34
|
-
{/*
|
|
35
|
-
<div className="pt-4">
|
|
36
|
+
{/* Project Buttons */}
|
|
37
|
+
<div className="pt-4 space-y-3">
|
|
36
38
|
<button
|
|
37
|
-
onClick={
|
|
39
|
+
onClick={onNewProject}
|
|
38
40
|
className="w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 hover:-translate-y-1 hover:scale-[1.01] active:translate-y-0 active:scale-100"
|
|
39
41
|
style={{
|
|
40
42
|
cursor: 'pointer',
|
|
@@ -51,6 +53,14 @@ export function WelcomeScreen({
|
|
|
51
53
|
inset 0 -2px 4px rgba(129, 157, 159, 0.05)
|
|
52
54
|
`,
|
|
53
55
|
}}
|
|
56
|
+
data-testid="new-project-button"
|
|
57
|
+
>
|
|
58
|
+
New Project
|
|
59
|
+
</button>
|
|
60
|
+
<button
|
|
61
|
+
onClick={onOpenProject}
|
|
62
|
+
className="w-full py-3 px-6 rounded-xl font-medium transition-colors text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-zinc-200 dark:border-zinc-700"
|
|
63
|
+
style={{ cursor: 'pointer' }}
|
|
54
64
|
data-testid="open-project-button"
|
|
55
65
|
>
|
|
56
66
|
Open Project
|
|
@@ -68,7 +78,7 @@ export function WelcomeScreen({
|
|
|
68
78
|
</div>
|
|
69
79
|
) : (
|
|
70
80
|
<div className="space-y-2">
|
|
71
|
-
{recentProjects.map((project) => (
|
|
81
|
+
{recentProjects.slice(0, 4).map((project) => (
|
|
72
82
|
<button
|
|
73
83
|
key={project.path}
|
|
74
84
|
onClick={() => onSelectRecentProject?.(project)}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { useUsage } from '@/contexts/UsageContext';
|
|
6
|
+
import { SubscribeContent } from '@/components/SubscribeContent';
|
|
7
|
+
|
|
8
|
+
const planColors: Record<string, string> = {
|
|
9
|
+
free: 'bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300',
|
|
10
|
+
monthly: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
|
11
|
+
lifetime: 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function AccountSection() {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const { plan, used, limit, loading, refresh } = useUsage();
|
|
17
|
+
const [email, setEmail] = useState<string | null>(null);
|
|
18
|
+
const [portalError, setPortalError] = useState<string | null>(null);
|
|
19
|
+
const [portalLoading, setPortalLoading] = useState(false);
|
|
20
|
+
const [showUpgrade, setShowUpgrade] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
async function loadAuth() {
|
|
24
|
+
if (!window.electronAPI?.isElectron) return;
|
|
25
|
+
try {
|
|
26
|
+
const status = await window.electronAPI.auth.getStatus();
|
|
27
|
+
if (status.authenticated && status.user) {
|
|
28
|
+
setEmail(status.user.email);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Auth status unavailable - email stays null, shows "Not signed in"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
loadAuth();
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const isFree = plan === 'free';
|
|
38
|
+
const isAtCapacity = isFree && limit > 0 && used >= limit;
|
|
39
|
+
|
|
40
|
+
const handleManageSubscription = async () => {
|
|
41
|
+
if (!window.electronAPI?.billing?.openCustomerPortal) return;
|
|
42
|
+
setPortalError(null);
|
|
43
|
+
setPortalLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const result = await window.electronAPI.billing.openCustomerPortal();
|
|
46
|
+
if (result && !result.success) {
|
|
47
|
+
setPortalError(result.error || 'Unable to open billing portal');
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
setPortalError('Unable to open billing portal');
|
|
51
|
+
} finally {
|
|
52
|
+
setPortalLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleLogout = async () => {
|
|
57
|
+
if (!window.electronAPI?.auth?.logout) return;
|
|
58
|
+
await window.electronAPI.auth.logout();
|
|
59
|
+
router.push('/login');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleUpgradeClose = () => {
|
|
63
|
+
setShowUpgrade(false);
|
|
64
|
+
refresh();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (showUpgrade) {
|
|
68
|
+
return (
|
|
69
|
+
<section id="account" className="relative min-h-[500px]">
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleUpgradeClose}
|
|
72
|
+
className="absolute top-0 right-0 p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors z-10"
|
|
73
|
+
aria-label="Close upgrade"
|
|
74
|
+
>
|
|
75
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
76
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
77
|
+
</svg>
|
|
78
|
+
</button>
|
|
79
|
+
<div className="flex flex-col items-center justify-center min-h-[500px] py-8">
|
|
80
|
+
<SubscribeContent onClose={handleUpgradeClose} />
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<section id="account">
|
|
88
|
+
<div className="flex items-center justify-between mb-4">
|
|
89
|
+
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-100">
|
|
90
|
+
Account
|
|
91
|
+
</h2>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Email & Plan */}
|
|
95
|
+
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg space-y-4">
|
|
96
|
+
<div className="flex items-center justify-between">
|
|
97
|
+
<div>
|
|
98
|
+
<label className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
99
|
+
Email
|
|
100
|
+
</label>
|
|
101
|
+
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-0.5">
|
|
102
|
+
{loading ? '...' : email || 'Not signed in'}
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${planColors[plan] || planColors.free}`}>
|
|
107
|
+
{plan}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Usage Bar (free users only) */}
|
|
113
|
+
{isFree && (
|
|
114
|
+
<div>
|
|
115
|
+
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 mb-1">
|
|
116
|
+
<span>Weekly usage</span>
|
|
117
|
+
<span>{used} / {limit} work items</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="w-full h-2 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
|
120
|
+
<div
|
|
121
|
+
className={`h-full rounded-full transition-all ${isAtCapacity ? 'bg-red-500' : 'bg-blue-500'}`}
|
|
122
|
+
style={{ width: `${limit > 0 ? Math.min((used / limit) * 100, 100) : 0}%` }}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Action Button */}
|
|
129
|
+
<div className="pt-2 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between">
|
|
130
|
+
<div>
|
|
131
|
+
{isFree ? (
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => setShowUpgrade(true)}
|
|
134
|
+
className="px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded transition-colors"
|
|
135
|
+
>
|
|
136
|
+
Upgrade
|
|
137
|
+
</button>
|
|
138
|
+
) : (
|
|
139
|
+
<>
|
|
140
|
+
<button
|
|
141
|
+
onClick={handleManageSubscription}
|
|
142
|
+
disabled={portalLoading}
|
|
143
|
+
className="px-3 py-1.5 text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 border border-zinc-300 dark:border-zinc-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
144
|
+
>
|
|
145
|
+
{portalLoading ? 'Opening...' : 'Manage Subscription'}
|
|
146
|
+
</button>
|
|
147
|
+
{portalError && (
|
|
148
|
+
<p className="text-xs text-red-500 mt-1">{portalError}</p>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
<button
|
|
154
|
+
onClick={handleLogout}
|
|
155
|
+
className="px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
|
156
|
+
>
|
|
157
|
+
Log Out
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</section>
|
|
162
|
+
);
|
|
163
|
+
}
|