create-varity-app 2.0.0-beta.11 → 2.0.0-beta.13
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/package.json +1 -1
- package/template/next.config.js +1 -0
- package/template/src/app/dashboard/layout.tsx +3 -4
- package/template/src/app/dashboard/page.tsx +7 -5
- package/template/src/app/dashboard/projects/page.tsx +4 -4
- package/template/src/app/dashboard/tasks/page.tsx +4 -4
- package/template/src/app/dashboard/team/page.tsx +4 -4
- package/template/src/app/layout.tsx +7 -10
- package/template/src/app/login/page.tsx +8 -8
- package/template/src/app/not-found.tsx +2 -0
- package/template/src/app/page.tsx +2 -1
- package/template/src/components/dashboard/RecentActivity.tsx +2 -1
- package/template/src/components/landing/Pricing.tsx +9 -10
- package/template/src/components/shared/Navbar.tsx +2 -0
- package/template/src/lib/constants.ts +1 -1
- package/template/src/lib/hooks.ts +4 -16
- package/template/src/services/dashboardService.ts +589 -0
package/package.json
CHANGED
package/template/next.config.js
CHANGED
|
@@ -3,6 +3,7 @@ const nextConfig = {
|
|
|
3
3
|
output: 'export',
|
|
4
4
|
images: { unoptimized: true },
|
|
5
5
|
trailingSlash: true,
|
|
6
|
+
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
|
6
7
|
productionBrowserSourceMaps: false,
|
|
7
8
|
webpack: (config, { isServer, dev }) => {
|
|
8
9
|
// Suppress MetaMask SDK warning for @react-native-async-storage
|
|
@@ -113,8 +113,8 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
113
113
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on require() success, stable across renders
|
|
114
114
|
const privy = usePrivyHook ? usePrivyHook() : { user: null, logout: async () => {} };
|
|
115
115
|
const { user, logout } = privy;
|
|
116
|
-
const router = useRouter();
|
|
117
116
|
const pathname = usePathname();
|
|
117
|
+
const router = useRouter();
|
|
118
118
|
const isMobile = useIsMobile();
|
|
119
119
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
120
120
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
@@ -154,7 +154,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
154
154
|
|
|
155
155
|
const handleLogout = async () => {
|
|
156
156
|
await logout();
|
|
157
|
-
|
|
157
|
+
router.push('/');
|
|
158
158
|
};
|
|
159
159
|
|
|
160
160
|
// Fallback layout when DashboardLayout from ui-kit isn't available
|
|
@@ -257,7 +257,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
|
257
257
|
name: userName,
|
|
258
258
|
address: userEmail,
|
|
259
259
|
}}
|
|
260
|
-
onNavigate={(path: string) => router.push(path)}
|
|
261
260
|
onLogout={handleLogout}
|
|
262
261
|
onNavigateToProfile={() => router.push('/dashboard/settings')}
|
|
263
262
|
onNavigateToSettings={() => router.push('/dashboard/settings')}
|
|
@@ -289,7 +288,7 @@ export default function DashboardRootLayout({
|
|
|
289
288
|
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}
|
|
290
289
|
thirdwebClientId={process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID}
|
|
291
290
|
loginMethods={['email', 'google']}
|
|
292
|
-
appearance={{ theme: 'light', accentColor: '#2563EB' }}
|
|
291
|
+
appearance={{ theme: 'light', accentColor: '#2563EB', logo: '/logo.svg' }}
|
|
293
292
|
>
|
|
294
293
|
<PrivyProtectedRoute fallback={<RedirectToLogin />}>
|
|
295
294
|
<DashboardShell>{children}</DashboardShell>
|
|
@@ -9,6 +9,7 @@ import { FolderKanban, ListTodo, Users, ArrowRight } from 'lucide-react';
|
|
|
9
9
|
|
|
10
10
|
function QuickActions() {
|
|
11
11
|
const router = useRouter();
|
|
12
|
+
|
|
12
13
|
const actions = [
|
|
13
14
|
{
|
|
14
15
|
label: 'New Project',
|
|
@@ -137,6 +138,7 @@ function GettingStarted({
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
export default function DashboardPage() {
|
|
141
|
+
const router = useRouter();
|
|
140
142
|
const { name } = useCurrentUser();
|
|
141
143
|
const { data: projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects();
|
|
142
144
|
const { data: tasks, loading: tasksLoading, error: tasksError, refresh: refreshTasks } = useTasks();
|
|
@@ -161,14 +163,14 @@ export default function DashboardPage() {
|
|
|
161
163
|
</p>
|
|
162
164
|
</div>
|
|
163
165
|
|
|
164
|
-
{error &&
|
|
165
|
-
<div className="flex items-center justify-between rounded-lg border border-
|
|
166
|
-
<p className="text-sm text-
|
|
166
|
+
{error && (
|
|
167
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
168
|
+
<p className="text-sm text-red-700">Failed to load data. Please check your connection and try again.</p>
|
|
167
169
|
<button
|
|
168
170
|
onClick={() => { refreshProjects(); refreshTasks(); refreshTeam(); }}
|
|
169
|
-
className="text-sm font-medium text-
|
|
171
|
+
className="text-sm font-medium text-red-700 hover:text-red-800 underline"
|
|
170
172
|
>
|
|
171
|
-
|
|
173
|
+
Retry
|
|
172
174
|
</button>
|
|
173
175
|
</div>
|
|
174
176
|
)}
|
|
@@ -606,10 +606,10 @@ export default function ProjectsPage() {
|
|
|
606
606
|
</div>
|
|
607
607
|
</Dialog>
|
|
608
608
|
|
|
609
|
-
{error &&
|
|
610
|
-
<div className="flex items-center justify-between rounded-lg border border-
|
|
611
|
-
<p className="text-sm text-
|
|
612
|
-
<button onClick={refresh} className="text-sm font-medium text-
|
|
609
|
+
{error && (
|
|
610
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
611
|
+
<p className="text-sm text-red-700">Failed to load projects. Please check your connection and try again.</p>
|
|
612
|
+
<button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
|
|
613
613
|
</div>
|
|
614
614
|
)}
|
|
615
615
|
|
|
@@ -258,10 +258,10 @@ export default function TasksPage() {
|
|
|
258
258
|
)}
|
|
259
259
|
</div>
|
|
260
260
|
|
|
261
|
-
{error &&
|
|
262
|
-
<div className="flex items-center justify-between rounded-lg border border-
|
|
263
|
-
<p className="text-sm text-
|
|
264
|
-
<button onClick={refresh} className="text-sm font-medium text-
|
|
261
|
+
{error && (
|
|
262
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
263
|
+
<p className="text-sm text-red-700">Failed to load tasks. Please check your connection and try again.</p>
|
|
264
|
+
<button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
|
|
265
265
|
</div>
|
|
266
266
|
)}
|
|
267
267
|
|
|
@@ -246,10 +246,10 @@ export default function TeamPage() {
|
|
|
246
246
|
loading={removeSubmitting}
|
|
247
247
|
/>
|
|
248
248
|
|
|
249
|
-
{error &&
|
|
250
|
-
<div className="flex items-center justify-between rounded-lg border border-
|
|
251
|
-
<p className="text-sm text-
|
|
252
|
-
<button onClick={refresh} className="text-sm font-medium text-
|
|
249
|
+
{error && (
|
|
250
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
251
|
+
<p className="text-sm text-red-700">Failed to load team data. Please check your connection and try again.</p>
|
|
252
|
+
<button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
|
|
253
253
|
</div>
|
|
254
254
|
)}
|
|
255
255
|
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { Providers } from '@/components/providers';
|
|
3
|
-
import { APP_NAME } from '@/lib/constants';
|
|
4
3
|
import './globals.css';
|
|
5
4
|
|
|
6
|
-
const description = 'Built with Varity — auth, database, and deployment included.';
|
|
7
|
-
|
|
8
5
|
export const metadata: Metadata = {
|
|
9
|
-
title:
|
|
10
|
-
description,
|
|
11
|
-
metadataBase: new URL('https://
|
|
6
|
+
title: 'TaskFlow - Project Management',
|
|
7
|
+
description: 'Manage projects, track tasks, and collaborate with your team.',
|
|
8
|
+
metadataBase: new URL('https://example.com'),
|
|
12
9
|
openGraph: {
|
|
13
|
-
title:
|
|
14
|
-
description,
|
|
10
|
+
title: 'TaskFlow - Project Management',
|
|
11
|
+
description: 'Manage projects, track tasks, and collaborate with your team.',
|
|
15
12
|
type: 'website',
|
|
16
13
|
},
|
|
17
14
|
twitter: {
|
|
18
15
|
card: 'summary',
|
|
19
|
-
title:
|
|
20
|
-
description,
|
|
16
|
+
title: 'TaskFlow - Project Management',
|
|
17
|
+
description: 'Manage projects, track tasks, and collaborate with your team.',
|
|
21
18
|
},
|
|
22
19
|
};
|
|
23
20
|
|
|
@@ -15,12 +15,6 @@ try {
|
|
|
15
15
|
usePrivyHook = uiKit.usePrivy;
|
|
16
16
|
} catch {}
|
|
17
17
|
|
|
18
|
-
function loginButtonLabel(privy: { ready: boolean; authenticated: boolean }): string {
|
|
19
|
-
if (!privy.ready) return 'Loading...';
|
|
20
|
-
if (privy.authenticated) return 'Already Signed In';
|
|
21
|
-
return 'Sign In with Email or Social';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
18
|
function LoginContent() {
|
|
25
19
|
const router = useRouter();
|
|
26
20
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on require() success, stable across renders
|
|
@@ -61,7 +55,11 @@ function LoginContent() {
|
|
|
61
55
|
disabled={!privy.ready || privy.authenticated}
|
|
62
56
|
className="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors shadow-sm"
|
|
63
57
|
>
|
|
64
|
-
{
|
|
58
|
+
{!privy.ready
|
|
59
|
+
? 'Loading...'
|
|
60
|
+
: privy.authenticated
|
|
61
|
+
? 'Already Signed In'
|
|
62
|
+
: 'Sign In with Email or Social'}
|
|
65
63
|
</button>
|
|
66
64
|
) : (
|
|
67
65
|
<div className="text-center space-y-4">
|
|
@@ -81,18 +79,20 @@ function LoginContent() {
|
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
export default function LoginPage() {
|
|
82
|
+
// Always wrap in PrivyStack - it uses dev credentials automatically when no appId is provided
|
|
84
83
|
if (PrivyStackComponent) {
|
|
85
84
|
return (
|
|
86
85
|
<PrivyStackComponent
|
|
87
86
|
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}
|
|
88
87
|
thirdwebClientId={process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID}
|
|
89
88
|
loginMethods={['email', 'google']}
|
|
90
|
-
appearance={{ theme: 'light', accentColor: '#2563EB' }}
|
|
89
|
+
appearance={{ theme: 'light', accentColor: '#2563EB', logo: '/logo.svg' }}
|
|
91
90
|
>
|
|
92
91
|
<LoginContent />
|
|
93
92
|
</PrivyStackComponent>
|
|
94
93
|
);
|
|
95
94
|
}
|
|
96
95
|
|
|
96
|
+
// Fallback if ui-kit package isn't installed
|
|
97
97
|
return <LoginContent />;
|
|
98
98
|
}
|
|
@@ -2,6 +2,7 @@ import { Navbar } from '@/components/shared/Navbar';
|
|
|
2
2
|
import { Hero } from '@/components/landing/Hero';
|
|
3
3
|
import { Features } from '@/components/landing/Features';
|
|
4
4
|
import { HowItWorks } from '@/components/landing/HowItWorks';
|
|
5
|
+
import { Testimonials } from '@/components/landing/Testimonials';
|
|
5
6
|
import { Pricing } from '@/components/landing/Pricing';
|
|
6
7
|
import { CTA } from '@/components/landing/CTA';
|
|
7
8
|
import { Footer } from '@/components/shared/Footer';
|
|
@@ -13,7 +14,7 @@ export default function HomePage() {
|
|
|
13
14
|
<Hero />
|
|
14
15
|
<Features />
|
|
15
16
|
<HowItWorks />
|
|
16
|
-
|
|
17
|
+
<Testimonials />
|
|
17
18
|
<Pricing />
|
|
18
19
|
<CTA />
|
|
19
20
|
<Footer />
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
-
import { DataTable
|
|
4
|
+
import { DataTable } from '@varity-labs/ui-kit';
|
|
5
|
+
import { TaskStatusBadge, PriorityBadge } from '@varity-labs/ui-kit';
|
|
5
6
|
import { formatRelativeDate } from '@/lib/utils';
|
|
6
7
|
import { ArrowRight } from 'lucide-react';
|
|
7
8
|
import type { Task } from '@/types';
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Link from 'next/link';
|
|
2
1
|
import { Check } from 'lucide-react';
|
|
3
2
|
|
|
4
3
|
const plans = [
|
|
@@ -53,12 +52,6 @@ const plans = [
|
|
|
53
52
|
},
|
|
54
53
|
];
|
|
55
54
|
|
|
56
|
-
function buttonStyle(plan: (typeof plans)[number]): string {
|
|
57
|
-
if (plan.popular) return 'bg-primary-600 text-white hover:bg-primary-700';
|
|
58
|
-
if (plan.price === 0) return 'bg-gray-100 text-gray-900 hover:bg-gray-200';
|
|
59
|
-
return 'bg-gray-900 text-white hover:bg-gray-800';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
55
|
export function Pricing() {
|
|
63
56
|
return (
|
|
64
57
|
<section id="pricing" className="py-24 bg-white">
|
|
@@ -109,12 +102,18 @@ export function Pricing() {
|
|
|
109
102
|
</ul>
|
|
110
103
|
|
|
111
104
|
<div className="mt-8">
|
|
112
|
-
<
|
|
105
|
+
<a
|
|
113
106
|
href="/login"
|
|
114
|
-
className={`block w-full rounded-lg py-3 text-center font-medium transition-colors ${
|
|
107
|
+
className={`block w-full rounded-lg py-3 text-center font-medium transition-colors ${
|
|
108
|
+
plan.popular
|
|
109
|
+
? 'bg-primary-600 text-white hover:bg-primary-700'
|
|
110
|
+
: plan.price === 0
|
|
111
|
+
? 'bg-gray-100 text-gray-900 hover:bg-gray-200'
|
|
112
|
+
: 'bg-gray-900 text-white hover:bg-gray-800'
|
|
113
|
+
}`}
|
|
115
114
|
>
|
|
116
115
|
{plan.cta}
|
|
117
|
-
</
|
|
116
|
+
</a>
|
|
118
117
|
</div>
|
|
119
118
|
</div>
|
|
120
119
|
))}
|
|
@@ -46,6 +46,7 @@ export function Navbar() {
|
|
|
46
46
|
>
|
|
47
47
|
Get Started
|
|
48
48
|
</Link>
|
|
49
|
+
{/* Mobile menu toggle */}
|
|
49
50
|
<button
|
|
50
51
|
onClick={() => setMobileOpen(!mobileOpen)}
|
|
51
52
|
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100 sm:hidden"
|
|
@@ -57,6 +58,7 @@ export function Navbar() {
|
|
|
57
58
|
</div>
|
|
58
59
|
</div>
|
|
59
60
|
|
|
61
|
+
{/* Mobile dropdown */}
|
|
60
62
|
{mobileOpen && (
|
|
61
63
|
<div className="border-t border-gray-100 bg-white px-4 pb-4 pt-2 sm:hidden">
|
|
62
64
|
<div className="space-y-1">
|
|
@@ -10,18 +10,6 @@ try {
|
|
|
10
10
|
usePrivyHook = uiKit.usePrivy;
|
|
11
11
|
} catch {}
|
|
12
12
|
|
|
13
|
-
async function fetchWithRetry<T>(fn: () => Promise<T>, retries = 3, delay = 1500): Promise<T> {
|
|
14
|
-
for (let i = 0; i < retries; i++) {
|
|
15
|
-
try {
|
|
16
|
-
return await fn();
|
|
17
|
-
} catch (err) {
|
|
18
|
-
if (i === retries - 1) throw err;
|
|
19
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
throw new Error('Unexpected');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
13
|
export function useCurrentUser() {
|
|
26
14
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on require() success, stable across renders
|
|
27
15
|
const privy = usePrivyHook ? usePrivyHook() : { user: null, authenticated: false, logout: async () => {} };
|
|
@@ -66,7 +54,7 @@ export function useProjects(): UseCollectionReturn<Project> {
|
|
|
66
54
|
try {
|
|
67
55
|
setLoading(true);
|
|
68
56
|
setError(null);
|
|
69
|
-
const result = await
|
|
57
|
+
const result = await projects().get();
|
|
70
58
|
setData(result as Project[]);
|
|
71
59
|
} catch (err) {
|
|
72
60
|
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
|
@@ -138,7 +126,7 @@ export function useTasks(projectId?: string): UseCollectionReturn<Task> {
|
|
|
138
126
|
try {
|
|
139
127
|
setLoading(true);
|
|
140
128
|
setError(null);
|
|
141
|
-
const result = await
|
|
129
|
+
const result = await tasks().get();
|
|
142
130
|
setAllTasks(result as Task[]);
|
|
143
131
|
} catch (err) {
|
|
144
132
|
setError(err instanceof Error ? err.message : 'Failed to load tasks');
|
|
@@ -214,7 +202,7 @@ export function useTeam(): UseCollectionReturn<TeamMember> {
|
|
|
214
202
|
try {
|
|
215
203
|
setLoading(true);
|
|
216
204
|
setError(null);
|
|
217
|
-
const result = await
|
|
205
|
+
const result = await teamMembers().get();
|
|
218
206
|
setData(result as TeamMember[]);
|
|
219
207
|
} catch (err) {
|
|
220
208
|
setError(err instanceof Error ? err.message : 'Failed to load team');
|
|
@@ -301,7 +289,7 @@ export function useUserSettings() {
|
|
|
301
289
|
try {
|
|
302
290
|
setLoading(true);
|
|
303
291
|
setError(null);
|
|
304
|
-
const all = await
|
|
292
|
+
const all = await userSettings().get();
|
|
305
293
|
const mine = (all as UserSettings[]).find((s) => s.user_id === userId);
|
|
306
294
|
if (mine) {
|
|
307
295
|
setSettings(mine);
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Service
|
|
3
|
+
*
|
|
4
|
+
* Centralized service for dashboard-related API calls.
|
|
5
|
+
* Provides type-safe interfaces and error handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Project, Task, TeamMember } from '@/types';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Type Definitions
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export interface KPIMetric {
|
|
15
|
+
title: string;
|
|
16
|
+
value: string | number;
|
|
17
|
+
change?: {
|
|
18
|
+
value: number;
|
|
19
|
+
period: string;
|
|
20
|
+
};
|
|
21
|
+
trend?: 'up' | 'down' | 'neutral';
|
|
22
|
+
sparklineData?: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KPIResponse {
|
|
26
|
+
kpis: KPIMetric[];
|
|
27
|
+
has_data: boolean;
|
|
28
|
+
last_updated: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Activity {
|
|
32
|
+
id: string;
|
|
33
|
+
type: 'project_created' | 'task_completed' | 'member_added' | 'comment_added';
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
timestamp: string;
|
|
37
|
+
user?: {
|
|
38
|
+
name: string;
|
|
39
|
+
avatar?: string;
|
|
40
|
+
};
|
|
41
|
+
metadata?: Record<string, any>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ActivityResponse {
|
|
45
|
+
activities: Activity[];
|
|
46
|
+
total_count: number;
|
|
47
|
+
has_more: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProjectResponse {
|
|
51
|
+
projects: Project[];
|
|
52
|
+
total_count: number;
|
|
53
|
+
active_count: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TaskResponse {
|
|
57
|
+
tasks: Task[];
|
|
58
|
+
total_count: number;
|
|
59
|
+
completed_count: number;
|
|
60
|
+
by_status: {
|
|
61
|
+
todo: number;
|
|
62
|
+
in_progress: number;
|
|
63
|
+
done: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TeamMemberResponse {
|
|
68
|
+
members: TeamMember[];
|
|
69
|
+
total_count: number;
|
|
70
|
+
roles: {
|
|
71
|
+
owner: number;
|
|
72
|
+
admin: number;
|
|
73
|
+
member: number;
|
|
74
|
+
viewer: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DashboardOverviewResponse {
|
|
79
|
+
kpis: KPIResponse;
|
|
80
|
+
recent_activity: Activity[];
|
|
81
|
+
projects_summary: {
|
|
82
|
+
active: number;
|
|
83
|
+
total: number;
|
|
84
|
+
recent: Project[];
|
|
85
|
+
};
|
|
86
|
+
tasks_summary: {
|
|
87
|
+
open: number;
|
|
88
|
+
completed: number;
|
|
89
|
+
completion_rate: number;
|
|
90
|
+
};
|
|
91
|
+
team_summary: {
|
|
92
|
+
total: number;
|
|
93
|
+
active: number;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Error Handling
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
export class DashboardServiceError extends Error {
|
|
102
|
+
constructor(
|
|
103
|
+
message: string,
|
|
104
|
+
public statusCode?: number,
|
|
105
|
+
public originalError?: Error
|
|
106
|
+
) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'DashboardServiceError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleResponse<T>(response: Response): Promise<T> {
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
115
|
+
throw new DashboardServiceError(
|
|
116
|
+
`API request failed: ${response.statusText}`,
|
|
117
|
+
response.status,
|
|
118
|
+
new Error(errorText)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
return await response.json();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new DashboardServiceError(
|
|
126
|
+
'Failed to parse API response',
|
|
127
|
+
response.status,
|
|
128
|
+
error instanceof Error ? error : undefined
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// API Client
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch dashboard KPIs
|
|
141
|
+
*/
|
|
142
|
+
export async function getKPIs(userId: string): Promise<KPIResponse> {
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(
|
|
145
|
+
`${API_BASE_URL}/dashboard/kpis?userId=${encodeURIComponent(userId)}`,
|
|
146
|
+
{
|
|
147
|
+
method: 'GET',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
},
|
|
151
|
+
cache: 'no-store', // Always fetch fresh data
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return await handleResponse<KPIResponse>(response);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof DashboardServiceError) {
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
throw new DashboardServiceError(
|
|
161
|
+
'Failed to fetch KPIs',
|
|
162
|
+
undefined,
|
|
163
|
+
error instanceof Error ? error : undefined
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch recent activity
|
|
170
|
+
*/
|
|
171
|
+
export async function getRecentActivity(
|
|
172
|
+
userId: string,
|
|
173
|
+
limit: number = 10
|
|
174
|
+
): Promise<Activity[]> {
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(
|
|
177
|
+
`${API_BASE_URL}/dashboard/activity?userId=${encodeURIComponent(userId)}&limit=${limit}`,
|
|
178
|
+
{
|
|
179
|
+
method: 'GET',
|
|
180
|
+
headers: {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
},
|
|
183
|
+
cache: 'no-store',
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const data = await handleResponse<ActivityResponse>(response);
|
|
188
|
+
return data.activities;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof DashboardServiceError) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
throw new DashboardServiceError(
|
|
194
|
+
'Failed to fetch recent activity',
|
|
195
|
+
undefined,
|
|
196
|
+
error instanceof Error ? error : undefined
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch all projects for a user
|
|
203
|
+
*/
|
|
204
|
+
export async function getProjects(userId: string): Promise<Project[]> {
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(
|
|
207
|
+
`${API_BASE_URL}/projects?userId=${encodeURIComponent(userId)}`,
|
|
208
|
+
{
|
|
209
|
+
method: 'GET',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
},
|
|
213
|
+
cache: 'no-store',
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const data = await handleResponse<ProjectResponse>(response);
|
|
218
|
+
return data.projects;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof DashboardServiceError) {
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
throw new DashboardServiceError(
|
|
224
|
+
'Failed to fetch projects',
|
|
225
|
+
undefined,
|
|
226
|
+
error instanceof Error ? error : undefined
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Fetch tasks, optionally filtered by project
|
|
233
|
+
*/
|
|
234
|
+
export async function getTasks(
|
|
235
|
+
userId: string,
|
|
236
|
+
projectId?: string
|
|
237
|
+
): Promise<Task[]> {
|
|
238
|
+
try {
|
|
239
|
+
const url = projectId
|
|
240
|
+
? `${API_BASE_URL}/tasks?userId=${encodeURIComponent(userId)}&projectId=${encodeURIComponent(projectId)}`
|
|
241
|
+
: `${API_BASE_URL}/tasks?userId=${encodeURIComponent(userId)}`;
|
|
242
|
+
|
|
243
|
+
const response = await fetch(url, {
|
|
244
|
+
method: 'GET',
|
|
245
|
+
headers: {
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
},
|
|
248
|
+
cache: 'no-store',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const data = await handleResponse<TaskResponse>(response);
|
|
252
|
+
return data.tasks;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error instanceof DashboardServiceError) {
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
throw new DashboardServiceError(
|
|
258
|
+
'Failed to fetch tasks',
|
|
259
|
+
undefined,
|
|
260
|
+
error instanceof Error ? error : undefined
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetch team members
|
|
267
|
+
*/
|
|
268
|
+
export async function getTeamMembers(userId: string): Promise<TeamMember[]> {
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch(
|
|
271
|
+
`${API_BASE_URL}/team?userId=${encodeURIComponent(userId)}`,
|
|
272
|
+
{
|
|
273
|
+
method: 'GET',
|
|
274
|
+
headers: {
|
|
275
|
+
'Content-Type': 'application/json',
|
|
276
|
+
},
|
|
277
|
+
cache: 'no-store',
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const data = await handleResponse<TeamMemberResponse>(response);
|
|
282
|
+
return data.members;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error instanceof DashboardServiceError) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
throw new DashboardServiceError(
|
|
288
|
+
'Failed to fetch team members',
|
|
289
|
+
undefined,
|
|
290
|
+
error instanceof Error ? error : undefined
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fetch complete dashboard overview
|
|
297
|
+
*/
|
|
298
|
+
export async function getDashboardOverview(
|
|
299
|
+
userId: string
|
|
300
|
+
): Promise<DashboardOverviewResponse> {
|
|
301
|
+
try {
|
|
302
|
+
const response = await fetch(
|
|
303
|
+
`${API_BASE_URL}/dashboard/overview?userId=${encodeURIComponent(userId)}`,
|
|
304
|
+
{
|
|
305
|
+
method: 'GET',
|
|
306
|
+
headers: {
|
|
307
|
+
'Content-Type': 'application/json',
|
|
308
|
+
},
|
|
309
|
+
cache: 'no-store',
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return await handleResponse<DashboardOverviewResponse>(response);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error instanceof DashboardServiceError) {
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
throw new DashboardServiceError(
|
|
319
|
+
'Failed to fetch dashboard overview',
|
|
320
|
+
undefined,
|
|
321
|
+
error instanceof Error ? error : undefined
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create a new project
|
|
328
|
+
*/
|
|
329
|
+
export async function createProject(
|
|
330
|
+
userId: string,
|
|
331
|
+
data: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>
|
|
332
|
+
): Promise<Project> {
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetch(`${API_BASE_URL}/projects`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: {
|
|
337
|
+
'Content-Type': 'application/json',
|
|
338
|
+
},
|
|
339
|
+
body: JSON.stringify({ userId, ...data }),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return await handleResponse<Project>(response);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (error instanceof DashboardServiceError) {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
throw new DashboardServiceError(
|
|
348
|
+
'Failed to create project',
|
|
349
|
+
undefined,
|
|
350
|
+
error instanceof Error ? error : undefined
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Update an existing project
|
|
357
|
+
*/
|
|
358
|
+
export async function updateProject(
|
|
359
|
+
projectId: string,
|
|
360
|
+
data: Partial<Project>
|
|
361
|
+
): Promise<Project> {
|
|
362
|
+
try {
|
|
363
|
+
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, {
|
|
364
|
+
method: 'PATCH',
|
|
365
|
+
headers: {
|
|
366
|
+
'Content-Type': 'application/json',
|
|
367
|
+
},
|
|
368
|
+
body: JSON.stringify(data),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return await handleResponse<Project>(response);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error instanceof DashboardServiceError) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
throw new DashboardServiceError(
|
|
377
|
+
'Failed to update project',
|
|
378
|
+
undefined,
|
|
379
|
+
error instanceof Error ? error : undefined
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Delete a project
|
|
386
|
+
*/
|
|
387
|
+
export async function deleteProject(projectId: string): Promise<void> {
|
|
388
|
+
try {
|
|
389
|
+
const response = await fetch(`${API_BASE_URL}/projects/${projectId}`, {
|
|
390
|
+
method: 'DELETE',
|
|
391
|
+
headers: {
|
|
392
|
+
'Content-Type': 'application/json',
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
throw new DashboardServiceError(
|
|
398
|
+
`Failed to delete project: ${response.statusText}`,
|
|
399
|
+
response.status
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof DashboardServiceError) {
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
throw new DashboardServiceError(
|
|
407
|
+
'Failed to delete project',
|
|
408
|
+
undefined,
|
|
409
|
+
error instanceof Error ? error : undefined
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create a new task
|
|
416
|
+
*/
|
|
417
|
+
export async function createTask(
|
|
418
|
+
userId: string,
|
|
419
|
+
data: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>
|
|
420
|
+
): Promise<Task> {
|
|
421
|
+
try {
|
|
422
|
+
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: {
|
|
425
|
+
'Content-Type': 'application/json',
|
|
426
|
+
},
|
|
427
|
+
body: JSON.stringify({ userId, ...data }),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return await handleResponse<Task>(response);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error instanceof DashboardServiceError) {
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
throw new DashboardServiceError(
|
|
436
|
+
'Failed to create task',
|
|
437
|
+
undefined,
|
|
438
|
+
error instanceof Error ? error : undefined
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Update an existing task
|
|
445
|
+
*/
|
|
446
|
+
export async function updateTask(
|
|
447
|
+
taskId: string,
|
|
448
|
+
data: Partial<Task>
|
|
449
|
+
): Promise<Task> {
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
|
452
|
+
method: 'PATCH',
|
|
453
|
+
headers: {
|
|
454
|
+
'Content-Type': 'application/json',
|
|
455
|
+
},
|
|
456
|
+
body: JSON.stringify(data),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
return await handleResponse<Task>(response);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error instanceof DashboardServiceError) {
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
throw new DashboardServiceError(
|
|
465
|
+
'Failed to update task',
|
|
466
|
+
undefined,
|
|
467
|
+
error instanceof Error ? error : undefined
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Delete a task
|
|
474
|
+
*/
|
|
475
|
+
export async function deleteTask(taskId: string): Promise<void> {
|
|
476
|
+
try {
|
|
477
|
+
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
|
478
|
+
method: 'DELETE',
|
|
479
|
+
headers: {
|
|
480
|
+
'Content-Type': 'application/json',
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (!response.ok) {
|
|
485
|
+
throw new DashboardServiceError(
|
|
486
|
+
`Failed to delete task: ${response.statusText}`,
|
|
487
|
+
response.status
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error instanceof DashboardServiceError) {
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
throw new DashboardServiceError(
|
|
495
|
+
'Failed to delete task',
|
|
496
|
+
undefined,
|
|
497
|
+
error instanceof Error ? error : undefined
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Invite a team member
|
|
504
|
+
*/
|
|
505
|
+
export async function inviteTeamMember(
|
|
506
|
+
userId: string,
|
|
507
|
+
email: string,
|
|
508
|
+
role: TeamMember['role']
|
|
509
|
+
): Promise<TeamMember> {
|
|
510
|
+
try {
|
|
511
|
+
const response = await fetch(`${API_BASE_URL}/team/invite`, {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: {
|
|
514
|
+
'Content-Type': 'application/json',
|
|
515
|
+
},
|
|
516
|
+
body: JSON.stringify({ userId, email, role }),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return await handleResponse<TeamMember>(response);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error instanceof DashboardServiceError) {
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
throw new DashboardServiceError(
|
|
525
|
+
'Failed to invite team member',
|
|
526
|
+
undefined,
|
|
527
|
+
error instanceof Error ? error : undefined
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Remove a team member
|
|
534
|
+
*/
|
|
535
|
+
export async function removeTeamMember(memberId: string): Promise<void> {
|
|
536
|
+
try {
|
|
537
|
+
const response = await fetch(`${API_BASE_URL}/team/${memberId}`, {
|
|
538
|
+
method: 'DELETE',
|
|
539
|
+
headers: {
|
|
540
|
+
'Content-Type': 'application/json',
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (!response.ok) {
|
|
545
|
+
throw new DashboardServiceError(
|
|
546
|
+
`Failed to remove team member: ${response.statusText}`,
|
|
547
|
+
response.status
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (error instanceof DashboardServiceError) {
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
throw new DashboardServiceError(
|
|
555
|
+
'Failed to remove team member',
|
|
556
|
+
undefined,
|
|
557
|
+
error instanceof Error ? error : undefined
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Update team member role
|
|
564
|
+
*/
|
|
565
|
+
export async function updateTeamMemberRole(
|
|
566
|
+
memberId: string,
|
|
567
|
+
role: TeamMember['role']
|
|
568
|
+
): Promise<TeamMember> {
|
|
569
|
+
try {
|
|
570
|
+
const response = await fetch(`${API_BASE_URL}/team/${memberId}`, {
|
|
571
|
+
method: 'PATCH',
|
|
572
|
+
headers: {
|
|
573
|
+
'Content-Type': 'application/json',
|
|
574
|
+
},
|
|
575
|
+
body: JSON.stringify({ role }),
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return await handleResponse<TeamMember>(response);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof DashboardServiceError) {
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
throw new DashboardServiceError(
|
|
584
|
+
'Failed to update team member role',
|
|
585
|
+
undefined,
|
|
586
|
+
error instanceof Error ? error : undefined
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|